feat(web): add workspace app tabs and embedded runtime

This makes Dench apps behave like first-class workspace views with persistent tabs and embedded app loading instead of exposing raw folders.
This commit is contained in:
kumarabhirup 2026-03-08 21:47:41 -07:00
parent 4e6ce90f0b
commit 8deeccf646
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
15 changed files with 1611 additions and 10 deletions

View File

@ -0,0 +1,154 @@
import { access, readFile, stat } from "node:fs/promises";
import { join, extname, resolve, relative } from "node:path";
import { resolveWorkspaceRoot, parseSimpleYaml } from "@/lib/workspace";
import { injectBridgeIntoHtml } from "@/lib/app-bridge";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
const MIME_TYPES: Record<string, string> = {
".html": "text/html; charset=utf-8",
".htm": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".mjs": "application/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".svg": "image/svg+xml",
".webp": "image/webp",
".ico": "image/x-icon",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".otf": "font/otf",
".wasm": "application/wasm",
".map": "application/json",
".txt": "text/plain; charset=utf-8",
".md": "text/markdown; charset=utf-8",
".xml": "application/xml",
".yaml": "text/yaml; charset=utf-8",
".yml": "text/yaml; charset=utf-8",
};
function getMimeType(filepath: string): string {
const ext = extname(filepath).toLowerCase();
return MIME_TYPES[ext] || "application/octet-stream";
}
async function pathExists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch {
return false;
}
}
export async function GET(req: Request) {
const url = new URL(req.url);
const appPath = url.searchParams.get("app");
const filePath = url.searchParams.get("file");
const metaOnly = url.searchParams.get("meta") === "1";
if (!appPath) {
return Response.json({ error: "Missing 'app' parameter" }, { status: 400 });
}
const workspaceRoot = resolveWorkspaceRoot();
if (!workspaceRoot) {
return Response.json({ error: "No workspace configured" }, { status: 404 });
}
const appAbsPath = resolve(join(workspaceRoot, appPath));
// Security: ensure the resolved path is within the workspace
const relToWorkspace = relative(workspaceRoot, appAbsPath);
if (relToWorkspace.startsWith("..") || relToWorkspace.startsWith("/")) {
return Response.json({ error: "Path traversal denied" }, { status: 403 });
}
if (!await pathExists(appAbsPath)) {
return Response.json({ error: "App not found" }, { status: 404 });
}
// Meta-only request: return parsed .dench.yaml manifest
if (metaOnly) {
const manifestPath = join(appAbsPath, ".dench.yaml");
if (!await pathExists(manifestPath)) {
return Response.json({ name: appPath.split("/").pop()?.replace(/\.dench\.app$/, "") || "App" });
}
try {
const content = await readFile(manifestPath, "utf-8");
const parsed = parseSimpleYaml(content);
return Response.json({
name: parsed.name || appPath.split("/").pop()?.replace(/\.dench\.app$/, "") || "App",
description: parsed.description,
icon: parsed.icon,
version: parsed.version,
author: parsed.author,
entry: parsed.entry || "index.html",
runtime: parsed.runtime || "static",
permissions: parsed.permissions,
});
} catch {
return Response.json({ name: "App" });
}
}
// Serve a specific file from the app directory
if (!filePath) {
return Response.json({ error: "Missing 'file' parameter" }, { status: 400 });
}
const fileAbsPath = resolve(join(appAbsPath, filePath));
// Security: ensure file is within the app directory
const relToApp = relative(appAbsPath, fileAbsPath);
if (relToApp.startsWith("..") || relToApp.startsWith("/")) {
return Response.json({ error: "Path traversal denied" }, { status: 403 });
}
if (!await pathExists(fileAbsPath)) {
return Response.json({ error: "File not found" }, { status: 404 });
}
try {
const fileStat = await stat(fileAbsPath);
if (!fileStat.isFile()) {
return Response.json({ error: "Not a file" }, { status: 400 });
}
const mimeType = getMimeType(filePath);
const ext = extname(filePath).toLowerCase();
// For HTML files, inject the DenchClaw bridge SDK
if (ext === ".html" || ext === ".htm") {
const htmlContent = await readFile(fileAbsPath, "utf-8");
const injected = injectBridgeIntoHtml(htmlContent);
return new Response(injected, {
status: 200,
headers: {
"Content-Type": mimeType,
"Cache-Control": "no-cache",
"X-Content-Type-Options": "nosniff",
},
});
}
const content = await readFile(fileAbsPath);
return new Response(content, {
status: 200,
headers: {
"Content-Type": mimeType,
"Content-Length": String(content.length),
"Cache-Control": "no-cache",
"X-Content-Type-Options": "nosniff",
},
});
} catch {
return Response.json({ error: "Failed to read file" }, { status: 500 });
}
}

View File

@ -0,0 +1,148 @@
/**
* Path-based app file server.
*
* Serves files from .dench.app folders via path-based URLs so that relative
* references (CSS, JS, images) in HTML files resolve correctly.
*
* URL format: /api/apps/serve/<appPath>/<filePath>
* Example: /api/apps/serve/apps/pacman.dench.app/style.css
*
* The app path is everything up to and including ".dench.app".
* The file path is everything after that.
*/
import { access, readFile, stat } from "node:fs/promises";
import { join, extname, resolve, relative } from "node:path";
import { resolveWorkspaceRoot } from "@/lib/workspace";
import { injectBridgeIntoHtml } from "@/lib/app-bridge";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
const MIME_TYPES: Record<string, string> = {
".html": "text/html; charset=utf-8",
".htm": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".mjs": "application/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".svg": "image/svg+xml",
".webp": "image/webp",
".ico": "image/x-icon",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".otf": "font/otf",
".wasm": "application/wasm",
".map": "application/json",
".txt": "text/plain; charset=utf-8",
".md": "text/markdown; charset=utf-8",
".xml": "application/xml",
".yaml": "text/yaml; charset=utf-8",
".yml": "text/yaml; charset=utf-8",
};
function getMimeType(filepath: string): string {
const ext = extname(filepath).toLowerCase();
return MIME_TYPES[ext] || "application/octet-stream";
}
async function pathExists(p: string): Promise<boolean> {
try {
await access(p);
return true;
} catch {
return false;
}
}
/**
* Split a URL path into app path and file path.
* The app path is everything up to and including ".dench.app".
* e.g. "apps/pacman.dench.app/style.css" -> ["apps/pacman.dench.app", "style.css"]
*/
function splitAppPath(segments: string[]): { appPath: string; filePath: string } | null {
const joined = segments.join("/");
const marker = ".dench.app";
const idx = joined.indexOf(marker);
if (idx === -1) return null;
const appEnd = idx + marker.length;
const appPath = joined.slice(0, appEnd);
const filePath = joined.slice(appEnd + 1) || "index.html";
return { appPath, filePath };
}
export async function GET(
_req: Request,
{ params }: { params: Promise<{ path: string[] }> },
) {
const { path: segments } = await params;
const split = splitAppPath(segments);
if (!split) {
return Response.json({ error: "Invalid app path — must contain .dench.app" }, { status: 400 });
}
const { appPath, filePath } = split;
const workspaceRoot = resolveWorkspaceRoot();
if (!workspaceRoot) {
return Response.json({ error: "No workspace configured" }, { status: 404 });
}
const appAbsPath = resolve(join(workspaceRoot, appPath));
const relToWorkspace = relative(workspaceRoot, appAbsPath);
if (relToWorkspace.startsWith("..") || relToWorkspace.startsWith("/")) {
return Response.json({ error: "Path traversal denied" }, { status: 403 });
}
const fileAbsPath = resolve(join(appAbsPath, filePath));
const relToApp = relative(appAbsPath, fileAbsPath);
if (relToApp.startsWith("..") || relToApp.startsWith("/")) {
return Response.json({ error: "Path traversal denied" }, { status: 403 });
}
if (!await pathExists(fileAbsPath)) {
return Response.json({ error: "File not found" }, { status: 404 });
}
try {
const fileStat = await stat(fileAbsPath);
if (!fileStat.isFile()) {
return Response.json({ error: "Not a file" }, { status: 400 });
}
const mimeType = getMimeType(filePath);
const ext = extname(filePath).toLowerCase();
if (ext === ".html" || ext === ".htm") {
const htmlContent = await readFile(fileAbsPath, "utf-8");
const injected = injectBridgeIntoHtml(htmlContent);
return new Response(injected, {
status: 200,
headers: {
"Content-Type": mimeType,
"Cache-Control": "no-cache",
"X-Content-Type-Options": "nosniff",
},
});
}
const content = await readFile(fileAbsPath);
return new Response(content, {
status: 200,
headers: {
"Content-Type": mimeType,
"Content-Length": String(content.length),
"Cache-Control": "no-cache",
"X-Content-Type-Options": "nosniff",
},
});
} catch {
return Response.json({ error: "Failed to read file" }, { status: 500 });
}
}

View File

@ -16,7 +16,7 @@ export const runtime = "nodejs";
export type TreeNode = {
name: string;
path: string; // relative to workspace root (or ~skills/ for virtual nodes)
type: "object" | "document" | "folder" | "file" | "database" | "report";
type: "object" | "document" | "folder" | "file" | "database" | "report" | "app";
icon?: string;
defaultView?: "table" | "kanban";
children?: TreeNode[];
@ -24,6 +24,15 @@ export type TreeNode = {
virtual?: boolean;
/** True when the entry is a symbolic link. */
symlink?: boolean;
/** App manifest metadata (only for type: "app"). */
appManifest?: {
name: string;
description?: string;
icon?: string;
version?: string;
entry?: string;
runtime?: string;
};
};
type DbObject = {
@ -97,6 +106,29 @@ async function resolveEntryType(
return null;
}
/** Read .dench.yaml manifest from a .dench.app directory. */
async function readAppManifest(
dirPath: string,
): Promise<TreeNode["appManifest"] | null> {
const yamlPath = join(dirPath, ".dench.yaml");
if (!await pathExists(yamlPath)) return null;
try {
const content = await readFile(yamlPath, "utf-8");
const parsed = parseSimpleYaml(content);
return {
name: (parsed.name as string) || dirPath.split("/").pop()?.replace(/\.dench\.app$/, "") || "App",
description: parsed.description as string | undefined,
icon: parsed.icon as string | undefined,
version: parsed.version as string | undefined,
entry: (parsed.entry as string) || "index.html",
runtime: (parsed.runtime as string) || "static",
};
} catch {
return null;
}
}
/** Recursively build a tree from a workspace directory. */
async function buildTree(
absDir: string,
@ -145,6 +177,21 @@ async function buildTree(
const isSymlink = entry.isSymbolicLink();
if (effectiveType === "directory") {
// Detect .dench.app folders -- treat as app nodes (no children exposed)
if (entry.name.endsWith(".dench.app")) {
const manifest = await readAppManifest(absPath);
const displayName = manifest?.name || entry.name.replace(/\.dench\.app$/, "");
nodes.push({
name: displayName,
path: relPath,
type: "app",
icon: manifest?.icon,
appManifest: manifest ?? { name: displayName, entry: "index.html", runtime: "static" },
...(isSymlink && { symlink: true }),
});
continue;
}
const objectMeta = await readObjectMeta(absPath);
const dbObject = dbObjects.get(entry.name);
const children = await buildTree(absPath, relPath, dbObjects, showHidden);

View File

@ -28,7 +28,7 @@ type MemoryFile = {
type TreeNode = {
name: string;
path: string;
type: "object" | "document" | "folder" | "file" | "database" | "report";
type: "object" | "document" | "folder" | "file" | "database" | "report" | "app";
icon?: string;
defaultView?: "table" | "kanban";
children?: TreeNode[];

View File

@ -0,0 +1,295 @@
"use client";
import { useState, useRef, useCallback, useEffect } from "react";
import type { DenchAppManifest } from "../../workspace/workspace-content";
/** Build a path-based URL for serving files from a .dench.app folder. */
export function appServeUrl(appPath: string, filePath: string): string {
return `/api/apps/serve/${appPath}/${filePath}`;
}
type AppViewerProps = {
appPath: string;
manifest: DenchAppManifest;
};
export function AppViewer({ appPath, manifest }: AppViewerProps) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const entryFile = manifest.entry || "index.html";
const appUrl = appServeUrl(appPath, entryFile);
const handleReload = useCallback(() => {
setLoading(true);
setError(null);
if (iframeRef.current) {
iframeRef.current.src = `${appUrl}?_t=${Date.now()}`;
}
}, [appUrl]);
const handleIframeLoad = useCallback(() => {
setLoading(false);
}, []);
const handleIframeError = useCallback(() => {
setLoading(false);
setError("Failed to load app");
}, []);
// Set up postMessage bridge listener
useEffect(() => {
const handleMessage = async (event: MessageEvent) => {
if (!event.data || event.data.type !== "dench:request") return;
const { id, method, params } = event.data;
const iframe = iframeRef.current;
if (!iframe?.contentWindow || event.source !== iframe.contentWindow) return;
const permissions = manifest.permissions || [];
try {
let result: unknown;
if (method === "app.getManifest") {
result = manifest;
} else if (method === "app.getTheme") {
result = document.documentElement.classList.contains("dark") ? "dark" : "light";
} else if (method === "db.query" && permissions.includes("database")) {
const res = await fetch("/api/workspace/query", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sql: params?.sql }),
});
result = await res.json();
} else if (method === "db.execute" && permissions.includes("database")) {
const res = await fetch("/api/workspace/query", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sql: params?.sql }),
});
result = await res.json();
} else if (method === "files.read" && permissions.includes("files")) {
const res = await fetch(`/api/workspace/file?path=${encodeURIComponent(params?.path)}`);
result = await res.json();
} else if (method === "files.list" && permissions.includes("files")) {
const res = await fetch(`/api/workspace/tree?showHidden=0`);
result = await res.json();
} else {
iframe.contentWindow.postMessage({
type: "dench:response",
id,
error: `Unknown method or insufficient permissions: ${method}`,
}, "*");
return;
}
iframe.contentWindow.postMessage({
type: "dench:response",
id,
result,
}, "*");
} catch (err) {
iframe.contentWindow?.postMessage({
type: "dench:response",
id,
error: err instanceof Error ? err.message : "Unknown error",
}, "*");
}
};
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, [manifest]);
const iconIsImage = manifest.icon && (
manifest.icon.endsWith(".png") ||
manifest.icon.endsWith(".svg") ||
manifest.icon.endsWith(".jpg") ||
manifest.icon.endsWith(".jpeg") ||
manifest.icon.endsWith(".webp")
);
return (
<div className="flex flex-col h-full">
{/* App header bar */}
<div
className="flex items-center gap-3 px-5 py-2.5 border-b flex-shrink-0"
style={{ borderColor: "var(--color-border)" }}
>
{iconIsImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={appServeUrl(appPath, manifest.icon!)}
alt=""
width={20}
height={20}
className="rounded flex-shrink-0"
style={{ objectFit: "cover" }}
/>
) : (
<DefaultAppIcon />
)}
<span className="text-sm font-medium truncate" style={{ color: "var(--color-text)" }}>
{manifest.name}
</span>
{manifest.version && (
<span
className="text-[10px] px-2 py-0.5 rounded-full flex-shrink-0"
style={{
background: "var(--color-accent-light)",
color: "var(--color-accent)",
border: "1px solid color-mix(in srgb, var(--color-accent) 20%, transparent)",
}}
>
v{manifest.version}
</span>
)}
<span
className="text-[10px] px-2 py-0.5 rounded-full flex-shrink-0"
style={{
background: "#6366f118",
color: "#6366f1",
border: "1px solid #6366f130",
}}
>
APP
</span>
<div className="flex items-center gap-1 ml-auto">
{/* Reload button */}
<button
type="button"
onClick={handleReload}
className="p-1.5 rounded-md transition-colors duration-100 cursor-pointer"
style={{ color: "var(--color-text-muted)" }}
title="Reload app"
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background = "transparent";
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
<path d="M16 16h5v5" />
</svg>
</button>
{/* Open in new tab */}
<a
href={appUrl}
target="_blank"
rel="noopener noreferrer"
className="p-1.5 rounded-md transition-colors duration-100"
style={{ color: "var(--color-text-muted)" }}
title="Open in new tab"
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background = "transparent";
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 3h6v6" /><path d="M10 14 21 3" />
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
</svg>
</a>
</div>
</div>
{/* App content */}
<div className="flex-1 overflow-hidden relative" style={{ background: "white" }}>
{loading && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 z-10" style={{ background: "var(--color-bg)" }}>
{iconIsImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={appServeUrl(appPath, manifest.icon!)}
alt=""
width={48}
height={48}
className="rounded-xl"
style={{ objectFit: "cover" }}
/>
) : (
<div
className="w-12 h-12 rounded-xl flex items-center justify-center"
style={{ background: "var(--color-accent-light)" }}
>
<DefaultAppIcon size={24} />
</div>
)}
<div
className="w-5 h-5 border-2 rounded-full animate-spin"
style={{
borderColor: "var(--color-border)",
borderTopColor: "var(--color-accent)",
}}
/>
<p className="text-xs" style={{ color: "var(--color-text-muted)" }}>
Loading {manifest.name}...
</p>
</div>
)}
{error && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 z-10" style={{ background: "var(--color-bg)" }}>
<div
className="w-12 h-12 rounded-xl flex items-center justify-center"
style={{ background: "color-mix(in srgb, var(--color-error) 10%, transparent)" }}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--color-error)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="15" x2="9" y1="9" y2="15" />
<line x1="9" x2="15" y1="9" y2="15" />
</svg>
</div>
<p className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
{error}
</p>
<button
type="button"
onClick={handleReload}
className="text-xs px-3 py-1.5 rounded-md cursor-pointer"
style={{
color: "var(--color-accent)",
background: "var(--color-accent-light)",
}}
>
Try again
</button>
</div>
)}
<iframe
ref={iframeRef}
src={appUrl}
className="w-full h-full border-0"
sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
title={manifest.name}
onLoad={handleIframeLoad}
onError={handleIframeError}
style={{ minHeight: "calc(100vh - 120px)" }}
/>
</div>
</div>
);
}
function DefaultAppIcon({ size = 18 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="#6366f1" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect width="7" height="7" x="3" y="3" rx="1" /><rect width="7" height="7" x="14" y="3" rx="1" />
<rect width="7" height="7" x="3" y="14" rx="1" /><rect width="7" height="7" x="14" y="14" rx="1" />
</svg>
);
}

View File

@ -22,7 +22,7 @@ import { InlineRename, RENAME_SHAKE_STYLE } from "./inline-rename";
export type TreeNode = {
name: string;
path: string;
type: "object" | "document" | "folder" | "file" | "database" | "report";
type: "object" | "document" | "folder" | "file" | "database" | "report" | "app";
icon?: string;
defaultView?: "table" | "kanban";
children?: TreeNode[];
@ -30,6 +30,15 @@ export type TreeNode = {
virtual?: boolean;
/** True when the entry is a symbolic link / shortcut. */
symlink?: boolean;
/** App manifest metadata (only for type: "app"). */
appManifest?: {
name: string;
description?: string;
icon?: string;
version?: string;
entry?: string;
runtime?: string;
};
};
/** Folder names reserved for virtual sections -- cannot be created/renamed to. */
@ -147,6 +156,15 @@ function ChatBubbleIcon() {
);
}
function AppNodeIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#6366f1" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect width="7" height="7" x="3" y="3" rx="1" /><rect width="7" height="7" x="14" y="3" rx="1" />
<rect width="7" height="7" x="3" y="14" rx="1" /><rect width="7" height="7" x="14" y="14" rx="1" />
</svg>
);
}
function LockBadge() {
return (
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.4 }}>
@ -199,6 +217,23 @@ function NodeIcon({ node, open }: { node: TreeNode; open?: boolean }) {
return <DatabaseIcon />;
case "report":
return <ReportIcon />;
case "app": {
const icon = node.appManifest?.icon ?? node.icon;
if (icon && (icon.endsWith(".png") || icon.endsWith(".svg") || icon.endsWith(".jpg") || icon.endsWith(".jpeg") || icon.endsWith(".webp"))) {
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/api/apps/serve/${node.path}/${icon}`}
alt=""
width={16}
height={16}
className="rounded-sm flex-shrink-0"
style={{ objectFit: "cover" }}
/>
);
}
return <AppNodeIcon />;
}
default:
return <FileIcon />;
}
@ -210,6 +245,7 @@ function typeColor(node: TreeNode): string {
case "document": return "#60a5fa";
case "database": return "#c084fc";
case "report": return "#22c55e";
case "app": return "#6366f1";
default: return "var(--color-text-muted)";
}
}
@ -416,7 +452,7 @@ function DraggableNode({
// Workspace root in browse mode: non-expandable entry point back to workspace
const isWorkspaceRoot = !!workspaceRoot && node.path === workspaceRoot;
const hasChildren = node.children && node.children.length > 0;
const isExpandable = isWorkspaceRoot ? false : (hasChildren || node.type === "folder" || node.type === "object");
const isExpandable = isWorkspaceRoot ? false : node.type === "app" ? false : (hasChildren || node.type === "folder" || node.type === "object");
const isExpanded = isWorkspaceRoot ? false : expandedPaths.has(node.path);
const isActive = activePath === node.path;
const isSelected = selectedPath === node.path;
@ -566,6 +602,14 @@ function DraggableNode({
</span>
)}
{/* App badge */}
{node.type === "app" && (
<span className="text-[9px] px-1.5 py-[1px] rounded flex-shrink-0 font-medium"
style={{ background: "#6366f118", color: "#6366f1" }}>
APP
</span>
)}
{/* Lock badge for system/virtual files (skip for workspace root -- it has its own badge) */}
{isProtected && !isWorkspaceRoot && !compact && (
<span className="flex-shrink-0 ml-1">

View File

@ -5,7 +5,7 @@ import { useState, useCallback } from "react";
export type TreeNode = {
name: string;
path: string;
type: "object" | "document" | "folder" | "file" | "database" | "report";
type: "object" | "document" | "folder" | "file" | "database" | "report" | "app";
icon?: string;
defaultView?: "table" | "kanban";
children?: TreeNode[];

View File

@ -26,7 +26,7 @@ const fileMentionPluginKey = new PluginKey("fileMention");
export type TreeNode = {
name: string;
path: string;
type: "object" | "document" | "folder" | "file" | "database" | "report";
type: "object" | "document" | "folder" | "file" | "database" | "report" | "app";
icon?: string;
children?: TreeNode[];
};

View File

@ -0,0 +1,339 @@
"use client";
import { useState, useRef, useCallback, useEffect } from "react";
import { type Tab, HOME_TAB_ID } from "@/lib/tab-state";
import { appServeUrl } from "./app-viewer";
type TabBarProps = {
tabs: Tab[];
activeTabId: string | null;
onActivate: (tabId: string) => void;
onClose: (tabId: string) => void;
onCloseOthers: (tabId: string) => void;
onCloseToRight: (tabId: string) => void;
onCloseAll: () => void;
onReorder: (fromIndex: number, toIndex: number) => void;
onTogglePin: (tabId: string) => void;
};
type ContextMenuState = {
tabId: string;
x: number;
y: number;
} | null;
export function TabBar({
tabs,
activeTabId,
onActivate,
onClose,
onCloseOthers,
onCloseToRight,
onCloseAll,
onReorder,
onTogglePin,
}: TabBarProps) {
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null);
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!contextMenu) return;
const close = () => setContextMenu(null);
window.addEventListener("click", close);
window.addEventListener("contextmenu", close);
return () => {
window.removeEventListener("click", close);
window.removeEventListener("contextmenu", close);
};
}, [contextMenu]);
const handleContextMenu = useCallback((e: React.MouseEvent, tabId: string) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({ tabId, x: e.clientX, y: e.clientY });
}, []);
const handleMiddleClick = useCallback((e: React.MouseEvent, tabId: string) => {
if (e.button === 1) {
e.preventDefault();
onClose(tabId);
}
}, [onClose]);
const handleDragStart = useCallback((e: React.DragEvent, index: number) => {
setDragIndex(index);
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", String(index));
}, []);
const handleDragOver = useCallback((e: React.DragEvent, index: number) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDragOverIndex(index);
}, []);
const handleDrop = useCallback((e: React.DragEvent, toIndex: number) => {
e.preventDefault();
if (dragIndex !== null && dragIndex !== toIndex) {
onReorder(dragIndex, toIndex);
}
setDragIndex(null);
setDragOverIndex(null);
}, [dragIndex, onReorder]);
const handleDragEnd = useCallback(() => {
setDragIndex(null);
setDragOverIndex(null);
}, []);
if (tabs.length === 0) return null;
const contextTab = contextMenu ? tabs.find((t) => t.id === contextMenu.tabId) : null;
return (
<>
<div
ref={scrollRef}
className="flex items-end overflow-x-auto flex-shrink-0"
style={{
background: "var(--color-surface)",
borderBottom: "1px solid var(--color-border)",
scrollbarWidth: "none",
}}
>
{tabs.map((tab, index) => {
const isActive = tab.id === activeTabId;
const isDragOver = dragOverIndex === index && dragIndex !== index;
const isHome = tab.id === HOME_TAB_ID;
return (
<button
key={tab.id}
type="button"
draggable={!isHome}
onClick={() => onActivate(tab.id)}
onMouseDown={isHome ? undefined : (e) => handleMiddleClick(e, tab.id)}
onContextMenu={isHome ? undefined : (e) => handleContextMenu(e, tab.id)}
onDragStart={isHome ? undefined : (e) => handleDragStart(e, index)}
onDragOver={isHome ? undefined : (e) => handleDragOver(e, index)}
onDrop={isHome ? undefined : (e) => handleDrop(e, index)}
onDragEnd={isHome ? undefined : handleDragEnd}
className={`group flex items-center gap-1.5 h-[34px] text-[12.5px] font-medium cursor-pointer flex-shrink-0 relative transition-colors duration-75 select-none ${isHome ? "px-2.5" : "pl-3 pr-1.5"}`}
style={{
color: isActive ? "var(--color-text)" : "var(--color-text-muted)",
background: isActive ? "var(--color-bg)" : "transparent",
borderBottom: isActive ? "2px solid var(--color-accent)" : "2px solid transparent",
borderLeft: isDragOver && !isHome ? "2px solid var(--color-accent)" : "2px solid transparent",
opacity: dragIndex === index ? 0.5 : 1,
maxWidth: isHome ? undefined : 200,
borderRight: isHome ? "1px solid var(--color-border)" : undefined,
}}
title={isHome ? "Home (New Chat)" : undefined}
>
{isHome ? (
<HomeIcon />
) : (
<>
{tab.pinned && <PinIcon />}
<TabIcon type={tab.type} icon={tab.icon} appPath={tab.path} />
<span className="truncate max-w-[140px]">{tab.title}</span>
{!tab.pinned && (
<span
role="button"
tabIndex={-1}
onClick={(e) => { e.stopPropagation(); onClose(tab.id); }}
onKeyDown={(e) => { if (e.key === "Enter") { e.stopPropagation(); onClose(tab.id); } }}
className="ml-0.5 p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
style={{ color: "var(--color-text-muted)" }}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background = "transparent";
}}
>
<CloseIcon />
</span>
)}
</>
)}
</button>
);
})}
</div>
{/* Context menu */}
{contextMenu && contextTab && (
<div
className="fixed z-[9999] min-w-[180px] rounded-lg border py-1 shadow-lg"
style={{
left: contextMenu.x,
top: contextMenu.y,
background: "var(--color-surface)",
borderColor: "var(--color-border)",
}}
>
<ContextMenuItem
label={contextTab.pinned ? "Unpin Tab" : "Pin Tab"}
onClick={() => { onTogglePin(contextMenu.tabId); setContextMenu(null); }}
/>
<div className="h-px my-1" style={{ background: "var(--color-border)" }} />
<ContextMenuItem
label="Close"
shortcut="⌘W"
disabled={contextTab.pinned}
onClick={() => { onClose(contextMenu.tabId); setContextMenu(null); }}
/>
<ContextMenuItem
label="Close Others"
onClick={() => { onCloseOthers(contextMenu.tabId); setContextMenu(null); }}
/>
<ContextMenuItem
label="Close to the Right"
onClick={() => { onCloseToRight(contextMenu.tabId); setContextMenu(null); }}
/>
<ContextMenuItem
label="Close All"
onClick={() => { onCloseAll(); setContextMenu(null); }}
/>
</div>
)}
</>
);
}
function ContextMenuItem({
label,
shortcut,
disabled,
onClick,
}: {
label: string;
shortcut?: string;
disabled?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className="w-full flex items-center justify-between px-3 py-1.5 text-[12.5px] text-left transition-colors disabled:opacity-40 cursor-pointer disabled:cursor-default"
style={{ color: "var(--color-text)" }}
onMouseEnter={(e) => {
if (!disabled) (e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background = "transparent";
}}
>
<span>{label}</span>
{shortcut && (
<span className="ml-4 text-[11px]" style={{ color: "var(--color-text-muted)" }}>
{shortcut}
</span>
)}
</button>
);
}
function TabIcon({ type, icon, appPath }: { type: string; icon?: string; appPath?: string }) {
if (icon && appPath && (icon.endsWith(".png") || icon.endsWith(".svg") || icon.endsWith(".jpg") || icon.endsWith(".jpeg") || icon.endsWith(".webp"))) {
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={appServeUrl(appPath, icon)}
alt=""
width={14}
height={14}
className="rounded-sm flex-shrink-0"
style={{ objectFit: "cover" }}
/>
);
}
switch (type) {
case "home":
return <HomeIcon />;
case "app":
return <AppIcon />;
case "chat":
return <ChatIcon />;
case "cron":
return <CronIcon />;
case "object":
return <ObjectIcon />;
default:
return <FileIcon />;
}
}
function CloseIcon() {
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
</svg>
);
}
function PinIcon() {
return (
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor" stroke="none" className="flex-shrink-0" style={{ opacity: 0.5 }}>
<circle cx="12" cy="12" r="4" />
</svg>
);
}
function HomeIcon() {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0" style={{ opacity: 0.7 }}>
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
);
}
function FileIcon() {
return (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0" style={{ opacity: 0.6 }}>
<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 AppIcon() {
return (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0" style={{ opacity: 0.6 }}>
<rect width="7" height="7" x="3" y="3" rx="1" /><rect width="7" height="7" x="14" y="3" rx="1" />
<rect width="7" height="7" x="3" y="14" rx="1" /><rect width="7" height="7" x="14" y="14" rx="1" />
</svg>
);
}
function ChatIcon() {
return (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0" style={{ opacity: 0.6 }}>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
);
}
function CronIcon() {
return (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0" style={{ opacity: 0.6 }}>
<circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
</svg>
);
}
function ObjectIcon() {
return (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0" style={{ opacity: 0.6 }}>
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M3 9h18" /><path d="M9 21V9" />
</svg>
);
}

View File

@ -5,7 +5,7 @@ import { useState, useEffect, useCallback, useRef } from "react";
export type TreeNode = {
name: string;
path: string;
type: "object" | "document" | "folder" | "file" | "database" | "report";
type: "object" | "document" | "folder" | "file" | "database" | "report" | "app";
icon?: string;
defaultView?: "table" | "kanban";
children?: TreeNode[];

View File

@ -17,6 +17,7 @@ import { DocumentView } from "../components/workspace/document-view";
import { FileViewer, isSpreadsheetFile } from "../components/workspace/file-viewer";
import { SpreadsheetEditor } from "../components/workspace/spreadsheet-editor";
import { HtmlViewer } from "../components/workspace/html-viewer";
import { AppViewer } from "../components/workspace/app-viewer";
import { MonacoCodeEditor } from "../components/workspace/code-editor";
import { MediaViewer, detectMediaType, type MediaType } from "../components/workspace/media-viewer";
import { DatabaseViewer, DuckDBMissing } from "../components/workspace/database-viewer";
@ -45,6 +46,15 @@ import {
import { UnicodeSpinner } from "../components/unicode-spinner";
import { resolveActiveViewSyncDecision } from "./object-view-active-view";
import { resetWorkspaceStateOnSwitch } from "./workspace-switch";
import { TabBar } from "../components/workspace/tab-bar";
import {
type Tab, type TabState,
HOME_TAB_ID, HOME_TAB,
generateTabId, loadTabs, saveTabs, openTab, closeTab,
closeOtherTabs, closeTabsToRight, closeAllTabs,
activateTab, reorderTabs, togglePinTab,
inferTabType, inferTabTitle,
} from "@/lib/tab-state";
import dynamic from "next/dynamic";
const TerminalDrawer = dynamic(
@ -129,7 +139,19 @@ type ContentState =
| { kind: "cron-job"; jobId: string; job: CronJob }
| { kind: "cron-session"; jobId: string; job: CronJob; sessionId: string; run: import("../types/cron").CronRunLogEntry }
| { kind: "duckdb-missing" }
| { kind: "richDocument"; html: string; filePath: string; mode: "docx" | "txt" };
| { kind: "richDocument"; html: string; filePath: string; mode: "docx" | "txt" }
| { kind: "app"; appPath: string; manifest: DenchAppManifest; filename: string };
export type DenchAppManifest = {
name: string;
description?: string;
icon?: string;
version?: string;
author?: string;
entry?: string;
runtime?: "static" | "esbuild" | "build";
permissions?: string[];
};
type SidebarPreviewContent =
| { kind: "document"; data: FileData; title: string }
@ -281,6 +303,7 @@ function objectNameFromPath(path: string): string {
/** Infer a tree node type from filename extension for ad-hoc path previews. */
function inferNodeTypeFromFileName(fileName: string): TreeNode["type"] {
if (fileName.endsWith(".dench.app")) return "app";
const ext = fileName.split(".").pop()?.toLowerCase() ?? "";
if (ext === "md" || ext === "mdx") {return "document";}
if (ext === "duckdb" || ext === "sqlite" || ext === "sqlite3" || ext === "db") {return "database";}
@ -485,6 +508,41 @@ function WorkspacePageInner() {
// Terminal drawer state
const [terminalOpen, setTerminalOpen] = useState(false);
// Tab state -- always starts with the home tab
const [tabState, setTabState] = useState<TabState>({ tabs: [HOME_TAB], activeTabId: HOME_TAB_ID });
// Track which workspace we loaded tabs for, so we reload if the workspace switches
// and don't save until we've loaded first.
const tabLoadedForWorkspace = useRef<string | null>(null);
// Load tabs from localStorage once workspace name is known
useEffect(() => {
const key = workspaceName || null;
if (tabLoadedForWorkspace.current === key) return;
tabLoadedForWorkspace.current = key;
const loaded = loadTabs(key);
setTabState(loaded);
}, [workspaceName]);
// Persist tabs to localStorage on change (only after initial load for this workspace)
useEffect(() => {
const key = workspaceName || null;
if (tabLoadedForWorkspace.current !== key) return;
saveTabs(tabState, key);
}, [tabState, workspaceName]);
// Ref for the keyboard shortcut to close the active tab (avoids stale closure over loadContent)
const tabCloseActiveRef = useRef<(() => void) | null>(null);
const openTabForNode = useCallback((node: { path: string; name: string; type: string }) => {
const tab: Tab = {
id: generateTabId(),
type: node.type === "object" ? "object" : inferTabType(node.path),
title: inferTabTitle(node.path, node.name),
path: node.path,
};
setTabState((prev) => openTab(prev, tab));
}, []);
// Resizable sidebar widths (desktop only; persisted in localStorage).
// Use static defaults so server and client match on first render (avoid hydration mismatch).
const [leftSidebarWidth, setLeftSidebarWidth] = useState(260);
@ -529,6 +587,12 @@ function WorkspacePageInner() {
setTerminalOpen((v) => !v);
return;
}
if (mod && key === "w" && !e.shiftKey && !e.altKey) {
e.preventDefault();
tabCloseActiveRef.current?.();
return;
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
@ -789,6 +853,14 @@ function WorkspacePageInner() {
} else {
setContent({ kind: "file", data, filename: node.name });
}
} else if (node.type === "app") {
// Fetch manifest from the tree node or API
const manifestRes = await fetch(`/api/apps?app=${encodeURIComponent(node.path)}&file=.dench.yaml&meta=1`);
let manifest: DenchAppManifest = { name: node.name };
if (manifestRes.ok) {
try { manifest = await manifestRes.json(); } catch { /* use default */ }
}
setContent({ kind: "app", appPath: node.path, manifest, filename: node.name });
} else if (node.type === "folder") {
setContent({ kind: "directory", node });
}
@ -874,11 +946,96 @@ function WorkspacePageInner() {
setContent({ kind: "cron-dashboard" });
return;
}
openTabForNode(node);
void loadContent(node);
},
[loadContent, router, cronJobs, browseDir, workspaceRoot, openclawDir, setBrowseDir],
[loadContent, openTabForNode, cronJobs, browseDir, workspaceRoot, openclawDir, setBrowseDir],
);
// Tab handler callbacks (defined after loadContent is available)
const handleTabActivate = useCallback((tabId: string) => {
if (tabId === HOME_TAB_ID) {
setActivePath(null);
setContent({ kind: "none" });
setTabState((prev) => activateTab(prev, tabId));
return;
}
setTabState((prev) => {
const next = activateTab(prev, tabId);
const tab = next.tabs.find((t) => t.id === tabId);
if (tab?.path) {
const node = resolveNode(tree, tab.path);
if (node) {
void loadContent(node);
} else if (tab.path === "~cron") {
setActivePath("~cron");
setContent({ kind: "cron-dashboard" });
} else if (tab.path.startsWith("~cron/")) {
setActivePath(tab.path);
const jobId = tab.path.slice("~cron/".length);
const job = cronJobs.find((j) => j.id === jobId);
if (job) setContent({ kind: "cron-job", jobId, job });
}
}
return next;
});
}, [tree, loadContent, cronJobs]);
const handleTabClose = useCallback((tabId: string) => {
setTabState((prev) => {
const next = closeTab(prev, tabId);
if (next.activeTabId !== prev.activeTabId) {
if (next.activeTabId === HOME_TAB_ID || !next.activeTabId) {
setActivePath(null);
setContent({ kind: "none" });
} else {
const newActive = next.tabs.find((t) => t.id === next.activeTabId);
if (newActive?.path) {
const node = resolveNode(tree, newActive.path);
if (node) {
void loadContent(node);
}
}
}
}
return next;
});
}, [tree, loadContent]);
// Keep ref in sync so keyboard shortcut can close active tab
useEffect(() => {
tabCloseActiveRef.current = () => {
if (tabState.activeTabId) {
handleTabClose(tabState.activeTabId);
}
};
}, [tabState.activeTabId, handleTabClose]);
const handleTabCloseOthers = useCallback((tabId: string) => {
setTabState((prev) => closeOtherTabs(prev, tabId));
}, []);
const handleTabCloseToRight = useCallback((tabId: string) => {
setTabState((prev) => closeTabsToRight(prev, tabId));
}, []);
const handleTabCloseAll = useCallback(() => {
setTabState((prev) => {
const next = closeAllTabs(prev);
setActivePath(null);
setContent({ kind: "none" });
return next;
});
}, []);
const handleTabReorder = useCallback((from: number, to: number) => {
setTabState((prev) => reorderTabs(prev, from, to));
}, []);
const handleTabTogglePin = useCallback((tabId: string) => {
setTabState((prev) => togglePinTab(prev, tabId));
}, []);
const loadSidebarPreviewFromNode = useCallback(
async (node: TreeNode): Promise<SidebarPreviewContent | null> => {
if (node.type === "folder") {
@ -1137,6 +1294,7 @@ function WorkspacePageInner() {
const handleGoToChat = useCallback(() => {
setActivePath(null);
setContent({ kind: "none" });
setTabState((prev) => activateTab(prev, HOME_TAB_ID));
}, []);
// Insert a file mention into the chat editor when a sidebar item is dropped on the chat input.
@ -1288,14 +1446,17 @@ function WorkspacePageInner() {
initialPathHandled.current = true;
const node = resolveNode(tree, urlState.path);
if (node) {
openTabForNode(node);
void loadContent(node);
} else if (urlState.path === "~cron") {
openTabForNode({ path: "~cron", name: "Cron", type: "folder" });
setActivePath("~cron");
setContent({ kind: "cron-dashboard" });
if (urlState.cronView) setCronView(urlState.cronView);
if (urlState.cronCalMode) setCronCalMode(urlState.cronCalMode);
if (urlState.cronDate) setCronDate(urlState.cronDate);
} else if (urlState.path.startsWith("~cron/")) {
openTabForNode({ path: urlState.path, name: urlState.path.split("/").pop() || "Cron Job", type: "file" });
setActivePath(urlState.path);
setContent({ kind: "cron-dashboard" });
if (urlState.cronRunFilter) setCronRunFilter(urlState.cronRunFilter);
@ -1303,6 +1464,7 @@ function WorkspacePageInner() {
} else if (isAbsolutePath(urlState.path) || isHomeRelativePath(urlState.path)) {
const name = urlState.path.split("/").pop() || urlState.path;
const syntheticNode: TreeNode = { name, path: urlState.path, type: "file" };
openTabForNode(syntheticNode);
void loadContent(syntheticNode);
}
if (urlState.fileChat) {
@ -1365,11 +1527,14 @@ function WorkspacePageInner() {
if (urlState.path) {
const node = resolveNode(tree, urlState.path);
if (node) {
openTabForNode(node);
void loadContent(node);
} else if (urlState.path === "~cron") {
openTabForNode({ path: "~cron", name: "Cron", type: "folder" });
setActivePath("~cron");
setContent({ kind: "cron-dashboard" });
} else if (urlState.path.startsWith("~cron/")) {
openTabForNode({ path: urlState.path, name: urlState.path.split("/").pop() || "Cron Job", type: "file" });
setActivePath(urlState.path);
const jobId = urlState.path.slice("~cron/".length);
const job = cronJobs.find((j) => j.id === jobId);
@ -1380,7 +1545,9 @@ function WorkspacePageInner() {
}
} else if (isAbsolutePath(urlState.path) || isHomeRelativePath(urlState.path)) {
const name = urlState.path.split("/").pop() || urlState.path;
void loadContent({ name, path: urlState.path, type: "file" });
const synNode: TreeNode = { name, path: urlState.path, type: "file" };
openTabForNode(synNode);
void loadContent(synNode);
}
setFileChatSessionId(urlState.fileChat);
} else if (urlState.chat) {
@ -1389,11 +1556,13 @@ function WorkspacePageInner() {
setContent({ kind: "none" });
void chatRef.current?.loadSession(urlState.chat);
setActiveSubagentKey(urlState.subagent);
setTabState((prev) => activateTab(prev, HOME_TAB_ID));
} else {
setActivePath(null);
setContent({ kind: "none" });
setActiveSessionId(null);
setActiveSubagentKey(null);
setTabState((prev) => activateTab(prev, HOME_TAB_ID));
}
if (urlState.entry) {
@ -1779,6 +1948,21 @@ function WorkspacePageInner() {
</div>
)}
{/* Tab bar (desktop only, always visible -- home tab is always present) */}
{!isMobile && (
<TabBar
tabs={tabState.tabs}
activeTabId={tabState.activeTabId}
onActivate={handleTabActivate}
onClose={handleTabClose}
onCloseOthers={handleTabCloseOthers}
onCloseToRight={handleTabCloseToRight}
onCloseAll={handleTabCloseAll}
onReorder={handleTabReorder}
onTogglePin={handleTabTogglePin}
/>
)}
{/* When a file is selected: show top bar with breadcrumbs (desktop only, mobile has unified top bar) */}
{!isMobile && activePath && content.kind !== "none" && (
<div
@ -2510,6 +2694,14 @@ function ContentRenderer({
/>
);
case "app":
return (
<AppViewer
appPath={content.appPath}
manifest={content.manifest}
/>
);
case "database":
return (
<DatabaseViewer

View File

@ -0,0 +1,92 @@
/**
* DenchClaw App Bridge SDK.
*
* This module generates the client-side SDK script that gets injected into
* app iframes, providing `window.dench` for app-to-DenchClaw communication.
*
* Protocol:
* App -> Parent: { type: "dench:request", id, method, params }
* Parent -> App: { type: "dench:response", id, result, error }
*/
export function generateBridgeScript(): string {
return `
(function() {
if (window.dench) return;
var _pendingRequests = {};
var _requestId = 0;
function sendRequest(method, params) {
return new Promise(function(resolve, reject) {
var id = ++_requestId;
_pendingRequests[id] = { resolve: resolve, reject: reject };
window.parent.postMessage({
type: "dench:request",
id: id,
method: method,
params: params
}, "*");
setTimeout(function() {
if (_pendingRequests[id]) {
_pendingRequests[id].reject(new Error("Request timeout: " + method));
delete _pendingRequests[id];
}
}, 30000);
});
}
window.addEventListener("message", function(event) {
if (!event.data || event.data.type !== "dench:response") return;
var pending = _pendingRequests[event.data.id];
if (!pending) return;
delete _pendingRequests[event.data.id];
if (event.data.error) {
pending.reject(new Error(event.data.error));
} else {
pending.resolve(event.data.result);
}
});
window.dench = {
db: {
query: function(sql) { return sendRequest("db.query", { sql: sql }); },
execute: function(sql) { return sendRequest("db.execute", { sql: sql }); }
},
files: {
read: function(path) { return sendRequest("files.read", { path: path }); },
list: function(dir) { return sendRequest("files.list", { dir: dir }); }
},
app: {
getManifest: function() { return sendRequest("app.getManifest"); },
getTheme: function() { return sendRequest("app.getTheme"); }
},
agent: {
send: function(message) { return sendRequest("agent.send", { message: message }); }
}
};
})();
`;
}
/**
* Wraps raw HTML content with the bridge SDK script tag.
* Used when serving HTML files to inject the SDK automatically.
*/
export function injectBridgeIntoHtml(html: string): string {
const script = `<script>${generateBridgeScript()}</script>`;
// Try to inject before </head>
if (html.includes("</head>")) {
return html.replace("</head>", `${script}\n</head>`);
}
// Try to inject after <head>
if (html.includes("<head>")) {
return html.replace("<head>", `<head>\n${script}`);
}
// Fallback: prepend to the HTML
return `${script}\n${html}`;
}

195
apps/web/lib/tab-state.ts Normal file
View File

@ -0,0 +1,195 @@
/**
* Tab state management for the workspace.
*
* Tabs are stored in localStorage keyed per workspace.
* The URL reflects only the active tab's content (backward compatible).
*/
export type TabType = "home" | "file" | "chat" | "app" | "object" | "cron";
export const HOME_TAB_ID = "__home__";
export const HOME_TAB: Tab = {
id: HOME_TAB_ID,
type: "home",
title: "Home",
pinned: true,
};
export type Tab = {
id: string;
type: TabType;
title: string;
icon?: string;
path?: string;
sessionId?: string;
pinned?: boolean;
};
export type TabState = {
tabs: Tab[];
activeTabId: string | null;
};
const STORAGE_PREFIX = "dench:tabs";
function storageKey(workspaceId?: string | null): string {
return `${STORAGE_PREFIX}:${workspaceId || "default"}`;
}
export function generateTabId(): string {
return Math.random().toString(36).slice(2, 10);
}
function ensureHomeTab(state: TabState): TabState {
const hasHome = state.tabs.some((t) => t.id === HOME_TAB_ID);
if (hasHome) {
// Make sure home is always first
const home = state.tabs.find((t) => t.id === HOME_TAB_ID)!;
const rest = state.tabs.filter((t) => t.id !== HOME_TAB_ID);
return { ...state, tabs: [home, ...rest] };
}
return {
tabs: [HOME_TAB, ...state.tabs],
activeTabId: state.activeTabId || HOME_TAB_ID,
};
}
export function loadTabs(workspaceId?: string | null): TabState {
if (typeof window === "undefined") return { tabs: [HOME_TAB], activeTabId: HOME_TAB_ID };
try {
const raw = localStorage.getItem(storageKey(workspaceId));
if (!raw) return { tabs: [HOME_TAB], activeTabId: HOME_TAB_ID };
const parsed = JSON.parse(raw) as TabState;
if (!Array.isArray(parsed.tabs)) return { tabs: [HOME_TAB], activeTabId: HOME_TAB_ID };
return ensureHomeTab(parsed);
} catch {
return { tabs: [HOME_TAB], activeTabId: HOME_TAB_ID };
}
}
export function saveTabs(state: TabState, workspaceId?: string | null): void {
if (typeof window === "undefined") return;
try {
const serializable: TabState = {
tabs: state.tabs.map(({ id, type, title, icon, path, sessionId, pinned }) => ({
id, type, title, icon, path, sessionId, pinned,
})),
activeTabId: state.activeTabId,
};
localStorage.setItem(storageKey(workspaceId), JSON.stringify(serializable));
} catch {
// localStorage full or unavailable
}
}
export function findTabByPath(tabs: Tab[], path: string): Tab | undefined {
return tabs.find((t) => t.path === path);
}
export function findTabBySessionId(tabs: Tab[], sessionId: string): Tab | undefined {
return tabs.find((t) => t.type === "chat" && t.sessionId === sessionId);
}
export function openTab(state: TabState, tab: Tab): TabState {
const existing = tab.path
? findTabByPath(state.tabs, tab.path)
: tab.sessionId
? findTabBySessionId(state.tabs, tab.sessionId)
: undefined;
if (existing) {
return { ...state, activeTabId: existing.id };
}
return {
tabs: [...state.tabs, tab],
activeTabId: tab.id,
};
}
export function closeTab(state: TabState, tabId: string): TabState {
if (tabId === HOME_TAB_ID) return state;
const idx = state.tabs.findIndex((t) => t.id === tabId);
if (idx === -1) return state;
if (state.tabs[idx].pinned) return state;
const newTabs = state.tabs.filter((t) => t.id !== tabId);
let newActiveId = state.activeTabId;
if (state.activeTabId === tabId) {
if (newTabs.length === 0) {
newActiveId = null;
} else if (idx < newTabs.length) {
newActiveId = newTabs[idx].id;
} else {
newActiveId = newTabs[newTabs.length - 1].id;
}
}
return { tabs: newTabs, activeTabId: newActiveId };
}
export function closeOtherTabs(state: TabState, tabId: string): TabState {
const keep = state.tabs.filter((t) => t.id === tabId || t.pinned);
return { tabs: keep, activeTabId: tabId };
}
export function closeTabsToRight(state: TabState, tabId: string): TabState {
const idx = state.tabs.findIndex((t) => t.id === tabId);
if (idx === -1) return state;
const keep = state.tabs.filter((t, i) => i <= idx || t.pinned);
const activeStillExists = keep.some((t) => t.id === state.activeTabId);
return { tabs: keep, activeTabId: activeStillExists ? state.activeTabId : tabId };
}
export function closeAllTabs(state: TabState): TabState {
const pinned = state.tabs.filter((t) => t.pinned);
const activeStillExists = pinned.some((t) => t.id === state.activeTabId);
return { tabs: pinned, activeTabId: activeStillExists ? state.activeTabId : HOME_TAB_ID };
}
export function activateTab(state: TabState, tabId: string): TabState {
if (!state.tabs.some((t) => t.id === tabId)) return state;
return { ...state, activeTabId: tabId };
}
export function reorderTabs(state: TabState, fromIndex: number, toIndex: number): TabState {
if (fromIndex === toIndex) return state;
// Don't allow moving the home tab or moving anything before it
if (state.tabs[fromIndex]?.id === HOME_TAB_ID) return state;
const effectiveTo = Math.max(1, toIndex); // keep index 0 reserved for home
const tabs = [...state.tabs];
const [moved] = tabs.splice(fromIndex, 1);
tabs.splice(effectiveTo, 0, moved);
return { ...state, tabs };
}
export function togglePinTab(state: TabState, tabId: string): TabState {
return {
...state,
tabs: state.tabs.map((t) =>
t.id === tabId ? { ...t, pinned: !t.pinned } : t,
),
};
}
export function updateTabTitle(state: TabState, tabId: string, title: string): TabState {
return {
...state,
tabs: state.tabs.map((t) =>
t.id === tabId ? { ...t, title } : t,
),
};
}
export function inferTabType(path: string): TabType {
if (path.includes(".dench.app")) return "app";
if (path.startsWith("~cron")) return "cron";
return "file";
}
export function inferTabTitle(path: string, name?: string): string {
if (name) return name;
return path.split("/").pop() || path;
}

View File

@ -1,4 +1,5 @@
export {
buildDenchClawIdentity,
seedWorkspaceFromAssets,
seedSampleApp,
} from "../../../src/cli/workspace-seed";

View File

@ -91,6 +91,7 @@ export const SEED_OBJECTS: SeedObject[] = [
export function buildDenchClawIdentity(workspaceDir: string): string {
const crmSkillPath = path.join(workspaceDir, "skills", "crm", "SKILL.md");
const browserSkillPath = path.join(workspaceDir, "skills", "browser", "SKILL.md");
const appBuilderSkillPath = path.join(workspaceDir, "skills", "app-builder", "SKILL.md");
return `# IDENTITY.md - DenchClaw
You are **DenchClaw** - a personal AI agent and a CRM Database System built by Dench (dench.com), running on top of [OpenClaw](https://github.com/openclaw/openclaw).
@ -116,11 +117,22 @@ Your browser automation behavior is defined by the Browser skill at:
- Always load and follow that skill for browser-based tasks.
- Treat the Browser skill as always-on system context.
## App Builder contract
Your app-building behavior is defined by the App Builder skill at:
\`${appBuilderSkillPath}\`
- Always load and follow that skill for app creation tasks.
- Treat the App Builder skill as always-on system context.
- Build apps using the \`.dench.app\` folder format with \`.dench.yaml\` manifests.
- Default app location: \`${workspaceDir}/apps/\`
## What you do
- Find and enrich leads, maintain CRM pipelines, and help run outreach workflows.
- Chat with local DuckDB workspace data and return structured insights.
- Generate analytics and maintain workspace documentation.
- Build custom apps that run inside the workspace with access to DuckDB data.
## Links
@ -188,6 +200,7 @@ export function seedDenchClawIdentity(workspaceDir: string): void {
export const MANAGED_SKILLS: ReadonlyArray<{ name: string; templatePaths?: boolean }> = [
{ name: "crm", templatePaths: true },
{ name: "browser" },
{ name: "app-builder", templatePaths: true },
];
export function seedSkill(
@ -279,6 +292,80 @@ export function syncManagedSkills(params: {
return { syncedSkills: synced, workspaceDirs: params.workspaceDirs, identityUpdated: true };
}
export function seedSampleApp(appsDir: string): void {
const appDir = path.join(appsDir, "hello.dench.app");
if (existsSync(appDir)) return;
mkdirSync(appDir, { recursive: true });
writeFileSync(
path.join(appDir, ".dench.yaml"),
`name: "Hello World"
description: "A sample DenchClaw app"
icon: "sparkles"
version: "1.0.0"
entry: "index.html"
runtime: "static"
permissions:
- database
`,
"utf-8",
);
writeFileSync(
path.join(appDir, "index.html"),
`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hello World</title>
<style>
* { box-sizing: border-box; margin: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 32px; min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; }
body.dark { background: #1a1a2e; color: #e0e0e0; }
body.light { background: #ffffff; color: #1a1a2e; }
h1 { font-size: 28px; margin-bottom: 8px; }
p { opacity: 0.6; margin-bottom: 24px; }
.stats { display: flex; gap: 16px; flex-wrap: wrap; justify-content: center; }
.stat { padding: 16px 24px; border-radius: 12px; background: color-mix(in srgb, currentColor 5%, transparent); border: 1px solid color-mix(in srgb, currentColor 10%, transparent); min-width: 120px; }
.stat .label { font-size: 12px; opacity: 0.5; margin-bottom: 4px; }
.stat .value { font-size: 24px; font-weight: 700; }
</style>
</head>
<body>
<h1>Hello from DenchClaw!</h1>
<p>This is a sample app running inside your workspace.</p>
<div class="stats" id="stats">Loading...</div>
<script>
async function init() {
try {
const theme = await window.dench.app.getTheme();
document.body.className = theme;
} catch { document.body.className = 'light'; }
try {
const result = await window.dench.db.query("SELECT name, entry_count FROM objects");
const el = document.getElementById('stats');
el.innerHTML = '';
for (const row of (result.rows || [])) {
el.innerHTML += '<div class="stat"><div class="label">' + row.name + '</div><div class="value">' + (row.entry_count || 0) + '</div></div>';
}
if (!result.rows || result.rows.length === 0) {
el.textContent = 'No objects found yet. Create some in DenchClaw!';
}
} catch (err) {
document.getElementById('stats').textContent = 'Could not load data: ' + err.message;
}
}
init();
</script>
</body>
</html>
`,
"utf-8",
);
}
export function writeIfMissing(filePath: string, content: string): boolean {
if (existsSync(filePath)) {
return false;
@ -352,6 +439,13 @@ export function seedWorkspaceFromAssets(params: {
}
writeIfMissing(path.join(workspaceDir, "WORKSPACE.md"), generateWorkspaceMd(SEED_OBJECTS));
// Create default apps directory
const appsDir = path.join(workspaceDir, "apps");
mkdirSync(appsDir, { recursive: true });
// Seed a sample hello-world app
seedSampleApp(appsDir);
return {
workspaceDir,
dbPath,