diff --git a/apps/web/app/api/chat/route.ts b/apps/web/app/api/chat/route.ts index 99bc172a4cb..80a35543144 100644 --- a/apps/web/app/api/chat/route.ts +++ b/apps/web/app/api/chat/route.ts @@ -1,5 +1,6 @@ import type { UIMessage } from "ai"; import { runAgent, type ToolResult } from "@/lib/agent-runner"; +import { resolveAgentWorkspacePrefix } from "@/lib/workspace"; // Force Node.js runtime (required for child_process) export const runtime = "nodejs"; @@ -14,9 +15,9 @@ export const maxDuration = 600; function buildToolOutput( result?: ToolResult, ): Record { - if (!result) return {}; + if (!result) {return {};} const out: Record = {}; - if (result.text) out.text = result.text; + if (result.text) {out.text = result.text;} if (result.details) { // Forward useful details (exit code, duration, status, cwd) for (const key of [ @@ -28,14 +29,15 @@ function buildToolOutput( "reason", ]) { if (result.details[key] !== undefined) - out[key] = result.details[key]; + {out[key] = result.details[key];} } } return out; } export async function POST(req: Request) { - const { messages }: { messages: UIMessage[] } = await req.json(); + const { messages, sessionId }: { messages: UIMessage[]; sessionId?: string } = + await req.json(); // Extract the latest user message text const lastUserMessage = messages.filter((m) => m.role === "user").pop(); @@ -51,6 +53,18 @@ export async function POST(req: Request) { return new Response("No message provided", { status: 400 }); } + // Resolve workspace file paths to be agent-cwd-relative. + // Tree paths are workspace-root-relative (e.g. "knowledge/leads/foo.md"), + // but the agent runs from the repo root and needs "dench/knowledge/leads/foo.md". + let agentMessage = userText; + const wsPrefix = resolveAgentWorkspacePrefix(); + if (wsPrefix) { + agentMessage = userText.replace( + /\[Context: workspace file '([^']+)'\]/, + `[Context: workspace file '${wsPrefix}/$1']`, + ); + } + // Create a custom SSE stream using the AI SDK v6 data stream wire format. // DefaultChatTransport parses these events into UIMessage parts automatically. const encoder = new TextEncoder(); @@ -75,7 +89,7 @@ export async function POST(req: Request) { /** Write an SSE event; silently no-ops if the stream was already cancelled. */ const writeEvent = (data: unknown) => { - if (closed) return; + if (closed) {return;} const json = JSON.stringify(data); controller.enqueue(encoder.encode(`data: ${json}\n\n`)); }; @@ -100,7 +114,7 @@ export async function POST(req: Request) { }; try { - await runAgent(userText, abortController.signal, { + await runAgent(agentMessage, abortController.signal, { onThinkingDelta: (delta) => { if (!reasoningStarted) { currentReasoningId = nextId("reasoning"); @@ -190,21 +204,46 @@ export async function POST(req: Request) { closeText(); }, - onError: (err) => { - console.error("[chat] Agent error:", err); + onAgentError: (message) => { + // Surface agent-level errors (API 402, rate limits, etc.) + // as visible text in the chat so the user sees what happened. closeReasoning(); - if (!textStarted) { - currentTextId = nextId("text"); - writeEvent({ - type: "text-start", - id: currentTextId, - }); - textStarted = true; - } + closeText(); + + currentTextId = nextId("text"); + writeEvent({ + type: "text-start", + id: currentTextId, + }); writeEvent({ type: "text-delta", id: currentTextId, - delta: `Error starting agent: ${err.message}`, + delta: `[error] ${message}`, + }); + writeEvent({ + type: "text-end", + id: currentTextId, + }); + textStarted = false; + everSentText = true; + }, + + onError: (err) => { + console.error("[chat] Agent error:", err); + closeReasoning(); + closeText(); + + currentTextId = nextId("text"); + writeEvent({ + type: "text-start", + id: currentTextId, + }); + textStarted = true; + everSentText = true; + writeEvent({ + type: "text-delta", + id: currentTextId, + delta: `[error] Failed to start agent: ${err.message}`, }); writeEvent({ type: "text-end", id: currentTextId }); textStarted = false; @@ -219,10 +258,14 @@ export async function POST(req: Request) { type: "text-start", id: currentTextId, }); + const msg = + _code !== null && _code !== 0 + ? `[error] Agent exited with code ${_code}. Check server logs for details.` + : "[error] No response from agent."; writeEvent({ type: "text-delta", id: currentTextId, - delta: "(No response from agent)", + delta: msg, }); writeEvent({ type: "text-end", @@ -233,7 +276,7 @@ export async function POST(req: Request) { closeText(); } }, - }); + }, sessionId ? { sessionId } : undefined); } catch (error) { console.error("[chat] Stream error:", error); writeEvent({ diff --git a/apps/web/app/api/workspace/assets/[...path]/route.ts b/apps/web/app/api/workspace/assets/[...path]/route.ts new file mode 100644 index 00000000000..1c7869b0ef5 --- /dev/null +++ b/apps/web/app/api/workspace/assets/[...path]/route.ts @@ -0,0 +1,57 @@ +import { readFileSync, existsSync } from "node:fs"; +import { extname } from "node:path"; +import { safeResolvePath } from "@/lib/workspace"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +const MIME_MAP: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".svg": "image/svg+xml", + ".bmp": "image/bmp", + ".ico": "image/x-icon", +}; + +/** + * GET /api/workspace/assets/ + * Serves an image file from the workspace's assets/ directory. + */ +export async function GET( + _req: Request, + { params }: { params: Promise<{ path: string[] }> }, +) { + const segments = (await params).path; + if (!segments || segments.length === 0) { + return new Response("Not found", { status: 404 }); + } + + const relPath = "assets/" + segments.join("/"); + const ext = extname(relPath).toLowerCase(); + + // Only serve known image types + const mime = MIME_MAP[ext]; + if (!mime) { + return new Response("Unsupported file type", { status: 400 }); + } + + const absPath = safeResolvePath(relPath); + if (!absPath || !existsSync(absPath)) { + return new Response("Not found", { status: 404 }); + } + + try { + const buffer = readFileSync(absPath); + return new Response(buffer, { + headers: { + "Content-Type": mime, + "Cache-Control": "public, max-age=31536000, immutable", + }, + }); + } catch { + return new Response("Read error", { status: 500 }); + } +} diff --git a/apps/web/app/api/workspace/upload/route.ts b/apps/web/app/api/workspace/upload/route.ts new file mode 100644 index 00000000000..84398db08c1 --- /dev/null +++ b/apps/web/app/api/workspace/upload/route.ts @@ -0,0 +1,86 @@ +import { writeFileSync, mkdirSync } from "node:fs"; +import { join, dirname, extname } from "node:path"; +import { resolveDenchRoot, safeResolveNewPath } from "@/lib/workspace"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +const ALLOWED_EXTENSIONS = new Set([ + ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".ico", +]); + +const MAX_SIZE = 10 * 1024 * 1024; // 10 MB + +/** + * POST /api/workspace/upload + * Accepts multipart form data with a "file" field. + * Saves to assets/- inside the workspace. + * Returns { ok, path } where path is workspace-relative. + */ +export async function POST(req: Request) { + const root = resolveDenchRoot(); + if (!root) { + return Response.json( + { error: "Workspace not found" }, + { status: 500 }, + ); + } + + let formData: FormData; + try { + formData = await req.formData(); + } catch { + return Response.json({ error: "Invalid form data" }, { status: 400 }); + } + + const file = formData.get("file"); + if (!file || !(file instanceof File)) { + return Response.json( + { error: "Missing 'file' field" }, + { status: 400 }, + ); + } + + // Validate extension + const ext = extname(file.name).toLowerCase(); + if (!ALLOWED_EXTENSIONS.has(ext)) { + return Response.json( + { error: `File type ${ext} is not allowed` }, + { status: 400 }, + ); + } + + // Validate size + if (file.size > MAX_SIZE) { + return Response.json( + { error: "File is too large (max 10 MB)" }, + { status: 400 }, + ); + } + + // Build a safe filename: timestamp + sanitized original name + const safeName = file.name + .replace(/[^a-zA-Z0-9._-]/g, "_") + .replace(/_{2,}/g, "_"); + const relPath = join("assets", `${Date.now()}-${safeName}`); + + const absPath = safeResolveNewPath(relPath); + if (!absPath) { + return Response.json( + { error: "Invalid path" }, + { status: 400 }, + ); + } + + try { + mkdirSync(dirname(absPath), { recursive: true }); + const buffer = Buffer.from(await file.arrayBuffer()); + writeFileSync(absPath, buffer); + return Response.json({ ok: true, path: relPath }); + } catch (err) { + return Response.json( + { error: err instanceof Error ? err.message : "Upload failed" }, + { status: 500 }, + ); + } +} diff --git a/apps/web/app/components/chat-message.tsx b/apps/web/app/components/chat-message.tsx index 230801ed51c..b90771869ef 100644 --- a/apps/web/app/components/chat-message.tsx +++ b/apps/web/app/components/chat-message.tsx @@ -141,17 +141,72 @@ export function ChatMessage({ message }: { message: UIMessage }) { : "bg-[var(--color-surface)] text-[var(--color-text)]" }`} > - {segments.map((segment, index) => { - if (segment.type === "text") { + {segments.map((segment, index) => { + if (segment.type === "text") { + // Detect agent error messages (prefixed with [error]) + const errorMatch = segment.text.match( + /^\[error\]\s*([\s\S]*)$/, + ); + if (errorMatch) { return (
- {segment.text} + + + {errorMatch[1].trim()} +
); } + return ( +
+ {segment.text} +
+ ); + } if (segment.type === "report-artifact") { return ( Promise; @@ -57,8 +56,6 @@ export const ChatPanel = forwardRef( }, ref, ) { - const { messages, sendMessage, status, stop, error, setMessages } = - useChat({ transport }); const [input, setInput] = useState(""); const [currentSessionId, setCurrentSessionId] = useState( null, @@ -80,6 +77,37 @@ export const ChatPanel = forwardRef( ); const filePath = fileContext?.path ?? null; + + // ── Ref-based session ID for transport ── + // The transport body function reads from this ref so it always has + // the latest session ID, even when called in the same event-loop + // tick as a state update (before the re-render). + const sessionIdRef = useRef(null); + + // Keep ref in sync with React state. + useEffect(() => { + sessionIdRef.current = currentSessionId; + }, [currentSessionId]); + + // ── Transport (per-instance) ── + // Each ChatPanel mounts its own transport. For file-scoped chats the + // body function injects the sessionId so the API spawns an isolated + // agent process (subagent) per chat session. + const transport = useMemo( + () => + new DefaultChatTransport({ + api: "/api/chat", + body: () => { + const sid = sessionIdRef.current; + return sid ? { sessionId: sid } : {}; + }, + }), + [], + ); + + const { messages, sendMessage, status, stop, error, setMessages } = + useChat({ transport }); + const isStreaming = status === "streaming" || status === "submitted"; // Auto-scroll to bottom on new messages @@ -87,38 +115,22 @@ export const ChatPanel = forwardRef( messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages]); - // ── File-scoped sessions ── + // ── Session persistence helpers ── - const fetchFileSessions = useCallback(async () => { - if (!filePath) {return;} - try { - const res = await fetch( - `/api/web-sessions?filePath=${encodeURIComponent(filePath)}`, - ); + const createSession = useCallback( + async (title: string): Promise => { + const body: Record = { title }; + if (filePath) {body.filePath = filePath;} + const res = await fetch("/api/web-sessions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); const data = await res.json(); - setFileSessions(data.sessions || []); - } catch { - // ignore - } - }, [filePath]); - - useEffect(() => { - if (filePath) {fetchFileSessions();} - }, [filePath, fetchFileSessions]); - - // Reset chat state when the active file changes - useEffect(() => { - if (!filePath) {return;} - stop(); - setCurrentSessionId(null); - onActiveSessionChange?.(null); - setMessages([]); - savedMessageIdsRef.current.clear(); - isFirstFileMessageRef.current = true; - // eslint-disable-next-line react-hooks/exhaustive-deps -- stable setters - }, [filePath]); - - // ── Session persistence ── + return data.session.id; + }, + [filePath], + ); const saveMessages = useCallback( async ( @@ -155,27 +167,11 @@ export const ChatPanel = forwardRef( for (const m of msgs) {savedMessageIdsRef.current.add(m.id);} onSessionsChange?.(); - if (filePath) {fetchFileSessions();} } catch (err) { console.error("Failed to save messages:", err); } }, - [onSessionsChange, filePath, fetchFileSessions], - ); - - const createSession = useCallback( - async (title: string): Promise => { - const body: Record = { title }; - if (filePath) {body.filePath = filePath;} - const res = await fetch("/api/web-sessions", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - const data = await res.json(); - return data.session.id; - }, - [filePath], + [onSessionsChange], ); /** Extract plain text from a UIMessage */ @@ -198,7 +194,94 @@ export const ChatPanel = forwardRef( [], ); - // Persist unsaved messages when streaming finishes + live-reload file + // ── File-scoped session initialization ── + // When the active file changes: reset chat state, fetch existing + // sessions for this file, and auto-load the most recent one. + + const fetchFileSessionsRef = useRef< + (() => Promise) | null + >(null); + + fetchFileSessionsRef.current = async () => { + if (!filePath) {return [];} + try { + const res = await fetch( + `/api/web-sessions?filePath=${encodeURIComponent(filePath)}`, + ); + const data = await res.json(); + return (data.sessions || []) as FileScopedSession[]; + } catch { + return []; + } + }; + + useEffect(() => { + if (!filePath) {return;} + let cancelled = false; + + // Reset state for the new file + sessionIdRef.current = null; + setCurrentSessionId(null); + onActiveSessionChange?.(null); + setMessages([]); + savedMessageIdsRef.current.clear(); + isFirstFileMessageRef.current = true; + + // Fetch sessions and auto-load the most recent + (async () => { + const sessions = await fetchFileSessionsRef.current?.() ?? []; + if (cancelled) {return;} + setFileSessions(sessions); + + if (sessions.length > 0) { + const latest = sessions[0]; + setCurrentSessionId(latest.id); + sessionIdRef.current = latest.id; + onActiveSessionChange?.(latest.id); + isFirstFileMessageRef.current = false; + + // Load messages for the most recent session + try { + const msgRes = await fetch( + `/api/web-sessions/${latest.id}`, + ); + if (cancelled) {return;} + const msgData = await msgRes.json(); + const sessionMessages: Array<{ + id: string; + role: "user" | "assistant"; + content: string; + parts?: Array>; + }> = msgData.messages || []; + + const uiMessages = sessionMessages.map((msg) => { + savedMessageIdsRef.current.add(msg.id); + return { + id: msg.id, + role: msg.role, + parts: (msg.parts ?? [ + { + type: "text" as const, + text: msg.content, + }, + ]) as UIMessage["parts"], + }; + }); + if (!cancelled) {setMessages(uiMessages);} + } catch { + // ignore – start with empty messages + } + } + })(); + + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- stable setters + }, [filePath]); + + // ── Persist unsaved messages + live-reload after streaming ── + const prevStatusRef = useRef(status); useEffect(() => { const wasStreaming = @@ -220,6 +303,13 @@ export const ChatPanel = forwardRef( saveMessages(currentSessionId, toSave); } + // Refresh file session list (title/count may have changed) + if (filePath) { + fetchFileSessionsRef.current?.().then((sessions) => { + setFileSessions(sessions); + }); + } + // Re-fetch file content for live reload after agent edits if (filePath && onFileChanged) { fetch( @@ -257,7 +347,7 @@ export const ChatPanel = forwardRef( return; } - // Create session if none + // Create session if none exists yet let sessionId = currentSessionId; if (!sessionId) { const title = @@ -266,21 +356,15 @@ export const ChatPanel = forwardRef( : userText; sessionId = await createSession(title); setCurrentSessionId(sessionId); + sessionIdRef.current = sessionId; onActiveSessionChange?.(sessionId); onSessionsChange?.(); - if (filePath) {fetchFileSessions();} - if (newSessionPendingRef.current) { - newSessionPendingRef.current = false; - const newMsgId = `system-new-${Date.now()}`; - await saveMessages(sessionId, [ - { - id: newMsgId, - role: "user", - content: "/new", - parts: [{ type: "text", text: "/new" }], - }, - ]); + // Refresh file session tabs + if (filePath) { + fetchFileSessionsRef.current?.().then((sessions) => { + setFileSessions(sessions); + }); } } @@ -298,8 +382,10 @@ export const ChatPanel = forwardRef( async (sessionId: string) => { if (sessionId === currentSessionId) {return;} + stop(); setLoadingSession(true); setCurrentSessionId(sessionId); + sessionIdRef.current = sessionId; onActiveSessionChange?.(sessionId); savedMessageIdsRef.current.clear(); isFirstFileMessageRef.current = false; // loaded session has context @@ -340,16 +426,18 @@ export const ChatPanel = forwardRef( setLoadingSession(false); } }, - [currentSessionId, setMessages, onActiveSessionChange], + [currentSessionId, setMessages, onActiveSessionChange, stop], ); const handleNewSession = useCallback(async () => { + stop(); setCurrentSessionId(null); + sessionIdRef.current = null; onActiveSessionChange?.(null); setMessages([]); savedMessageIdsRef.current.clear(); isFirstFileMessageRef.current = true; - newSessionPendingRef.current = true; + newSessionPendingRef.current = false; // Only send /new to backend for non-file sessions (main chat) if (!filePath) { @@ -362,7 +450,9 @@ export const ChatPanel = forwardRef( setStartingNewSession(false); } } - }, [setMessages, onActiveSessionChange, filePath]); + // NOTE: we intentionally do NOT clear fileSessions so the + // session tab list remains intact. + }, [setMessages, onActiveSessionChange, filePath, stop]); // Expose imperative handle for parent-driven session management useImperativeHandle( @@ -598,11 +688,35 @@ export const ChatPanel = forwardRef( )} - {/* Error display */} + {/* Transport-level error display */} {error && ( -
-

- Error: {error.message} +

+ + + + + +

+ {error.message}

)} diff --git a/apps/web/app/components/workspace/document-view.tsx b/apps/web/app/components/workspace/document-view.tsx index 1d25dc82d25..0e18ee07268 100644 --- a/apps/web/app/components/workspace/document-view.tsx +++ b/apps/web/app/components/workspace/document-view.tsx @@ -1,7 +1,9 @@ "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( @@ -34,12 +36,40 @@ const ReportCard = dynamic( }, ); +// 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: () => ( +
+
+
+
+
+ ), + }, +); + type DocumentViewProps = { content: string; title?: string; + filePath?: string; + tree?: TreeNode[]; + onSave?: () => void; + onNavigate?: (path: string) => void; }; -export function DocumentView({ content, title }: DocumentViewProps) { +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/, ""); @@ -49,19 +79,53 @@ export function DocumentView({ content, title }: DocumentViewProps) { 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 ( +
+ setEditMode(false)} + /> +
+ ); + } + // Check if the markdown contains embedded report-json blocks const hasReports = hasReportBlocks(markdownBody); return (
- {displayTitle && ( -

- {displayTitle} -

- )} + {/* Header row with title + edit button */} +
+ {displayTitle && ( +

+ {displayTitle} +

+ )} + {filePath && ( + + )} +
{hasReports ? ( diff --git a/apps/web/app/components/workspace/markdown-editor.tsx b/apps/web/app/components/workspace/markdown-editor.tsx new file mode 100644 index 00000000000..9a693a09ae5 --- /dev/null +++ b/apps/web/app/components/workspace/markdown-editor.tsx @@ -0,0 +1,709 @@ +"use client"; + +import { useEditor, EditorContent } from "@tiptap/react"; +import { BubbleMenu } from "@tiptap/react/menus"; +import StarterKit from "@tiptap/starter-kit"; +import { Markdown } from "@tiptap/markdown"; +import Image from "@tiptap/extension-image"; +import Link from "@tiptap/extension-link"; +import { Table } from "@tiptap/extension-table"; +import TableRow from "@tiptap/extension-table-row"; +import TableCell from "@tiptap/extension-table-cell"; +import TableHeader from "@tiptap/extension-table-header"; +import TaskList from "@tiptap/extension-task-list"; +import TaskItem from "@tiptap/extension-task-item"; +import Placeholder from "@tiptap/extension-placeholder"; +import { useState, useCallback, useEffect, useRef, useMemo } from "react"; + +import { ReportBlockNode, preprocessReportBlocks, postprocessReportBlocks } from "./report-block-node"; +import { createSlashCommand, createFileMention, type TreeNode } from "./slash-command"; + +// --- Types --- + +export type MarkdownEditorProps = { + /** The markdown body (frontmatter already stripped by parent). */ + content: string; + /** Original raw file content including frontmatter, used to preserve it on save. */ + rawContent?: string; + filePath: string; + tree: TreeNode[]; + onSave?: () => void; + onNavigate?: (path: string) => void; + /** Switch to read-only mode (renders a "Read" button in the top bar). */ + onSwitchToRead?: () => void; +}; + +// --- Main component --- + +/** Extract YAML frontmatter (if any) from raw file content. */ +function extractFrontmatter(raw: string): string { + const match = raw.match(/^(---\s*\n[\s\S]*?\n---\s*\n)/); + return match ? match[1] : ""; +} + +export function MarkdownEditor({ + content, + rawContent, + filePath, + tree, + onSave, + onNavigate, + onSwitchToRead, +}: MarkdownEditorProps) { + const [saving, setSaving] = useState(false); + const [saveStatus, setSaveStatus] = useState<"idle" | "saved" | "error">("idle"); + const [isDirty, setIsDirty] = useState(false); + // Tracks the `content` prop so we can detect external updates (parent re-fetch). + // Only updated when the prop itself changes -- never on save. + const lastPropContentRef = useRef(content); + const saveTimerRef = useRef | null>(null); + // Preserve frontmatter so save can prepend it back + const frontmatterRef = useRef(extractFrontmatter(rawContent ?? "")); + + // "/" for block commands, "@" for file mentions + const slashCommand = useMemo(() => createSlashCommand(), []); + const fileMention = useMemo(() => createFileMention(tree), [tree]); + + const editor = useEditor({ + extensions: [ + StarterKit.configure({ + codeBlock: { + HTMLAttributes: { class: "code-block" }, + }, + }), + Markdown.configure({ + markedOptions: { gfm: true }, + }), + Image.configure({ + inline: false, + allowBase64: true, + HTMLAttributes: { class: "editor-image" }, + }), + Link.configure({ + openOnClick: false, + HTMLAttributes: { class: "editor-link" }, + }), + Table.configure({ resizable: false }), + TableRow, + TableCell, + TableHeader, + TaskList, + TaskItem.configure({ nested: true }), + Placeholder.configure({ + placeholder: "Start writing, or type / for commands...", + }), + ReportBlockNode, + slashCommand, + fileMention, + ], + // Parse initial content as markdown (not HTML -- the default) + content: preprocessReportBlocks(content), + contentType: "markdown", + immediatelyRender: false, + onUpdate: () => { + setIsDirty(true); + setSaveStatus("idle"); + }, + }); + + // --- Image upload helper --- + const uploadImage = useCallback( + async (file: File): Promise => { + const form = new FormData(); + form.append("file", file); + try { + const res = await fetch("/api/workspace/upload", { + method: "POST", + body: form, + }); + if (!res.ok) {return null;} + const data = await res.json(); + // Return a URL the browser can fetch to display the image + return `/api/workspace/assets/${(data.path as string).replace(/^assets\//, "")}`; + } catch { + return null; + } + }, + [], + ); + + /** Upload one or more image Files and insert them at the current cursor. */ + const insertUploadedImages = useCallback( + async (files: File[]) => { + if (!editor) {return;} + for (const file of files) { + const url = await uploadImage(file); + if (url) { + editor.chain().focus().setImage({ src: url, alt: file.name }).run(); + } + } + }, + [editor, uploadImage], + ); + + // --- Drop & paste handlers for images --- + useEffect(() => { + if (!editor) {return;} + + const editorElement = editor.view.dom; + + // Prevent the browser default (open file in tab) and upload instead + const handleDrop = (event: DragEvent) => { + if (!event.dataTransfer?.files?.length) {return;} + + const imageFiles = Array.from(event.dataTransfer.files).filter((f) => + f.type.startsWith("image/"), + ); + if (imageFiles.length === 0) {return;} + + event.preventDefault(); + event.stopPropagation(); + insertUploadedImages(imageFiles); + }; + + // Also prevent dragover so the browser doesn't hijack the drop + const handleDragOver = (event: DragEvent) => { + if (event.dataTransfer?.types?.includes("Files")) { + event.preventDefault(); + } + }; + + const handlePaste = (event: ClipboardEvent) => { + if (!event.clipboardData) {return;} + + // 1. Handle pasted image files (e.g. screenshots) + const imageFiles = Array.from(event.clipboardData.files).filter((f) => + f.type.startsWith("image/"), + ); + if (imageFiles.length > 0) { + event.preventDefault(); + event.stopPropagation(); + insertUploadedImages(imageFiles); + return; + } + + // 2. Handle pasted text that looks like a local image path or file:// URL + const text = event.clipboardData.getData("text/plain"); + if (!text) {return;} + + const isLocalPath = + text.startsWith("file://") || + /^(\/|~\/|[A-Z]:\\).*\.(png|jpe?g|gif|webp|svg|bmp|ico)$/i.test(text.trim()); + + if (isLocalPath) { + event.preventDefault(); + event.stopPropagation(); + // Insert as an image node directly -- the browser can't fetch file:// but + // the user likely has the file accessible on their machine. We insert the + // cleaned path; the asset serving route won't help here but at least the + // markdown ![](path) will be correct. + const cleanPath = text.trim().replace(/^file:\/\//, ""); + editor?.chain().focus().setImage({ src: cleanPath }).run(); + } + }; + + editorElement.addEventListener("drop", handleDrop); + editorElement.addEventListener("dragover", handleDragOver); + editorElement.addEventListener("paste", handlePaste); + return () => { + editorElement.removeEventListener("drop", handleDrop); + editorElement.removeEventListener("dragover", handleDragOver); + editorElement.removeEventListener("paste", handlePaste); + }; + }, [editor, insertUploadedImages]); + + // Handle link clicks for workspace navigation + useEffect(() => { + if (!editor || !onNavigate) {return;} + + const handleClick = (event: MouseEvent) => { + const target = event.target as HTMLElement; + const link = target.closest("a"); + if (!link) {return;} + + const href = link.getAttribute("href"); + if (!href) {return;} + + // Workspace-internal link (relative path, no protocol) + if (!href.startsWith("http://") && !href.startsWith("https://") && !href.startsWith("mailto:")) { + event.preventDefault(); + event.stopPropagation(); + onNavigate(href); + } + }; + + const editorElement = editor.view.dom; + editorElement.addEventListener("click", handleClick); + return () => editorElement.removeEventListener("click", handleClick); + }, [editor, onNavigate]); + + // Save handler + const handleSave = useCallback(async () => { + if (!editor || saving) {return;} + + setSaving(true); + setSaveStatus("idle"); + + try { + // Serialize editor content back to markdown + // The Markdown extension adds getMarkdown() to the editor instance + const editorAny = editor as unknown as { getMarkdown?: () => string }; + let markdown: string; + + if (typeof editorAny.getMarkdown === "function") { + markdown = editorAny.getMarkdown(); + } else { + // Fallback: use HTML output + markdown = editor.getHTML(); + } + + // Convert report block HTML back to ```report-json``` fenced blocks + const bodyContent = postprocessReportBlocks(markdown); + // Prepend preserved frontmatter so it isn't lost on save + const finalContent = frontmatterRef.current + bodyContent; + + const res = await fetch("/api/workspace/file", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: filePath, content: finalContent }), + }); + + if (res.ok) { + setSaveStatus("saved"); + setIsDirty(false); + // Sync the prop tracker to the body we just saved so the external-update + // effect doesn't see a mismatch and reset the editor. + lastPropContentRef.current = content; + onSave?.(); + + // Clear "saved" indicator after 2s + if (saveTimerRef.current) {clearTimeout(saveTimerRef.current);} + saveTimerRef.current = setTimeout(() => setSaveStatus("idle"), 2000); + } else { + setSaveStatus("error"); + } + } catch { + setSaveStatus("error"); + } finally { + setSaving(false); + } + }, [editor, filePath, saving, onSave]); + + // Keyboard shortcut: Cmd/Ctrl+S + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "s") { + e.preventDefault(); + handleSave(); + } + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [handleSave]); + + // Update content when file changes externally (parent re-fetched the file) + useEffect(() => { + if (!editor || isDirty) {return;} + if (content !== lastPropContentRef.current) { + lastPropContentRef.current = content; + // Also update frontmatter in case the raw content changed + frontmatterRef.current = extractFrontmatter(rawContent ?? ""); + const processed = preprocessReportBlocks(content); + editor.commands.setContent(processed, { contentType: "markdown" }); + setIsDirty(false); + } + }, [content, rawContent, editor, isDirty]); + + if (!editor) { + return ( +
+
+
+
+
+ ); + } + + return ( +
+ {/* Sticky top bar: save status + save button + read toggle */} +
+
+ {isDirty && ( + + Unsaved changes + + )} + {saveStatus === "saved" && !isDirty && ( + + Saved + + )} + {saveStatus === "error" && ( + + Save failed + + )} +
+
+ + {typeof navigator !== "undefined" && navigator.platform?.includes("Mac") ? "\u2318" : "Ctrl"}+S + + + {onSwitchToRead && ( + + )} +
+
+ + {/* Toolbar */} + + + {/* Bubble menu for text selection */} + +
+ editor.chain().focus().toggleBold().run()} + title="Bold" + > + B + + editor.chain().focus().toggleItalic().run()} + title="Italic" + > + I + + editor.chain().focus().toggleStrike().run()} + title="Strikethrough" + > + S + + editor.chain().focus().toggleCode().run()} + title="Inline code" + > + {"<>"} + + { + if (editor.isActive("link")) { + editor.chain().focus().unsetLink().run(); + } else { + const url = window.prompt("URL:"); + if (url) { + editor.chain().focus().setLink({ href: url }).run(); + } + } + }} + title="Link" + > + + + + + +
+
+ + {/* Editor content */} +
+ +
+ +
+ ); +} + +// --- Toolbar --- + +function EditorToolbar({ + editor, + onUploadImages, +}: { + editor: ReturnType; + onUploadImages?: (files: File[]) => void; +}) { + const imageInputRef = useRef(null); + + if (!editor) {return null;} + + return ( +
+ {/* Headings */} + + editor.chain().focus().toggleHeading({ level: 1 }).run()} + title="Heading 1" + > + H1 + + editor.chain().focus().toggleHeading({ level: 2 }).run()} + title="Heading 2" + > + H2 + + editor.chain().focus().toggleHeading({ level: 3 }).run()} + title="Heading 3" + > + H3 + + + + + + {/* Inline formatting */} + + editor.chain().focus().toggleBold().run()} + title="Bold" + > + B + + editor.chain().focus().toggleItalic().run()} + title="Italic" + > + I + + editor.chain().focus().toggleStrike().run()} + title="Strikethrough" + > + S + + editor.chain().focus().toggleCode().run()} + title="Inline code" + > + {"<>"} + + + + + + {/* Block elements */} + + editor.chain().focus().toggleBulletList().run()} + title="Bullet list" + > + + + + + + editor.chain().focus().toggleOrderedList().run()} + title="Ordered list" + > + + + + + + editor.chain().focus().toggleTaskList().run()} + title="Task list" + > + + + + + editor.chain().focus().toggleBlockquote().run()} + title="Blockquote" + > + + + + + + editor.chain().focus().toggleCodeBlock().run()} + title="Code block" + > + + + + + + + + + {/* Insert items */} + + { + const url = window.prompt("Link URL:"); + if (url) { + editor.chain().focus().setLink({ href: url }).run(); + } + }} + title="Insert link" + > + + + + + + { + // Open file picker for local images; shift-click for URL input + if (onUploadImages) { + imageInputRef.current?.click(); + } else { + const url = window.prompt("Image URL:"); + if (url) { + editor.chain().focus().setImage({ src: url }).run(); + } + } + }} + title="Insert image (click to upload, or drag & drop)" + > + + + + + + + {/* Hidden file input for image upload */} + { + const files = Array.from(e.target.files ?? []); + if (files.length > 0 && onUploadImages) { + onUploadImages(files); + } + // Reset so the same file can be picked again + e.target.value = ""; + }} + /> + { + editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(); + }} + title="Insert table" + > + + + + + { + editor.chain().focus().setHorizontalRule().run(); + }} + title="Horizontal rule" + > + + + + + +
+ ); +} + +// --- Toolbar primitives --- + +function ToolbarGroup({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +function ToolbarDivider() { + return
; +} + +function ToolbarButton({ + active, + onClick, + title, + children, +}: { + active: boolean; + onClick: () => void; + title: string; + children: React.ReactNode; +}) { + return ( + + ); +} + +// --- Bubble menu button --- + +function BubbleButton({ + active, + onClick, + title, + children, +}: { + active: boolean; + onClick: () => void; + title: string; + children: React.ReactNode; +}) { + return ( + + ); +} diff --git a/apps/web/app/components/workspace/report-block-node.tsx b/apps/web/app/components/workspace/report-block-node.tsx new file mode 100644 index 00000000000..a0fe76a9ddd --- /dev/null +++ b/apps/web/app/components/workspace/report-block-node.tsx @@ -0,0 +1,223 @@ +"use client"; + +import { Node, mergeAttributes } from "@tiptap/core"; +import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react"; +import { useState, useCallback } from "react"; +import type { ReportConfig } from "../../components/charts/types"; + +// Lazy-load ReportCard to keep bundle light +import dynamic from "next/dynamic"; +const ReportCard = dynamic( + () => + import("../../components/charts/report-card").then((m) => ({ + default: m.ReportCard, + })), + { + ssr: false, + loading: () => ( +
+ ), + }, +); + +// --- React NodeView Component --- + +function ReportBlockView({ + node, + updateAttributes, + deleteNode, + selected, +}: { + node: { attrs: { config: string } }; + updateAttributes: (attrs: Record) => void; + deleteNode: () => void; + selected: boolean; +}) { + const [showSource, setShowSource] = useState(false); + const [editValue, setEditValue] = useState(node.attrs.config); + + let parsedConfig: ReportConfig | null = null; + let parseError: string | null = null; + + try { + const parsed = JSON.parse(node.attrs.config); + if (parsed?.panels && Array.isArray(parsed.panels)) { + parsedConfig = parsed as ReportConfig; + } else { + parseError = "Invalid report config: missing panels array"; + } + } catch { + parseError = "Invalid JSON in report block"; + } + + const handleSaveSource = useCallback(() => { + try { + JSON.parse(editValue); // validate + updateAttributes({ config: editValue }); + setShowSource(false); + } catch { + // Don't close if invalid JSON + } + }, [editValue, updateAttributes]); + + return ( + + {/* Overlay toolbar */} +
+ + +
+ + {showSource ? ( + /* JSON source editor */ +
+
report-json
+