openclaw/apps/web/app/components/workspace/document-view.tsx
kumarabhirup 624dc6b91e
Dench workspace: Tiptap markdown editor, subagent sessions, and error surfacing
── Tiptap Markdown Editor ──

- Add full Tiptap-based WYSIWYG markdown editor (markdown-editor.tsx, 709 LOC)
  with bubble menu, auto-save (debounced), image drag-and-drop/paste upload,
  table editing, task list checkboxes, and frontmatter preservation on save.
- Add slash command system (slash-command.tsx, 607 LOC) with "/" trigger for
  block insertion (headings, lists, tables, code blocks, images, reports) and
  "@" trigger for file/document mention with fuzzy search across the workspace
  tree.
- Add ReportBlockNode (report-block-node.tsx) — custom Tiptap node that renders
  embedded report-json blocks as interactive ReportCard widgets inline in the
  editor, with expand/collapse and edit-JSON support.
- Add workspace asset serving API (api/workspace/assets/[...path]/route.ts) to
  serve images from the workspace with proper MIME types.
- Add workspace file upload orkspace/upload/route.ts) for multipart
  image uploads (10 MB limit, image types only), saving to assets/ directory.
- Add ~500 lines of Tiptap editor CSS to globals.css (editor layout, task lists,
  images, tables, slash command dropdown, bubble menu toolbar, code blocks, etc.).
- Add 14 @tiptap/* dependencies to apps/web/package.json (react, starter-kit,
  markdown, image, link, table, task-list, suggestion, placeholder, etc.).

── Document View: Edit/Read Mode Toggle ──

- document-view.tsx: Add edit/read mode toggle; defaults to edit mode when a
  filePath is available. Lazy-loads MarkdownEditor to keep initial bundle light.
- workspace/page.tsx: Pass activePath, tree, onSave, onNavigate, and
  onRefreshTree through to DocumentView for full editor integration with
  workspace navigation and tree refresh after saves.

── Subagent Session Isolation ──

- agent-runner.ts: Add RunAgentOptions with optional sessionId; when set, spawns
  the agent with --session-key agent:main:subagent:<id> ant so
  file-scoped sidebar chats run in isolated sessions independent of the main
  agent.
- route.ts (chat API): Accept sessionId from request body and forward it to
  runAgent. Resolve workspace file path prefixes (resolveAgentWorkspacePrefix)
  so tree-relative paths become agent-cwd-relative.
- chat-panel.tsx: Create per-instance DefaultChatTransport that injects sessionId
  via body function and a ref (avoids stale closures). On file change, auto-load
  the most recent session and its messages. Refresh session tab list after
  streaming ends. Stop ongoing stream when switching sessions.
- register.agent.ts: Add --session-key <key> and --lane <lane> CLI flags.
- agent-via-gateway.ts: Wire sessionKey into session resolution and validation
  for both interactive and --stream-json code paths.
- workspace.ts: Add resolveAgentWorkspacePrefix() to map workspace-root-relative
  paths to repo-root-relative paths for the agent process.

── Error Surfacing ──

- agent-runner.ts: Add onAgentError callback extraction helpers
  (parseAgentErrorMessage, parseErrorBody, parseErrorFromStderr) to surface
  API-level errors (402 payment, rate limits, etc.) to the UI. Captures stderr
  for fallback error detection on non-zero exit.
- route.ts: Wire onAgentError into the SSE stream as [error]-prefixed text
  parts. Improve onError and onClose handlers with clearer error messages and
  exit code reporting.
- chat-message.tsx: Detect [error]-prefixed text segments and render them as
  styled error banners with alert icon instead of plain text.
- chat-panel.tsx: Restyle the transport-level error bar with themed colors and
  an alert icon consistent with in-message error styling.
2026-02-11 20:54:30 -08:00

168 lines
5.0 KiB
TypeScript

"use client";
import { useState } from "react";
import dynamic from "next/dynamic";
import { splitReportBlocks, hasReportBlocks } from "@/lib/report-blocks";
import type { TreeNode } 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;
};
export function DocumentView({
content,
title,
filePath,
tree,
onSave,
onNavigate,
}: 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}
onSwitchToRead={() => setEditMode(false)}
/>
</div>
);
}
// Check if the markdown contains embedded report-json blocks
const hasReports = hasReportBlocks(markdownBody);
return (
<div className="max-w-3xl mx-auto px-6 py-8">
{/* 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>
{hasReports ? (
<EmbeddedReportContent content={markdownBody} />
) : (
<div className="workspace-prose">
<MarkdownContent content={markdownBody} />
</div>
)}
</div>
);
}
/**
* Renders markdown content that contains embedded report-json blocks.
* Splits the content into alternating markdown and interactive chart sections.
*/
function EmbeddedReportContent({ content }: { content: string }) {
const segments = splitReportBlocks(content);
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>
);
}
// Text segment -- render as markdown
return (
<div key={index} className="workspace-prose">
<MarkdownContent content={segment.text} />
</div>
);
})}
</div>
);
}