Overhaul the Dench web app with a comprehensive visual redesign and several major feature additions across the chat interface, workspace, and agent runtime layer. Theme & Design System - Replace the dark-only palette with a full light/dark theme system that respects system preference via localStorage + inline script (no FOUC). - Introduce new design tokens: glassmorphism surfaces, semantic colors (success/warning/error/info), object-type chip palettes, and a tiered shadow scale (sm/md/lg/xl). - Add Instrument Serif + Inter via Google Fonts for a refined typographic hierarchy; headings use the serif face, body uses Inter. - Rebrand UI from "Ironclaw" to "Dench" across the landing page and metadata. Chat & Chain-of-Thought - Rewrite the chain-of-thought component with inline media detection and rendering — images, video, audio, and PDFs referenced in agent output are now displayed directly in the conversation thread. - Add status indicator parts (e.g. "Preparing response...", "Optimizing session context...") that render as subtle activity badges instead of verbose reasoning blocks. - Integrate react-markdown with remark-gfm for proper markdown rendering in assistant messages (tables, strikethrough, autolinks, etc.). - Improve report-block splitting and lazy-loaded ReportCard rendering. Workspace - Introduce @tanstack/react-table for the object table, replacing the hand-rolled table with full column sorting, fuzzy filtering via match-sorter-utils, row selection, and bulk actions. - Add a new media viewer component for in-workspace image/video/PDF preview. - New API routes: bulk-delete entries, field management (CRUD + reorder), raw-file serving endpoint for media assets. - Redesign workspace sidebar, empty state, and entry detail modal with the new theme tokens and improved layout. Agent Runtime - Switch web agent execution from --local to gateway-routed mode so concurrent chat threads share the gateway's lane-based concurrency system, eliminating cross-process file-lock contention. - Advertise "tool-events" capability during WebSocket handshake so the gateway streams tool start/update/result events to the UI. - Add new agent callback hooks: onLifecycleStart, onCompactionStart/End, and onToolUpdate for richer real-time feedback. - Forward media URLs emitted by agent events into the chat stream. Dependencies - Add @tanstack/match-sorter-utils and @tanstack/react-table to the web app. Published as ironclaw@2026.2.10-1. Co-authored-by: Cursor <cursoragent@cursor.com>
368 lines
9.7 KiB
TypeScript
368 lines
9.7 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;
|
|
// Track whether the status reasoning block is the one currently open
|
|
// so we can close it cleanly when real content arrives.
|
|
let statusReasoningActive = 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;
|
|
statusReasoningActive = false;
|
|
}
|
|
};
|
|
|
|
/** Close the text part if open. */
|
|
const closeText = () => {
|
|
if (textStarted) {
|
|
writeEvent({ type: "text-end", id: currentTextId });
|
|
textStarted = false;
|
|
}
|
|
};
|
|
|
|
/** Open a status reasoning block (auto-closes any existing one). */
|
|
const openStatusReasoning = (label: string) => {
|
|
closeReasoning();
|
|
closeText();
|
|
currentReasoningId = nextId("status");
|
|
writeEvent({
|
|
type: "reasoning-start",
|
|
id: currentReasoningId,
|
|
});
|
|
writeEvent({
|
|
type: "reasoning-delta",
|
|
id: currentReasoningId,
|
|
delta: label,
|
|
});
|
|
reasoningStarted = true;
|
|
statusReasoningActive = true;
|
|
};
|
|
|
|
try {
|
|
await runAgent(agentMessage, abortController.signal, {
|
|
onLifecycleStart: () => {
|
|
// Show immediate feedback — the agent has started working.
|
|
// This eliminates the "Streaming... (silence)" gap.
|
|
openStatusReasoning("Preparing response...");
|
|
},
|
|
|
|
onThinkingDelta: (delta) => {
|
|
// Close the status block if it's still the active one;
|
|
// real reasoning content is now arriving.
|
|
if (statusReasoningActive) {
|
|
closeReasoning();
|
|
}
|
|
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),
|
|
});
|
|
}
|
|
},
|
|
|
|
onCompactionStart: () => {
|
|
// Show compaction status while the gateway is
|
|
// optimizing the session context (can take 10-30s).
|
|
openStatusReasoning("Optimizing session context...");
|
|
},
|
|
|
|
onCompactionEnd: (willRetry) => {
|
|
// Close the compaction status block. If the gateway
|
|
// will retry the prompt, leave the reasoning area open
|
|
// so the next status/thinking block follows smoothly.
|
|
if (statusReasoningActive) {
|
|
if (willRetry) {
|
|
// Append a note, keep block open for retry
|
|
writeEvent({
|
|
type: "reasoning-delta",
|
|
id: currentReasoningId,
|
|
delta: "\nRetrying with compacted context...",
|
|
});
|
|
} else {
|
|
closeReasoning();
|
|
}
|
|
}
|
|
},
|
|
|
|
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",
|
|
},
|
|
});
|
|
}
|