diff --git a/.gitignore b/.gitignore index ea74e9fc3f5..07dce81b01f 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,7 @@ bun.lockb coverage __pycache__/ *.pyc -.tsbuildinfo +*.tsbuildinfo .pnpm-store .worktrees/ .DS_Store diff --git a/apps/web/app/api/web-sessions/[id]/route.ts b/apps/web/app/api/web-sessions/[id]/route.ts index 1596f7be30d..cdc94aa69ae 100644 --- a/apps/web/app/api/web-sessions/[id]/route.ts +++ b/apps/web/app/api/web-sessions/[id]/route.ts @@ -1,9 +1,28 @@ -import { readFileSync, existsSync } from "node:fs"; +import { readFileSync, writeFileSync, existsSync, unlinkSync, mkdirSync } from "node:fs"; import { join } from "node:path"; import { resolveWebChatDir } from "@/lib/workspace"; export const dynamic = "force-dynamic"; +type IndexEntry = { id: string; [k: string]: unknown }; + +function readIndex(): IndexEntry[] { + const dir = resolveWebChatDir(); + const indexFile = join(dir, "index.json"); + if (!existsSync(indexFile)) { return []; } + try { + return JSON.parse(readFileSync(indexFile, "utf-8")); + } catch { + return []; + } +} + +function writeIndex(sessions: IndexEntry[]) { + const dir = resolveWebChatDir(); + if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } + writeFileSync(join(dir, "index.json"), JSON.stringify(sessions, null, 2)); +} + export type ChatLine = { id: string; role: "user" | "assistant"; @@ -44,3 +63,48 @@ export async function GET( return Response.json({ id, messages }); } + +/** PATCH /api/web-sessions/[id] — update session metadata (e.g. rename). */ +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + let body: { title?: string }; + try { + body = await request.json(); + } catch { + return Response.json({ error: "Invalid JSON" }, { status: 400 }); + } + const sessions = readIndex(); + const session = sessions.find((s) => s.id === id); + if (!session) { + return Response.json({ error: "Session not found" }, { status: 404 }); + } + if (typeof body.title === "string") { + session.title = body.title; + } + writeIndex(sessions); + return Response.json({ ok: true, session }); +} + +/** DELETE /api/web-sessions/[id] — remove a web chat session and its messages. */ +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + const dir = resolveWebChatDir(); + const filePath = join(dir, `${id}.jsonl`); + + const sessions = readIndex(); + const filtered = sessions.filter((s) => s.id !== id); + if (filtered.length === sessions.length) { + return Response.json({ error: "Session not found" }, { status: 404 }); + } + writeIndex(filtered); + if (existsSync(filePath)) { + unlinkSync(filePath); + } + return Response.json({ ok: true }); +} diff --git a/apps/web/app/api/web-sessions/route.ts b/apps/web/app/api/web-sessions/route.ts index 204e81756ff..7c2de3e1f1a 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, 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[]) { diff --git a/apps/web/app/api/workspace/browse-file/route.ts b/apps/web/app/api/workspace/browse-file/route.ts index eeed22d9857..3b5b3300ec5 100644 --- a/apps/web/app/api/workspace/browse-file/route.ts +++ b/apps/web/app/api/workspace/browse-file/route.ts @@ -18,8 +18,6 @@ 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 8fa024977c6..3304bfbb29e 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, 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({ diff --git a/apps/web/app/api/workspace/init/route.ts b/apps/web/app/api/workspace/init/route.ts index 6fd4165c7c5..949ae402786 100644 --- a/apps/web/app/api/workspace/init/route.ts +++ b/apps/web/app/api/workspace/init/route.ts @@ -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 = { + "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 = { - "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); } diff --git a/apps/web/app/api/workspace/open-file/route.ts b/apps/web/app/api/workspace/open-file/route.ts index 6da26fc8351..402f0e2f527 100644 --- a/apps/web/app/api/workspace/open-file/route.ts +++ b/apps/web/app/api/workspace/open-file/route.ts @@ -35,7 +35,24 @@ export async function POST(req: Request) { ? rawPath.replace(/^~/, homedir()) : rawPath; - const resolved = resolve(normalize(expanded)); + let resolved = resolve(normalize(expanded)); + + // If the file doesn't exist and looks like a bare filename, try to locate it + // using macOS Spotlight (mdfind). + if (!existsSync(resolved) && !rawPath.includes("/")) { + const found = await new Promise((res) => { + exec( + `mdfind -name ${JSON.stringify(rawPath)} | head -1`, + (err, stdout) => { + if (err || !stdout.trim()) {res(null);} + else {res(stdout.trim().split("\n")[0]);} + }, + ); + }); + if (found && existsSync(found)) { + resolved = found; + } + } if (!existsSync(resolved)) { return Response.json( diff --git a/apps/web/app/api/workspace/path-info/route.ts b/apps/web/app/api/workspace/path-info/route.ts new file mode 100644 index 00000000000..e06c0e60469 --- /dev/null +++ b/apps/web/app/api/workspace/path-info/route.ts @@ -0,0 +1,88 @@ +import { exec } from "node:child_process"; +import { existsSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, normalize, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +/** + * GET /api/workspace/path-info?path=... + * Resolves and inspects a filesystem path for in-app preview routing. + */ +export async function GET(req: Request) { + const url = new URL(req.url); + const rawPath = url.searchParams.get("path"); + + if (!rawPath) { + return Response.json( + { error: "Missing 'path' query parameter" }, + { status: 400 }, + ); + } + + let candidatePath = rawPath; + + // Convert file:// URLs into local paths first. + if (candidatePath.startsWith("file://")) { + try { + candidatePath = fileURLToPath(candidatePath); + } catch { + return Response.json( + { error: "Invalid file URL" }, + { status: 400 }, + ); + } + } + + // Expand "~/..." to the current user's home directory. + const expandedPath = candidatePath.startsWith("~/") + ? candidatePath.replace(/^~/, homedir()) + : candidatePath; + let resolvedPath = resolve(normalize(expandedPath)); + + // If the path doesn't exist and looks like a bare filename, try to locate it + // using macOS Spotlight (mdfind). + if (!existsSync(resolvedPath) && !rawPath.includes("/")) { + const found = await new Promise((res) => { + exec( + `mdfind -name ${JSON.stringify(rawPath)} | head -1`, + (err, stdout) => { + if (err || !stdout.trim()) {res(null);} + else {res(stdout.trim().split("\n")[0]);} + }, + ); + }); + if (found && existsSync(found)) { + resolvedPath = found; + } + } + + if (!existsSync(resolvedPath)) { + return Response.json( + { error: "Path not found", path: resolvedPath }, + { status: 404 }, + ); + } + + try { + const stat = statSync(resolvedPath); + const type = stat.isDirectory() + ? "directory" + : stat.isFile() + ? "file" + : "other"; + + return Response.json({ + path: resolvedPath, + name: basename(resolvedPath) || resolvedPath, + type, + }); + } catch { + return Response.json( + { error: "Cannot stat path", path: resolvedPath }, + { status: 500 }, + ); + } +} diff --git a/apps/web/app/api/workspace/raw-file/route.ts b/apps/web/app/api/workspace/raw-file/route.ts index 25c0084fe13..82861ef8073 100644 --- a/apps/web/app/api/workspace/raw-file/route.ts +++ b/apps/web/app/api/workspace/raw-file/route.ts @@ -33,8 +33,6 @@ 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/thumbnail/route.ts b/apps/web/app/api/workspace/thumbnail/route.ts new file mode 100644 index 00000000000..22b298ead14 --- /dev/null +++ b/apps/web/app/api/workspace/thumbnail/route.ts @@ -0,0 +1,69 @@ +import { existsSync, readFileSync, mkdirSync } from "node:fs"; +import { execSync } from "node:child_process"; +import { join, basename } from "node:path"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { safeResolvePath } from "@/lib/workspace"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +const THUMB_DIR = join(tmpdir(), "ironclaw-thumbs"); +mkdirSync(THUMB_DIR, { recursive: true }); + +/** + * Resolve a file path — supports absolute paths and workspace-relative paths. + */ +function resolveFile(path: string): string | null { + if (path.startsWith("/")) { + const abs = resolve(path); + if (existsSync(abs)) {return abs;} + } + return safeResolvePath(path) ?? null; +} + +/** + * GET /api/workspace/thumbnail?path=...&size=200 + * Uses macOS Quick Look (qlmanage) to generate a thumbnail image. + * Returns the thumbnail as image/png. + */ +export async function GET(req: Request) { + const url = new URL(req.url); + const path = url.searchParams.get("path"); + const size = url.searchParams.get("size") ?? "200"; + + if (!path) { + return new Response("Missing path", { status: 400 }); + } + + const absolute = resolveFile(path); + if (!absolute) { + return new Response("Not found", { status: 404 }); + } + + // The thumbnail output filename is .png + const thumbName = `${basename(absolute)}.png`; + const thumbPath = join(THUMB_DIR, thumbName); + + try { + // Generate thumbnail using macOS Quick Look + execSync( + `qlmanage -t -s ${parseInt(size, 10)} -o "${THUMB_DIR}" "${absolute}" 2>/dev/null`, + { timeout: 5000 }, + ); + + if (!existsSync(thumbPath)) { + return new Response("Thumbnail generation failed", { status: 500 }); + } + + const buffer = readFileSync(thumbPath); + return new Response(buffer, { + headers: { + "Content-Type": "image/png", + "Cache-Control": "public, max-age=3600", + }, + }); + } catch { + return new Response("Thumbnail generation failed", { status: 500 }); + } +} diff --git a/apps/web/app/api/workspace/tree/route.ts b/apps/web/app/api/workspace/tree/route.ts index c603d38b4eb..30e759bb3c4 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, 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 { 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[] = []; @@ -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);} diff --git a/apps/web/app/api/workspace/upload/route.ts b/apps/web/app/api/workspace/upload/route.ts index 04909bf2e62..522ea2ccfb9 100644 --- a/apps/web/app/api/workspace/upload/route.ts +++ b/apps/web/app/api/workspace/upload/route.ts @@ -1,27 +1,21 @@ import { writeFileSync, mkdirSync } from "node:fs"; import { join, dirname } from "node:path"; -import { resolveWorkspaceRoot, safeResolveNewPath } from "@/lib/workspace"; +import { homedir } from "node:os"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; const MAX_SIZE = 25 * 1024 * 1024; // 25 MB +/** Hidden uploads dir in the user's home directory — persists forever, invisible to users. */ +const UPLOADS_DIR = join(homedir(), ".ironclaw", "uploads"); + /** * POST /api/workspace/upload * Accepts multipart form data with a "file" field. - * Saves to assets/- inside the workspace. - * Returns { ok, path } where path is workspace-relative. + * Saves to a temp directory and returns the absolute path. */ export async function POST(req: Request) { - const root = resolveWorkspaceRoot(); - if (!root) { - return Response.json( - { error: "Workspace not found" }, - { status: 500 }, - ); - } - let formData: FormData; try { formData = await req.formData(); @@ -49,21 +43,13 @@ export async function POST(req: Request) { const safeName = file.name .replace(/[^a-zA-Z0-9._-]/g, "_") .replace(/_{2,}/g, "_"); - const relPath = join("assets", `${Date.now()}-${safeName}`); - - const absPath = safeResolveNewPath(relPath); - if (!absPath) { - return Response.json( - { error: "Invalid path" }, - { status: 400 }, - ); - } + const absPath = join(UPLOADS_DIR, `${Date.now()}-${safeName}`); try { mkdirSync(dirname(absPath), { recursive: true }); const buffer = Buffer.from(await file.arrayBuffer()); writeFileSync(absPath, buffer); - return Response.json({ ok: true, path: relPath }); + return Response.json({ ok: true, path: absPath }); } catch (err) { return Response.json( { error: err instanceof Error ? err.message : "Upload failed" }, diff --git a/apps/web/app/components/chain-of-thought.tsx b/apps/web/app/components/chain-of-thought.tsx index eb62d212315..5f7fa9d32fe 100644 --- a/apps/web/app/components/chain-of-thought.tsx +++ b/apps/web/app/components/chain-of-thought.tsx @@ -566,7 +566,7 @@ function groupToolSteps(tools: ToolPart[]): VisualItem[] { /* ─── Main component ─── */ export function ChainOfThought({ parts, isStreaming }: { parts: ChainPart[]; isStreaming?: boolean }) { - const [isOpen, setIsOpen] = useState(true); + const [isOpen, setIsOpen] = useState(!!isStreaming); const isActive = parts.some( (p) => @@ -691,7 +691,7 @@ export function ChainOfThought({ parts, isStreaming }: { parts: ChainPart[]; isS className="flex items-start gap-2.5 py-1.5" >
- {anyRunning ? ( - - ) : ( + - )} +
- + {anyRunning ? `Fetching ${items.length} sources...` : `Fetched ${items.length} sources`} @@ -841,7 +833,7 @@ function FetchGroup({ items }: { items: ToolPart[] }) {
@@ -984,18 +976,10 @@ function MediaGroup({ return (
- {anyRunning ? ( - - ) : ( + - )} +
- + + +
)} {hasMore && ( @@ -1259,27 +1237,21 @@ function ToolStep({ return (
- {status === "running" ? ( - - ) : status === "error" ? ( + {status === "error" ? ( ) : ( - + + + )}
- {label} + {label} {/* Exit code badge for exec tools */} {kind === "exec" && status === "done" && output?.exitCode !== undefined && ( -
- - - - - {paths.length}{" "} - {paths.length === 1 ? "file" : "files"}{" "} - attached - -
-
- {paths.map((filePath, i) => { - const category = - getCategoryFromPath(filePath); - const filename = - filePath.split("/").pop() ?? - filePath; - const meta = - attachCategoryMeta[category] ?? - attachCategoryMeta.other; - const short = shortenPath(filePath); +
+ {paths.map((filePath, i) => { + const category = getCategoryFromPath(filePath); + const src = category === "image" + ? `/api/workspace/raw-file?path=${encodeURIComponent(filePath)}` + : `/api/workspace/thumbnail?path=${encodeURIComponent(filePath)}&size=200`; + const ext = filePath.split(".").pop()?.toUpperCase() ?? ""; - return ( -
-
-
- -
-
-

- {filename} -

-

- {short} -

-
-
-
- ); - })} -
+ return ( +
+ {filePath.split("/").pop() { (e.currentTarget as HTMLImageElement).style.display = "none"; }} + /> + {category !== "image" && ( + + {ext} + + )} +
+ ); + })}
); } @@ -465,17 +414,28 @@ function AttachedFilesCard({ paths }: { paths: string[] }) { function looksLikeFilePath(text: string): boolean { const t = text.trim(); if (!t || t.length < 3 || t.length > 500) {return false;} - // Must start with a path prefix - if (!(t.startsWith("~/") || t.startsWith("/") || t.startsWith("./") || t.startsWith("../"))) { - return false; + // Full path prefix + if (t.startsWith("~/") || t.startsWith("/") || t.startsWith("./") || t.startsWith("../")) { + const afterPrefix = t.startsWith("~/") ? t.slice(2) : + t.startsWith("../") ? t.slice(3) : + t.startsWith("./") ? t.slice(2) : + t.slice(1); + return afterPrefix.includes("/") || afterPrefix.includes("."); } - // Must have at least one path separator beyond the prefix - // (avoids matching bare `/` or standalone commands like `/bin`) - const afterPrefix = t.startsWith("~/") ? t.slice(2) : - t.startsWith("../") ? t.slice(3) : - t.startsWith("./") ? t.slice(2) : - t.slice(1); - return afterPrefix.includes("/") || afterPrefix.includes("."); + // Bare filename with a known extension (e.g. "Rachapoom-Passport.pdf") + const fileExtPattern = /\.(pdf|docx?|xlsx?|pptx?|csv|txt|rtf|pages|numbers|key|md|json|yaml|yml|toml|xml|html?|css|jsx?|tsx?|py|rb|go|rs|java|cpp|c|h|sh|sql|swift|kt|png|jpe?g|gif|webp|svg|bmp|ico|heic|tiff|mp[34]|webm|mov|avi|mkv|flv|wav|ogg|aac|flac|m4a|zip|tar|gz|dmg)$/i; + if (fileExtPattern.test(t) && !t.includes(" ")) { + return true; + } + return false; +} + +/** Check if text looks like a filename (allows spaces, used for bold text). */ +function looksLikeFileName(text: string): boolean { + const t = text.trim(); + if (!t || t.length < 3 || t.length > 300) {return false;} + const fileExtPattern = /\.(pdf|docx?|xlsx?|pptx?|csv|txt|rtf|pages|numbers|key|md|json|yaml|yml|toml|xml|html?|css|jsx?|tsx?|py|rb|go|rs|java|cpp|c|h|sh|sql|swift|kt|png|jpe?g|gif|webp|svg|bmp|ico|heic|tiff|mp[34]|webm|mov|avi|mkv|flv|wav|ogg|aac|flac|m4a|zip|tar|gz|dmg)$/i; + return fileExtPattern.test(t); } /** Open a file path using the system default application. */ @@ -495,13 +455,41 @@ async function openFilePath(path: string, reveal = false) { } } +type FilePathClickHandler = ( + path: string, +) => Promise | boolean | void; + +/** Convert file:// URLs to local paths for in-app preview routing. */ +function normalizePathReference(value: string): string { + const trimmed = value.trim(); + if (!trimmed.startsWith("file://")) { + return trimmed; + } + try { + const url = new URL(trimmed); + if (url.protocol !== "file:") { + return trimmed; + } + const decoded = decodeURIComponent(url.pathname); + // Windows file URLs are /C:/... in URL form + if (/^\/[A-Za-z]:\//.test(decoded)) { + return decoded.slice(1); + } + return decoded; + } catch { + return trimmed; + } +} + /** Clickable file path inline code element */ function FilePathCode({ path, children, + onFilePathClick, }: { path: string; children: React.ReactNode; + onFilePathClick?: FilePathClickHandler; }) { const [status, setStatus] = useState<"idle" | "opening" | "error">("idle"); @@ -509,16 +497,26 @@ function FilePathCode({ e.preventDefault(); setStatus("opening"); try { - const res = await fetch("/api/workspace/open-file", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ path }), - }); - if (!res.ok) { - setStatus("error"); - setTimeout(() => setStatus("idle"), 2000); - } else { + if (onFilePathClick) { + const handled = await onFilePathClick(path); + if (handled === false) { + setStatus("error"); + setTimeout(() => setStatus("idle"), 2000); + return; + } setStatus("idle"); + } else { + const res = await fetch("/api/workspace/open-file", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path }), + }); + if (!res.ok) { + setStatus("error"); + setTimeout(() => setStatus("idle"), 2000); + } else { + setStatus("idle"); + } } } catch { setStatus("error"); @@ -534,35 +532,18 @@ function FilePathCode({ return ( - - {status === "error" ? ( - <> - - - - - ) : ( - <> - - - - )} - {children} ); @@ -570,103 +551,144 @@ function FilePathCode({ /* ─── Markdown component overrides for chat ─── */ -const mdComponents: Components = { - // Open external links in new tab - a: ({ href, children, ...props }) => { - const isExternal = - href && (href.startsWith("http") || href.startsWith("//")); - return ( - - {children} - - ); - }, - // Render images with loading=lazy - img: ({ src, alt, ...props }) => ( - // eslint-disable-next-line @next/next/no-img-element - {alt - ), - // Syntax-highlighted fenced code blocks - pre: ({ children, ...props }) => { - // react-markdown wraps code blocks in
...
-		// Extract the code element to get lang + content
-		const child = Array.isArray(children) ? children[0] : children;
-		if (
-			child &&
-			typeof child === "object" &&
-			"type" in child &&
-			(child as { type?: string }).type === "code"
-		) {
-			const codeEl = child as {
-				props?: {
-					className?: string;
-					children?: string;
+function createMarkdownComponents(
+	onFilePathClick?: FilePathClickHandler,
+): Components {
+	return {
+		// Open external links in new tab
+		a: ({ href, children, ...props }) => {
+			const rawHref = typeof href === "string" ? href : "";
+			const normalizedHref = normalizePathReference(rawHref);
+			const isExternal =
+				rawHref && (rawHref.startsWith("http://") || rawHref.startsWith("https://") || rawHref.startsWith("//"));
+			const isWorkspaceAppLink = rawHref.startsWith("/workspace");
+			const isLocalPathLink =
+				!isWorkspaceAppLink &&
+				(Boolean(rawHref.startsWith("file://")) ||
+					looksLikeFilePath(normalizedHref));
+			return (
+				 {
+						if (!isLocalPathLink || !onFilePathClick) {return;}
+						e.preventDefault();
+						void onFilePathClick(normalizedHref);
+					}}
+				>
+					{children}
+				
+			);
+		},
+		// Render images with loading=lazy
+		img: ({ src, alt, ...props }) => (
+			// eslint-disable-next-line @next/next/no-img-element
+			{alt
+		),
+		// Syntax-highlighted fenced code blocks
+		pre: ({ children, ...props }) => {
+			// react-markdown wraps code blocks in 
...
+			// Extract the code element to get lang + content
+			const child = Array.isArray(children) ? children[0] : children;
+			if (
+				child &&
+				typeof child === "object" &&
+				"type" in child &&
+				(child as { type?: string }).type === "code"
+			) {
+				const codeEl = child as {
+					props?: {
+						className?: string;
+						children?: string;
+					};
 				};
-			};
-			const className = codeEl.props?.className ?? "";
-			const langMatch = className.match(/language-(\w+)/);
-			const lang = langMatch?.[1] ?? "";
-			const code =
-				typeof codeEl.props?.children === "string"
-					? codeEl.props.children.replace(/\n$/, "")
-					: "";
+				const className = codeEl.props?.className ?? "";
+				const langMatch = className.match(/language-(\w+)/);
+				const lang = langMatch?.[1] ?? "";
+				const code =
+					typeof codeEl.props?.children === "string"
+						? codeEl.props.children.replace(/\n$/, "")
+						: "";
 
-			// Diff language: render as DiffCard
-			if (lang === "diff") {
-				return ;
-			}
+				// Diff language: render as DiffCard
+				if (lang === "diff") {
+					return ;
+				}
 
-			// Known language: syntax-highlight with shiki
-			if (lang) {
-				return (
-					
-
- {lang} + // Known language: syntax-highlight with shiki + if (lang) { + return ( +
+
+ {lang} +
+
- -
+ ); + } + } + // Fallback: default pre rendering + return
{children}
; + }, + // Inline code — detect file paths and make them clickable + code: ({ children, className, ...props }) => { + // If this code has a language class, it's inside a
 and
+			// will be handled by the pre override above. Just return raw.
+			if (className?.startsWith("language-")) {
+				return (
+					
+						{children}
+					
 				);
 			}
-		}
-		// Fallback: default pre rendering
-		return 
{children}
; - }, - // Inline code — detect file paths and make them clickable - code: ({ children, className, ...props }) => { - // If this code has a language class, it's inside a
 and
-		// will be handled by the pre override above. Just return raw.
-		if (className?.startsWith("language-")) {
-			return (
-				
-					{children}
-				
-			);
-		}
 
-		// Check if the inline code content looks like a file path
-		const text = typeof children === "string" ? children : "";
-		if (text && looksLikeFilePath(text)) {
-			return {children};
-		}
+			// Check if the inline code content looks like a file path
+			const text = typeof children === "string" ? children : "";
+			const normalizedText = normalizePathReference(text);
+			if (normalizedText && looksLikeFilePath(normalizedText)) {
+				return (
+					
+						{children}
+					
+				);
+			}
 
-		// Regular inline code
-		return {children};
-	},
-};
+			// Regular inline code
+			return {children};
+		},
+		// Bold text — detect filenames and make them clickable
+		strong: ({ children, ...props }) => {
+			const text = typeof children === "string" ? children
+				: Array.isArray(children) ? children.filter((c) => typeof c === "string").join("")
+				: "";
+			if (text && looksLikeFileName(text)) {
+				return (
+					
+						
+							{children}
+						
+					
+				);
+			}
+			return {children};
+		},
+	};
+}
 
 /* ─── Chat message ─── */
 
-export const ChatMessage = memo(function ChatMessage({ message, isStreaming, onSubagentClick }: { message: UIMessage; isStreaming?: boolean; onSubagentClick?: (task: string) => void }) {
+export const ChatMessage = memo(function ChatMessage({ message, isStreaming, onSubagentClick, onFilePathClick }: { message: UIMessage; isStreaming?: boolean; onSubagentClick?: (task: string) => void; onFilePathClick?: FilePathClickHandler }) {
 	const isUser = message.role === "user";
 	const segments = groupParts(message.parts);
+	const markdownComponents = useMemo(
+		() => createMarkdownComponents(onFilePathClick),
+		[onFilePathClick],
+	);
 
 	if (isUser) {
 		// User: right-aligned subtle pill
@@ -681,35 +703,41 @@ export const ChatMessage = memo(function ChatMessage({ message, isStreaming, onS
 		// Parse attachment prefix from sent messages
 		const attachmentInfo = parseAttachments(textContent);
 
+		if (attachmentInfo) {
+			return (
+				
+ {/* Attachment previews — standalone above the text bubble */} + + {/* Text bubble */} + {attachmentInfo.message && ( +
+

+ {attachmentInfo.message} +

+
+ )} +
+ ); + } + return (
- {attachmentInfo ? ( - <> - - {attachmentInfo.message && ( -

- { - attachmentInfo.message - } -

- )} - - ) : ( -

- {textContent} -

- )} +

+ {textContent} +

); @@ -734,7 +762,7 @@ export const ChatMessage = memo(function ChatMessage({ message, isStreaming, onS return (
{segment.text} @@ -805,12 +833,12 @@ export const ChatMessage = memo(function ChatMessage({ message, isStreaming, onS initial={{ opacity: 0, y: 4 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.2, ease: "easeOut" }} - className="chat-prose font-bookerly text-sm" + className="chat-prose chat-message-font text-sm" style={{ color: "var(--color-text)" }} > {segment.text} diff --git a/apps/web/app/components/chat-panel.tsx b/apps/web/app/components/chat-panel.tsx index 326b390aff1..26daa5a0060 100644 --- a/apps/web/app/components/chat-panel.tsx +++ b/apps/web/app/components/chat-panel.tsx @@ -17,6 +17,13 @@ import { type SelectedFile, } from "./file-picker-modal"; import { ChatEditor, type ChatEditorHandle } from "./tiptap/chat-editor"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "./ui/dropdown-menu"; +import { UnicodeSpinner } from "./unicode-spinner"; // ── Attachment types & helpers ── @@ -24,6 +31,10 @@ type AttachedFile = { id: string; name: string; path: string; + /** True while the file is still uploading to the server. */ + uploading?: boolean; + /** Local blob URL for instant preview before upload completes. */ + localUrl?: string; }; function getFileCategory( @@ -148,6 +159,153 @@ function FileTypeIcon({ category }: { category: string }) { } } +function QueueItem({ + msg, + idx, + onEdit, + onSendNow, + onRemove, +}: { + msg: QueuedMessage; + idx: number; + onEdit: (id: string, text: string) => void; + onSendNow: (id: string) => void; + onRemove: (id: string) => void; +}) { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(msg.text); + const inputRef = useRef(null); + + const autoResize = () => { + const el = inputRef.current; + if (!el) {return;} + el.style.height = "auto"; + el.style.height = `${el.scrollHeight}px`; + }; + + useEffect(() => { + if (editing) { + inputRef.current?.focus(); + const len = inputRef.current?.value.length ?? 0; + inputRef.current?.setSelectionRange(len, len); + autoResize(); + } + }, [editing]); + + const commitEdit = () => { + const trimmed = draft.trim(); + if (trimmed && trimmed !== msg.text) { + onEdit(msg.id, trimmed); + } else { + setDraft(msg.text); + } + setEditing(false); + }; + + return ( +
0 ? "border-t" : ""}`} + style={idx > 0 ? { borderColor: "var(--color-border)" } : undefined} + > + + {idx + 1} + + {editing ? ( +