Revert "Merge kumar workspaces to design branch"
This reverts commit bf4445115b911fa7831e6a35f7d0114154bb58ec, reversing changes made to face53f2341417e339c02d5d7d4c412b961f87f6.
This commit is contained in:
parent
bf4445115b
commit
ca8ac9fda1
@ -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;
|
||||
|
||||
@ -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[]) {
|
||||
|
||||
@ -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. */
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -33,8 +33,6 @@ const MIME_MAP: Record<string, string> = {
|
||||
m4a: "audio/mp4",
|
||||
// Documents
|
||||
pdf: "application/pdf",
|
||||
html: "text/html",
|
||||
htm: "text/html",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@ -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);}
|
||||
|
||||
|
||||
@ -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);}
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -437,7 +437,6 @@ export function Sidebar({
|
||||
<ProfileSwitcher
|
||||
onProfileSwitch={handleProfileSwitch}
|
||||
onCreateWorkspace={() => setShowCreateWorkspace(true)}
|
||||
activeProfileHint={String(sidebarRefreshKey)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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(() => {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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 */ }
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
72
pnpm-lock.yaml
generated
@ -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: {}
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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(
|
||||
{
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user