kumarabhirup 624dc6b91e
Dench workspace: Tiptap markdown editor, subagent sessions, and error surfacing
── Tiptap Markdown Editor ──

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

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

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

── Subagent Session Isolation ──

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

── Error Surfacing ──

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

311 lines
7.9 KiB
TypeScript

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";
// Allow streaming responses up to 10 minutes
export const maxDuration = 600;
/**
* Build a flat output object from the agent's tool result so the frontend
* can render tool output text, exit codes, etc.
*/
function buildToolOutput(
result?: ToolResult,
): Record<string, unknown> {
if (!result) {return {};}
const out: Record<string, unknown> = {};
if (result.text) {out.text = result.text;}
if (result.details) {
// Forward useful details (exit code, duration, status, cwd)
for (const key of [
"exitCode",
"status",
"durationMs",
"cwd",
"error",
"reason",
]) {
if (result.details[key] !== undefined)
{out[key] = result.details[key];}
}
}
return out;
}
export async function POST(req: Request) {
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();
const userText =
lastUserMessage?.parts
?.filter(
(p): p is { type: "text"; text: string } => p.type === "text",
)
.map((p) => p.text)
.join("\n") ?? "";
if (!userText.trim()) {
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();
let closed = false;
const abortController = new AbortController();
const stream = new ReadableStream({
async start(controller) {
// Use incrementing IDs so multi-round reasoning/text cycles get
// unique part IDs (avoids conflicts in the AI SDK transport).
let idCounter = 0;
const nextId = (prefix: string) =>
`${prefix}-${Date.now()}-${++idCounter}`;
let currentTextId = "";
let currentReasoningId = "";
let textStarted = false;
let reasoningStarted = false;
// Track whether ANY text was ever sent across the full run.
// onLifecycleEnd closes the text part (textStarted→false), so
// onClose can't rely on textStarted alone to detect "no output".
let everSentText = false;
/** Write an SSE event; silently no-ops if the stream was already cancelled. */
const writeEvent = (data: unknown) => {
if (closed) {return;}
const json = JSON.stringify(data);
controller.enqueue(encoder.encode(`data: ${json}\n\n`));
};
/** Close the reasoning part if open. */
const closeReasoning = () => {
if (reasoningStarted) {
writeEvent({
type: "reasoning-end",
id: currentReasoningId,
});
reasoningStarted = false;
}
};
/** Close the text part if open. */
const closeText = () => {
if (textStarted) {
writeEvent({ type: "text-end", id: currentTextId });
textStarted = false;
}
};
try {
await runAgent(agentMessage, abortController.signal, {
onThinkingDelta: (delta) => {
if (!reasoningStarted) {
currentReasoningId = nextId("reasoning");
writeEvent({
type: "reasoning-start",
id: currentReasoningId,
});
reasoningStarted = true;
}
writeEvent({
type: "reasoning-delta",
id: currentReasoningId,
delta,
});
},
onTextDelta: (delta) => {
// Close reasoning once text starts streaming
closeReasoning();
if (!textStarted) {
currentTextId = nextId("text");
writeEvent({
type: "text-start",
id: currentTextId,
});
textStarted = true;
}
everSentText = true;
writeEvent({
type: "text-delta",
id: currentTextId,
delta,
});
},
onToolStart: (toolCallId, toolName, args) => {
// Close open reasoning/text parts before tool events
closeReasoning();
closeText();
writeEvent({
type: "tool-input-start",
toolCallId,
toolName,
});
// Include actual tool arguments so the frontend can
// display what the tool is doing (command, path, etc.)
writeEvent({
type: "tool-input-available",
toolCallId,
toolName,
input: args ?? {},
});
},
onToolEnd: (
toolCallId,
_toolName,
isError,
result,
) => {
if (isError) {
const errorText =
result?.text ||
(result?.details?.error as
| string
| undefined) ||
"Tool execution failed";
writeEvent({
type: "tool-output-error",
toolCallId,
errorText,
});
} else {
// Include the actual tool output (text, exit code, etc.)
writeEvent({
type: "tool-output-available",
toolCallId,
output: buildToolOutput(result),
});
}
},
onLifecycleEnd: () => {
closeReasoning();
closeText();
},
onAgentError: (message) => {
// Surface agent-level errors (API 402, rate limits, etc.)
// as visible text in the chat so the user sees what happened.
closeReasoning();
closeText();
currentTextId = nextId("text");
writeEvent({
type: "text-start",
id: currentTextId,
});
writeEvent({
type: "text-delta",
id: currentTextId,
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;
},
onClose: (_code) => {
closeReasoning();
if (!everSentText) {
// No text was ever sent during the entire run
currentTextId = nextId("text");
writeEvent({
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: msg,
});
writeEvent({
type: "text-end",
id: currentTextId,
});
} else {
// Ensure any still-open text part is closed
closeText();
}
},
}, sessionId ? { sessionId } : undefined);
} catch (error) {
console.error("[chat] Stream error:", error);
writeEvent({
type: "error",
errorText:
error instanceof Error
? error.message
: String(error),
});
} finally {
if (!closed) {
closed = true;
controller.close();
}
}
},
cancel() {
// Client disconnected (e.g. user hit stop) — tear down gracefully.
closed = true;
abortController.abort();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
},
});
}