diff --git a/.agents/skills/pr/SKILL.md b/.agents/skills/pr/SKILL.md index defb75d4b8..c2aa19964d 100644 --- a/.agents/skills/pr/SKILL.md +++ b/.agents/skills/pr/SKILL.md @@ -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 ` to generate embed markdown + - Read `apps/web/.pr-assets/embed.md` and append its contents to the PR body using `gh pr edit --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. diff --git a/apps/pnpm-lock.yaml b/apps/pnpm-lock.yaml index df60aeaa91..9546c7aeca 100644 --- a/apps/pnpm-lock.yaml +++ b/apps/pnpm-lock.yaml @@ -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 diff --git a/apps/web/.gitignore b/apps/web/.gitignore index 38e2c5dbf8..0fb70f5482 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -14,6 +14,7 @@ /coverage test-results/ playwright-report/ +.pr-assets/ # next.js /.next/ diff --git a/apps/web/components/diff/diff-header-toolbar.tsx b/apps/web/components/diff/diff-header-toolbar.tsx index b6bd7e593d..aedc0056b6 100644 --- a/apps/web/components/diff/diff-header-toolbar.tsx +++ b/apps/web/components/diff/diff-header-toolbar.tsx @@ -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 & { resolvedPath: string; onCopyDiff: () => void; + isMarkdownFile: boolean; }; function DiffHeaderToolbarButtons({ @@ -77,6 +80,8 @@ function DiffHeaderToolbarButtons({ viewMode, onToggleViewMode, onOpenFile, + onPreviewMarkdown, + isMarkdownFile, }: ToolbarButtonsProps) { return (
@@ -123,6 +128,16 @@ function DiffHeaderToolbarButtons({ )} + {isMarkdownFile && onPreviewMarkdown && ( + onPreviewMarkdown(resolvedPath)} + tooltip="Preview markdown" + className={iconBtn} + > + + + )} + {onOpenFile && ( onOpenFile(resolvedPath)} tooltip="Edit"> @@ -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 ( 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, diff --git a/apps/web/components/diff/diff-viewer-resolver.tsx b/apps/web/components/diff/diff-viewer-resolver.tsx index b7b527415f..9089fa0014 100644 --- a/apps/web/components/diff/diff-viewer-resolver.tsx +++ b/apps/web/components/diff/diff-viewer-resolver.tsx @@ -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 */ diff --git a/apps/web/components/diff/diff-viewer.tsx b/apps/web/components/diff/diff-viewer.tsx index 8e40ece5ab..19e6cef17a 100644 --- a/apps/web/components/diff/diff-viewer.tsx +++ b/apps/web/components/diff/diff-viewer.tsx @@ -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; @@ -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, diff --git a/apps/web/components/diff/file-diff-viewer.tsx b/apps/web/components/diff/file-diff-viewer.tsx index d10299cc54..c53ce22dc6 100644 --- a/apps/web/components/diff/file-diff-viewer.tsx +++ b/apps/web/components/diff/file-diff-viewer.tsx @@ -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} diff --git a/apps/web/components/diff/use-diff-options.tsx b/apps/web/components/diff/use-diff-options.tsx index 9ad59edf83..09107aecef 100644 --- a/apps/web/components/diff/use-diff-options.tsx +++ b/apps/web/components/diff/use-diff-options.tsx @@ -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, diff --git a/apps/web/components/editors/codemirror/codemirror-code-editor.tsx b/apps/web/components/editors/codemirror/codemirror-code-editor.tsx index d19a529dd7..a4a340c2dc 100644 --- a/apps/web/components/editors/codemirror/codemirror-code-editor.tsx +++ b/apps/web/components/editors/codemirror/codemirror-code-editor.tsx @@ -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 ( + + + + + Preview markdown + + ); +} + /** 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 ( + {onToggleMarkdownPreview && ( + + )} ; + +function CodeMirrorOverlays({ state }: { state: CodeMirrorEditorState }) { + return ( + <> + {state.floatingButtonPos && !state.textSelection && ( + + )} + {state.textSelection && ( + + )} + {state.commentView && ( + + )} + + ); +} + 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(null); const editorRef = useRef(null); - const state = useCodeMirrorEditorState({ path, content, @@ -283,8 +349,8 @@ export function CodeMirrorCodeEditor({ onSave={onSave} onReloadFromAgent={onReloadFromAgent} onDelete={onDelete} + onToggleMarkdownPreview={onToggleMarkdownPreview} /> -
- - {state.floatingButtonPos && !state.textSelection && ( - - )} - - {state.textSelection && ( - - )} - - {state.commentView && ( - - )} +
); diff --git a/apps/web/components/editors/monaco/monaco-code-editor.tsx b/apps/web/components/editors/monaco/monaco-code-editor.tsx index 120612cdb3..345ecf6ca9 100644 --- a/apps/web/components/editors/monaco/monaco-code-editor.tsx +++ b/apps/web/components/editors/monaco/monaco-code-editor.tsx @@ -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} />
void }) { ); } +function MarkdownPreviewButton({ onTogglePreview }: { onTogglePreview: () => void }) { + return ( + + + + + Preview markdown + + ); +} + 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 ( )} + {onToggleMarkdownPreview && ( + + )} ; @@ -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>; }; @@ -54,6 +61,7 @@ export const ReviewDiffList = memo(function ReviewDiffList({ onToggleReviewed, onDiscard, onOpenFile, + onPreviewMarkdown, fileRefs, }: ReviewDiffListProps) { const scrollContainerRef = useRef(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; scrollContainer: React.RefObject; }; @@ -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) { )} + {onPreviewMarkdown && isMarkdownPath(filePath) && ( + onPreviewMarkdown(filePath)} tooltip="Preview markdown"> + + + )} {onOpenFile && ( onOpenFile(filePath)} tooltip="Edit"> @@ -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} diff --git a/apps/web/components/task/file-editor-content.tsx b/apps/web/components/task/file-editor-content.tsx index dddea25582..93e83d53cb 100644 --- a/apps/web/components/task/file-editor-content.tsx +++ b/apps/web/components/task/file-editor-content.tsx @@ -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 ( + + ); + } + return provider === "monaco" ? ( ) : ( diff --git a/apps/web/components/task/file-editor-panel.tsx b/apps/web/components/task/file-editor-panel.tsx index a188c65f6f..6794f6e28c 100644 --- a/apps/web/components/task/file-editor-panel.tsx +++ b/apps/web/components/task/file-editor-panel.tsx @@ -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 ( @@ -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} diff --git a/apps/web/components/task/markdown-preview-content.tsx b/apps/web/components/task/markdown-preview-content.tsx new file mode 100644 index 0000000000..6910a714fc --- /dev/null +++ b/apps/web/components/task/markdown-preview-content.tsx @@ -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 ( + + {toRelativePath(path, worktreePath)} + Preview +
+ } + right={ +
+ + + + + Show code + +
+ } + /> + ); +} + +interface MarkdownPreviewContentProps { + path: string; + content: string; + worktreePath?: string; + onTogglePreview: () => void; +} + +export const MarkdownPreviewContent = memo(function MarkdownPreviewContent({ + path, + content, + worktreePath, + onTogglePreview, +}: MarkdownPreviewContentProps) { + return ( +
+ +
+
+ + {content} + +
+
+
+ ); +}); diff --git a/apps/web/components/task/task-changes-panel.tsx b/apps/web/components/task/task-changes-panel.tsx index e370f614a1..2c5b5fd1ff 100644 --- a/apps/web/components/task/task-changes-panel.tsx +++ b/apps/web/components/task/task-changes-panel.tsx @@ -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} /> @@ -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; onOpenFile: (path: string) => void; + onPreviewMarkdown?: (path: string) => void; fileRefs: Map>; }) { if (isLoading && files.length === 0) { @@ -474,6 +477,7 @@ function ChangesPanelContent({ onToggleReviewed={onToggleReviewed} onDiscard={onDiscard} onOpenFile={onOpenFile} + onPreviewMarkdown={onPreviewMarkdown} fileRefs={fileRefs} /> ); diff --git a/apps/web/e2e/fixtures/test-base.ts b/apps/web/e2e/fixtures/test-base.ts index d6e3174354..2d7d541fac 100644 --- a/apps/web/e2e/fixtures/test-base.ts +++ b/apps/web/e2e/fixtures/test-base.ts @@ -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"; diff --git a/apps/web/e2e/helpers/pr-asset-capture.ts b/apps/web/e2e/helpers/pr-asset-capture.ts new file mode 100644 index 0000000000..bad7d12950 --- /dev/null +++ b/apps/web/e2e/helpers/pr-asset-capture.ts @@ -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 | 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 { + 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 { + 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 { + 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); + } +} diff --git a/apps/web/e2e/scripts/convert-assets.ts b/apps/web/e2e/scripts/convert-assets.ts new file mode 100644 index 0000000000..e3565400b1 --- /dev/null +++ b/apps/web/e2e/scripts/convert-assets.ts @@ -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(); diff --git a/apps/web/e2e/scripts/upload-pr-assets.ts b/apps/web/e2e/scripts/upload-pr-assets.ts new file mode 100644 index 0000000000..cc6ca21938 --- /dev/null +++ b/apps/web/e2e/scripts/upload-pr-assets.ts @@ -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 [] + * + * 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(``); + lines.push(""); + } else { + lines.push(`**${caption}**`); + lines.push(""); + lines.push(``); + 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(); diff --git a/apps/web/e2e/tests/markdown-preview.spec.ts b/apps/web/e2e/tests/markdown-preview.spec.ts new file mode 100644 index 0000000000..7dbf702470 --- /dev/null +++ b/apps/web/e2e/tests/markdown-preview.spec.ts @@ -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 { + 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

) + 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"); + }); +}); diff --git a/apps/web/hooks/use-file-editors.ts b/apps/web/hooks/use-file-editors.ts index d83d11d0c0..8ef644a36e 100644 --- a/apps/web/hooks/use-file-editors.ts +++ b/apps/web/hooks/use-file-editors.ts @@ -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, diff --git a/apps/web/hooks/use-panel-actions.ts b/apps/web/hooks/use-panel-actions.ts index 61fc8b6088..2d38aa1eb0 100644 --- a/apps/web/hooks/use-panel-actions.ts +++ b/apps/web/hooks/use-panel-actions.ts @@ -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, }; } diff --git a/apps/web/lib/local-storage.ts b/apps/web/lib/local-storage.ts index ee022fd941..f575d32b9c 100644 --- a/apps/web/lib/local-storage.ts +++ b/apps/web/lib/local-storage.ts @@ -416,6 +416,7 @@ const ACTIVE_TAB_KEY = "kandev.activeTab"; export interface StoredFileTab { path: string; name: string; + markdownPreview?: boolean; } /** diff --git a/apps/web/lib/state/dockview-store.ts b/apps/web/lib/state/dockview-store.ts index 3059957b07..9937e327d6 100644 --- a/apps/web/lib/state/dockview-store.ts +++ b/apps/web/lib/state/dockview-store.ts @@ -54,6 +54,7 @@ export type FileEditorState = { hasRemoteUpdate?: boolean; remoteContent?: string; remoteOriginalHash?: string; + markdownPreview?: boolean; }; /** Direction relative to a reference panel or group. */ diff --git a/apps/web/package.json b/apps/web/package.json index 862b529a47..7bae756069 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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" }