web: restore functional features dropped by design merge

Restore backend/logic features that were incorrectly auto-merged from the
design branch:

- Spreadsheet viewer (xlsx, csv, ods, etc.) and xlsx dependency
- HTML iframe viewer with source toggle
- Directory picker modal for workspace creation
- Workspace registry for custom-path workspaces
- Session auto-discovery for orphaned sessions
- Workspace init seeding (CRM objects, DuckDB, templates, bootstrap files)
- Symlink resolution and showHidden in tree/browse routes
- Upload to workspace assets/ instead of hidden ~/.ironclaw/uploads/
- Webpack dev watcher config (next.config.ts)
- router.push for back-button navigation history
This commit is contained in:
kumarabhirup 2026-02-21 13:10:32 -08:00
parent db4c90b37d
commit 109b88b93c
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
17 changed files with 1779 additions and 217 deletions

View File

@ -1,28 +1,9 @@
import { readFileSync, writeFileSync, existsSync, unlinkSync, mkdirSync } from "node:fs";
import { readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { resolveWebChatDir } from "@/lib/workspace";
export const dynamic = "force-dynamic";
type IndexEntry = { id: string; [k: string]: unknown };
function readIndex(): IndexEntry[] {
const dir = resolveWebChatDir();
const indexFile = join(dir, "index.json");
if (!existsSync(indexFile)) { return []; }
try {
return JSON.parse(readFileSync(indexFile, "utf-8"));
} catch {
return [];
}
}
function writeIndex(sessions: IndexEntry[]) {
const dir = resolveWebChatDir();
if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); }
writeFileSync(join(dir, "index.json"), JSON.stringify(sessions, null, 2));
}
export type ChatLine = {
id: string;
role: "user" | "assistant";
@ -63,48 +44,3 @@ export async function GET(
return Response.json({ id, messages });
}
/** PATCH /api/web-sessions/[id] — update session metadata (e.g. rename). */
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
let body: { title?: string };
try {
body = await request.json();
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
const sessions = readIndex();
const session = sessions.find((s) => s.id === id);
if (!session) {
return Response.json({ error: "Session not found" }, { status: 404 });
}
if (typeof body.title === "string") {
session.title = body.title;
}
writeIndex(sessions);
return Response.json({ ok: true, session });
}
/** DELETE /api/web-sessions/[id] — remove a web chat session and its messages. */
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const dir = resolveWebChatDir();
const filePath = join(dir, `${id}.jsonl`);
const sessions = readIndex();
const filtered = sessions.filter((s) => s.id !== id);
if (filtered.length === sessions.length) {
return Response.json({ error: "Session not found" }, { status: 404 });
}
writeIndex(filtered);
if (existsSync(filePath)) {
unlinkSync(filePath);
}
return Response.json({ ok: true });
}

View File

@ -1,4 +1,4 @@
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { randomUUID } from "node:crypto";
import { resolveWebChatDir } from "@/lib/workspace";
@ -23,15 +23,68 @@ function ensureDir() {
return dir;
}
/**
* Read the session index, auto-discovering any orphaned .jsonl files
* that aren't in the index (e.g. from profile switches or missing index).
*/
function readIndex(): WebSessionMeta[] {
const dir = ensureDir();
const indexFile = join(dir, "index.json");
if (!existsSync(indexFile)) {return [];}
try {
return JSON.parse(readFileSync(indexFile, "utf-8"));
} catch {
return [];
let index: WebSessionMeta[] = [];
if (existsSync(indexFile)) {
try {
index = JSON.parse(readFileSync(indexFile, "utf-8"));
} catch {
index = [];
}
}
// Scan for orphaned .jsonl files not in the index
try {
const indexed = new Set(index.map((s) => s.id));
const files = readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
let dirty = false;
for (const file of files) {
const id = file.replace(/\.jsonl$/, "");
if (indexed.has(id)) {continue;}
// Build a minimal index entry from the file
const fp = join(dir, file);
const stat = statSync(fp);
let title = "New Chat";
let messageCount = 0;
try {
const content = readFileSync(fp, "utf-8");
const lines = content.split("\n").filter((l) => l.trim());
messageCount = lines.length;
// Try to extract a title from the first user message
for (const line of lines) {
const parsed = JSON.parse(line);
if (parsed.role === "user" && parsed.content) {
const text = String(parsed.content);
title = text.length > 60 ? text.slice(0, 60) + "..." : text;
break;
}
}
} catch { /* best-effort */ }
index.push({
id,
title,
createdAt: stat.birthtimeMs || stat.mtimeMs,
updatedAt: stat.mtimeMs,
messageCount,
});
dirty = true;
}
if (dirty) {
index.sort((a, b) => b.updatedAt - a.updatedAt);
writeFileSync(indexFile, JSON.stringify(index, null, 2));
}
} catch { /* best-effort */ }
return index;
}
function writeIndex(sessions: WebSessionMeta[]) {

View File

@ -1,4 +1,4 @@
import { readdirSync, type Dirent } from "node:fs";
import { readdirSync, statSync, type Dirent } from "node:fs";
import { join, dirname, resolve } from "node:path";
import { resolveWorkspaceRoot } from "@/lib/workspace";
@ -10,16 +10,34 @@ type BrowseNode = {
path: string; // absolute path
type: "folder" | "file" | "document" | "database";
children?: BrowseNode[];
symlink?: boolean;
};
/** Directories to skip when browsing the filesystem. */
const SKIP_DIRS = new Set(["node_modules", ".git", ".Trash", "__pycache__", ".cache"]);
/** Resolve a dirent's effective type, following symlinks to their target. */
function resolveEntryType(entry: Dirent, absPath: string): "directory" | "file" | null {
if (entry.isDirectory()) {return "directory";}
if (entry.isFile()) {return "file";}
if (entry.isSymbolicLink()) {
try {
const st = statSync(absPath);
if (st.isDirectory()) {return "directory";}
if (st.isFile()) {return "file";}
} catch {
// Broken symlink
}
}
return null;
}
/** Build a depth-limited tree from an absolute directory. */
function buildBrowseTree(
absDir: string,
maxDepth: number,
currentDepth = 0,
showHidden = false,
): BrowseNode[] {
if (currentDepth >= maxDepth) {return [];}
@ -30,29 +48,43 @@ function buildBrowseTree(
return [];
}
const sorted = entries
.filter((e) => !e.name.startsWith("."))
.filter((e) => !(e.isDirectory() && SKIP_DIRS.has(e.name)))
.toSorted((a, b) => {
if (a.isDirectory() && !b.isDirectory()) {return -1;}
if (!a.isDirectory() && b.isDirectory()) {return 1;}
return a.name.localeCompare(b.name);
const filtered = entries
.filter((e) => showHidden || !e.name.startsWith("."))
.filter((e) => {
const absPath = join(absDir, e.name);
const t = resolveEntryType(e, absPath);
return !(t === "directory" && SKIP_DIRS.has(e.name));
});
const sorted = filtered.toSorted((a, b) => {
const absA = join(absDir, a.name);
const absB = join(absDir, b.name);
const typeA = resolveEntryType(a, absA);
const typeB = resolveEntryType(b, absB);
const dirA = typeA === "directory";
const dirB = typeB === "directory";
if (dirA && !dirB) {return -1;}
if (!dirA && dirB) {return 1;}
return a.name.localeCompare(b.name);
});
const nodes: BrowseNode[] = [];
for (const entry of sorted) {
const absPath = join(absDir, entry.name);
const isSymlink = entry.isSymbolicLink();
const effectiveType = resolveEntryType(entry, absPath);
if (entry.isDirectory()) {
const children = buildBrowseTree(absPath, maxDepth, currentDepth + 1);
if (effectiveType === "directory") {
const children = buildBrowseTree(absPath, maxDepth, currentDepth + 1, showHidden);
nodes.push({
name: entry.name,
path: absPath,
type: "folder",
children: children.length > 0 ? children : undefined,
...(isSymlink && { symlink: true }),
});
} else if (entry.isFile()) {
} else if (effectiveType === "file") {
const ext = entry.name.split(".").pop()?.toLowerCase();
const isDocument = ext === "md" || ext === "mdx";
const isDatabase = ext === "duckdb" || ext === "sqlite" || ext === "sqlite3" || ext === "db";
@ -61,6 +93,7 @@ function buildBrowseTree(
name: entry.name,
path: absPath,
type: isDatabase ? "database" : isDocument ? "document" : "file",
...(isSymlink && { symlink: true }),
});
}
}
@ -71,8 +104,8 @@ function buildBrowseTree(
export async function GET(req: Request) {
const url = new URL(req.url);
let dir = url.searchParams.get("dir");
const showHidden = url.searchParams.get("showHidden") === "1";
// Default to the workspace root
if (!dir) {
dir = resolveWorkspaceRoot();
}
@ -83,10 +116,9 @@ export async function GET(req: Request) {
);
}
// Resolve and normalize the directory path
const resolved = resolve(dir);
const entries = buildBrowseTree(resolved, 3);
const entries = buildBrowseTree(resolved, 3, 0, showHidden);
const parentDir = resolved === "/" ? null : dirname(resolved);
return Response.json({

View File

@ -1,38 +1,239 @@
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { existsSync, mkdirSync, writeFileSync, readFileSync, copyFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { homedir } from "node:os";
import { resolveOpenClawStateDir, setUIActiveProfile, getEffectiveProfile, resolveWorkspaceRoot } from "@/lib/workspace";
import { resolveOpenClawStateDir, setUIActiveProfile, getEffectiveProfile, resolveWorkspaceRoot, registerWorkspacePath } from "@/lib/workspace";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
const BOOTSTRAP_FILES: Record<string, string> = {
"AGENTS.md": `# Workspace Agent Instructions
// ---------------------------------------------------------------------------
// Bootstrap file names (must match src/agents/workspace.ts)
// ---------------------------------------------------------------------------
Add instructions here that your agent should follow when working in this workspace.
`,
"SOUL.md": `# Soul
const BOOTSTRAP_FILENAMES = [
"AGENTS.md",
"SOUL.md",
"TOOLS.md",
"IDENTITY.md",
"USER.md",
"HEARTBEAT.md",
"BOOTSTRAP.md",
] as const;
Describe the personality and behavior of your agent here.
`,
"USER.md": `# User
Describe yourself your preferences, context, and how you'd like the agent to interact with you.
`,
// Minimal fallback content used when templates can't be loaded from disk
const FALLBACK_CONTENT: Record<string, string> = {
"AGENTS.md": "# AGENTS.md - Your Workspace\n\nThis folder is home. Treat it that way.\n",
"SOUL.md": "# SOUL.md - Who You Are\n\nDescribe the personality and behavior of your agent here.\n",
"TOOLS.md": "# TOOLS.md - Local Notes\n\nSkills define how tools work. This file is for your specifics.\n",
"IDENTITY.md": "# IDENTITY.md - Who Am I?\n\nFill this in during your first conversation.\n",
"USER.md": "# USER.md - About Your Human\n\nDescribe yourself and how you'd like the agent to interact with you.\n",
"HEARTBEAT.md": "# HEARTBEAT.md\n\n# Keep this file empty (or with only comments) to skip heartbeat API calls.\n",
"BOOTSTRAP.md": "# BOOTSTRAP.md - Hello, World\n\nYou just woke up. Time to figure out who you are.\n",
};
// ---------------------------------------------------------------------------
// CRM seed objects (mirrors src/agents/workspace-seed.ts)
// ---------------------------------------------------------------------------
type SeedField = {
name: string;
type: string;
required?: boolean;
enumValues?: string[];
};
type SeedObject = {
id: string;
name: string;
description: string;
icon: string;
defaultView: string;
entryCount: number;
fields: SeedField[];
};
const SEED_OBJECTS: SeedObject[] = [
{
id: "seed_obj_people_00000000000000",
name: "people",
description: "Contact management",
icon: "users",
defaultView: "table",
entryCount: 5,
fields: [
{ name: "Full Name", type: "text", required: true },
{ name: "Email Address", type: "email", required: true },
{ name: "Phone Number", type: "phone" },
{ name: "Company", type: "text" },
{ name: "Status", type: "enum", enumValues: ["Active", "Inactive", "Lead"] },
{ name: "Notes", type: "richtext" },
],
},
{
id: "seed_obj_company_0000000000000",
name: "company",
description: "Company tracking",
icon: "building-2",
defaultView: "table",
entryCount: 3,
fields: [
{ name: "Company Name", type: "text", required: true },
{
name: "Industry",
type: "enum",
enumValues: ["Technology", "Finance", "Healthcare", "Education", "Retail", "Other"],
},
{ name: "Website", type: "text" },
{ name: "Type", type: "enum", enumValues: ["Client", "Partner", "Vendor", "Prospect"] },
{ name: "Notes", type: "richtext" },
],
},
{
id: "seed_obj_task_000000000000000",
name: "task",
description: "Task tracking board",
icon: "check-square",
defaultView: "kanban",
entryCount: 5,
fields: [
{ name: "Title", type: "text", required: true },
{ name: "Description", type: "text" },
{ name: "Status", type: "enum", enumValues: ["In Queue", "In Progress", "Done"] },
{ name: "Priority", type: "enum", enumValues: ["Low", "Medium", "High"] },
{ name: "Due Date", type: "date" },
{ name: "Notes", type: "richtext" },
],
},
];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function stripFrontMatter(content: string): string {
if (!content.startsWith("---")) {return content;}
const endIndex = content.indexOf("\n---", 3);
if (endIndex === -1) {return content;}
return content.slice(endIndex + "\n---".length).replace(/^\s+/, "");
}
/** Try multiple candidate paths to find the monorepo root. */
function resolveProjectRoot(): string | null {
const marker = join("docs", "reference", "templates", "AGENTS.md");
const cwd = process.cwd();
// CWD is the repo root (standalone builds)
if (existsSync(join(cwd, marker))) {return cwd;}
// CWD is apps/web/ (dev mode)
const fromApps = resolve(cwd, "..", "..");
if (existsSync(join(fromApps, marker))) {return fromApps;}
return null;
}
function loadTemplateContent(filename: string, projectRoot: string | null): string {
if (projectRoot) {
const templatePath = join(projectRoot, "docs", "reference", "templates", filename);
try {
const raw = readFileSync(templatePath, "utf-8");
return stripFrontMatter(raw);
} catch {
// fall through to fallback
}
}
return FALLBACK_CONTENT[filename] ?? "";
}
function generateObjectYaml(obj: SeedObject): string {
const lines: string[] = [
`id: "${obj.id}"`,
`name: "${obj.name}"`,
`description: "${obj.description}"`,
`icon: "${obj.icon}"`,
`default_view: "${obj.defaultView}"`,
`entry_count: ${obj.entryCount}`,
"fields:",
];
for (const field of obj.fields) {
lines.push(` - name: "${field.name}"`);
lines.push(` type: ${field.type}`);
if (field.required) {lines.push(" required: true");}
if (field.enumValues) {lines.push(` values: ${JSON.stringify(field.enumValues)}`);}
}
return lines.join("\n") + "\n";
}
function generateWorkspaceMd(objects: SeedObject[]): string {
const lines: string[] = ["# Workspace Schema", "", "Auto-generated summary of the workspace database.", ""];
for (const obj of objects) {
lines.push(`## ${obj.name}`, "");
lines.push(`- **Description**: ${obj.description}`);
lines.push(`- **View**: \`${obj.defaultView}\``);
lines.push(`- **Entries**: ${obj.entryCount}`);
lines.push("- **Fields**:");
for (const field of obj.fields) {
const req = field.required ? " (required)" : "";
const vals = field.enumValues ? `${field.enumValues.join(", ")}` : "";
lines.push(` - ${field.name} (\`${field.type}\`)${req}${vals}`);
}
lines.push("");
}
return lines.join("\n");
}
function writeIfMissing(filePath: string, content: string): boolean {
if (existsSync(filePath)) {return false;}
try {
writeFileSync(filePath, content, { encoding: "utf-8", flag: "wx" });
return true;
} catch {
return false;
}
}
function seedDuckDB(workspaceDir: string, projectRoot: string | null): boolean {
const destPath = join(workspaceDir, "workspace.duckdb");
if (existsSync(destPath)) {return false;}
if (!projectRoot) {return false;}
const seedDb = join(projectRoot, "assets", "seed", "workspace.duckdb");
if (!existsSync(seedDb)) {return false;}
try {
copyFileSync(seedDb, destPath);
} catch {
return false;
}
// Create filesystem projections for CRM objects
for (const obj of SEED_OBJECTS) {
const objDir = join(workspaceDir, obj.name);
mkdirSync(objDir, { recursive: true });
writeIfMissing(join(objDir, ".object.yaml"), generateObjectYaml(obj));
}
writeIfMissing(join(workspaceDir, "WORKSPACE.md"), generateWorkspaceMd(SEED_OBJECTS));
return true;
}
// ---------------------------------------------------------------------------
// Route handler
// ---------------------------------------------------------------------------
export async function POST(req: Request) {
const body = (await req.json()) as {
profile?: string;
/** Absolute path override (optional; defaults to profile-based resolution). */
path?: string;
/** Seed bootstrap files into the new workspace. Default true. */
seedBootstrap?: boolean;
};
const profileName = body.profile?.trim() || null;
// Validate profile name if provided
if (profileName && profileName !== "default" && !/^[a-zA-Z0-9_-]+$/.test(profileName)) {
return Response.json(
{ error: "Invalid profile name. Use letters, numbers, hyphens, or underscores." },
@ -56,7 +257,6 @@ export async function POST(req: Request) {
}
}
// Create the workspace directory
try {
mkdirSync(workspaceDir, { recursive: true });
} catch (err) {
@ -66,24 +266,57 @@ export async function POST(req: Request) {
);
}
// Seed bootstrap files
const seedBootstrap = body.seedBootstrap !== false;
const seeded: string[] = [];
if (seedBootstrap) {
for (const [filename, content] of Object.entries(BOOTSTRAP_FILES)) {
const projectRoot = resolveProjectRoot();
// Seed all bootstrap files from templates
for (const filename of BOOTSTRAP_FILENAMES) {
const filePath = join(workspaceDir, filename);
if (!existsSync(filePath)) {
try {
writeFileSync(filePath, content, "utf-8");
const content = loadTemplateContent(filename, projectRoot);
if (writeIfMissing(filePath, content)) {
seeded.push(filename);
} catch {
// Skip files that can't be written (permissions, etc.)
}
}
}
// Seed DuckDB + CRM object projections
if (seedDuckDB(workspaceDir, projectRoot)) {
seeded.push("workspace.duckdb");
for (const obj of SEED_OBJECTS) {
seeded.push(`${obj.name}/.object.yaml`);
}
}
// Write workspace state so the gateway knows seeding was done
const stateDir = join(workspaceDir, ".openclaw");
const statePath = join(stateDir, "workspace-state.json");
if (!existsSync(statePath)) {
try {
mkdirSync(stateDir, { recursive: true });
const state = {
version: 1,
bootstrapSeededAt: new Date().toISOString(),
duckdbSeededAt: existsSync(join(workspaceDir, "workspace.duckdb"))
? new Date().toISOString()
: undefined,
};
writeFileSync(statePath, JSON.stringify(state, null, 2) + "\n", "utf-8");
} catch {
// Best-effort state tracking
}
}
}
// If a profile was specified, switch to it
// Remember custom-path workspaces in the registry
if (body.path?.trim() && profileName) {
registerWorkspacePath(profileName, workspaceDir);
}
// Switch to the new profile
if (profileName) {
setUIActiveProfile(profileName === "default" ? null : profileName);
}

View File

@ -1,4 +1,4 @@
import { readdirSync, readFileSync, existsSync, type Dirent } from "node:fs";
import { readdirSync, readFileSync, existsSync, statSync, type Dirent } from "node:fs";
import { join } from "node:path";
import { resolveWorkspaceRoot, resolveOpenClawStateDir, getEffectiveProfile, parseSimpleYaml, duckdbQueryAll, isDatabaseFile } from "@/lib/workspace";
@ -14,6 +14,8 @@ export type TreeNode = {
children?: TreeNode[];
/** Virtual nodes live outside the main workspace (e.g. Skills, Memories). */
virtual?: boolean;
/** True when the entry is a symbolic link. */
symlink?: boolean;
};
type DbObject = {
@ -58,11 +60,28 @@ function loadDbObjects(): Map<string, DbObject> {
return map;
}
/** Resolve a dirent's effective type, following symlinks to their target. */
function resolveEntryType(entry: Dirent, absPath: string): "directory" | "file" | null {
if (entry.isDirectory()) {return "directory";}
if (entry.isFile()) {return "file";}
if (entry.isSymbolicLink()) {
try {
const st = statSync(absPath);
if (st.isDirectory()) {return "directory";}
if (st.isFile()) {return "file";}
} catch {
// Broken symlink -- skip
}
}
return null;
}
/** Recursively build a tree from a workspace directory. */
function buildTree(
absDir: string,
relativeBase: string,
dbObjects: Map<string, DbObject>,
showHidden = false,
): TreeNode[] {
const nodes: TreeNode[] = [];
@ -73,32 +92,44 @@ function buildTree(
return nodes;
}
const filtered = entries.filter((e) => {
// .object.yaml is always needed for metadata; also shown as a node when showHidden is on
if (e.name === ".object.yaml") {return true;}
if (e.name.startsWith(".")) {return showHidden;}
return true;
});
// Sort: directories first, then files, alphabetical within each group
const sorted = entries
.filter((e) => !e.name.startsWith(".") || e.name === ".object.yaml")
.toSorted((a, b) => {
if (a.isDirectory() && !b.isDirectory()) {return -1;}
if (!a.isDirectory() && b.isDirectory()) {return 1;}
return a.name.localeCompare(b.name);
});
const sorted = filtered.toSorted((a, b) => {
const absA = join(absDir, a.name);
const absB = join(absDir, b.name);
const typeA = resolveEntryType(a, absA);
const typeB = resolveEntryType(b, absB);
const dirA = typeA === "directory";
const dirB = typeB === "directory";
if (dirA && !dirB) {return -1;}
if (!dirA && dirB) {return 1;}
return a.name.localeCompare(b.name);
});
for (const entry of sorted) {
// Skip hidden files except .object.yaml (but don't list it as a node)
if (entry.name === ".object.yaml") {continue;}
if (entry.name.startsWith(".")) {continue;}
// .object.yaml is consumed for metadata; only show it as a visible node when revealing hidden files
if (entry.name === ".object.yaml" && !showHidden) {continue;}
const absPath = join(absDir, entry.name);
const relPath = relativeBase
? `${relativeBase}/${entry.name}`
: entry.name;
if (entry.isDirectory()) {
const isSymlink = entry.isSymbolicLink();
const effectiveType = resolveEntryType(entry, absPath);
if (effectiveType === "directory") {
const objectMeta = readObjectMeta(absPath);
const dbObject = dbObjects.get(entry.name);
const children = buildTree(absPath, relPath, dbObjects);
const children = buildTree(absPath, relPath, dbObjects, showHidden);
if (objectMeta || dbObject) {
// This directory represents a CRM object (from .object.yaml OR DuckDB)
nodes.push({
name: entry.name,
path: relPath,
@ -109,17 +140,18 @@ function buildTree(
| "table"
| "kanban") ?? "table",
children: children.length > 0 ? children : undefined,
...(isSymlink && { symlink: true }),
});
} else {
// Regular folder
nodes.push({
name: entry.name,
path: relPath,
type: "folder",
children: children.length > 0 ? children : undefined,
...(isSymlink && { symlink: true }),
});
}
} else if (entry.isFile()) {
} else if (effectiveType === "file") {
const ext = entry.name.split(".").pop()?.toLowerCase();
const isReport = entry.name.endsWith(".report.json");
const isDocument = ext === "md" || ext === "mdx";
@ -129,6 +161,7 @@ function buildTree(
name: entry.name,
path: relPath,
type: isReport ? "report" : isDatabase ? "database" : isDocument ? "document" : "file",
...(isSymlink && { symlink: true }),
});
}
}
@ -206,27 +239,24 @@ function buildSkillsVirtualFolder(): TreeNode | null {
}
export async function GET() {
export async function GET(req: Request) {
const url = new URL(req.url);
const showHidden = url.searchParams.get("showHidden") === "1";
const openclawDir = resolveOpenClawStateDir();
const profile = getEffectiveProfile();
const root = resolveWorkspaceRoot();
if (!root) {
// Even without a workspace, return virtual folders if they exist
const tree: TreeNode[] = [];
const skillsFolder = buildSkillsVirtualFolder();
if (skillsFolder) {tree.push(skillsFolder);}
return Response.json({ tree, exists: false, workspaceRoot: null, openclawDir, profile });
}
// Load objects from DuckDB for smart directory detection
const dbObjects = loadDbObjects();
// Scan the workspace root — it IS the knowledge base.
// All top-level directories, files, objects, and documents are visible
// in the sidebar (USER.md, SOUL.md, memory/, etc. are all part of the tree).
const tree = buildTree(root, "", dbObjects);
const tree = buildTree(root, "", dbObjects, showHidden);
// Virtual folders go after all real files/folders
const skillsFolder = buildSkillsVirtualFolder();
if (skillsFolder) {tree.push(skillsFolder);}

View File

@ -1,21 +1,27 @@
import { writeFileSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { homedir } from "node:os";
import { resolveWorkspaceRoot, safeResolveNewPath } from "@/lib/workspace";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
const MAX_SIZE = 25 * 1024 * 1024; // 25 MB
/** Hidden uploads dir in the user's home directory — persists forever, invisible to users. */
const UPLOADS_DIR = join(homedir(), ".ironclaw", "uploads");
/**
* POST /api/workspace/upload
* Accepts multipart form data with a "file" field.
* Saves to a temp directory and returns the absolute path.
* Saves to assets/<timestamp>-<filename> inside the workspace.
* Returns { ok, path } where path is workspace-relative.
*/
export async function POST(req: Request) {
const root = resolveWorkspaceRoot();
if (!root) {
return Response.json(
{ error: "Workspace not found" },
{ status: 500 },
);
}
let formData: FormData;
try {
formData = await req.formData();
@ -43,13 +49,21 @@ export async function POST(req: Request) {
const safeName = file.name
.replace(/[^a-zA-Z0-9._-]/g, "_")
.replace(/_{2,}/g, "_");
const absPath = join(UPLOADS_DIR, `${Date.now()}-${safeName}`);
const relPath = join("assets", `${Date.now()}-${safeName}`);
const absPath = safeResolveNewPath(relPath);
if (!absPath) {
return Response.json(
{ error: "Invalid path" },
{ status: 400 },
);
}
try {
mkdirSync(dirname(absPath), { recursive: true });
const buffer = Buffer.from(await file.arrayBuffer());
writeFileSync(absPath, buffer);
return Response.json({ ok: true, path: absPath });
return Response.json({ ok: true, path: relPath });
} catch (err) {
return Response.json(
{ error: err instanceof Error ? err.message : "Upload failed" },

View File

@ -1,6 +1,7 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { DirectoryPickerModal } from "./directory-picker-modal";
type CreateWorkspaceDialogProps = {
isOpen: boolean;
@ -8,10 +9,15 @@ type CreateWorkspaceDialogProps = {
onCreated?: () => void;
};
function shortenPath(p: string): string {
return p.replace(/^\/Users\/[^/]+/, "~").replace(/^\/home\/[^/]+/, "~");
}
export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWorkspaceDialogProps) {
const [profileName, setProfileName] = useState("");
const [customPath, setCustomPath] = useState("");
const [useCustomPath, setUseCustomPath] = useState(false);
const [showDirPicker, setShowDirPicker] = useState(false);
const [seedBootstrap, setSeedBootstrap] = useState(true);
const [creating, setCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -25,22 +31,23 @@ export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWork
setProfileName("");
setCustomPath("");
setUseCustomPath(false);
setShowDirPicker(false);
setError(null);
setResult(null);
setTimeout(() => inputRef.current?.focus(), 100);
}
}, [isOpen]);
// Close on Escape
// Close on Escape (only if dir picker is not open)
useEffect(() => {
function handleKey(e: KeyboardEvent) {
if (e.key === "Escape") {onClose();}
if (e.key === "Escape" && !showDirPicker) {onClose();}
}
if (isOpen) {
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}
}, [isOpen, onClose]);
}, [isOpen, onClose, showDirPicker]);
const handleCreate = async () => {
const name = profileName.trim();
@ -222,18 +229,65 @@ export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWork
</button>
{useCustomPath && (
<input
type="text"
value={customPath}
onChange={(e) => setCustomPath(e.target.value)}
placeholder="~/my-workspace or /absolute/path"
className="w-full mt-2 px-3 py-2 text-sm rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-accent)]"
style={{
background: "var(--color-bg)",
border: "1px solid var(--color-border)",
color: "var(--color-text)",
}}
/>
<div className="mt-2 space-y-2">
{customPath ? (
<div
className="flex items-center gap-2.5 px-3 py-2.5 rounded-lg"
style={{
background: "var(--color-bg)",
border: "1px solid var(--color-border)",
}}
>
<div
className="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0"
style={{ background: "rgba(245, 158, 11, 0.12)", color: "#f59e0b" }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<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>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate" style={{ color: "var(--color-text)" }}>
{customPath.split("/").pop()}
</p>
<p className="text-[11px] truncate" style={{ color: "var(--color-text-muted)" }} title={customPath}>
{shortenPath(customPath)}
</p>
</div>
<button
onClick={() => setShowDirPicker(true)}
className="px-2 py-1 text-xs rounded-md transition-colors hover:opacity-80"
style={{ color: "var(--color-accent)" }}
>
Change
</button>
<button
onClick={() => setCustomPath("")}
className="p-1 rounded-md transition-colors hover:bg-[var(--color-surface-hover)]"
style={{ color: "var(--color-text-muted)" }}
>
<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>
) : (
<button
onClick={() => setShowDirPicker(true)}
className="w-full flex items-center justify-center gap-2 px-3 py-3 rounded-lg text-sm transition-colors hover:opacity-90"
style={{
background: "var(--color-bg)",
border: "1px dashed var(--color-border-strong)",
color: "var(--color-text-muted)",
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<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>
Browse for a directory...
</button>
)}
</div>
)}
</div>
@ -250,7 +304,7 @@ export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWork
className="text-sm"
style={{ color: "var(--color-text-secondary)" }}
>
Seed bootstrap files (AGENTS.md, SOUL.md, USER.md)
Seed bootstrap files and workspace database
</span>
</label>
@ -309,6 +363,13 @@ export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWork
)}
</div>
</div>
{/* Directory picker modal */}
<DirectoryPickerModal
open={showDirPicker}
onClose={() => setShowDirPicker(false)}
onSelect={(path) => setCustomPath(path)}
/>
</div>
);
}

View File

@ -0,0 +1,474 @@
"use client";
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
type BrowseEntry = {
name: string;
path: string;
type: "folder" | "file" | "document" | "database";
};
type DirectoryPickerModalProps = {
open: boolean;
onClose: () => void;
onSelect: (path: string) => void;
/** Starting directory (absolute). Falls back to the workspace root / home. */
startDir?: string;
};
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 folderColors = { bg: "rgba(245, 158, 11, 0.12)", fg: "#f59e0b" };
function FolderIcon({ size = 16 }: { size?: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<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>
);
}
export function DirectoryPickerModal({
open,
onClose,
onSelect,
startDir,
}: DirectoryPickerModalProps) {
const [currentDir, setCurrentDir] = useState<string | null>(startDir ?? null);
const [displayDir, setDisplayDir] = useState("");
const [entries, setEntries] = useState<BrowseEntry[]>([]);
const [parentDir, setParentDir] = useState<string | null>(null);
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);
const [visible, setVisible] = useState(false);
useEffect(() => {
if (open) {
requestAnimationFrame(() => requestAnimationFrame(() => setVisible(true)));
} else {
setVisible(false);
}
}, [open]);
useEffect(() => {
if (!open) {
setSearch("");
setCreatingFolder(false);
setNewFolderName("");
setError(null);
}
}, [open]);
// Reset to startDir when reopening
useEffect(() => {
if (open) {
setCurrentDir(startDir ?? null);
}
}, [open, startDir]);
const searchRef = useRef<HTMLInputElement>(null);
const newFolderRef = useRef<HTMLInputElement>(null);
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);
}
}, []);
useEffect(() => {
if (open) { void fetchDir(currentDir); }
}, [open, currentDir, fetchDir]);
useEffect(() => {
if (!open) {return;}
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") {onClose();}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, onClose]);
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("");
void fetchDir(currentDir);
} catch {
setError("Failed to create folder");
}
}, [newFolderName, displayDir, currentDir, fetchDir]);
const handleSelectCurrent = useCallback(() => {
if (displayDir) {
onSelect(displayDir);
onClose();
}
}, [displayDir, onSelect, onClose]);
// Only show folders
const folders = entries
.filter((e) => e.type === "folder")
.filter((e) => !search || e.name.toLowerCase().includes(search.toLowerCase()))
.toSorted((a, b) => a.name.localeCompare(b.name));
const breadcrumbs = displayDir ? buildBreadcrumbs(displayDir) : [];
// Shorten display path for the footer
const shortDir = displayDir
.replace(/^\/Users\/[^/]+/, "~")
.replace(/^\/home\/[^/]+/, "~");
if (!open) {return null;}
return (
<div
className="fixed inset-0 z-[60] flex items-center justify-center"
style={{
opacity: visible ? 1 : 0,
transition: "opacity 150ms ease-out",
}}
>
<div
className="absolute inset-0"
style={{ background: "rgba(0,0,0,0.4)", backdropFilter: "blur(4px)" }}
onClick={onClose}
/>
<div
className="relative flex flex-col rounded-2xl shadow-2xl overflow-hidden w-[calc(100%-2rem)] max-w-[540px]"
style={{
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: folderColors.bg, color: folderColors.fg }}
>
<FolderIcon size={18} />
</div>
<div>
<h2 className="text-sm font-semibold" style={{ color: "var(--color-text)" }}>
Choose Directory
</h2>
<p className="text-[11px]" style={{ color: "var(--color-text-muted)" }}>
Navigate to a folder for the workspace
</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>
{/* Breadcrumbs */}
{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 + 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 folders..."
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>
New Folder
</button>
</div>
{/* Folder 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>
) : (
<>
{/* Go up */}
{parentDir && (
<button
type="button"
onClick={() => navigateInto(parentDir)}
className="w-full flex items-center gap-3 px-4 py-2 text-left hover:bg-[var(--color-surface-hover)] transition-colors"
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: folderColors.bg, color: folderColors.fg }}
>
<FolderIcon />
</div>
<input
ref={newFolderRef}
type="text"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {void 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>
)}
{/* Folder entries */}
{folders.length === 0 && !parentDir && (
<div className="flex items-center justify-center py-16 text-[13px]" style={{ color: "var(--color-text-muted)" }}>
No subfolders here
</div>
)}
{folders.map((entry) => (
<button
key={entry.path}
type="button"
onClick={() => navigateInto(entry.path)}
className="w-full flex items-center gap-3 px-4 py-1.5 group text-left hover:bg-[var(--color-surface-hover)] transition-colors"
>
<div
className="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0"
style={{ background: folderColors.bg, color: folderColors.fg }}
>
<FolderIcon />
</div>
<span
className="flex-1 text-[13px] font-medium truncate"
style={{ color: "var(--color-text)" }}
title={entry.path}
>
{entry.name}
</span>
<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>
</button>
))}
</>
)}
</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)" }}
>
<div className="min-w-0 flex-1 mr-3">
<p className="text-[11px] truncate" style={{ color: "var(--color-text-muted)" }} title={displayDir}>
{shortDir || "Loading..."}
</p>
</div>
<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={handleSelectCurrent}
disabled={!displayDir}
className="px-3 py-1.5 rounded-lg text-[13px] font-medium disabled:opacity-40 disabled:cursor-not-allowed"
style={{
color: "white",
background: displayDir ? "var(--color-accent)" : "var(--color-border-strong)",
}}
>
Select This Folder
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -1,53 +1,36 @@
"use client";
type FileViewerProps = {
content: string;
filename: string;
type: "yaml" | "text";
};
import { useState, useEffect } from "react";
import { read, utils, type WorkBook } from "xlsx";
export function FileViewer({ content, filename, type }: FileViewerProps) {
const SPREADSHEET_EXTENSIONS = new Set([
"xlsx", "xls", "xlsb", "xlsm", "xltx", "xltm",
"ods", "fods",
"csv", "tsv",
"numbers",
]);
export function isSpreadsheetFile(filename: string): boolean {
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
return SPREADSHEET_EXTENSIONS.has(ext);
}
type FileViewerProps =
| { content: string; filename: string; type: "yaml" | "text" }
| { filename: string; type: "spreadsheet"; url: string; content?: never };
export function FileViewer(props: FileViewerProps) {
if (props.type === "spreadsheet") {
return <SpreadsheetViewer filename={props.filename} url={props.url} />;
}
const { content, filename, type } = props;
const lines = content.split("\n");
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)" }}
>
<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>
<span className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
{filename}
</span>
<span
className="text-xs px-1.5 py-0.5 rounded ml-auto"
style={{
background: "var(--color-surface-hover)",
color: "var(--color-text-muted)",
}}
>
{type.toUpperCase()}
</span>
</div>
<FileHeader filename={filename} label={type.toUpperCase()} />
{/* File content */}
<div
className="rounded-b-lg border overflow-x-auto"
style={{
@ -62,7 +45,6 @@ export function FileViewer({ content, filename, type }: FileViewerProps) {
key={idx}
className="flex hover:bg-[var(--color-surface-hover)] transition-colors duration-75"
>
{/* Line number */}
<span
className="select-none text-right pr-4 pl-4 flex-shrink-0 tabular-nums"
style={{
@ -75,7 +57,6 @@ export function FileViewer({ content, filename, type }: FileViewerProps) {
{idx + 1}
</span>
{/* Line content */}
<span
className="pr-4 flex-1"
style={{ color: "var(--color-text)" }}
@ -95,6 +76,272 @@ export function FileViewer({ content, filename, type }: FileViewerProps) {
);
}
function FileHeader({ filename, label, icon }: { filename: string; label: string; icon?: React.ReactNode }) {
return (
<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)",
}}
>
{icon ?? (
<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)" }}
>
<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>
)}
<span className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
{filename}
</span>
<span
className="text-xs px-1.5 py-0.5 rounded ml-auto"
style={{
background: "var(--color-surface-hover)",
color: "var(--color-text-muted)",
}}
>
{label}
</span>
</div>
);
}
// ---------------------------------------------------------------------------
// Spreadsheet viewer
// ---------------------------------------------------------------------------
function SpreadsheetViewer({ filename, url }: { filename: string; url: string }) {
const [workbook, setWorkbook] = useState<WorkBook | null>(null);
const [activeSheet, setActiveSheet] = useState(0);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setWorkbook(null);
setActiveSheet(0);
setError(null);
fetch(url)
.then((res) => {
if (!res.ok) {throw new Error(`Failed to load file (${res.status})`);}
return res.arrayBuffer();
})
.then((buf) => {
if (cancelled) {return;}
const wb = read(buf, { type: "array" });
setWorkbook(wb);
})
.catch((err) => {
if (!cancelled) {setError(String(err));}
});
return () => { cancelled = true; };
}, [url]);
const ext = filename.split(".").pop()?.toUpperCase() ?? "SPREADSHEET";
if (error) {
return (
<div className="max-w-6xl mx-auto px-6 py-8">
<FileHeader filename={filename} label={ext} icon={<SpreadsheetIcon />} />
<div
className="rounded-b-lg border p-8 text-center"
style={{ background: "var(--color-bg)", borderColor: "var(--color-border)", color: "var(--color-text-muted)" }}
>
Failed to load spreadsheet: {error}
</div>
</div>
);
}
if (!workbook) {
return (
<div className="max-w-6xl mx-auto px-6 py-8">
<FileHeader filename={filename} label={ext} icon={<SpreadsheetIcon />} />
<div
className="rounded-b-lg border p-8 text-center"
style={{ background: "var(--color-bg)", borderColor: "var(--color-border)", color: "var(--color-text-muted)" }}
>
Loading spreadsheet...
</div>
</div>
);
}
const sheetNames = workbook.SheetNames;
const sheet = workbook.Sheets[sheetNames[activeSheet]];
const rows: string[][] = sheet ? utils.sheet_to_json(sheet, { header: 1, defval: "" }) : [];
return (
<div className="max-w-6xl mx-auto px-6 py-8">
<FileHeader filename={filename} label={ext} icon={<SpreadsheetIcon />} />
{/* Sheet tabs */}
{sheetNames.length > 1 && (
<div
className="flex gap-0 border-x overflow-x-auto"
style={{ borderColor: "var(--color-border)" }}
>
{sheetNames.map((name, idx) => (
<button
key={name}
type="button"
onClick={() => setActiveSheet(idx)}
className="px-4 py-1.5 text-xs font-medium whitespace-nowrap border-b-2 transition-colors"
style={{
background: idx === activeSheet ? "var(--color-bg)" : "var(--color-surface)",
color: idx === activeSheet ? "var(--color-text)" : "var(--color-text-muted)",
borderBottomColor: idx === activeSheet ? "var(--color-accent)" : "transparent",
}}
>
{name}
</button>
))}
</div>
)}
{/* Table */}
<div
className="rounded-b-lg border overflow-auto"
style={{
background: "var(--color-bg)",
borderColor: "var(--color-border)",
maxHeight: "70vh",
}}
>
{rows.length === 0 ? (
<div className="p-8 text-center text-sm" style={{ color: "var(--color-text-muted)" }}>
This sheet is empty.
</div>
) : (
<table className="w-full text-sm border-collapse">
<thead>
<tr>
{/* Row number header */}
<th
className="sticky top-0 z-10 px-3 py-2 text-right select-none"
style={{
background: "var(--color-surface)",
borderBottom: "1px solid var(--color-border)",
borderRight: "1px solid var(--color-border)",
color: "var(--color-text-muted)",
minWidth: "3rem",
}}
/>
{rows[0]?.map((_cell, colIdx) => (
<th
key={colIdx}
className="sticky top-0 z-10 px-3 py-2 text-left font-medium whitespace-nowrap"
style={{
background: "var(--color-surface)",
borderBottom: "1px solid var(--color-border)",
borderRight: "1px solid var(--color-border)",
color: "var(--color-text-muted)",
}}
>
{columnLabel(colIdx)}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, rowIdx) => (
<tr
key={rowIdx}
className="hover:bg-[var(--color-surface-hover)] transition-colors duration-75"
>
<td
className="px-3 py-1.5 text-right select-none tabular-nums"
style={{
color: "var(--color-text-muted)",
opacity: 0.5,
borderRight: "1px solid var(--color-border)",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface)",
}}
>
{rowIdx + 1}
</td>
{row.map((cell, colIdx) => (
<td
key={colIdx}
className="px-3 py-1.5 whitespace-pre-wrap"
style={{
color: "var(--color-text)",
borderRight: "1px solid var(--color-border)",
borderBottom: "1px solid var(--color-border)",
maxWidth: "300px",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{String(cell)}
</td>
))}
</tr>
))}
</tbody>
</table>
)}
</div>
<div
className="mt-2 text-xs text-right"
style={{ color: "var(--color-text-muted)" }}
>
{rows.length} row{rows.length !== 1 ? "s" : ""}
{rows[0] ? ` \u00d7 ${rows[0].length} column${rows[0].length !== 1 ? "s" : ""}` : ""}
{sheetNames.length > 1 ? ` \u00b7 ${sheetNames.length} sheets` : ""}
</div>
</div>
);
}
/** Convert zero-based column index to Excel-style label (A, B, ..., Z, AA, AB, ...) */
function columnLabel(idx: number): string {
let label = "";
let n = idx;
do {
label = String.fromCharCode(65 + (n % 26)) + label;
n = Math.floor(n / 26) - 1;
} while (n >= 0);
return label;
}
function SpreadsheetIcon() {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{ color: "#22c55e" }}
>
<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="M8 13h2" />
<path d="M14 13h2" />
<path d="M8 17h2" />
<path d="M14 17h2" />
</svg>
);
}
/** Simple YAML syntax highlighting */
function YamlLine({ line }: { line: string }) {
// Comment

View File

@ -0,0 +1,276 @@
"use client";
import { useState, useEffect, useMemo, useCallback } from "react";
import { createHighlighter, type Highlighter } from "shiki";
type HtmlViewerProps = {
filename: string;
/** Raw URL for iframe rendering (served with text/html) */
rawUrl: string;
/** JSON API URL to fetch source content on demand (for code view) */
contentUrl: string;
};
type ViewMode = "rendered" | "code";
let highlighterPromise: Promise<Highlighter> | null = null;
function getHighlighter(): Promise<Highlighter> {
if (!highlighterPromise) {
highlighterPromise = createHighlighter({
themes: ["github-dark", "github-light"],
langs: ["html"],
});
}
return highlighterPromise;
}
export function HtmlViewer({ filename, rawUrl, contentUrl }: HtmlViewerProps) {
const [mode, setMode] = useState<ViewMode>("rendered");
const [source, setSource] = useState<string | null>(null);
const [sourceLoading, setSourceLoading] = useState(false);
const handleCodeToggle = useCallback(() => {
setMode("code");
if (source !== null) {return;}
setSourceLoading(true);
void fetch(contentUrl)
.then((r) => r.json())
.then((data: { content: string }) => setSource(data.content))
.catch(() => setSource("<!-- Failed to load source -->"))
.finally(() => setSourceLoading(false));
}, [contentUrl, source]);
return (
<div className="flex flex-col h-full">
{/* Header bar */}
<div
className="flex items-center gap-3 px-5 py-3 border-b flex-shrink-0"
style={{ borderColor: "var(--color-border)" }}
>
<HtmlIcon />
<span className="text-sm font-medium truncate" style={{ color: "var(--color-text)" }}>
{filename}
</span>
<span
className="text-[10px] px-2 py-0.5 rounded-full flex-shrink-0"
style={{
background: "#f9731618",
color: "#f97316",
border: "1px solid #f9731630",
}}
>
HTML
</span>
{/* Mode toggle */}
<div
className="flex items-center ml-auto rounded-lg p-0.5"
style={{ background: "var(--color-surface-hover)" }}
>
<button
type="button"
onClick={() => setMode("rendered")}
className="flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium transition-colors duration-100 cursor-pointer"
style={{
background: mode === "rendered" ? "var(--color-surface)" : "transparent",
color: mode === "rendered" ? "var(--color-text)" : "var(--color-text-muted)",
boxShadow: mode === "rendered" ? "0 1px 2px rgba(0,0,0,0.1)" : "none",
}}
>
<EyeIcon />
Preview
</button>
<button
type="button"
onClick={handleCodeToggle}
className="flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium transition-colors duration-100 cursor-pointer"
style={{
background: mode === "code" ? "var(--color-surface)" : "transparent",
color: mode === "code" ? "var(--color-text)" : "var(--color-text-muted)",
boxShadow: mode === "code" ? "0 1px 2px rgba(0,0,0,0.1)" : "none",
}}
>
<CodeIcon />
Code
</button>
</div>
{/* Open in new tab */}
<a
href={rawUrl}
target="_blank"
rel="noopener noreferrer"
className="p-1.5 rounded-md transition-colors duration-100"
style={{ color: "var(--color-text-muted)" }}
title="Open in new tab"
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background = "transparent";
}}
>
<ExternalLinkIcon />
</a>
</div>
{/* Content */}
{mode === "rendered" ? (
<RenderedView rawUrl={rawUrl} />
) : sourceLoading || source === null ? (
<div className="flex-1 flex items-center justify-center">
<div
className="w-6 h-6 border-2 rounded-full animate-spin"
style={{
borderColor: "var(--color-border)",
borderTopColor: "var(--color-accent)",
}}
/>
</div>
) : (
<CodeView content={source} />
)}
</div>
);
}
// --- Rendered HTML view (sandboxed iframe) ---
function RenderedView({ rawUrl }: { rawUrl: string }) {
return (
<div className="flex-1 overflow-hidden" style={{ background: "white" }}>
<iframe
src={rawUrl}
className="w-full h-full border-0"
sandbox="allow-same-origin allow-scripts allow-popups"
title="HTML preview"
style={{ minHeight: "calc(100vh - 120px)" }}
/>
</div>
);
}
// --- Syntax-highlighted code view ---
function CodeView({ content }: { content: string }) {
const [html, setHtml] = useState<string | null>(null);
const lineCount = useMemo(() => content.split("\n").length, [content]);
useEffect(() => {
let cancelled = false;
void getHighlighter().then((highlighter) => {
if (cancelled) {return;}
const result = highlighter.codeToHtml(content, {
lang: "html",
themes: { dark: "github-dark", light: "github-light" },
});
setHtml(result);
});
return () => { cancelled = true; };
}, [content]);
return (
<div className="flex-1 overflow-auto" style={{ background: "var(--color-surface)" }}>
<div className="max-w-4xl mx-auto px-6 py-8">
<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)",
}}
>
<CodeIcon />
<span className="text-xs" style={{ color: "var(--color-text-muted)" }}>
HTML
</span>
<span className="text-xs ml-auto" style={{ color: "var(--color-text-muted)" }}>
{lineCount} lines
</span>
</div>
<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 }}
/>
) : (
<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>
</div>
);
}
// --- Icons ---
function HtmlIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#f97316" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" />
<line x1="12" x2="10" y1="2" y2="22" />
</svg>
);
}
function EyeIcon() {
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" />
<circle cx="12" cy="12" r="3" />
</svg>
);
}
function CodeIcon() {
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" />
</svg>
);
}
function ExternalLinkIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 3h6v6" />
<path d="M10 14 21 3" />
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
</svg>
);
}

View File

@ -9,6 +9,8 @@ export type TreeNode = {
icon?: string;
defaultView?: "table" | "kanban";
children?: TreeNode[];
/** True when the entry is a symbolic link. */
symlink?: boolean;
};
/**
@ -30,6 +32,9 @@ export function useWorkspaceWatcher() {
const [openclawDir, setOpenclawDir] = useState<string | null>(null);
const [activeProfile, setActiveProfile] = useState<string | null>(null);
// Show hidden (dot) files/folders
const [showHidden, setShowHidden] = useState(false);
const mountedRef = useRef(true);
const retryDelayRef = useRef(1000);
// Version counter: prevents stale fetch responses from overwriting newer data.
@ -44,7 +49,8 @@ export function useWorkspaceWatcher() {
const fetchWorkspaceTree = useCallback(async () => {
const version = ++fetchVersionRef.current;
try {
const res = await fetch("/api/workspace/tree");
const qs = showHidden ? "?showHidden=1" : "";
const res = await fetch(`/api/workspace/tree${qs}`);
const data = await res.json();
if (mountedRef.current && fetchVersionRef.current === version) {
setTree(data.tree ?? []);
@ -57,14 +63,15 @@ export function useWorkspaceWatcher() {
} catch {
if (mountedRef.current && fetchVersionRef.current === version) {setLoading(false);}
}
}, []);
}, [showHidden]);
// Fetch a directory listing from the browse API
const fetchBrowseTree = useCallback(async (dir: string) => {
const version = ++fetchVersionRef.current;
try {
setLoading(true);
const res = await fetch(`/api/workspace/browse?dir=${encodeURIComponent(dir)}`);
const hiddenQs = showHidden ? "&showHidden=1" : "";
const res = await fetch(`/api/workspace/browse?dir=${encodeURIComponent(dir)}${hiddenQs}`);
const data = await res.json();
if (mountedRef.current && fetchVersionRef.current === version) {
setTree(data.entries ?? []);
@ -75,7 +82,7 @@ export function useWorkspaceWatcher() {
} catch {
if (mountedRef.current && fetchVersionRef.current === version) {setLoading(false);}
}
}, []);
}, [showHidden]);
// Smart setBrowseDir: auto-return to workspace mode when navigating to the
// workspace root, so all virtual folders (Chats, Cron, etc.) and DuckDB
@ -211,5 +218,5 @@ export function useWorkspaceWatcher() {
};
}, [browseDirRaw, fetchWorkspaceTree, sseReconnectKey]);
return { tree, loading, exists, refresh, reconnect, browseDir, setBrowseDir, parentDir, workspaceRoot, openclawDir, activeProfile };
return { tree, loading, exists, refresh, reconnect, browseDir, setBrowseDir, parentDir, workspaceRoot, openclawDir, activeProfile, showHidden, setShowHidden };
}

View File

@ -8,7 +8,8 @@ import { useWorkspaceWatcher } from "../hooks/use-workspace-watcher";
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 { FileViewer, isSpreadsheetFile } from "../components/workspace/file-viewer";
import { HtmlViewer } from "../components/workspace/html-viewer";
import { CodeViewer } from "../components/workspace/code-viewer";
import { MediaViewer, detectMediaType, type MediaType } from "../components/workspace/media-viewer";
import { DatabaseViewer, DuckDBMissing } from "../components/workspace/database-viewer";
@ -97,6 +98,8 @@ type ContentState =
| { kind: "file"; data: FileData; filename: string }
| { kind: "code"; data: FileData; filename: string }
| { kind: "media"; url: string; mediaType: MediaType; filename: string; filePath: string }
| { kind: "spreadsheet"; url: string; filename: string }
| { kind: "html"; rawUrl: string; contentUrl: string; filename: string }
| { kind: "database"; dbPath: string; filename: string }
| { kind: "report"; reportPath: string; filename: string }
| { kind: "directory"; node: TreeNode }
@ -656,6 +659,20 @@ function WorkspacePageInner() {
} else if (node.type === "report") {
setContent({ kind: "report", reportPath: node.path, filename: node.name });
} else if (node.type === "file") {
// Spreadsheet files get their own binary viewer
if (isSpreadsheetFile(node.name)) {
const url = rawFileUrl(node.path);
setContent({ kind: "spreadsheet", url, filename: node.name });
return;
}
// HTML files get an iframe preview
const ext = node.name.split(".").pop()?.toLowerCase() ?? "";
if (ext === "html" || ext === "htm") {
setContent({ kind: "html", rawUrl: rawFileUrl(node.path), contentUrl: fileApiUrl(node.path), filename: node.name });
return;
}
// Check if this is a media file (image/video/audio/pdf)
const mediaType = detectMediaType(node.name);
if (mediaType) {
@ -1046,12 +1063,12 @@ function WorkspacePageInner() {
params.set("path", activePath);
const entry = current.get("entry");
if (entry) {params.set("entry", entry);}
router.replace(`/workspace?${params.toString()}`, { scroll: false });
router.push(`/workspace?${params.toString()}`, { scroll: false });
}
} else if (activeSessionId) {
// Chat mode — no file selected.
if (current.get("chat") !== activeSessionId || current.has("path")) {
router.replace(`/workspace?chat=${encodeURIComponent(activeSessionId)}`, { scroll: false });
router.push(`/workspace?chat=${encodeURIComponent(activeSessionId)}`, { scroll: false });
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally excludes searchParams to avoid infinite loop
@ -1063,7 +1080,7 @@ function WorkspacePageInner() {
setEntryModal({ objectName, entryId });
const params = new URLSearchParams(searchParams.toString());
params.set("entry", `${objectName}:${entryId}`);
router.replace(`/workspace?${params.toString()}`, { scroll: false });
router.push(`/workspace?${params.toString()}`, { scroll: false });
},
[searchParams, router],
);
@ -2084,6 +2101,24 @@ function ContentRenderer({
/>
);
case "spreadsheet":
return (
<FileViewer
filename={content.filename}
type="spreadsheet"
url={content.url}
/>
);
case "html":
return (
<HtmlViewer
rawUrl={content.rawUrl}
contentUrl={content.contentUrl}
filename={content.filename}
/>
);
case "database":
return (
<DatabaseViewer

View File

@ -384,12 +384,35 @@ function updateIndex(
) {
try {
const idxPath = indexFile();
if (!existsSync(idxPath)) {return;}
const index = JSON.parse(
let index: Array<Record<string, unknown>>;
if (!existsSync(idxPath)) {
// Auto-create index with a bootstrap entry for this session so
// orphaned .jsonl files become visible in the sidebar.
index = [{
id: sessionId,
title: opts.title || "New Chat",
createdAt: Date.now(),
updatedAt: Date.now(),
messageCount: opts.incrementCount || 0,
}];
writeFileSync(idxPath, JSON.stringify(index, null, 2));
return;
}
index = JSON.parse(
readFileSync(idxPath, "utf-8"),
) as Array<Record<string, unknown>>;
const session = index.find((s) => s.id === sessionId);
if (!session) {return;}
let session = index.find((s) => s.id === sessionId);
if (!session) {
// Session file exists but wasn't indexed — add it.
session = {
id: sessionId,
title: opts.title || "New Chat",
createdAt: Date.now(),
updatedAt: Date.now(),
messageCount: 0,
};
index.unshift(session);
}
session.updatedAt = Date.now();
if (opts.incrementCount) {
session.messageCount =

View File

@ -19,7 +19,11 @@ const UI_STATE_FILENAME = ".ironclaw-ui-state.json";
/** In-memory override; takes precedence over the persisted file. */
let _uiActiveProfile: string | null | undefined;
type UIState = { activeProfile?: string | null };
type UIState = {
activeProfile?: string | null;
/** Maps profile names to absolute workspace paths for workspaces outside ~/.openclaw/. */
workspaceRegistry?: Record<string, string>;
};
function uiStatePath(): string {
const home = process.env.OPENCLAW_HOME?.trim() || homedir();
@ -55,7 +59,8 @@ export function getEffectiveProfile(): string | null {
export function setUIActiveProfile(profile: string | null): void {
const normalized = profile?.trim() || null;
_uiActiveProfile = normalized;
writeUIState({ activeProfile: normalized });
const existing = readUIState();
writeUIState({ ...existing, activeProfile: normalized });
}
/** Reset the in-memory override (re-reads from file on next call). */
@ -63,6 +68,29 @@ export function clearUIActiveProfileCache(): void {
_uiActiveProfile = undefined;
}
// ---------------------------------------------------------------------------
// Workspace registry — remembers workspaces created outside ~/.openclaw/.
// ---------------------------------------------------------------------------
/** Read the full workspace registry (profile → absolute path). */
export function getWorkspaceRegistry(): Record<string, string> {
return readUIState().workspaceRegistry ?? {};
}
/** Look up a single profile's registered workspace path. */
export function getRegisteredWorkspacePath(profile: string | null): string | null {
if (!profile) {return null;}
return getWorkspaceRegistry()[profile] ?? null;
}
/** Persist a profile → workspace-path mapping in the registry. */
export function registerWorkspacePath(profile: string, absolutePath: string): void {
const state = readUIState();
const registry = state.workspaceRegistry ?? {};
registry[profile] = absolutePath;
writeUIState({ ...state, workspaceRegistry: registry });
}
// ---------------------------------------------------------------------------
// Profile discovery — scans the filesystem for all profiles/workspaces.
// ---------------------------------------------------------------------------
@ -123,6 +151,26 @@ export function discoverProfiles(): DiscoveredProfile[] {
}
}
// Merge workspaces registered via custom paths (outside ~/.openclaw/)
const registry = getWorkspaceRegistry();
for (const [profileName, wsPath] of Object.entries(registry)) {
if (seen.has(profileName)) {
const existing = profiles.find((p) => p.name === profileName);
if (existing && !existing.workspaceDir && existsSync(wsPath)) {
existing.workspaceDir = wsPath;
}
continue;
}
seen.add(profileName);
profiles.push({
name: profileName,
stateDir: baseStateDir,
workspaceDir: existsSync(wsPath) ? wsPath : null,
isActive: activeProfile === profileName,
hasConfig: existsSync(join(baseStateDir, "openclaw.json")),
});
}
return profiles;
}
@ -173,8 +221,10 @@ export function resolveWebChatDir(): string {
export function resolveWorkspaceRoot(): string | null {
const stateDir = resolveOpenClawStateDir();
const profile = getEffectiveProfile();
const registryPath = getRegisteredWorkspacePath(profile);
const candidates = [
process.env.OPENCLAW_WORKSPACE,
registryPath,
profile && profile.toLowerCase() !== "default"
? join(stateDir, `workspace-${profile}`)
: null,

View File

@ -1,5 +1,6 @@
import type { NextConfig } from "next";
import path from "node:path";
import { homedir } from "node:os";
const nextConfig: NextConfig = {
// Produce a self-contained standalone build so npm global installs
@ -17,6 +18,23 @@ const nextConfig: NextConfig = {
// Transpile ESM-only packages so webpack can bundle them
transpilePackages: ["react-markdown", "remark-gfm"],
webpack: (config, { dev }) => {
if (dev) {
config.watchOptions = {
...config.watchOptions,
ignored: [
"**/node_modules/**",
"**/.git/**",
"**/dist/**",
"**/.next/**",
path.join(homedir(), ".openclaw", "**"),
],
poll: 1500,
};
}
return config;
},
};
export default nextConfig;

View File

@ -47,7 +47,8 @@
"remark-gfm": "^4.0.1",
"shiki": "^3.22.0",
"tailwind-merge": "^3.5.0",
"unicode-animations": "^1.0.3"
"unicode-animations": "^1.0.3",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.8",

72
pnpm-lock.yaml generated
View File

@ -407,6 +407,9 @@ importers:
unicode-animations:
specifier: ^1.0.3
version: 1.0.3
xlsx:
specifier: ^0.18.5
version: 0.18.5
devDependencies:
'@tailwindcss/postcss':
specifier: ^4.1.8
@ -3955,6 +3958,10 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
adler-32@1.3.1:
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
engines: {node: '>=0.8'}
agent-base@7.1.4:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
@ -4182,6 +4189,10 @@ packages:
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
cfb@1.2.2:
resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
engines: {node: '>=0.8'}
chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
@ -4260,6 +4271,10 @@ packages:
engines: {node: '>= 14.15.0'}
hasBin: true
codepage@1.15.0:
resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
engines: {node: '>=0.8'}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@ -4326,6 +4341,11 @@ packages:
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
crc-32@1.2.2:
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
engines: {node: '>=0.8'}
hasBin: true
crelt@1.0.6:
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
@ -4753,6 +4773,10 @@ packages:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'}
frac@1.1.2:
resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
engines: {node: '>=0.8'}
framer-motion@12.34.0:
resolution: {integrity: sha512-+/H49owhzkzQyxtn7nZeF4kdH++I2FWrESQ184Zbcw5cEqNHYkE5yxWxcTLSj5lNx3NWdbIRy5FHqUvetD8FWg==}
peerDependencies:
@ -6588,6 +6612,10 @@ packages:
sqlite-vec@0.1.7-alpha.2:
resolution: {integrity: sha512-rNgRCv+4V4Ed3yc33Qr+nNmjhtrMnnHzXfLVPeGb28Dx5mmDL3Ngw/Wk8vhCGjj76+oC6gnkmMG8y73BZWGBwQ==}
ssf@0.11.2:
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
engines: {node: '>=0.8'}
sshpk@1.18.0:
resolution: {integrity: sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==}
engines: {node: '>=0.10.0'}
@ -7097,6 +7125,14 @@ packages:
win-guid@0.2.1:
resolution: {integrity: sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A==}
wmf@1.0.2:
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
engines: {node: '>=0.8'}
word@0.3.0:
resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
engines: {node: '>=0.8'}
wordwrapjs@5.1.1:
resolution: {integrity: sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==}
engines: {node: '>=12.17'}
@ -7124,6 +7160,11 @@ packages:
utf-8-validate:
optional: true
xlsx@0.18.5:
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
engines: {node: '>=0.8'}
hasBin: true
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@ -10792,6 +10833,8 @@ snapshots:
acorn@8.15.0: {}
adler-32@1.3.1: {}
agent-base@7.1.4: {}
ai@6.0.86(zod@4.3.6):
@ -11027,6 +11070,11 @@ snapshots:
ccount@2.0.1: {}
cfb@1.2.2:
dependencies:
adler-32: 1.3.1
crc-32: 1.2.2
chai@6.2.2: {}
chalk-template@0.4.0:
@ -11108,6 +11156,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
codepage@1.15.0: {}
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@ -11160,6 +11210,8 @@ snapshots:
core-util-is@1.0.3: {}
crc-32@1.2.2: {}
crelt@1.0.6: {}
croner@10.0.1: {}
@ -11600,6 +11652,8 @@ snapshots:
forwarded@0.2.0: {}
frac@1.1.2: {}
framer-motion@12.34.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
motion-dom: 12.34.0
@ -13999,6 +14053,10 @@ snapshots:
sqlite-vec-linux-x64: 0.1.7-alpha.2
sqlite-vec-windows-x64: 0.1.7-alpha.2
ssf@0.11.2:
dependencies:
frac: 1.1.2
sshpk@1.18.0:
dependencies:
asn1: 0.2.6
@ -14523,6 +14581,10 @@ snapshots:
win-guid@0.2.1: {}
wmf@1.0.2: {}
word@0.3.0: {}
wordwrapjs@5.1.1: {}
wrap-ansi@7.0.0:
@ -14541,6 +14603,16 @@ snapshots:
ws@8.19.0: {}
xlsx@0.18.5:
dependencies:
adler-32: 1.3.1
cfb: 1.2.2
codepage: 1.15.0
crc-32: 1.2.2
ssf: 0.11.2
wmf: 1.0.2
word: 0.3.0
y18n@5.0.8: {}
yallist@4.0.0: {}