Revert "Merge kumar workspaces to design branch"

This reverts commit bf4445115b911fa7831e6a35f7d0114154bb58ec, reversing
changes made to face53f2341417e339c02d5d7d4c412b961f87f6.
This commit is contained in:
Mark 2026-02-20 12:45:42 -08:00
parent bf4445115b
commit ca8ac9fda1
32 changed files with 221 additions and 2089 deletions

View File

@ -1,4 +1,4 @@
import { subscribeToSubagent, hasActiveSubagent, isSubagentRunning, ensureRegisteredFromDisk, ensureSubagentStreamable } from "@/lib/subagent-runs";
import { subscribeToSubagent, hasActiveSubagent, isSubagentRunning, ensureRegisteredFromDisk } from "@/lib/subagent-runs";
import type { SseEvent } from "@/lib/subagent-runs";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
@ -51,10 +51,6 @@ export async function GET(req: Request) {
return new Response("Subagent not found", { status: 404 });
}
// For still-running subagents rehydrated from disk, activate the gateway
// WebSocket subscription so new events arrive in real time.
ensureSubagentStreamable(sessionKey);
const isActive = isSubagentRunning(sessionKey);
const encoder = new TextEncoder();
let closed = false;

View File

@ -1,4 +1,4 @@
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from "node:fs";
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { randomUUID } from "node:crypto";
import { resolveWebChatDir } from "@/lib/workspace";
@ -23,68 +23,15 @@ 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");
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
if (!existsSync(indexFile)) {return [];}
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;
return JSON.parse(readFileSync(indexFile, "utf-8"));
} catch {
return [];
}
}
function writeIndex(sessions: WebSessionMeta[]) {

View File

@ -18,8 +18,6 @@ const MIME_MAP: Record<string, string> = {
wav: "audio/wav",
ogg: "audio/ogg",
pdf: "application/pdf",
html: "text/html",
htm: "text/html",
};
/** Extensions recognized as code files for syntax-highlighted viewing. */

View File

@ -1,4 +1,4 @@
import { readdirSync, statSync, type Dirent } from "node:fs";
import { readdirSync, type Dirent } from "node:fs";
import { join, dirname, resolve } from "node:path";
import { resolveWorkspaceRoot } from "@/lib/workspace";
@ -10,34 +10,16 @@ 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 [];}
@ -48,43 +30,29 @@ function buildBrowseTree(
return [];
}
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 = 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 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 (effectiveType === "directory") {
const children = buildBrowseTree(absPath, maxDepth, currentDepth + 1, showHidden);
if (entry.isDirectory()) {
const children = buildBrowseTree(absPath, maxDepth, currentDepth + 1);
nodes.push({
name: entry.name,
path: absPath,
type: "folder",
children: children.length > 0 ? children : undefined,
...(isSymlink && { symlink: true }),
});
} else if (effectiveType === "file") {
} else if (entry.isFile()) {
const ext = entry.name.split(".").pop()?.toLowerCase();
const isDocument = ext === "md" || ext === "mdx";
const isDatabase = ext === "duckdb" || ext === "sqlite" || ext === "sqlite3" || ext === "db";
@ -93,7 +61,6 @@ function buildBrowseTree(
name: entry.name,
path: absPath,
type: isDatabase ? "database" : isDocument ? "document" : "file",
...(isSymlink && { symlink: true }),
});
}
}
@ -104,8 +71,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();
}
@ -116,9 +83,10 @@ export async function GET(req: Request) {
);
}
// Resolve and normalize the directory path
const resolved = resolve(dir);
const entries = buildBrowseTree(resolved, 3, 0, showHidden);
const entries = buildBrowseTree(resolved, 3);
const parentDir = resolved === "/" ? null : dirname(resolved);
return Response.json({

View File

@ -1,239 +1,38 @@
import { existsSync, mkdirSync, writeFileSync, readFileSync, copyFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import { resolveOpenClawStateDir, setUIActiveProfile, getEffectiveProfile, resolveWorkspaceRoot, registerWorkspacePath } from "@/lib/workspace";
import { resolveOpenClawStateDir, setUIActiveProfile, getEffectiveProfile, resolveWorkspaceRoot } from "@/lib/workspace";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
// ---------------------------------------------------------------------------
// Bootstrap file names (must match src/agents/workspace.ts)
// ---------------------------------------------------------------------------
const BOOTSTRAP_FILES: Record<string, string> = {
"AGENTS.md": `# Workspace Agent Instructions
const BOOTSTRAP_FILENAMES = [
"AGENTS.md",
"SOUL.md",
"TOOLS.md",
"IDENTITY.md",
"USER.md",
"HEARTBEAT.md",
"BOOTSTRAP.md",
] as const;
Add instructions here that your agent should follow when working in this workspace.
`,
"SOUL.md": `# Soul
// 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",
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.
`,
};
// ---------------------------------------------------------------------------
// 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." },
@ -257,6 +56,7 @@ export async function POST(req: Request) {
}
}
// Create the workspace directory
try {
mkdirSync(workspaceDir, { recursive: true });
} catch (err) {
@ -266,57 +66,24 @@ export async function POST(req: Request) {
);
}
// Seed bootstrap files
const seedBootstrap = body.seedBootstrap !== false;
const seeded: string[] = [];
if (seedBootstrap) {
const projectRoot = resolveProjectRoot();
// Seed all bootstrap files from templates
for (const filename of BOOTSTRAP_FILENAMES) {
for (const [filename, content] of Object.entries(BOOTSTRAP_FILES)) {
const filePath = join(workspaceDir, filename);
if (!existsSync(filePath)) {
const content = loadTemplateContent(filename, projectRoot);
if (writeIfMissing(filePath, content)) {
try {
writeFileSync(filePath, content, "utf-8");
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
}
}
}
// Remember custom-path workspaces in the registry
if (body.path?.trim() && profileName) {
registerWorkspacePath(profileName, workspaceDir);
}
// Switch to the new profile
// If a profile was specified, switch to it
if (profileName) {
setUIActiveProfile(profileName === "default" ? null : profileName);
}

View File

@ -33,8 +33,6 @@ const MIME_MAP: Record<string, string> = {
m4a: "audio/mp4",
// Documents
pdf: "application/pdf",
html: "text/html",
htm: "text/html",
};
/**

View File

@ -1,4 +1,4 @@
import { readdirSync, readFileSync, existsSync, statSync, type Dirent } from "node:fs";
import { readdirSync, readFileSync, existsSync, type Dirent } from "node:fs";
import { join } from "node:path";
import { resolveWorkspaceRoot, resolveOpenClawStateDir, getEffectiveProfile, parseSimpleYaml, duckdbQueryAll, isDatabaseFile } from "@/lib/workspace";
@ -14,8 +14,6 @@ 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 = {
@ -60,28 +58,11 @@ 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[] = [];
@ -92,44 +73,32 @@ 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 = 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 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);
});
for (const entry of sorted) {
// .object.yaml is consumed for metadata; only show it as a visible node when revealing hidden files
if (entry.name === ".object.yaml" && !showHidden) {continue;}
// 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;}
const absPath = join(absDir, entry.name);
const relPath = relativeBase
? `${relativeBase}/${entry.name}`
: entry.name;
const isSymlink = entry.isSymbolicLink();
const effectiveType = resolveEntryType(entry, absPath);
if (effectiveType === "directory") {
if (entry.isDirectory()) {
const objectMeta = readObjectMeta(absPath);
const dbObject = dbObjects.get(entry.name);
const children = buildTree(absPath, relPath, dbObjects, showHidden);
const children = buildTree(absPath, relPath, dbObjects);
if (objectMeta || dbObject) {
// This directory represents a CRM object (from .object.yaml OR DuckDB)
nodes.push({
name: entry.name,
path: relPath,
@ -140,18 +109,17 @@ 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 (effectiveType === "file") {
} else if (entry.isFile()) {
const ext = entry.name.split(".").pop()?.toLowerCase();
const isReport = entry.name.endsWith(".report.json");
const isDocument = ext === "md" || ext === "mdx";
@ -161,7 +129,6 @@ function buildTree(
name: entry.name,
path: relPath,
type: isReport ? "report" : isDatabase ? "database" : isDocument ? "document" : "file",
...(isSymlink && { symlink: true }),
});
}
}
@ -239,24 +206,27 @@ function buildSkillsVirtualFolder(): TreeNode | null {
}
export async function GET(req: Request) {
const url = new URL(req.url);
const showHidden = url.searchParams.get("showHidden") === "1";
export async function GET() {
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();
const tree = buildTree(root, "", dbObjects, showHidden);
// 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);
// Virtual folders go after all real files/folders
const skillsFolder = buildSkillsVirtualFolder();
if (skillsFolder) {tree.push(skillsFolder);}

View File

@ -3,137 +3,95 @@ import { resolveWorkspaceRoot } from "@/lib/workspace";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
// ---------------------------------------------------------------------------
// Singleton watcher: one chokidar instance shared across all SSE connections.
// Uses polling (no native fs.watch FDs) so it doesn't compete with Next.js's
// own dev watcher for the macOS per-process file-descriptor limit.
// ---------------------------------------------------------------------------
type Listener = (type: string, relPath: string) => void;
let listeners = new Set<Listener>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let sharedWatcher: any = null;
let sharedRoot: string | null = null;
let __watcherReady = false;
async function ensureWatcher(root: string) {
if (sharedWatcher && sharedRoot === root) {return;}
// Root changed (e.g. profile switch) -- close the old watcher first.
if (sharedWatcher) {
await sharedWatcher.close();
sharedWatcher = null;
sharedRoot = null;
_watcherReady = false;
}
try {
const chokidar = await import("chokidar");
sharedRoot = root;
sharedWatcher = chokidar.watch(root, {
ignoreInitial: true,
usePolling: true,
interval: 1500,
binaryInterval: 3000,
ignored: [
/(^|[\\/])node_modules([\\/]|$)/,
/(^|[\\/])\.git([\\/]|$)/,
/(^|[\\/])\.next([\\/]|$)/,
/(^|[\\/])dist([\\/]|$)/,
/\.duckdb\.wal$/,
/\.duckdb\.tmp$/,
],
depth: 5,
});
sharedWatcher.on("all", (eventType: string, filePath: string) => {
const rel = filePath.startsWith(root)
? filePath.slice(root.length + 1)
: filePath;
for (const fn of listeners) {fn(eventType, rel);}
});
sharedWatcher.once("ready", () => {_watcherReady = true;});
sharedWatcher.on("error", () => {
// Swallow; polling mode shouldn't hit EMFILE but be safe.
});
} catch {
// chokidar unavailable -- listeners simply won't fire.
}
}
function stopWatcherIfIdle() {
if (listeners.size > 0 || !sharedWatcher) {return;}
sharedWatcher.close();
sharedWatcher = null;
sharedRoot = null;
_watcherReady = false;
}
/**
* GET /api/workspace/watch
*
* Server-Sent Events endpoint that watches the workspace for file changes.
* Sends events: { type: "add"|"change"|"unlink"|"addDir"|"unlinkDir", path: string }
* Falls back gracefully if chokidar is unavailable.
*/
export async function GET(req: Request) {
export async function GET() {
const root = resolveWorkspaceRoot();
if (!root) {
return new Response("Workspace not found", { status: 404 });
}
const encoder = new TextEncoder();
let closed = false;
let heartbeat: ReturnType<typeof setInterval> | null = null;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
const stream = new ReadableStream({
async start(controller) {
// Send initial heartbeat so the client knows the connection is alive
controller.enqueue(encoder.encode("event: connected\ndata: {}\n\n"));
const listener: Listener = (_type, _rel) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let watcher: any = null;
let closed = false;
// Debounce: batch rapid events into a single "refresh" signal
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
function sendEvent(type: string, filePath: string) {
if (closed) {return;}
if (debounceTimer) {clearTimeout(debounceTimer);}
debounceTimer = setTimeout(() => {
if (closed) {return;}
try {
const data = JSON.stringify({ type: _type, path: _rel });
const data = JSON.stringify({ type, path: filePath });
controller.enqueue(encoder.encode(`event: change\ndata: ${data}\n\n`));
} catch { /* stream closed */ }
}, 300);
};
} catch {
// Stream may have been closed
}
}, 200);
}
heartbeat = setInterval(() => {
// Keep-alive heartbeat every 30s to prevent proxy/timeout disconnects
const heartbeat = setInterval(() => {
if (closed) {return;}
try {
controller.enqueue(encoder.encode(": heartbeat\n\n"));
} catch { /* closed */ }
} catch {
// Ignore if closed
}
}, 30_000);
function teardown() {
if (closed) {return;}
closed = true;
listeners.delete(listener);
if (heartbeat) {clearInterval(heartbeat);}
if (debounceTimer) {clearTimeout(debounceTimer);}
stopWatcherIfIdle();
}
try {
// Dynamic import so the route still compiles if chokidar is missing
const chokidar = await import("chokidar");
watcher = chokidar.watch(root, {
ignoreInitial: true,
awaitWriteFinish: { stabilityThreshold: 150, pollInterval: 50 },
ignored: [
/(^|[\\/])node_modules([\\/]|$)/,
/\.duckdb\.wal$/,
/\.duckdb\.tmp$/,
],
depth: 10,
});
req.signal.addEventListener("abort", teardown, { once: true });
listeners.add(listener);
await ensureWatcher(root);
if (!sharedWatcher) {
watcher.on("all", (eventType: string, filePath: string) => {
// Make path relative to workspace root
const rel = filePath.startsWith(root)
? filePath.slice(root.length + 1)
: filePath;
sendEvent(eventType, rel);
});
} catch {
// chokidar not available, send a fallback event and close
controller.enqueue(
encoder.encode("event: error\ndata: {\"error\":\"File watching unavailable\"}\n\n"),
);
}
},
cancel() {
closed = true;
// Cleanup when the client disconnects
// The cancel callback is invoked by the runtime when the response is aborted
const originalCancel = stream.cancel?.bind(stream);
stream.cancel = async (reason) => {
closed = true;
clearInterval(heartbeat);
if (debounceTimer) {clearTimeout(debounceTimer);}
if (watcher) {await watcher.close();}
if (originalCancel) {return originalCancel(reason);}
};
},
});

View File

@ -437,7 +437,6 @@ export function Sidebar({
<ProfileSwitcher
onProfileSwitch={handleProfileSwitch}
onCreateWorkspace={() => setShowCreateWorkspace(true)}
activeProfileHint={String(sidebarRefreshKey)}
/>
</div>

View File

@ -1,7 +1,6 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { DirectoryPickerModal } from "./directory-picker-modal";
type CreateWorkspaceDialogProps = {
isOpen: boolean;
@ -9,15 +8,10 @@ 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);
@ -31,23 +25,22 @@ 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 (only if dir picker is not open)
// Close on Escape
useEffect(() => {
function handleKey(e: KeyboardEvent) {
if (e.key === "Escape" && !showDirPicker) {onClose();}
if (e.key === "Escape") {onClose();}
}
if (isOpen) {
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}
}, [isOpen, onClose, showDirPicker]);
}, [isOpen, onClose]);
const handleCreate = async () => {
const name = profileName.trim();
@ -229,65 +222,18 @@ export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWork
</button>
{useCustomPath && (
<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>
<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>
@ -304,7 +250,7 @@ export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWork
className="text-sm"
style={{ color: "var(--color-text-secondary)" }}
>
Seed bootstrap files and workspace database
Seed bootstrap files (AGENTS.md, SOUL.md, USER.md)
</span>
</label>
@ -363,13 +309,6 @@ 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

@ -1,474 +0,0 @@
"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

@ -28,8 +28,6 @@ export type TreeNode = {
children?: TreeNode[];
/** When true, the node represents a virtual folder/file outside the real workspace (e.g. Skills, Memories). CRUD ops are disabled. */
virtual?: boolean;
/** True when the entry is a symbolic link / shortcut. */
symlink?: boolean;
};
/** Folder names reserved for virtual sections -- cannot be created/renamed to. */
@ -155,14 +153,6 @@ function LockBadge() {
);
}
function SymlinkBadge() {
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.55 }}>
<path d="m9 18 6-6-6-6" />
</svg>
);
}
function ChevronIcon({ open }: { open: boolean }) {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
@ -573,13 +563,6 @@ function DraggableNode({
</span>
)}
{/* Symlink indicator */}
{node.symlink && !compact && (
<span className="flex-shrink-0 ml-0.5" title="Symbolic link" style={{ color: "var(--color-text-muted)" }}>
<SymlinkBadge />
</span>
)}
{/* Type badge for objects */}
{node.type === "object" && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full flex-shrink-0"

View File

@ -1,36 +1,53 @@
"use client";
import { useState, useEffect } from "react";
import { read, utils, type WorkBook } from "xlsx";
type FileViewerProps = {
content: string;
filename: string;
type: "yaml" | "text";
};
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;
export function FileViewer({ content, filename, type }: FileViewerProps) {
const lines = content.split("\n");
return (
<div className="max-w-4xl mx-auto px-6 py-8">
<FileHeader filename={filename} label={type.toUpperCase()} />
{/* 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>
{/* File content */}
<div
className="rounded-b-lg border overflow-x-auto"
style={{
@ -45,6 +62,7 @@ export function FileViewer(props: 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={{
@ -57,6 +75,7 @@ export function FileViewer(props: FileViewerProps) {
{idx + 1}
</span>
{/* Line content */}
<span
className="pr-4 flex-1"
style={{ color: "var(--color-text)" }}
@ -76,272 +95,6 @@ export function FileViewer(props: 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

@ -1,276 +0,0 @@
"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

@ -22,11 +22,9 @@ type ProfileSwitcherProps = {
onCreateWorkspace?: () => void;
/** When set, this renders instead of the default button; dropdown still opens below. */
trigger?: (props: ProfileSwitcherTriggerProps) => React.ReactNode;
/** Parent-tracked active profile — triggers a re-fetch when it changes (e.g. after workspace creation). */
activeProfileHint?: string | null;
};
export function ProfileSwitcher({ onProfileSwitch, onCreateWorkspace, trigger, activeProfileHint }: ProfileSwitcherProps) {
export function ProfileSwitcher({ onProfileSwitch, onCreateWorkspace, trigger }: ProfileSwitcherProps) {
const [profiles, setProfiles] = useState<ProfileInfo[]>([]);
const [activeProfile, setActiveProfile] = useState("default");
const [isOpen, setIsOpen] = useState(false);
@ -46,7 +44,7 @@ export function ProfileSwitcher({ onProfileSwitch, onCreateWorkspace, trigger, a
useEffect(() => {
void fetchProfiles();
}, [fetchProfiles, activeProfileHint]);
}, [fetchProfiles]);
// Close dropdown on outside click
useEffect(() => {

View File

@ -48,10 +48,6 @@ type WorkspaceSidebarProps = {
onProfileSwitch?: () => void;
/** Called when the user clicks the collapse/hide sidebar button. */
onCollapse?: () => void;
/** Whether hidden (dot) files/folders are currently shown. */
showHidden?: boolean;
/** Toggle hidden files visibility. */
onToggleHidden?: () => void;
};
function HomeIcon() {
@ -410,8 +406,6 @@ export function WorkspaceSidebar({
onProfileSwitch,
width: widthProp,
onCollapse,
showHidden,
onToggleHidden,
}: WorkspaceSidebarProps) {
const isBrowsing = browseDir != null;
const [showCreateWorkspace, setShowCreateWorkspace] = useState(false);
@ -494,7 +488,6 @@ export function WorkspaceSidebar({
<ProfileSwitcher
onProfileSwitch={onProfileSwitch}
onCreateWorkspace={() => setShowCreateWorkspace(true)}
activeProfileHint={activeProfile}
trigger={({ isOpen, onClick, activeProfile: profileName, switching }) => (
<button
type="button"
@ -602,43 +595,7 @@ export function WorkspaceSidebar({
>
ironclaw.sh
</a>
<div className="flex items-center gap-0.5">
{onToggleHidden && (
<button
type="button"
onClick={onToggleHidden}
className="p-1.5 rounded-lg transition-colors"
style={{ color: showHidden ? "var(--color-accent)" : "var(--color-text-muted)" }}
title={showHidden ? "Hide dotfiles" : "Show dotfiles"}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
{showHidden ? (
<>
<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" />
</>
) : (
<>
<path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49" />
<path d="M14.084 14.158a3 3 0 0 1-4.242-4.242" />
<path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143" />
<path d="m2 2 20 20" />
</>
)}
</svg>
</button>
)}
<ThemeToggle />
</div>
<ThemeToggle />
</div>
</aside>
);

View File

@ -9,8 +9,6 @@ export type TreeNode = {
icon?: string;
defaultView?: "table" | "kanban";
children?: TreeNode[];
/** True when the entry is a symbolic link. */
symlink?: boolean;
};
/**
@ -32,9 +30,6 @@ 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.
@ -49,8 +44,7 @@ export function useWorkspaceWatcher() {
const fetchWorkspaceTree = useCallback(async () => {
const version = ++fetchVersionRef.current;
try {
const qs = showHidden ? "?showHidden=1" : "";
const res = await fetch(`/api/workspace/tree${qs}`);
const res = await fetch("/api/workspace/tree");
const data = await res.json();
if (mountedRef.current && fetchVersionRef.current === version) {
setTree(data.tree ?? []);
@ -63,15 +57,14 @@ 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 hiddenQs = showHidden ? "&showHidden=1" : "";
const res = await fetch(`/api/workspace/browse?dir=${encodeURIComponent(dir)}${hiddenQs}`);
const res = await fetch(`/api/workspace/browse?dir=${encodeURIComponent(dir)}`);
const data = await res.json();
if (mountedRef.current && fetchVersionRef.current === version) {
setTree(data.entries ?? []);
@ -82,7 +75,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
@ -218,5 +211,5 @@ export function useWorkspaceWatcher() {
};
}, [browseDirRaw, fetchWorkspaceTree, sseReconnectKey]);
return { tree, loading, exists, refresh, reconnect, browseDir, setBrowseDir, parentDir, workspaceRoot, openclawDir, activeProfile, showHidden, setShowHidden };
return { tree, loading, exists, refresh, reconnect, browseDir, setBrowseDir, parentDir, workspaceRoot, openclawDir, activeProfile };
}

View File

@ -8,10 +8,9 @@ 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, isSpreadsheetFile } from "../components/workspace/file-viewer";
import { FileViewer } from "../components/workspace/file-viewer";
import { CodeViewer } from "../components/workspace/code-viewer";
import { MediaViewer, detectMediaType, type MediaType } from "../components/workspace/media-viewer";
import { HtmlViewer } from "../components/workspace/html-viewer";
import { DatabaseViewer, DuckDBMissing } from "../components/workspace/database-viewer";
import { Breadcrumbs } from "../components/workspace/breadcrumbs";
import { ChatSessionsSidebar } from "../components/workspace/chat-sessions-sidebar";
@ -97,8 +96,6 @@ type ContentState =
| { kind: "document"; data: FileData; title: string }
| { kind: "file"; data: FileData; filename: string }
| { kind: "code"; data: FileData; filename: string }
| { kind: "html"; filename: string; rawUrl: string; contentUrl: string }
| { kind: "spreadsheet"; url: string; filename: string }
| { kind: "media"; url: string; mediaType: MediaType; filename: string; filePath: string }
| { kind: "database"; dbPath: string; filename: string }
| { kind: "report"; reportPath: string; filename: string }
@ -358,7 +355,6 @@ function WorkspacePageInner() {
reconnect: reconnectWorkspace,
browseDir, setBrowseDir, parentDir: browseParentDir, workspaceRoot, openclawDir,
activeProfile,
showHidden, setShowHidden,
} = useWorkspaceWatcher();
// handleProfileSwitch is defined below fetchSessions/fetchCronJobs (avoids TDZ)
@ -668,33 +664,13 @@ function WorkspacePageInner() {
return;
}
// HTML files: load iframe immediately, lazy-fetch source for code view
const ext = node.name.split(".").pop()?.toLowerCase() ?? "";
if (ext === "html" || ext === "htm") {
setContent({
kind: "html",
filename: node.name,
rawUrl: rawFileUrl(node.path),
contentUrl: fileApiUrl(node.path),
});
return;
}
if (isSpreadsheetFile(node.name)) {
setContent({
kind: "spreadsheet",
url: rawFileUrl(node.path),
filename: node.name,
});
return;
}
const res = await fetch(fileApiUrl(node.path));
if (!res.ok) {
setContent({ kind: "none" });
return;
}
const data: FileData = await res.json();
// Route code files to the syntax-highlighted CodeViewer
if (isCodeFile(node.name)) {
setContent({ kind: "code", data, filename: node.name });
} else {
@ -1059,9 +1035,7 @@ function WorkspacePageInner() {
// Sync URL bar with active content / chat state.
// Uses window.location instead of searchParams in the comparison to
// avoid a circular dependency (searchParams updates → effect fires →
// router.push → searchParams updates → …).
// push (not replace) so the browser back button walks through previous
// workspace views instead of jumping straight out of /workspace.
// router.replace → searchParams updates → …).
useEffect(() => {
const current = new URLSearchParams(window.location.search);
@ -1072,12 +1046,12 @@ function WorkspacePageInner() {
params.set("path", activePath);
const entry = current.get("entry");
if (entry) {params.set("entry", entry);}
router.push(`/workspace?${params.toString()}`, { scroll: false });
router.replace(`/workspace?${params.toString()}`, { scroll: false });
}
} else if (activeSessionId) {
// Chat mode — no file selected.
if (current.get("chat") !== activeSessionId || current.has("path")) {
router.push(`/workspace?chat=${encodeURIComponent(activeSessionId)}`, { scroll: false });
router.replace(`/workspace?chat=${encodeURIComponent(activeSessionId)}`, { scroll: false });
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally excludes searchParams to avoid infinite loop
@ -1089,7 +1063,7 @@ function WorkspacePageInner() {
setEntryModal({ objectName, entryId });
const params = new URLSearchParams(searchParams.toString());
params.set("entry", `${objectName}:${entryId}`);
router.push(`/workspace?${params.toString()}`, { scroll: false });
router.replace(`/workspace?${params.toString()}`, { scroll: false });
},
[searchParams, router],
);
@ -1334,8 +1308,6 @@ function WorkspacePageInner() {
onExternalDrop={handleSidebarExternalDrop}
activeProfile={activeProfile}
onProfileSwitch={handleProfileSwitch}
showHidden={showHidden}
onToggleHidden={() => setShowHidden((v) => !v)}
mobile
onClose={() => setSidebarOpen(false)}
/>
@ -1373,8 +1345,6 @@ function WorkspacePageInner() {
onProfileSwitch={handleProfileSwitch}
width={leftSidebarWidth}
onCollapse={() => setLeftSidebarCollapsed(true)}
showHidden={showHidden}
onToggleHidden={() => setShowHidden((v) => !v)}
/>
</div>
)}
@ -2096,15 +2066,6 @@ function ContentRenderer({
/>
);
case "spreadsheet":
return (
<FileViewer
filename={content.filename}
type="spreadsheet"
url={content.url}
/>
);
case "code":
return (
<CodeViewer
@ -2113,15 +2074,6 @@ function ContentRenderer({
/>
);
case "html":
return (
<HtmlViewer
filename={content.filename}
rawUrl={content.rawUrl}
contentUrl={content.contentUrl}
/>
);
case "media":
return (
<MediaViewer

View File

@ -356,35 +356,12 @@ function updateIndex(
) {
try {
const idxPath = indexFile();
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(
if (!existsSync(idxPath)) {return;}
const index = JSON.parse(
readFileSync(idxPath, "utf-8"),
) as Array<Record<string, unknown>>;
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);
}
const session = index.find((s) => s.id === sessionId);
if (!session) {return;}
session.updatedAt = Date.now();
if (opts.incrementCount) {
session.messageCount =

View File

@ -6,7 +6,7 @@
*
* Events are fed from the gateway WebSocket connection (gateway-events.ts).
*/
import { existsSync, readFileSync, mkdirSync, appendFileSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import {
extractToolResult,
@ -54,7 +54,7 @@ type TransformState = {
// ── Constants ──
const CLEANUP_GRACE_MS = 24 * 60 * 60_000; // 24 hours — events are persisted to disk
const CLEANUP_GRACE_MS = 60_000;
const GLOBAL_KEY = "__openclaw_subagentRuns" as const;
// ── Singleton registry ──
@ -82,89 +82,28 @@ function getRegistry(): SubagentRegistry {
return registry;
}
// ── Event persistence ──
function subagentEventsDir(): string {
return join(resolveOpenClawStateDir(), "web-chat", "subagent-events");
}
/** Filesystem-safe filename derived from a session key. */
function safeFilename(sessionKey: string): string {
return sessionKey.replaceAll(":", "_") + ".jsonl";
}
function persistEvent(sessionKey: string, event: SseEvent): void {
try {
const dir = subagentEventsDir();
mkdirSync(dir, { recursive: true });
appendFileSync(join(dir, safeFilename(sessionKey)), JSON.stringify(event) + "\n");
} catch { /* best-effort */ }
}
function loadPersistedEvents(sessionKey: string): SseEvent[] {
const filePath = join(subagentEventsDir(), safeFilename(sessionKey));
if (!existsSync(filePath)) {return [];}
try {
const lines = readFileSync(filePath, "utf-8").split("\n");
const events: SseEvent[] = [];
for (const line of lines) {
if (!line.trim()) {continue;}
try { events.push(JSON.parse(line) as SseEvent); } catch { /* skip */ }
}
return events;
} catch { return []; }
}
/** Read the on-disk registry entry and derive the proper status. */
function readDiskStatus(sessionKey: string): "running" | "completed" | "error" {
const registryPath = join(resolveOpenClawStateDir(), "subagents", "runs.json");
if (!existsSync(registryPath)) {return "running";}
try {
const raw = JSON.parse(readFileSync(registryPath, "utf-8"));
const runs = raw?.runs;
if (!runs || typeof runs !== "object") {return "running";}
for (const entry of Object.values(runs)) {
if (entry.childSessionKey === sessionKey) {
if (typeof entry.endedAt !== "number") {return "running";}
const outcome = entry.outcome as { status?: string } | undefined;
if (outcome?.status === "error") {return "error";}
return "completed";
}
}
} catch { /* ignore */ }
return "running";
}
// ── Public API ──
/**
* Register a newly spawned subagent. Called when the parent agent's
* `sessions_spawn` tool result is detected in active-runs.ts.
*
* When `fromDisk` is true, the run is being rehydrated after a refresh,
* so we load persisted events and set the correct status from the registry.
*/
export function registerSubagent(
parentWebSessionId: string,
info: { sessionKey: string; runId: string; task: string; label?: string },
options?: { fromDisk?: boolean },
): void {
const reg = getRegistry();
// Avoid duplicate registration
if (reg.runs.has(info.sessionKey)) {return;}
const fromDisk = options?.fromDisk ?? false;
const diskStatus = fromDisk ? readDiskStatus(info.sessionKey) : "running";
const run: SubagentRun = {
sessionKey: info.sessionKey,
runId: info.runId,
parentWebSessionId,
task: info.task,
label: info.label,
status: diskStatus,
status: "running",
startedAt: Date.now(),
eventBuffer: [],
subscribers: new Set(),
@ -173,11 +112,6 @@ export function registerSubagent(
_cleanupTimer: null,
};
// Load persisted events from disk (fills the replay buffer)
if (fromDisk) {
run.eventBuffer = loadPersistedEvents(info.sessionKey);
}
reg.runs.set(info.sessionKey, run);
// Update parent index
@ -188,13 +122,11 @@ export function registerSubagent(
}
keys.add(info.sessionKey);
// NOTE: We do NOT subscribe to gateway WebSocket here. During live
// streaming, events arrive via routeRawEvent() from the parent's NDJSON
// stream. After the parent exits, activateGatewayFallback() subscribes.
// For on-demand rehydration (page refresh), ensureSubagentStreamable()
// handles the subscription.
// The primary event source is the parent agent's NDJSON stream, routed
// via routeRawEvent(). We do NOT subscribe to gateway WebSocket here to
// avoid duplicate events (the parent CLI already receives all broadcasts).
// Replay any pre-registration buffered events (live sessions only)
// Replay any pre-registration buffered events
const buf = reg.preRegBuffer.get(info.sessionKey);
if (buf && buf.length > 0) {
for (const evt of buf) {
@ -204,19 +136,6 @@ export function registerSubagent(
}
}
/**
* Ensure a rehydrated subagent can receive live events. Called when a client
* actually connects to the subagent's SSE stream after a page refresh.
* For still-running subagents, this activates the gateway WebSocket fallback.
*/
export function ensureSubagentStreamable(sessionKey: string): void {
const run = getRegistry().runs.get(sessionKey);
if (!run || run.status !== "running" || run._unsubGateway) {return;}
run._unsubGateway = subscribeToSessionKey(sessionKey, (evt) => {
handleGatewayEvent(run, evt);
});
}
/** Get metadata for all subagents belonging to a parent web session. */
export function getSubagentsForSession(parentWebSessionId: string): SubagentInfo[] {
const reg = getRegistry();
@ -377,7 +296,7 @@ export function ensureRegisteredFromDisk(
runId: typeof entry.runId === "string" ? entry.runId : "",
task: typeof entry.task === "string" ? entry.task : "",
label: typeof entry.label === "string" ? entry.label : undefined,
}, { fromDisk: true });
});
return true;
}
}
@ -417,7 +336,6 @@ function handleGatewayEvent(run: SubagentRun, evt: GatewayEvent): void {
const emit = (event: SseEvent) => {
run.eventBuffer.push(event);
persistEvent(run.sessionKey, event);
for (const sub of run.subscribers) {
try { sub(event); } catch { /* ignore */ }
}

View File

@ -19,11 +19,7 @@ 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;
/** Maps profile names to absolute workspace paths for workspaces outside ~/.openclaw/. */
workspaceRegistry?: Record<string, string>;
};
type UIState = { activeProfile?: string | null };
function uiStatePath(): string {
const home = process.env.OPENCLAW_HOME?.trim() || homedir();
@ -59,8 +55,7 @@ export function getEffectiveProfile(): string | null {
export function setUIActiveProfile(profile: string | null): void {
const normalized = profile?.trim() || null;
_uiActiveProfile = normalized;
const existing = readUIState();
writeUIState({ ...existing, activeProfile: normalized });
writeUIState({ activeProfile: normalized });
}
/** Reset the in-memory override (re-reads from file on next call). */
@ -68,29 +63,6 @@ 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.
// ---------------------------------------------------------------------------
@ -151,26 +123,6 @@ 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;
}
@ -221,10 +173,8 @@ 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,6 +1,5 @@
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
@ -18,23 +17,6 @@ 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,8 +47,7 @@
"remark-gfm": "^4.0.1",
"shiki": "^3.22.0",
"tailwind-merge": "^3.5.0",
"unicode-animations": "^1.0.3",
"xlsx": "^0.18.5"
"unicode-animations": "^1.0.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.8",

72
pnpm-lock.yaml generated
View File

@ -407,9 +407,6 @@ 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
@ -3958,10 +3955,6 @@ 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'}
@ -4189,10 +4182,6 @@ 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'}
@ -4271,10 +4260,6 @@ 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'}
@ -4341,11 +4326,6 @@ 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==}
@ -4773,10 +4753,6 @@ 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:
@ -6612,10 +6588,6 @@ 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'}
@ -7125,14 +7097,6 @@ 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'}
@ -7160,11 +7124,6 @@ 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'}
@ -10833,8 +10792,6 @@ snapshots:
acorn@8.15.0: {}
adler-32@1.3.1: {}
agent-base@7.1.4: {}
ai@6.0.86(zod@4.3.6):
@ -11070,11 +11027,6 @@ 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:
@ -11156,8 +11108,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
codepage@1.15.0: {}
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@ -11210,8 +11160,6 @@ snapshots:
core-util-is@1.0.3: {}
crc-32@1.2.2: {}
crelt@1.0.6: {}
croner@10.0.1: {}
@ -11652,8 +11600,6 @@ 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
@ -14053,10 +13999,6 @@ 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
@ -14581,10 +14523,6 @@ snapshots:
win-guid@0.2.1: {}
wmf@1.0.2: {}
word@0.3.0: {}
wordwrapjs@5.1.1: {}
wrap-ansi@7.0.0:
@ -14603,16 +14541,6 @@ 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: {}

View File

@ -667,19 +667,6 @@ VALUES ('Roadmap', 'map', 'projects/roadmap.md', '<parent_doc_id>', 0);
- **Field names**: human-readable, proper capitalization ("Email Address" not "email")
- **Be descriptive**: "Phone Number" not "Phone"
- **Be consistent**: Don't mix "Full Name" and "Name" in the same object
- **TRIPLE ALIGNMENT (MANDATORY)**: The DuckDB object `name`, the filesystem directory name, and the `.object.yaml` `name` field MUST all be identical. If any one of these three diverges, the UI will fail to render the object. For example, if DuckDB has `name = 'contract'`, the directory MUST be `contract/` (in workspace) and the yaml MUST have `name: "contract"`. Never use plural for one and singular for another.
### Renaming / Moving Objects
When renaming or relocating an object, you MUST update ALL THREE in a single operation:
1. **DuckDB**: Update `objects.name` (if FK constraints block this, recreate the object with the new name and migrate entries)
2. **Directory**: `mv` the old directory to the new name
3. **`.object.yaml`**: Update the `name` field to match
4. **PIVOT view**: `DROP VIEW IF EXISTS v_{old_name}; CREATE OR REPLACE VIEW v_{new_name} ...`
5. **Verify**: Confirm all three match and the view returns data
Never rename partially. If you can't complete all steps, don't start the rename — explain the constraint to the user first.
## Error Handling
@ -885,7 +872,7 @@ After creating a `.report.json` file:
## Critical Reminders
- Handle the ENTIRE CRM operation from analysis to SQL execution to filesystem projection to summary
- **NEVER SKIP FILESYSTEM PROJECTION**: After creating/modifying any object, you MUST create/update `{object}/.object.yaml` in workspace AND the `v_{object}` view. If you skip this, the object will be invisible in the sidebar. This is NOT optional.
- **NEVER SKIP FILESYSTEM PROJECTION**: After creating/modifying any object, you MUST create/update `~/.openclaw/workspace/{object}/.object.yaml` AND the `v_{object}` view. If you skip this, the object will be invisible in the sidebar. This is NOT optional.
- **THREE STEPS, EVERY TIME**: (1) SQL transaction, (2) filesystem projection (.object.yaml + directory), (3) verify. An operation is NOT complete until all three are done.
- Always check existing data before creating (`SELECT` before `INSERT`, or `ON CONFLICT`)
- Use views (`v_{object}`) for all reads — never write raw PIVOT queries for search
@ -903,9 +890,8 @@ After creating a `.report.json` file:
- **workspace_context.yaml**: READ-ONLY. Never modify. Data flows from Dench UI only.
- **Source of truth**: DuckDB for all structured data. Filesystem for document content and navigation tree. Never duplicate entry data to the filesystem.
- **ENTRY COUNT**: After adding entries, update `entry_count` in `.object.yaml`.
- **NAME CONSISTENCY**: The DuckDB `objects.name`, the filesystem directory name, and `.object.yaml` `name` MUST be identical. A mismatch between ANY of these three will break the UI. Before finishing any object creation or modification, verify: `objects.name == directory_name == yaml.name`. See "Renaming / Moving Objects" under Naming Conventions.
- **NEVER POLLUTE THE WORKSPACE**: Always keep cleaning / organising the workspace to something more nicely structured. Always look out for bloat and too many random files scattered around everywhere for no reason, every time you do any actions in filesystem always try to come up with the most efficient and nice file system structure inside the workspace.
- **TEMPORARY FILES**: All temporary scripts / code / text / other files as and when needed for processing must go into `tmp/` directory (create it in the workspace if it doesn't exist, only if needed).
- **NEVER POLLUTE THE WORKSPACE**: Always keep cleaning / organising the workspace to something more nicely structured. Always look out for bloat and too many random files scattered around everywhere for no reason, every time you do any actions in filesystem always try to come up with the most efficient and nice file system structure inside `~/.openclaw/workspace`.
- **TEMPORARY FILES**: All temporary scripts / code / text / other files as and when needed for processing must go into `~/.openclaw/workspace/tmp/` directory (create it if it doesn't exist, only if needed).
## Browser Use

View File

@ -94,6 +94,4 @@ export type SkillSnapshot = {
/** Skills with `inject: true` whose full content should be included in the system prompt. */
injectedSkills?: InjectedSkillContent[];
version?: number;
/** Workspace dir this snapshot was built for (used to invalidate on profile switch). */
workspaceDir?: string;
};

View File

@ -265,28 +265,12 @@ export function buildWorkspaceSkillSnapshot(
const remoteNote = opts?.eligibility?.remote?.note?.trim();
const prompt = [remoteNote, formatSkillsForPrompt(resolvedSkills)].filter(Boolean).join("\n");
// Read full content of injected skills, substituting workspace path placeholders.
// We replace both the tilde form and the expanded default path to handle
// cases where the replacement target is a profile-specific workspace dir.
//
// Use regex with a negative lookahead so "~/.openclaw/workspace" doesn't
// match inside "~/.openclaw/workspace-<profile>", which would double the
// profile suffix (e.g. workspace-kumareth → workspace-kumareth-kumareth).
const defaultExpandedWorkspace = resolveUserPath("~/.openclaw/workspace");
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const tildePattern = new RegExp(escapeRegex("~/.openclaw/workspace") + "(?![\\w-])", "g");
// Read full content of injected skills, substituting workspace path placeholders
const injectedSkills: InjectedSkillContent[] = [];
for (const entry of injectedEntries) {
const rawContent = readSkillContent(entry.skill.filePath);
if (rawContent) {
let content = rawContent.replace(tildePattern, workspaceDir);
if (workspaceDir !== defaultExpandedWorkspace) {
const expandedPattern = new RegExp(
escapeRegex(defaultExpandedWorkspace) + "(?![\\w-])",
"g",
);
content = content.replace(expandedPattern, workspaceDir);
}
const content = rawContent.replaceAll("~/.openclaw/workspace", workspaceDir);
injectedSkills.push({ name: entry.skill.name, content });
}
}
@ -300,7 +284,6 @@ export function buildWorkspaceSkillSnapshot(
resolvedSkills,
injectedSkills: injectedSkills.length > 0 ? injectedSkills : undefined,
version: opts?.snapshotVersion,
workspaceDir,
};
}

View File

@ -132,8 +132,6 @@ export async function agentViaGatewayCommand(opts: AgentCliOpts, runtime: Runtim
const channel = normalizeMessageChannel(opts.channel) ?? DEFAULT_CHAT_CHANNEL;
const idempotencyKey = opts.runId?.trim() || randomIdempotencyKey();
const workspaceOverride = process.env.OPENCLAW_WORKSPACE?.trim() || undefined;
const response = await withProgress(
{
label: "Waiting for agent reply…",
@ -159,7 +157,6 @@ export async function agentViaGatewayCommand(opts: AgentCliOpts, runtime: Runtim
lane: opts.lane,
extraSystemPrompt: opts.extraSystemPrompt,
idempotencyKey,
workspace: workspaceOverride,
},
expectFinal: true,
timeoutMs: gatewayTimeoutMs,
@ -243,8 +240,6 @@ async function agentViaGatewayStreamJson(opts: AgentCliOpts, _runtime: RuntimeEn
process.on("SIGTERM", onSignal);
process.on("SIGINT", onSignal);
const streamWorkspaceOverride = process.env.OPENCLAW_WORKSPACE?.trim() || undefined;
try {
const response = await callGateway<GatewayAgentResponse>({
method: "agent",
@ -264,7 +259,6 @@ async function agentViaGatewayStreamJson(opts: AgentCliOpts, _runtime: RuntimeEn
lane: opts.lane,
extraSystemPrompt: opts.extraSystemPrompt,
idempotencyKey,
workspace: streamWorkspaceOverride,
},
expectFinal: true,
timeoutMs: gatewayTimeoutMs,

View File

@ -217,7 +217,7 @@ export async function agentCommand(
}
const agentCfg = cfg.agents?.defaults;
const sessionAgentId = agentIdOverride ?? resolveAgentIdFromSessionKey(opts.sessionKey?.trim());
const workspaceDirRaw = opts.workspace?.trim() || resolveAgentWorkspaceDir(cfg, sessionAgentId);
const workspaceDirRaw = resolveAgentWorkspaceDir(cfg, sessionAgentId);
const agentDir = resolveAgentDir(cfg, sessionAgentId);
const workspace = await ensureAgentWorkspace({
dir: workspaceDirRaw,
@ -332,11 +332,7 @@ export async function agentCommand(
});
}
const cachedSnapshot = sessionEntry?.skillsSnapshot;
const needsSkillsSnapshot =
isNewSession ||
!cachedSnapshot ||
(cachedSnapshot.workspaceDir && cachedSnapshot.workspaceDir !== workspaceDir);
const needsSkillsSnapshot = isNewSession || !sessionEntry?.skillsSnapshot;
const skillsSnapshotVersion = getSkillsSnapshotVersion(workspaceDir);
const skillFilter = resolveAgentSkillsFilter(cfg, sessionAgentId);
const skillsSnapshot = needsSkillsSnapshot

View File

@ -78,6 +78,4 @@ export type AgentCommandOpts = {
inputProvenance?: InputProvenance;
/** Per-call stream param overrides (best-effort). */
streamParams?: AgentStreamParams;
/** Workspace directory override (passed via RPC from the web UI for profile switching). */
workspace?: string;
};

View File

@ -73,7 +73,6 @@ export const AgentParamsSchema = Type.Object(
timeout: Type.Optional(Type.Integer({ minimum: 0 })),
lane: Type.Optional(Type.String()),
extraSystemPrompt: Type.Optional(Type.String()),
workspace: Type.Optional(Type.String()),
inputProvenance: Type.Optional(
Type.Object(
{

View File

@ -189,7 +189,6 @@ export const agentHandlers: GatewayRequestHandlers = {
groupSpace?: string;
lane?: string;
extraSystemPrompt?: string;
workspace?: string;
idempotencyKey: string;
timeout?: number;
label?: string;
@ -558,7 +557,6 @@ export const agentHandlers: GatewayRequestHandlers = {
lane: request.lane,
extraSystemPrompt: request.extraSystemPrompt,
inputProvenance,
workspace: typeof request.workspace === "string" ? request.workspace.trim() : undefined,
},
defaultRuntime,
context.deps,