diff --git a/apps/web/app/api/chat/subagent-stream/route.ts b/apps/web/app/api/chat/subagent-stream/route.ts index a0d1c8f74ba..75a93999587 100644 --- a/apps/web/app/api/chat/subagent-stream/route.ts +++ b/apps/web/app/api/chat/subagent-stream/route.ts @@ -1,4 +1,4 @@ -import { subscribeToSubagent, hasActiveSubagent, isSubagentRunning, ensureRegisteredFromDisk } from "@/lib/subagent-runs"; +import { subscribeToSubagent, hasActiveSubagent, isSubagentRunning, ensureRegisteredFromDisk, ensureSubagentStreamable } from "@/lib/subagent-runs"; import type { SseEvent } from "@/lib/subagent-runs"; import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; @@ -51,6 +51,10 @@ 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; diff --git a/apps/web/app/api/web-sessions/route.ts b/apps/web/app/api/web-sessions/route.ts index 7c2de3e1f1a..204e81756ff 100644 --- a/apps/web/app/api/web-sessions/route.ts +++ b/apps/web/app/api/web-sessions/route.ts @@ -1,4 +1,4 @@ -import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; +import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from "node:fs"; import { join } from "node:path"; import { randomUUID } from "node:crypto"; import { resolveWebChatDir } from "@/lib/workspace"; @@ -23,15 +23,68 @@ function ensureDir() { return dir; } +/** + * Read the session index, auto-discovering any orphaned .jsonl files + * that aren't in the index (e.g. from profile switches or missing index). + */ function readIndex(): WebSessionMeta[] { const dir = ensureDir(); const indexFile = join(dir, "index.json"); - if (!existsSync(indexFile)) {return [];} - try { - return JSON.parse(readFileSync(indexFile, "utf-8")); - } catch { - return []; + let index: WebSessionMeta[] = []; + if (existsSync(indexFile)) { + try { + index = JSON.parse(readFileSync(indexFile, "utf-8")); + } catch { + index = []; + } } + + // Scan for orphaned .jsonl files not in the index + try { + const indexed = new Set(index.map((s) => s.id)); + const files = readdirSync(dir).filter((f) => f.endsWith(".jsonl")); + let dirty = false; + for (const file of files) { + const id = file.replace(/\.jsonl$/, ""); + if (indexed.has(id)) {continue;} + + // Build a minimal index entry from the file + const fp = join(dir, file); + const stat = statSync(fp); + let title = "New Chat"; + let messageCount = 0; + try { + const content = readFileSync(fp, "utf-8"); + const lines = content.split("\n").filter((l) => l.trim()); + messageCount = lines.length; + // Try to extract a title from the first user message + for (const line of lines) { + const parsed = JSON.parse(line); + if (parsed.role === "user" && parsed.content) { + const text = String(parsed.content); + title = text.length > 60 ? text.slice(0, 60) + "..." : text; + break; + } + } + } catch { /* best-effort */ } + + index.push({ + id, + title, + createdAt: stat.birthtimeMs || stat.mtimeMs, + updatedAt: stat.mtimeMs, + messageCount, + }); + dirty = true; + } + + if (dirty) { + index.sort((a, b) => b.updatedAt - a.updatedAt); + writeFileSync(indexFile, JSON.stringify(index, null, 2)); + } + } catch { /* best-effort */ } + + return index; } function writeIndex(sessions: WebSessionMeta[]) { diff --git a/apps/web/app/api/workspace/browse-file/route.ts b/apps/web/app/api/workspace/browse-file/route.ts index 3b5b3300ec5..eeed22d9857 100644 --- a/apps/web/app/api/workspace/browse-file/route.ts +++ b/apps/web/app/api/workspace/browse-file/route.ts @@ -18,6 +18,8 @@ const MIME_MAP: Record = { wav: "audio/wav", ogg: "audio/ogg", pdf: "application/pdf", + html: "text/html", + htm: "text/html", }; /** Extensions recognized as code files for syntax-highlighted viewing. */ diff --git a/apps/web/app/api/workspace/browse/route.ts b/apps/web/app/api/workspace/browse/route.ts index 3304bfbb29e..8fa024977c6 100644 --- a/apps/web/app/api/workspace/browse/route.ts +++ b/apps/web/app/api/workspace/browse/route.ts @@ -1,4 +1,4 @@ -import { readdirSync, type Dirent } from "node:fs"; +import { readdirSync, statSync, type Dirent } from "node:fs"; import { join, dirname, resolve } from "node:path"; import { resolveWorkspaceRoot } from "@/lib/workspace"; @@ -10,16 +10,34 @@ type BrowseNode = { path: string; // absolute path type: "folder" | "file" | "document" | "database"; children?: BrowseNode[]; + symlink?: boolean; }; /** Directories to skip when browsing the filesystem. */ const SKIP_DIRS = new Set(["node_modules", ".git", ".Trash", "__pycache__", ".cache"]); +/** Resolve a dirent's effective type, following symlinks to their target. */ +function resolveEntryType(entry: Dirent, absPath: string): "directory" | "file" | null { + if (entry.isDirectory()) {return "directory";} + if (entry.isFile()) {return "file";} + if (entry.isSymbolicLink()) { + try { + const st = statSync(absPath); + if (st.isDirectory()) {return "directory";} + if (st.isFile()) {return "file";} + } catch { + // Broken symlink + } + } + return null; +} + /** Build a depth-limited tree from an absolute directory. */ function buildBrowseTree( absDir: string, maxDepth: number, currentDepth = 0, + showHidden = false, ): BrowseNode[] { if (currentDepth >= maxDepth) {return [];} @@ -30,29 +48,43 @@ function buildBrowseTree( return []; } - const sorted = entries - .filter((e) => !e.name.startsWith(".")) - .filter((e) => !(e.isDirectory() && SKIP_DIRS.has(e.name))) - .toSorted((a, b) => { - if (a.isDirectory() && !b.isDirectory()) {return -1;} - if (!a.isDirectory() && b.isDirectory()) {return 1;} - return a.name.localeCompare(b.name); + const filtered = entries + .filter((e) => showHidden || !e.name.startsWith(".")) + .filter((e) => { + const absPath = join(absDir, e.name); + const t = resolveEntryType(e, absPath); + return !(t === "directory" && SKIP_DIRS.has(e.name)); }); + const sorted = filtered.toSorted((a, b) => { + const absA = join(absDir, a.name); + const absB = join(absDir, b.name); + const typeA = resolveEntryType(a, absA); + const typeB = resolveEntryType(b, absB); + const dirA = typeA === "directory"; + const dirB = typeB === "directory"; + if (dirA && !dirB) {return -1;} + if (!dirA && dirB) {return 1;} + return a.name.localeCompare(b.name); + }); + const nodes: BrowseNode[] = []; for (const entry of sorted) { const absPath = join(absDir, entry.name); + const isSymlink = entry.isSymbolicLink(); + const effectiveType = resolveEntryType(entry, absPath); - if (entry.isDirectory()) { - const children = buildBrowseTree(absPath, maxDepth, currentDepth + 1); + if (effectiveType === "directory") { + const children = buildBrowseTree(absPath, maxDepth, currentDepth + 1, showHidden); nodes.push({ name: entry.name, path: absPath, type: "folder", children: children.length > 0 ? children : undefined, + ...(isSymlink && { symlink: true }), }); - } else if (entry.isFile()) { + } else if (effectiveType === "file") { const ext = entry.name.split(".").pop()?.toLowerCase(); const isDocument = ext === "md" || ext === "mdx"; const isDatabase = ext === "duckdb" || ext === "sqlite" || ext === "sqlite3" || ext === "db"; @@ -61,6 +93,7 @@ function buildBrowseTree( name: entry.name, path: absPath, type: isDatabase ? "database" : isDocument ? "document" : "file", + ...(isSymlink && { symlink: true }), }); } } @@ -71,8 +104,8 @@ function buildBrowseTree( export async function GET(req: Request) { const url = new URL(req.url); let dir = url.searchParams.get("dir"); + const showHidden = url.searchParams.get("showHidden") === "1"; - // Default to the workspace root if (!dir) { dir = resolveWorkspaceRoot(); } @@ -83,10 +116,9 @@ export async function GET(req: Request) { ); } - // Resolve and normalize the directory path const resolved = resolve(dir); - const entries = buildBrowseTree(resolved, 3); + const entries = buildBrowseTree(resolved, 3, 0, showHidden); const parentDir = resolved === "/" ? null : dirname(resolved); return Response.json({ diff --git a/apps/web/app/api/workspace/init/route.ts b/apps/web/app/api/workspace/init/route.ts index 949ae402786..6fd4165c7c5 100644 --- a/apps/web/app/api/workspace/init/route.ts +++ b/apps/web/app/api/workspace/init/route.ts @@ -1,38 +1,239 @@ -import { existsSync, mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { existsSync, mkdirSync, writeFileSync, readFileSync, copyFileSync } from "node:fs"; +import { join, resolve } from "node:path"; import { homedir } from "node:os"; -import { resolveOpenClawStateDir, setUIActiveProfile, getEffectiveProfile, resolveWorkspaceRoot } from "@/lib/workspace"; +import { resolveOpenClawStateDir, setUIActiveProfile, getEffectiveProfile, resolveWorkspaceRoot, registerWorkspacePath } from "@/lib/workspace"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; -const BOOTSTRAP_FILES: Record = { - "AGENTS.md": `# Workspace Agent Instructions +// --------------------------------------------------------------------------- +// Bootstrap file names (must match src/agents/workspace.ts) +// --------------------------------------------------------------------------- -Add instructions here that your agent should follow when working in this workspace. -`, - "SOUL.md": `# Soul +const BOOTSTRAP_FILENAMES = [ + "AGENTS.md", + "SOUL.md", + "TOOLS.md", + "IDENTITY.md", + "USER.md", + "HEARTBEAT.md", + "BOOTSTRAP.md", +] as const; -Describe the personality and behavior of your agent here. -`, - "USER.md": `# User - -Describe yourself — your preferences, context, and how you'd like the agent to interact with you. -`, +// Minimal fallback content used when templates can't be loaded from disk +const FALLBACK_CONTENT: Record = { + "AGENTS.md": "# AGENTS.md - Your Workspace\n\nThis folder is home. Treat it that way.\n", + "SOUL.md": "# SOUL.md - Who You Are\n\nDescribe the personality and behavior of your agent here.\n", + "TOOLS.md": "# TOOLS.md - Local Notes\n\nSkills define how tools work. This file is for your specifics.\n", + "IDENTITY.md": "# IDENTITY.md - Who Am I?\n\nFill this in during your first conversation.\n", + "USER.md": "# USER.md - About Your Human\n\nDescribe yourself and how you'd like the agent to interact with you.\n", + "HEARTBEAT.md": "# HEARTBEAT.md\n\n# Keep this file empty (or with only comments) to skip heartbeat API calls.\n", + "BOOTSTRAP.md": "# BOOTSTRAP.md - Hello, World\n\nYou just woke up. Time to figure out who you are.\n", }; +// --------------------------------------------------------------------------- +// CRM seed objects (mirrors src/agents/workspace-seed.ts) +// --------------------------------------------------------------------------- + +type SeedField = { + name: string; + type: string; + required?: boolean; + enumValues?: string[]; +}; + +type SeedObject = { + id: string; + name: string; + description: string; + icon: string; + defaultView: string; + entryCount: number; + fields: SeedField[]; +}; + +const SEED_OBJECTS: SeedObject[] = [ + { + id: "seed_obj_people_00000000000000", + name: "people", + description: "Contact management", + icon: "users", + defaultView: "table", + entryCount: 5, + fields: [ + { name: "Full Name", type: "text", required: true }, + { name: "Email Address", type: "email", required: true }, + { name: "Phone Number", type: "phone" }, + { name: "Company", type: "text" }, + { name: "Status", type: "enum", enumValues: ["Active", "Inactive", "Lead"] }, + { name: "Notes", type: "richtext" }, + ], + }, + { + id: "seed_obj_company_0000000000000", + name: "company", + description: "Company tracking", + icon: "building-2", + defaultView: "table", + entryCount: 3, + fields: [ + { name: "Company Name", type: "text", required: true }, + { + name: "Industry", + type: "enum", + enumValues: ["Technology", "Finance", "Healthcare", "Education", "Retail", "Other"], + }, + { name: "Website", type: "text" }, + { name: "Type", type: "enum", enumValues: ["Client", "Partner", "Vendor", "Prospect"] }, + { name: "Notes", type: "richtext" }, + ], + }, + { + id: "seed_obj_task_000000000000000", + name: "task", + description: "Task tracking board", + icon: "check-square", + defaultView: "kanban", + entryCount: 5, + fields: [ + { name: "Title", type: "text", required: true }, + { name: "Description", type: "text" }, + { name: "Status", type: "enum", enumValues: ["In Queue", "In Progress", "Done"] }, + { name: "Priority", type: "enum", enumValues: ["Low", "Medium", "High"] }, + { name: "Due Date", type: "date" }, + { name: "Notes", type: "richtext" }, + ], + }, +]; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function stripFrontMatter(content: string): string { + if (!content.startsWith("---")) {return content;} + const endIndex = content.indexOf("\n---", 3); + if (endIndex === -1) {return content;} + return content.slice(endIndex + "\n---".length).replace(/^\s+/, ""); +} + +/** Try multiple candidate paths to find the monorepo root. */ +function resolveProjectRoot(): string | null { + const marker = join("docs", "reference", "templates", "AGENTS.md"); + const cwd = process.cwd(); + + // CWD is the repo root (standalone builds) + if (existsSync(join(cwd, marker))) {return cwd;} + + // CWD is apps/web/ (dev mode) + const fromApps = resolve(cwd, "..", ".."); + if (existsSync(join(fromApps, marker))) {return fromApps;} + + return null; +} + +function loadTemplateContent(filename: string, projectRoot: string | null): string { + if (projectRoot) { + const templatePath = join(projectRoot, "docs", "reference", "templates", filename); + try { + const raw = readFileSync(templatePath, "utf-8"); + return stripFrontMatter(raw); + } catch { + // fall through to fallback + } + } + return FALLBACK_CONTENT[filename] ?? ""; +} + +function generateObjectYaml(obj: SeedObject): string { + const lines: string[] = [ + `id: "${obj.id}"`, + `name: "${obj.name}"`, + `description: "${obj.description}"`, + `icon: "${obj.icon}"`, + `default_view: "${obj.defaultView}"`, + `entry_count: ${obj.entryCount}`, + "fields:", + ]; + + for (const field of obj.fields) { + lines.push(` - name: "${field.name}"`); + lines.push(` type: ${field.type}`); + if (field.required) {lines.push(" required: true");} + if (field.enumValues) {lines.push(` values: ${JSON.stringify(field.enumValues)}`);} + } + + return lines.join("\n") + "\n"; +} + +function generateWorkspaceMd(objects: SeedObject[]): string { + const lines: string[] = ["# Workspace Schema", "", "Auto-generated summary of the workspace database.", ""]; + for (const obj of objects) { + lines.push(`## ${obj.name}`, ""); + lines.push(`- **Description**: ${obj.description}`); + lines.push(`- **View**: \`${obj.defaultView}\``); + lines.push(`- **Entries**: ${obj.entryCount}`); + lines.push("- **Fields**:"); + for (const field of obj.fields) { + const req = field.required ? " (required)" : ""; + const vals = field.enumValues ? ` — ${field.enumValues.join(", ")}` : ""; + lines.push(` - ${field.name} (\`${field.type}\`)${req}${vals}`); + } + lines.push(""); + } + return lines.join("\n"); +} + +function writeIfMissing(filePath: string, content: string): boolean { + if (existsSync(filePath)) {return false;} + try { + writeFileSync(filePath, content, { encoding: "utf-8", flag: "wx" }); + return true; + } catch { + return false; + } +} + +function seedDuckDB(workspaceDir: string, projectRoot: string | null): boolean { + const destPath = join(workspaceDir, "workspace.duckdb"); + if (existsSync(destPath)) {return false;} + + if (!projectRoot) {return false;} + + const seedDb = join(projectRoot, "assets", "seed", "workspace.duckdb"); + if (!existsSync(seedDb)) {return false;} + + try { + copyFileSync(seedDb, destPath); + } catch { + return false; + } + + // Create filesystem projections for CRM objects + for (const obj of SEED_OBJECTS) { + const objDir = join(workspaceDir, obj.name); + mkdirSync(objDir, { recursive: true }); + writeIfMissing(join(objDir, ".object.yaml"), generateObjectYaml(obj)); + } + + writeIfMissing(join(workspaceDir, "WORKSPACE.md"), generateWorkspaceMd(SEED_OBJECTS)); + + return true; +} + +// --------------------------------------------------------------------------- +// Route handler +// --------------------------------------------------------------------------- + export async function POST(req: Request) { const body = (await req.json()) as { profile?: string; - /** Absolute path override (optional; defaults to profile-based resolution). */ path?: string; - /** Seed bootstrap files into the new workspace. Default true. */ seedBootstrap?: boolean; }; const profileName = body.profile?.trim() || null; - // Validate profile name if provided if (profileName && profileName !== "default" && !/^[a-zA-Z0-9_-]+$/.test(profileName)) { return Response.json( { error: "Invalid profile name. Use letters, numbers, hyphens, or underscores." }, @@ -56,7 +257,6 @@ export async function POST(req: Request) { } } - // Create the workspace directory try { mkdirSync(workspaceDir, { recursive: true }); } catch (err) { @@ -66,24 +266,57 @@ export async function POST(req: Request) { ); } - // Seed bootstrap files const seedBootstrap = body.seedBootstrap !== false; const seeded: string[] = []; + if (seedBootstrap) { - for (const [filename, content] of Object.entries(BOOTSTRAP_FILES)) { + const projectRoot = resolveProjectRoot(); + + // Seed all bootstrap files from templates + for (const filename of BOOTSTRAP_FILENAMES) { const filePath = join(workspaceDir, filename); if (!existsSync(filePath)) { - try { - writeFileSync(filePath, content, "utf-8"); + const content = loadTemplateContent(filename, projectRoot); + if (writeIfMissing(filePath, content)) { seeded.push(filename); - } catch { - // Skip files that can't be written (permissions, etc.) } } } + + // Seed DuckDB + CRM object projections + if (seedDuckDB(workspaceDir, projectRoot)) { + seeded.push("workspace.duckdb"); + for (const obj of SEED_OBJECTS) { + seeded.push(`${obj.name}/.object.yaml`); + } + } + + // Write workspace state so the gateway knows seeding was done + const stateDir = join(workspaceDir, ".openclaw"); + const statePath = join(stateDir, "workspace-state.json"); + if (!existsSync(statePath)) { + try { + mkdirSync(stateDir, { recursive: true }); + const state = { + version: 1, + bootstrapSeededAt: new Date().toISOString(), + duckdbSeededAt: existsSync(join(workspaceDir, "workspace.duckdb")) + ? new Date().toISOString() + : undefined, + }; + writeFileSync(statePath, JSON.stringify(state, null, 2) + "\n", "utf-8"); + } catch { + // Best-effort state tracking + } + } } - // If a profile was specified, switch to it + // Remember custom-path workspaces in the registry + if (body.path?.trim() && profileName) { + registerWorkspacePath(profileName, workspaceDir); + } + + // Switch to the new profile if (profileName) { setUIActiveProfile(profileName === "default" ? null : profileName); } diff --git a/apps/web/app/api/workspace/raw-file/route.ts b/apps/web/app/api/workspace/raw-file/route.ts index 82861ef8073..25c0084fe13 100644 --- a/apps/web/app/api/workspace/raw-file/route.ts +++ b/apps/web/app/api/workspace/raw-file/route.ts @@ -33,6 +33,8 @@ const MIME_MAP: Record = { m4a: "audio/mp4", // Documents pdf: "application/pdf", + html: "text/html", + htm: "text/html", }; /** diff --git a/apps/web/app/api/workspace/tree/route.ts b/apps/web/app/api/workspace/tree/route.ts index 30e759bb3c4..c603d38b4eb 100644 --- a/apps/web/app/api/workspace/tree/route.ts +++ b/apps/web/app/api/workspace/tree/route.ts @@ -1,4 +1,4 @@ -import { readdirSync, readFileSync, existsSync, type Dirent } from "node:fs"; +import { readdirSync, readFileSync, existsSync, statSync, type Dirent } from "node:fs"; import { join } from "node:path"; import { resolveWorkspaceRoot, resolveOpenClawStateDir, getEffectiveProfile, parseSimpleYaml, duckdbQueryAll, isDatabaseFile } from "@/lib/workspace"; @@ -14,6 +14,8 @@ export type TreeNode = { children?: TreeNode[]; /** Virtual nodes live outside the main workspace (e.g. Skills, Memories). */ virtual?: boolean; + /** True when the entry is a symbolic link. */ + symlink?: boolean; }; type DbObject = { @@ -58,11 +60,28 @@ function loadDbObjects(): Map { 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, + showHidden = false, ): TreeNode[] { const nodes: TreeNode[] = []; @@ -73,32 +92,44 @@ function buildTree( return nodes; } + const filtered = entries.filter((e) => { + // .object.yaml is always needed for metadata; also shown as a node when showHidden is on + if (e.name === ".object.yaml") {return true;} + if (e.name.startsWith(".")) {return showHidden;} + return true; + }); + // Sort: directories first, then files, alphabetical within each group - const sorted = entries - .filter((e) => !e.name.startsWith(".") || e.name === ".object.yaml") - .toSorted((a, b) => { - if (a.isDirectory() && !b.isDirectory()) {return -1;} - if (!a.isDirectory() && b.isDirectory()) {return 1;} - return a.name.localeCompare(b.name); - }); + const sorted = filtered.toSorted((a, b) => { + const absA = join(absDir, a.name); + const absB = join(absDir, b.name); + const typeA = resolveEntryType(a, absA); + const typeB = resolveEntryType(b, absB); + const dirA = typeA === "directory"; + const dirB = typeB === "directory"; + if (dirA && !dirB) {return -1;} + if (!dirA && dirB) {return 1;} + return a.name.localeCompare(b.name); + }); for (const entry of sorted) { - // Skip hidden files except .object.yaml (but don't list it as a node) - if (entry.name === ".object.yaml") {continue;} - if (entry.name.startsWith(".")) {continue;} + // .object.yaml is consumed for metadata; only show it as a visible node when revealing hidden files + if (entry.name === ".object.yaml" && !showHidden) {continue;} const absPath = join(absDir, entry.name); const relPath = relativeBase ? `${relativeBase}/${entry.name}` : entry.name; - if (entry.isDirectory()) { + const isSymlink = entry.isSymbolicLink(); + const effectiveType = resolveEntryType(entry, absPath); + + if (effectiveType === "directory") { const objectMeta = readObjectMeta(absPath); const dbObject = dbObjects.get(entry.name); - const children = buildTree(absPath, relPath, dbObjects); + const children = buildTree(absPath, relPath, dbObjects, showHidden); if (objectMeta || dbObject) { - // This directory represents a CRM object (from .object.yaml OR DuckDB) nodes.push({ name: entry.name, path: relPath, @@ -109,17 +140,18 @@ function buildTree( | "table" | "kanban") ?? "table", children: children.length > 0 ? children : undefined, + ...(isSymlink && { symlink: true }), }); } else { - // Regular folder nodes.push({ name: entry.name, path: relPath, type: "folder", children: children.length > 0 ? children : undefined, + ...(isSymlink && { symlink: true }), }); } - } else if (entry.isFile()) { + } else if (effectiveType === "file") { const ext = entry.name.split(".").pop()?.toLowerCase(); const isReport = entry.name.endsWith(".report.json"); const isDocument = ext === "md" || ext === "mdx"; @@ -129,6 +161,7 @@ function buildTree( name: entry.name, path: relPath, type: isReport ? "report" : isDatabase ? "database" : isDocument ? "document" : "file", + ...(isSymlink && { symlink: true }), }); } } @@ -206,27 +239,24 @@ function buildSkillsVirtualFolder(): TreeNode | null { } -export async function GET() { +export async function GET(req: Request) { + const url = new URL(req.url); + const showHidden = url.searchParams.get("showHidden") === "1"; + const openclawDir = resolveOpenClawStateDir(); const profile = getEffectiveProfile(); const root = resolveWorkspaceRoot(); if (!root) { - // Even without a workspace, return virtual folders if they exist const tree: TreeNode[] = []; const skillsFolder = buildSkillsVirtualFolder(); if (skillsFolder) {tree.push(skillsFolder);} return Response.json({ tree, exists: false, workspaceRoot: null, openclawDir, profile }); } - // Load objects from DuckDB for smart directory detection const dbObjects = loadDbObjects(); - // Scan the workspace root — it IS the knowledge base. - // All top-level directories, files, objects, and documents are visible - // in the sidebar (USER.md, SOUL.md, memory/, etc. are all part of the tree). - const tree = buildTree(root, "", dbObjects); + const tree = buildTree(root, "", dbObjects, showHidden); - // Virtual folders go after all real files/folders const skillsFolder = buildSkillsVirtualFolder(); if (skillsFolder) {tree.push(skillsFolder);} diff --git a/apps/web/app/api/workspace/watch/route.ts b/apps/web/app/api/workspace/watch/route.ts index e9062fcc119..e75777ced84 100644 --- a/apps/web/app/api/workspace/watch/route.ts +++ b/apps/web/app/api/workspace/watch/route.ts @@ -3,95 +3,137 @@ 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(); +// 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() { +export async function GET(req: Request) { const root = resolveWorkspaceRoot(); if (!root) { return new Response("Workspace not found", { status: 404 }); } const encoder = new TextEncoder(); + let closed = false; + let heartbeat: ReturnType | null = null; + let debounceTimer: ReturnType | 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")); - // 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 | null = null; - - function sendEvent(type: string, filePath: string) { + const listener: Listener = (_type, _rel) => { if (closed) {return;} if (debounceTimer) {clearTimeout(debounceTimer);} debounceTimer = setTimeout(() => { if (closed) {return;} try { - const data = JSON.stringify({ type, path: filePath }); + const data = JSON.stringify({ type: _type, path: _rel }); controller.enqueue(encoder.encode(`event: change\ndata: ${data}\n\n`)); - } catch { - // Stream may have been closed - } - }, 200); - } + } catch { /* stream closed */ } + }, 300); + }; - // Keep-alive heartbeat every 30s to prevent proxy/timeout disconnects - const heartbeat = setInterval(() => { + heartbeat = setInterval(() => { if (closed) {return;} try { controller.enqueue(encoder.encode(": heartbeat\n\n")); - } catch { - // Ignore if closed - } + } catch { /* closed */ } }, 30_000); - 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, - }); + function teardown() { + if (closed) {return;} + closed = true; + listeners.delete(listener); + if (heartbeat) {clearInterval(heartbeat);} + if (debounceTimer) {clearTimeout(debounceTimer);} + stopWatcherIfIdle(); + } - 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 + req.signal.addEventListener("abort", teardown, { once: true }); + + listeners.add(listener); + await ensureWatcher(root); + + if (!sharedWatcher) { controller.enqueue( encoder.encode("event: error\ndata: {\"error\":\"File watching unavailable\"}\n\n"), ); } - - // 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);} - }; + }, + cancel() { + closed = true; }, }); diff --git a/apps/web/app/components/sidebar.tsx b/apps/web/app/components/sidebar.tsx index 094683da12f..f4575c955b4 100644 --- a/apps/web/app/components/sidebar.tsx +++ b/apps/web/app/components/sidebar.tsx @@ -437,6 +437,7 @@ export function Sidebar({ setShowCreateWorkspace(true)} + activeProfileHint={String(sidebarRefreshKey)} /> diff --git a/apps/web/app/components/workspace/create-workspace-dialog.tsx b/apps/web/app/components/workspace/create-workspace-dialog.tsx index cdbdbf39b3e..4f6ba07f8d4 100644 --- a/apps/web/app/components/workspace/create-workspace-dialog.tsx +++ b/apps/web/app/components/workspace/create-workspace-dialog.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useRef, useEffect } from "react"; +import { DirectoryPickerModal } from "./directory-picker-modal"; type CreateWorkspaceDialogProps = { isOpen: boolean; @@ -8,10 +9,15 @@ type CreateWorkspaceDialogProps = { onCreated?: () => void; }; +function shortenPath(p: string): string { + return p.replace(/^\/Users\/[^/]+/, "~").replace(/^\/home\/[^/]+/, "~"); +} + export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWorkspaceDialogProps) { const [profileName, setProfileName] = useState(""); const [customPath, setCustomPath] = useState(""); const [useCustomPath, setUseCustomPath] = useState(false); + const [showDirPicker, setShowDirPicker] = useState(false); const [seedBootstrap, setSeedBootstrap] = useState(true); const [creating, setCreating] = useState(false); const [error, setError] = useState(null); @@ -25,22 +31,23 @@ export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWork setProfileName(""); setCustomPath(""); setUseCustomPath(false); + setShowDirPicker(false); setError(null); setResult(null); setTimeout(() => inputRef.current?.focus(), 100); } }, [isOpen]); - // Close on Escape + // Close on Escape (only if dir picker is not open) useEffect(() => { function handleKey(e: KeyboardEvent) { - if (e.key === "Escape") {onClose();} + if (e.key === "Escape" && !showDirPicker) {onClose();} } if (isOpen) { document.addEventListener("keydown", handleKey); return () => document.removeEventListener("keydown", handleKey); } - }, [isOpen, onClose]); + }, [isOpen, onClose, showDirPicker]); const handleCreate = async () => { const name = profileName.trim(); @@ -222,18 +229,65 @@ export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWork {useCustomPath && ( - 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)", - }} - /> +
+ {customPath ? ( +
+
+ + + +
+
+

+ {customPath.split("/").pop()} +

+

+ {shortenPath(customPath)} +

+
+ + +
+ ) : ( + + )} +
)} @@ -250,7 +304,7 @@ export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWork className="text-sm" style={{ color: "var(--color-text-secondary)" }} > - Seed bootstrap files (AGENTS.md, SOUL.md, USER.md) + Seed bootstrap files and workspace database @@ -309,6 +363,13 @@ export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWork )} + + {/* Directory picker modal */} + setShowDirPicker(false)} + onSelect={(path) => setCustomPath(path)} + /> ); } diff --git a/apps/web/app/components/workspace/directory-picker-modal.tsx b/apps/web/app/components/workspace/directory-picker-modal.tsx new file mode 100644 index 00000000000..ed56bad6323 --- /dev/null +++ b/apps/web/app/components/workspace/directory-picker-modal.tsx @@ -0,0 +1,474 @@ +"use client"; + +import { Fragment, useCallback, useEffect, useRef, useState } from "react"; + +type BrowseEntry = { + name: string; + path: string; + type: "folder" | "file" | "document" | "database"; +}; + +type DirectoryPickerModalProps = { + open: boolean; + onClose: () => void; + onSelect: (path: string) => void; + /** Starting directory (absolute). Falls back to the workspace root / home. */ + startDir?: string; +}; + +function buildBreadcrumbs(dir: string): { label: string; path: string }[] { + const segments: { label: string; path: string }[] = []; + const homeMatch = dir.match(/^(\/Users\/[^/]+|\/home\/[^/]+)/); + const homeDir = homeMatch?.[1]; + + if (homeDir) { + segments.push({ label: "~", path: homeDir }); + const rest = dir.slice(homeDir.length); + const parts = rest.split("/").filter(Boolean); + let currentPath = homeDir; + for (const part of parts) { + currentPath += "/" + part; + segments.push({ label: part, path: currentPath }); + } + } else if (dir === "/") { + segments.push({ label: "/", path: "/" }); + } else { + segments.push({ label: "/", path: "/" }); + const parts = dir.split("/").filter(Boolean); + let currentPath = ""; + for (const part of parts) { + currentPath += "/" + part; + segments.push({ label: part, path: currentPath }); + } + } + return segments; +} + +const folderColors = { bg: "rgba(245, 158, 11, 0.12)", fg: "#f59e0b" }; + +function FolderIcon({ size = 16 }: { size?: number }) { + return ( + + + + ); +} + +export function DirectoryPickerModal({ + open, + onClose, + onSelect, + startDir, +}: DirectoryPickerModalProps) { + const [currentDir, setCurrentDir] = useState(startDir ?? null); + const [displayDir, setDisplayDir] = useState(""); + const [entries, setEntries] = useState([]); + const [parentDir, setParentDir] = useState(null); + const [loading, setLoading] = useState(false); + const [search, setSearch] = useState(""); + const [creatingFolder, setCreatingFolder] = useState(false); + const [newFolderName, setNewFolderName] = useState(""); + const [error, setError] = useState(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(null); + const newFolderRef = useRef(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 ( +
+
+ +
+ {/* Header */} +
+
+
+ +
+
+

+ Choose Directory +

+

+ Navigate to a folder for the workspace +

+
+
+ +
+ + {/* Breadcrumbs */} + {displayDir && ( +
+ {breadcrumbs.map((seg, i) => ( + + {i > 0 && ( + + / + + )} + + + ))} +
+ )} + + {/* Search + New Folder */} +
+
+ + + + 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)" }} + /> +
+ +
+ + {/* Folder list */} +
+ {loading ? ( +
+
+
+ ) : error ? ( +
+ {error} +
+ ) : ( + <> + {/* Go up */} + {parentDir && ( + + )} + + {/* New folder input */} + {creatingFolder && ( +
+
+ +
+ 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)", + }} + /> +
+ )} + + {/* Folder entries */} + {folders.length === 0 && !parentDir && ( +
+ No subfolders here +
+ )} + {folders.map((entry) => ( + + ))} + + )} +
+ + {/* Footer */} +
+
+

+ {shortDir || "Loading..."} +

+
+
+ + +
+
+
+
+ ); +} diff --git a/apps/web/app/components/workspace/file-manager-tree.tsx b/apps/web/app/components/workspace/file-manager-tree.tsx index 8f1104f537f..a01694d0dc5 100644 --- a/apps/web/app/components/workspace/file-manager-tree.tsx +++ b/apps/web/app/components/workspace/file-manager-tree.tsx @@ -28,6 +28,8 @@ 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. */ @@ -153,6 +155,14 @@ function LockBadge() { ); } +function SymlinkBadge() { + return ( + + + + ); +} + function ChevronIcon({ open }: { open: boolean }) { return ( )} + {/* Symlink indicator */} + {node.symlink && !compact && ( + + + + )} + {/* Type badge for objects */} {node.type === "object" && ( ; + } + + const { content, filename, type } = props; const lines = content.split("\n"); return (
- {/* File header */} -
- - - - - - {filename} - - - {type.toUpperCase()} - -
+ - {/* File content */}
- {/* Line number */} - {/* Line content */} + {icon ?? ( + + + + + )} + + {filename} + + + {label} + +
+ ); +} + +// --------------------------------------------------------------------------- +// Spreadsheet viewer +// --------------------------------------------------------------------------- + +function SpreadsheetViewer({ filename, url }: { filename: string; url: string }) { + const [workbook, setWorkbook] = useState(null); + const [activeSheet, setActiveSheet] = useState(0); + const [error, setError] = useState(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 ( +
+ } /> +
+ Failed to load spreadsheet: {error} +
+
+ ); + } + + if (!workbook) { + return ( +
+ } /> +
+ Loading spreadsheet... +
+
+ ); + } + + const sheetNames = workbook.SheetNames; + const sheet = workbook.Sheets[sheetNames[activeSheet]]; + const rows: string[][] = sheet ? utils.sheet_to_json(sheet, { header: 1, defval: "" }) : []; + + return ( +
+ } /> + + {/* Sheet tabs */} + {sheetNames.length > 1 && ( +
+ {sheetNames.map((name, idx) => ( + + ))} +
+ )} + + {/* Table */} +
+ {rows.length === 0 ? ( +
+ This sheet is empty. +
+ ) : ( + + + + {/* Row number header */} + + ))} + + + + {rows.map((row, rowIdx) => ( + + + {row.map((cell, colIdx) => ( + + ))} + + ))} + +
+ {rows[0]?.map((_cell, colIdx) => ( + + {columnLabel(colIdx)} +
+ {rowIdx + 1} + + {String(cell)} +
+ )} +
+ +
+ {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` : ""} +
+
+ ); +} + +/** 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 ( + + + + + + + + + ); +} + /** Simple YAML syntax highlighting */ function YamlLine({ line }: { line: string }) { // Comment diff --git a/apps/web/app/components/workspace/html-viewer.tsx b/apps/web/app/components/workspace/html-viewer.tsx new file mode 100644 index 00000000000..aebc7b21bfa --- /dev/null +++ b/apps/web/app/components/workspace/html-viewer.tsx @@ -0,0 +1,276 @@ +"use client"; + +import { useState, useEffect, useMemo, useCallback } from "react"; +import { createHighlighter, type Highlighter } from "shiki"; + +type HtmlViewerProps = { + filename: string; + /** Raw URL for iframe rendering (served with text/html) */ + rawUrl: string; + /** JSON API URL to fetch source content on demand (for code view) */ + contentUrl: string; +}; + +type ViewMode = "rendered" | "code"; + +let highlighterPromise: Promise | null = null; + +function getHighlighter(): Promise { + if (!highlighterPromise) { + highlighterPromise = createHighlighter({ + themes: ["github-dark", "github-light"], + langs: ["html"], + }); + } + return highlighterPromise; +} + +export function HtmlViewer({ filename, rawUrl, contentUrl }: HtmlViewerProps) { + const [mode, setMode] = useState("rendered"); + const [source, setSource] = useState(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("")) + .finally(() => setSourceLoading(false)); + }, [contentUrl, source]); + + return ( +
+ {/* Header bar */} +
+ + + {filename} + + + HTML + + + {/* Mode toggle */} +
+ + +
+ + {/* Open in new tab */} + { + (e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)"; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLElement).style.background = "transparent"; + }} + > + + +
+ + {/* Content */} + {mode === "rendered" ? ( + + ) : sourceLoading || source === null ? ( +
+
+
+ ) : ( + + )} +
+ ); +} + +// --- Rendered HTML view (sandboxed iframe) --- + +function RenderedView({ rawUrl }: { rawUrl: string }) { + return ( +
+