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.
This commit is contained in:
kumarabhirup 2026-02-11 20:54:30 -08:00
parent 6d8623b00f
commit 624dc6b91e
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
18 changed files with 3879 additions and 128 deletions

View File

@ -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<string, unknown> {
if (!result) return {};
if (!result) {return {};}
const out: Record<string, unknown> = {};
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({

View File

@ -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<string, string> = {
".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/<path>
* 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 });
}
}

View File

@ -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/<timestamp>-<filename> 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 },
);
}
}

View File

@ -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 (
<div
key={index}
className="whitespace-pre-wrap text-[15px] leading-relaxed"
className="flex items-start gap-2 rounded-lg px-3 py-2 text-[13px] leading-relaxed"
style={{
background:
"color-mix(in srgb, var(--color-error, #ef4444) 12%, transparent)",
color: "var(--color-error, #ef4444)",
border: "1px solid color-mix(in srgb, var(--color-error, #ef4444) 25%, transparent)",
}}
>
{segment.text}
<span
className="flex-shrink-0 mt-0.5"
aria-hidden="true"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle
cx="12"
cy="12"
r="10"
/>
<line
x1="12"
y1="8"
x2="12"
y2="12"
/>
<line
x1="12"
y1="16"
x2="12.01"
y2="16"
/>
</svg>
</span>
<span className="whitespace-pre-wrap">
{errorMatch[1].trim()}
</span>
</div>
);
}
return (
<div
key={index}
className="whitespace-pre-wrap text-[15px] leading-relaxed"
>
{segment.text}
</div>
);
}
if (segment.type === "report-artifact") {
return (
<ReportCard

View File

@ -7,13 +7,12 @@ import {
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
import { ChatMessage } from "./chat-message";
const transport = new DefaultChatTransport({ api: "/api/chat" });
/** Imperative handle for parent-driven session control (main page). */
export type ChatPanelHandle = {
loadSession: (sessionId: string) => Promise<void>;
@ -57,8 +56,6 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
},
ref,
) {
const { messages, sendMessage, status, stop, error, setMessages } =
useChat({ transport });
const [input, setInput] = useState("");
const [currentSessionId, setCurrentSessionId] = useState<string | null>(
null,
@ -80,6 +77,37 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
);
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<string | null>(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<ChatPanelHandle, ChatPanelProps>(
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<string> => {
const body: Record<string, string> = { 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<ChatPanelHandle, ChatPanelProps>(
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<string> => {
const body: Record<string, string> = { 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<ChatPanelHandle, ChatPanelProps>(
[],
);
// 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<FileScopedSession[]>) | 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<Record<string, unknown>>;
}> = 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<ChatPanelHandle, ChatPanelProps>(
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<ChatPanelHandle, ChatPanelProps>(
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<ChatPanelHandle, ChatPanelProps>(
: 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<ChatPanelHandle, ChatPanelProps>(
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<ChatPanelHandle, ChatPanelProps>(
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<ChatPanelHandle, ChatPanelProps>(
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<ChatPanelHandle, ChatPanelProps>(
)}
</div>
{/* Error display */}
{/* Transport-level error display */}
{error && (
<div className="px-3 py-1.5 bg-red-900/20 border-t border-red-800/30 flex-shrink-0">
<p className="text-xs text-red-400">
Error: {error.message}
<div
className="px-3 py-2 border-t flex-shrink-0 flex items-center gap-2"
style={{
background:
"color-mix(in srgb, var(--color-error, #ef4444) 10%, var(--color-surface))",
borderColor:
"color-mix(in srgb, var(--color-error, #ef4444) 25%, transparent)",
color: "var(--color-error, #ef4444)",
}}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="flex-shrink-0"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<p className="text-xs">
{error.message}
</p>
</div>
)}

View File

@ -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: () => (
<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 }: 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 (
<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">
{displayTitle && (
<h1
className="text-3xl font-bold mb-6"
style={{ color: "var(--color-text)" }}
>
{displayTitle}
</h1>
)}
{/* 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} />

View File

@ -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<ReturnType<typeof setTimeout> | 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<string | null> => {
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 (
<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>
);
}
return (
<div className="markdown-editor-container">
{/* Sticky top bar: save status + save button + read toggle */}
<div className="editor-top-bar">
<div className="editor-top-bar-left">
{isDirty && (
<span className="editor-save-indicator editor-save-unsaved">
Unsaved changes
</span>
)}
{saveStatus === "saved" && !isDirty && (
<span className="editor-save-indicator editor-save-saved">
Saved
</span>
)}
{saveStatus === "error" && (
<span className="editor-save-indicator editor-save-error">
Save failed
</span>
)}
</div>
<div className="editor-top-bar-right">
<span className="editor-save-hint">
{typeof navigator !== "undefined" && navigator.platform?.includes("Mac") ? "\u2318" : "Ctrl"}+S
</span>
<button
type="button"
onClick={handleSave}
disabled={saving || !isDirty}
className="editor-save-button"
>
{saving ? "Saving..." : "Save"}
</button>
{onSwitchToRead && (
<button
type="button"
onClick={onSwitchToRead}
className="editor-mode-toggle"
title="Switch to read mode"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" />
<circle cx="12" cy="12" r="3" />
</svg>
<span>Read</span>
</button>
)}
</div>
</div>
{/* Toolbar */}
<EditorToolbar editor={editor} onUploadImages={insertUploadedImages} />
{/* Bubble menu for text selection */}
<BubbleMenu editor={editor} tippyOptions={{ duration: 100 }}>
<div className="bubble-menu">
<BubbleButton
active={editor.isActive("bold")}
onClick={() => editor.chain().focus().toggleBold().run()}
title="Bold"
>
<strong>B</strong>
</BubbleButton>
<BubbleButton
active={editor.isActive("italic")}
onClick={() => editor.chain().focus().toggleItalic().run()}
title="Italic"
>
<em>I</em>
</BubbleButton>
<BubbleButton
active={editor.isActive("strike")}
onClick={() => editor.chain().focus().toggleStrike().run()}
title="Strikethrough"
>
<s>S</s>
</BubbleButton>
<BubbleButton
active={editor.isActive("code")}
onClick={() => editor.chain().focus().toggleCode().run()}
title="Inline code"
>
{"<>"}
</BubbleButton>
<BubbleButton
active={editor.isActive("link")}
onClick={() => {
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"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
</BubbleButton>
</div>
</BubbleMenu>
{/* Editor content */}
<div className="editor-content-area workspace-prose">
<EditorContent editor={editor} />
</div>
</div>
);
}
// --- Toolbar ---
function EditorToolbar({
editor,
onUploadImages,
}: {
editor: ReturnType<typeof useEditor>;
onUploadImages?: (files: File[]) => void;
}) {
const imageInputRef = useRef<HTMLInputElement>(null);
if (!editor) {return null;}
return (
<div className="editor-toolbar">
{/* Headings */}
<ToolbarGroup>
<ToolbarButton
active={editor.isActive("heading", { level: 1 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
title="Heading 1"
>
H1
</ToolbarButton>
<ToolbarButton
active={editor.isActive("heading", { level: 2 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
title="Heading 2"
>
H2
</ToolbarButton>
<ToolbarButton
active={editor.isActive("heading", { level: 3 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
title="Heading 3"
>
H3
</ToolbarButton>
</ToolbarGroup>
<ToolbarDivider />
{/* Inline formatting */}
<ToolbarGroup>
<ToolbarButton
active={editor.isActive("bold")}
onClick={() => editor.chain().focus().toggleBold().run()}
title="Bold"
>
<strong>B</strong>
</ToolbarButton>
<ToolbarButton
active={editor.isActive("italic")}
onClick={() => editor.chain().focus().toggleItalic().run()}
title="Italic"
>
<em>I</em>
</ToolbarButton>
<ToolbarButton
active={editor.isActive("strike")}
onClick={() => editor.chain().focus().toggleStrike().run()}
title="Strikethrough"
>
<s>S</s>
</ToolbarButton>
<ToolbarButton
active={editor.isActive("code")}
onClick={() => editor.chain().focus().toggleCode().run()}
title="Inline code"
>
{"<>"}
</ToolbarButton>
</ToolbarGroup>
<ToolbarDivider />
{/* Block elements */}
<ToolbarGroup>
<ToolbarButton
active={editor.isActive("bulletList")}
onClick={() => editor.chain().focus().toggleBulletList().run()}
title="Bullet list"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="8" x2="21" y1="6" y2="6" /><line x1="8" x2="21" y1="12" y2="12" /><line x1="8" x2="21" y1="18" y2="18" />
<line x1="3" x2="3.01" y1="6" y2="6" /><line x1="3" x2="3.01" y1="12" y2="12" /><line x1="3" x2="3.01" y1="18" y2="18" />
</svg>
</ToolbarButton>
<ToolbarButton
active={editor.isActive("orderedList")}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
title="Ordered list"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="10" x2="21" y1="6" y2="6" /><line x1="10" x2="21" y1="12" y2="12" /><line x1="10" x2="21" y1="18" y2="18" />
<path d="M4 6h1v4" /><path d="M4 10h2" /><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1" />
</svg>
</ToolbarButton>
<ToolbarButton
active={editor.isActive("taskList")}
onClick={() => editor.chain().focus().toggleTaskList().run()}
title="Task list"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="5" width="6" height="6" rx="1" /><path d="m3 17 2 2 4-4" /><line x1="13" x2="21" y1="6" y2="6" /><line x1="13" x2="21" y1="18" y2="18" />
</svg>
</ToolbarButton>
<ToolbarButton
active={editor.isActive("blockquote")}
onClick={() => editor.chain().focus().toggleBlockquote().run()}
title="Blockquote"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V21z" />
<path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3z" />
</svg>
</ToolbarButton>
<ToolbarButton
active={editor.isActive("codeBlock")}
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
title="Code block"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="16 18 22 12 16 6" /><polyline points="8 6 2 12 8 18" />
</svg>
</ToolbarButton>
</ToolbarGroup>
<ToolbarDivider />
{/* Insert items */}
<ToolbarGroup>
<ToolbarButton
active={false}
onClick={() => {
const url = window.prompt("Link URL:");
if (url) {
editor.chain().focus().setLink({ href: url }).run();
}
}}
title="Insert link"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
</ToolbarButton>
<ToolbarButton
active={false}
onClick={() => {
// 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)"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
</svg>
</ToolbarButton>
{/* Hidden file input for image upload */}
<input
ref={imageInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => {
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 = "";
}}
/>
<ToolbarButton
active={false}
onClick={() => {
editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
}}
title="Insert table"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3v18" /><rect width="18" height="18" x="3" y="3" rx="2" /><path d="M3 9h18" /><path d="M3 15h18" />
</svg>
</ToolbarButton>
<ToolbarButton
active={false}
onClick={() => {
editor.chain().focus().setHorizontalRule().run();
}}
title="Horizontal rule"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="2" x2="22" y1="12" y2="12" />
</svg>
</ToolbarButton>
</ToolbarGroup>
</div>
);
}
// --- Toolbar primitives ---
function ToolbarGroup({ children }: { children: React.ReactNode }) {
return <div className="editor-toolbar-group">{children}</div>;
}
function ToolbarDivider() {
return <div className="editor-toolbar-divider" />;
}
function ToolbarButton({
active,
onClick,
title,
children,
}: {
active: boolean;
onClick: () => void;
title: string;
children: React.ReactNode;
}) {
return (
<button
type="button"
className={`editor-toolbar-btn ${active ? "editor-toolbar-btn-active" : ""}`}
onClick={onClick}
title={title}
>
{children}
</button>
);
}
// --- Bubble menu button ---
function BubbleButton({
active,
onClick,
title,
children,
}: {
active: boolean;
onClick: () => void;
title: string;
children: React.ReactNode;
}) {
return (
<button
type="button"
className={`bubble-menu-btn ${active ? "bubble-menu-btn-active" : ""}`}
onClick={onClick}
title={title}
>
{children}
</button>
);
}

View File

@ -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: () => (
<div
className="h-48 rounded-xl animate-pulse"
style={{ background: "var(--color-surface)" }}
/>
),
},
);
// --- React NodeView Component ---
function ReportBlockView({
node,
updateAttributes,
deleteNode,
selected,
}: {
node: { attrs: { config: string } };
updateAttributes: (attrs: Record<string, unknown>) => 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 (
<NodeViewWrapper
className="report-block-wrapper"
data-selected={selected || undefined}
>
{/* Overlay toolbar */}
<div className="report-block-toolbar">
<button
type="button"
onClick={() => {
if (showSource) {
handleSaveSource();
} else {
setEditValue(node.attrs.config);
setShowSource(true);
}
}}
className="report-block-btn"
title={showSource ? "Apply & show chart" : "Edit JSON source"}
>
{showSource ? (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" />
</svg>
)}
<span>{showSource ? "Apply" : "Edit JSON"}</span>
</button>
<button
type="button"
onClick={deleteNode}
className="report-block-btn report-block-btn-danger"
title="Remove report block"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
</svg>
</button>
</div>
{showSource ? (
/* JSON source editor */
<div className="report-block-source">
<div className="report-block-source-label">report-json</div>
<textarea
className="report-block-textarea"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
spellCheck={false}
rows={Math.min(20, editValue.split("\n").length + 2)}
/>
</div>
) : parseError ? (
/* Error state */
<div className="report-block-error">
<span>{parseError}</span>
<button
type="button"
onClick={() => {
setEditValue(node.attrs.config);
setShowSource(true);
}}
className="report-block-btn"
>
Fix JSON
</button>
</div>
) : (
/* Rendered chart */
<ReportCard config={parsedConfig!} />
)}
</NodeViewWrapper>
);
}
// --- Tiptap Node Extension ---
export const ReportBlockNode = Node.create({
name: "reportBlock",
group: "block",
atom: true, // not editable inline -- managed by NodeView
addAttributes() {
return {
config: {
default: "{}",
parseHTML: (element: HTMLElement) =>
element.getAttribute("data-config") || "{}",
renderHTML: (attributes: Record<string, string>) => ({
"data-config": attributes.config,
}),
},
};
},
parseHTML() {
return [
{
tag: 'div[data-type="report-block"]',
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes(HTMLAttributes, { "data-type": "report-block" }),
];
},
addNodeView() {
return ReactNodeViewRenderer(ReportBlockView);
},
});
/**
* Pre-process markdown before Tiptap parses it:
* Convert ```report-json ... ``` fenced blocks into HTML that Tiptap can parse
* as ReportBlock nodes.
*/
export function preprocessReportBlocks(markdown: string): string {
return markdown.replace(
/```report-json\s*\n([\s\S]*?)```/g,
(_match, json: string) => {
const escaped = json
.trim()
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
return `<div data-type="report-block" data-config="${escaped}"></div>`;
},
);
}
/**
* Post-process HTML before serializing to markdown:
* Convert ReportBlock HTML back to ```report-json``` fenced blocks.
*/
export function postprocessReportBlocks(markdown: string): string {
return markdown.replace(
/<div data-type="report-block" data-config="([^"]*)">\s*<\/div>/g,
(_match, escaped: string) => {
const json = escaped
.replace(/&gt;/g, ">")
.replace(/&lt;/g, "<")
.replace(/&quot;/g, '"')
.replace(/&amp;/g, "&");
return "```report-json\n" + json + "\n```";
},
);
}

View File

@ -0,0 +1,607 @@
"use client";
import {
useState,
useEffect,
useRef,
useCallback,
useLayoutEffect,
forwardRef,
useImperativeHandle,
} from "react";
import { Extension } from "@tiptap/core";
import Suggestion, { type SuggestionOptions } from "@tiptap/suggestion";
import { PluginKey } from "@tiptap/pm/state";
import { createPortal } from "react-dom";
import type { Editor, Range } from "@tiptap/core";
// Unique plugin keys so both suggestions can coexist
const slashCommandPluginKey = new PluginKey("slashCommand");
const fileMentionPluginKey = new PluginKey("fileMention");
// --- Types ---
export type TreeNode = {
name: string;
path: string;
type: "object" | "document" | "folder" | "file" | "database" | "report";
icon?: string;
children?: TreeNode[];
};
type SlashItem = {
title: string;
description?: string;
icon: React.ReactNode;
category: "file" | "block";
command: (props: { editor: Editor; range: Range }) => void;
};
// --- Helpers ---
function flattenTree(nodes: TreeNode[]): TreeNode[] {
const result: TreeNode[] = [];
for (const node of nodes) {
if (node.type !== "folder") {
result.push(node);
}
if (node.children) {
result.push(...flattenTree(node.children));
}
}
return result;
}
function nodeTypeIcon(type: string) {
switch (type) {
case "document":
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
</svg>
);
case "object":
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3v18" /><rect width="18" height="18" x="3" y="3" rx="2" /><path d="M3 9h18" /><path d="M3 15h18" />
</svg>
);
case "report":
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" x2="12" y1="20" y2="10" />
<line x1="18" x2="18" y1="20" y2="4" />
<line x1="6" x2="6" y1="20" y2="14" />
</svg>
);
default:
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
</svg>
);
}
}
// --- Block command icons ---
const headingIcon = (level: number) => (
<span className="slash-cmd-icon-text">H{level}</span>
);
const bulletListIcon = (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="8" x2="21" y1="6" y2="6" /><line x1="8" x2="21" y1="12" y2="12" /><line x1="8" x2="21" y1="18" y2="18" />
<line x1="3" x2="3.01" y1="6" y2="6" /><line x1="3" x2="3.01" y1="12" y2="12" /><line x1="3" x2="3.01" y1="18" y2="18" />
</svg>
);
const orderedListIcon = (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="10" x2="21" y1="6" y2="6" /><line x1="10" x2="21" y1="12" y2="12" /><line x1="10" x2="21" y1="18" y2="18" />
<path d="M4 6h1v4" /><path d="M4 10h2" /><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1" />
</svg>
);
const blockquoteIcon = (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V21z" />
<path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3z" />
</svg>
);
const codeBlockIcon = (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="16 18 22 12 16 6" /><polyline points="8 6 2 12 8 18" />
</svg>
);
const horizontalRuleIcon = (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="2" x2="22" y1="12" y2="12" />
</svg>
);
const imageIcon = (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
</svg>
);
const tableIcon = (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3v18" /><rect width="18" height="18" x="3" y="3" rx="2" /><path d="M3 9h18" /><path d="M3 15h18" />
</svg>
);
const taskListIcon = (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="5" width="6" height="6" rx="1" /><path d="m3 17 2 2 4-4" /><line x1="13" x2="21" y1="6" y2="6" /><line x1="13" x2="21" y1="18" y2="18" />
</svg>
);
const reportIcon = (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" x2="12" y1="20" y2="10" />
<line x1="18" x2="18" y1="20" y2="4" />
<line x1="6" x2="6" y1="20" y2="14" />
</svg>
);
// --- Build items ---
function buildBlockCommands(): SlashItem[] {
return [
{
title: "Heading 1",
description: "Large section heading",
icon: headingIcon(1),
category: "block",
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run();
},
},
{
title: "Heading 2",
description: "Medium section heading",
icon: headingIcon(2),
category: "block",
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run();
},
},
{
title: "Heading 3",
description: "Small section heading",
icon: headingIcon(3),
category: "block",
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run();
},
},
{
title: "Bullet List",
description: "Unordered list",
icon: bulletListIcon,
category: "block",
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleBulletList().run();
},
},
{
title: "Numbered List",
description: "Ordered list",
icon: orderedListIcon,
category: "block",
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
},
},
{
title: "Task List",
description: "Checklist with checkboxes",
icon: taskListIcon,
category: "block",
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleTaskList().run();
},
},
{
title: "Blockquote",
description: "Quote block",
icon: blockquoteIcon,
category: "block",
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleBlockquote().run();
},
},
{
title: "Code Block",
description: "Fenced code block",
icon: codeBlockIcon,
category: "block",
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
},
},
{
title: "Horizontal Rule",
description: "Divider line",
icon: horizontalRuleIcon,
category: "block",
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
},
},
{
title: "Image",
description: "Insert image from URL",
icon: imageIcon,
category: "block",
command: ({ editor, range }) => {
const url = window.prompt("Image URL:");
if (url) {
editor.chain().focus().deleteRange(range).setImage({ src: url }).run();
}
},
},
{
title: "Table",
description: "Insert a 3x3 table",
icon: tableIcon,
category: "block",
command: ({ editor, range }) => {
editor
.chain()
.focus()
.deleteRange(range)
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
.run();
},
},
{
title: "Report Chart",
description: "Interactive report-json block",
icon: reportIcon,
category: "block",
command: ({ editor, range }) => {
const template = JSON.stringify(
{
version: 1,
title: "New Report",
panels: [
{
id: "panel-1",
title: "Chart",
type: "bar",
sql: "SELECT 1 as x, 10 as y",
mapping: { xAxis: "x", yAxis: ["y"] },
},
],
},
null,
2,
);
editor
.chain()
.focus()
.deleteRange(range)
.insertContent({
type: "reportBlock",
attrs: { config: template },
})
.run();
},
},
];
}
function buildFileItems(tree: TreeNode[]): SlashItem[] {
const flatFiles = flattenTree(tree);
return flatFiles.map((node) => ({
title: node.name.replace(/\.md$/, ""),
description: node.path,
icon: nodeTypeIcon(node.type),
category: "file" as const,
command: ({ editor, range }: { editor: Editor; range: Range }) => {
const label = node.name.replace(/\.md$/, "");
// Insert as structured content so the link mark is applied properly
// (raw HTML strings get escaped by the Markdown extension)
editor
.chain()
.focus()
.deleteRange(range)
.insertContent({
type: "text",
text: label,
marks: [
{
type: "link",
attrs: { href: node.path, target: null },
},
],
})
.run();
},
}));
}
// --- Popup Component ---
type CommandListRef = {
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
};
type CommandListProps = {
items: SlashItem[];
command: (item: SlashItem) => void;
};
const CommandList = forwardRef<CommandListRef, CommandListProps>(
({ items, command }, ref) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const listRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setSelectedIndex(0);
}, [items]);
// Scroll selected into view
useEffect(() => {
const el = listRef.current?.children[selectedIndex] as HTMLElement | undefined;
el?.scrollIntoView({ block: "nearest" });
}, [selectedIndex]);
const selectItem = useCallback(
(index: number) => {
const item = items[index];
if (item) {
command(item);
}
},
[items, command],
);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
if (event.key === "ArrowUp") {
setSelectedIndex((i) => (i + items.length - 1) % items.length);
return true;
}
if (event.key === "ArrowDown") {
setSelectedIndex((i) => (i + 1) % items.length);
return true;
}
if (event.key === "Enter") {
selectItem(selectedIndex);
return true;
}
return false;
},
}));
if (items.length === 0) {
return (
<div className="slash-cmd-popup">
<div className="slash-cmd-empty">No results</div>
</div>
);
}
return (
<div className="slash-cmd-popup" ref={listRef}>
{items.map((item, index) => (
<button
type="button"
key={`${item.category}-${item.title}`}
className={`slash-cmd-item ${index === selectedIndex ? "slash-cmd-item-active" : ""}`}
onClick={() => selectItem(index)}
onMouseEnter={() => setSelectedIndex(index)}
>
<span className="slash-cmd-item-icon">{item.icon}</span>
<span className="slash-cmd-item-body">
<span className="slash-cmd-item-title">{item.title}</span>
{item.description && (
<span className="slash-cmd-item-desc">{item.description}</span>
)}
</span>
</button>
))}
</div>
);
},
);
CommandList.displayName = "CommandList";
// --- Floating wrapper that renders into a portal ---
function SlashPopupRenderer({
items,
command,
clientRect,
componentRef,
}: {
items: SlashItem[];
command: (item: SlashItem) => void;
clientRect: (() => DOMRect | null) | null;
componentRef: React.RefObject<CommandListRef | null>;
}) {
const popupRef = useRef<HTMLDivElement>(null);
// Position popup near the cursor
useLayoutEffect(() => {
if (!popupRef.current || !clientRect) {return;}
const rect = clientRect();
if (!rect) {return;}
const el = popupRef.current;
el.style.position = "fixed";
el.style.left = `${rect.left}px`;
el.style.top = `${rect.bottom + 4}px`;
el.style.zIndex = "50";
}, [clientRect, items]);
return createPortal(
<div ref={popupRef}>
<CommandList ref={componentRef} items={items} command={command} />
</div>,
document.body,
);
}
// --- Shared suggestion render factory ---
function createSuggestionRenderer() {
return () => {
let container: HTMLDivElement | null = null;
let root: ReturnType<typeof import("react-dom/client").createRoot> | null = null;
const componentRef: React.RefObject<CommandListRef | null> = { current: null };
return {
onStart: (props: {
items: SlashItem[];
command: (item: SlashItem) => void;
clientRect: (() => DOMRect | null) | null;
}) => {
container = document.createElement("div");
document.body.appendChild(container);
import("react-dom/client").then(({ createRoot }) => {
root = createRoot(container!);
root.render(
<SlashPopupRenderer
items={props.items}
command={props.command}
clientRect={props.clientRect}
componentRef={componentRef}
/>,
);
});
},
onUpdate: (props: {
items: SlashItem[];
command: (item: SlashItem) => void;
clientRect: (() => DOMRect | null) | null;
}) => {
root?.render(
<SlashPopupRenderer
items={props.items}
command={props.command}
clientRect={props.clientRect}
componentRef={componentRef}
/>,
);
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
root?.unmount();
container?.remove();
container = null;
root = null;
return true;
}
return componentRef.current?.onKeyDown(props) ?? false;
},
onExit: () => {
root?.unmount();
container?.remove();
container = null;
root = null;
},
};
};
}
// --- Extension factories ---
/**
* "/" slash command -- markdown block commands only (headings, lists, code, etc.)
*/
export function createSlashCommand() {
const blockCommands = buildBlockCommands();
return Extension.create({
name: "slashCommand",
addOptions() {
return {
suggestion: {
char: "/",
pluginKey: slashCommandPluginKey,
startOfLine: false,
command: ({ editor, range, props: item }: { editor: Editor; range: Range; props: SlashItem }) => {
item.command({ editor, range });
},
items: ({ query }: { query: string }) => {
const q = query.toLowerCase();
if (!q) {return blockCommands;}
return blockCommands.filter(
(item) =>
item.title.toLowerCase().includes(q) ||
(item.description?.toLowerCase().includes(q) ?? false),
);
},
render: createSuggestionRenderer(),
} satisfies Partial<SuggestionOptions<SlashItem>>,
};
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion,
}),
];
},
});
}
/**
* "@" mention command -- workspace file cross-linking
*/
export function createFileMention(tree: TreeNode[]) {
const fileItems = buildFileItems(tree);
return Extension.create({
name: "fileMention",
addOptions() {
return {
suggestion: {
char: "@",
pluginKey: fileMentionPluginKey,
startOfLine: false,
command: ({ editor, range, props: item }: { editor: Editor; range: Range; props: SlashItem }) => {
item.command({ editor, range });
},
items: ({ query }: { query: string }) => {
const q = query.toLowerCase();
if (!q) {return fileItems.slice(0, 15);}
return fileItems
.filter(
(item) =>
item.title.toLowerCase().includes(q) ||
(item.description?.toLowerCase().includes(q) ?? false),
)
.slice(0, 15);
},
render: createSuggestionRenderer(),
} satisfies Partial<SuggestionOptions<SlashItem>>,
};
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion,
}),
];
},
});
}

View File

@ -222,3 +222,497 @@ body {
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
/* ========================================
Tiptap Markdown Editor
======================================== */
/* Editor container layout */
.markdown-editor-container {
display: flex;
flex-direction: column;
min-height: 0;
}
/* Tiptap contenteditable area -- inherits workspace-prose via parent */
.editor-content-area {
flex: 1;
padding: 1rem 1.5rem 2rem;
min-height: 300px;
}
.editor-content-area .tiptap {
outline: none;
min-height: 200px;
}
.editor-content-area .tiptap:focus {
outline: none;
}
/* Placeholder */
.editor-content-area .tiptap p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: var(--color-text-muted);
opacity: 0.5;
pointer-events: none;
height: 0;
}
/* Tiptap task list (editable checkboxes) */
.editor-content-area .tiptap ul[data-type="taskList"] {
list-style: none;
padding-left: 0;
}
.editor-content-area .tiptap ul[data-type="taskList"] li {
display: flex;
align-items: flex-start;
gap: 0.5em;
margin-bottom: 0.25em;
}
.editor-content-area .tiptap ul[data-type="taskList"] li label {
flex-shrink: 0;
margin-top: 0.25em;
}
.editor-content-area .tiptap ul[data-type="taskList"] li label input[type="checkbox"] {
appearance: none;
width: 1em;
height: 1em;
border: 1.5px solid var(--color-border);
border-radius: 0.2em;
cursor: pointer;
position: relative;
}
.editor-content-area .tiptap ul[data-type="taskList"] li label input[type="checkbox"]:checked {
background: var(--color-accent);
border-color: var(--color-accent);
}
.editor-content-area .tiptap ul[data-type="taskList"] li label input[type="checkbox"]:checked::after {
content: "";
position: absolute;
left: 3px;
top: 1px;
width: 4px;
height: 8px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.editor-content-area .tiptap ul[data-type="taskList"] li > div {
flex: 1;
}
/* Images in editor */
.editor-content-area .tiptap .editor-image {
max-width: 100%;
height: auto;
border-radius: 0.5rem;
margin: 1em 0;
cursor: default;
}
.editor-content-area .tiptap .editor-image.ProseMirror-selectednode {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
border-radius: 0.5rem;
}
/* Table editing */
.editor-content-area .tiptap table {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
}
.editor-content-area .tiptap th,
.editor-content-area .tiptap td {
border: 1px solid var(--color-border);
padding: 0.5em 0.75em;
position: relative;
}
.editor-content-area .tiptap th {
background: var(--color-surface);
font-weight: 600;
}
.editor-content-area .tiptap .selectedCell {
background: rgba(232, 93, 58, 0.08);
}
/* --- Toolbar --- */
.editor-toolbar {
display: flex;
align-items: center;
gap: 2px;
padding: 0.375rem 1.5rem;
border-bottom: 1px solid var(--color-border);
background: var(--color-bg);
flex-shrink: 0;
flex-wrap: wrap;
}
.editor-toolbar-group {
display: flex;
align-items: center;
gap: 1px;
}
.editor-toolbar-divider {
width: 1px;
height: 1.25rem;
background: var(--color-border);
margin: 0 0.375rem;
}
.editor-toolbar-btn {
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 0.25rem;
border: none;
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
font-size: 0.75rem;
font-weight: 500;
transition: all 0.1s;
}
.editor-toolbar-btn:hover {
background: var(--color-surface-hover);
color: var(--color-text);
}
.editor-toolbar-btn-active {
background: rgba(232, 93, 58, 0.12);
color: var(--color-accent);
}
.editor-toolbar-btn-active:hover {
background: rgba(232, 93, 58, 0.18);
}
/* --- Bubble menu --- */
.bubble-menu {
display: flex;
align-items: center;
gap: 1px;
padding: 0.25rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.bubble-menu-btn {
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 0.25rem;
border: none;
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
font-size: 0.8rem;
transition: all 0.1s;
}
.bubble-menu-btn:hover {
background: var(--color-surface-hover);
color: var(--color-text);
}
.bubble-menu-btn-active {
color: var(--color-accent);
background: rgba(232, 93, 58, 0.12);
}
/* --- Sticky top bar (save + read toggle) --- */
.editor-top-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1.5rem;
border-bottom: 1px solid var(--color-border);
background: var(--color-bg);
flex-shrink: 0;
position: sticky;
top: 0;
z-index: 20;
}
.editor-top-bar-left {
display: flex;
align-items: center;
gap: 0.5rem;
}
.editor-top-bar-right {
display: flex;
align-items: center;
gap: 0.75rem;
}
.editor-save-indicator {
font-size: 0.75rem;
}
.editor-save-unsaved {
color: #f59e0b;
}
.editor-save-saved {
color: #22c55e;
}
.editor-save-error {
color: #f87171;
}
.editor-save-hint {
font-size: 0.7rem;
color: var(--color-text-muted);
opacity: 0.6;
padding: 0.15rem 0.4rem;
background: var(--color-surface);
border-radius: 0.25rem;
border: 1px solid var(--color-border);
}
.editor-save-button {
padding: 0.35rem 1rem;
font-size: 0.8rem;
font-weight: 500;
border-radius: 0.375rem;
border: none;
background: var(--color-accent);
color: white;
cursor: pointer;
transition: all 0.15s;
}
.editor-save-button:hover:not(:disabled) {
background: var(--color-accent-hover);
}
.editor-save-button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* --- Edit / Read mode toggle --- */
.editor-mode-toggle {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.3rem 0.6rem;
font-size: 0.75rem;
font-weight: 500;
border-radius: 0.375rem;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text-muted);
cursor: pointer;
transition: all 0.15s;
}
.editor-mode-toggle:hover {
background: var(--color-surface-hover);
color: var(--color-text);
border-color: var(--color-text-muted);
}
/* ========================================
Slash Command Popup
======================================== */
.slash-cmd-popup {
max-height: 320px;
overflow-y: auto;
min-width: 240px;
max-width: 320px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
padding: 0.25rem;
}
.slash-cmd-empty {
padding: 0.75rem 1rem;
font-size: 0.8rem;
color: var(--color-text-muted);
text-align: center;
}
.slash-cmd-item {
display: flex;
align-items: center;
gap: 0.625rem;
width: 100%;
padding: 0.5rem 0.625rem;
border: none;
background: transparent;
border-radius: 0.375rem;
cursor: pointer;
text-align: left;
transition: background 0.1s;
}
.slash-cmd-item:hover,
.slash-cmd-item-active {
background: var(--color-surface-hover);
}
.slash-cmd-item-icon {
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 0.25rem;
background: var(--color-bg);
border: 1px solid var(--color-border);
color: var(--color-text-muted);
flex-shrink: 0;
}
.slash-cmd-icon-text {
font-size: 0.65rem;
font-weight: 700;
color: var(--color-text-muted);
}
.slash-cmd-item-body {
display: flex;
flex-direction: column;
min-width: 0;
}
.slash-cmd-item-title {
font-size: 0.8rem;
font-weight: 500;
color: var(--color-text);
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.slash-cmd-item-desc {
font-size: 0.7rem;
color: var(--color-text-muted);
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ========================================
Report Block (in-editor)
======================================== */
.report-block-wrapper {
position: relative;
margin: 1em 0;
border-radius: 0.75rem;
border: 1px solid var(--color-border);
overflow: hidden;
}
.report-block-wrapper[data-selected] {
border-color: var(--color-accent);
box-shadow: 0 0 0 1px var(--color-accent);
}
.report-block-toolbar {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.375rem 0.5rem;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
}
.report-block-btn {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.2rem 0.5rem;
font-size: 0.7rem;
font-weight: 500;
border-radius: 0.25rem;
border: 1px solid var(--color-border);
background: var(--color-bg);
color: var(--color-text-muted);
cursor: pointer;
transition: all 0.1s;
}
.report-block-btn:hover {
background: var(--color-surface-hover);
color: var(--color-text);
}
.report-block-btn-danger:hover {
background: rgba(248, 113, 113, 0.1);
color: #f87171;
border-color: rgba(248, 113, 113, 0.3);
}
.report-block-source {
position: relative;
}
.report-block-source-label {
position: absolute;
top: 0.375rem;
right: 0.5rem;
font-size: 0.6rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
opacity: 0.5;
pointer-events: none;
}
.report-block-textarea {
width: 100%;
padding: 1rem;
background: var(--color-bg);
color: var(--color-text);
border: none;
outline: none;
font-family: "SF Mono", "Fira Code", "JetBrains Mono", monospace;
font-size: 0.8rem;
line-height: 1.5;
resize: vertical;
min-height: 100px;
}
.report-block-error {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
background: rgba(248, 113, 113, 0.05);
color: #f87171;
font-size: 0.8rem;
}

View File

@ -328,10 +328,12 @@ export default function WorkspacePage() {
content={content}
workspaceExists={workspaceExists}
tree={tree}
activePath={activePath}
members={context?.members}
onNodeSelect={handleNodeSelect}
onNavigateToObject={handleNavigateToObject}
onRefreshObject={refreshCurrentObject}
onRefreshTree={refreshTree}
/>
</div>
@ -364,18 +366,22 @@ function ContentRenderer({
content,
workspaceExists,
tree,
activePath,
members,
onNodeSelect,
onNavigateToObject,
onRefreshObject,
onRefreshTree,
}: {
content: ContentState;
workspaceExists: boolean;
tree: TreeNode[];
activePath: string | null;
members?: Array<{ id: string; name: string; email: string; role: string }>;
onNodeSelect: (node: TreeNode) => void;
onNavigateToObject: (objectName: string) => void;
onRefreshObject: () => void;
onRefreshTree: () => void;
}) {
switch (content.kind) {
case "loading":
@ -406,6 +412,16 @@ function ContentRenderer({
<DocumentView
content={content.data.content}
title={content.title}
filePath={activePath ?? undefined}
tree={tree}
onSave={onRefreshTree}
onNavigate={(path) => {
// Find the node in the tree and navigate to it
const node = findNode(tree, path);
if (node) {
onNodeSelect(node);
}
}}
/>
);

View File

@ -40,6 +40,8 @@ export type AgentCallback = {
onLifecycleEnd: () => void;
onError: (error: Error) => void;
onClose: (code: number | null) => void;
/** Called when the agent encounters an API or runtime error (402, rate limit, etc.) */
onAgentError?: (message: string) => void;
};
/**
@ -50,7 +52,7 @@ export type AgentCallback = {
function extractToolResult(
raw: unknown,
): ToolResult | undefined {
if (!raw || typeof raw !== "object") return undefined;
if (!raw || typeof raw !== "object") {return undefined;}
const r = raw as Record<string, unknown>;
// Extract text from content blocks
@ -76,14 +78,24 @@ function extractToolResult(
return { text, details };
}
export type RunAgentOptions = {
/** When set, the agent runs in an isolated session (e.g. file-scoped subagent). */
sessionId?: string;
};
/**
* Spawn the openclaw agent and stream its output.
* Pass an AbortSignal to kill the child process when the caller cancels.
*
* When `options.sessionId` is set the child process gets `--session-id <id>`,
* which creates an isolated agent session that won't interfere with the main
* agent or other sidebar chats.
*/
export async function runAgent(
message: string,
signal: AbortSignal | undefined,
callback: AgentCallback,
options?: RunAgentOptions,
): Promise<void> {
// Get repo root - construct path dynamically at runtime
const cwd = process.cwd();
@ -96,21 +108,32 @@ export async function runAgent(
const scriptPath = join(root, ...pathParts);
return new Promise<void>((resolve) => {
const args = [
scriptPath,
"agent",
"--agent",
"main",
"--message",
message,
"--stream-json",
// Run embedded (--local) so we get ALL events (tool, thinking,
// lifecycle) unfiltered. The gateway path drops tool events
// unless verbose is explicitly "on".
"--local",
];
// Isolated session for file-scoped subagent chats.
// Uses a proper subagent session key (agent:main:subagent:<id>) so the
// agent runs in the Subagent concurrency lane with its own session
// context, completely independent of the main agent session.
if (options?.sessionId) {
const sessionKey = `agent:main:subagent:${options.sessionId}`;
args.push("--session-key", sessionKey, "--lane", "subagent");
}
const child = spawn(
"node",
[
scriptPath,
"agent",
"--agent",
"main",
"--message",
message,
"--stream-json",
// Run embedded (--local) so we get ALL events (tool, thinking,
// lifecycle) unfiltered. The gateway path drops tool events
// unless verbose is explicitly "on".
"--local",
],
args,
{
cwd: root,
env: { ...process.env },
@ -131,10 +154,14 @@ export async function runAgent(
}
}
// Collect stderr so we can surface errors to the UI
const stderrChunks: string[] = [];
let agentErrorReported = false;
const rl = createInterface({ input: child.stdout });
rl.on("line", (line: string) => {
if (!line.trim()) return;
if (!line.trim()) {return;}
let event: AgentEvent;
try {
@ -203,9 +230,58 @@ export async function runAgent(
) {
callback.onLifecycleEnd();
}
// ── Surface agent-level errors (API 402, rate limits, etc.) ──
// Lifecycle error phase
if (
event.event === "agent" &&
event.stream === "lifecycle" &&
event.data?.phase === "error"
) {
const msg = parseAgentErrorMessage(event.data);
if (msg && !agentErrorReported) {
agentErrorReported = true;
callback.onAgentError?.(msg);
}
}
// Top-level error events
if (event.event === "error") {
const msg = parseAgentErrorMessage(event.data ?? event);
if (msg && !agentErrorReported) {
agentErrorReported = true;
callback.onAgentError?.(msg);
}
}
// Messages with stopReason "error" (some agents inline errors this way)
if (
event.event === "agent" &&
event.stream === "assistant" &&
typeof event.data?.stopReason === "string" &&
event.data.stopReason === "error" &&
typeof event.data?.errorMessage === "string"
) {
if (!agentErrorReported) {
agentErrorReported = true;
callback.onAgentError?.(
parseErrorBody(event.data.errorMessage as string),
);
}
}
});
child.on("close", (code) => {
// If no error was reported yet, check stderr for useful info
if (!agentErrorReported && stderrChunks.length > 0) {
const stderr = stderrChunks.join("").trim();
const msg = parseErrorFromStderr(stderr);
if (msg) {
agentErrorReported = true;
callback.onAgentError?.(msg);
}
}
callback.onClose(code);
resolve();
});
@ -215,9 +291,92 @@ export async function runAgent(
resolve();
});
// Log stderr for debugging
// Capture stderr for debugging + error surfacing
child.stderr?.on("data", (chunk: Buffer) => {
console.error("[openclaw stderr]", chunk.toString());
const text = chunk.toString();
stderrChunks.push(text);
console.error("[openclaw stderr]", text);
});
});
}
// ── Error message extraction helpers ──
/**
* Extract a user-friendly error message from an agent event's data object.
* Handles various shapes: `{ error: "..." }`, `{ message: "..." }`,
* `{ errorMessage: "402 {...}" }`, etc.
*/
function parseAgentErrorMessage(
data: Record<string, unknown> | undefined,
): string | undefined {
if (!data) {return undefined;}
// Direct error string
if (typeof data.error === "string") {return parseErrorBody(data.error);}
// Message field
if (typeof data.message === "string") {return parseErrorBody(data.message);}
// errorMessage field (may contain "402 {json}")
if (typeof data.errorMessage === "string")
{return parseErrorBody(data.errorMessage);}
return undefined;
}
/**
* Parse a raw error string that may contain an HTTP status + JSON body,
* e.g. `402 {"error":{"message":"Insufficient funds..."}}`.
* Returns a clean, user-readable message.
*/
function parseErrorBody(raw: string): string {
// Try to extract JSON body from "STATUS {json}" pattern
const jsonIdx = raw.indexOf("{");
if (jsonIdx >= 0) {
try {
const parsed = JSON.parse(raw.slice(jsonIdx));
const msg =
parsed?.error?.message ?? parsed?.message ?? parsed?.error;
if (typeof msg === "string") {return msg;}
} catch {
// not valid JSON, fall through
}
}
return raw;
}
/**
* Extract a meaningful error message from raw stderr output.
* Strips ANSI codes and looks for common error patterns.
*/
function parseErrorFromStderr(stderr: string): string | undefined {
if (!stderr) {return undefined;}
// Strip ANSI escape codes
const clean = stderr.replace(
/\x1B\[[0-9;]*[A-Za-z]/g,
"",
);
// Look for JSON error bodies (e.g. from API responses)
const jsonMatch = clean.match(/\{"error":\{[^}]*"message":"([^"]+)"[^}]*\}/);
if (jsonMatch?.[1]) {return jsonMatch[1];}
// Look for lines containing "error" (case-insensitive)
const lines = clean.split("\n").filter(Boolean);
for (const line of lines) {
const trimmed = line.trim();
if (/\b(error|failed|fatal)\b/i.test(trimmed)) {
// Strip common prefixes like "[openclaw]", timestamps, etc.
const stripped = trimmed
.replace(/^\[.*?\]\s*/, "")
.replace(/^Error:\s*/i, "");
if (stripped.length > 5) {return stripped;}
}
}
// Last resort: return last non-empty line if it's short enough
const last = lines[lines.length - 1]?.trim();
if (last && last.length <= 300) {return last;}
return undefined;
}

View File

@ -1,6 +1,6 @@
import { existsSync, readFileSync } from "node:fs";
import { execSync } from "node:child_process";
import { join, resolve, normalize } from "node:path";
import { join, resolve, normalize, relative } from "node:path";
import { homedir } from "node:os";
/**
@ -22,6 +22,25 @@ export function resolveDenchRoot(): string | null {
return null;
}
/**
* Return the workspace path prefix relative to the repo root (agent's cwd).
* Tree paths are relative to the dench workspace root (e.g. "knowledge/leads/foo.md"),
* but the agent runs from the repo root, so it needs "dench/knowledge/leads/foo.md".
* Returns e.g. "dench", or null if the workspace isn't found.
*/
export function resolveAgentWorkspacePrefix(): string | null {
const root = resolveDenchRoot();
if (!root) {return null;}
const cwd = process.cwd();
const repoRoot = cwd.endsWith(join("apps", "web"))
? resolve(cwd, "..", "..")
: cwd;
const rel = relative(repoRoot, root);
return rel || null;
}
/** Path to the DuckDB database file, or null if workspace doesn't exist. */
export function duckdbPath(): string | null {
const root = resolveDenchRoot();

File diff suppressed because it is too large Load Diff

View File

@ -14,6 +14,20 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tiptap/extension-image": "^3.19.0",
"@tiptap/extension-link": "^3.19.0",
"@tiptap/extension-placeholder": "^3.19.0",
"@tiptap/extension-table": "^3.19.0",
"@tiptap/extension-table-cell": "^3.19.0",
"@tiptap/extension-table-header": "^3.19.0",
"@tiptap/extension-table-row": "^3.19.0",
"@tiptap/extension-task-item": "^3.19.0",
"@tiptap/extension-task-list": "^3.19.0",
"@tiptap/markdown": "^3.19.0",
"@tiptap/pm": "^3.19.0",
"@tiptap/react": "^3.19.0",
"@tiptap/starter-kit": "^3.19.0",
"@tiptap/suggestion": "^3.19.0",
"ai": "^6.0.73",
"next": "^15.3.3",
"react": "^19.1.0",

File diff suppressed because one or more lines are too long

View File

@ -24,7 +24,9 @@ export function registerAgentCommands(program: Command, args: { agentChannelOpti
.requiredOption("-m, --message <text>", "Message body for the agent")
.option("-t, --to <number>", "Recipient number in E.164 used to derive the session key")
.option("--session-id <id>", "Use an explicit session id")
.option("--session-key <key>", "Explicit session key (e.g. agent:main:subagent:uuid)")
.option("--agent <id>", "Agent id (overrides routing bindings)")
.option("--lane <lane>", "Concurrency lane: main | subagent | cron | nested")
.option("--thinking <level>", "Thinking level: off | minimal | low | medium | high")
.option("--verbose <on|off>", "Persist agent verbose level for the session")
.option(

View File

@ -41,6 +41,7 @@ export type AgentCliOpts = {
agent?: string;
to?: string;
sessionId?: string;
sessionKey?: string;
thinking?: string;
verbose?: string;
json?: boolean;
@ -95,8 +96,10 @@ export async function agentViaGatewayCommand(opts: AgentCliOpts, runtime: Runtim
if (!body) {
throw new Error("Message (--message) is required");
}
if (!opts.to && !opts.sessionId && !opts.agent) {
throw new Error("Pass --to <E.164>, --session-id, or --agent to choose a session");
if (!opts.to && !opts.sessionId && !opts.sessionKey && !opts.agent) {
throw new Error(
"Pass --to <E.164>, --session-id, --session-key, or --agent to choose a session",
);
}
const cfg = loadConfig();
@ -118,6 +121,7 @@ export async function agentViaGatewayCommand(opts: AgentCliOpts, runtime: Runtim
agentId,
to: opts.to,
sessionId: opts.sessionId,
sessionKey: opts.sessionKey,
}).sessionKey;
const channel = normalizeMessageChannel(opts.channel) ?? DEFAULT_CHAT_CHANNEL;
@ -188,8 +192,10 @@ async function agentViaGatewayStreamJson(opts: AgentCliOpts, _runtime: RuntimeEn
if (!body) {
throw new Error("Message (--message) is required");
}
if (!opts.to && !opts.sessionId && !opts.agent) {
throw new Error("Pass --to <E.164>, --session-id, or --agent to choose a session");
if (!opts.to && !opts.sessionId && !opts.sessionKey && !opts.agent) {
throw new Error(
"Pass --to <E.164>, --session-id, --session-key, or --agent to choose a session",
);
}
const cfg = loadConfig();
@ -211,6 +217,7 @@ async function agentViaGatewayStreamJson(opts: AgentCliOpts, _runtime: RuntimeEn
agentId,
to: opts.to,
sessionId: opts.sessionId,
sessionKey: opts.sessionKey,
}).sessionKey;
const channel = normalizeMessageChannel(opts.channel) ?? DEFAULT_CHAT_CHANNEL;