kumarabhirup 8341c6048c
feat(web): full UI redesign with light/dark theme, TanStack data tables, media rendering, and gateway-routed agent execution
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>
2026-02-12 11:17:23 -08:00

388 lines
13 KiB
TypeScript

"use client";
import { useState, useCallback } from "react";
// --- Types ---
export type MediaType = "image" | "video" | "audio" | "pdf";
type MediaViewerProps = {
/** URL to serve the raw file (e.g. /api/workspace/raw-file?path=...) */
url: string;
/** Original filename for display */
filename: string;
/** Detected media type */
mediaType: MediaType;
/** Original workspace path for download/copy */
filePath?: string;
};
// --- Extension → MediaType mapping ---
const IMAGE_EXTS = new Set([
"jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "avif", "heic", "heif",
"ico", "tiff", "tif",
]);
const VIDEO_EXTS = new Set(["mp4", "webm", "mov", "avi", "mkv"]);
const AUDIO_EXTS = new Set(["mp3", "wav", "ogg", "m4a", "aac", "flac"]);
const PDF_EXTS = new Set(["pdf"]);
/** Returns the media type for a filename, or null if it's not a known media file. */
export function detectMediaType(filename: string): MediaType | null {
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
if (IMAGE_EXTS.has(ext)) {return "image";}
if (VIDEO_EXTS.has(ext)) {return "video";}
if (AUDIO_EXTS.has(ext)) {return "audio";}
if (PDF_EXTS.has(ext)) {return "pdf";}
return null;
}
// --- Icons ---
function DownloadIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" x2="12" y1="15" y2="3" />
</svg>
);
}
function ExternalLinkIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 3h6v6" />
<path d="M10 14 21 3" />
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
</svg>
);
}
function ZoomInIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8" />
<line x1="21" x2="16.65" y1="21" y2="16.65" />
<line x1="11" x2="11" y1="8" y2="14" />
<line x1="8" x2="14" y1="11" y2="11" />
</svg>
);
}
function ZoomOutIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8" />
<line x1="21" x2="16.65" y1="21" y2="16.65" />
<line x1="8" x2="14" y1="11" y2="11" />
</svg>
);
}
function mediaTypeLabel(mediaType: MediaType): string {
switch (mediaType) {
case "image": return "Image";
case "video": return "Video";
case "audio": return "Audio";
case "pdf": return "PDF";
}
}
function mediaTypeColor(mediaType: MediaType): string {
switch (mediaType) {
case "image": return "#60a5fa";
case "video": return "#c084fc";
case "audio": return "#f59e0b";
case "pdf": return "#ef4444";
}
}
// --- Main Component ---
export function MediaViewer({ url, filename, mediaType, filePath }: MediaViewerProps) {
return (
<div className="flex flex-col h-full">
{/* Header bar */}
<div
className="flex items-center gap-3 px-5 py-3 border-b flex-shrink-0"
style={{ borderColor: "var(--color-border)" }}
>
<MediaTypeIcon mediaType={mediaType} />
<span className="text-sm font-medium truncate" style={{ color: "var(--color-text)" }}>
{filename}
</span>
<span
className="text-[10px] px-2 py-0.5 rounded-full flex-shrink-0"
style={{
background: `${mediaTypeColor(mediaType)}18`,
color: mediaTypeColor(mediaType),
border: `1px solid ${mediaTypeColor(mediaType)}30`,
}}
>
{mediaTypeLabel(mediaType)}
</span>
<div className="flex items-center gap-1 ml-auto">
{/* Open in new tab */}
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="p-1.5 rounded-md transition-colors duration-100"
style={{ color: "var(--color-text-muted)" }}
title="Open in new tab"
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background = "transparent";
}}
>
<ExternalLinkIcon />
</a>
{/* Download */}
<a
href={url}
download={filename}
className="p-1.5 rounded-md transition-colors duration-100"
style={{ color: "var(--color-text-muted)" }}
title="Download"
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background = "transparent";
}}
>
<DownloadIcon />
</a>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-auto flex items-center justify-center p-6" style={{ background: "var(--color-surface)" }}>
{mediaType === "image" && <ImageViewer url={url} filename={filename} />}
{mediaType === "video" && <VideoViewer url={url} />}
{mediaType === "audio" && <AudioViewer url={url} filename={filename} />}
{mediaType === "pdf" && <PdfViewer url={url} />}
</div>
{/* Footer with path */}
{filePath && (
<div
className="px-5 py-2 border-t flex-shrink-0 flex items-center"
style={{ borderColor: "var(--color-border)" }}
>
<span
className="text-[11px] truncate"
style={{ color: "var(--color-text-muted)", fontFamily: "'SF Mono', 'Fira Code', monospace" }}
>
{filePath}
</span>
</div>
)}
</div>
);
}
// --- Image Viewer (with zoom) ---
function ImageViewer({ url, filename }: { url: string; filename: string }) {
const [zoom, setZoom] = useState(1);
const [error, setError] = useState(false);
const handleZoomIn = useCallback(() => setZoom((z) => Math.min(z * 1.5, 5)), []);
const handleZoomOut = useCallback(() => setZoom((z) => Math.max(z / 1.5, 0.25)), []);
const handleReset = useCallback(() => setZoom(1), []);
if (error) {
return (
<div className="flex flex-col items-center gap-3 py-12">
<span className="text-4xl" style={{ opacity: 0.3 }}>🖼</span>
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
Failed to load image
</p>
</div>
);
}
return (
<div className="flex flex-col items-center gap-4 w-full">
{/* Zoom controls */}
<div className="flex items-center gap-1">
<button
type="button"
onClick={handleZoomOut}
className="p-1.5 rounded-md transition-colors duration-100 cursor-pointer"
style={{ color: "var(--color-text-muted)" }}
title="Zoom out"
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)"; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = "transparent"; }}
>
<ZoomOutIcon />
</button>
<button
type="button"
onClick={handleReset}
className="px-2 py-1 rounded-md text-[11px] tabular-nums transition-colors duration-100 cursor-pointer"
style={{ color: "var(--color-text-muted)" }}
title="Reset zoom"
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)"; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = "transparent"; }}
>
{Math.round(zoom * 100)}%
</button>
<button
type="button"
onClick={handleZoomIn}
className="p-1.5 rounded-md transition-colors duration-100 cursor-pointer"
style={{ color: "var(--color-text-muted)" }}
title="Zoom in"
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)"; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = "transparent"; }}
>
<ZoomInIcon />
</button>
</div>
{/* Image container with checkerboard background for transparency */}
<div
className="overflow-auto max-w-full max-h-[calc(100vh-260px)] rounded-xl border"
style={{
borderColor: "var(--color-border)",
backgroundImage: "linear-gradient(45deg, var(--color-surface-hover) 25%, transparent 25%), linear-gradient(-45deg, var(--color-surface-hover) 25%, transparent 25%), linear-gradient(45deg, transparent 75%, var(--color-surface-hover) 75%), linear-gradient(-45deg, transparent 75%, var(--color-surface-hover) 75%)",
backgroundSize: "20px 20px",
backgroundPosition: "0 0, 0 10px, 10px -10px, -10px 0px",
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={url}
alt={filename}
onError={() => setError(true)}
style={{
transform: `scale(${zoom})`,
transformOrigin: "center center",
transition: "transform 200ms ease",
maxWidth: zoom <= 1 ? "100%" : "none",
display: "block",
}}
draggable={false}
/>
</div>
</div>
);
}
// --- Video Viewer ---
function VideoViewer({ url }: { url: string }) {
return (
<div className="w-full max-w-4xl">
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
src={url}
controls
className="w-full rounded-xl border"
style={{
borderColor: "var(--color-border)",
maxHeight: "calc(100vh - 220px)",
background: "#000",
}}
/>
</div>
);
}
// --- Audio Viewer ---
function AudioViewer({ url, filename }: { url: string; filename: string }) {
return (
<div className="flex flex-col items-center gap-6 py-8">
{/* Visual representation */}
<div
className="w-32 h-32 rounded-2xl flex items-center justify-center"
style={{
background: "linear-gradient(135deg, #f59e0b20, #f59e0b10)",
border: "1px solid #f59e0b30",
}}
>
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
</div>
<p className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
{filename}
</p>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<audio src={url} controls className="w-full max-w-md" />
</div>
);
}
// --- PDF Viewer ---
function PdfViewer({ url }: { url: string }) {
return (
<div className="w-full h-full flex flex-col">
<iframe
src={url}
className="w-full flex-1 rounded-xl border"
style={{
borderColor: "var(--color-border)",
minHeight: "calc(100vh - 220px)",
background: "white",
}}
title="PDF viewer"
/>
</div>
);
}
// --- Media type icon ---
function MediaTypeIcon({ mediaType }: { mediaType: MediaType }) {
const color = mediaTypeColor(mediaType);
switch (mediaType) {
case "image":
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke={color} 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>
);
case "video":
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5" />
<rect x="2" y="6" width="14" height="12" rx="2" />
</svg>
);
case "audio":
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
);
case "pdf":
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke={color} 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" />
<path d="M10 9H8" />
<path d="M16 13H8" />
<path d="M16 17H8" />
</svg>
);
}
}