openclaw/apps/web/app/components/workspace/document-view.tsx
kumarabhirup 0f6849a731
Web app: add syntax highlighting, diff viewer, rich chat editor, and file search
Syntax highlighting & code viewer:
- Add shiki for syntax-highlighted fenced code blocks in chat messages
- New SyntaxBlock component (lazy shiki, dual light/dark theme)
- New CodeViewer for workspace file panel (routes code files via isCodeFile())
- API routes (browse-file, virtual-file) now return "code" type for known extensions

Diff rendering:
- New DiffCard component for rendering unified diffs with add/remove colors
- diff-blocks.ts parser to extract fenced blocks from markdown
- Chain-of-thought tool steps show inline diffs for edit/write tools
  (synthetic from old_string/new_string or direct from tool output)
- Agent runner passes through diff/firstChangedLine from edit tool results
- Document view handles diff blocks alongside report blocks

Rich chat editor (Tiptap):
- Replace plain textarea with Tiptap-based ChatEditor
- File mention extension (@-mention files with autocomplete dropdown)
- File mention list with keyboard navigation and search via suggest-files API
- New suggest-files API endpoint for fuzzy file search

File search & navigation:
- FileSearch component in workspace sidebar (debounced search, keyboard nav)
- Search results navigate sidebar to file location and open in panel
- File picker modal for browsing/selecting workspace files

Drag & drop:
- File tree nodes support native HTML5 drag (application/x-file-mention)
  for cross-component drops (e.g. dragging files into chat editor)

Chat attachments reworked:
- Switch from browser File objects to path-based references (name + path)
- Simplified attachment strip (no media previews, shows shortened paths)

Also adds software-engineering skill and related CSS for code blocks/shiki.
2026-02-13 18:06:59 -08:00

216 lines
6.8 KiB
TypeScript

"use client";
import { useState, useCallback, type MouseEvent as ReactMouseEvent } from "react";
import dynamic from "next/dynamic";
import { splitReportBlocks, hasReportBlocks } from "@/lib/report-blocks";
import { splitDiffBlocks, hasDiffBlocks } from "@/lib/diff-blocks";
import { isWorkspaceLink } from "@/lib/workspace-links";
import { DiffCard } from "../diff-viewer";
import type { TreeNode, MentionSearchFn } from "./slash-command";
// Load markdown renderer client-only to avoid SSR issues with ESM-only packages
const MarkdownContent = dynamic(
() =>
import("./markdown-content").then((mod) => mod.MarkdownContent),
{
ssr: false,
loading: () => (
<div className="animate-pulse space-y-3 py-4">
<div className="h-4 rounded" style={{ background: "var(--color-surface)", width: "80%" }} />
<div className="h-4 rounded" style={{ background: "var(--color-surface)", width: "60%" }} />
<div className="h-4 rounded" style={{ background: "var(--color-surface)", width: "70%" }} />
</div>
),
},
);
// Lazy-load ReportCard (uses Recharts which is heavy)
const ReportCard = dynamic(
() =>
import("../charts/report-card").then((m) => ({ default: m.ReportCard })),
{
ssr: false,
loading: () => (
<div
className="h-48 rounded-xl animate-pulse my-4"
style={{ background: "var(--color-surface)" }}
/>
),
},
);
// Lazy-load the Tiptap-based editor (heavy -- keep out of initial bundle)
const MarkdownEditor = dynamic(
() => import("./markdown-editor").then((m) => ({ default: m.MarkdownEditor })),
{
ssr: false,
loading: () => (
<div className="animate-pulse space-y-3 py-4 px-6">
<div className="h-4 rounded" style={{ background: "var(--color-surface)", width: "80%" }} />
<div className="h-4 rounded" style={{ background: "var(--color-surface)", width: "60%" }} />
<div className="h-4 rounded" style={{ background: "var(--color-surface)", width: "70%" }} />
</div>
),
},
);
type DocumentViewProps = {
content: string;
title?: string;
filePath?: string;
tree?: TreeNode[];
onSave?: () => void;
onNavigate?: (path: string) => void;
searchFn?: MentionSearchFn;
};
export function DocumentView({
content,
title,
filePath,
tree,
onSave,
onNavigate,
searchFn,
}: DocumentViewProps) {
const [editMode, setEditMode] = useState(!!filePath);
// Strip YAML frontmatter if present
const body = content.replace(/^---\s*\n[\s\S]*?\n---\s*\n/, "");
// Extract title from first H1 if no title provided
const h1Match = body.match(/^#\s+(.+)/m);
const displayTitle = title ?? h1Match?.[1];
const markdownBody =
displayTitle && h1Match ? body.replace(/^#\s+.+\n?/, "") : body;
// If we have a filePath and editing is enabled, render the Tiptap editor
if (editMode && filePath) {
return (
<div className="max-w-3xl mx-auto">
<MarkdownEditor
content={body}
rawContent={content}
filePath={filePath}
tree={tree ?? []}
onSave={onSave}
onNavigate={onNavigate}
searchFn={searchFn}
/>
</div>
);
}
// Check if the markdown contains embedded rich blocks (reports or diffs)
const hasRichBlocks = hasReportBlocks(markdownBody) || hasDiffBlocks(markdownBody);
// Intercept workspace-internal links in read mode (delegated click handler)
const handleLinkClick = useCallback(
(event: ReactMouseEvent<HTMLDivElement>) => {
if (!onNavigate) {return;}
const target = event.target as HTMLElement;
const link = target.closest("a");
if (!link) {return;}
const href = link.getAttribute("href");
if (!href) {return;}
if (isWorkspaceLink(href)) {
event.preventDefault();
event.stopPropagation();
onNavigate(href);
}
},
[onNavigate],
);
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div className="max-w-3xl mx-auto px-6 py-8" onClick={handleLinkClick}>
{/* Header row with title + edit button */}
<div className="flex items-start justify-between gap-4">
{displayTitle && (
<h1
className="text-3xl font-bold mb-6 flex-1"
style={{ color: "var(--color-text)" }}
>
{displayTitle}
</h1>
)}
{filePath && (
<button
type="button"
onClick={() => setEditMode(true)}
className="editor-mode-toggle flex-shrink-0 mt-1"
title="Edit this document"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
<path d="m15 5 4 4" />
</svg>
<span>Edit</span>
</button>
)}
</div>
{hasRichBlocks ? (
<EmbeddedRichContent content={markdownBody} />
) : (
<div className="workspace-prose">
<MarkdownContent content={markdownBody} />
</div>
)}
</div>
);
}
/**
* Renders markdown content that contains embedded rich blocks (reports and diffs).
* Splits the content into alternating markdown, chart, and diff sections.
*/
function EmbeddedRichContent({ content }: { content: string }) {
// First split on report blocks, then further split text segments on diff blocks
const reportSegments = splitReportBlocks(content);
type RichSegment =
| { type: "text"; text: string }
| { type: "report-artifact"; config: import("@/lib/report-blocks").ReportConfig }
| { type: "diff-artifact"; diff: string };
const segments: RichSegment[] = [];
for (const seg of reportSegments) {
if (seg.type === "text" && hasDiffBlocks(seg.text)) {
for (const ds of splitDiffBlocks(seg.text)) {
segments.push(ds);
}
} else {
segments.push(seg as RichSegment);
}
}
return (
<div className="space-y-4">
{segments.map((segment, index) => {
if (segment.type === "report-artifact") {
return (
<div key={index} className="my-6">
<ReportCard config={segment.config} />
</div>
);
}
if (segment.type === "diff-artifact") {
return (
<div key={index} className="my-4">
<DiffCard diff={segment.diff} />
</div>
);
}
// Text segment -- render as markdown
return (
<div key={index} className="workspace-prose">
<MarkdownContent content={segment.text} />
</div>
);
})}
</div>
);
}