feat(web): add markdown preview mode and PR screenshot capture (#461)

This commit is contained in:
Carlos Florêncio
2026-03-24 23:38:02 +00:00
committed by GitHub
parent df70eee749
commit fb5886600a
26 changed files with 986 additions and 53 deletions
+7 -1
View File
@@ -59,4 +59,10 @@ Commit, push, and create a pull request.
5. **If ready (not draft):** Run `/pr-fixup` to wait for CI checks and CodeRabbit review, fix any failures or valid comments, and push.
6. **Return the PR URL** when done.
6. **PR screenshots:** After creating the PR, check if `apps/web/.pr-assets/manifest.json` exists. If it does:
- Read the manifest to list available screenshots/GIFs
- Run `npx tsx apps/web/e2e/scripts/upload-pr-assets.ts <PR_NUMBER>` to generate embed markdown
- Read `apps/web/.pr-assets/embed.md` and append its contents to the PR body using `gh pr edit <PR_NUMBER> --body "..."`
- Tell the user to drag and drop the image files from `.pr-assets/` into the PR description on GitHub for the images to render
7. **Return the PR URL** when done.
+3
View File
@@ -507,6 +507,9 @@ importers:
tailwindcss:
specifier: ^4
version: 4.1.18
tsx:
specifier: ^4.21.0
version: 4.21.0
typescript:
specifier: ^5
version: 5.9.3
+1
View File
@@ -14,6 +14,7 @@
/coverage
test-results/
playwright-report/
.pr-assets/
# next.js
/.next/
@@ -13,6 +13,7 @@ import {
IconArrowBackUp,
IconFoldDown,
IconFold,
IconEye,
} from "@tabler/icons-react";
import type { RenderHeaderMetadataProps } from "@pierre/diffs";
import type { ViewMode } from "@/hooks/use-global-view-mode";
@@ -56,6 +57,7 @@ interface DiffHeaderToolbarOptions {
viewMode: ViewMode;
onToggleViewMode: () => void;
onOpenFile?: (filePath: string) => void;
onPreviewMarkdown?: (filePath: string) => void;
onRevert?: (filePath: string) => void;
expandUnchanged?: boolean;
onToggleExpandUnchanged?: () => void;
@@ -64,6 +66,7 @@ interface DiffHeaderToolbarOptions {
type ToolbarButtonsProps = Omit<DiffHeaderToolbarOptions, "filePath" | "diff"> & {
resolvedPath: string;
onCopyDiff: () => void;
isMarkdownFile: boolean;
};
function DiffHeaderToolbarButtons({
@@ -77,6 +80,8 @@ function DiffHeaderToolbarButtons({
viewMode,
onToggleViewMode,
onOpenFile,
onPreviewMarkdown,
isMarkdownFile,
}: ToolbarButtonsProps) {
return (
<div className="flex items-center gap-1">
@@ -123,6 +128,16 @@ function DiffHeaderToolbarButtons({
)}
</ToolbarBtn>
{isMarkdownFile && onPreviewMarkdown && (
<ToolbarBtn
onClick={() => onPreviewMarkdown(resolvedPath)}
tooltip="Preview markdown"
className={iconBtn}
>
<IconEye className="h-3.5 w-3.5" />
</ToolbarBtn>
)}
{onOpenFile && (
<ToolbarBtn onClick={() => onOpenFile(resolvedPath)} tooltip="Edit">
<IconPencil className="h-3.5 w-3.5" />
@@ -132,6 +147,11 @@ function DiffHeaderToolbarButtons({
);
}
function checkIsMarkdown(filePath: string): boolean {
const ext = filePath.split(".").pop()?.toLowerCase();
return ext === "md" || ext === "mdx";
}
export function useDiffHeaderToolbar(opts: DiffHeaderToolbarOptions) {
const {
filePath,
@@ -141,6 +161,7 @@ export function useDiffHeaderToolbar(opts: DiffHeaderToolbarOptions) {
viewMode,
onToggleViewMode,
onOpenFile,
onPreviewMarkdown,
onRevert,
expandUnchanged,
onToggleExpandUnchanged,
@@ -152,12 +173,14 @@ export function useDiffHeaderToolbar(opts: DiffHeaderToolbarOptions) {
return (
<DiffHeaderToolbarButtons
resolvedPath={resolvedPath}
isMarkdownFile={checkIsMarkdown(resolvedPath)}
onCopyDiff={() => navigator.clipboard.writeText(diff || "")}
wordWrap={wordWrap}
onToggleWordWrap={onToggleWordWrap}
viewMode={viewMode}
onToggleViewMode={onToggleViewMode}
onOpenFile={onOpenFile}
onPreviewMarkdown={onPreviewMarkdown}
onRevert={onRevert}
expandUnchanged={expandUnchanged}
onToggleExpandUnchanged={onToggleExpandUnchanged}
@@ -172,6 +195,7 @@ export function useDiffHeaderToolbar(opts: DiffHeaderToolbarOptions) {
viewMode,
onToggleViewMode,
onOpenFile,
onPreviewMarkdown,
onRevert,
expandUnchanged,
onToggleExpandUnchanged,
@@ -23,6 +23,7 @@ interface DiffViewerResolverProps {
compact?: boolean;
hideHeader?: boolean;
onOpenFile?: (filePath: string) => void;
onPreviewMarkdown?: (filePath: string) => void;
onRevert?: (filePath: string) => void;
enableAcceptReject?: boolean;
onRevertBlock?: (filePath: string, info: RevertBlockInfo) => void;
@@ -50,6 +51,7 @@ export const DiffViewerResolved = memo(function DiffViewerResolved(props: DiffVi
enableExpansion,
expandUnchanged,
onToggleExpandUnchanged,
onPreviewMarkdown,
...rest
} = props;
/* eslint-enable @typescript-eslint/no-unused-vars */
+4
View File
@@ -30,6 +30,7 @@ interface DiffViewerProps {
compact?: boolean;
hideHeader?: boolean;
onOpenFile?: (filePath: string) => void;
onPreviewMarkdown?: (filePath: string) => void;
onRevert?: (filePath: string) => void;
enableAcceptReject?: boolean;
onRevertBlock?: (filePath: string, info: RevertBlockInfo) => Promise<void> | void;
@@ -51,6 +52,7 @@ const SCALAR_PROP_KEYS: (keyof DiffViewerProps)[] = [
"hideHeader",
"className",
"onOpenFile",
"onPreviewMarkdown",
"onRevert",
"enableAcceptReject",
"onRevertBlock",
@@ -126,6 +128,7 @@ export const DiffViewer = memo(function DiffViewer({
compact = false,
hideHeader = false,
onOpenFile,
onPreviewMarkdown,
onRevert,
enableAcceptReject = false,
onRevertBlock,
@@ -193,6 +196,7 @@ export const DiffViewer = memo(function DiffViewer({
onLineEnter,
onLineLeave,
onOpenFile,
onPreviewMarkdown,
onRevert,
enableExpansion: canUseExpansion,
expandUnchanged,
@@ -20,6 +20,7 @@ interface FileDiffViewerProps {
compact?: boolean;
hideHeader?: boolean;
onOpenFile?: (filePath: string) => void;
onPreviewMarkdown?: (filePath: string) => void;
onRevert?: (filePath: string) => void;
enableAcceptReject?: boolean;
onRevertBlock?: (filePath: string, info: RevertBlockInfo) => void;
@@ -57,6 +58,7 @@ export const FileDiffViewer = memo(function FileDiffViewer({
compact,
hideHeader,
onOpenFile,
onPreviewMarkdown,
onRevert,
enableAcceptReject,
onRevertBlock,
@@ -81,6 +83,7 @@ export const FileDiffViewer = memo(function FileDiffViewer({
compact={compact}
hideHeader={hideHeader}
onOpenFile={onOpenFile}
onPreviewMarkdown={onPreviewMarkdown}
onRevert={onRevert}
enableAcceptReject={enableAcceptReject}
onRevertBlock={onRevertBlock}
@@ -67,6 +67,7 @@ type UseDiffOptionsArgs = {
onLineEnter: (props: { lineType?: string; lineNumber?: number; annotationSide?: string }) => void;
onLineLeave: () => void;
onOpenFile?: (filePath: string) => void;
onPreviewMarkdown?: (filePath: string) => void;
onRevert?: (filePath: string) => void;
/** Enable diff expansion (requires oldLines/newLines in metadata) */
enableExpansion?: boolean;
@@ -97,6 +98,7 @@ export function useDiffOptions(args: UseDiffOptionsArgs): UseDiffOptionsResult {
onLineEnter,
onLineLeave,
onOpenFile,
onPreviewMarkdown,
onRevert,
enableExpansion = false,
expansionLineCount = 20,
@@ -122,6 +124,7 @@ export function useDiffOptions(args: UseDiffOptionsArgs): UseDiffOptionsResult {
viewMode: globalViewMode,
onToggleViewMode: toggleViewMode,
onOpenFile,
onPreviewMarkdown,
onRevert,
expandUnchanged,
onToggleExpandUnchanged,
@@ -11,6 +11,7 @@ import {
IconTextWrapDisabled,
IconMessagePlus,
IconRefresh,
IconEye,
} from "@tabler/icons-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { formatDiffStats } from "@/lib/utils/file-diff";
@@ -35,6 +36,7 @@ type FileEditorContentProps = {
sessionId?: string;
worktreePath?: string;
enableComments?: boolean;
onToggleMarkdownPreview?: () => void;
onChange: (newContent: string) => void;
onSave: () => void;
onReloadFromAgent?: () => void;
@@ -170,6 +172,25 @@ function CodeMirrorSaveButton({
);
}
function CodeMirrorMarkdownPreviewButton({ onToggle }: { onToggle: () => void }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
onClick={onToggle}
className="h-8 w-8 p-0 cursor-pointer text-muted-foreground"
data-testid="markdown-preview-toggle"
>
<IconEye className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Preview markdown</TooltipContent>
</Tooltip>
);
}
/** Toolbar for the CodeMirror code editor. */
function CodeMirrorToolbar({
path,
@@ -186,6 +207,7 @@ function CodeMirrorToolbar({
onSave,
onReloadFromAgent,
onDelete,
onToggleMarkdownPreview,
}: {
path: string;
worktreePath?: string;
@@ -201,6 +223,7 @@ function CodeMirrorToolbar({
onSave: () => void;
onReloadFromAgent?: () => void;
onDelete?: () => void;
onToggleMarkdownPreview?: () => void;
}) {
return (
<PanelHeaderBarSplit
@@ -221,6 +244,9 @@ function CodeMirrorToolbar({
sessionId={sessionId}
commentCount={commentCount}
/>
{onToggleMarkdownPreview && (
<CodeMirrorMarkdownPreviewButton onToggle={onToggleMarkdownPreview} />
)}
<CodeMirrorWrapButton wrapEnabled={wrapEnabled} onToggleWrap={onToggleWrap} />
<CodeMirrorReloadButton
hasRemoteUpdate={hasRemoteUpdate}
@@ -234,6 +260,46 @@ function CodeMirrorToolbar({
);
}
type CodeMirrorEditorState = ReturnType<typeof useCodeMirrorEditorState>;
function CodeMirrorOverlays({ state }: { state: CodeMirrorEditorState }) {
return (
<>
{state.floatingButtonPos && !state.textSelection && (
<Button
size="sm"
variant="secondary"
className="floating-comment-btn fixed z-50 gap-1.5 shadow-lg animate-in fade-in-0 zoom-in-95 duration-100 cursor-pointer"
style={{ left: state.floatingButtonPos.x + 8, top: state.floatingButtonPos.y + 8 }}
onMouseDown={(e) => e.stopPropagation()}
onClick={state.handleFloatingButtonClick}
>
<IconMessagePlus className="h-3.5 w-3.5" />
Comment
</Button>
)}
{state.textSelection && (
<EditorCommentPopover
selectedText={state.textSelection.text}
lineRange={{ start: state.textSelection.startLine, end: state.textSelection.endLine }}
position={state.textSelection.position}
onSubmit={state.handleCommentSubmit}
onSubmitAndRun={state.handleCommentSubmitAndRun}
onClose={state.handlePopoverClose}
/>
)}
{state.commentView && (
<CommentViewPopover
comments={state.commentView.comments}
position={state.commentView.position}
onDelete={state.handleDeleteComment}
onClose={state.handleCommentViewClose}
/>
)}
</>
);
}
export function CodeMirrorCodeEditor({
path,
content,
@@ -244,6 +310,7 @@ export function CodeMirrorCodeEditor({
sessionId,
worktreePath,
enableComments = false,
onToggleMarkdownPreview,
onChange,
onSave,
onReloadFromAgent,
@@ -251,7 +318,6 @@ export function CodeMirrorCodeEditor({
}: FileEditorContentProps) {
const wrapperRef = useRef<HTMLDivElement>(null);
const editorRef = useRef<ReactCodeMirrorRef>(null);
const state = useCodeMirrorEditorState({
path,
content,
@@ -283,8 +349,8 @@ export function CodeMirrorCodeEditor({
onSave={onSave}
onReloadFromAgent={onReloadFromAgent}
onDelete={onDelete}
onToggleMarkdownPreview={onToggleMarkdownPreview}
/>
<div className="flex-1 overflow-hidden relative">
<CodeMirror
ref={editorRef}
@@ -302,40 +368,7 @@ export function CodeMirrorCodeEditor({
}}
className="h-full overflow-auto text-xs"
/>
{state.floatingButtonPos && !state.textSelection && (
<Button
size="sm"
variant="secondary"
className="floating-comment-btn fixed z-50 gap-1.5 shadow-lg animate-in fade-in-0 zoom-in-95 duration-100 cursor-pointer"
style={{ left: state.floatingButtonPos.x + 8, top: state.floatingButtonPos.y + 8 }}
onMouseDown={(e) => e.stopPropagation()}
onClick={state.handleFloatingButtonClick}
>
<IconMessagePlus className="h-3.5 w-3.5" />
Comment
</Button>
)}
{state.textSelection && (
<EditorCommentPopover
selectedText={state.textSelection.text}
lineRange={{ start: state.textSelection.startLine, end: state.textSelection.endLine }}
position={state.textSelection.position}
onSubmit={state.handleCommentSubmit}
onSubmitAndRun={state.handleCommentSubmitAndRun}
onClose={state.handlePopoverClose}
/>
)}
{state.commentView && (
<CommentViewPopover
comments={state.commentView.comments}
position={state.commentView.position}
onDelete={state.handleDeleteComment}
onClose={state.handleCommentViewClose}
/>
)}
<CodeMirrorOverlays state={state} />
</div>
</div>
);
@@ -28,6 +28,7 @@ type MonacoCodeEditorProps = {
sessionId?: string;
worktreePath?: string;
enableComments?: boolean;
onToggleMarkdownPreview?: () => void;
onChange: (newContent: string) => void;
onSave: () => void;
onReloadFromAgent?: () => void;
@@ -175,6 +176,7 @@ export function MonacoCodeEditor(props: MonacoCodeEditorProps) {
sessionId,
worktreePath,
enableComments = false,
onToggleMarkdownPreview,
onSave,
onReloadFromAgent,
onDelete,
@@ -205,6 +207,7 @@ export function MonacoCodeEditor(props: MonacoCodeEditorProps) {
onSave={onSave}
onReloadFromAgent={onReloadFromAgent}
onDelete={onDelete}
onToggleMarkdownPreview={onToggleMarkdownPreview}
/>
<div className="flex-1 overflow-hidden relative">
<Editor
@@ -11,6 +11,7 @@ import {
IconMessagePlus,
IconArrowsDiff,
IconRefresh,
IconEye,
} from "@tabler/icons-react";
import { formatDiffStats } from "@/lib/utils/file-diff";
import { toRelativePath } from "@/lib/utils";
@@ -193,6 +194,25 @@ function DeleteButton({ onDelete }: { onDelete?: () => void }) {
);
}
function MarkdownPreviewButton({ onTogglePreview }: { onTogglePreview: () => void }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
onClick={onTogglePreview}
className="h-8 w-8 p-0 cursor-pointer text-muted-foreground"
data-testid="markdown-preview-toggle"
>
<IconEye className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Preview markdown</TooltipContent>
</Tooltip>
);
}
interface MonacoEditorToolbarProps {
path: string;
worktreePath?: string;
@@ -214,6 +234,7 @@ interface MonacoEditorToolbarProps {
onSave: () => void;
onReloadFromAgent?: () => void;
onDelete?: () => void;
onToggleMarkdownPreview?: () => void;
}
export function MonacoEditorToolbar({
@@ -237,6 +258,7 @@ export function MonacoEditorToolbar({
onSave,
onReloadFromAgent,
onDelete,
onToggleMarkdownPreview,
}: MonacoEditorToolbarProps) {
return (
<PanelHeaderBarSplit
@@ -262,6 +284,9 @@ export function MonacoEditorToolbar({
onToggle={onToggleDiffIndicators}
/>
)}
{onToggleMarkdownPreview && (
<MarkdownPreviewButton onTogglePreview={onToggleMarkdownPreview} />
)}
<WrapButton wrapEnabled={wrapEnabled} onToggleWrap={onToggleWrap} />
<ReloadFromAgentButton
hasRemoteUpdate={hasRemoteUpdate}
@@ -13,6 +13,7 @@ import {
IconLayoutRows,
IconPencil,
IconTextWrap,
IconEye,
} from "@tabler/icons-react";
import { Checkbox } from "@kandev/ui/checkbox";
import { Button } from "@kandev/ui/button";
@@ -29,6 +30,11 @@ import { useRunComment } from "@/hooks/domains/comments/use-run-comment";
import type { DiffComment } from "@/lib/diff/types";
import type { ReviewFile } from "./types";
function isMarkdownPath(filePath: string): boolean {
const ext = filePath.split(".").pop()?.toLowerCase();
return ext === "md" || ext === "mdx";
}
type ReviewDiffListProps = {
files: ReviewFile[];
reviewedFiles: Set<string>;
@@ -40,6 +46,7 @@ type ReviewDiffListProps = {
onToggleReviewed: (path: string, reviewed: boolean) => void;
onDiscard: (path: string) => void;
onOpenFile?: (filePath: string) => void;
onPreviewMarkdown?: (filePath: string) => void;
fileRefs: Map<string, React.RefObject<HTMLDivElement | null>>;
};
@@ -54,6 +61,7 @@ export const ReviewDiffList = memo(function ReviewDiffList({
onToggleReviewed,
onDiscard,
onOpenFile,
onPreviewMarkdown,
fileRefs,
}: ReviewDiffListProps) {
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
@@ -75,6 +83,7 @@ export const ReviewDiffList = memo(function ReviewDiffList({
onToggleReviewed={onToggleReviewed}
onDiscard={onDiscard}
onOpenFile={onOpenFile}
onPreviewMarkdown={onPreviewMarkdown}
sectionRef={fileRefs.get(file.path)}
scrollContainer={scrollContainerRef}
/>
@@ -95,6 +104,7 @@ type FileDiffSectionProps = {
onToggleReviewed: (path: string, reviewed: boolean) => void;
onDiscard: (path: string) => void;
onOpenFile?: (filePath: string) => void;
onPreviewMarkdown?: (filePath: string) => void;
sectionRef?: React.RefObject<HTMLDivElement | null>;
scrollContainer: React.RefObject<HTMLDivElement | null>;
};
@@ -179,6 +189,7 @@ type FileDiffToolbarProps = {
expandUnchanged: boolean;
onDiscard: () => void;
onOpenFile?: (filePath: string) => void;
onPreviewMarkdown?: (filePath: string) => void;
onToggleExpandUnchanged: () => void;
onToggleWordWrap: () => void;
};
@@ -226,6 +237,7 @@ function FileDiffToolbar(props: FileDiffToolbarProps) {
expandUnchanged,
onDiscard,
onOpenFile,
onPreviewMarkdown,
onToggleExpandUnchanged,
onToggleWordWrap,
} = props;
@@ -266,6 +278,11 @@ function FileDiffToolbar(props: FileDiffToolbarProps) {
<IconLayoutColumns className="h-3.5 w-3.5" />
)}
</ToolbarIconBtn>
{onPreviewMarkdown && isMarkdownPath(filePath) && (
<ToolbarIconBtn onClick={() => onPreviewMarkdown(filePath)} tooltip="Preview markdown">
<IconEye className="h-3.5 w-3.5" />
</ToolbarIconBtn>
)}
{onOpenFile && (
<ToolbarIconBtn onClick={() => onOpenFile(filePath)} tooltip="Edit">
<IconPencil className="h-3.5 w-3.5" />
@@ -296,6 +313,7 @@ type FileDiffHeaderProps = {
onCheckboxChange: (checked: boolean | "indeterminate") => void;
onDiscard: () => void;
onOpenFile?: (filePath: string) => void;
onPreviewMarkdown?: (filePath: string) => void;
onToggleCollapse: () => void;
onToggleExpandUnchanged: () => void;
onToggleWordWrap: () => void;
@@ -312,6 +330,7 @@ function FileDiffHeader({
onCheckboxChange,
onDiscard,
onOpenFile,
onPreviewMarkdown,
onToggleCollapse,
onToggleExpandUnchanged,
onToggleWordWrap,
@@ -354,6 +373,7 @@ function FileDiffHeader({
expandUnchanged={expandUnchanged}
onDiscard={onDiscard}
onOpenFile={onOpenFile}
onPreviewMarkdown={onPreviewMarkdown}
onToggleExpandUnchanged={onToggleExpandUnchanged}
onToggleWordWrap={onToggleWordWrap}
/>
@@ -429,6 +449,7 @@ function FileDiffSection({
onToggleReviewed,
onDiscard,
onOpenFile,
onPreviewMarkdown,
sectionRef,
scrollContainer,
}: FileDiffSectionProps) {
@@ -485,6 +506,7 @@ function FileDiffSection({
onCheckboxChange={handleCheckboxChange}
onDiscard={handleDiscard}
onOpenFile={onOpenFile}
onPreviewMarkdown={onPreviewMarkdown}
onToggleCollapse={handleToggleCollapse}
onToggleExpandUnchanged={handleToggleExpandUnchanged}
onToggleWordWrap={handleToggleWordWrap}
@@ -4,6 +4,7 @@ import { memo } from "react";
import { useEditorProvider } from "@/hooks/use-editor-resolver";
import { MonacoCodeEditor } from "@/components/editors/monaco/monaco-code-editor";
import { CodeMirrorCodeEditor } from "@/components/editors/codemirror/codemirror-code-editor";
import { MarkdownPreviewContent } from "./markdown-preview-content";
export type FileEditorContentProps = {
path: string;
@@ -16,6 +17,8 @@ export type FileEditorContentProps = {
sessionId?: string;
worktreePath?: string;
enableComments?: boolean;
markdownPreview?: boolean;
onToggleMarkdownPreview?: () => void;
onChange: (newContent: string) => void;
onSave: () => void;
onReloadFromAgent?: () => void;
@@ -24,6 +27,18 @@ export type FileEditorContentProps = {
export const FileEditorContent = memo(function FileEditorContent(props: FileEditorContentProps) {
const provider = useEditorProvider("code-editor");
if (props.markdownPreview && props.onToggleMarkdownPreview) {
return (
<MarkdownPreviewContent
path={props.path}
content={props.content}
worktreePath={props.worktreePath}
onTogglePreview={props.onToggleMarkdownPreview}
/>
);
}
return provider === "monaco" ? (
<MonacoCodeEditor {...props} />
) : (
@@ -16,6 +16,11 @@ import { calculateHash } from "@/lib/utils/file-diff";
type FileCategory = "image" | "binary" | "text";
function isMarkdownFile(path: string): boolean {
const ext = path.split(".").pop()?.toLowerCase();
return ext === "md" || ext === "mdx";
}
function resolveFileCategory(isBinary: boolean, path: string): FileCategory {
if (!isBinary) return "text";
return getFileCategory(path) === "image" ? "image" : "binary";
@@ -92,7 +97,9 @@ export const FileEditorPanel = memo(function FileEditorPanel({ params }: FileEdi
const hasRemoteUpdate = useDockviewStore((s) => s.openFiles.get(path)?.hasRemoteUpdate ?? false);
const isBinary = useDockviewStore((s) => s.openFiles.get(path)?.isBinary ?? false);
const originalContent = useDockviewStore((s) => s.openFiles.get(path)?.originalContent ?? "");
const markdownPreview = useDockviewStore((s) => s.openFiles.get(path)?.markdownPreview ?? false);
const setFileState = useDockviewStore((s) => s.setFileState);
const updateFileState = useDockviewStore((s) => s.updateFileState);
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
const activeSession = useAppStore((state) =>
@@ -111,6 +118,10 @@ export const FileEditorPanel = memo(function FileEditorPanel({ params }: FileEdi
const onSave = useCallback(() => saveFile(path), [saveFile, path]);
const onReloadFromAgent = useCallback(() => applyRemoteUpdate(path), [applyRemoteUpdate, path]);
const onDelete = useCallback(() => deleteFile(path), [deleteFile, path]);
const onToggleMarkdownPreview = useCallback(
() => updateFileState(path, { markdownPreview: !markdownPreview }),
[updateFileState, path, markdownPreview],
);
if (!hasFile) {
return (
@@ -141,6 +152,8 @@ export const FileEditorPanel = memo(function FileEditorPanel({ params }: FileEdi
);
}
const isMarkdown = isMarkdownFile(path);
return (
<PanelRoot>
<PanelBody padding={false} scroll={false}>
@@ -155,6 +168,8 @@ export const FileEditorPanel = memo(function FileEditorPanel({ params }: FileEdi
sessionId={activeSessionId || undefined}
worktreePath={worktreePath}
enableComments={!!activeSessionId}
markdownPreview={isMarkdown ? markdownPreview : false}
onToggleMarkdownPreview={isMarkdown ? onToggleMarkdownPreview : undefined}
onChange={onChange}
onSave={onSave}
onReloadFromAgent={onReloadFromAgent}
@@ -0,0 +1,82 @@
"use client";
import { memo } from "react";
import ReactMarkdown from "react-markdown";
import { Button } from "@kandev/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { IconCode } from "@tabler/icons-react";
import { remarkPlugins, markdownComponents } from "@/components/shared/markdown-components";
import { toRelativePath } from "@/lib/utils";
import { PanelHeaderBarSplit } from "@/components/task/panel-primitives";
interface MarkdownPreviewToolbarProps {
path: string;
worktreePath?: string;
onTogglePreview: () => void;
}
function MarkdownPreviewToolbar({
path,
worktreePath,
onTogglePreview,
}: MarkdownPreviewToolbarProps) {
return (
<PanelHeaderBarSplit
left={
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-mono">{toRelativePath(path, worktreePath)}</span>
<span className="text-xs text-muted-foreground/60">Preview</span>
</div>
}
right={
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
onClick={onTogglePreview}
className="h-8 w-8 p-0 cursor-pointer text-foreground"
data-testid="markdown-preview-toggle"
>
<IconCode className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Show code</TooltipContent>
</Tooltip>
</div>
}
/>
);
}
interface MarkdownPreviewContentProps {
path: string;
content: string;
worktreePath?: string;
onTogglePreview: () => void;
}
export const MarkdownPreviewContent = memo(function MarkdownPreviewContent({
path,
content,
worktreePath,
onTogglePreview,
}: MarkdownPreviewContentProps) {
return (
<div className="flex h-full flex-col" data-testid="markdown-preview">
<MarkdownPreviewToolbar
path={path}
worktreePath={worktreePath}
onTogglePreview={onTogglePreview}
/>
<div className="flex-1 overflow-auto p-6">
<div className="markdown-body max-w-3xl">
<ReactMarkdown remarkPlugins={remarkPlugins} components={markdownComponents}>
{content}
</ReactMarkdown>
</div>
</div>
</div>
);
});
@@ -325,7 +325,7 @@ const TaskChangesPanel = memo(function TaskChangesPanel({
onOpenFile: onOpenFileProp,
}: TaskChangesPanelProps) {
const isArchived = useIsTaskArchived();
const { openFile: panelOpenFile } = usePanelActions();
const { openFile: panelOpenFile, openFileInMarkdownPreview } = usePanelActions();
const handleOpenFile = onOpenFileProp ?? panelOpenFile;
const {
@@ -413,6 +413,7 @@ const TaskChangesPanel = memo(function TaskChangesPanel({
onToggleReviewed={handleToggleReviewed}
onDiscard={handleDiscard}
onOpenFile={handleOpenFile}
onPreviewMarkdown={openFileInMarkdownPreview}
fileRefs={visibleFileRefs}
/>
</PanelBody>
@@ -432,6 +433,7 @@ function ChangesPanelContent({
onToggleReviewed,
onDiscard,
onOpenFile,
onPreviewMarkdown,
fileRefs,
}: {
isLoading: boolean;
@@ -445,6 +447,7 @@ function ChangesPanelContent({
onToggleReviewed: (path: string, reviewed: boolean) => void;
onDiscard: (path: string) => Promise<void>;
onOpenFile: (path: string) => void;
onPreviewMarkdown?: (path: string) => void;
fileRefs: Map<string, React.RefObject<HTMLDivElement | null>>;
}) {
if (isLoading && files.length === 0) {
@@ -474,6 +477,7 @@ function ChangesPanelContent({
onToggleReviewed={onToggleReviewed}
onDiscard={onDiscard}
onOpenFile={onOpenFile}
onPreviewMarkdown={onPreviewMarkdown}
fileRefs={fileRefs}
/>
);
+11 -1
View File
@@ -4,6 +4,7 @@ import fs from "node:fs";
import path from "node:path";
import { backendFixture, type BackendContext } from "./backend";
import { ApiClient } from "../helpers/api-client";
import { PrAssetCapture } from "../helpers/pr-asset-capture";
import type { WorkflowStep } from "../../lib/types/http";
export type SeedData = {
@@ -16,7 +17,7 @@ export type SeedData = {
};
export const test = backendFixture.extend<
{ testPage: Page },
{ testPage: Page; prCapture: PrAssetCapture },
{ apiClient: ApiClient; seedData: SeedData }
>({
// Worker-scoped API client
@@ -99,6 +100,15 @@ export const test = backendFixture.extend<
await use(page);
await context.close();
},
// PR asset capture — gated behind CAPTURE_PR_ASSETS env var.
// When enabled, provides screenshot/recording helpers for PR descriptions.
// Destructure in tests that need it: { testPage, prCapture }
prCapture: async ({ testPage }, use, testInfo) => {
const capture = new PrAssetCapture(testPage, testInfo.file);
await use(capture);
capture.flush();
},
});
export { expect } from "@playwright/test";
+184
View File
@@ -0,0 +1,184 @@
import type { Page } from "@playwright/test";
import fs from "node:fs";
import path from "node:path";
export type AssetEntry = {
name: string;
type: "screenshot" | "recording";
format: "png" | "gif" | "webm";
file: string;
test: string;
caption?: string;
};
export type AssetManifest = {
generated_at: string;
assets: AssetEntry[];
};
const DEFAULT_OUTPUT_DIR = path.resolve(__dirname, "../../.pr-assets");
const FRAMES_DIR = ".frames";
const RECORDING_FPS = 5;
const RECORDING_INTERVAL = 1000 / RECORDING_FPS;
// Ensures the output directory is wiped exactly once per process (per e2e run).
let _cleanedForRun = false;
function cleanOutputDir(outputDir: string): void {
if (_cleanedForRun) return;
_cleanedForRun = true;
if (fs.existsSync(outputDir)) {
fs.rmSync(outputDir, { recursive: true });
}
fs.mkdirSync(outputDir, { recursive: true });
}
function sanitizeName(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
function getTestSlug(testFile: string): string {
const base = path.basename(testFile, path.extname(testFile));
return sanitizeName(base.replace(/\.spec$/, ""));
}
function ensureDir(dir: string): void {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
function readManifest(outputDir: string): AssetManifest {
const manifestPath = path.join(outputDir, "manifest.json");
if (fs.existsSync(manifestPath)) {
return JSON.parse(fs.readFileSync(manifestPath, "utf-8")) as AssetManifest;
}
return { generated_at: new Date().toISOString(), assets: [] };
}
function writeManifest(outputDir: string, manifest: AssetManifest): void {
manifest.generated_at = new Date().toISOString();
fs.writeFileSync(path.join(outputDir, "manifest.json"), JSON.stringify(manifest, null, 2));
}
export class PrAssetCapture {
private readonly page: Page;
private readonly outputDir: string;
private readonly enabled: boolean;
private readonly testSlug: string;
private readonly assets: AssetEntry[] = [];
private recordingName: string | null = null;
private recordingInterval: ReturnType<typeof setInterval> | null = null;
private recordingFrameCount = 0;
constructor(page: Page, testFile: string, opts?: { outputDir?: string }) {
this.page = page;
this.enabled = !!process.env.CAPTURE_PR_ASSETS;
this.outputDir = opts?.outputDir ?? DEFAULT_OUTPUT_DIR;
this.testSlug = getTestSlug(testFile);
if (this.enabled) {
cleanOutputDir(this.outputDir);
}
}
async screenshot(name: string, opts?: { caption?: string; fullPage?: boolean }): Promise<void> {
if (!this.enabled) return;
const safeName = sanitizeName(name);
const fileName = `${this.testSlug}--${safeName}.png`;
const filePath = path.join(this.outputDir, fileName);
await this.page.screenshot({
path: filePath,
fullPage: opts?.fullPage ?? false,
});
this.assets.push({
name: safeName,
type: "screenshot",
format: "png",
file: fileName,
test: `${this.testSlug}.spec.ts`,
caption: opts?.caption,
});
}
async startRecording(name: string): Promise<void> {
if (!this.enabled) return;
if (this.recordingName) {
await this.stopRecording();
}
const safeName = sanitizeName(name);
this.recordingName = safeName;
this.recordingFrameCount = 0;
const framesDir = path.join(this.outputDir, FRAMES_DIR, safeName);
ensureDir(framesDir);
this.recordingInterval = setInterval(async () => {
try {
const frameNum = String(this.recordingFrameCount++).padStart(4, "0");
await this.page.screenshot({
path: path.join(framesDir, `frame-${frameNum}.png`),
});
} catch {
// Page may have navigated — ignore frame capture errors
}
}, RECORDING_INTERVAL);
}
async stopRecording(opts?: { caption?: string }): Promise<void> {
if (!this.enabled || !this.recordingName || !this.recordingInterval) return;
clearInterval(this.recordingInterval);
this.recordingInterval = null;
const safeName = this.recordingName;
this.recordingName = null;
// Take one final frame
const framesDir = path.join(this.outputDir, FRAMES_DIR, safeName);
try {
const frameNum = String(this.recordingFrameCount).padStart(4, "0");
await this.page.screenshot({
path: path.join(framesDir, `frame-${frameNum}.png`),
});
} catch {
// ignore
}
// Register the asset — conversion happens in the post-processing script
const fileName = `${this.testSlug}--${safeName}.gif`;
this.assets.push({
name: safeName,
type: "recording",
format: "gif",
file: fileName,
test: `${this.testSlug}.spec.ts`,
caption: opts?.caption,
});
}
/** Write captured assets to the manifest. Call once after test completes. */
flush(): void {
if (!this.enabled || this.assets.length === 0) return;
// Stop any in-progress recording
if (this.recordingInterval) {
clearInterval(this.recordingInterval);
this.recordingInterval = null;
}
const manifest = readManifest(this.outputDir);
// Remove stale entries from this test
manifest.assets = manifest.assets.filter((a) => a.test !== `${this.testSlug}.spec.ts`);
manifest.assets.push(...this.assets);
writeManifest(this.outputDir, manifest);
}
}
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env tsx
/**
* Post-e2e script: converts frame sequences to GIFs using ffmpeg.
* Falls back gracefully if ffmpeg is not available.
*
* Usage: npx tsx e2e/scripts/convert-assets.ts
*/
import fs from "node:fs";
import path from "node:path";
import { execSync } from "node:child_process";
import type { AssetManifest } from "../helpers/pr-asset-capture";
const ASSETS_DIR = path.resolve(__dirname, "../../.pr-assets");
const FRAMES_DIR = path.join(ASSETS_DIR, ".frames");
const MANIFEST_PATH = path.join(ASSETS_DIR, "manifest.json");
const GIF_WIDTH = 800;
const GIF_FPS = 5;
function hasFfmpeg(): boolean {
try {
execSync("ffmpeg -version", { stdio: "ignore" });
return true;
} catch {
return false;
}
}
function convertFramesToGif(framesDir: string, outputPath: string): boolean {
try {
const cmd = [
"ffmpeg",
"-y",
`-framerate ${GIF_FPS}`,
`-i "${path.join(framesDir, "frame-%04d.png")}"`,
`-vf "fps=${GIF_FPS},scale=${GIF_WIDTH}:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse"`,
"-loop 0",
`"${outputPath}"`,
].join(" ");
execSync(cmd, { stdio: "ignore" });
return true;
} catch (err) {
console.error(`Failed to convert frames to GIF: ${outputPath}`, err);
return false;
}
}
function cleanupFrames(recordingName: string): void {
const framesPath = path.join(FRAMES_DIR, recordingName);
if (fs.existsSync(framesPath)) {
fs.rmSync(framesPath, { recursive: true });
}
}
function main(): void {
if (!fs.existsSync(MANIFEST_PATH)) {
console.log("No manifest.json found — nothing to convert.");
return;
}
const manifest: AssetManifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, "utf-8"));
const recordings = manifest.assets.filter((a) => a.type === "recording");
if (recordings.length === 0) {
console.log("No recordings to convert.");
return;
}
const ffmpegAvailable = hasFfmpeg();
if (!ffmpegAvailable) {
console.warn("ffmpeg not found — skipping GIF conversion. Install ffmpeg for GIF support.");
// Keep recordings in manifest but mark as unconverted
for (const rec of recordings) {
const framesDir = path.join(FRAMES_DIR, rec.name);
if (!fs.existsSync(framesDir)) continue;
// Fall back: use first frame as a static screenshot
const firstFrame = path.join(framesDir, "frame-0000.png");
if (fs.existsSync(firstFrame)) {
const fallbackName = rec.file.replace(".gif", ".png");
fs.copyFileSync(firstFrame, path.join(ASSETS_DIR, fallbackName));
rec.file = fallbackName;
rec.format = "png";
rec.type = "screenshot";
console.log(` Fallback: ${rec.name} → ${fallbackName} (first frame)`);
}
cleanupFrames(rec.name);
}
manifest.generated_at = new Date().toISOString();
fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2));
return;
}
let converted = 0;
for (const rec of recordings) {
const framesDir = path.join(FRAMES_DIR, rec.name);
if (!fs.existsSync(framesDir)) {
console.warn(` Frames directory not found for: ${rec.name}`);
continue;
}
const outputPath = path.join(ASSETS_DIR, rec.file);
console.log(` Converting: ${rec.name} → ${rec.file}`);
if (convertFramesToGif(framesDir, outputPath)) {
converted++;
cleanupFrames(rec.name);
}
}
// Clean up .frames dir if empty
if (fs.existsSync(FRAMES_DIR)) {
const remaining = fs.readdirSync(FRAMES_DIR);
if (remaining.length === 0) {
fs.rmSync(FRAMES_DIR, { recursive: true });
}
}
manifest.generated_at = new Date().toISOString();
fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2));
console.log(`Converted ${converted}/${recordings.length} recordings to GIF.`);
}
main();
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env tsx
/**
* Generate a markdown snippet from captured PR assets.
*
* Reads .pr-assets/manifest.json and produces .pr-assets/embed.md with
* placeholder markdown for each asset. The user (or PR skill) then drags
* and drops the actual image files into the PR description on GitHub.
*
* Usage: npx tsx e2e/scripts/upload-pr-assets.ts [<pr-number>]
*
* Outputs: .pr-assets/embed.md
*/
import fs from "node:fs";
import path from "node:path";
import type { AssetManifest, AssetEntry } from "../helpers/pr-asset-capture";
const ASSETS_DIR = path.resolve(__dirname, "../../.pr-assets");
const MANIFEST_PATH = path.join(ASSETS_DIR, "manifest.json");
const EMBED_PATH = path.join(ASSETS_DIR, "embed.md");
function generateMarkdown(assets: AssetEntry[]): string {
if (assets.length === 0) return "";
const lines: string[] = ["## Screenshots", ""];
for (const asset of assets) {
const caption = asset.caption ?? asset.name.replace(/-/g, " ");
if (asset.format === "gif") {
lines.push(`### ${caption}`);
lines.push("");
lines.push(`<!-- Drag and drop .pr-assets/${asset.file} here -->`);
lines.push("");
} else {
lines.push(`**${caption}**`);
lines.push("");
lines.push(`<!-- Drag and drop .pr-assets/${asset.file} here -->`);
lines.push("");
}
}
return lines.join("\n");
}
function main(): void {
if (!fs.existsSync(MANIFEST_PATH)) {
console.log("No manifest.json found — nothing to process.");
process.exit(0);
}
const manifest: AssetManifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, "utf-8"));
const available = manifest.assets.filter((a) => fs.existsSync(path.join(ASSETS_DIR, a.file)));
if (available.length === 0) {
console.log("No asset files found on disk.");
process.exit(0);
}
const markdown = generateMarkdown(available);
fs.writeFileSync(EMBED_PATH, markdown);
console.log(`Generated: ${EMBED_PATH}`);
console.log(`\nAssets ready (${available.length} files):`);
for (const asset of available) {
const filePath = path.join(ASSETS_DIR, asset.file);
const size = (fs.statSync(filePath).size / 1024).toFixed(1);
console.log(` .pr-assets/${asset.file} (${size} KB)`);
}
console.log(
"\nTo add screenshots to the PR, drag and drop the files from .pr-assets/ into the PR description on GitHub.",
);
}
main();
+232
View File
@@ -0,0 +1,232 @@
import fs from "node:fs";
import path from "node:path";
import { type Page } from "@playwright/test";
import { test, expect } from "../fixtures/test-base";
import type { SeedData } from "../fixtures/test-base";
import type { ApiClient } from "../helpers/api-client";
import { SessionPage } from "../pages/session-page";
const MARKDOWN_CONTENT = `# Hello World
This is a **markdown** file with some content.
- Item 1
- Item 2
- Item 3
\`\`\`js
console.log("hello");
\`\`\`
`;
async function seedTaskWithSession(
testPage: Page,
apiClient: ApiClient,
seedData: SeedData,
title: string,
): Promise<{ session: SessionPage; sessionId: string }> {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
title,
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id");
await testPage.goto(`/s/${task.session_id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
return { session, sessionId: task.session_id };
}
/** Open a markdown file from the Files panel and enable preview mode. */
async function openFileInPreview(
testPage: Page,
session: SessionPage,
fileName: string,
): Promise<void> {
await session.clickTab("Files");
await expect(session.files).toBeVisible({ timeout: 5_000 });
const fileRow = session.files.getByText(fileName);
await expect(fileRow).toBeVisible({ timeout: 10_000 });
await fileRow.click();
const editorTab = testPage.locator(`.dv-default-tab:has-text('${fileName}')`);
await expect(editorTab).toBeVisible({ timeout: 10_000 });
const previewToggle = testPage.getByTestId("markdown-preview-toggle").first();
await expect(previewToggle).toBeVisible({ timeout: 10_000 });
await previewToggle.click();
await expect(testPage.getByTestId("markdown-preview")).toBeVisible({ timeout: 5_000 });
}
test.describe("Markdown preview", () => {
test.describe.configure({ retries: 1, timeout: 120_000 });
test("toggle markdown preview in file editor", async ({
testPage,
apiClient,
seedData,
backend,
prCapture,
}) => {
// Create a markdown file in the workspace repo before navigating
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
const filePath = path.join(repoDir, "readme.md");
fs.writeFileSync(filePath, MARKDOWN_CONTENT);
const { session } = await seedTaskWithSession(
testPage,
apiClient,
seedData,
"Markdown Preview Test",
);
// Open the Files panel and click on the markdown file
await session.clickTab("Files");
await expect(session.files).toBeVisible({ timeout: 5_000 });
const fileRow = session.files.getByText("readme.md");
await expect(fileRow).toBeVisible({ timeout: 10_000 });
await fileRow.click();
// Wait for the file editor tab to appear
const editorTab = testPage.locator(".dv-default-tab:has-text('readme.md')");
await expect(editorTab).toBeVisible({ timeout: 10_000 });
// The preview toggle button should be visible (only for markdown files)
const previewToggle = testPage.getByTestId("markdown-preview-toggle").first();
await expect(previewToggle).toBeVisible({ timeout: 10_000 });
// Click to enable markdown preview
await previewToggle.click();
// The markdown preview should be visible with rendered content
const preview = testPage.getByTestId("markdown-preview");
await expect(preview).toBeVisible({ timeout: 5_000 });
// Check that markdown is rendered (heading should be an <h1>)
await expect(preview.locator("h1")).toContainText("Hello World");
// Check that list items are rendered
await expect(preview.locator("li")).toHaveCount(3);
// Capture screenshot for PR description (only when CAPTURE_PR_ASSETS=true)
await prCapture.screenshot("markdown-preview-on", {
caption: "Markdown file rendered in preview mode",
});
// Toggle back to code view
const codeToggle = testPage.getByTestId("markdown-preview-toggle").first();
await codeToggle.click();
// Preview should be gone
await expect(preview).not.toBeVisible({ timeout: 5_000 });
});
test("markdown preview persists across page refresh", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
// Create a markdown file
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
const filePath = path.join(repoDir, "persist-test.md");
fs.writeFileSync(filePath, "# Persist Test\n\nSome content here.");
const { session, sessionId } = await seedTaskWithSession(
testPage,
apiClient,
seedData,
"Markdown Persist Test",
);
// Open file and enable preview
await openFileInPreview(testPage, session, "persist-test.md");
await expect(testPage.getByTestId("markdown-preview").locator("h1")).toContainText(
"Persist Test",
);
// Verify the markdownPreview flag is in sessionStorage
const storedTabs = await testPage.evaluate((sid) => {
const raw = window.sessionStorage.getItem(`kandev.openFiles.${sid}`);
return raw ? JSON.parse(raw) : null;
}, sessionId);
expect(storedTabs).not.toBeNull();
const mdTab = storedTabs.find((t: { path: string }) => t.path.endsWith("persist-test.md"));
expect(mdTab).toBeTruthy();
expect(mdTab.markdownPreview).toBe(true);
// Brief pause to let the sessionStorage write settle before reload
await testPage.waitForTimeout(500);
// Reload the page — sessionStorage survives same-URL reload.
// After reload, the restored file tab becomes active (not the chat),
// so we wait for the sidebar instead of the chat panel.
await testPage.reload();
const sessionAfter = new SessionPage(testPage);
await expect(sessionAfter.sidebar).toBeVisible({ timeout: 30_000 });
// The file tab should be restored with preview still active
const editorTabAfter = testPage.locator(".dv-default-tab:has-text('persist-test.md')");
await expect(editorTabAfter).toBeVisible({ timeout: 15_000 });
await editorTabAfter.click();
const previewAfter = testPage.getByTestId("markdown-preview");
await expect(previewAfter).toBeVisible({ timeout: 10_000 });
await expect(previewAfter.locator("h1")).toContainText("Persist Test");
});
test("open markdown preview from diff toolbar", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
// Create a markdown file as an untracked file — it will show in Changes
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
const filePath = path.join(repoDir, "preview-from-diff.md");
fs.writeFileSync(filePath, "# Preview From Diff\n\nThis file was created for the diff test.");
const { session } = await seedTaskWithSession(
testPage,
apiClient,
seedData,
"Markdown Diff Preview Test",
);
// Open Changes panel — the untracked .md file should appear in the file list.
// Click "Changes" tab first, then click the file to open its diff.
await session.clickTab("Changes");
await expect(session.changes).toBeVisible({ timeout: 5_000 });
// Wait for git status to detect the file and show it in the changes list
const fileEntry = session.changes.getByText("preview-from-diff.md");
await expect(fileEntry).toBeVisible({ timeout: 15_000 });
await fileEntry.click();
// The diff opens as a center panel tab "Diff [preview-from-diff.md]".
// Wait for the diff tab and its content to render.
const diffTab = testPage.locator(".dv-default-tab:has-text('preview-from-diff.md')");
await expect(diffTab).toBeVisible({ timeout: 10_000 });
// The diff content should show our markdown text
await expect(testPage.getByText("# Preview From Diff")).toBeVisible({ timeout: 15_000 });
// Click the "Preview markdown" button (eye icon) in the diff header toolbar.
// The button is in the center diff panel, not the right-side changes panel.
const previewBtn = testPage.getByRole("button", { name: "Preview markdown" }).first();
await expect(previewBtn).toBeVisible({ timeout: 10_000 });
await previewBtn.click();
// The file editor should open in preview mode (not code mode).
// The markdown preview should be active immediately (no need to toggle).
const preview = testPage.getByTestId("markdown-preview");
await expect(preview).toBeVisible({ timeout: 10_000 });
await expect(preview.locator("h1")).toContainText("Preview From Diff");
});
});
+62 -12
View File
@@ -150,7 +150,7 @@ async function syncOpenFileFromWorkspace({
type RestoreTabsParams = {
activeSessionId: string;
savedTabs: Array<{ path: string; name: string }>;
savedTabs: Array<{ path: string; name: string; markdownPreview?: boolean }>;
savedActiveTab: string;
setFileState: (path: string, state: FileEditorState) => void;
addFileEditorPanel: (path: string, name: string, quiet?: boolean) => void;
@@ -183,6 +183,7 @@ async function loadAndRestoreTabs(params: RestoreTabsParams, retryCount = 0): Pr
originalHash: hash,
isDirty: false,
isBinary: response.is_binary,
markdownPreview: savedTab.markdownPreview,
});
addFileEditorPanel(savedTab.path, savedTab.name, true);
} catch {
@@ -242,7 +243,11 @@ function useFileEditorEffects({
if (!sessionId || _restorationInProgress || state.isRestoringLayout) return;
saveOpenFileTabs(
sessionId,
Array.from(state.openFiles.values()).map(({ path, name }) => ({ path, name })),
Array.from(state.openFiles.values()).map(({ path, name, markdownPreview }) => ({
path,
name,
...(markdownPreview ? { markdownPreview } : {}),
})),
);
});
return unsub;
@@ -494,7 +499,45 @@ function useFileEditorActions({
toast,
});
return { openFile, handleFileChange, saveFile, deleteFileAction, applyRemoteUpdate };
const openFileInMarkdownPreview = useCallback(
async (filePath: string) => {
const client = getWebSocketClient();
const currentSessionId = activeSessionIdRef.current;
if (!client || !currentSessionId) return;
const files = getOpenFiles();
if (files.has(filePath)) {
updateFileState(filePath, { markdownPreview: true });
addFileEditorPanel(filePath, filePath.split("/").pop() || filePath);
return;
}
try {
const response: FileContentResponse = await requestFileContent(
client,
currentSessionId,
filePath,
);
const state = await buildFileEditorState(filePath, response);
setFileState(filePath, { ...state, markdownPreview: true });
addFileEditorPanel(filePath, state.name);
} catch (error) {
toast({
title: "Failed to open file",
description: error instanceof Error ? error.message : "Unknown error",
variant: "error",
});
}
},
[activeSessionIdRef, setFileState, updateFileState, addFileEditorPanel, toast],
);
return {
openFile,
openFileInMarkdownPreview,
handleFileChange,
saveFile,
deleteFileAction,
applyRemoteUpdate,
};
}
export function useFileEditors() {
@@ -533,19 +576,26 @@ export function useFileEditors() {
activeSessionIdRef,
gitFileSignaturesRef,
});
const { openFile, handleFileChange, saveFile, deleteFileAction, applyRemoteUpdate } =
useFileEditorActions({
activeSessionIdRef,
setFileState,
updateFileState,
addFileEditorPanel,
setSavingFiles,
toast,
});
const {
openFile,
openFileInMarkdownPreview,
handleFileChange,
saveFile,
deleteFileAction,
applyRemoteUpdate,
} = useFileEditorActions({
activeSessionIdRef,
setFileState,
updateFileState,
addFileEditorPanel,
setSavingFiles,
toast,
});
return {
savingFiles,
openFile,
openFileInMarkdownPreview,
saveFile,
deleteFile: deleteFileAction,
handleFileChange,
+12 -1
View File
@@ -25,7 +25,8 @@ export function usePanelActions() {
const dockAddVscode = useDockviewStore((s) => s.addVscodePanel);
// File editors (works on desktop through dockview)
const { openFile: dockOpenFile } = useFileEditors();
const { openFile: dockOpenFile, openFileInMarkdownPreview: dockOpenFileInPreview } =
useFileEditors();
// Mobile/Tablet: layout store
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
@@ -101,6 +102,15 @@ export function usePanelActions() {
[isDesktop, dockOpenFile],
);
const openFileInMarkdownPreview = useCallback(
(filePath: string) => {
if (isDesktop) {
dockOpenFileInPreview(filePath);
}
},
[isDesktop, dockOpenFileInPreview],
);
return {
addBrowser,
addPlan,
@@ -109,5 +119,6 @@ export function usePanelActions() {
addTerminal,
addVscode,
openFile,
openFileInMarkdownPreview,
};
}
+1
View File
@@ -416,6 +416,7 @@ const ACTIVE_TAB_KEY = "kandev.activeTab";
export interface StoredFileTab {
path: string;
name: string;
markdownPreview?: boolean;
}
/**
+1
View File
@@ -54,6 +54,7 @@ export type FileEditorState = {
hasRemoteUpdate?: boolean;
remoteContent?: string;
remoteOriginalHash?: string;
markdownPreview?: boolean;
};
/** Direction relative to a reference panel or group. */
+3 -1
View File
@@ -12,7 +12,8 @@
"test": "vitest run",
"e2e": "playwright test --config e2e/playwright.config.ts",
"e2e:ui": "playwright test --config e2e/playwright.config.ts --ui",
"e2e:headed": "playwright test --config e2e/playwright.config.ts --headed"
"e2e:headed": "playwright test --config e2e/playwright.config.ts --headed",
"e2e:capture": "cross-env CAPTURE_PR_ASSETS=true playwright test --config e2e/playwright.config.ts && tsx e2e/scripts/convert-assets.ts"
},
"dependencies": {
"@base-ui/react": "^1.0.0",
@@ -129,6 +130,7 @@
"eslint-plugin-unused-imports": "^4.4.1",
"happy-dom": "^20.1.0",
"tailwindcss": "^4",
"tsx": "^4.21.0",
"typescript": "^5",
"vitest": "^1.6.0"
}