Web app: add syntax highlighting, diff viewer, rich chat editor, and file search

Syntax highlighting & code viewer:
- Add shiki for syntax-highlighted fenced code blocks in chat messages
- New SyntaxBlock component (lazy shiki, dual light/dark theme)
- New CodeViewer for workspace file panel (routes code files via isCodeFile())
- API routes (browse-file, virtual-file) now return "code" type for known extensions

Diff rendering:
- New DiffCard component for rendering unified diffs with add/remove colors
- diff-blocks.ts parser to extract fenced blocks from markdown
- Chain-of-thought tool steps show inline diffs for edit/write tools
  (synthetic from old_string/new_string or direct from tool output)
- Agent runner passes through diff/firstChangedLine from edit tool results
- Document view handles diff blocks alongside report blocks

Rich chat editor (Tiptap):
- Replace plain textarea with Tiptap-based ChatEditor
- File mention extension (@-mention files with autocomplete dropdown)
- File mention list with keyboard navigation and search via suggest-files API
- New suggest-files API endpoint for fuzzy file search

File search & navigation:
- FileSearch component in workspace sidebar (debounced search, keyboard nav)
- Search results navigate sidebar to file location and open in panel
- File picker modal for browsing/selecting workspace files

Drag & drop:
- File tree nodes support native HTML5 drag (application/x-file-mention)
  for cross-component drops (e.g. dragging files into chat editor)

Chat attachments reworked:
- Switch from browser File objects to path-based references (name + path)
- Simplified attachment strip (no media previews, shows shortened paths)

Also adds software-engineering skill and related CSS for code blocks/shiki.
This commit is contained in:
kumarabhirup 2026-02-13 18:06:59 -08:00
parent b86f5cf441
commit 0f6849a731
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
26 changed files with 3930 additions and 307 deletions

View File

@ -20,6 +20,16 @@ const MIME_MAP: Record<string, string> = {
pdf: "application/pdf",
};
/** Extensions recognized as code files for syntax-highlighted viewing. */
const CODE_EXTENSIONS = new Set([
"ts", "tsx", "js", "jsx", "mjs", "cjs", "py", "rb", "go", "rs",
"java", "kt", "swift", "c", "cpp", "h", "hpp", "cs", "css", "scss",
"less", "html", "htm", "xml", "json", "jsonc", "toml", "sh", "bash",
"zsh", "fish", "ps1", "sql", "graphql", "gql", "dockerfile", "makefile",
"r", "lua", "php", "vue", "svelte", "diff", "patch", "ini", "env",
"tf", "proto", "zig", "elixir", "ex", "erl", "hs", "scala", "clj", "dart",
]);
export async function GET(req: Request) {
const url = new URL(req.url);
const filePath = url.searchParams.get("path");
@ -82,9 +92,10 @@ export async function GET(req: Request) {
const content = readFileSync(resolved, "utf-8");
const ext = resolved.split(".").pop()?.toLowerCase();
let type: "markdown" | "yaml" | "text" = "text";
let type: "markdown" | "yaml" | "code" | "text" = "text";
if (ext === "md" || ext === "mdx") {type = "markdown";}
else if (ext === "yaml" || ext === "yml") {type = "yaml";}
else if (CODE_EXTENSIONS.has(ext ?? "")) {type = "code";}
return Response.json({ content, type });
} catch {

View File

@ -0,0 +1,218 @@
import { readdirSync, type Dirent } from "node:fs";
import { join, dirname, resolve, basename } from "node:path";
import { homedir } from "node:os";
import { resolveDenchRoot } from "@/lib/workspace";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
type SuggestItem = {
name: string;
path: string;
type: "folder" | "file" | "document" | "database";
};
const SKIP_DIRS = new Set([
"node_modules",
".git",
".Trash",
"__pycache__",
".cache",
".DS_Store",
]);
/** List entries in a directory, sorted folders-first then alphabetically. */
function listDir(absDir: string, filter?: string): SuggestItem[] {
let entries: Dirent[];
try {
entries = readdirSync(absDir, { withFileTypes: true });
} catch {
return [];
}
const lowerFilter = filter?.toLowerCase();
const sorted = entries
.filter((e) => !e.name.startsWith("."))
.filter((e) => !(e.isDirectory() && SKIP_DIRS.has(e.name)))
.filter((e) => !lowerFilter || e.name.toLowerCase().includes(lowerFilter))
.toSorted((a, b) => {
if (a.isDirectory() && !b.isDirectory()) {return -1;}
if (!a.isDirectory() && b.isDirectory()) {return 1;}
return a.name.localeCompare(b.name);
});
const items: SuggestItem[] = [];
for (const entry of sorted) {
if (items.length >= 30) {break;}
const absPath = join(absDir, entry.name);
if (entry.isDirectory()) {
items.push({ name: entry.name, path: absPath, type: "folder" });
} else if (entry.isFile()) {
const ext = entry.name.split(".").pop()?.toLowerCase();
const isDocument = ext === "md" || ext === "mdx";
const isDatabase =
ext === "duckdb" || ext === "sqlite" || ext === "sqlite3" || ext === "db";
items.push({
name: entry.name,
path: absPath,
type: isDatabase ? "database" : isDocument ? "document" : "file",
});
}
}
return items;
}
/** Recursively search for files matching a query, up to a limit. */
function searchFiles(
absDir: string,
query: string,
results: SuggestItem[],
maxResults: number,
depth = 0,
): void {
if (depth > 6 || results.length >= maxResults) {return;}
let entries: Dirent[];
try {
entries = readdirSync(absDir, { withFileTypes: true });
} catch {
return;
}
const lowerQuery = query.toLowerCase();
for (const entry of entries) {
if (results.length >= maxResults) {return;}
if (entry.name.startsWith(".")) {continue;}
if (entry.isDirectory() && SKIP_DIRS.has(entry.name)) {continue;}
const absPath = join(absDir, entry.name);
if (entry.isFile() && entry.name.toLowerCase().includes(lowerQuery)) {
const ext = entry.name.split(".").pop()?.toLowerCase();
const isDocument = ext === "md" || ext === "mdx";
const isDatabase =
ext === "duckdb" || ext === "sqlite" || ext === "sqlite3" || ext === "db";
results.push({
name: entry.name,
path: absPath,
type: isDatabase ? "database" : isDocument ? "document" : "file",
});
} else if (
entry.isDirectory() &&
entry.name.toLowerCase().includes(lowerQuery)
) {
results.push({ name: entry.name, path: absPath, type: "folder" });
}
if (entry.isDirectory()) {
searchFiles(absPath, query, results, maxResults, depth + 1);
}
}
}
/**
* Resolve a user-typed path query into a directory to list and an optional filter.
*
* Examples:
* "../" list parent of workspace root
* "/" list filesystem root
* "~/" list home dir
* "~/Doc" list home dir, filter "Doc"
* "src/utils" list <workspace>/src, filter "utils"
* "foo.ts" search by filename
*/
function resolvePath(
raw: string,
workspaceRoot: string,
): { dir: string; filter?: string } | null {
const home = homedir();
if (raw.startsWith("~/")) {
const rest = raw.slice(2);
if (!rest || rest.endsWith("/")) {
// List the directory
const dir = rest ? resolve(home, rest) : home;
return { dir };
}
// Has a trailing segment → list parent, filter by segment
const dir = resolve(home, dirname(rest));
return { dir, filter: basename(rest) };
}
if (raw.startsWith("/")) {
if (raw === "/") {return { dir: "/" };}
if (raw.endsWith("/")) {
return { dir: resolve(raw) };
}
const dir = dirname(resolve(raw));
return { dir, filter: basename(raw) };
}
if (raw.startsWith("../") || raw === "..") {
const resolved = resolve(workspaceRoot, raw);
if (raw.endsWith("/") || raw === "..") {
return { dir: resolved };
}
return { dir: dirname(resolved), filter: basename(resolved) };
}
if (raw.startsWith("./")) {
const rest = raw.slice(2);
if (!rest || rest.endsWith("/")) {
const dir = rest ? resolve(workspaceRoot, rest) : workspaceRoot;
return { dir };
}
const dir = resolve(workspaceRoot, dirname(rest));
return { dir, filter: basename(rest) };
}
// Contains a slash → treat as relative path from workspace
if (raw.includes("/")) {
if (raw.endsWith("/")) {
return { dir: resolve(workspaceRoot, raw) };
}
const dir = resolve(workspaceRoot, dirname(raw));
return { dir, filter: basename(raw) };
}
// No path separator → this is a filename search
return null;
}
export async function GET(req: Request) {
const url = new URL(req.url);
const pathQuery = url.searchParams.get("path");
const searchQuery = url.searchParams.get("q");
const workspaceRoot = resolveDenchRoot() ?? homedir();
// Search mode: find files by name
if (searchQuery) {
const results: SuggestItem[] = [];
searchFiles(workspaceRoot, searchQuery, results, 20);
// Also search home dir if workspace didn't yield enough
if (results.length < 20) {
searchFiles(homedir(), searchQuery, results, 20);
}
return Response.json({ items: results });
}
// Browse mode: resolve path and list directory
if (pathQuery) {
const resolved = resolvePath(pathQuery, workspaceRoot);
if (!resolved) {
// Treat as filename search
const results: SuggestItem[] = [];
searchFiles(workspaceRoot, pathQuery, results, 20);
return Response.json({ items: results });
}
const items = listDir(resolved.dir, resolved.filter);
return Response.json({ items });
}
// Default: list workspace root
const items = listDir(workspaceRoot);
return Response.json({ items });
}

View File

@ -94,6 +94,15 @@ function isSafePath(absPath: string): boolean {
return allowed.some((dir) => normalized.startsWith(dir));
}
/** Extensions recognized as code files for syntax-highlighted viewing. */
const VIRTUAL_CODE_EXTENSIONS = new Set([
"ts", "tsx", "js", "jsx", "mjs", "cjs", "py", "rb", "go", "rs",
"java", "kt", "swift", "c", "cpp", "h", "hpp", "cs", "css", "scss",
"less", "html", "htm", "xml", "json", "jsonc", "toml", "sh", "bash",
"zsh", "fish", "ps1", "sql", "graphql", "gql", "diff", "patch",
"ini", "env", "tf", "proto", "zig", "lua", "php",
]);
export async function GET(req: Request) {
const url = new URL(req.url);
const path = url.searchParams.get("path");
@ -114,9 +123,10 @@ export async function GET(req: Request) {
try {
const content = readFileSync(absPath, "utf-8");
const ext = absPath.split(".").pop()?.toLowerCase();
let type: "markdown" | "yaml" | "text" = "text";
let type: "markdown" | "yaml" | "code" | "text" = "text";
if (ext === "md" || ext === "mdx") {type = "markdown";}
else if (ext === "yaml" || ext === "yml") {type = "yaml";}
else if (VIRTUAL_CODE_EXTENSIONS.has(ext ?? "")) {type = "code";}
return Response.json({ content, type });
} catch (err) {
return Response.json(

View File

@ -1,6 +1,30 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { DiffCard } from "./diff-viewer";
/* ─── Diff synthesis from edit tool args ─── */
/**
* Build a unified diff string from old_string/new_string pairs.
* This provides a visual diff even when the tool result doesn't include one.
*/
function buildSyntheticDiff(filePath: string, oldStr: string, newStr: string): string {
const oldLines = oldStr.split("\n");
const newLines = newStr.split("\n");
const lines: string[] = [
`--- a/${filePath}`,
`+++ b/${filePath}`,
`@@ -1,${oldLines.length} +1,${newLines.length} @@`,
];
for (const line of oldLines) {
lines.push(`-${line}`);
}
for (const line of newLines) {
lines.push(`+${line}`);
}
return lines.join("\n");
}
/* ─── Public types ─── */
@ -1059,6 +1083,8 @@ function ToolStep({
errorText?: string;
}) {
const [showOutput, setShowOutput] = useState(false);
// Auto-expand diffs for write tool steps
const [showDiff, setShowDiff] = useState(true);
const kind = classifyTool(toolName, args);
const label = buildStepLabel(kind, toolName, args, output);
const domains =
@ -1070,6 +1096,27 @@ function ToolStep({
const outputText =
typeof output?.text === "string" ? output.text : undefined;
// Detect diff data from edit/write tool results.
// Priority: output.diff (from edit tool), then synthesize from args.
const diffText = (() => {
if (kind !== "write" || status !== "done") {return undefined;}
// 1. Direct diff from tool result (edit tool returns this)
if (typeof output?.diff === "string") {return output.diff;}
// 2. Synthesize from edit args (old_string/new_string or oldText/newText)
const oldStr =
typeof args?.old_string === "string" ? args.old_string :
typeof args?.oldText === "string" ? args.oldText : null;
const newStr =
typeof args?.new_string === "string" ? args.new_string :
typeof args?.newText === "string" ? args.newText : null;
if (oldStr !== null && newStr !== null) {
const path = typeof args?.path === "string" ? args.path :
typeof args?.file_path === "string" ? args.file_path : "file";
return buildSyntheticDiff(path, oldStr, newStr);
}
return undefined;
})();
// For single-file reads that are media, render inline preview
const filePath = getFilePath(args, output);
const media = filePath ? detectMedia(filePath) : null;
@ -1109,6 +1156,23 @@ function ToolStep({
{label}
</div>
{/* Inline diff for edit/write tool steps */}
{diffText && status === "done" && (
<div className="mt-1.5">
<button
type="button"
onClick={() => setShowDiff((v) => !v)}
className="text-[11px] hover:underline cursor-pointer mb-1"
style={{ color: "var(--color-accent)" }}
>
{showDiff ? "Hide changes" : "Show changes"}
</button>
{showDiff && (
<DiffCard diff={diffText} />
)}
</div>
)}
{/* Single media inline preview (when not grouped) */}
{isSingleMedia && filePath && media === "image" && (
<div className="mt-1.5">
@ -1247,11 +1311,12 @@ function ToolStep({
</div>
)}
{/* Output toggle — skip for media files and search */}
{/* Output toggle — skip for media files, search, and diffs */}
{outputText &&
status === "done" &&
kind !== "search" &&
!isSingleMedia && (
!isSingleMedia &&
!diffText && (
<div className="mt-1">
<button
type="button"

View File

@ -7,7 +7,10 @@ import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { ChainOfThought, type ChainPart } from "./chain-of-thought";
import { splitReportBlocks, hasReportBlocks } from "@/lib/report-blocks";
import { splitDiffBlocks, hasDiffBlocks } from "@/lib/diff-blocks";
import type { ReportConfig } from "./charts/types";
import { DiffCard } from "./diff-viewer";
import { SyntaxBlock } from "./syntax-block";
// Lazy-load ReportCard (uses Recharts which is heavy)
const ReportCard = dynamic(
@ -31,7 +34,8 @@ const ReportCard = dynamic(
type MessageSegment =
| { type: "text"; text: string }
| { type: "chain"; parts: ChainPart[] }
| { type: "report-artifact"; config: ReportConfig };
| { type: "report-artifact"; config: ReportConfig }
| { type: "diff-artifact"; diff: string };
/** Map AI SDK tool state string to a simplified status */
function toolStatus(state: string): "running" | "done" | "error" {
@ -67,6 +71,14 @@ function groupParts(parts: UIMessage["parts"]): MessageSegment[] {
segments.push(
...(splitReportBlocks(text) as MessageSegment[]),
);
} else if (hasDiffBlocks(text)) {
for (const seg of splitDiffBlocks(text)) {
if (seg.type === "diff-artifact") {
segments.push({ type: "diff-artifact", diff: seg.diff });
} else {
segments.push({ type: "text", text: seg.text });
}
}
} else {
segments.push({ type: "text", text });
}
@ -116,6 +128,8 @@ function groupParts(parts: UIMessage["parts"]): MessageSegment[] {
output: asRecord(tp.output),
});
} else if (part.type.startsWith("tool-")) {
// Handles both live SSE parts (input/output fields) and
// persisted JSONL parts (args/result fields from tool-invocation)
const tp = part as {
type: string;
toolCallId: string;
@ -124,7 +138,16 @@ function groupParts(parts: UIMessage["parts"]): MessageSegment[] {
title?: string;
input?: unknown;
output?: unknown;
// Persisted JSONL format uses args/result instead
args?: unknown;
result?: unknown;
errorText?: string;
};
// Persisted tool-invocation parts have no state field but
// include result/errorText to indicate completion.
const resolvedState =
tp.state ??
(tp.errorText ? "error" : "result" in tp ? "output-available" : "input-available");
chain.push({
kind: "tool",
toolName:
@ -132,9 +155,9 @@ function groupParts(parts: UIMessage["parts"]): MessageSegment[] {
tp.toolName ??
part.type.replace("tool-", ""),
toolCallId: tp.toolCallId,
status: toolStatus(tp.state ?? "input-available"),
args: asRecord(tp.input),
output: asRecord(tp.output),
status: toolStatus(resolvedState),
args: asRecord(tp.input) ?? asRecord(tp.args),
output: asRecord(tp.output) ?? asRecord(tp.result),
});
}
}
@ -416,6 +439,67 @@ const mdComponents: Components = {
// eslint-disable-next-line @next/next/no-img-element
<img src={src} alt={alt ?? ""} loading="lazy" {...props} />
),
// Syntax-highlighted fenced code blocks
pre: ({ children, ...props }) => {
// react-markdown wraps code blocks in <pre><code>...
// Extract the code element to get lang + content
const child = Array.isArray(children) ? children[0] : children;
if (
child &&
typeof child === "object" &&
"type" in child &&
(child as { type?: string }).type === "code"
) {
const codeEl = child as {
props?: {
className?: string;
children?: string;
};
};
const className = codeEl.props?.className ?? "";
const langMatch = className.match(/language-(\w+)/);
const lang = langMatch?.[1] ?? "";
const code =
typeof codeEl.props?.children === "string"
? codeEl.props.children.replace(/\n$/, "")
: "";
// Diff language: render as DiffCard
if (lang === "diff") {
return <DiffCard diff={code} />;
}
// Known language: syntax-highlight with shiki
if (lang) {
return (
<div className="chat-code-block">
<div
className="chat-code-lang"
>
{lang}
</div>
<SyntaxBlock code={code} lang={lang} />
</div>
);
}
}
// Fallback: default pre rendering
return <pre {...props}>{children}</pre>;
},
// Inline code (no highlighting needed)
code: ({ children, className, ...props }) => {
// If this code has a language class, it's inside a <pre> and
// will be handled by the pre override above. Just return raw.
if (className?.startsWith("language-")) {
return (
<code className={className} {...props}>
{children}
</code>
);
}
// Inline code
return <code {...props}>{children}</code>;
},
};
/* ─── Chat message ─── */
@ -545,14 +629,22 @@ export function ChatMessage({ message }: { message: UIMessage }) {
</div>
);
}
if (segment.type === "report-artifact") {
return (
<ReportCard
key={index}
config={segment.config}
/>
);
}
if (segment.type === "report-artifact") {
return (
<ReportCard
key={index}
config={segment.config}
/>
);
}
if (segment.type === "diff-artifact") {
return (
<DiffCard
key={index}
diff={segment.diff}
/>
);
}
return (
<ChainOfThought
key={index}

View File

@ -12,26 +12,36 @@ import {
useState,
} from "react";
import { ChatMessage } from "./chat-message";
import {
FilePickerModal,
type SelectedFile,
} from "./file-picker-modal";
import { ChatEditor, type ChatEditorHandle } from "./tiptap/chat-editor";
// ── Attachment types & helpers ──
type AttachedFile = {
id: string;
file: File;
/** Full filesystem path when available (Electron/Chromium), otherwise filename. */
name: string;
path: string;
previewUrl: string | null;
};
function getFileCategory(
file: File,
name: string,
): "image" | "video" | "audio" | "pdf" | "code" | "document" | "other" {
const mime = file.type;
if (mime.startsWith("image/")) {return "image";}
if (mime.startsWith("video/")) {return "video";}
if (mime.startsWith("audio/")) {return "audio";}
if (mime === "application/pdf") {return "pdf";}
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
const ext = name.split(".").pop()?.toLowerCase() ?? "";
if (
[
"jpg", "jpeg", "png", "gif", "webp", "svg", "bmp",
"ico", "tiff", "heic",
].includes(ext)
)
{return "image";}
if (["mp4", "webm", "mov", "avi", "mkv", "flv"].includes(ext))
{return "video";}
if (["mp3", "wav", "ogg", "aac", "flac", "m4a"].includes(ext))
{return "audio";}
if (ext === "pdf") {return "pdf";}
if (
[
"js", "ts", "tsx", "jsx", "py", "rb", "go", "rs", "java",
@ -50,10 +60,11 @@ function getFileCategory(
return "other";
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) {return bytes + " B";}
if (bytes < 1024 * 1024) {return (bytes / 1024).toFixed(1) + " KB";}
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
function shortenPath(path: string): string {
return path
.replace(/^\/Users\/[^/]+/, "~")
.replace(/^\/home\/[^/]+/, "~")
.replace(/^[A-Z]:\\Users\\[^\\]+/, "~");
}
const categoryMeta: Record<string, { bg: string; fg: string }> = {
@ -176,14 +187,13 @@ function AttachmentStrip({
style={{ scrollbarWidth: "thin" }}
>
{files.map((af) => {
const category = getFileCategory(af.file);
const category = getFileCategory(
af.name,
);
const meta =
categoryMeta[category] ??
categoryMeta.other;
const isMedia =
(category === "image" ||
category === "video") &&
af.previewUrl;
const short = shortenPath(af.path);
return (
<div
@ -198,7 +208,9 @@ function AttachmentStrip({
{/* Remove button */}
<button
type="button"
onClick={() => onRemove(af.id)}
onClick={() =>
onRemove(af.id)
}
className="absolute top-1 right-1 z-10 w-[18px] h-[18px] rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
style={{
background:
@ -222,113 +234,46 @@ function AttachmentStrip({
</svg>
</button>
{isMedia ? (
<div>
{category === "image" ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={
af.previewUrl!
}
alt={
af.file
.name
}
className="w-[100px] h-[64px] object-cover"
/>
) : (
<div className="relative w-[100px] h-[64px]">
<video
src={
af.previewUrl!
}
className="w-full h-full object-cover"
muted
preload="metadata"
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/20">
<div className="w-6 h-6 rounded-full bg-black/50 flex items-center justify-center backdrop-blur-sm">
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="white"
>
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
</div>
</div>
</div>
)}
<div className="px-2 py-1.5">
<p
className="text-[10px] font-medium truncate w-[84px]"
style={{
color: "var(--color-text)",
}}
title={
af.file
.name
}
>
{af.file.name}
</p>
<p
className="text-[9px]"
style={{
color: "var(--color-text-muted)",
}}
>
{formatFileSize(
af.file
.size,
)}
</p>
</div>
<div className="flex items-center gap-2.5 px-3 py-2.5">
<div
className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
style={{
background:
meta.bg,
color: meta.fg,
}}
>
<FileTypeIcon
category={
category
}
/>
</div>
) : (
<div className="flex items-center gap-2.5 px-3 py-2.5">
<div
className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
<div className="min-w-0 max-w-[140px]">
<p
className="text-[11px] font-medium truncate"
style={{
background:
meta.bg,
color: meta.fg,
color: "var(--color-text)",
}}
title={
af.path
}
>
<FileTypeIcon
category={
category
}
/>
</div>
<div className="min-w-0 max-w-[120px]">
<p
className="text-[11px] font-medium truncate"
style={{
color: "var(--color-text)",
}}
title={
af.file
.name
}
>
{af.file.name}
</p>
<p
className="text-[9px]"
style={{
color: "var(--color-text-muted)",
}}
>
{formatFileSize(
af.file
.size,
)}
</p>
</div>
{af.name}
</p>
<p
className="text-[9px] truncate"
style={{
color: "var(--color-text-muted)",
}}
title={
af.path
}
>
{short}
</p>
</div>
)}
</div>
</div>
);
})}
@ -474,6 +419,8 @@ function createStreamParser() {
export type ChatPanelHandle = {
loadSession: (sessionId: string) => Promise<void>;
newSession: () => Promise<void>;
/** Insert a file mention into the chat editor (e.g. from sidebar drag). */
insertFileMention?: (name: string, path: string) => void;
};
export type FileContext = {
@ -513,7 +460,8 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
},
ref,
) {
const [input, setInput] = useState("");
const editorRef = useRef<ChatEditorHandle>(null);
const [editorEmpty, setEditorEmpty] = useState(true);
const [currentSessionId, setCurrentSessionId] = useState<
string | null
>(null);
@ -525,7 +473,8 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
const [attachedFiles, setAttachedFiles] = useState<
AttachedFile[]
>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const [showFilePicker, setShowFilePicker] =
useState(false);
// ── Reconnection state ──
const [isReconnecting, setIsReconnecting] = useState(false);
@ -880,74 +829,93 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
// ── Actions ──
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const hasText = input.trim().length > 0;
const hasFiles = attachedFiles.length > 0;
if ((!hasText && !hasFiles) || isStreaming) {
return;
}
// Ref for handleNewSession so handleEditorSubmit doesn't depend on the hook order
const handleNewSessionRef = useRef<() => void>(() => {});
const userText = input.trim();
const currentAttachments = [...attachedFiles];
setInput("");
// Clear attachments and revoke preview URLs
if (currentAttachments.length > 0) {
for (const f of currentAttachments) {
if (f.previewUrl)
{URL.revokeObjectURL(f.previewUrl);}
/** Submit from the Tiptap editor (called on Enter or send button). */
const handleEditorSubmit = useCallback(
async (
text: string,
mentionedFiles: Array<{ name: string; path: string }>,
) => {
const hasText = text.trim().length > 0;
const hasMentions = mentionedFiles.length > 0;
const hasFiles = attachedFiles.length > 0;
if ((!hasText && !hasMentions && !hasFiles) || isStreaming) {
return;
}
setAttachedFiles([]);
}
if (userText.toLowerCase() === "/new") {
handleNewSession();
return;
}
const userText = text.trim();
const currentAttachments = [...attachedFiles];
let sessionId = currentSessionId;
if (!sessionId) {
const titleSource =
userText || "File attachment";
const title =
titleSource.length > 60
? titleSource.slice(0, 60) + "..."
: titleSource;
sessionId = await createSession(title);
setCurrentSessionId(sessionId);
sessionIdRef.current = sessionId;
onActiveSessionChange?.(sessionId);
onSessionsChange?.();
if (filePath) {
fetchFileSessionsRef.current?.().then(
(sessions) => {
setFileSessions(sessions);
},
);
// Clear attachments
if (currentAttachments.length > 0) {
setAttachedFiles([]);
}
}
// Build message with optional attachment prefix
let messageText = userText;
if (currentAttachments.length > 0) {
const filePaths = currentAttachments
.map((f) => f.path)
.join(", ");
const prefix = `[Attached files: ${filePaths}]`;
messageText = messageText
? `${prefix}\n\n${messageText}`
: prefix;
}
if (userText.toLowerCase() === "/new") {
handleNewSessionRef.current();
return;
}
if (fileContext && isFirstFileMessageRef.current) {
messageText = `[Context: workspace file '${fileContext.path}']\n\n${messageText}`;
isFirstFileMessageRef.current = false;
}
let sessionId = currentSessionId;
if (!sessionId) {
const titleSource =
userText || "File attachment";
const title =
titleSource.length > 60
? titleSource.slice(0, 60) + "..."
: titleSource;
sessionId = await createSession(title);
setCurrentSessionId(sessionId);
sessionIdRef.current = sessionId;
onActiveSessionChange?.(sessionId);
onSessionsChange?.();
if (filePath) {
fetchFileSessionsRef.current?.().then(
(sessions) => {
setFileSessions(sessions);
},
);
}
}
// Build message with optional attachment prefix
let messageText = userText;
// Merge mention paths and attachment paths
const allFilePaths = [
...mentionedFiles.map((f) => f.path),
...currentAttachments.map((f) => f.path),
];
if (allFilePaths.length > 0) {
const prefix = `[Attached files: ${allFilePaths.join(", ")}]`;
messageText = messageText
? `${prefix}\n\n${messageText}`
: prefix;
}
if (fileContext && isFirstFileMessageRef.current) {
messageText = `[Context: workspace file '${fileContext.path}']\n\n${messageText}`;
isFirstFileMessageRef.current = false;
}
sendMessage({ text: messageText });
},
[
attachedFiles,
isStreaming,
currentSessionId,
createSession,
onActiveSessionChange,
onSessionsChange,
filePath,
fileContext,
sendMessage,
],
);
sendMessage({ text: messageText });
};
const handleSessionSelect = useCallback(
async (sessionId: string) => {
@ -1058,11 +1026,17 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
}
}, [setMessages, onActiveSessionChange, filePath, stop]);
// Keep the ref in sync so handleEditorSubmit can call it
handleNewSessionRef.current = handleNewSession;
useImperativeHandle(
ref,
() => ({
loadSession: handleSessionSelect,
newSession: handleNewSession,
insertFileMention: (name: string, path: string) => {
editorRef.current?.insertFileMention(name, path);
},
}),
[handleSessionSelect, handleNewSession],
);
@ -1092,69 +1066,31 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
// ── Attachment handlers ──
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) {return;}
const newFiles: AttachedFile[] = Array.from(
files,
).map((file) => {
const cat = getFileCategory(file);
const previewUrl =
cat === "image" || cat === "video"
? URL.createObjectURL(file)
: null;
// Chromium/Electron exposes the full filesystem path
const fullPath =
(file as File & { path?: string })
.path ||
file.webkitRelativePath ||
file.name;
return {
id: `${file.name}-${file.size}-${Date.now()}-${Math.random().toString(36).slice(2)}`,
file,
path: fullPath,
previewUrl,
};
});
setAttachedFiles((prev) => [...prev, ...newFiles]);
e.target.value = "";
const handleFilesSelected = useCallback(
(files: SelectedFile[]) => {
const newFiles: AttachedFile[] = files.map(
(f) => ({
id: `${f.path}-${Date.now()}-${Math.random().toString(36).slice(2)}`,
name: f.name,
path: f.path,
}),
);
setAttachedFiles((prev) => [
...prev,
...newFiles,
]);
},
[],
);
const removeAttachment = useCallback((id: string) => {
setAttachedFiles((prev) => {
const found = prev.find((f) => f.id === id);
if (found?.previewUrl) {
URL.revokeObjectURL(found.previewUrl);
}
return prev.filter((f) => f.id !== id);
});
setAttachedFiles((prev) =>
prev.filter((f) => f.id !== id),
);
}, []);
const clearAllAttachments = useCallback(() => {
setAttachedFiles((prev) => {
for (const f of prev) {
if (f.previewUrl)
{URL.revokeObjectURL(f.previewUrl);}
}
return [];
});
}, []);
// Cleanup preview URLs on unmount
const attachedFilesRef = useRef(attachedFiles);
attachedFilesRef.current = attachedFiles;
useEffect(() => {
return () => {
for (const f of attachedFilesRef.current) {
if (f.previewUrl)
{URL.revokeObjectURL(f.previewUrl);}
}
};
setAttachedFiles([]);
}, []);
// ── Status label ──
@ -1445,32 +1381,27 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
border: "1px solid var(--color-border)",
}}
>
<form onSubmit={handleSubmit}>
<input
type="text"
value={input}
onChange={(e) =>
setInput(e.target.value)
}
<ChatEditor
ref={editorRef}
onSubmit={handleEditorSubmit}
onChange={(isEmpty) =>
setEditorEmpty(isEmpty)
}
placeholder={
compact && fileContext
? `Ask about ${fileContext.filename}...`
: attachedFiles.length >
0
? "Add a message or send files..."
: "Ask anything..."
: "Type @ to mention files..."
}
disabled={
isStreaming ||
loadingSession ||
startingNewSession
}
className={`w-full ${compact ? "px-3 py-2.5 text-xs" : "px-4 py-3.5 text-sm"} bg-transparent outline-none placeholder:text-[var(--color-text-muted)] disabled:opacity-50`}
style={{
color: "var(--color-text)",
}}
/>
</form>
disabled={
isStreaming ||
loadingSession ||
startingNewSession
}
compact={compact}
/>
{/* Attachment preview strip */}
<AttachmentStrip
@ -1487,20 +1418,12 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
className={`flex items-center justify-between ${compact ? "px-2 pb-1.5" : "px-3 pb-2.5"}`}
>
<div className="flex items-center gap-0.5">
{/* File input (hidden) */}
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={
handleFileSelect
}
/>
<button
type="button"
onClick={() =>
fileInputRef.current?.click()
setShowFilePicker(
true,
)
}
className="p-1.5 rounded-lg hover:opacity-80 transition-opacity"
style={{
@ -1528,10 +1451,12 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
</div>
{/* Send button */}
<button
type="submit"
onClick={handleSubmit}
type="button"
onClick={() => {
editorRef.current?.submit();
}}
disabled={
(!input.trim() &&
(editorEmpty &&
attachedFiles.length ===
0) ||
isStreaming ||
@ -1541,7 +1466,7 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
className={`${compact ? "w-6 h-6" : "w-7 h-7"} rounded-full flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed`}
style={{
background:
input.trim() ||
!editorEmpty ||
attachedFiles.length >
0
? "var(--color-accent)"
@ -1573,6 +1498,15 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
</div>
</div>
</div>
{/* File picker modal */}
<FilePickerModal
open={showFilePicker}
onClose={() =>
setShowFilePicker(false)
}
onSelect={handleFilesSelected}
/>
</div>
);
},

View File

@ -0,0 +1,300 @@
"use client";
import { useState, useMemo } from "react";
type DiffCardProps = {
/** Raw unified diff text (contents of a ```diff block) */
diff: string;
};
type DiffFile = {
oldPath: string;
newPath: string;
hunks: DiffHunk[];
additions: number;
deletions: number;
};
type DiffHunk = {
header: string;
lines: DiffLine[];
};
type DiffLine = {
type: "addition" | "deletion" | "context" | "header";
content: string;
oldLine?: number;
newLine?: number;
};
/** Parse unified diff text into structured file sections. */
function parseDiff(raw: string): DiffFile[] {
const files: DiffFile[] = [];
const lines = raw.split("\n");
let current: DiffFile | null = null;
let currentHunk: DiffHunk | null = null;
let oldLine = 0;
let newLine = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// File header: --- a/path or --- /dev/null
if (line.startsWith("--- ")) {
const nextLine = lines[i + 1];
if (nextLine?.startsWith("+++ ")) {
const oldPath = line.replace(/^--- (a\/)?/, "").trim();
const newPath = nextLine.replace(/^\+\+\+ (b\/)?/, "").trim();
current = { oldPath, newPath, hunks: [], additions: 0, deletions: 0 };
files.push(current);
i++; // skip +++ line
continue;
}
}
// Hunk header: @@ -old,count +new,count @@
const hunkMatch = line.match(/^@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@(.*)/);
if (hunkMatch) {
oldLine = parseInt(hunkMatch[1], 10);
newLine = parseInt(hunkMatch[2], 10);
currentHunk = {
header: line,
lines: [{ type: "header", content: line }],
};
if (current) {
current.hunks.push(currentHunk);
} else {
// Diff without file headers -- create an implicit file
current = { oldPath: "", newPath: "", hunks: [currentHunk], additions: 0, deletions: 0 };
files.push(current);
}
continue;
}
if (!currentHunk || !current) {continue;}
if (line.startsWith("+")) {
currentHunk.lines.push({ type: "addition", content: line.slice(1), newLine });
current.additions++;
newLine++;
} else if (line.startsWith("-")) {
currentHunk.lines.push({ type: "deletion", content: line.slice(1), oldLine });
current.deletions++;
oldLine++;
} else if (line.startsWith(" ") || line === "") {
currentHunk.lines.push({ type: "context", content: line.slice(1) || "", oldLine, newLine });
oldLine++;
newLine++;
}
}
// If no structured files were found, treat the whole thing as one block
if (files.length === 0 && raw.trim()) {
const fallbackLines = raw.split("\n").map((l): DiffLine => {
if (l.startsWith("+")) {return { type: "addition", content: l.slice(1) };}
if (l.startsWith("-")) {return { type: "deletion", content: l.slice(1) };}
return { type: "context", content: l };
});
files.push({
oldPath: "",
newPath: "",
hunks: [{ header: "", lines: fallbackLines }],
additions: fallbackLines.filter((l) => l.type === "addition").length,
deletions: fallbackLines.filter((l) => l.type === "deletion").length,
});
}
return files;
}
function displayPath(file: DiffFile): string {
if (file.newPath && file.newPath !== "/dev/null") {return file.newPath;}
if (file.oldPath && file.oldPath !== "/dev/null") {return file.oldPath;}
return "diff";
}
/* ─── Icons ─── */
function FileIcon() {
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>
);
}
function ChevronIcon({ expanded }: { expanded: boolean }) {
return (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{
transition: "transform 150ms ease",
transform: expanded ? "rotate(90deg)" : "rotate(0deg)",
}}
>
<polyline points="9 18 15 12 9 6" />
</svg>
);
}
/* ─── Single file diff ─── */
function DiffFileCard({ file }: { file: DiffFile }) {
const [expanded, setExpanded] = useState(true);
const path = displayPath(file);
return (
<div
className="rounded-lg border overflow-hidden"
style={{ borderColor: "var(--color-border)" }}
>
{/* File header */}
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-2 w-full px-3 py-2 text-left"
style={{
background: "var(--color-surface)",
borderBottom: expanded ? "1px solid var(--color-border)" : "none",
}}
>
<ChevronIcon expanded={expanded} />
<FileIcon />
<span
className="text-sm font-mono font-medium flex-1 truncate"
style={{ color: "var(--color-text)" }}
>
{path}
</span>
{file.additions > 0 && (
<span className="text-xs font-mono font-medium" style={{ color: "#22c55e" }}>
+{file.additions}
</span>
)}
{file.deletions > 0 && (
<span className="text-xs font-mono font-medium" style={{ color: "#ef4444" }}>
-{file.deletions}
</span>
)}
</button>
{/* Diff lines */}
{expanded && (
<div
className="overflow-x-auto"
style={{ background: "var(--color-bg)" }}
>
<table className="w-full text-xs font-mono leading-5 border-collapse" style={{ tabSize: 4 }}>
<tbody>
{file.hunks.map((hunk, hi) =>
hunk.lines.map((line, li) => {
if (line.type === "header") {
return (
<tr key={`${hi}-${li}`}>
<td
colSpan={3}
className="px-3 py-1 select-none"
style={{
background: "var(--color-surface)",
color: "var(--color-text-muted)",
}}
>
{line.content}
</td>
</tr>
);
}
const bgColor =
line.type === "addition"
? "rgba(34,197,94,0.10)"
: line.type === "deletion"
? "rgba(239,68,68,0.10)"
: "transparent";
const textColor =
line.type === "addition"
? "#4ade80"
: line.type === "deletion"
? "#f87171"
: "var(--color-text)";
const prefix =
line.type === "addition"
? "+"
: line.type === "deletion"
? "-"
: " ";
return (
<tr key={`${hi}-${li}`} style={{ background: bgColor }}>
{/* Old line number */}
<td
className="select-none text-right pr-2 pl-3"
style={{
color: "var(--color-text-muted)",
opacity: 0.5,
width: "1%",
whiteSpace: "nowrap",
userSelect: "none",
}}
>
{line.type !== "addition" ? line.oldLine : ""}
</td>
{/* New line number */}
<td
className="select-none text-right pr-3"
style={{
color: "var(--color-text-muted)",
opacity: 0.5,
width: "1%",
whiteSpace: "nowrap",
userSelect: "none",
}}
>
{line.type !== "deletion" ? line.newLine : ""}
</td>
{/* Content */}
<td
className="pr-4"
style={{ color: textColor }}
>
<span
className="select-none inline-block w-4 text-center"
style={{ opacity: 0.6, userSelect: "none" }}
>
{prefix}
</span>
{line.content}
</td>
</tr>
);
}),
)}
</tbody>
</table>
</div>
)}
</div>
);
}
/* ─── Main DiffCard ─── */
export function DiffCard({ diff }: DiffCardProps) {
const files = useMemo(() => parseDiff(diff), [diff]);
return (
<div className="space-y-2 my-3">
{files.map((file, i) => (
<DiffFileCard key={i} file={file} />
))}
</div>
);
}

View File

@ -0,0 +1,936 @@
"use client";
import {
Fragment,
useCallback,
useEffect,
useRef,
useState,
} from "react";
// ── Types ──
type BrowseEntry = {
name: string;
path: string;
type: "folder" | "file" | "document" | "database";
children?: BrowseEntry[];
};
export type SelectedFile = {
name: string;
path: string;
};
type FilePickerModalProps = {
open: boolean;
onClose: () => void;
onSelect: (files: SelectedFile[]) => void;
};
// ── Helpers ──
function getCategoryFromName(
name: string,
): "image" | "video" | "audio" | "pdf" | "code" | "document" | "folder" | "other" {
const ext = name.split(".").pop()?.toLowerCase() ?? "";
if (
["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "ico", "tiff", "heic"].includes(ext)
)
{return "image";}
if (["mp4", "webm", "mov", "avi", "mkv", "flv"].includes(ext)) {return "video";}
if (["mp3", "wav", "ogg", "aac", "flac", "m4a"].includes(ext)) {return "audio";}
if (ext === "pdf") {return "pdf";}
if (
[
"js", "ts", "tsx", "jsx", "py", "rb", "go", "rs", "java",
"cpp", "c", "h", "css", "html", "json", "yaml", "yml",
"toml", "md", "sh", "bash", "sql", "swift", "kt",
].includes(ext)
)
{return "code";}
if (
["doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "rtf", "csv"].includes(ext)
)
{return "document";}
return "other";
}
function buildBreadcrumbs(
dir: string,
): { label: string; path: string }[] {
const segments: { label: string; path: string }[] = [];
const homeMatch = dir.match(/^(\/Users\/[^/]+|\/home\/[^/]+)/);
const homeDir = homeMatch?.[1];
if (homeDir) {
segments.push({ label: "~", path: homeDir });
const rest = dir.slice(homeDir.length);
const parts = rest.split("/").filter(Boolean);
let currentPath = homeDir;
for (const part of parts) {
currentPath += "/" + part;
segments.push({ label: part, path: currentPath });
}
} else if (dir === "/") {
segments.push({ label: "/", path: "/" });
} else {
segments.push({ label: "/", path: "/" });
const parts = dir.split("/").filter(Boolean);
let currentPath = "";
for (const part of parts) {
currentPath += "/" + part;
segments.push({ label: part, path: currentPath });
}
}
return segments;
}
const pickerColors: Record<string, { bg: string; fg: string }> = {
folder: { bg: "rgba(245, 158, 11, 0.12)", fg: "#f59e0b" },
image: { bg: "rgba(16, 185, 129, 0.12)", fg: "#10b981" },
video: { bg: "rgba(139, 92, 246, 0.12)", fg: "#8b5cf6" },
audio: { bg: "rgba(245, 158, 11, 0.12)", fg: "#f59e0b" },
pdf: { bg: "rgba(239, 68, 68, 0.12)", fg: "#ef4444" },
code: { bg: "rgba(59, 130, 246, 0.12)", fg: "#3b82f6" },
document: { bg: "rgba(107, 114, 128, 0.12)", fg: "#6b7280" },
other: { bg: "rgba(107, 114, 128, 0.08)", fg: "#9ca3af" },
};
// ── Icons ──
function PickerIcon({
category,
size = 16,
}: {
category: string;
size?: number;
}) {
const props = {
width: size,
height: size,
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 2,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
};
switch (category) {
case "folder":
return (
<svg {...props}>
<path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z" />
</svg>
);
case "image":
return (
<svg {...props}>
<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 {...props}>
<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 {...props}>
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
);
case "pdf":
return (
<svg {...props}>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<path d="M14 2v6h6" />
<path d="M10 13h4" />
<path d="M10 17h4" />
</svg>
);
case "code":
return (
<svg {...props}>
<polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" />
</svg>
);
case "document":
return (
<svg {...props}>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<path d="M14 2v6h6" />
<path d="M16 13H8" />
<path d="M16 17H8" />
<path d="M10 9H8" />
</svg>
);
default:
return (
<svg {...props}>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<path d="M14 2v6h6" />
</svg>
);
}
}
// ── Main component ──
export function FilePickerModal({
open,
onClose,
onSelect,
}: FilePickerModalProps) {
const [currentDir, setCurrentDir] = useState<string | null>(null);
const [displayDir, setDisplayDir] = useState("");
const [entries, setEntries] = useState<BrowseEntry[]>([]);
const [parentDir, setParentDir] = useState<string | null>(null);
const [selected, setSelected] = useState<
Map<string, SelectedFile>
>(new Map());
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState("");
const [creatingFolder, setCreatingFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState("");
const [error, setError] = useState<string | null>(null);
// Animation
const [visible, setVisible] = useState(false);
useEffect(() => {
if (open) {
requestAnimationFrame(() =>
requestAnimationFrame(() => setVisible(true)),
);
} else {
setVisible(false);
}
}, [open]);
// Reset transient state on close
useEffect(() => {
if (!open) {
setSearch("");
setCreatingFolder(false);
setNewFolderName("");
setError(null);
}
}, [open]);
// Search input ref for autofocus
const searchRef = useRef<HTMLInputElement>(null);
const newFolderRef = useRef<HTMLInputElement>(null);
// Fetch directory
const fetchDir = useCallback(async (dir: string | null) => {
setLoading(true);
setError(null);
try {
const url = dir
? `/api/workspace/browse?dir=${encodeURIComponent(dir)}`
: "/api/workspace/browse";
const res = await fetch(url);
if (!res.ok) {throw new Error("Failed to list directory");}
const data = await res.json();
setEntries(data.entries || []);
setDisplayDir(data.currentDir || "");
setParentDir(data.parentDir ?? null);
} catch {
setError("Could not load this directory");
setEntries([]);
} finally {
setLoading(false);
}
}, []);
// Fetch on open and navigation
useEffect(() => {
if (open) {fetchDir(currentDir);}
}, [open, currentDir, fetchDir]);
// Escape key
useEffect(() => {
if (!open) {return;}
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") {onClose();}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, onClose]);
// Handlers
const toggleSelect = useCallback(
(entry: BrowseEntry) => {
setSelected((prev) => {
const next = new Map(prev);
if (next.has(entry.path)) {
next.delete(entry.path);
} else {
next.set(entry.path, {
name: entry.name,
path: entry.path,
});
}
return next;
});
},
[],
);
const navigateInto = useCallback((path: string) => {
setCurrentDir(path);
setSearch("");
setCreatingFolder(false);
}, []);
const handleCreateFolder = useCallback(async () => {
if (!newFolderName.trim() || !displayDir) {return;}
const folderPath = `${displayDir}/${newFolderName.trim()}`;
try {
await fetch("/api/workspace/mkdir", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: folderPath }),
});
setCreatingFolder(false);
setNewFolderName("");
fetchDir(currentDir);
} catch {
setError("Failed to create folder");
}
}, [newFolderName, displayDir, currentDir, fetchDir]);
const handleConfirm = useCallback(() => {
onSelect(Array.from(selected.values()));
setSelected(new Map());
onClose();
}, [selected, onSelect, onClose]);
// Filter & sort entries (folders first, then alphabetically)
const sorted = entries
.filter(
(e) =>
!search ||
e.name
.toLowerCase()
.includes(search.toLowerCase()),
)
.toSorted((a, b) => {
if (a.type === "folder" && b.type !== "folder")
{return -1;}
if (a.type !== "folder" && b.type === "folder")
{return 1;}
return a.name.localeCompare(b.name);
});
const breadcrumbs = displayDir
? buildBreadcrumbs(displayDir)
: [];
if (!open) {return null;}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{
opacity: visible ? 1 : 0,
transition: "opacity 150ms ease-out",
}}
>
{/* Backdrop */}
<div
className="absolute inset-0"
style={{
background: "rgba(0,0,0,0.4)",
backdropFilter: "blur(4px)",
}}
onClick={onClose}
/>
{/* Modal */}
<div
className="relative flex flex-col rounded-2xl shadow-2xl overflow-hidden"
style={{
width: 540,
maxHeight: "70vh",
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
transform: visible
? "scale(1)"
: "scale(0.97)",
transition:
"transform 150ms ease-out",
}}
>
{/* Header */}
<div
className="flex items-center justify-between px-5 py-3.5 border-b flex-shrink-0"
style={{
borderColor: "var(--color-border)",
}}
>
<div className="flex items-center gap-2.5">
<div
className="w-8 h-8 rounded-lg flex items-center justify-center"
style={{
background:
pickerColors.folder
.bg,
color: pickerColors
.folder.fg,
}}
>
<PickerIcon
category="folder"
size={18}
/>
</div>
<div>
<h2
className="text-sm font-semibold"
style={{
color: "var(--color-text)",
}}
>
Select Files
</h2>
<p
className="text-[11px]"
style={{
color: "var(--color-text-muted)",
}}
>
Browse and attach
files
</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="w-7 h-7 rounded-lg flex items-center justify-center"
style={{
color: "var(--color-text-muted)",
background:
"var(--color-surface-hover)",
}}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
</div>
{/* Breadcrumb path */}
{displayDir && (
<div
className="flex items-center gap-1 px-5 py-2 border-b overflow-x-auto flex-shrink-0"
style={{
borderColor:
"var(--color-border)",
scrollbarWidth: "thin",
}}
>
{breadcrumbs.map(
(seg, i) => (
<Fragment
key={
seg.path
}
>
{i >
0 && (
<span
className="text-[10px] flex-shrink-0"
style={{
color: "var(--color-text-muted)",
opacity: 0.5,
}}
>
/
</span>
)}
<button
type="button"
onClick={() =>
navigateInto(
seg.path,
)
}
className="text-[12px] font-medium flex-shrink-0 rounded px-1 py-0.5 hover:underline"
style={{
color:
i ===
breadcrumbs.length -
1
? "var(--color-text)"
: "var(--color-text-muted)",
}}
>
{
seg.label
}
</button>
</Fragment>
),
)}
</div>
)}
{/* Search bar + New Folder */}
<div
className="flex items-center gap-2 px-4 py-2 border-b flex-shrink-0"
style={{
borderColor: "var(--color-border)",
}}
>
<div
className="flex-1 flex items-center gap-2 rounded-lg px-2.5 py-1.5"
style={{
background:
"var(--color-bg)",
border: "1px solid var(--color-border)",
}}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{
color: "var(--color-text-muted)",
flexShrink: 0,
}}
>
<circle
cx="11"
cy="11"
r="8"
/>
<path d="m21 21-4.3-4.3" />
</svg>
<input
ref={searchRef}
type="text"
value={search}
onChange={(e) =>
setSearch(
e.target.value,
)
}
placeholder="Filter files..."
className="flex-1 bg-transparent outline-none text-[13px] placeholder:text-[var(--color-text-muted)]"
style={{
color: "var(--color-text)",
}}
/>
</div>
<button
type="button"
onClick={() => {
setCreatingFolder(true);
setTimeout(
() =>
newFolderRef.current?.focus(),
50,
);
}}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-[12px] font-medium whitespace-nowrap"
style={{
color: "var(--color-text-muted)",
background:
"var(--color-surface-hover)",
border: "1px solid var(--color-border)",
}}
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M12 5v14" />
<path d="M5 12h14" />
</svg>
Folder
</button>
</div>
{/* File list */}
<div
className="flex-1 overflow-y-auto"
style={{
background: "var(--color-bg)",
minHeight: 200,
}}
>
{loading ? (
<div className="flex items-center justify-center py-16">
<div
className="w-5 h-5 border-2 rounded-full animate-spin"
style={{
borderColor:
"var(--color-border)",
borderTopColor:
"var(--color-accent)",
}}
/>
</div>
) : error ? (
<div
className="flex items-center justify-center py-16 text-[13px]"
style={{
color: "var(--color-text-muted)",
}}
>
{error}
</div>
) : (
<>
{/* Parent directory row */}
{parentDir && (
<button
type="button"
onClick={() =>
navigateInto(
parentDir,
)
}
className="w-full flex items-center gap-3 px-4 py-2 text-left"
style={{
color: "var(--color-text-muted)",
}}
>
<div
className="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0"
style={{
background:
"var(--color-surface-hover)",
}}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m15 18-6-6 6-6" />
</svg>
</div>
<span className="text-[13px] font-medium">
..
</span>
</button>
)}
{/* New folder input */}
{creatingFolder && (
<div className="flex items-center gap-3 px-4 py-2">
<div
className="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0"
style={{
background:
pickerColors
.folder
.bg,
color: pickerColors
.folder
.fg,
}}
>
<PickerIcon category="folder" />
</div>
<input
ref={
newFolderRef
}
type="text"
value={
newFolderName
}
onChange={(
e,
) =>
setNewFolderName(
e
.target
.value,
)
}
onKeyDown={(
e,
) => {
if (
e.key ===
"Enter"
)
{handleCreateFolder();}
if (
e.key ===
"Escape"
) {
setCreatingFolder(
false,
);
setNewFolderName(
"",
);
}
}}
placeholder="Folder name..."
className="flex-1 bg-transparent outline-none text-[13px] placeholder:text-[var(--color-text-muted)] rounded px-2 py-1"
style={{
color: "var(--color-text)",
background:
"var(--color-surface)",
border: "1px solid var(--color-accent)",
}}
/>
</div>
)}
{/* Entries */}
{sorted.length ===
0 &&
!parentDir && (
<div
className="flex items-center justify-center py-16 text-[13px]"
style={{
color: "var(--color-text-muted)",
}}
>
This
folder
is
empty
</div>
)}
{sorted.map(
(entry) => {
const isFolder =
entry.type ===
"folder";
const category =
isFolder
? "folder"
: getCategoryFromName(
entry.name,
);
const colors =
pickerColors[
category
] ??
pickerColors.other;
const isSelected =
selected.has(
entry.path,
);
return (
<div
key={
entry.path
}
className="flex items-center gap-3 px-4 py-1.5 group cursor-pointer"
style={{
background:
isSelected
? "color-mix(in srgb, var(--color-accent) 8%, transparent)"
: undefined,
}}
onClick={() => {
if (
isFolder
) {
navigateInto(
entry.path,
);
} else {
toggleSelect(
entry,
);
}
}}
>
{/* Checkbox */}
<button
type="button"
onClick={(
e,
) => {
e.stopPropagation();
toggleSelect(
entry,
);
}}
className="w-4 h-4 rounded flex items-center justify-center flex-shrink-0 border"
style={{
borderColor:
isSelected
? "var(--color-accent)"
: "var(--color-border-strong)",
background:
isSelected
? "var(--color-accent)"
: "transparent",
}}
>
{isSelected && (
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="white"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
)}
</button>
{/* Icon */}
<div
className="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0"
style={{
background:
colors.bg,
color: colors.fg,
}}
>
<PickerIcon
category={
category
}
/>
</div>
{/* Name */}
<span
className="flex-1 text-[13px] truncate"
style={{
color: "var(--color-text)",
fontWeight:
isFolder
? 500
: 400,
}}
title={
entry.path
}
>
{
entry.name
}
</span>
{/* Folder chevron */}
{isFolder && (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="flex-shrink-0 opacity-0 group-hover:opacity-50 transition-opacity"
>
<path d="m9 18 6-6-6-6" />
</svg>
)}
</div>
);
},
)}
</>
)}
</div>
{/* Footer */}
<div
className="flex items-center justify-between px-5 py-3 border-t flex-shrink-0"
style={{
borderColor: "var(--color-border)",
background: "var(--color-surface)",
}}
>
<span
className="text-[12px]"
style={{
color: "var(--color-text-muted)",
}}
>
{selected.size > 0
? `${selected.size} ${selected.size === 1 ? "item" : "items"} selected`
: "No files selected"}
</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={onClose}
className="px-3 py-1.5 rounded-lg text-[13px] font-medium"
style={{
color: "var(--color-text-muted)",
background:
"var(--color-surface-hover)",
border: "1px solid var(--color-border)",
}}
>
Cancel
</button>
<button
type="button"
onClick={handleConfirm}
disabled={
selected.size === 0
}
className="px-3 py-1.5 rounded-lg text-[13px] font-medium disabled:opacity-40 disabled:cursor-not-allowed"
style={{
color: "white",
background:
selected.size > 0
? "var(--color-accent)"
: "var(--color-border-strong)",
}}
>
Attach{" "}
{selected.size > 0 &&
`${selected.size} ${selected.size === 1 ? "file" : "files"}`}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,89 @@
"use client";
import { useEffect, useState } from "react";
import { createHighlighter, type Highlighter } from "shiki";
// Singleton highlighter (shared with code-viewer)
let highlighterPromise: Promise<Highlighter> | null = null;
/** Languages to preload for chat code blocks. */
const CHAT_LANGS = [
"typescript", "tsx", "javascript", "jsx",
"python", "ruby", "go", "rust", "java",
"c", "cpp", "csharp", "swift", "kotlin",
"css", "scss", "html", "xml",
"json", "yaml", "toml",
"bash", "sql", "graphql",
"markdown", "diff", "php", "lua",
"vue", "svelte", "dart", "zig",
];
function getHighlighter(): Promise<Highlighter> {
if (!highlighterPromise) {
highlighterPromise = createHighlighter({
themes: ["github-dark", "github-light"],
langs: CHAT_LANGS,
});
}
return highlighterPromise;
}
type SyntaxBlockProps = {
code: string;
lang: string;
};
/**
* Renders a syntax-highlighted code block using shiki.
* Falls back to plain monospace while loading.
*/
export function SyntaxBlock({ code, lang }: SyntaxBlockProps) {
const [html, setHtml] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
getHighlighter().then((hl) => {
if (cancelled) {return;}
try {
const result = hl.codeToHtml(code, {
lang,
themes: {
dark: "github-dark",
light: "github-light",
},
});
setHtml(result);
} catch {
// If the language isn't loaded, fall back to plain text
try {
const result = hl.codeToHtml(code, {
lang: "text",
themes: {
dark: "github-dark",
light: "github-light",
},
});
setHtml(result);
} catch {
// Give up on highlighting
}
}
});
return () => { cancelled = true; };
}, [code, lang]);
if (html) {
return (
<div
className="syntax-block"
// biome-ignore lint/security/noDangerouslySetInnerHtml: shiki output is trusted
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
// Fallback: plain code while shiki loads
return (
<code className="block">{code}</code>
);
}

View File

@ -0,0 +1,415 @@
"use client";
import {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
} from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import Suggestion from "@tiptap/suggestion";
import { Extension, type Editor } from "@tiptap/core";
import { FileMentionNode, chatFileMentionPluginKey } from "./file-mention-extension";
import {
createFileMentionRenderer,
type SuggestItem,
} from "./file-mention-list";
// ── Types ──
export type ChatEditorHandle = {
/** Insert a file mention node programmatically. */
insertFileMention: (name: string, path: string) => void;
/** Clear the editor content. */
clear: () => void;
/** Focus the editor. */
focus: () => void;
/** Check if the editor is empty (no text, no mentions). */
isEmpty: () => boolean;
/** Programmatically submit the current content. */
submit: () => void;
};
type ChatEditorProps = {
/** Called when user presses Enter (without Shift). */
onSubmit: (text: string, mentionedFiles: Array<{ name: string; path: string }>) => void;
/** Called on every content change. */
onChange?: (isEmpty: boolean) => void;
placeholder?: string;
disabled?: boolean;
compact?: boolean;
};
// ── Helpers ──
function getFileCategory(name: string): string {
const ext = name.split(".").pop()?.toLowerCase() ?? "";
if (
["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "ico", "tiff", "heic"].includes(ext)
)
{return "image";}
if (["mp4", "webm", "mov", "avi", "mkv", "flv"].includes(ext)) {return "video";}
if (["mp3", "wav", "ogg", "aac", "flac", "m4a"].includes(ext)) {return "audio";}
if (ext === "pdf") {return "pdf";}
if (
[
"js", "ts", "tsx", "jsx", "py", "rb", "go", "rs", "java",
"cpp", "c", "h", "css", "html", "json", "yaml", "yml",
"toml", "md", "sh", "bash", "sql", "swift", "kt",
].includes(ext)
)
{return "code";}
if (["doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "rtf", "csv"].includes(ext))
{return "document";}
return "other";
}
const categoryColors: Record<string, { bg: string; fg: string }> = {
image: { bg: "rgba(16, 185, 129, 0.15)", fg: "#10b981" },
video: { bg: "rgba(139, 92, 246, 0.15)", fg: "#8b5cf6" },
audio: { bg: "rgba(245, 158, 11, 0.15)", fg: "#f59e0b" },
pdf: { bg: "rgba(239, 68, 68, 0.15)", fg: "#ef4444" },
code: { bg: "rgba(59, 130, 246, 0.15)", fg: "#3b82f6" },
document: { bg: "rgba(107, 114, 128, 0.15)", fg: "#6b7280" },
folder: { bg: "rgba(245, 158, 11, 0.15)", fg: "#f59e0b" },
other: { bg: "rgba(107, 114, 128, 0.10)", fg: "#9ca3af" },
};
function shortenPath(path: string): string {
return path
.replace(/^\/Users\/[^/]+/, "~")
.replace(/^\/home\/[^/]+/, "~")
.replace(/^[A-Z]:\\Users\\[^\\]+/, "~");
}
/**
* Serialize the editor content to plain text with file mention markers.
* Returns { text, mentionedFiles }.
*/
function serializeContent(editor: ReturnType<typeof useEditor>): {
text: string;
mentionedFiles: Array<{ name: string; path: string }>;
} {
if (!editor) {return { text: "", mentionedFiles: [] };}
const mentionedFiles: Array<{ name: string; path: string }> = [];
const parts: string[] = [];
editor.state.doc.descendants((node) => {
if (node.type.name === "chatFileMention") {
const label = node.attrs.label as string;
const path = node.attrs.path as string;
mentionedFiles.push({ name: label, path });
parts.push(`[file: ${path}]`);
return false;
}
if (node.isText && node.text) {
parts.push(node.text);
}
if (node.type.name === "paragraph" && parts.length > 0) {
// Add newline between paragraphs (except the first)
const lastPart = parts[parts.length - 1];
if (lastPart !== undefined && lastPart !== "\n") {
parts.push("\n");
}
}
return true;
});
return { text: parts.join("").trim(), mentionedFiles };
}
// ── File mention suggestion extension (wired to the async popup) ──
function createChatFileMentionSuggestion() {
return Extension.create({
name: "chatFileMentionSuggestion",
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
char: "@",
pluginKey: chatFileMentionPluginKey,
startOfLine: false,
allowSpaces: true,
command: ({
editor,
range,
props,
}: {
editor: Editor;
range: { from: number; to: number };
props: SuggestItem;
}) => {
// For folders: update the query text to navigate into the folder
if (props.type === "folder") {
// Replace the current @query with @folderpath/
const shortPath = shortenPath(props.path);
editor
.chain()
.focus()
.deleteRange(range)
.insertContent(`@${shortPath}/`)
.run();
return;
}
// For files: insert mention node + trailing space
editor
.chain()
.focus()
.deleteRange(range)
.insertContent([
{
type: "chatFileMention",
attrs: {
label: props.name,
path: props.path,
},
},
{ type: "text", text: " " },
])
.run();
},
items: ({ query }: { query: string }) => {
// Items are fetched async by the renderer, return empty here
void query;
return [];
},
render: createFileMentionRenderer(),
}),
];
},
});
}
// ── Main component ──
export const ChatEditor = forwardRef<ChatEditorHandle, ChatEditorProps>(
function ChatEditor({ onSubmit, onChange, placeholder, disabled, compact }, ref) {
const submitRef = useRef(onSubmit);
submitRef.current = onSubmit;
const editor = useEditor({
immediatelyRender: false,
extensions: [
StarterKit.configure({
heading: false,
codeBlock: false,
blockquote: false,
horizontalRule: false,
bulletList: false,
orderedList: false,
listItem: false,
}),
Placeholder.configure({
placeholder: placeholder ?? "Ask anything...",
}),
FileMentionNode,
createChatFileMentionSuggestion(),
],
editorProps: {
attributes: {
class: `chat-editor-content ${compact ? "chat-editor-compact" : ""}`,
style: `color: var(--color-text);`,
},
handleKeyDown: (_view, event) => {
// Enter without shift = submit
if (event.key === "Enter" && !event.shiftKey) {
// Don't submit if suggestion popup is active
// The suggestion plugin handles Enter in that case
return false;
}
return false;
},
},
onUpdate: ({ editor: ed }) => {
onChange?.(ed.isEmpty);
},
});
// Handle Enter-to-submit via a keydown listener on the editor DOM
useEffect(() => {
if (!editor) {return;}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
// Check if suggestion popup is active by checking if the plugin has active state
const suggestState = chatFileMentionPluginKey.getState(editor.state);
if (suggestState?.active) {return;} // Let suggestion handle it
event.preventDefault();
const { text, mentionedFiles } = serializeContent(editor);
if (text.trim() || mentionedFiles.length > 0) {
submitRef.current(text, mentionedFiles);
editor.commands.clearContent(true);
}
}
};
const el = editor.view.dom;
el.addEventListener("keydown", handleKeyDown);
return () => el.removeEventListener("keydown", handleKeyDown);
}, [editor]);
// Handle drag-and-drop of files from the sidebar
useEffect(() => {
if (!editor) {return;}
const el = editor.view.dom;
const handleDragOver = (e: DragEvent) => {
if (e.dataTransfer?.types.includes("application/x-file-mention")) {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
}
};
const handleDrop = (e: DragEvent) => {
const data = e.dataTransfer?.getData("application/x-file-mention");
if (!data) {return;}
e.preventDefault();
e.stopPropagation();
try {
const { name, path } = JSON.parse(data);
if (name && path) {
editor
.chain()
.focus()
.insertContent([
{
type: "chatFileMention",
attrs: { label: name, path },
},
{ type: "text", text: " " },
])
.run();
}
} catch {
// ignore malformed data
}
};
el.addEventListener("dragover", handleDragOver);
el.addEventListener("drop", handleDrop);
return () => {
el.removeEventListener("dragover", handleDragOver);
el.removeEventListener("drop", handleDrop);
};
}, [editor]);
// Disable/enable editor
useEffect(() => {
if (editor) {
editor.setEditable(!disabled);
}
}, [editor, disabled]);
useImperativeHandle(ref, () => ({
insertFileMention: (name: string, path: string) => {
editor
?.chain()
.focus()
.insertContent([
{
type: "chatFileMention",
attrs: { label: name, path },
},
{ type: "text", text: " " },
])
.run();
},
clear: () => {
editor?.commands.clearContent(true);
},
focus: () => {
editor?.commands.focus();
},
isEmpty: () => {
return editor?.isEmpty ?? true;
},
submit: () => {
if (!editor) {return;}
const { text, mentionedFiles } = serializeContent(editor);
if (text.trim() || mentionedFiles.length > 0) {
submitRef.current(text, mentionedFiles);
editor.commands.clearContent(true);
}
},
}));
return (
<>
<EditorContent editor={editor} />
<style>{`
.chat-editor-content {
outline: none;
min-height: 20px;
max-height: 200px;
overflow-y: auto;
padding: ${compact ? "10px 12px" : "14px 16px"};
font-size: ${compact ? "12px" : "14px"};
line-height: 1.5;
}
.chat-editor-content p {
margin: 0;
}
.chat-editor-content p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
color: var(--color-text-muted);
float: left;
height: 0;
pointer-events: none;
}
/* File mention pill styles */
.chat-editor-content span[data-chat-file-mention] {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 1px 8px 1px 6px;
margin: 0 1px;
border-radius: 6px;
background: var(--mention-bg, rgba(59, 130, 246, 0.12));
color: var(--mention-fg, #3b82f6);
font-size: 12px;
font-weight: 500;
line-height: 1.6;
vertical-align: baseline;
cursor: default;
user-select: all;
white-space: nowrap;
transition: opacity 0.15s ease;
}
.chat-editor-content span[data-chat-file-mention]::before {
content: "@";
opacity: 0.5;
font-size: 11px;
}
.chat-editor-content span[data-chat-file-mention]:hover {
opacity: 0.85;
}
.chat-editor-content.chat-editor-compact {
min-height: 16px;
}
`}</style>
</>
);
},
);
/**
* Helper to extract file mention info for styling (used by renderHTML).
* Returns CSS custom properties for the mention pill.
*/
export function getMentionStyle(label: string): React.CSSProperties {
const category = getFileCategory(label);
const colors = categoryColors[category] ?? categoryColors.other;
return {
"--mention-bg": colors.bg,
"--mention-fg": colors.fg,
} as React.CSSProperties;
}

View File

@ -0,0 +1,102 @@
import { Node, mergeAttributes } from "@tiptap/core";
import { type SuggestionOptions } from "@tiptap/suggestion";
import { PluginKey } from "@tiptap/pm/state";
export const chatFileMentionPluginKey = new PluginKey("chatFileMention");
export type FileMentionAttrs = {
label: string;
path: string;
};
/** Resolve mention pill colors from the filename extension. */
function mentionColors(label: string): { bg: string; fg: string } {
const ext = label.split(".").pop()?.toLowerCase() ?? "";
if (
["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "ico", "tiff", "heic"].includes(ext)
)
{return { bg: "rgba(16,185,129,0.15)", fg: "#10b981" };}
if (["mp4", "webm", "mov", "avi", "mkv", "flv"].includes(ext))
{return { bg: "rgba(139,92,246,0.15)", fg: "#8b5cf6" };}
if (["mp3", "wav", "ogg", "aac", "flac", "m4a"].includes(ext))
{return { bg: "rgba(245,158,11,0.15)", fg: "#f59e0b" };}
if (ext === "pdf") {return { bg: "rgba(239,68,68,0.15)", fg: "#ef4444" };}
if (
[
"js", "ts", "tsx", "jsx", "py", "rb", "go", "rs", "java",
"cpp", "c", "h", "css", "html", "json", "yaml", "yml",
"toml", "md", "sh", "bash", "sql", "swift", "kt",
].includes(ext)
)
{return { bg: "rgba(59,130,246,0.15)", fg: "#3b82f6" };}
if (
["doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "rtf", "csv"].includes(ext)
)
{return { bg: "rgba(107,114,128,0.15)", fg: "#6b7280" };}
return { bg: "rgba(107,114,128,0.10)", fg: "#9ca3af" };
}
/**
* Inline atom node for file mentions in the chat editor.
* Renders as a non-editable pill: [@icon filename].
* Serializes to `[file: /absolute/path]` for the chat API.
*/
export const FileMentionNode = Node.create({
name: "chatFileMention",
group: "inline",
inline: true,
atom: true,
selectable: true,
draggable: true,
addAttributes() {
return {
label: { default: "" },
path: { default: "" },
};
},
parseHTML() {
return [{ tag: 'span[data-chat-file-mention]' }];
},
renderHTML({ HTMLAttributes }) {
const label = (HTMLAttributes.label as string) || "file";
const colors = mentionColors(label);
return [
"span",
mergeAttributes(
{
"data-chat-file-mention": "",
class: "chat-file-mention",
style: `--mention-bg: ${colors.bg}; --mention-fg: ${colors.fg};`,
title: HTMLAttributes.path || "",
},
HTMLAttributes,
),
`@${label}`,
];
},
});
/** Suggestion configuration for the @ trigger in the chat editor. */
export type FileMentionSuggestionOptions = Omit<
SuggestionOptions<{ name: string; path: string; type: string }>,
"editor"
>;
/**
* Build the suggestion config for the file mention node.
* The actual items fetching and rendering is handled by the chat-editor component.
*/
export function buildFileMentionSuggestion(
overrides: Partial<FileMentionSuggestionOptions>,
): Partial<FileMentionSuggestionOptions> {
return {
char: "@",
pluginKey: chatFileMentionPluginKey,
startOfLine: false,
allowSpaces: true,
...overrides,
};
}

View File

@ -0,0 +1,501 @@
"use client";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
// ── Types ──
type SuggestItem = {
name: string;
path: string;
type: "folder" | "file" | "document" | "database";
};
export type FileMentionListRef = {
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
};
type FileMentionListProps = {
items: SuggestItem[];
command: (item: SuggestItem) => void;
loading?: boolean;
};
// ── File type helpers ──
function getFileCategory(
name: string,
type: string,
): "folder" | "image" | "video" | "audio" | "pdf" | "code" | "document" | "database" | "other" {
if (type === "folder") {return "folder";}
if (type === "database") {return "database";}
const ext = name.split(".").pop()?.toLowerCase() ?? "";
if (
["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "ico", "tiff", "heic"].includes(ext)
)
{return "image";}
if (["mp4", "webm", "mov", "avi", "mkv", "flv"].includes(ext)) {return "video";}
if (["mp3", "wav", "ogg", "aac", "flac", "m4a"].includes(ext)) {return "audio";}
if (ext === "pdf") {return "pdf";}
if (
[
"js", "ts", "tsx", "jsx", "py", "rb", "go", "rs", "java",
"cpp", "c", "h", "css", "html", "json", "yaml", "yml",
"toml", "md", "sh", "bash", "sql", "swift", "kt",
].includes(ext)
)
{return "code";}
if (type === "document") {return "document";}
return "other";
}
const categoryColors: Record<string, { bg: string; fg: string }> = {
folder: { bg: "rgba(245, 158, 11, 0.12)", fg: "#f59e0b" },
image: { bg: "rgba(16, 185, 129, 0.12)", fg: "#10b981" },
video: { bg: "rgba(139, 92, 246, 0.12)", fg: "#8b5cf6" },
audio: { bg: "rgba(245, 158, 11, 0.12)", fg: "#f59e0b" },
pdf: { bg: "rgba(239, 68, 68, 0.12)", fg: "#ef4444" },
code: { bg: "rgba(59, 130, 246, 0.12)", fg: "#3b82f6" },
document: { bg: "rgba(107, 114, 128, 0.12)", fg: "#6b7280" },
database: { bg: "rgba(168, 85, 247, 0.12)", fg: "#a855f7" },
other: { bg: "rgba(107, 114, 128, 0.08)", fg: "#9ca3af" },
};
function MiniIcon({ category }: { category: string }) {
const props = {
width: 12,
height: 12,
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 2,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
};
switch (category) {
case "folder":
return (
<svg {...props}>
<path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z" />
</svg>
);
case "image":
return (
<svg {...props}>
<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 {...props}>
<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 {...props}>
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
);
case "pdf":
return (
<svg {...props}>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<path d="M14 2v6h6" />
<path d="M10 13h4" />
<path d="M10 17h4" />
</svg>
);
case "code":
return (
<svg {...props}>
<polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" />
</svg>
);
case "database":
return (
<svg {...props}>
<ellipse cx="12" cy="5" rx="9" ry="3" />
<path d="M3 5V19A9 3 0 0 0 21 19V5" />
<path d="M3 12A9 3 0 0 0 21 12" />
</svg>
);
default:
return (
<svg {...props}>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<path d="M14 2v6h6" />
</svg>
);
}
}
function shortenPath(path: string): string {
return path
.replace(/^\/Users\/[^/]+/, "~")
.replace(/^\/home\/[^/]+/, "~")
.replace(/^[A-Z]:\\Users\\[^\\]+/, "~");
}
// ── List component ──
const FileMentionList = forwardRef<FileMentionListRef, FileMentionListProps>(
({ items, command, loading }, ref) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const listRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setSelectedIndex(0);
}, [items]);
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" || event.key === "Tab") {
selectItem(selectedIndex);
return true;
}
return false;
},
}));
if (loading) {
return (
<div
className="rounded-xl py-3 px-4 shadow-xl"
style={{
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
backdropFilter: "blur(12px)",
minWidth: 260,
}}
>
<div className="flex items-center gap-2">
<div
className="w-3.5 h-3.5 border-2 rounded-full animate-spin"
style={{
borderColor: "var(--color-border)",
borderTopColor: "var(--color-accent)",
}}
/>
<span
className="text-[12px]"
style={{ color: "var(--color-text-muted)" }}
>
Searching files...
</span>
</div>
</div>
);
}
if (items.length === 0) {
return (
<div
className="rounded-xl py-3 px-4 shadow-xl"
style={{
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
backdropFilter: "blur(12px)",
minWidth: 260,
}}
>
<span
className="text-[12px]"
style={{ color: "var(--color-text-muted)" }}
>
No files found
</span>
</div>
);
}
return (
<div
ref={listRef}
className="rounded-xl py-1 shadow-xl overflow-y-auto"
style={{
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
backdropFilter: "blur(12px)",
minWidth: 280,
maxWidth: 400,
maxHeight: 300,
}}
>
{items.map((item, index) => {
const category = getFileCategory(item.name, item.type);
const colors = categoryColors[category] ?? categoryColors.other;
const short = shortenPath(item.path);
return (
<button
key={item.path}
type="button"
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-left transition-colors"
style={{
background:
index === selectedIndex
? "var(--color-surface-hover)"
: "transparent",
}}
onClick={() => selectItem(index)}
onMouseEnter={() => setSelectedIndex(index)}
>
<div
className="w-6 h-6 rounded-md flex items-center justify-center flex-shrink-0"
style={{ background: colors.bg, color: colors.fg }}
>
<MiniIcon category={category} />
</div>
<div className="min-w-0 flex-1">
<div
className="text-[12px] font-medium truncate"
style={{ color: "var(--color-text)" }}
>
{item.name}
</div>
<div
className="text-[10px] truncate"
style={{ color: "var(--color-text-muted)" }}
title={item.path}
>
{short}
</div>
</div>
{item.type === "folder" && (
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="flex-shrink-0 opacity-40"
>
<path d="m9 18 6-6-6-6" />
</svg>
)}
</button>
);
})}
</div>
);
},
);
FileMentionList.displayName = "FileMentionList";
// ── Floating portal renderer for Tiptap suggestion ──
export type MentionRendererProps = {
items: SuggestItem[];
command: (item: SuggestItem) => void;
clientRect: (() => DOMRect | null) | null | undefined;
componentRef: React.RefObject<FileMentionListRef | null>;
loading?: boolean;
};
export function MentionPopupRenderer({
items,
command,
clientRect,
componentRef,
loading,
}: MentionRendererProps) {
const popupRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
if (!popupRef.current || !clientRect) {return;}
const rect = clientRect();
if (!rect) {return;}
const el = popupRef.current;
const popupHeight = el.offsetHeight || 200;
// Position above the cursor if not enough space below
const spaceBelow = window.innerHeight - rect.bottom;
if (spaceBelow < popupHeight + 8) {
el.style.position = "fixed";
el.style.left = `${rect.left}px`;
el.style.bottom = `${window.innerHeight - rect.top + 4}px`;
el.style.top = "auto";
} else {
el.style.position = "fixed";
el.style.left = `${rect.left}px`;
el.style.top = `${rect.bottom + 4}px`;
el.style.bottom = "auto";
}
el.style.zIndex = "100";
}, [clientRect, items, loading]);
return createPortal(
<div ref={popupRef}>
<FileMentionList
ref={componentRef}
items={items}
command={command}
loading={loading}
/>
</div>,
document.body,
);
}
/**
* Creates a Tiptap suggestion render() function that fetches file suggestions
* from /api/workspace/suggest-files and renders them in a floating popup.
*/
export function createFileMentionRenderer() {
return () => {
let container: HTMLDivElement | null = null;
let root: ReturnType<typeof import("react-dom/client").createRoot> | null =
null;
const componentRef: React.RefObject<FileMentionListRef | null> = {
current: null,
};
let currentQuery = "";
let currentItems: SuggestItem[] = [];
let isLoading = false;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let latestCommand: ((item: SuggestItem) => void) | null = null;
let latestClientRect: (() => DOMRect | null) | null = null;
function render() {
if (!root || !latestCommand) {return;}
root.render(
<MentionPopupRenderer
items={currentItems}
command={latestCommand}
clientRect={latestClientRect}
componentRef={componentRef}
loading={isLoading}
/>,
);
}
async function fetchSuggestions(query: string) {
isLoading = true;
render();
try {
const hasPath =
query.startsWith("/") ||
query.startsWith("~/") ||
query.startsWith("../") ||
query.startsWith("./") ||
query.includes("/");
const param = hasPath
? `path=${encodeURIComponent(query)}`
: query
? `q=${encodeURIComponent(query)}`
: "";
const url = `/api/workspace/suggest-files${param ? `?${param}` : ""}`;
const res = await fetch(url);
const data = await res.json();
currentItems = data.items ?? [];
} catch {
currentItems = [];
}
isLoading = false;
render();
}
function debouncedFetch(query: string) {
if (debounceTimer) {clearTimeout(debounceTimer);}
debounceTimer = setTimeout(() => {
fetchSuggestions(query);
}, 120);
}
return {
onStart: (props: {
query: string;
command: (item: SuggestItem) => void;
clientRect?: (() => DOMRect | null) | null;
}) => {
container = document.createElement("div");
document.body.appendChild(container);
latestCommand = props.command;
latestClientRect = props.clientRect ?? null;
currentQuery = props.query;
import("react-dom/client").then(({ createRoot }) => {
root = createRoot(container!);
debouncedFetch(currentQuery);
});
},
onUpdate: (props: {
query: string;
command: (item: SuggestItem) => void;
clientRect?: (() => DOMRect | null) | null;
}) => {
latestCommand = props.command;
latestClientRect = props.clientRect ?? null;
currentQuery = props.query;
debouncedFetch(currentQuery);
},
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: () => {
if (debounceTimer) {clearTimeout(debounceTimer);}
root?.unmount();
container?.remove();
container = null;
root = null;
},
};
};
}
export type { SuggestItem };

View File

@ -0,0 +1,251 @@
"use client";
import { useEffect, useState, useMemo } from "react";
import { createHighlighter, type Highlighter } from "shiki";
import { DiffCard } from "../diff-viewer";
/** Map file extensions to shiki language identifiers. */
const EXT_TO_LANG: Record<string, string> = {
ts: "typescript",
tsx: "tsx",
js: "javascript",
jsx: "jsx",
mjs: "javascript",
cjs: "javascript",
py: "python",
rb: "ruby",
go: "go",
rs: "rust",
java: "java",
kt: "kotlin",
swift: "swift",
c: "c",
cpp: "cpp",
h: "c",
hpp: "cpp",
cs: "csharp",
css: "css",
scss: "scss",
less: "less",
html: "html",
htm: "html",
xml: "xml",
svg: "xml",
json: "json",
jsonc: "jsonc",
yaml: "yaml",
yml: "yaml",
toml: "toml",
md: "markdown",
mdx: "mdx",
sh: "bash",
bash: "bash",
zsh: "bash",
fish: "fish",
ps1: "powershell",
sql: "sql",
graphql: "graphql",
gql: "graphql",
dockerfile: "dockerfile",
docker: "dockerfile",
makefile: "makefile",
cmake: "cmake",
r: "r",
lua: "lua",
php: "php",
vue: "vue",
svelte: "svelte",
diff: "diff",
patch: "diff",
ini: "ini",
env: "ini",
tf: "terraform",
proto: "proto",
zig: "zig",
elixir: "elixir",
ex: "elixir",
erl: "erlang",
hs: "haskell",
scala: "scala",
clj: "clojure",
dart: "dart",
};
/** All language IDs we might need to load. */
const ALL_LANGS = [...new Set(Object.values(EXT_TO_LANG))];
function extFromFilename(filename: string): string {
const lower = filename.toLowerCase();
// Handle special filenames
if (lower === "dockerfile" || lower.startsWith("dockerfile.")) {return "dockerfile";}
if (lower === "makefile" || lower === "gnumakefile") {return "makefile";}
if (lower === "cmakelists.txt") {return "cmake";}
return lower.split(".").pop() ?? "";
}
export function langFromFilename(filename: string): string {
const ext = extFromFilename(filename);
return EXT_TO_LANG[ext] ?? "text";
}
export function isCodeFile(filename: string): boolean {
const ext = extFromFilename(filename);
return ext in EXT_TO_LANG;
}
type CodeViewerProps = {
content: string;
filename: string;
};
// Singleton highlighter so we only create it once
let highlighterPromise: Promise<Highlighter> | null = null;
function getHighlighter(): Promise<Highlighter> {
if (!highlighterPromise) {
highlighterPromise = createHighlighter({
themes: ["github-dark", "github-light"],
langs: ALL_LANGS,
});
}
return highlighterPromise;
}
export function CodeViewer({ content, filename }: CodeViewerProps) {
const lang = langFromFilename(filename);
const ext = extFromFilename(filename);
// For .diff/.patch files, use the DiffCard instead
if (ext === "diff" || ext === "patch") {
return (
<div className="max-w-4xl mx-auto px-6 py-8">
<DiffCard diff={content} />
</div>
);
}
return <HighlightedCode content={content} filename={filename} lang={lang} />;
}
function HighlightedCode({
content,
filename,
lang,
}: {
content: string;
filename: string;
lang: string;
}) {
const [html, setHtml] = useState<string | null>(null);
const lineCount = useMemo(() => content.split("\n").length, [content]);
useEffect(() => {
let cancelled = false;
getHighlighter().then((highlighter) => {
if (cancelled) {return;}
const result = highlighter.codeToHtml(content, {
lang: lang === "text" ? "text" : lang,
themes: {
dark: "github-dark",
light: "github-light",
},
// We'll handle line numbers ourselves
});
setHtml(result);
});
return () => { cancelled = true; };
}, [content, lang]);
return (
<div className="max-w-4xl mx-auto px-6 py-8">
{/* File header */}
<div
className="flex items-center gap-2 px-4 py-2.5 rounded-t-lg border border-b-0"
style={{
background: "var(--color-surface)",
borderColor: "var(--color-border)",
}}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{ color: "var(--color-text-muted)" }}
>
<polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" />
</svg>
<span
className="text-sm font-medium flex-1 truncate"
style={{ color: "var(--color-text)" }}
>
{filename}
</span>
<span
className="text-xs px-1.5 py-0.5 rounded"
style={{
background: "var(--color-surface-hover)",
color: "var(--color-text-muted)",
}}
>
{lang.toUpperCase()}
</span>
<span
className="text-xs"
style={{ color: "var(--color-text-muted)" }}
>
{lineCount} lines
</span>
</div>
{/* Code content */}
<div
className="code-viewer-content rounded-b-lg border overflow-x-auto"
style={{
background: "var(--color-bg)",
borderColor: "var(--color-border)",
}}
>
{html ? (
<div
className="code-viewer-highlighted"
// biome-ignore lint/security/noDangerouslySetInnerHtml: shiki output is trusted
dangerouslySetInnerHTML={{ __html: html }}
/>
) : (
// Fallback: plain text with line numbers while loading
<pre className="text-sm leading-6" style={{ margin: 0 }}>
<code>
{content.split("\n").map((line, idx) => (
<div
key={idx}
className="flex hover:bg-[var(--color-surface-hover)] transition-colors duration-75"
>
<span
className="select-none text-right pr-4 pl-4 flex-shrink-0 tabular-nums"
style={{
color: "var(--color-text-muted)",
opacity: 0.5,
minWidth: "3rem",
userSelect: "none",
}}
>
{idx + 1}
</span>
<span className="pr-4 flex-1" style={{ color: "var(--color-text)" }}>
{line || " "}
</span>
</div>
))}
</code>
</pre>
)}
</div>
</div>
);
}

View File

@ -3,7 +3,9 @@
import { useState, useCallback, type MouseEvent as ReactMouseEvent } from "react";
import dynamic from "next/dynamic";
import { splitReportBlocks, hasReportBlocks } from "@/lib/report-blocks";
import { splitDiffBlocks, hasDiffBlocks } from "@/lib/diff-blocks";
import { isWorkspaceLink } from "@/lib/workspace-links";
import { DiffCard } from "../diff-viewer";
import type { TreeNode, MentionSearchFn } from "./slash-command";
// Load markdown renderer client-only to avoid SSR issues with ESM-only packages
@ -99,8 +101,8 @@ export function DocumentView({
);
}
// Check if the markdown contains embedded report-json blocks
const hasReports = hasReportBlocks(markdownBody);
// Check if the markdown contains embedded rich blocks (reports or diffs)
const hasRichBlocks = hasReportBlocks(markdownBody) || hasDiffBlocks(markdownBody);
// Intercept workspace-internal links in read mode (delegated click handler)
const handleLinkClick = useCallback(
@ -149,8 +151,8 @@ export function DocumentView({
)}
</div>
{hasReports ? (
<EmbeddedReportContent content={markdownBody} />
{hasRichBlocks ? (
<EmbeddedRichContent content={markdownBody} />
) : (
<div className="workspace-prose">
<MarkdownContent content={markdownBody} />
@ -161,11 +163,28 @@ export function DocumentView({
}
/**
* Renders markdown content that contains embedded report-json blocks.
* Splits the content into alternating markdown and interactive chart sections.
* Renders markdown content that contains embedded rich blocks (reports and diffs).
* Splits the content into alternating markdown, chart, and diff sections.
*/
function EmbeddedReportContent({ content }: { content: string }) {
const segments = splitReportBlocks(content);
function EmbeddedRichContent({ content }: { content: string }) {
// First split on report blocks, then further split text segments on diff blocks
const reportSegments = splitReportBlocks(content);
type RichSegment =
| { type: "text"; text: string }
| { type: "report-artifact"; config: import("@/lib/report-blocks").ReportConfig }
| { type: "diff-artifact"; diff: string };
const segments: RichSegment[] = [];
for (const seg of reportSegments) {
if (seg.type === "text" && hasDiffBlocks(seg.text)) {
for (const ds of splitDiffBlocks(seg.text)) {
segments.push(ds);
}
} else {
segments.push(seg as RichSegment);
}
}
return (
<div className="space-y-4">
@ -177,6 +196,13 @@ function EmbeddedReportContent({ content }: { content: string }) {
</div>
);
}
if (segment.type === "diff-artifact") {
return (
<div key={index} className="my-4">
<DiffCard diff={segment.diff} />
</div>
);
}
// Text segment -- render as markdown
return (
<div key={index} className="workspace-prose">

View File

@ -448,6 +448,17 @@ function DraggableNode({
{...listeners}
role="treeitem"
tabIndex={-1}
draggable={!isProtected}
onDragStart={(e) => {
// Native HTML5 drag for cross-component drops (e.g. into chat editor).
// Coexists with @dnd-kit which uses pointer events for intra-tree reordering.
e.dataTransfer.setData(
"application/x-file-mention",
JSON.stringify({ name: node.name, path: node.path }),
);
e.dataTransfer.setData("text/plain", node.path);
e.dataTransfer.effectAllowed = "copy";
}}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onContextMenu={handleContextMenu}

View File

@ -1,8 +1,15 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useState, useRef, useCallback } from "react";
import { FileManagerTree, type TreeNode } from "./file-manager-tree";
/** Shape returned by /api/workspace/suggest-files */
type SuggestItem = {
name: string;
path: string;
type: "folder" | "file" | "document" | "database";
};
type WorkspaceSidebarProps = {
tree: TreeNode[];
activePath: string | null;
@ -18,6 +25,8 @@ type WorkspaceSidebarProps = {
onNavigateUp?: () => void;
/** Return to workspace mode from browse mode. */
onGoHome?: () => void;
/** Called when a file/folder is selected from the search dropdown. */
onFileSearchSelect?: (item: SuggestItem) => void;
};
function WorkspaceLogo() {
@ -145,6 +154,232 @@ function ThemeToggle() {
);
}
function SearchIcon() {
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" />
<path d="m21 21-4.3-4.3" />
</svg>
);
}
function SmallFolderIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
</svg>
);
}
function SmallFileIcon() {
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>
);
}
function SmallDocIcon() {
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" /><path d="M10 9H8" /><path d="M16 13H8" /><path d="M16 17H8" />
</svg>
);
}
function SmallDbIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<ellipse cx="12" cy="5" rx="9" ry="3" /><path d="M3 5V19A9 3 0 0 0 21 19V5" /><path d="M3 12A9 3 0 0 0 21 12" />
</svg>
);
}
function SuggestTypeIcon({ type }: { type: string }) {
switch (type) {
case "folder": return <SmallFolderIcon />;
case "document": return <SmallDocIcon />;
case "database": return <SmallDbIcon />;
default: return <SmallFileIcon />;
}
}
/* ─── File search ─── */
function FileSearch({ onSelect }: { onSelect: (item: SuggestItem) => void }) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<SuggestItem[]>([]);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Debounced fetch from the same suggest-files API that tiptap uses
useEffect(() => {
if (!query.trim()) {
setResults([]);
setOpen(false);
return;
}
setLoading(true);
const timer = setTimeout(async () => {
try {
const res = await fetch(
`/api/workspace/suggest-files?q=${encodeURIComponent(query.trim())}`,
);
const data = await res.json();
setResults(data.items ?? []);
setOpen(true);
setSelectedIndex(0);
} catch {
setResults([]);
} finally {
setLoading(false);
}
}, 150);
return () => clearTimeout(timer);
}, [query]);
// Click outside to close
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex((i) => Math.min(i + 1, results.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter" && results[selectedIndex]) {
e.preventDefault();
onSelect(results[selectedIndex]);
setQuery("");
setOpen(false);
inputRef.current?.blur();
} else if (e.key === "Escape") {
setOpen(false);
setQuery("");
inputRef.current?.blur();
}
},
[results, selectedIndex, onSelect],
);
const handleSelect = useCallback(
(item: SuggestItem) => {
onSelect(item);
setQuery("");
setOpen(false);
},
[onSelect],
);
return (
<div ref={containerRef} className="relative px-3 pt-2 pb-1">
<div className="relative">
<span
className="absolute left-2.5 top-1/2 -translate-y-1/2 pointer-events-none"
style={{ color: "var(--color-text-muted)" }}
>
<SearchIcon />
</span>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => { if (results.length > 0) {setOpen(true);} }}
placeholder="Search files..."
className="w-full pl-8 pr-3 py-1.5 rounded-lg text-xs outline-none transition-colors"
style={{
background: "var(--color-bg)",
color: "var(--color-text)",
border: "1px solid var(--color-border)",
}}
/>
{loading && (
<span className="absolute right-2.5 top-1/2 -translate-y-1/2">
<div
className="w-3 h-3 border border-t-transparent rounded-full animate-spin"
style={{ borderColor: "var(--color-text-muted)" }}
/>
</span>
)}
</div>
{open && results.length > 0 && (
<div
className="absolute left-3 right-3 mt-1 rounded-lg shadow-lg border overflow-hidden z-50 max-h-[300px] overflow-y-auto"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
{results.map((item, i) => (
<button
key={item.path}
type="button"
onClick={() => handleSelect(item)}
className="w-full flex items-center gap-2 px-3 py-2 text-left text-xs cursor-pointer transition-colors"
style={{
background: i === selectedIndex ? "var(--color-surface-hover)" : "transparent",
color: "var(--color-text)",
}}
onMouseEnter={() => setSelectedIndex(i)}
>
<span className="flex-shrink-0" style={{ color: "var(--color-text-muted)" }}>
<SuggestTypeIcon type={item.type} />
</span>
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{item.name}</div>
<div className="truncate" style={{ color: "var(--color-text-muted)", fontSize: "10px" }}>
{item.path.split("/").slice(0, -1).join("/")}
</div>
</div>
<span
className="text-[10px] px-1.5 py-0.5 rounded-full flex-shrink-0 capitalize"
style={{ background: "var(--color-surface-hover)", color: "var(--color-text-muted)" }}
>
{item.type}
</span>
</button>
))}
</div>
)}
{open && query.trim() && !loading && results.length === 0 && (
<div
className="absolute left-3 right-3 mt-1 rounded-lg shadow-lg border z-50 px-3 py-3 text-center"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
<p className="text-xs" style={{ color: "var(--color-text-muted)" }}>
No files found
</p>
</div>
)}
</div>
);
}
/** Extract the directory name from an absolute path for display. */
function dirDisplayName(dir: string): string {
if (dir === "/") {return "/";}
@ -162,6 +397,7 @@ export function WorkspaceSidebar({
parentDir,
onNavigateUp,
onGoHome,
onFileSearchSelect,
}: WorkspaceSidebarProps) {
const isBrowsing = browseDir != null;
@ -252,6 +488,11 @@ export function WorkspaceSidebar({
)}
</div>
{/* File search */}
{onFileSearchSelect && (
<FileSearch onSelect={onFileSearchSelect} />
)}
{/* Tree */}
<div className="flex-1 overflow-y-auto px-1">
{loading ? (

View File

@ -1121,3 +1121,88 @@ a,
color: var(--color-error);
font-size: 0.8rem;
}
/* ─── Chat code block (syntax-highlighted) ─── */
.chat-code-block {
position: relative;
border: 1px solid var(--color-border);
border-radius: 0.75rem;
overflow: hidden;
margin: 0.75em 0;
}
.chat-code-lang {
position: absolute;
top: 0.5rem;
right: 0.75rem;
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
opacity: 0.6;
pointer-events: none;
z-index: 1;
}
/* ─── Shiki output styling (shared by chat + code-viewer) ─── */
.syntax-block pre,
.code-viewer-highlighted pre {
margin: 0;
padding: 0.875em 1em;
overflow-x: auto;
font-family: "SF Mono", "Fira Code", "JetBrains Mono", monospace;
font-size: 0.82em;
line-height: 1.6;
background: var(--color-surface) !important;
}
.syntax-block code,
.code-viewer-highlighted code {
background: transparent !important;
border: none !important;
padding: 0 !important;
font-family: inherit;
font-size: inherit;
}
/* Shiki dual-theme: show the right theme based on data-theme */
.shiki,
.shiki span {
color: var(--shiki-dark) !important;
background-color: transparent !important;
}
@media (prefers-color-scheme: light) {
.shiki,
.shiki span {
color: var(--shiki-light) !important;
}
}
[data-theme="light"] .shiki,
[data-theme="light"] .shiki span {
color: var(--shiki-light) !important;
}
/* ─── Code viewer (workspace file viewer) ─── */
.code-viewer-content pre {
margin: 0;
}
.code-viewer-highlighted pre {
padding: 0.5em 0;
}
.code-viewer-highlighted .line {
padding: 0 1em;
display: inline-block;
width: 100%;
}
.code-viewer-highlighted .line:hover {
background: var(--color-surface-hover);
}

View File

@ -9,6 +9,7 @@ import { ObjectTable } from "../components/workspace/object-table";
import { ObjectKanban } from "../components/workspace/object-kanban";
import { DocumentView } from "../components/workspace/document-view";
import { FileViewer } from "../components/workspace/file-viewer";
import { CodeViewer } from "../components/workspace/code-viewer";
import { MediaViewer, detectMediaType, type MediaType } from "../components/workspace/media-viewer";
import { DatabaseViewer } from "../components/workspace/database-viewer";
import { Breadcrumbs } from "../components/workspace/breadcrumbs";
@ -18,6 +19,7 @@ import { ChatPanel, type ChatPanelHandle } from "../components/chat-panel";
import { EntryDetailModal } from "../components/workspace/entry-detail-modal";
import { useSearchIndex } from "@/lib/search-index";
import { parseWorkspaceLink, isWorkspaceLink } from "@/lib/workspace-links";
import { isCodeFile } from "@/lib/report-utils";
import { CronDashboard } from "../components/cron/cron-dashboard";
import { CronJobDetail } from "../components/cron/cron-job-detail";
import type { CronJob, CronJobsResponse } from "../types/cron";
@ -73,7 +75,7 @@ type ObjectData = {
type FileData = {
content: string;
type: "markdown" | "yaml" | "text";
type: "markdown" | "yaml" | "code" | "text";
};
type ContentState =
@ -82,6 +84,7 @@ type ContentState =
| { kind: "object"; data: ObjectData }
| { kind: "document"; data: FileData; title: string }
| { kind: "file"; data: FileData; filename: string }
| { kind: "code"; data: FileData; filename: string }
| { kind: "media"; url: string; mediaType: MediaType; filename: string; filePath: string }
| { kind: "database"; dbPath: string; filename: string }
| { kind: "report"; reportPath: string; filename: string }
@ -257,7 +260,7 @@ function WorkspacePageInner() {
if (prev.kind === "document") {
return { ...prev, data: { ...prev.data, content: newContent } };
}
if (prev.kind === "file") {
if (prev.kind === "file" || prev.kind === "code") {
return { ...prev, data: { ...prev.data, content: newContent } };
}
return prev;
@ -364,7 +367,12 @@ function WorkspacePageInner() {
return;
}
const data: FileData = await res.json();
setContent({ kind: "file", data, filename: node.name });
// Route code files to the syntax-highlighted CodeViewer
if (isCodeFile(node.name)) {
setContent({ kind: "code", data, filename: node.name });
} else {
setContent({ kind: "file", data, filename: node.name });
}
} else if (node.type === "folder") {
setContent({ kind: "directory", node });
}
@ -404,6 +412,12 @@ function WorkspacePageInner() {
return;
}
}
// Clicking a folder in browse mode → navigate into it so the tree
// is fetched fresh (avoids stale/empty children from depth limits).
if (node.type === "folder") {
setBrowseDir(node.path);
return;
}
}
// --- Virtual path handlers (workspace mode) ---
@ -523,6 +537,28 @@ function WorkspacePageInner() {
setBrowseDir(null);
}, [setBrowseDir]);
// Handle file search selection: navigate sidebar to the file's location and open it
const handleFileSearchSelect = useCallback(
(item: { name: string; path: string; type: string }) => {
if (item.type === "folder") {
// Navigate the sidebar into the folder
setBrowseDir(item.path);
} else {
// Navigate the sidebar to the parent directory of the file
const parentOfFile = item.path.split("/").slice(0, -1).join("/") || "/";
setBrowseDir(parentOfFile);
// Open the file in the main panel
const node: TreeNode = {
name: item.name,
path: item.path,
type: item.type as TreeNode["type"],
};
void loadContent(node);
}
},
[setBrowseDir, loadContent],
);
// Sync URL bar with active content / chat state.
// Uses window.location instead of searchParams in the comparison to
// avoid a circular dependency (searchParams updates → effect fires →
@ -733,6 +769,7 @@ function WorkspacePageInner() {
parentDir={effectiveParentDir}
onNavigateUp={handleNavigateUp}
onGoHome={handleGoHome}
onFileSearchSelect={handleFileSearchSelect}
/>
{/* Main content */}
@ -941,6 +978,14 @@ function ContentRenderer({
/>
);
case "code":
return (
<CodeViewer
content={content.data.content}
filename={content.filename}
/>
);
case "media":
return (
<MediaViewer

View File

@ -216,6 +216,9 @@ export function buildToolOutput(
"query",
"results",
"citations",
// Edit tool fields — pass through so the UI can render inline diffs
"diff",
"firstChangedLine",
]) {
if (result.details[key] !== undefined)
{out[key] = result.details[key];}

View File

@ -0,0 +1,49 @@
/**
* Pure utility functions for parsing ```diff blocks from chat/document text.
* Mirrors the pattern in report-blocks.ts for testability.
*/
export type DiffSegment =
| { type: "text"; text: string }
| { type: "diff-artifact"; diff: string };
/**
* Split text containing ```diff ... ``` fenced blocks into
* alternating text and diff-artifact segments.
*/
export function splitDiffBlocks(text: string): DiffSegment[] {
const diffFenceRegex = /```diff\s*\n([\s\S]*?)```/g;
const segments: DiffSegment[] = [];
let lastIndex = 0;
for (const match of text.matchAll(diffFenceRegex)) {
const before = text.slice(lastIndex, match.index);
if (before.trim()) {
segments.push({ type: "text", text: before });
}
const diffContent = match[1].trimEnd();
if (diffContent) {
segments.push({ type: "diff-artifact", diff: diffContent });
} else {
// Empty diff block -- render as plain text
segments.push({ type: "text", text: match[0] });
}
lastIndex = (match.index ?? 0) + match[0].length;
}
const remaining = text.slice(lastIndex);
if (remaining.trim()) {
segments.push({ type: "text", text: remaining });
}
return segments;
}
/**
* Check if text contains any diff fenced blocks.
*/
export function hasDiffBlocks(text: string): boolean {
return text.includes("```diff");
}

View File

@ -62,8 +62,12 @@ describe("classifyFileType", () => {
expect(classifyFileType("page.mdx", mockIsDb)).toBe("document");
});
it("classifies .yaml as file", () => {
expect(classifyFileType("config.yaml", mockIsDb)).toBe("file");
it("classifies .yaml as code", () => {
expect(classifyFileType("config.yaml", mockIsDb)).toBe("code");
});
it("classifies .ts as code", () => {
expect(classifyFileType("index.ts", mockIsDb)).toBe("code");
});
it("classifies .txt as file", () => {

View File

@ -8,18 +8,45 @@ export function isReportFile(filename: string): boolean {
return filename.endsWith(".report.json");
}
/** Extensions recognized as code files for syntax-highlighted viewing. */
const CODE_EXTENSIONS = new Set([
"ts", "tsx", "js", "jsx", "mjs", "cjs",
"py", "rb", "go", "rs", "java", "kt", "swift",
"c", "cpp", "h", "hpp", "cs",
"css", "scss", "less",
"html", "htm", "xml", "svg",
"json", "jsonc",
"yaml", "yml", "toml",
"sh", "bash", "zsh", "fish", "ps1",
"sql", "graphql", "gql",
"dockerfile", "makefile", "cmake",
"r", "lua", "php",
"vue", "svelte",
"diff", "patch",
"ini", "env",
"tf", "proto", "zig",
"elixir", "ex", "erl", "hs", "scala", "clj", "dart",
]);
/** Check if a filename has a recognized code extension. */
export function isCodeFile(name: string): boolean {
const ext = name.split(".").pop()?.toLowerCase() ?? "";
return CODE_EXTENSIONS.has(ext);
}
/**
* Classify a file's type for the tree display.
* Returns "report", "database", "document", or "file".
* Returns "report", "database", "document", "code", or "file".
*/
export function classifyFileType(
name: string,
isDatabaseFile: (n: string) => boolean,
): "report" | "database" | "document" | "file" {
): "report" | "database" | "document" | "code" | "file" {
if (isReportFile(name)) {return "report";}
if (isDatabaseFile(name)) {return "database";}
const ext = name.split(".").pop()?.toLowerCase();
if (ext === "md" || ext === "mdx") {return "document";}
if (isCodeFile(name)) {return "code";}
return "file";
}

View File

@ -41,7 +41,8 @@
"react-is": "^19.2.4",
"react-markdown": "^10.1.0",
"recharts": "^3.7.0",
"remark-gfm": "^4.0.1"
"remark-gfm": "^4.0.1",
"shiki": "^3.22.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.8",

File diff suppressed because one or more lines are too long

111
pnpm-lock.yaml generated
View File

@ -393,6 +393,9 @@ importers:
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
shiki:
specifier: ^3.22.0
version: 3.22.0
devDependencies:
'@tailwindcss/postcss':
specifier: ^4.1.8
@ -4877,6 +4880,53 @@ packages:
selderee: 0.11.0
dev: false
/@shikijs/core@3.22.0:
resolution: {integrity: sha512-iAlTtSDDbJiRpvgL5ugKEATDtHdUVkqgHDm/gbD2ZS9c88mx7G1zSYjjOxp5Qa0eaW0MAQosFRmJSk354PRoQA==}
dependencies:
'@shikijs/types': 3.22.0
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
hast-util-to-html: 9.0.5
dev: false
/@shikijs/engine-javascript@3.22.0:
resolution: {integrity: sha512-jdKhfgW9CRtj3Tor0L7+yPwdG3CgP7W+ZEqSsojrMzCjD1e0IxIbwUMDDpYlVBlC08TACg4puwFGkZfLS+56Tw==}
dependencies:
'@shikijs/types': 3.22.0
'@shikijs/vscode-textmate': 10.0.2
oniguruma-to-es: 4.3.4
dev: false
/@shikijs/engine-oniguruma@3.22.0:
resolution: {integrity: sha512-DyXsOG0vGtNtl7ygvabHd7Mt5EY8gCNqR9Y7Lpbbd/PbJvgWrqaKzH1JW6H6qFkuUa8aCxoiYVv8/YfFljiQxA==}
dependencies:
'@shikijs/types': 3.22.0
'@shikijs/vscode-textmate': 10.0.2
dev: false
/@shikijs/langs@3.22.0:
resolution: {integrity: sha512-x/42TfhWmp6H00T6uwVrdTJGKgNdFbrEdhaDwSR5fd5zhQ1Q46bHq9EO61SCEWJR0HY7z2HNDMaBZp8JRmKiIA==}
dependencies:
'@shikijs/types': 3.22.0
dev: false
/@shikijs/themes@3.22.0:
resolution: {integrity: sha512-o+tlOKqsr6FE4+mYJG08tfCFDS+3CG20HbldXeVoyP+cYSUxDhrFf3GPjE60U55iOkkjbpY2uC3It/eeja35/g==}
dependencies:
'@shikijs/types': 3.22.0
dev: false
/@shikijs/types@3.22.0:
resolution: {integrity: sha512-491iAekgKDBFE67z70Ok5a8KBMsQ2IJwOWw3us/7ffQkIBCyOQfm/aNwVMBUriP02QshIfgHCBSIYAl3u2eWjg==}
dependencies:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
dev: false
/@shikijs/vscode-textmate@10.0.2:
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
dev: false
/@silvia-odwyer/photon-node@0.3.4:
resolution: {integrity: sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==}
dev: false
@ -8442,6 +8492,22 @@ packages:
function-bind: 1.1.2
dev: false
/hast-util-to-html@9.0.5:
resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
dependencies:
'@types/hast': 3.0.4
'@types/unist': 3.0.3
ccount: 2.0.1
comma-separated-tokens: 2.0.3
hast-util-whitespace: 3.0.0
html-void-elements: 3.0.0
mdast-util-to-hast: 13.2.1
property-information: 7.1.0
space-separated-tokens: 2.0.2
stringify-entities: 4.0.4
zwitch: 2.0.4
dev: false
/hast-util-to-jsx-runtime@2.3.6:
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
dependencies:
@ -8517,6 +8583,10 @@ packages:
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
dev: false
/html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
dev: false
/htmlencode@0.0.4:
resolution: {integrity: sha512-0uDvNVpzj/E2TfvLLyyXhKBRvF1y84aZsyRxRXFsQobnHaL4pcaXk+Y9cnFlvnxrBLeXDNq/VJBD+ngdBgQG1w==}
dev: false
@ -10255,6 +10325,18 @@ packages:
mimic-function: 5.0.1
dev: false
/oniguruma-parser@0.12.1:
resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==}
dev: false
/oniguruma-to-es@4.3.4:
resolution: {integrity: sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==}
dependencies:
oniguruma-parser: 0.12.1
regex: 6.1.0
regex-recursion: 6.0.2
dev: false
/openai@6.10.0(ws@8.19.0)(zod@4.3.6):
resolution: {integrity: sha512-ITxOGo7rO3XRMiKA5l7tQ43iNNu+iXGFAcf2t+aWVzzqRaS0i7m1K2BhxNdaveB+5eENhO0VY1FkiZzhBk4v3A==}
hasBin: true
@ -11153,6 +11235,22 @@ packages:
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
dev: false
/regex-recursion@6.0.2:
resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
dependencies:
regex-utilities: 2.3.0
dev: false
/regex-utilities@2.3.0:
resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==}
dev: false
/regex@6.1.0:
resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==}
dependencies:
regex-utilities: 2.3.0
dev: false
/remark-gfm@4.0.1:
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
dependencies:
@ -11604,6 +11702,19 @@ packages:
engines: {node: '>=8'}
dev: false
/shiki@3.22.0:
resolution: {integrity: sha512-LBnhsoYEe0Eou4e1VgJACes+O6S6QC0w71fCSp5Oya79inkwkm15gQ1UF6VtQ8j/taMDh79hAB49WUk8ALQW3g==}
dependencies:
'@shikijs/core': 3.22.0
'@shikijs/engine-javascript': 3.22.0
'@shikijs/engine-oniguruma': 3.22.0
'@shikijs/langs': 3.22.0
'@shikijs/themes': 3.22.0
'@shikijs/types': 3.22.0
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
dev: false
/side-channel-list@1.0.0:
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
engines: {node: '>= 0.4'}

View File

@ -0,0 +1,96 @@
---
name: software-engineering
description: Core software engineering skill - code quality, architecture, testing, debugging, and structured diff output for code changes.
metadata: { "openclaw": { "inject": true, "always": true, "emoji": "🛠" } }
---
# Software Engineering
You are an exceptional software engineer. Apply these principles to every coding task.
## Code Changes as Diffs
When proposing code changes, **always output unified diff format** inside a fenced `diff` code block. This renders as a rich diff viewer in the UI.
Format:
```diff
--- a/path/to/file.ts
+++ b/path/to/file.ts
@@ -10,7 +10,8 @@ function example() {
const existing = true;
- const old = "remove this";
+ const replacement = "add this";
+ const extra = "new line";
return existing;
}
```
Rules for diffs:
- Include `--- a/` and `+++ b/` file headers so the viewer shows the filename.
- Include 3 lines of context around each change (standard unified diff).
- Use `@@` hunk headers with line numbers.
- For new files use `--- /dev/null` and `+++ b/path/to/new-file.ts`.
- For deleted files use `--- a/path/to/old-file.ts` and `+++ /dev/null`.
- When changes span multiple files, use one diff block per file or a single block with multiple file sections.
When the user asks you to "make a change", "fix this", "refactor", or any code modification task, default to showing the diff unless they ask for the full file.
## Code Quality
- Write clear, readable code. Favor explicitness over cleverness.
- Keep functions small and focused (single responsibility).
- Name variables and functions descriptively -- the name should explain the intent.
- Add brief comments only for non-obvious logic; don't comment what the code already says.
- Handle errors explicitly; never silently swallow exceptions.
- Prefer immutability: `const` over `let`, avoid mutation when practical.
- Use strict typing; avoid `any` in TypeScript.
## Architecture & Design
- Understand the existing patterns before introducing new ones.
- Keep modules loosely coupled; depend on interfaces, not implementations.
- Follow the existing project structure and conventions.
- Extract shared logic into utilities rather than duplicating code.
- When introducing a new pattern, explain why the existing approach is insufficient.
## Testing
- Write tests for non-trivial logic. Prioritize unit tests for pure functions.
- Test edge cases and error paths, not just the happy path.
- Keep tests focused: one assertion per test when possible.
- Use descriptive test names that explain the scenario and expected outcome.
- Mock external dependencies; don't mock the thing you're testing.
## Debugging Methodology
1. **Reproduce** -- confirm the issue with a minimal reproduction.
2. **Read the error** -- stack traces, logs, and error messages contain the answer most of the time.
3. **Trace the data flow** -- follow input through the system to find where it diverges.
4. **Check recent changes** -- `git log` and `git diff` reveal what changed.
5. **Isolate** -- narrow the problem space by eliminating variables.
6. **Fix and verify** -- confirm the fix resolves the issue without introducing regressions.
## Commits & PRs
- Write concise, action-oriented commit messages (e.g., "add retry logic to API client").
- Group related changes in a single commit; keep unrelated changes separate.
- PR descriptions should explain the _why_, not just the _what_.
## Performance
- Don't optimize prematurely; profile first.
- Prefer algorithmic improvements over micro-optimizations.
- Be mindful of N+1 queries, unnecessary re-renders, and unbounded data structures.
## Download All the Additional Skills for all different types of Software Engineering tasks
- Install these skills to make you a better software engineer, read those skills and carry out what those each skills say.
- https://skills.sh/davila7/claude-code-templates/senior-ml-engineer
- https://skills.sh/jeffallan/claude-skills/devops-engineer
- https://skills.sh/404kidwiz/claude-supercode-skills/frontend-ui-ux-engineer
- https://skills.sh/sickn33/antigravity-awesome-skills/ai-engineer
- https://skills.sh/jezweb/claude-skills/nextjs
- https://skills.sh/sickn33/antigravity-awesome-skills/typescript-expert
- Use those skills as and when required pertaining to the task.