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:
parent
6d8623b00f
commit
624dc6b91e
@ -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({
|
||||
|
||||
57
apps/web/app/api/workspace/assets/[...path]/route.ts
Normal file
57
apps/web/app/api/workspace/assets/[...path]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
86
apps/web/app/api/workspace/upload/route.ts
Normal file
86
apps/web/app/api/workspace/upload/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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} />
|
||||
|
||||
709
apps/web/app/components/workspace/markdown-editor.tsx
Normal file
709
apps/web/app/components/workspace/markdown-editor.tsx
Normal 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  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>
|
||||
);
|
||||
}
|
||||
223
apps/web/app/components/workspace/report-block-node.tsx
Normal file
223
apps/web/app/components/workspace/report-block-node.tsx
Normal 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, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
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(/>/g, ">")
|
||||
.replace(/</g, "<")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/&/g, "&");
|
||||
return "```report-json\n" + json + "\n```";
|
||||
},
|
||||
);
|
||||
}
|
||||
607
apps/web/app/components/workspace/slash-command.tsx
Normal file
607
apps/web/app/components/workspace/slash-command.tsx
Normal 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,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
1084
apps/web/package-lock.json
generated
1084
apps/web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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
@ -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(
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user