Merge remote-tracking branch 'origin/markrachapoom/design' into kumareth/workspaces
Co-authored-by: Cursor <cursoragent@cursor.com> # Conflicts: # apps/web/app/api/chat/subagent-stream/route.ts # apps/web/app/api/workspace/watch/route.ts # apps/web/app/components/chat-panel.tsx # apps/web/app/components/subagent-panel.tsx # apps/web/lib/subagent-runs.ts # apps/web/tsconfig.tsbuildinfo
This commit is contained in:
commit
db4c90b37d
2
.gitignore
vendored
2
.gitignore
vendored
@ -9,7 +9,7 @@ bun.lockb
|
||||
coverage
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.tsbuildinfo
|
||||
*.tsbuildinfo
|
||||
.pnpm-store
|
||||
.worktrees/
|
||||
.DS_Store
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from "node:fs";
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { resolveWebChatDir } from "@/lib/workspace";
|
||||
@ -23,68 +23,15 @@ function ensureDir() {
|
||||
return dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the session index, auto-discovering any orphaned .jsonl files
|
||||
* that aren't in the index (e.g. from profile switches or missing index).
|
||||
*/
|
||||
function readIndex(): WebSessionMeta[] {
|
||||
const dir = ensureDir();
|
||||
const indexFile = join(dir, "index.json");
|
||||
let index: WebSessionMeta[] = [];
|
||||
if (existsSync(indexFile)) {
|
||||
try {
|
||||
index = JSON.parse(readFileSync(indexFile, "utf-8"));
|
||||
} catch {
|
||||
index = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Scan for orphaned .jsonl files not in the index
|
||||
if (!existsSync(indexFile)) {return [];}
|
||||
try {
|
||||
const indexed = new Set(index.map((s) => s.id));
|
||||
const files = readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
|
||||
let dirty = false;
|
||||
for (const file of files) {
|
||||
const id = file.replace(/\.jsonl$/, "");
|
||||
if (indexed.has(id)) {continue;}
|
||||
|
||||
// Build a minimal index entry from the file
|
||||
const fp = join(dir, file);
|
||||
const stat = statSync(fp);
|
||||
let title = "New Chat";
|
||||
let messageCount = 0;
|
||||
try {
|
||||
const content = readFileSync(fp, "utf-8");
|
||||
const lines = content.split("\n").filter((l) => l.trim());
|
||||
messageCount = lines.length;
|
||||
// Try to extract a title from the first user message
|
||||
for (const line of lines) {
|
||||
const parsed = JSON.parse(line);
|
||||
if (parsed.role === "user" && parsed.content) {
|
||||
const text = String(parsed.content);
|
||||
title = text.length > 60 ? text.slice(0, 60) + "..." : text;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch { /* best-effort */ }
|
||||
|
||||
index.push({
|
||||
id,
|
||||
title,
|
||||
createdAt: stat.birthtimeMs || stat.mtimeMs,
|
||||
updatedAt: stat.mtimeMs,
|
||||
messageCount,
|
||||
});
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
if (dirty) {
|
||||
index.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
writeFileSync(indexFile, JSON.stringify(index, null, 2));
|
||||
}
|
||||
} catch { /* best-effort */ }
|
||||
|
||||
return index;
|
||||
return JSON.parse(readFileSync(indexFile, "utf-8"));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeIndex(sessions: WebSessionMeta[]) {
|
||||
|
||||
@ -18,8 +18,6 @@ const MIME_MAP: Record<string, string> = {
|
||||
wav: "audio/wav",
|
||||
ogg: "audio/ogg",
|
||||
pdf: "application/pdf",
|
||||
html: "text/html",
|
||||
htm: "text/html",
|
||||
};
|
||||
|
||||
/** Extensions recognized as code files for syntax-highlighted viewing. */
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { readdirSync, statSync, type Dirent } from "node:fs";
|
||||
import { readdirSync, type Dirent } from "node:fs";
|
||||
import { join, dirname, resolve } from "node:path";
|
||||
import { resolveWorkspaceRoot } from "@/lib/workspace";
|
||||
|
||||
@ -10,34 +10,16 @@ type BrowseNode = {
|
||||
path: string; // absolute path
|
||||
type: "folder" | "file" | "document" | "database";
|
||||
children?: BrowseNode[];
|
||||
symlink?: boolean;
|
||||
};
|
||||
|
||||
/** Directories to skip when browsing the filesystem. */
|
||||
const SKIP_DIRS = new Set(["node_modules", ".git", ".Trash", "__pycache__", ".cache"]);
|
||||
|
||||
/** Resolve a dirent's effective type, following symlinks to their target. */
|
||||
function resolveEntryType(entry: Dirent, absPath: string): "directory" | "file" | null {
|
||||
if (entry.isDirectory()) {return "directory";}
|
||||
if (entry.isFile()) {return "file";}
|
||||
if (entry.isSymbolicLink()) {
|
||||
try {
|
||||
const st = statSync(absPath);
|
||||
if (st.isDirectory()) {return "directory";}
|
||||
if (st.isFile()) {return "file";}
|
||||
} catch {
|
||||
// Broken symlink
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Build a depth-limited tree from an absolute directory. */
|
||||
function buildBrowseTree(
|
||||
absDir: string,
|
||||
maxDepth: number,
|
||||
currentDepth = 0,
|
||||
showHidden = false,
|
||||
): BrowseNode[] {
|
||||
if (currentDepth >= maxDepth) {return [];}
|
||||
|
||||
@ -48,43 +30,29 @@ function buildBrowseTree(
|
||||
return [];
|
||||
}
|
||||
|
||||
const filtered = entries
|
||||
.filter((e) => showHidden || !e.name.startsWith("."))
|
||||
.filter((e) => {
|
||||
const absPath = join(absDir, e.name);
|
||||
const t = resolveEntryType(e, absPath);
|
||||
return !(t === "directory" && SKIP_DIRS.has(e.name));
|
||||
const sorted = entries
|
||||
.filter((e) => !e.name.startsWith("."))
|
||||
.filter((e) => !(e.isDirectory() && SKIP_DIRS.has(e.name)))
|
||||
.toSorted((a, b) => {
|
||||
if (a.isDirectory() && !b.isDirectory()) {return -1;}
|
||||
if (!a.isDirectory() && b.isDirectory()) {return 1;}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
const sorted = filtered.toSorted((a, b) => {
|
||||
const absA = join(absDir, a.name);
|
||||
const absB = join(absDir, b.name);
|
||||
const typeA = resolveEntryType(a, absA);
|
||||
const typeB = resolveEntryType(b, absB);
|
||||
const dirA = typeA === "directory";
|
||||
const dirB = typeB === "directory";
|
||||
if (dirA && !dirB) {return -1;}
|
||||
if (!dirA && dirB) {return 1;}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
const nodes: BrowseNode[] = [];
|
||||
|
||||
for (const entry of sorted) {
|
||||
const absPath = join(absDir, entry.name);
|
||||
const isSymlink = entry.isSymbolicLink();
|
||||
const effectiveType = resolveEntryType(entry, absPath);
|
||||
|
||||
if (effectiveType === "directory") {
|
||||
const children = buildBrowseTree(absPath, maxDepth, currentDepth + 1, showHidden);
|
||||
if (entry.isDirectory()) {
|
||||
const children = buildBrowseTree(absPath, maxDepth, currentDepth + 1);
|
||||
nodes.push({
|
||||
name: entry.name,
|
||||
path: absPath,
|
||||
type: "folder",
|
||||
children: children.length > 0 ? children : undefined,
|
||||
...(isSymlink && { symlink: true }),
|
||||
});
|
||||
} else if (effectiveType === "file") {
|
||||
} else if (entry.isFile()) {
|
||||
const ext = entry.name.split(".").pop()?.toLowerCase();
|
||||
const isDocument = ext === "md" || ext === "mdx";
|
||||
const isDatabase = ext === "duckdb" || ext === "sqlite" || ext === "sqlite3" || ext === "db";
|
||||
@ -93,7 +61,6 @@ function buildBrowseTree(
|
||||
name: entry.name,
|
||||
path: absPath,
|
||||
type: isDatabase ? "database" : isDocument ? "document" : "file",
|
||||
...(isSymlink && { symlink: true }),
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -104,8 +71,8 @@ function buildBrowseTree(
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
let dir = url.searchParams.get("dir");
|
||||
const showHidden = url.searchParams.get("showHidden") === "1";
|
||||
|
||||
// Default to the workspace root
|
||||
if (!dir) {
|
||||
dir = resolveWorkspaceRoot();
|
||||
}
|
||||
@ -116,9 +83,10 @@ export async function GET(req: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve and normalize the directory path
|
||||
const resolved = resolve(dir);
|
||||
|
||||
const entries = buildBrowseTree(resolved, 3, 0, showHidden);
|
||||
const entries = buildBrowseTree(resolved, 3);
|
||||
const parentDir = resolved === "/" ? null : dirname(resolved);
|
||||
|
||||
return Response.json({
|
||||
|
||||
@ -1,239 +1,38 @@
|
||||
import { existsSync, mkdirSync, writeFileSync, readFileSync, copyFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { resolveOpenClawStateDir, setUIActiveProfile, getEffectiveProfile, resolveWorkspaceRoot, registerWorkspacePath } from "@/lib/workspace";
|
||||
import { resolveOpenClawStateDir, setUIActiveProfile, getEffectiveProfile, resolveWorkspaceRoot } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bootstrap file names (must match src/agents/workspace.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
const BOOTSTRAP_FILES: Record<string, string> = {
|
||||
"AGENTS.md": `# Workspace Agent Instructions
|
||||
|
||||
const BOOTSTRAP_FILENAMES = [
|
||||
"AGENTS.md",
|
||||
"SOUL.md",
|
||||
"TOOLS.md",
|
||||
"IDENTITY.md",
|
||||
"USER.md",
|
||||
"HEARTBEAT.md",
|
||||
"BOOTSTRAP.md",
|
||||
] as const;
|
||||
Add instructions here that your agent should follow when working in this workspace.
|
||||
`,
|
||||
"SOUL.md": `# Soul
|
||||
|
||||
// Minimal fallback content used when templates can't be loaded from disk
|
||||
const FALLBACK_CONTENT: Record<string, string> = {
|
||||
"AGENTS.md": "# AGENTS.md - Your Workspace\n\nThis folder is home. Treat it that way.\n",
|
||||
"SOUL.md": "# SOUL.md - Who You Are\n\nDescribe the personality and behavior of your agent here.\n",
|
||||
"TOOLS.md": "# TOOLS.md - Local Notes\n\nSkills define how tools work. This file is for your specifics.\n",
|
||||
"IDENTITY.md": "# IDENTITY.md - Who Am I?\n\nFill this in during your first conversation.\n",
|
||||
"USER.md": "# USER.md - About Your Human\n\nDescribe yourself and how you'd like the agent to interact with you.\n",
|
||||
"HEARTBEAT.md": "# HEARTBEAT.md\n\n# Keep this file empty (or with only comments) to skip heartbeat API calls.\n",
|
||||
"BOOTSTRAP.md": "# BOOTSTRAP.md - Hello, World\n\nYou just woke up. Time to figure out who you are.\n",
|
||||
Describe the personality and behavior of your agent here.
|
||||
`,
|
||||
"USER.md": `# User
|
||||
|
||||
Describe yourself — your preferences, context, and how you'd like the agent to interact with you.
|
||||
`,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CRM seed objects (mirrors src/agents/workspace-seed.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type SeedField = {
|
||||
name: string;
|
||||
type: string;
|
||||
required?: boolean;
|
||||
enumValues?: string[];
|
||||
};
|
||||
|
||||
type SeedObject = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
defaultView: string;
|
||||
entryCount: number;
|
||||
fields: SeedField[];
|
||||
};
|
||||
|
||||
const SEED_OBJECTS: SeedObject[] = [
|
||||
{
|
||||
id: "seed_obj_people_00000000000000",
|
||||
name: "people",
|
||||
description: "Contact management",
|
||||
icon: "users",
|
||||
defaultView: "table",
|
||||
entryCount: 5,
|
||||
fields: [
|
||||
{ name: "Full Name", type: "text", required: true },
|
||||
{ name: "Email Address", type: "email", required: true },
|
||||
{ name: "Phone Number", type: "phone" },
|
||||
{ name: "Company", type: "text" },
|
||||
{ name: "Status", type: "enum", enumValues: ["Active", "Inactive", "Lead"] },
|
||||
{ name: "Notes", type: "richtext" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "seed_obj_company_0000000000000",
|
||||
name: "company",
|
||||
description: "Company tracking",
|
||||
icon: "building-2",
|
||||
defaultView: "table",
|
||||
entryCount: 3,
|
||||
fields: [
|
||||
{ name: "Company Name", type: "text", required: true },
|
||||
{
|
||||
name: "Industry",
|
||||
type: "enum",
|
||||
enumValues: ["Technology", "Finance", "Healthcare", "Education", "Retail", "Other"],
|
||||
},
|
||||
{ name: "Website", type: "text" },
|
||||
{ name: "Type", type: "enum", enumValues: ["Client", "Partner", "Vendor", "Prospect"] },
|
||||
{ name: "Notes", type: "richtext" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "seed_obj_task_000000000000000",
|
||||
name: "task",
|
||||
description: "Task tracking board",
|
||||
icon: "check-square",
|
||||
defaultView: "kanban",
|
||||
entryCount: 5,
|
||||
fields: [
|
||||
{ name: "Title", type: "text", required: true },
|
||||
{ name: "Description", type: "text" },
|
||||
{ name: "Status", type: "enum", enumValues: ["In Queue", "In Progress", "Done"] },
|
||||
{ name: "Priority", type: "enum", enumValues: ["Low", "Medium", "High"] },
|
||||
{ name: "Due Date", type: "date" },
|
||||
{ name: "Notes", type: "richtext" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function stripFrontMatter(content: string): string {
|
||||
if (!content.startsWith("---")) {return content;}
|
||||
const endIndex = content.indexOf("\n---", 3);
|
||||
if (endIndex === -1) {return content;}
|
||||
return content.slice(endIndex + "\n---".length).replace(/^\s+/, "");
|
||||
}
|
||||
|
||||
/** Try multiple candidate paths to find the monorepo root. */
|
||||
function resolveProjectRoot(): string | null {
|
||||
const marker = join("docs", "reference", "templates", "AGENTS.md");
|
||||
const cwd = process.cwd();
|
||||
|
||||
// CWD is the repo root (standalone builds)
|
||||
if (existsSync(join(cwd, marker))) {return cwd;}
|
||||
|
||||
// CWD is apps/web/ (dev mode)
|
||||
const fromApps = resolve(cwd, "..", "..");
|
||||
if (existsSync(join(fromApps, marker))) {return fromApps;}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function loadTemplateContent(filename: string, projectRoot: string | null): string {
|
||||
if (projectRoot) {
|
||||
const templatePath = join(projectRoot, "docs", "reference", "templates", filename);
|
||||
try {
|
||||
const raw = readFileSync(templatePath, "utf-8");
|
||||
return stripFrontMatter(raw);
|
||||
} catch {
|
||||
// fall through to fallback
|
||||
}
|
||||
}
|
||||
return FALLBACK_CONTENT[filename] ?? "";
|
||||
}
|
||||
|
||||
function generateObjectYaml(obj: SeedObject): string {
|
||||
const lines: string[] = [
|
||||
`id: "${obj.id}"`,
|
||||
`name: "${obj.name}"`,
|
||||
`description: "${obj.description}"`,
|
||||
`icon: "${obj.icon}"`,
|
||||
`default_view: "${obj.defaultView}"`,
|
||||
`entry_count: ${obj.entryCount}`,
|
||||
"fields:",
|
||||
];
|
||||
|
||||
for (const field of obj.fields) {
|
||||
lines.push(` - name: "${field.name}"`);
|
||||
lines.push(` type: ${field.type}`);
|
||||
if (field.required) {lines.push(" required: true");}
|
||||
if (field.enumValues) {lines.push(` values: ${JSON.stringify(field.enumValues)}`);}
|
||||
}
|
||||
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
function generateWorkspaceMd(objects: SeedObject[]): string {
|
||||
const lines: string[] = ["# Workspace Schema", "", "Auto-generated summary of the workspace database.", ""];
|
||||
for (const obj of objects) {
|
||||
lines.push(`## ${obj.name}`, "");
|
||||
lines.push(`- **Description**: ${obj.description}`);
|
||||
lines.push(`- **View**: \`${obj.defaultView}\``);
|
||||
lines.push(`- **Entries**: ${obj.entryCount}`);
|
||||
lines.push("- **Fields**:");
|
||||
for (const field of obj.fields) {
|
||||
const req = field.required ? " (required)" : "";
|
||||
const vals = field.enumValues ? ` — ${field.enumValues.join(", ")}` : "";
|
||||
lines.push(` - ${field.name} (\`${field.type}\`)${req}${vals}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function writeIfMissing(filePath: string, content: string): boolean {
|
||||
if (existsSync(filePath)) {return false;}
|
||||
try {
|
||||
writeFileSync(filePath, content, { encoding: "utf-8", flag: "wx" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function seedDuckDB(workspaceDir: string, projectRoot: string | null): boolean {
|
||||
const destPath = join(workspaceDir, "workspace.duckdb");
|
||||
if (existsSync(destPath)) {return false;}
|
||||
|
||||
if (!projectRoot) {return false;}
|
||||
|
||||
const seedDb = join(projectRoot, "assets", "seed", "workspace.duckdb");
|
||||
if (!existsSync(seedDb)) {return false;}
|
||||
|
||||
try {
|
||||
copyFileSync(seedDb, destPath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create filesystem projections for CRM objects
|
||||
for (const obj of SEED_OBJECTS) {
|
||||
const objDir = join(workspaceDir, obj.name);
|
||||
mkdirSync(objDir, { recursive: true });
|
||||
writeIfMissing(join(objDir, ".object.yaml"), generateObjectYaml(obj));
|
||||
}
|
||||
|
||||
writeIfMissing(join(workspaceDir, "WORKSPACE.md"), generateWorkspaceMd(SEED_OBJECTS));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Route handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body = (await req.json()) as {
|
||||
profile?: string;
|
||||
/** Absolute path override (optional; defaults to profile-based resolution). */
|
||||
path?: string;
|
||||
/** Seed bootstrap files into the new workspace. Default true. */
|
||||
seedBootstrap?: boolean;
|
||||
};
|
||||
|
||||
const profileName = body.profile?.trim() || null;
|
||||
|
||||
// Validate profile name if provided
|
||||
if (profileName && profileName !== "default" && !/^[a-zA-Z0-9_-]+$/.test(profileName)) {
|
||||
return Response.json(
|
||||
{ error: "Invalid profile name. Use letters, numbers, hyphens, or underscores." },
|
||||
@ -257,6 +56,7 @@ export async function POST(req: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Create the workspace directory
|
||||
try {
|
||||
mkdirSync(workspaceDir, { recursive: true });
|
||||
} catch (err) {
|
||||
@ -266,57 +66,24 @@ export async function POST(req: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
// Seed bootstrap files
|
||||
const seedBootstrap = body.seedBootstrap !== false;
|
||||
const seeded: string[] = [];
|
||||
|
||||
if (seedBootstrap) {
|
||||
const projectRoot = resolveProjectRoot();
|
||||
|
||||
// Seed all bootstrap files from templates
|
||||
for (const filename of BOOTSTRAP_FILENAMES) {
|
||||
for (const [filename, content] of Object.entries(BOOTSTRAP_FILES)) {
|
||||
const filePath = join(workspaceDir, filename);
|
||||
if (!existsSync(filePath)) {
|
||||
const content = loadTemplateContent(filename, projectRoot);
|
||||
if (writeIfMissing(filePath, content)) {
|
||||
try {
|
||||
writeFileSync(filePath, content, "utf-8");
|
||||
seeded.push(filename);
|
||||
} catch {
|
||||
// Skip files that can't be written (permissions, etc.)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Seed DuckDB + CRM object projections
|
||||
if (seedDuckDB(workspaceDir, projectRoot)) {
|
||||
seeded.push("workspace.duckdb");
|
||||
for (const obj of SEED_OBJECTS) {
|
||||
seeded.push(`${obj.name}/.object.yaml`);
|
||||
}
|
||||
}
|
||||
|
||||
// Write workspace state so the gateway knows seeding was done
|
||||
const stateDir = join(workspaceDir, ".openclaw");
|
||||
const statePath = join(stateDir, "workspace-state.json");
|
||||
if (!existsSync(statePath)) {
|
||||
try {
|
||||
mkdirSync(stateDir, { recursive: true });
|
||||
const state = {
|
||||
version: 1,
|
||||
bootstrapSeededAt: new Date().toISOString(),
|
||||
duckdbSeededAt: existsSync(join(workspaceDir, "workspace.duckdb"))
|
||||
? new Date().toISOString()
|
||||
: undefined,
|
||||
};
|
||||
writeFileSync(statePath, JSON.stringify(state, null, 2) + "\n", "utf-8");
|
||||
} catch {
|
||||
// Best-effort state tracking
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remember custom-path workspaces in the registry
|
||||
if (body.path?.trim() && profileName) {
|
||||
registerWorkspacePath(profileName, workspaceDir);
|
||||
}
|
||||
|
||||
// Switch to the new profile
|
||||
// If a profile was specified, switch to it
|
||||
if (profileName) {
|
||||
setUIActiveProfile(profileName === "default" ? null : profileName);
|
||||
}
|
||||
|
||||
@ -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<string | null>((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(
|
||||
|
||||
88
apps/web/app/api/workspace/path-info/route.ts
Normal file
88
apps/web/app/api/workspace/path-info/route.ts
Normal file
@ -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<string | null>((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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -33,8 +33,6 @@ const MIME_MAP: Record<string, string> = {
|
||||
m4a: "audio/mp4",
|
||||
// Documents
|
||||
pdf: "application/pdf",
|
||||
html: "text/html",
|
||||
htm: "text/html",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
69
apps/web/app/api/workspace/thumbnail/route.ts
Normal file
69
apps/web/app/api/workspace/thumbnail/route.ts
Normal file
@ -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 <original-basename>.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 });
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { readdirSync, readFileSync, existsSync, statSync, type Dirent } from "node:fs";
|
||||
import { readdirSync, readFileSync, existsSync, type Dirent } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { resolveWorkspaceRoot, resolveOpenClawStateDir, getEffectiveProfile, parseSimpleYaml, duckdbQueryAll, isDatabaseFile } from "@/lib/workspace";
|
||||
|
||||
@ -14,8 +14,6 @@ export type TreeNode = {
|
||||
children?: TreeNode[];
|
||||
/** Virtual nodes live outside the main workspace (e.g. Skills, Memories). */
|
||||
virtual?: boolean;
|
||||
/** True when the entry is a symbolic link. */
|
||||
symlink?: boolean;
|
||||
};
|
||||
|
||||
type DbObject = {
|
||||
@ -60,28 +58,11 @@ function loadDbObjects(): Map<string, DbObject> {
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Resolve a dirent's effective type, following symlinks to their target. */
|
||||
function resolveEntryType(entry: Dirent, absPath: string): "directory" | "file" | null {
|
||||
if (entry.isDirectory()) {return "directory";}
|
||||
if (entry.isFile()) {return "file";}
|
||||
if (entry.isSymbolicLink()) {
|
||||
try {
|
||||
const st = statSync(absPath);
|
||||
if (st.isDirectory()) {return "directory";}
|
||||
if (st.isFile()) {return "file";}
|
||||
} catch {
|
||||
// Broken symlink -- skip
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Recursively build a tree from a workspace directory. */
|
||||
function buildTree(
|
||||
absDir: string,
|
||||
relativeBase: string,
|
||||
dbObjects: Map<string, DbObject>,
|
||||
showHidden = false,
|
||||
): TreeNode[] {
|
||||
const nodes: TreeNode[] = [];
|
||||
|
||||
@ -92,44 +73,32 @@ function buildTree(
|
||||
return nodes;
|
||||
}
|
||||
|
||||
const filtered = entries.filter((e) => {
|
||||
// .object.yaml is always needed for metadata; also shown as a node when showHidden is on
|
||||
if (e.name === ".object.yaml") {return true;}
|
||||
if (e.name.startsWith(".")) {return showHidden;}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Sort: directories first, then files, alphabetical within each group
|
||||
const sorted = filtered.toSorted((a, b) => {
|
||||
const absA = join(absDir, a.name);
|
||||
const absB = join(absDir, b.name);
|
||||
const typeA = resolveEntryType(a, absA);
|
||||
const typeB = resolveEntryType(b, absB);
|
||||
const dirA = typeA === "directory";
|
||||
const dirB = typeB === "directory";
|
||||
if (dirA && !dirB) {return -1;}
|
||||
if (!dirA && dirB) {return 1;}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
const sorted = entries
|
||||
.filter((e) => !e.name.startsWith(".") || e.name === ".object.yaml")
|
||||
.toSorted((a, b) => {
|
||||
if (a.isDirectory() && !b.isDirectory()) {return -1;}
|
||||
if (!a.isDirectory() && b.isDirectory()) {return 1;}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
for (const entry of sorted) {
|
||||
// .object.yaml is consumed for metadata; only show it as a visible node when revealing hidden files
|
||||
if (entry.name === ".object.yaml" && !showHidden) {continue;}
|
||||
// Skip hidden files except .object.yaml (but don't list it as a node)
|
||||
if (entry.name === ".object.yaml") {continue;}
|
||||
if (entry.name.startsWith(".")) {continue;}
|
||||
|
||||
const absPath = join(absDir, entry.name);
|
||||
const relPath = relativeBase
|
||||
? `${relativeBase}/${entry.name}`
|
||||
: entry.name;
|
||||
|
||||
const isSymlink = entry.isSymbolicLink();
|
||||
const effectiveType = resolveEntryType(entry, absPath);
|
||||
|
||||
if (effectiveType === "directory") {
|
||||
if (entry.isDirectory()) {
|
||||
const objectMeta = readObjectMeta(absPath);
|
||||
const dbObject = dbObjects.get(entry.name);
|
||||
const children = buildTree(absPath, relPath, dbObjects, showHidden);
|
||||
const children = buildTree(absPath, relPath, dbObjects);
|
||||
|
||||
if (objectMeta || dbObject) {
|
||||
// This directory represents a CRM object (from .object.yaml OR DuckDB)
|
||||
nodes.push({
|
||||
name: entry.name,
|
||||
path: relPath,
|
||||
@ -140,18 +109,17 @@ function buildTree(
|
||||
| "table"
|
||||
| "kanban") ?? "table",
|
||||
children: children.length > 0 ? children : undefined,
|
||||
...(isSymlink && { symlink: true }),
|
||||
});
|
||||
} else {
|
||||
// Regular folder
|
||||
nodes.push({
|
||||
name: entry.name,
|
||||
path: relPath,
|
||||
type: "folder",
|
||||
children: children.length > 0 ? children : undefined,
|
||||
...(isSymlink && { symlink: true }),
|
||||
});
|
||||
}
|
||||
} else if (effectiveType === "file") {
|
||||
} else if (entry.isFile()) {
|
||||
const ext = entry.name.split(".").pop()?.toLowerCase();
|
||||
const isReport = entry.name.endsWith(".report.json");
|
||||
const isDocument = ext === "md" || ext === "mdx";
|
||||
@ -161,7 +129,6 @@ function buildTree(
|
||||
name: entry.name,
|
||||
path: relPath,
|
||||
type: isReport ? "report" : isDatabase ? "database" : isDocument ? "document" : "file",
|
||||
...(isSymlink && { symlink: true }),
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -239,24 +206,27 @@ function buildSkillsVirtualFolder(): TreeNode | null {
|
||||
}
|
||||
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const showHidden = url.searchParams.get("showHidden") === "1";
|
||||
|
||||
export async function GET() {
|
||||
const openclawDir = resolveOpenClawStateDir();
|
||||
const profile = getEffectiveProfile();
|
||||
const root = resolveWorkspaceRoot();
|
||||
if (!root) {
|
||||
// Even without a workspace, return virtual folders if they exist
|
||||
const tree: TreeNode[] = [];
|
||||
const skillsFolder = buildSkillsVirtualFolder();
|
||||
if (skillsFolder) {tree.push(skillsFolder);}
|
||||
return Response.json({ tree, exists: false, workspaceRoot: null, openclawDir, profile });
|
||||
}
|
||||
|
||||
// Load objects from DuckDB for smart directory detection
|
||||
const dbObjects = loadDbObjects();
|
||||
|
||||
const tree = buildTree(root, "", dbObjects, showHidden);
|
||||
// Scan the workspace root — it IS the knowledge base.
|
||||
// All top-level directories, files, objects, and documents are visible
|
||||
// in the sidebar (USER.md, SOUL.md, memory/, etc. are all part of the tree).
|
||||
const tree = buildTree(root, "", dbObjects);
|
||||
|
||||
// Virtual folders go after all real files/folders
|
||||
const skillsFolder = buildSkillsVirtualFolder();
|
||||
if (skillsFolder) {tree.push(skillsFolder);}
|
||||
|
||||
|
||||
@ -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/<timestamp>-<filename> 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" },
|
||||
|
||||
@ -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"
|
||||
>
|
||||
<div
|
||||
className="relative z-10 flex-shrink-0 w-5 h-5 mt-0.5 flex items-center justify-center rounded-full"
|
||||
className="relative z-10 flex-shrink-0 w-[18px] h-[18px] flex items-center justify-center rounded-full"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
}}
|
||||
@ -800,20 +800,12 @@ function FetchGroup({ items }: { items: ToolPart[] }) {
|
||||
return (
|
||||
<div className="flex items-start gap-2.5 py-1.5">
|
||||
<div
|
||||
className="relative z-10 flex-shrink-0 w-5 h-5 mt-0.5 flex items-center justify-center rounded-full"
|
||||
className="relative z-10 flex-shrink-0 w-[18px] h-[18px] flex items-center justify-center rounded-full"
|
||||
style={{ background: "var(--color-bg)" }}
|
||||
>
|
||||
{anyRunning ? (
|
||||
<span
|
||||
className="w-4 h-4 border-[1.5px] rounded-full animate-spin"
|
||||
style={{
|
||||
borderColor: "var(--color-border-strong)",
|
||||
borderTopColor: "var(--color-accent)",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className={anyRunning ? "animate-pulse" : ""}>
|
||||
<StepIcon kind="fetch" />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
@ -824,7 +816,7 @@ function FetchGroup({ items }: { items: ToolPart[] }) {
|
||||
: "var(--color-text-secondary)",
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<span className={anyRunning ? "animate-pulse" : ""}>
|
||||
{anyRunning
|
||||
? `Fetching ${items.length} sources...`
|
||||
: `Fetched ${items.length} sources`}
|
||||
@ -841,7 +833,7 @@ function FetchGroup({ items }: { items: ToolPart[] }) {
|
||||
<div
|
||||
className="rounded-2xl overflow-hidden"
|
||||
style={{
|
||||
background: "rgba(255, 255, 255, 0.5)",
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
@ -984,18 +976,10 @@ function MediaGroup({
|
||||
return (
|
||||
<div className="flex items-start gap-2.5 py-1.5">
|
||||
<div
|
||||
className="relative z-10 flex-shrink-0 w-5 h-5 mt-0.5 flex items-center justify-center rounded-full"
|
||||
className="relative z-10 flex-shrink-0 w-[18px] h-[18px] flex items-center justify-center rounded-full"
|
||||
style={{ background: "var(--color-bg)" }}
|
||||
>
|
||||
{anyRunning ? (
|
||||
<span
|
||||
className="w-4 h-4 border-[1.5px] rounded-full animate-spin"
|
||||
style={{
|
||||
borderColor: "var(--color-border-strong)",
|
||||
borderTopColor: "var(--color-accent)",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className={anyRunning ? "animate-pulse" : ""}>
|
||||
<StepIcon
|
||||
kind={
|
||||
mediaKind === "image"
|
||||
@ -1003,11 +987,11 @@ function MediaGroup({
|
||||
: "read"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className="text-[13px] leading-snug mb-1.5"
|
||||
className={`text-[13px] leading-snug mb-1.5${anyRunning ? " animate-pulse" : ""}`}
|
||||
style={{
|
||||
color: anyRunning
|
||||
? "var(--color-text)"
|
||||
@ -1038,15 +1022,9 @@ function MediaGroup({
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="w-4 h-4 border-[1.5px] rounded-full animate-spin"
|
||||
style={{
|
||||
borderColor:
|
||||
"var(--color-border-strong)",
|
||||
borderTopColor:
|
||||
"var(--color-accent)",
|
||||
}}
|
||||
/>
|
||||
<span className="animate-pulse" style={{ color: "var(--color-text-muted)" }}>
|
||||
<StepIcon kind="image" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{hasMore && (
|
||||
@ -1259,27 +1237,21 @@ function ToolStep({
|
||||
return (
|
||||
<div className="flex items-start gap-2.5 py-1.5">
|
||||
<div
|
||||
className="relative z-10 flex-shrink-0 w-5 h-5 mt-0.5 flex items-center justify-center rounded-full"
|
||||
className="relative z-10 flex-shrink-0 w-[18px] h-[18px] flex items-center justify-center rounded-full"
|
||||
style={{ background: "var(--color-bg)" }}
|
||||
>
|
||||
{status === "running" ? (
|
||||
<span
|
||||
className="w-4 h-4 border-[1.5px] rounded-full animate-spin"
|
||||
style={{
|
||||
borderColor: "var(--color-border-strong)",
|
||||
borderTopColor: "var(--color-accent)",
|
||||
}}
|
||||
/>
|
||||
) : status === "error" ? (
|
||||
{status === "error" ? (
|
||||
<ErrorCircleIcon />
|
||||
) : (
|
||||
<StepIcon kind={kind} />
|
||||
<span className={status === "running" ? "animate-pulse" : ""}>
|
||||
<StepIcon kind={kind} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className="text-[13px] leading-snug flex items-start gap-2 flex-wrap"
|
||||
className="text-[13px] leading-snug flex items-center gap-2 flex-wrap"
|
||||
style={{
|
||||
color:
|
||||
status === "running"
|
||||
@ -1287,7 +1259,7 @@ function ToolStep({
|
||||
: "var(--color-text-secondary)",
|
||||
}}
|
||||
>
|
||||
<span className="break-all">{label}</span>
|
||||
<span className={`break-all${status === "running" ? " animate-pulse" : ""}`}>{label}</span>
|
||||
{/* Exit code badge for exec tools */}
|
||||
{kind === "exec" && status === "done" && output?.exitCode !== undefined && (
|
||||
<span
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import type { UIMessage } from "ai";
|
||||
import { memo, useState } from "react";
|
||||
import { memo, useMemo, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import type { Components } from "react-markdown";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
@ -360,93 +360,42 @@ function AttachFileIcon({ category }: { category: string }) {
|
||||
|
||||
function AttachedFilesCard({ paths }: { paths: string[] }) {
|
||||
return (
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ opacity: 0.5 }}
|
||||
>
|
||||
<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48" />
|
||||
</svg>
|
||||
<span
|
||||
className="text-[11px] font-medium uppercase tracking-wider"
|
||||
style={{ opacity: 0.5 }}
|
||||
>
|
||||
{paths.length}{" "}
|
||||
{paths.length === 1 ? "file" : "files"}{" "}
|
||||
attached
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{paths.map((filePath, i) => {
|
||||
const category =
|
||||
getCategoryFromPath(filePath);
|
||||
const filename =
|
||||
filePath.split("/").pop() ??
|
||||
filePath;
|
||||
const meta =
|
||||
attachCategoryMeta[category] ??
|
||||
attachCategoryMeta.other;
|
||||
const short = shortenPath(filePath);
|
||||
<div className="flex flex-wrap gap-1.5 mb-2 justify-end">
|
||||
{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 (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-shrink-0 rounded-lg"
|
||||
style={{
|
||||
background:
|
||||
"rgba(0,0,0,0.04)",
|
||||
border: "1px solid rgba(0,0,0,0.06)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-2.5 py-1.5">
|
||||
<div
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0"
|
||||
style={{
|
||||
background:
|
||||
meta.bg,
|
||||
color: meta.fg,
|
||||
}}
|
||||
>
|
||||
<AttachFileIcon
|
||||
category={
|
||||
category
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className="text-[12px] font-medium truncate max-w-[160px]"
|
||||
title={
|
||||
filePath
|
||||
}
|
||||
>
|
||||
{filename}
|
||||
</p>
|
||||
<p
|
||||
className="text-[10px] truncate max-w-[160px]"
|
||||
style={{
|
||||
opacity: 0.45,
|
||||
}}
|
||||
title={
|
||||
filePath
|
||||
}
|
||||
>
|
||||
{short}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="relative rounded-xl overflow-hidden shrink-0"
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt={filePath.split("/").pop() ?? ""}
|
||||
className="block rounded-xl object-cover"
|
||||
style={{ maxHeight: 140, maxWidth: 160, background: "rgba(0,0,0,0.04)" }}
|
||||
loading="lazy"
|
||||
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
{category !== "image" && (
|
||||
<span
|
||||
className="absolute bottom-2 left-2 rounded-md px-1.5 py-0.5 text-[10px] font-bold uppercase"
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.85)",
|
||||
color: "rgba(0,0,0,0.5)",
|
||||
backdropFilter: "blur(4px)",
|
||||
}}
|
||||
>
|
||||
{ext}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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> | 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 (
|
||||
<code
|
||||
className={`inline-flex items-center gap-[0.2em] px-[0.3em] py-0 whitespace-nowrap max-w-full overflow-hidden text-ellipsis no-underline transition-colors duration-150 rounded-md text-[color:var(--color-accent)] border border-[color:var(--color-border)] bg-white/20 hover:bg-white/40 active:bg-white ${status === "opening" ? "cursor-wait opacity-70" : "cursor-pointer"}`}
|
||||
className={`px-[0.3em] no-underline transition-colors duration-150 rounded-[4px] border border-[color:var(--color-border)] bg-white/20 hover:bg-white/40 active:bg-white ${status === "opening" ? "cursor-wait opacity-70" : "cursor-pointer"}`}
|
||||
style={{ color: "var(--color-accent)" }}
|
||||
onClick={handleClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
title={status === "error" ? "File not found" : "Click to open · Right-click to reveal in Finder"}
|
||||
title={
|
||||
status === "error"
|
||||
? "File not found"
|
||||
: onFilePathClick
|
||||
? "Click to preview in workspace · Right-click to reveal in Finder"
|
||||
: "Click to open · Right-click to reveal in Finder"
|
||||
}
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="shrink-0 opacity-60"
|
||||
>
|
||||
{status === "error" ? (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="15" x2="9" y1="9" y2="15" />
|
||||
<line x1="9" x2="15" y1="9" y2="15" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
||||
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
@ -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 (
|
||||
<a
|
||||
href={href}
|
||||
{...(isExternal
|
||||
? { target: "_blank", rel: "noopener noreferrer" }
|
||||
: {})}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
// Render images with loading=lazy
|
||||
img: ({ src, alt, ...props }) => (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={src} alt={alt ?? ""} loading="lazy" {...props} />
|
||||
),
|
||||
// Syntax-highlighted fenced code blocks
|
||||
pre: ({ children, ...props }) => {
|
||||
// react-markdown wraps code blocks in <pre><code>...
|
||||
// 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 (
|
||||
<a
|
||||
href={href}
|
||||
{...(isExternal
|
||||
? { target: "_blank", rel: "noopener noreferrer" }
|
||||
: {})}
|
||||
{...props}
|
||||
onClick={(e) => {
|
||||
if (!isLocalPathLink || !onFilePathClick) {return;}
|
||||
e.preventDefault();
|
||||
void onFilePathClick(normalizedHref);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
// Render images with loading=lazy
|
||||
img: ({ src, alt, ...props }) => (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={src} alt={alt ?? ""} loading="lazy" {...props} />
|
||||
),
|
||||
// Syntax-highlighted fenced code blocks
|
||||
pre: ({ children, ...props }) => {
|
||||
// react-markdown wraps code blocks in <pre><code>...
|
||||
// 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 <DiffCard diff={code} />;
|
||||
}
|
||||
// Diff language: render as DiffCard
|
||||
if (lang === "diff") {
|
||||
return <DiffCard diff={code} />;
|
||||
}
|
||||
|
||||
// Known language: syntax-highlight with shiki
|
||||
if (lang) {
|
||||
return (
|
||||
<div className="chat-code-block">
|
||||
<div
|
||||
className="chat-code-lang"
|
||||
>
|
||||
{lang}
|
||||
// Known language: syntax-highlight with shiki
|
||||
if (lang) {
|
||||
return (
|
||||
<div className="chat-code-block">
|
||||
<div
|
||||
className="chat-code-lang"
|
||||
>
|
||||
{lang}
|
||||
</div>
|
||||
<SyntaxBlock code={code} lang={lang} />
|
||||
</div>
|
||||
<SyntaxBlock code={code} lang={lang} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
// Fallback: default pre rendering
|
||||
return <pre {...props}>{children}</pre>;
|
||||
},
|
||||
// Inline code — detect file paths and make them clickable
|
||||
code: ({ children, className, ...props }) => {
|
||||
// If this code has a language class, it's inside a <pre> and
|
||||
// will be handled by the pre override above. Just return raw.
|
||||
if (className?.startsWith("language-")) {
|
||||
return (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
}
|
||||
// Fallback: default pre rendering
|
||||
return <pre {...props}>{children}</pre>;
|
||||
},
|
||||
// Inline code — detect file paths and make them clickable
|
||||
code: ({ children, className, ...props }) => {
|
||||
// If this code has a language class, it's inside a <pre> and
|
||||
// will be handled by the pre override above. Just return raw.
|
||||
if (className?.startsWith("language-")) {
|
||||
return (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
// Check if the inline code content looks like a file path
|
||||
const text = typeof children === "string" ? children : "";
|
||||
if (text && looksLikeFilePath(text)) {
|
||||
return <FilePathCode path={text}>{children}</FilePathCode>;
|
||||
}
|
||||
// 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 (
|
||||
<FilePathCode path={normalizedText} onFilePathClick={onFilePathClick}>
|
||||
{children}
|
||||
</FilePathCode>
|
||||
);
|
||||
}
|
||||
|
||||
// Regular inline code
|
||||
return <code {...props}>{children}</code>;
|
||||
},
|
||||
};
|
||||
// Regular inline code
|
||||
return <code {...props}>{children}</code>;
|
||||
},
|
||||
// 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 (
|
||||
<strong {...props}>
|
||||
<FilePathCode path={text} onFilePathClick={onFilePathClick}>
|
||||
{children}
|
||||
</FilePathCode>
|
||||
</strong>
|
||||
);
|
||||
}
|
||||
return <strong {...props}>{children}</strong>;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/* ─── 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 (
|
||||
<div className="flex flex-col items-end gap-1.5 py-2">
|
||||
{/* Attachment previews — standalone above the text bubble */}
|
||||
<AttachedFilesCard paths={attachmentInfo.paths} />
|
||||
{/* Text bubble */}
|
||||
{attachmentInfo.message && (
|
||||
<div
|
||||
className="max-w-[80%] w-fit rounded-2xl rounded-br-sm px-3 py-2 text-sm leading-6 break-words chat-message-font"
|
||||
style={{
|
||||
background: "var(--color-user-bubble)",
|
||||
color: "var(--color-user-bubble-text)",
|
||||
}}
|
||||
>
|
||||
<p className="whitespace-pre-wrap break-words">
|
||||
{attachmentInfo.message}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-end py-2">
|
||||
<div
|
||||
className="font-bookerly max-w-[80%] min-w-0 rounded-2xl rounded-br-sm px-3 py-2 text-sm leading-6 overflow-hidden break-all"
|
||||
className="max-w-[80%] min-w-0 rounded-2xl rounded-br-sm px-3 py-2 text-sm leading-6 overflow-hidden break-words chat-message-font"
|
||||
style={{
|
||||
background: "var(--color-user-bubble)",
|
||||
color: "var(--color-user-bubble-text)",
|
||||
}}
|
||||
>
|
||||
{attachmentInfo ? (
|
||||
<>
|
||||
<AttachedFilesCard
|
||||
paths={
|
||||
attachmentInfo.paths
|
||||
}
|
||||
/>
|
||||
{attachmentInfo.message && (
|
||||
<p className="whitespace-pre-wrap break-all">
|
||||
{
|
||||
attachmentInfo.message
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="whitespace-pre-wrap break-all">
|
||||
{textContent}
|
||||
</p>
|
||||
)}
|
||||
<p className="whitespace-pre-wrap break-words text-right">
|
||||
{textContent}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -734,7 +762,7 @@ export const ChatMessage = memo(function ChatMessage({ message, isStreaming, onS
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="font-bookerly flex items-start gap-2 rounded-xl px-3 py-2 text-[13px] leading-relaxed overflow-hidden"
|
||||
className="chat-message-font flex items-start gap-2 rounded-xl px-3 py-2 text-[13px] leading-relaxed overflow-hidden"
|
||||
style={{
|
||||
background: `color-mix(in srgb, var(--color-error) 6%, var(--color-surface))`,
|
||||
color: "var(--color-error)",
|
||||
@ -791,7 +819,7 @@ 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 whitespace-pre-wrap break-all"
|
||||
className="chat-prose chat-message-font text-sm whitespace-pre-wrap break-all"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{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)" }}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={mdComponents}
|
||||
components={markdownComponents}
|
||||
>
|
||||
{segment.text}
|
||||
</ReactMarkdown>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -437,7 +437,6 @@ export function Sidebar({
|
||||
<ProfileSwitcher
|
||||
onProfileSwitch={handleProfileSwitch}
|
||||
onCreateWorkspace={() => setShowCreateWorkspace(true)}
|
||||
activeProfileHint={String(sidebarRefreshKey)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ import type { UIMessage } from "ai";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ChatMessage } from "./chat-message";
|
||||
import { createStreamParser } from "./chat-panel";
|
||||
import { UnicodeSpinner } from "./unicode-spinner";
|
||||
import { ChatEditor, type ChatEditorHandle } from "./tiptap/chat-editor";
|
||||
|
||||
type SubagentPanelProps = {
|
||||
@ -296,9 +297,9 @@ export function SubagentPanel({ sessionKey, task, label, onBack }: SubagentPanel
|
||||
}, [messages]);
|
||||
|
||||
const statusLabel = useMemo(() => {
|
||||
if (!connected && (isStreaming || isReconnecting)) {return "Connecting...";}
|
||||
if (isReconnecting) {return "Resuming stream...";}
|
||||
if (isStreaming) {return "Streaming...";}
|
||||
if (!connected && (isStreaming || isReconnecting)) {return <UnicodeSpinner name="braille">Connecting</UnicodeSpinner>;}
|
||||
if (isReconnecting) {return <UnicodeSpinner name="braille">Resuming</UnicodeSpinner>;}
|
||||
if (isStreaming) {return <UnicodeSpinner name="braille" />;}
|
||||
return "Completed";
|
||||
}, [connected, isStreaming, isReconnecting]);
|
||||
|
||||
|
||||
@ -259,6 +259,31 @@ export const ChatEditor = forwardRef<ChatEditorHandle, ChatEditorProps>(
|
||||
// otherwise consume the event or insert the text/plain
|
||||
// fallback data as raw text.
|
||||
handleDOMEvents: {
|
||||
paste: (_view, event) => {
|
||||
const clipboardData = event.clipboardData;
|
||||
if (!clipboardData) {return false;}
|
||||
|
||||
// Collect files from clipboard (images, screenshots, etc.)
|
||||
const pastedFiles: File[] = [];
|
||||
if (clipboardData.items) {
|
||||
for (const item of Array.from(clipboardData.items)) {
|
||||
if (item.kind === "file") {
|
||||
const file = item.getAsFile();
|
||||
if (file) {pastedFiles.push(file);}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pastedFiles.length > 0) {
|
||||
event.preventDefault();
|
||||
const dt = new DataTransfer();
|
||||
for (const f of pastedFiles) {dt.items.add(f);}
|
||||
nativeFileDropRef.current?.(dt.files);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
dragover: (_view, event) => {
|
||||
const de = event;
|
||||
if (de.dataTransfer?.types.includes("application/x-file-mention")) {
|
||||
@ -396,7 +421,7 @@ export const ChatEditor = forwardRef<ChatEditorHandle, ChatEditorProps>(
|
||||
<style>{`
|
||||
.chat-editor-content {
|
||||
outline: none;
|
||||
min-height: 20px;
|
||||
min-height: ${compact ? "16px" : "28px"};
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
padding: ${compact ? "10px 12px" : "14px 16px"};
|
||||
|
||||
314
apps/web/app/components/ui/dropdown-menu.tsx
Normal file
314
apps/web/app/components/ui/dropdown-menu.tsx
Normal file
@ -0,0 +1,314 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Menu as MenuPrimitive } from "@base-ui/react/menu";
|
||||
import { ChevronRightIcon, CheckIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenuPrimitive.Root>) {
|
||||
return (
|
||||
<MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenuPrimitive.Portal>) {
|
||||
return (
|
||||
<MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<MenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
className={cn("cursor-pointer outline-none ring-0 border-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenuPrimitive.Popup> &
|
||||
Pick<
|
||||
React.ComponentProps<typeof MenuPrimitive.Positioner>,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<MenuPrimitive.Portal>
|
||||
<MenuPrimitive.Positioner
|
||||
className="isolate z-[100] outline-none"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<MenuPrimitive.Popup
|
||||
data-slot="dropdown-menu-content"
|
||||
className={cn(
|
||||
"bg-neutral-100/[0.67] border border-white backdrop-blur-md text-[var(--color-text)] z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-3xl p-1 shadow-[0_0_25px_0_rgba(0,0,0,0.16)] outline-none",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</MenuPrimitive.Positioner>
|
||||
</MenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenuPrimitive.Group>) {
|
||||
return (
|
||||
<MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenuPrimitive.GroupLabel> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.GroupLabel
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
onSelect,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
onSelect?: () => void;
|
||||
}) {
|
||||
const handleClick = (e: React.MouseEvent<HTMLDivElement> & { preventBaseUIHandler: () => void }) => {
|
||||
onClick?.(e);
|
||||
onSelect?.();
|
||||
};
|
||||
return (
|
||||
<MenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"bg-transparent hover:bg-neutral-400/15 text-sm transition-all relative flex cursor-pointer items-center gap-2 rounded-full px-2 py-1.5 outline-none ring-0 border-none select-none",
|
||||
"data-[variant=destructive]:text-[var(--color-error)] data-[variant=destructive]:hover:bg-[var(--color-error)]/10 data-[variant=destructive]:hover:text-[var(--color-error)]",
|
||||
inset && "pl-8",
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"data-disabled:pointer-events-none data-disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
onClick={handleClick}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenuPrimitive.SubmenuRoot>) {
|
||||
return (
|
||||
<MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenuPrimitive.SubmenuTrigger> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.SubmenuTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"bg-transparent hover:bg-neutral-400/15 focus:bg-neutral-400/15 data-open:bg-neutral-400/15 flex cursor-pointer items-center gap-2 rounded-full px-2 py-1.5 text-sm outline-none select-none transition-all focus:ring-0 focus:outline-none focus-visible:ring-0 focus-visible:outline-none",
|
||||
inset && "pl-8",
|
||||
"[&_svg:not([class*='text-'])]:text-[var(--color-text-muted)] [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</MenuPrimitive.SubmenuTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
align = "start",
|
||||
alignOffset = -3,
|
||||
side = "right",
|
||||
sideOffset = 0,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuContent>) {
|
||||
return (
|
||||
<DropdownMenuContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn("min-w-[96px]", className)}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenuPrimitive.CheckboxItem> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"hover:bg-neutral-400/15 focus:bg-neutral-400/15 relative flex cursor-pointer items-center gap-2 rounded-full py-1.5 pr-2 pl-8 text-sm outline-none select-none transition-colors",
|
||||
inset && "pl-8",
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"data-disabled:pointer-events-none data-disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<MenuPrimitive.CheckboxItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</MenuPrimitive.CheckboxItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<MenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenuPrimitive.RadioItem> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"hover:bg-neutral-400/15 focus:bg-neutral-400/15 relative flex cursor-pointer items-center gap-2 rounded-full py-1.5 pr-2 pl-8 text-sm outline-none select-none transition-colors",
|
||||
inset && "pl-8",
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"data-disabled:pointer-events-none data-disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<MenuPrimitive.RadioItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</MenuPrimitive.RadioItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenuPrimitive.Separator>) {
|
||||
return (
|
||||
<MenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn(
|
||||
"bg-neutral-400/15 -mx-1 my-1 h-px",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-[var(--color-text-muted)] ml-auto text-xs tracking-widest",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
};
|
||||
36
apps/web/app/components/unicode-spinner.tsx
Normal file
36
apps/web/app/components/unicode-spinner.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import spinners from "unicode-animations";
|
||||
|
||||
type SpinnerName = keyof typeof spinners;
|
||||
|
||||
export function UnicodeSpinner({
|
||||
name = "braille",
|
||||
children,
|
||||
className,
|
||||
style,
|
||||
}: {
|
||||
name?: SpinnerName;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
const [frame, setFrame] = useState(0);
|
||||
const s = spinners[name];
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(
|
||||
() => setFrame((f) => (f + 1) % s.frames.length),
|
||||
s.interval,
|
||||
);
|
||||
return () => clearInterval(timer);
|
||||
}, [name, s.frames.length, s.interval]);
|
||||
|
||||
return (
|
||||
<span className={className} style={{ fontFamily: "monospace", ...style }}>
|
||||
{s.frames[frame]}
|
||||
{children != null && <> {children}</>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { UnicodeSpinner } from "../unicode-spinner";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
|
||||
type WebSession = {
|
||||
id: string;
|
||||
@ -38,6 +45,16 @@ type ChatSessionsSidebarProps = {
|
||||
mobile?: boolean;
|
||||
/** Close the mobile drawer. */
|
||||
onClose?: () => void;
|
||||
/** Fixed width in px when not mobile (overrides default 260). */
|
||||
width?: number;
|
||||
/** Called when the user deletes a session from the sidebar menu. */
|
||||
onDeleteSession?: (sessionId: string) => void;
|
||||
/** Called when the user renames a session from the sidebar menu. */
|
||||
onRenameSession?: (sessionId: string, newTitle: string) => void;
|
||||
/** Called when the user clicks the collapse/hide sidebar button. */
|
||||
onCollapse?: () => void;
|
||||
/** When true, show a loader instead of empty state (e.g. initial sessions fetch). */
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
/** Format a timestamp into a human-readable relative time string. */
|
||||
@ -111,6 +128,25 @@ function ChatBubbleIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
function MoreHorizontalIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="1" />
|
||||
<circle cx="5" cy="12" r="1" />
|
||||
<circle cx="19" cy="12" r="1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatSessionsSidebar({
|
||||
sessions,
|
||||
activeSessionId,
|
||||
@ -121,10 +157,17 @@ export function ChatSessionsSidebar({
|
||||
onSelectSession,
|
||||
onNewSession,
|
||||
onSelectSubagent,
|
||||
onDeleteSession,
|
||||
onRenameSession,
|
||||
onCollapse,
|
||||
mobile,
|
||||
onClose,
|
||||
width: widthProp,
|
||||
loading = false,
|
||||
}: ChatSessionsSidebarProps) {
|
||||
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(id: string) => {
|
||||
@ -142,6 +185,26 @@ export function ChatSessionsSidebar({
|
||||
[onSelectSubagent, onClose],
|
||||
);
|
||||
|
||||
const handleDeleteSession = useCallback(
|
||||
(sessionId: string) => {
|
||||
onDeleteSession?.(sessionId);
|
||||
},
|
||||
[onDeleteSession],
|
||||
);
|
||||
|
||||
const handleStartRename = useCallback((sessionId: string, currentTitle: string) => {
|
||||
setRenamingId(sessionId);
|
||||
setRenameValue(currentTitle || "");
|
||||
}, []);
|
||||
|
||||
const handleCommitRename = useCallback(() => {
|
||||
if (renamingId && renameValue.trim()) {
|
||||
onRenameSession?.(renamingId, renameValue.trim());
|
||||
}
|
||||
setRenamingId(null);
|
||||
setRenameValue("");
|
||||
}, [renamingId, renameValue, onRenameSession]);
|
||||
|
||||
// Index subagents by parent session ID
|
||||
const subagentsByParent = useMemo(() => {
|
||||
const map = new Map<string, SidebarSubagentInfo[]>();
|
||||
@ -160,45 +223,40 @@ export function ChatSessionsSidebar({
|
||||
// Group sessions: today, yesterday, this week, this month, older
|
||||
const grouped = groupSessions(sessions);
|
||||
|
||||
const width = mobile ? "280px" : (widthProp ?? 260);
|
||||
const headerHeight = 40; // px — match padding so list content clears the overlay
|
||||
const sidebar = (
|
||||
<aside
|
||||
className={`flex flex-col h-full flex-shrink-0 ${mobile ? "drawer-right" : "border-l"}`}
|
||||
className={`flex flex-col h-full shrink-0 ${mobile ? "drawer-right" : "border-l"}`}
|
||||
style={{
|
||||
width: mobile ? "280px" : 260,
|
||||
width: typeof width === "number" ? `${width}px` : width,
|
||||
minWidth: typeof width === "number" ? `${width}px` : width,
|
||||
borderColor: "var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
background: "var(--color-sidebar-bg)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between px-6 py-4 border-b flex-shrink-0"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span
|
||||
className="text-sm font-medium truncate block"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
Chats
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewSession}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-colors cursor-pointer flex-shrink-0 ml-2"
|
||||
style={{
|
||||
color: "var(--color-accent)",
|
||||
background: "var(--color-accent-light)",
|
||||
}}
|
||||
title="New chat"
|
||||
{/* Scrollable list fills the sidebar; header overlays the top with blur */}
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
{/* Session list — scrolls under the header */}
|
||||
<div
|
||||
className="absolute inset-0 overflow-y-auto"
|
||||
style={{ paddingTop: headerHeight }}
|
||||
>
|
||||
<PlusIcon />
|
||||
New
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Session list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{sessions.length === 0 ? (
|
||||
{loading && sessions.length === 0 ? (
|
||||
<div className="px-4 py-8 flex flex-col items-center justify-center min-h-[120px]">
|
||||
<UnicodeSpinner
|
||||
name="braille"
|
||||
className="text-xl mb-2"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
/>
|
||||
<p
|
||||
className="text-xs"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Loading…
|
||||
</p>
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center">
|
||||
<div
|
||||
className="mx-auto w-10 h-10 rounded-xl flex items-center justify-center mb-3"
|
||||
@ -231,68 +289,117 @@ export function ChatSessionsSidebar({
|
||||
{group.sessions.map((session) => {
|
||||
const isActive = session.id === activeSessionId && !activeSubagentKey;
|
||||
const isHovered = session.id === hoveredId;
|
||||
const showMore = isHovered;
|
||||
const isStreamingSession = streamingSessionIds?.has(session.id) ?? false;
|
||||
const sessionSubagents = subagentsByParent.get(session.id);
|
||||
return (
|
||||
<div key={session.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelect(session.id)}
|
||||
<div
|
||||
key={session.id}
|
||||
className="group relative"
|
||||
onMouseEnter={() => setHoveredId(session.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
className="w-full text-left px-2 py-2 rounded-lg transition-colors cursor-pointer"
|
||||
>
|
||||
<div
|
||||
className="flex items-stretch w-full rounded-lg"
|
||||
style={{
|
||||
background: isActive
|
||||
? "var(--color-accent-light)"
|
||||
? "var(--color-chat-sidebar-active-bg)"
|
||||
: isHovered
|
||||
? "var(--color-surface-hover)"
|
||||
: "transparent",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isStreamingSession && (
|
||||
<span
|
||||
className="inline-block w-1.5 h-1.5 rounded-full flex-shrink-0 animate-pulse"
|
||||
style={{ background: "var(--color-accent)" }}
|
||||
title="Agent is running"
|
||||
{renamingId === session.id ? (
|
||||
<form
|
||||
className="flex-1 min-w-0 px-2 py-1.5"
|
||||
onSubmit={(e) => { e.preventDefault(); handleCommitRename(); }}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onBlur={handleCommitRename}
|
||||
onKeyDown={(e) => { if (e.key === "Escape") { setRenamingId(null); setRenameValue(""); } }}
|
||||
autoFocus
|
||||
className="w-full text-xs font-medium px-1 py-0.5 rounded outline-none border"
|
||||
style={{ color: "var(--color-text)", background: "var(--color-surface)", borderColor: "var(--color-border)" }}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="text-xs font-medium truncate"
|
||||
style={{
|
||||
color: isActive
|
||||
? "var(--color-accent)"
|
||||
: "var(--color-text)",
|
||||
}}
|
||||
>
|
||||
{session.title || "Untitled chat"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5" style={{ paddingLeft: isStreamingSession ? "calc(0.375rem + 6px)" : undefined }}>
|
||||
{isStreamingSession && (
|
||||
<span
|
||||
className="text-[10px] font-medium"
|
||||
style={{ color: "var(--color-accent)" }}
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelect(session.id)}
|
||||
className="flex-1 min-w-0 text-left px-2 py-2 rounded-l-lg transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isStreamingSession && (
|
||||
<UnicodeSpinner
|
||||
name="braille"
|
||||
className="text-[10px] flex-shrink-0"
|
||||
style={{ color: "var(--color-chat-sidebar-muted)" }}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="text-xs font-medium truncate"
|
||||
style={{
|
||||
color: isActive
|
||||
? "var(--color-chat-sidebar-active-text)"
|
||||
: "var(--color-text)",
|
||||
}}
|
||||
>
|
||||
Streaming
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="text-[10px]"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{timeAgo(session.updatedAt)}
|
||||
</span>
|
||||
{session.messageCount > 0 && (
|
||||
{session.title || "Untitled chat"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5" style={{ paddingLeft: isStreamingSession ? "calc(0.375rem + 6px)" : undefined }}>
|
||||
|
||||
<span
|
||||
className="text-[10px]"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{session.messageCount} msg{session.messageCount !== 1 ? "s" : ""}
|
||||
{timeAgo(session.updatedAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{session.messageCount > 0 && (
|
||||
<span
|
||||
className="text-[10px]"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{session.messageCount} msg{session.messageCount !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
{onDeleteSession && (
|
||||
<div className={`shrink-0 flex items-center pr-1 transition-opacity ${showMore ? "opacity-100" : "opacity-0"}`}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex items-center justify-center w-6 h-6 rounded-md"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title="More options"
|
||||
aria-label="More options"
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" side="bottom">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => handleStartRename(session.id, session.title)}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" /></svg>
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => handleDeleteSession(session.id)}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" /><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" /></svg>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Subagent sub-items */}
|
||||
{sessionSubagents && sessionSubagents.length > 0 && (
|
||||
<div className="ml-4 border-l" style={{ borderColor: "var(--color-border)" }}>
|
||||
@ -309,16 +416,16 @@ export function ChatSessionsSidebar({
|
||||
className="w-full text-left pl-3 pr-2 py-1.5 rounded-r-lg transition-colors cursor-pointer"
|
||||
style={{
|
||||
background: isSubActive
|
||||
? "var(--color-accent-light)"
|
||||
? "var(--color-chat-sidebar-active-bg)"
|
||||
: "transparent",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isSubRunning && (
|
||||
<span
|
||||
className="inline-block w-1 h-1 rounded-full flex-shrink-0 animate-pulse"
|
||||
style={{ background: "var(--color-accent)" }}
|
||||
title="Subagent running"
|
||||
<UnicodeSpinner
|
||||
name="braille"
|
||||
className="text-[9px] flex-shrink-0"
|
||||
style={{ color: "var(--color-chat-sidebar-muted)" }}
|
||||
/>
|
||||
)}
|
||||
<SubagentIcon />
|
||||
@ -326,7 +433,7 @@ export function ChatSessionsSidebar({
|
||||
className="text-[11px] truncate"
|
||||
style={{
|
||||
color: isSubActive
|
||||
? "var(--color-accent)"
|
||||
? "var(--color-chat-sidebar-active-text)"
|
||||
: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
@ -346,6 +453,52 @@ export function ChatSessionsSidebar({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Header overlay: backdrop blur + 80% bg; list scrolls under it */}
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 z-10 flex items-center justify-between border-b px-4 py-2 backdrop-blur-md"
|
||||
style={{
|
||||
height: headerHeight,
|
||||
borderColor: "var(--color-border)",
|
||||
background: "color-mix(in srgb, var(--color-sidebar-bg) 80%, transparent)",
|
||||
}}
|
||||
>
|
||||
<div className="min-w-0 flex-1 flex items-center gap-1.5">
|
||||
{onCollapse && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCollapse}
|
||||
className="p-1 rounded-md shrink-0 transition-colors hover:bg-black/5"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title="Hide chat sidebar (⌘⇧B)"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
<path d="M15 3v18" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<span
|
||||
className="text-xs font-medium truncate block"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
Chats
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewSession}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-md text-[11px] font-medium transition-colors cursor-pointer shrink-0 ml-1.5"
|
||||
style={{
|
||||
color: "var(--color-chat-sidebar-active-text)",
|
||||
background: "var(--color-chat-sidebar-active-bg)",
|
||||
}}
|
||||
title="New chat"
|
||||
>
|
||||
<PlusIcon />
|
||||
New
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { DirectoryPickerModal } from "./directory-picker-modal";
|
||||
|
||||
type CreateWorkspaceDialogProps = {
|
||||
isOpen: boolean;
|
||||
@ -9,15 +8,10 @@ type CreateWorkspaceDialogProps = {
|
||||
onCreated?: () => void;
|
||||
};
|
||||
|
||||
function shortenPath(p: string): string {
|
||||
return p.replace(/^\/Users\/[^/]+/, "~").replace(/^\/home\/[^/]+/, "~");
|
||||
}
|
||||
|
||||
export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWorkspaceDialogProps) {
|
||||
const [profileName, setProfileName] = useState("");
|
||||
const [customPath, setCustomPath] = useState("");
|
||||
const [useCustomPath, setUseCustomPath] = useState(false);
|
||||
const [showDirPicker, setShowDirPicker] = useState(false);
|
||||
const [seedBootstrap, setSeedBootstrap] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@ -31,23 +25,22 @@ export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWork
|
||||
setProfileName("");
|
||||
setCustomPath("");
|
||||
setUseCustomPath(false);
|
||||
setShowDirPicker(false);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
setTimeout(() => inputRef.current?.focus(), 100);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Close on Escape (only if dir picker is not open)
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape" && !showDirPicker) {onClose();}
|
||||
if (e.key === "Escape") {onClose();}
|
||||
}
|
||||
if (isOpen) {
|
||||
document.addEventListener("keydown", handleKey);
|
||||
return () => document.removeEventListener("keydown", handleKey);
|
||||
}
|
||||
}, [isOpen, onClose, showDirPicker]);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
const name = profileName.trim();
|
||||
@ -229,65 +222,18 @@ export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWork
|
||||
</button>
|
||||
|
||||
{useCustomPath && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{customPath ? (
|
||||
<div
|
||||
className="flex items-center gap-2.5 px-3 py-2.5 rounded-lg"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: "rgba(245, 158, 11, 0.12)", color: "#f59e0b" }}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate" style={{ color: "var(--color-text)" }}>
|
||||
{customPath.split("/").pop()}
|
||||
</p>
|
||||
<p className="text-[11px] truncate" style={{ color: "var(--color-text-muted)" }} title={customPath}>
|
||||
{shortenPath(customPath)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowDirPicker(true)}
|
||||
className="px-2 py-1 text-xs rounded-md transition-colors hover:opacity-80"
|
||||
style={{ color: "var(--color-accent)" }}
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCustomPath("")}
|
||||
className="p-1 rounded-md transition-colors hover:bg-[var(--color-surface-hover)]"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowDirPicker(true)}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-3 rounded-lg text-sm transition-colors hover:opacity-90"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
border: "1px dashed var(--color-border-strong)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z" />
|
||||
</svg>
|
||||
Browse for a directory...
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={customPath}
|
||||
onChange={(e) => setCustomPath(e.target.value)}
|
||||
placeholder="~/my-workspace or /absolute/path"
|
||||
className="w-full mt-2 px-3 py-2 text-sm rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-accent)]"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -304,7 +250,7 @@ export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWork
|
||||
className="text-sm"
|
||||
style={{ color: "var(--color-text-secondary)" }}
|
||||
>
|
||||
Seed bootstrap files and workspace database
|
||||
Seed bootstrap files (AGENTS.md, SOUL.md, USER.md)
|
||||
</span>
|
||||
</label>
|
||||
|
||||
@ -363,13 +309,6 @@ export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWork
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Directory picker modal */}
|
||||
<DirectoryPickerModal
|
||||
open={showDirPicker}
|
||||
onClose={() => setShowDirPicker(false)}
|
||||
onSelect={(path) => setCustomPath(path)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,474 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
type BrowseEntry = {
|
||||
name: string;
|
||||
path: string;
|
||||
type: "folder" | "file" | "document" | "database";
|
||||
};
|
||||
|
||||
type DirectoryPickerModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (path: string) => void;
|
||||
/** Starting directory (absolute). Falls back to the workspace root / home. */
|
||||
startDir?: string;
|
||||
};
|
||||
|
||||
function buildBreadcrumbs(dir: string): { label: string; path: string }[] {
|
||||
const segments: { label: string; path: string }[] = [];
|
||||
const homeMatch = dir.match(/^(\/Users\/[^/]+|\/home\/[^/]+)/);
|
||||
const homeDir = homeMatch?.[1];
|
||||
|
||||
if (homeDir) {
|
||||
segments.push({ label: "~", path: homeDir });
|
||||
const rest = dir.slice(homeDir.length);
|
||||
const parts = rest.split("/").filter(Boolean);
|
||||
let currentPath = homeDir;
|
||||
for (const part of parts) {
|
||||
currentPath += "/" + part;
|
||||
segments.push({ label: part, path: currentPath });
|
||||
}
|
||||
} else if (dir === "/") {
|
||||
segments.push({ label: "/", path: "/" });
|
||||
} else {
|
||||
segments.push({ label: "/", path: "/" });
|
||||
const parts = dir.split("/").filter(Boolean);
|
||||
let currentPath = "";
|
||||
for (const part of parts) {
|
||||
currentPath += "/" + part;
|
||||
segments.push({ label: part, path: currentPath });
|
||||
}
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
const folderColors = { bg: "rgba(245, 158, 11, 0.12)", fg: "#f59e0b" };
|
||||
|
||||
function FolderIcon({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function DirectoryPickerModal({
|
||||
open,
|
||||
onClose,
|
||||
onSelect,
|
||||
startDir,
|
||||
}: DirectoryPickerModalProps) {
|
||||
const [currentDir, setCurrentDir] = useState<string | null>(startDir ?? null);
|
||||
const [displayDir, setDisplayDir] = useState("");
|
||||
const [entries, setEntries] = useState<BrowseEntry[]>([]);
|
||||
const [parentDir, setParentDir] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [creatingFolder, setCreatingFolder] = useState(false);
|
||||
const [newFolderName, setNewFolderName] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [visible, setVisible] = useState(false);
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => setVisible(true)));
|
||||
} else {
|
||||
setVisible(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSearch("");
|
||||
setCreatingFolder(false);
|
||||
setNewFolderName("");
|
||||
setError(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Reset to startDir when reopening
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setCurrentDir(startDir ?? null);
|
||||
}
|
||||
}, [open, startDir]);
|
||||
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const newFolderRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const fetchDir = useCallback(async (dir: string | null) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const url = dir
|
||||
? `/api/workspace/browse?dir=${encodeURIComponent(dir)}`
|
||||
: "/api/workspace/browse";
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {throw new Error("Failed to list directory");}
|
||||
const data = await res.json();
|
||||
setEntries(data.entries || []);
|
||||
setDisplayDir(data.currentDir || "");
|
||||
setParentDir(data.parentDir ?? null);
|
||||
} catch {
|
||||
setError("Could not load this directory");
|
||||
setEntries([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) { void fetchDir(currentDir); }
|
||||
}, [open, currentDir, fetchDir]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {return;}
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {onClose();}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [open, onClose]);
|
||||
|
||||
const navigateInto = useCallback((path: string) => {
|
||||
setCurrentDir(path);
|
||||
setSearch("");
|
||||
setCreatingFolder(false);
|
||||
}, []);
|
||||
|
||||
const handleCreateFolder = useCallback(async () => {
|
||||
if (!newFolderName.trim() || !displayDir) {return;}
|
||||
const folderPath = `${displayDir}/${newFolderName.trim()}`;
|
||||
try {
|
||||
await fetch("/api/workspace/mkdir", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: folderPath }),
|
||||
});
|
||||
setCreatingFolder(false);
|
||||
setNewFolderName("");
|
||||
void fetchDir(currentDir);
|
||||
} catch {
|
||||
setError("Failed to create folder");
|
||||
}
|
||||
}, [newFolderName, displayDir, currentDir, fetchDir]);
|
||||
|
||||
const handleSelectCurrent = useCallback(() => {
|
||||
if (displayDir) {
|
||||
onSelect(displayDir);
|
||||
onClose();
|
||||
}
|
||||
}, [displayDir, onSelect, onClose]);
|
||||
|
||||
// Only show folders
|
||||
const folders = entries
|
||||
.filter((e) => e.type === "folder")
|
||||
.filter((e) => !search || e.name.toLowerCase().includes(search.toLowerCase()))
|
||||
.toSorted((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const breadcrumbs = displayDir ? buildBreadcrumbs(displayDir) : [];
|
||||
|
||||
// Shorten display path for the footer
|
||||
const shortDir = displayDir
|
||||
.replace(/^\/Users\/[^/]+/, "~")
|
||||
.replace(/^\/home\/[^/]+/, "~");
|
||||
|
||||
if (!open) {return null;}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center"
|
||||
style={{
|
||||
opacity: visible ? 1 : 0,
|
||||
transition: "opacity 150ms ease-out",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ background: "rgba(0,0,0,0.4)", backdropFilter: "blur(4px)" }}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="relative flex flex-col rounded-2xl shadow-2xl overflow-hidden w-[calc(100%-2rem)] max-w-[540px]"
|
||||
style={{
|
||||
maxHeight: "70vh",
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
transform: visible ? "scale(1)" : "scale(0.97)",
|
||||
transition: "transform 150ms ease-out",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-5 py-3.5 border-b flex-shrink-0"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center"
|
||||
style={{ background: folderColors.bg, color: folderColors.fg }}
|
||||
>
|
||||
<FolderIcon size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold" style={{ color: "var(--color-text)" }}>
|
||||
Choose Directory
|
||||
</h2>
|
||||
<p className="text-[11px]" style={{ color: "var(--color-text-muted)" }}>
|
||||
Navigate to a folder for the workspace
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center"
|
||||
style={{ color: "var(--color-text-muted)", background: "var(--color-surface-hover)" }}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumbs */}
|
||||
{displayDir && (
|
||||
<div
|
||||
className="flex items-center gap-1 px-5 py-2 border-b overflow-x-auto flex-shrink-0"
|
||||
style={{ borderColor: "var(--color-border)", scrollbarWidth: "thin" }}
|
||||
>
|
||||
{breadcrumbs.map((seg, i) => (
|
||||
<Fragment key={seg.path}>
|
||||
{i > 0 && (
|
||||
<span
|
||||
className="text-[10px] flex-shrink-0"
|
||||
style={{ color: "var(--color-text-muted)", opacity: 0.5 }}
|
||||
>
|
||||
/
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigateInto(seg.path)}
|
||||
className="text-[12px] font-medium flex-shrink-0 rounded px-1 py-0.5 hover:underline"
|
||||
style={{
|
||||
color: i === breadcrumbs.length - 1
|
||||
? "var(--color-text)"
|
||||
: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{seg.label}
|
||||
</button>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search + New Folder */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-4 py-2 border-b flex-shrink-0"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<div
|
||||
className="flex-1 flex items-center gap-2 rounded-lg px-2.5 py-1.5"
|
||||
style={{ background: "var(--color-bg)", border: "1px solid var(--color-border)" }}
|
||||
>
|
||||
<svg
|
||||
width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
||||
style={{ color: "var(--color-text-muted)", flexShrink: 0 }}
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />
|
||||
</svg>
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Filter folders..."
|
||||
className="flex-1 bg-transparent outline-none text-[13px] placeholder:text-[var(--color-text-muted)]"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCreatingFolder(true);
|
||||
setTimeout(() => newFolderRef.current?.focus(), 50);
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-[12px] font-medium whitespace-nowrap"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
background: "var(--color-surface-hover)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M12 5v14" /><path d="M5 12h14" />
|
||||
</svg>
|
||||
New Folder
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Folder list */}
|
||||
<div
|
||||
className="flex-1 overflow-y-auto"
|
||||
style={{ background: "var(--color-bg)", minHeight: 200 }}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div
|
||||
className="w-5 h-5 border-2 rounded-full animate-spin"
|
||||
style={{ borderColor: "var(--color-border)", borderTopColor: "var(--color-accent)" }}
|
||||
/>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center justify-center py-16 text-[13px]" style={{ color: "var(--color-text-muted)" }}>
|
||||
{error}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Go up */}
|
||||
{parentDir && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigateInto(parentDir)}
|
||||
className="w-full flex items-center gap-3 px-4 py-2 text-left hover:bg-[var(--color-surface-hover)] transition-colors"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<div
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: "var(--color-surface-hover)" }}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-[13px] font-medium">..</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* New folder input */}
|
||||
{creatingFolder && (
|
||||
<div className="flex items-center gap-3 px-4 py-2">
|
||||
<div
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: folderColors.bg, color: folderColors.fg }}
|
||||
>
|
||||
<FolderIcon />
|
||||
</div>
|
||||
<input
|
||||
ref={newFolderRef}
|
||||
type="text"
|
||||
value={newFolderName}
|
||||
onChange={(e) => setNewFolderName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {void handleCreateFolder();}
|
||||
if (e.key === "Escape") {
|
||||
setCreatingFolder(false);
|
||||
setNewFolderName("");
|
||||
}
|
||||
}}
|
||||
placeholder="Folder name..."
|
||||
className="flex-1 bg-transparent outline-none text-[13px] placeholder:text-[var(--color-text-muted)] rounded px-2 py-1"
|
||||
style={{
|
||||
color: "var(--color-text)",
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-accent)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Folder entries */}
|
||||
{folders.length === 0 && !parentDir && (
|
||||
<div className="flex items-center justify-center py-16 text-[13px]" style={{ color: "var(--color-text-muted)" }}>
|
||||
No subfolders here
|
||||
</div>
|
||||
)}
|
||||
{folders.map((entry) => (
|
||||
<button
|
||||
key={entry.path}
|
||||
type="button"
|
||||
onClick={() => navigateInto(entry.path)}
|
||||
className="w-full flex items-center gap-3 px-4 py-1.5 group text-left hover:bg-[var(--color-surface-hover)] transition-colors"
|
||||
>
|
||||
<div
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: folderColors.bg, color: folderColors.fg }}
|
||||
>
|
||||
<FolderIcon />
|
||||
</div>
|
||||
<span
|
||||
className="flex-1 text-[13px] font-medium truncate"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
title={entry.path}
|
||||
>
|
||||
{entry.name}
|
||||
</span>
|
||||
<svg
|
||||
width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
||||
className="flex-shrink-0 opacity-0 group-hover:opacity-50 transition-opacity"
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
className="flex items-center justify-between px-5 py-3 border-t flex-shrink-0"
|
||||
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
||||
>
|
||||
<div className="min-w-0 flex-1 mr-3">
|
||||
<p className="text-[11px] truncate" style={{ color: "var(--color-text-muted)" }} title={displayDir}>
|
||||
{shortDir || "Loading..."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-3 py-1.5 rounded-lg text-[13px] font-medium"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
background: "var(--color-surface-hover)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSelectCurrent}
|
||||
disabled={!displayDir}
|
||||
className="px-3 py-1.5 rounded-lg text-[13px] font-medium disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
style={{
|
||||
color: "white",
|
||||
background: displayDir ? "var(--color-accent)" : "var(--color-border-strong)",
|
||||
}}
|
||||
>
|
||||
Select This Folder
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -28,8 +28,6 @@ export type TreeNode = {
|
||||
children?: TreeNode[];
|
||||
/** When true, the node represents a virtual folder/file outside the real workspace (e.g. Skills, Memories). CRUD ops are disabled. */
|
||||
virtual?: boolean;
|
||||
/** True when the entry is a symbolic link / shortcut. */
|
||||
symlink?: boolean;
|
||||
};
|
||||
|
||||
/** Folder names reserved for virtual sections -- cannot be created/renamed to. */
|
||||
@ -83,14 +81,15 @@ function isSystemFile(path: string): boolean {
|
||||
// --- Icons (inline SVG, zero-dep) ---
|
||||
|
||||
function FolderIcon({ open }: { open?: boolean }) {
|
||||
return open ? (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
|
||||
</svg>
|
||||
return (
|
||||
<img
|
||||
src={open ? "/icons/folder-open.png" : "/icons/folder.png"}
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
draggable={false}
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -112,17 +111,13 @@ function KanbanIcon() {
|
||||
|
||||
function DocumentIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /><path d="M10 9H8" /><path d="M16 13H8" /><path d="M16 17H8" />
|
||||
</svg>
|
||||
<img src="/icons/document.png" alt="" width={16} height={16} draggable={false} style={{ flexShrink: 0, filter: "drop-shadow(0 0.5px 1.5px rgba(0,0,0,0.2))" }} />
|
||||
);
|
||||
}
|
||||
|
||||
function FileIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
</svg>
|
||||
<img src="/icons/document.png" alt="" width={16} height={16} draggable={false} style={{ flexShrink: 0, filter: "drop-shadow(0 0.5px 1.5px rgba(0,0,0,0.2))" }} />
|
||||
);
|
||||
}
|
||||
|
||||
@ -158,14 +153,6 @@ function LockBadge() {
|
||||
);
|
||||
}
|
||||
|
||||
function SymlinkBadge() {
|
||||
return (
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.55 }}>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ChevronIcon({ open }: { open: boolean }) {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
||||
@ -558,7 +545,7 @@ function DraggableNode({
|
||||
onCancel={onCancelRename}
|
||||
/>
|
||||
) : (
|
||||
<span className="truncate flex-1">{node.name.replace(/\.md$/, "")}</span>
|
||||
<span className="truncate flex-1">{node.name}</span>
|
||||
)}
|
||||
|
||||
{/* Workspace badge for the workspace root entry point */}
|
||||
@ -576,13 +563,6 @@ function DraggableNode({
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Symlink indicator */}
|
||||
{node.symlink && !compact && (
|
||||
<span className="flex-shrink-0 ml-0.5" title="Symbolic link" style={{ color: "var(--color-text-muted)" }}>
|
||||
<SymlinkBadge />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Type badge for objects */}
|
||||
{node.type === "object" && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full flex-shrink-0"
|
||||
|
||||
@ -1,36 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { read, utils, type WorkBook } from "xlsx";
|
||||
type FileViewerProps = {
|
||||
content: string;
|
||||
filename: string;
|
||||
type: "yaml" | "text";
|
||||
};
|
||||
|
||||
const SPREADSHEET_EXTENSIONS = new Set([
|
||||
"xlsx", "xls", "xlsb", "xlsm", "xltx", "xltm",
|
||||
"ods", "fods",
|
||||
"csv", "tsv",
|
||||
"numbers",
|
||||
]);
|
||||
|
||||
export function isSpreadsheetFile(filename: string): boolean {
|
||||
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
|
||||
return SPREADSHEET_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
type FileViewerProps =
|
||||
| { content: string; filename: string; type: "yaml" | "text" }
|
||||
| { filename: string; type: "spreadsheet"; url: string; content?: never };
|
||||
|
||||
export function FileViewer(props: FileViewerProps) {
|
||||
if (props.type === "spreadsheet") {
|
||||
return <SpreadsheetViewer filename={props.filename} url={props.url} />;
|
||||
}
|
||||
|
||||
const { content, filename, type } = props;
|
||||
export function FileViewer({ content, filename, type }: FileViewerProps) {
|
||||
const lines = content.split("\n");
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-6 py-8">
|
||||
<FileHeader filename={filename} label={type.toUpperCase()} />
|
||||
{/* File header */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-t-lg border border-b-0"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
borderColor: "var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
||||
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
|
||||
{filename}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs px-1.5 py-0.5 rounded ml-auto"
|
||||
style={{
|
||||
background: "var(--color-surface-hover)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{type.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* File content */}
|
||||
<div
|
||||
className="rounded-b-lg border overflow-x-auto"
|
||||
style={{
|
||||
@ -45,6 +62,7 @@ export function FileViewer(props: FileViewerProps) {
|
||||
key={idx}
|
||||
className="flex hover:bg-[var(--color-surface-hover)] transition-colors duration-75"
|
||||
>
|
||||
{/* Line number */}
|
||||
<span
|
||||
className="select-none text-right pr-4 pl-4 flex-shrink-0 tabular-nums"
|
||||
style={{
|
||||
@ -57,6 +75,7 @@ export function FileViewer(props: FileViewerProps) {
|
||||
{idx + 1}
|
||||
</span>
|
||||
|
||||
{/* Line content */}
|
||||
<span
|
||||
className="pr-4 flex-1"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
@ -76,272 +95,6 @@ export function FileViewer(props: FileViewerProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function FileHeader({ filename, label, icon }: { filename: string; label: string; icon?: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-t-lg border border-b-0"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
borderColor: "var(--color-border)",
|
||||
}}
|
||||
>
|
||||
{icon ?? (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
||||
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
</svg>
|
||||
)}
|
||||
<span className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
|
||||
{filename}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs px-1.5 py-0.5 rounded ml-auto"
|
||||
style={{
|
||||
background: "var(--color-surface-hover)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spreadsheet viewer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SpreadsheetViewer({ filename, url }: { filename: string; url: string }) {
|
||||
const [workbook, setWorkbook] = useState<WorkBook | null>(null);
|
||||
const [activeSheet, setActiveSheet] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setWorkbook(null);
|
||||
setActiveSheet(0);
|
||||
setError(null);
|
||||
|
||||
fetch(url)
|
||||
.then((res) => {
|
||||
if (!res.ok) {throw new Error(`Failed to load file (${res.status})`);}
|
||||
return res.arrayBuffer();
|
||||
})
|
||||
.then((buf) => {
|
||||
if (cancelled) {return;}
|
||||
const wb = read(buf, { type: "array" });
|
||||
setWorkbook(wb);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {setError(String(err));}
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [url]);
|
||||
|
||||
const ext = filename.split(".").pop()?.toUpperCase() ?? "SPREADSHEET";
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-6 py-8">
|
||||
<FileHeader filename={filename} label={ext} icon={<SpreadsheetIcon />} />
|
||||
<div
|
||||
className="rounded-b-lg border p-8 text-center"
|
||||
style={{ background: "var(--color-bg)", borderColor: "var(--color-border)", color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Failed to load spreadsheet: {error}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!workbook) {
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-6 py-8">
|
||||
<FileHeader filename={filename} label={ext} icon={<SpreadsheetIcon />} />
|
||||
<div
|
||||
className="rounded-b-lg border p-8 text-center"
|
||||
style={{ background: "var(--color-bg)", borderColor: "var(--color-border)", color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Loading spreadsheet...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sheetNames = workbook.SheetNames;
|
||||
const sheet = workbook.Sheets[sheetNames[activeSheet]];
|
||||
const rows: string[][] = sheet ? utils.sheet_to_json(sheet, { header: 1, defval: "" }) : [];
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-6 py-8">
|
||||
<FileHeader filename={filename} label={ext} icon={<SpreadsheetIcon />} />
|
||||
|
||||
{/* Sheet tabs */}
|
||||
{sheetNames.length > 1 && (
|
||||
<div
|
||||
className="flex gap-0 border-x overflow-x-auto"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
{sheetNames.map((name, idx) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => setActiveSheet(idx)}
|
||||
className="px-4 py-1.5 text-xs font-medium whitespace-nowrap border-b-2 transition-colors"
|
||||
style={{
|
||||
background: idx === activeSheet ? "var(--color-bg)" : "var(--color-surface)",
|
||||
color: idx === activeSheet ? "var(--color-text)" : "var(--color-text-muted)",
|
||||
borderBottomColor: idx === activeSheet ? "var(--color-accent)" : "transparent",
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div
|
||||
className="rounded-b-lg border overflow-auto"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
borderColor: "var(--color-border)",
|
||||
maxHeight: "70vh",
|
||||
}}
|
||||
>
|
||||
{rows.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm" style={{ color: "var(--color-text-muted)" }}>
|
||||
This sheet is empty.
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
{/* Row number header */}
|
||||
<th
|
||||
className="sticky top-0 z-10 px-3 py-2 text-right select-none"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
borderRight: "1px solid var(--color-border)",
|
||||
color: "var(--color-text-muted)",
|
||||
minWidth: "3rem",
|
||||
}}
|
||||
/>
|
||||
{rows[0]?.map((_cell, colIdx) => (
|
||||
<th
|
||||
key={colIdx}
|
||||
className="sticky top-0 z-10 px-3 py-2 text-left font-medium whitespace-nowrap"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
borderRight: "1px solid var(--color-border)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{columnLabel(colIdx)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, rowIdx) => (
|
||||
<tr
|
||||
key={rowIdx}
|
||||
className="hover:bg-[var(--color-surface-hover)] transition-colors duration-75"
|
||||
>
|
||||
<td
|
||||
className="px-3 py-1.5 text-right select-none tabular-nums"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
opacity: 0.5,
|
||||
borderRight: "1px solid var(--color-border)",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
}}
|
||||
>
|
||||
{rowIdx + 1}
|
||||
</td>
|
||||
{row.map((cell, colIdx) => (
|
||||
<td
|
||||
key={colIdx}
|
||||
className="px-3 py-1.5 whitespace-pre-wrap"
|
||||
style={{
|
||||
color: "var(--color-text)",
|
||||
borderRight: "1px solid var(--color-border)",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
maxWidth: "300px",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{String(cell)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mt-2 text-xs text-right"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{rows.length} row{rows.length !== 1 ? "s" : ""}
|
||||
{rows[0] ? ` \u00d7 ${rows[0].length} column${rows[0].length !== 1 ? "s" : ""}` : ""}
|
||||
{sheetNames.length > 1 ? ` \u00b7 ${sheetNames.length} sheets` : ""}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Convert zero-based column index to Excel-style label (A, B, ..., Z, AA, AB, ...) */
|
||||
function columnLabel(idx: number): string {
|
||||
let label = "";
|
||||
let n = idx;
|
||||
do {
|
||||
label = String.fromCharCode(65 + (n % 26)) + label;
|
||||
n = Math.floor(n / 26) - 1;
|
||||
} while (n >= 0);
|
||||
return label;
|
||||
}
|
||||
|
||||
function SpreadsheetIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ color: "#22c55e" }}
|
||||
>
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
||||
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
<path d="M8 13h2" />
|
||||
<path d="M14 13h2" />
|
||||
<path d="M8 17h2" />
|
||||
<path d="M14 17h2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Simple YAML syntax highlighting */
|
||||
function YamlLine({ line }: { line: string }) {
|
||||
// Comment
|
||||
|
||||
@ -1,276 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { createHighlighter, type Highlighter } from "shiki";
|
||||
|
||||
type HtmlViewerProps = {
|
||||
filename: string;
|
||||
/** Raw URL for iframe rendering (served with text/html) */
|
||||
rawUrl: string;
|
||||
/** JSON API URL to fetch source content on demand (for code view) */
|
||||
contentUrl: string;
|
||||
};
|
||||
|
||||
type ViewMode = "rendered" | "code";
|
||||
|
||||
let highlighterPromise: Promise<Highlighter> | null = null;
|
||||
|
||||
function getHighlighter(): Promise<Highlighter> {
|
||||
if (!highlighterPromise) {
|
||||
highlighterPromise = createHighlighter({
|
||||
themes: ["github-dark", "github-light"],
|
||||
langs: ["html"],
|
||||
});
|
||||
}
|
||||
return highlighterPromise;
|
||||
}
|
||||
|
||||
export function HtmlViewer({ filename, rawUrl, contentUrl }: HtmlViewerProps) {
|
||||
const [mode, setMode] = useState<ViewMode>("rendered");
|
||||
const [source, setSource] = useState<string | null>(null);
|
||||
const [sourceLoading, setSourceLoading] = useState(false);
|
||||
|
||||
const handleCodeToggle = useCallback(() => {
|
||||
setMode("code");
|
||||
if (source !== null) {return;}
|
||||
setSourceLoading(true);
|
||||
void fetch(contentUrl)
|
||||
.then((r) => r.json())
|
||||
.then((data: { content: string }) => setSource(data.content))
|
||||
.catch(() => setSource("<!-- Failed to load source -->"))
|
||||
.finally(() => setSourceLoading(false));
|
||||
}, [contentUrl, source]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header bar */}
|
||||
<div
|
||||
className="flex items-center gap-3 px-5 py-3 border-b flex-shrink-0"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<HtmlIcon />
|
||||
<span className="text-sm font-medium truncate" style={{ color: "var(--color-text)" }}>
|
||||
{filename}
|
||||
</span>
|
||||
<span
|
||||
className="text-[10px] px-2 py-0.5 rounded-full flex-shrink-0"
|
||||
style={{
|
||||
background: "#f9731618",
|
||||
color: "#f97316",
|
||||
border: "1px solid #f9731630",
|
||||
}}
|
||||
>
|
||||
HTML
|
||||
</span>
|
||||
|
||||
{/* Mode toggle */}
|
||||
<div
|
||||
className="flex items-center ml-auto rounded-lg p-0.5"
|
||||
style={{ background: "var(--color-surface-hover)" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("rendered")}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium transition-colors duration-100 cursor-pointer"
|
||||
style={{
|
||||
background: mode === "rendered" ? "var(--color-surface)" : "transparent",
|
||||
color: mode === "rendered" ? "var(--color-text)" : "var(--color-text-muted)",
|
||||
boxShadow: mode === "rendered" ? "0 1px 2px rgba(0,0,0,0.1)" : "none",
|
||||
}}
|
||||
>
|
||||
<EyeIcon />
|
||||
Preview
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCodeToggle}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium transition-colors duration-100 cursor-pointer"
|
||||
style={{
|
||||
background: mode === "code" ? "var(--color-surface)" : "transparent",
|
||||
color: mode === "code" ? "var(--color-text)" : "var(--color-text-muted)",
|
||||
boxShadow: mode === "code" ? "0 1px 2px rgba(0,0,0,0.1)" : "none",
|
||||
}}
|
||||
>
|
||||
<CodeIcon />
|
||||
Code
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Open in new tab */}
|
||||
<a
|
||||
href={rawUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-1.5 rounded-md transition-colors duration-100"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title="Open in new tab"
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
<ExternalLinkIcon />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{mode === "rendered" ? (
|
||||
<RenderedView rawUrl={rawUrl} />
|
||||
) : sourceLoading || source === null ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div
|
||||
className="w-6 h-6 border-2 rounded-full animate-spin"
|
||||
style={{
|
||||
borderColor: "var(--color-border)",
|
||||
borderTopColor: "var(--color-accent)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<CodeView content={source} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Rendered HTML view (sandboxed iframe) ---
|
||||
|
||||
function RenderedView({ rawUrl }: { rawUrl: string }) {
|
||||
return (
|
||||
<div className="flex-1 overflow-hidden" style={{ background: "white" }}>
|
||||
<iframe
|
||||
src={rawUrl}
|
||||
className="w-full h-full border-0"
|
||||
sandbox="allow-same-origin allow-scripts allow-popups"
|
||||
title="HTML preview"
|
||||
style={{ minHeight: "calc(100vh - 120px)" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Syntax-highlighted code view ---
|
||||
|
||||
function CodeView({ content }: { content: string }) {
|
||||
const [html, setHtml] = useState<string | null>(null);
|
||||
const lineCount = useMemo(() => content.split("\n").length, [content]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void getHighlighter().then((highlighter) => {
|
||||
if (cancelled) {return;}
|
||||
const result = highlighter.codeToHtml(content, {
|
||||
lang: "html",
|
||||
themes: { dark: "github-dark", light: "github-light" },
|
||||
});
|
||||
setHtml(result);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [content]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-auto" style={{ background: "var(--color-surface)" }}>
|
||||
<div className="max-w-4xl mx-auto px-6 py-8">
|
||||
<div
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-t-lg border border-b-0"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
borderColor: "var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<CodeIcon />
|
||||
<span className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
HTML
|
||||
</span>
|
||||
<span className="text-xs ml-auto" style={{ color: "var(--color-text-muted)" }}>
|
||||
{lineCount} lines
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="code-viewer-content rounded-b-lg border overflow-x-auto"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
borderColor: "var(--color-border)",
|
||||
}}
|
||||
>
|
||||
{html ? (
|
||||
<div
|
||||
className="code-viewer-highlighted"
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: shiki output is trusted
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
) : (
|
||||
<pre className="text-sm leading-6" style={{ margin: 0 }}>
|
||||
<code>
|
||||
{content.split("\n").map((line, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex hover:bg-[var(--color-surface-hover)] transition-colors duration-75"
|
||||
>
|
||||
<span
|
||||
className="select-none text-right pr-4 pl-4 flex-shrink-0 tabular-nums"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
opacity: 0.5,
|
||||
minWidth: "3rem",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{idx + 1}
|
||||
</span>
|
||||
<span className="pr-4 flex-1" style={{ color: "var(--color-text)" }}>
|
||||
{line || " "}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Icons ---
|
||||
|
||||
function HtmlIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#f97316" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="16 18 22 12 16 6" />
|
||||
<polyline points="8 6 2 12 8 18" />
|
||||
<line x1="12" x2="10" y1="2" y2="22" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function EyeIcon() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeIcon() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="16 18 22 12 16 6" />
|
||||
<polyline points="8 6 2 12 8 18" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ExternalLinkIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15 3h6v6" />
|
||||
<path d="M10 14 21 3" />
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@ -10,14 +10,21 @@ export type ProfileInfo = {
|
||||
hasConfig: boolean;
|
||||
};
|
||||
|
||||
export type ProfileSwitcherTriggerProps = {
|
||||
isOpen: boolean;
|
||||
onClick: () => void;
|
||||
activeProfile: string;
|
||||
switching: boolean;
|
||||
};
|
||||
|
||||
type ProfileSwitcherProps = {
|
||||
onProfileSwitch?: () => void;
|
||||
onCreateWorkspace?: () => void;
|
||||
/** Parent-tracked active profile — triggers a re-fetch when it changes (e.g. after workspace creation). */
|
||||
activeProfileHint?: string | null;
|
||||
/** When set, this renders instead of the default button; dropdown still opens below. */
|
||||
trigger?: (props: ProfileSwitcherTriggerProps) => React.ReactNode;
|
||||
};
|
||||
|
||||
export function ProfileSwitcher({ onProfileSwitch, onCreateWorkspace, activeProfileHint }: ProfileSwitcherProps) {
|
||||
export function ProfileSwitcher({ onProfileSwitch, onCreateWorkspace, trigger }: ProfileSwitcherProps) {
|
||||
const [profiles, setProfiles] = useState<ProfileInfo[]>([]);
|
||||
const [activeProfile, setActiveProfile] = useState("default");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@ -37,7 +44,7 @@ export function ProfileSwitcher({ onProfileSwitch, onCreateWorkspace, activeProf
|
||||
|
||||
useEffect(() => {
|
||||
void fetchProfiles();
|
||||
}, [fetchProfiles, activeProfileHint]);
|
||||
}, [fetchProfiles]);
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
@ -80,35 +87,51 @@ export function ProfileSwitcher({ onProfileSwitch, onCreateWorkspace, activeProf
|
||||
|
||||
// Don't show the switcher if there's only one profile and no way to create more
|
||||
const showSwitcher = profiles.length > 0;
|
||||
if (!showSwitcher) {return null;}
|
||||
const handleToggle = () => {
|
||||
if (showSwitcher) { setIsOpen((o) => !o); }
|
||||
};
|
||||
|
||||
if (!trigger && !showSwitcher) { return null; }
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
disabled={switching}
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md text-xs transition-colors hover:bg-[var(--color-surface-hover)] disabled:opacity-50"
|
||||
style={{ color: "var(--color-text-secondary)" }}
|
||||
title="Switch workspace profile"
|
||||
>
|
||||
{/* Workspace icon */}
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
|
||||
</svg>
|
||||
<span className="truncate max-w-[120px]">
|
||||
{activeProfile === "default" ? "Default" : activeProfile}
|
||||
</span>
|
||||
<svg
|
||||
className={`w-3 h-3 transition-transform ${isOpen ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
<div
|
||||
className={`relative ${trigger ? "flex-1 min-w-0" : ""}`}
|
||||
ref={dropdownRef}
|
||||
>
|
||||
{trigger ? (
|
||||
trigger({
|
||||
isOpen,
|
||||
onClick: handleToggle,
|
||||
activeProfile,
|
||||
switching,
|
||||
})
|
||||
) : (
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
disabled={switching}
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md text-xs transition-colors hover:bg-[var(--color-surface-hover)] disabled:opacity-50"
|
||||
style={{ color: "var(--color-text-secondary)" }}
|
||||
title="Switch workspace profile"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{/* Workspace icon */}
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
|
||||
</svg>
|
||||
<span className="truncate max-w-[120px]">
|
||||
{activeProfile === "default" ? "Default" : activeProfile}
|
||||
</span>
|
||||
<svg
|
||||
className={`w-3 h-3 transition-transform ${isOpen ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isOpen && (
|
||||
{showSwitcher && isOpen && (
|
||||
<div
|
||||
className="absolute left-0 top-full mt-1 w-64 rounded-lg overflow-hidden z-50"
|
||||
style={{
|
||||
|
||||
@ -4,6 +4,7 @@ import { useEffect, useState, useRef, useCallback } from "react";
|
||||
import { FileManagerTree, type TreeNode } from "./file-manager-tree";
|
||||
import { ProfileSwitcher } from "./profile-switcher";
|
||||
import { CreateWorkspaceDialog } from "./create-workspace-dialog";
|
||||
import { UnicodeSpinner } from "../unicode-spinner";
|
||||
|
||||
/** Shape returned by /api/workspace/suggest-files */
|
||||
type SuggestItem = {
|
||||
@ -41,12 +42,12 @@ type WorkspaceSidebarProps = {
|
||||
onClose?: () => void;
|
||||
/** Active workspace profile name (null = default). */
|
||||
activeProfile?: string | null;
|
||||
/** Fixed width in px when not mobile (overrides default 260). */
|
||||
width?: number;
|
||||
/** Called after the user switches to a different profile. */
|
||||
onProfileSwitch?: () => void;
|
||||
/** Whether hidden (dot) files/folders are currently shown. */
|
||||
showHidden?: boolean;
|
||||
/** Toggle hidden files visibility. */
|
||||
onToggleHidden?: () => void;
|
||||
/** Called when the user clicks the collapse/hide sidebar button. */
|
||||
onCollapse?: () => void;
|
||||
};
|
||||
|
||||
function HomeIcon() {
|
||||
@ -69,18 +70,14 @@ function HomeIcon() {
|
||||
|
||||
function FolderOpenIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
<img
|
||||
src="/icons/folder-open.png"
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
draggable={false}
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -174,25 +171,26 @@ function SearchIcon() {
|
||||
|
||||
function SmallFolderIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
|
||||
</svg>
|
||||
<img
|
||||
src="/icons/folder.png"
|
||||
alt=""
|
||||
width={14}
|
||||
height={14}
|
||||
draggable={false}
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SmallFileIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
</svg>
|
||||
<img src="/icons/document.png" alt="" width={14} height={14} draggable={false} style={{ flexShrink: 0, filter: "drop-shadow(0 0.5px 1.5px rgba(0,0,0,0.2))" }} />
|
||||
);
|
||||
}
|
||||
|
||||
function SmallDocIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /><path d="M10 9H8" /><path d="M16 13H8" /><path d="M16 17H8" />
|
||||
</svg>
|
||||
<img src="/icons/document.png" alt="" width={14} height={14} draggable={false} style={{ flexShrink: 0, filter: "drop-shadow(0 0.5px 1.5px rgba(0,0,0,0.2))" }} />
|
||||
);
|
||||
}
|
||||
|
||||
@ -321,9 +319,10 @@ function FileSearch({ onSelect }: { onSelect: (item: SuggestItem) => void }) {
|
||||
/>
|
||||
{loading && (
|
||||
<span className="absolute right-2.5 top-1/2 -translate-y-1/2">
|
||||
<div
|
||||
className="w-3 h-3 border border-t-transparent rounded-full animate-spin"
|
||||
style={{ borderColor: "var(--color-text-muted)" }}
|
||||
<UnicodeSpinner
|
||||
name="braille"
|
||||
className="text-sm"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
@ -405,24 +404,26 @@ export function WorkspaceSidebar({
|
||||
onClose,
|
||||
activeProfile,
|
||||
onProfileSwitch,
|
||||
showHidden,
|
||||
onToggleHidden,
|
||||
width: widthProp,
|
||||
onCollapse,
|
||||
}: WorkspaceSidebarProps) {
|
||||
const isBrowsing = browseDir != null;
|
||||
const [showCreateWorkspace, setShowCreateWorkspace] = useState(false);
|
||||
const width = mobile ? "280px" : (widthProp ?? 260);
|
||||
|
||||
const sidebar = (
|
||||
<aside
|
||||
className={`flex flex-col h-screen flex-shrink-0 ${mobile ? "drawer-left" : "border-r"}`}
|
||||
className={`flex flex-col h-screen shrink-0 ${mobile ? "drawer-left" : "border-r"}`}
|
||||
style={{
|
||||
width: mobile ? "280px" : "260px",
|
||||
background: "var(--color-surface)",
|
||||
width: typeof width === "number" ? `${width}px` : width,
|
||||
minWidth: typeof width === "number" ? `${width}px` : width,
|
||||
background: "var(--color-sidebar-bg)",
|
||||
borderColor: "var(--color-border)",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center gap-2.5 px-4 py-3 border-b"
|
||||
className="flex items-center gap-2 px-3 py-2.5 border-b"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
{isBrowsing ? (
|
||||
@ -472,65 +473,77 @@ export function WorkspaceSidebar({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onGoToChat?.()}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 cursor-pointer transition-opacity"
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center shrink-0 cursor-pointer transition-colors hover:bg-stone-200 dark:hover:bg-stone-700"
|
||||
style={{
|
||||
background: "var(--color-accent-light)",
|
||||
color: "var(--color-accent)",
|
||||
background: "transparent",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
title="All Chats"
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.opacity = "0.7"; }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.opacity = "1"; }}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
<polyline points="9 22 9 12 15 12 15 22" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className="text-sm font-medium truncate"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{orgName || "Workspace"}
|
||||
</div>
|
||||
<div
|
||||
className="text-[11px] flex items-center gap-1"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
<span>Ironclaw</span>
|
||||
{activeProfile && activeProfile !== "default" && (
|
||||
<span
|
||||
className="px-1 py-0.5 rounded text-[10px]"
|
||||
style={{
|
||||
background: "var(--color-accent-light)",
|
||||
color: "var(--color-accent)",
|
||||
}}
|
||||
<ProfileSwitcher
|
||||
onProfileSwitch={onProfileSwitch}
|
||||
onCreateWorkspace={() => setShowCreateWorkspace(true)}
|
||||
trigger={({ isOpen, onClick, activeProfile: profileName, switching }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={switching}
|
||||
className="flex-1 min-w-0 w-full flex items-center justify-between gap-1.5 text-left rounded-lg py-1 px-1.5 transition-colors hover:bg-stone-100 dark:hover:bg-stone-800 disabled:opacity-50"
|
||||
title="Switch workspace profile"
|
||||
>
|
||||
<div className="min-w-0 truncate">
|
||||
<div
|
||||
className="text-[13px] font-semibold truncate text-stone-700 dark:text-stone-200"
|
||||
>
|
||||
{orgName || "Workspace"}
|
||||
</div>
|
||||
<div
|
||||
className="text-[11px] flex items-center gap-1 truncate text-stone-400 dark:text-stone-500"
|
||||
>
|
||||
<span>Ironclaw</span>
|
||||
{profileName && profileName !== "default" && (
|
||||
<span
|
||||
className="px-1 py-0.5 rounded text-[10px] shrink-0 bg-stone-200 text-stone-500 dark:bg-stone-700 dark:text-stone-400"
|
||||
>
|
||||
{profileName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
className={`w-3 h-3 shrink-0 transition-transform text-stone-400 ${isOpen ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
{activeProfile}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{onCollapse && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCollapse}
|
||||
className="p-1 rounded-md shrink-0 transition-colors hover:bg-stone-200 dark:hover:bg-stone-700"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title="Hide sidebar (⌘B)"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
<path d="M9 3v18" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Profile switcher — only in workspace mode */}
|
||||
{!isBrowsing && (
|
||||
<div
|
||||
className="px-3 py-1.5 border-b"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<ProfileSwitcher
|
||||
onProfileSwitch={onProfileSwitch}
|
||||
onCreateWorkspace={() => setShowCreateWorkspace(true)}
|
||||
activeProfileHint={activeProfile}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create workspace dialog */}
|
||||
<CreateWorkspaceDialog
|
||||
isOpen={showCreateWorkspace}
|
||||
@ -547,13 +560,10 @@ export function WorkspaceSidebar({
|
||||
<div className="flex-1 overflow-y-auto px-1">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div
|
||||
className="w-5 h-5 border-2 rounded-full animate-spin"
|
||||
style={{
|
||||
borderColor: "var(--color-border)",
|
||||
borderTopColor:
|
||||
"var(--color-accent)",
|
||||
}}
|
||||
<UnicodeSpinner
|
||||
name="braille"
|
||||
className="text-2xl"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
@ -585,43 +595,7 @@ export function WorkspaceSidebar({
|
||||
>
|
||||
ironclaw.sh
|
||||
</a>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{onToggleHidden && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleHidden}
|
||||
className="p-1.5 rounded-lg transition-colors"
|
||||
style={{ color: showHidden ? "var(--color-accent)" : "var(--color-text-muted)" }}
|
||||
title={showHidden ? "Hide dotfiles" : "Show dotfiles"}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{showHidden ? (
|
||||
<>
|
||||
<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49" />
|
||||
<path d="M14.084 14.158a3 3 0 0 1-4.242-4.242" />
|
||||
<path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143" />
|
||||
<path d="m2 2 20 20" />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@ -8,6 +8,8 @@
|
||||
/* Background / Surface */
|
||||
--color-bg: #f5f5f4;
|
||||
--color-surface: #ffffff;
|
||||
--color-sidebar-bg: #ffffff;
|
||||
--color-main-bg: rgba(250, 250, 249, 0.5);
|
||||
--color-surface-hover: #f5f4f1;
|
||||
--color-surface-raised: #ffffff;
|
||||
|
||||
@ -29,6 +31,10 @@
|
||||
--color-user-bubble: #eae8e4;
|
||||
--color-user-bubble-text: #1c1c1a;
|
||||
--color-chat-input-bg: rgba(255, 255, 255, 0.8);
|
||||
/* Chat sidebar (right) — stone-style selected/active, light theme */
|
||||
--color-chat-sidebar-active-bg: #f5f5f4;
|
||||
--color-chat-sidebar-active-text: #44403c;
|
||||
--color-chat-sidebar-muted: #57534e;
|
||||
|
||||
/* Semantic */
|
||||
--color-success: #16a34a;
|
||||
@ -39,7 +45,7 @@
|
||||
/* Glassmorphism */
|
||||
--color-glass: rgba(255, 255, 255, 0.72);
|
||||
--color-glass-border: rgba(255, 255, 255, 0.85);
|
||||
--color-bg-glass: rgba(245, 245, 244, 0.8);
|
||||
--color-bg-glass: rgba(250, 250, 249, 0.8);
|
||||
|
||||
/* Object type chips */
|
||||
--color-chip-object: rgba(37, 99, 235, 0.08);
|
||||
@ -70,6 +76,8 @@
|
||||
/* Background / Surface */
|
||||
--color-bg: #0c0c0b;
|
||||
--color-surface: #161615;
|
||||
--color-sidebar-bg: #141413;
|
||||
--color-main-bg: #161615;
|
||||
--color-surface-hover: #1e1e1c;
|
||||
--color-surface-raised: #1a1a18;
|
||||
|
||||
@ -91,6 +99,10 @@
|
||||
--color-user-bubble: #1e1e1c;
|
||||
--color-user-bubble-text: #ececea;
|
||||
--color-chat-input-bg: #1e1e1c;
|
||||
/* Chat sidebar (right) — stone-style selected/active, dark theme */
|
||||
--color-chat-sidebar-active-bg: #1e1e1c;
|
||||
--color-chat-sidebar-active-text: #ececea;
|
||||
--color-chat-sidebar-muted: #78776f;
|
||||
|
||||
/* Semantic */
|
||||
--color-success: #22c55e;
|
||||
@ -101,7 +113,7 @@
|
||||
/* Glassmorphism */
|
||||
--color-glass: rgba(22, 22, 21, 0.72);
|
||||
--color-glass-border: rgba(255, 255, 255, 0.06);
|
||||
--color-bg-glass: rgba(12, 12, 11, 0.8);
|
||||
--color-bg-glass: rgba(22, 22, 21, 0.8);
|
||||
|
||||
/* Object type chips */
|
||||
--color-chip-object: rgba(59, 130, 246, 0.12);
|
||||
@ -128,6 +140,10 @@
|
||||
--shadow-xl: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* Disable iframe pointer events during sidebar resize so the
|
||||
drag isn't swallowed by embedded content (e.g. PDF viewer). */
|
||||
body.resizing iframe { pointer-events: none; }
|
||||
|
||||
/* ============================================================
|
||||
Fonts — Bookerly (local)
|
||||
============================================================ */
|
||||
@ -192,6 +208,11 @@ body {
|
||||
font-family: "Bookerly", Georgia, "Times New Roman", serif;
|
||||
}
|
||||
|
||||
/* Message bubbles and assistant text: use Bookerly for a polished reading experience. */
|
||||
.chat-message-font {
|
||||
font-family: "Bookerly", Georgia, "Times New Roman", serif;
|
||||
}
|
||||
|
||||
/* Smooth theme transitions */
|
||||
*,
|
||||
*::before,
|
||||
@ -217,6 +238,21 @@ a,
|
||||
Scrollbar
|
||||
============================================================ */
|
||||
|
||||
/* Base UI menu — remove default focus outlines */
|
||||
[data-slot="dropdown-menu-content"],
|
||||
[data-slot="dropdown-menu-item"],
|
||||
[data-slot="dropdown-menu-sub-content"],
|
||||
[data-slot="dropdown-menu-checkbox-item"],
|
||||
[data-slot="dropdown-menu-radio-item"],
|
||||
[data-slot="dropdown-menu-sub-trigger"] {
|
||||
outline: none !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
[data-slot="dropdown-menu-content"] {
|
||||
border: 1px solid white !important;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
@ -865,6 +901,7 @@ a,
|
||||
line-height: 1.8;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
font-family: "Bookerly", Georgia, "Times New Roman", serif;
|
||||
}
|
||||
|
||||
.chat-prose > *:first-child {
|
||||
@ -889,15 +926,15 @@ a,
|
||||
}
|
||||
|
||||
.chat-prose h1 {
|
||||
font-family: "Instrument Serif", serif;
|
||||
font-family: inherit;
|
||||
font-size: 1.6em;
|
||||
font-weight: 400;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chat-prose h2 {
|
||||
font-family: "Instrument Serif", serif;
|
||||
font-family: inherit;
|
||||
font-size: 1.35em;
|
||||
font-weight: 400;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chat-prose h3 {
|
||||
|
||||
@ -9,8 +9,6 @@ export type TreeNode = {
|
||||
icon?: string;
|
||||
defaultView?: "table" | "kanban";
|
||||
children?: TreeNode[];
|
||||
/** True when the entry is a symbolic link. */
|
||||
symlink?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -32,9 +30,6 @@ export function useWorkspaceWatcher() {
|
||||
const [openclawDir, setOpenclawDir] = useState<string | null>(null);
|
||||
const [activeProfile, setActiveProfile] = useState<string | null>(null);
|
||||
|
||||
// Show hidden (dot) files/folders
|
||||
const [showHidden, setShowHidden] = useState(false);
|
||||
|
||||
const mountedRef = useRef(true);
|
||||
const retryDelayRef = useRef(1000);
|
||||
// Version counter: prevents stale fetch responses from overwriting newer data.
|
||||
@ -49,8 +44,7 @@ export function useWorkspaceWatcher() {
|
||||
const fetchWorkspaceTree = useCallback(async () => {
|
||||
const version = ++fetchVersionRef.current;
|
||||
try {
|
||||
const qs = showHidden ? "?showHidden=1" : "";
|
||||
const res = await fetch(`/api/workspace/tree${qs}`);
|
||||
const res = await fetch("/api/workspace/tree");
|
||||
const data = await res.json();
|
||||
if (mountedRef.current && fetchVersionRef.current === version) {
|
||||
setTree(data.tree ?? []);
|
||||
@ -63,15 +57,14 @@ export function useWorkspaceWatcher() {
|
||||
} catch {
|
||||
if (mountedRef.current && fetchVersionRef.current === version) {setLoading(false);}
|
||||
}
|
||||
}, [showHidden]);
|
||||
}, []);
|
||||
|
||||
// Fetch a directory listing from the browse API
|
||||
const fetchBrowseTree = useCallback(async (dir: string) => {
|
||||
const version = ++fetchVersionRef.current;
|
||||
try {
|
||||
setLoading(true);
|
||||
const hiddenQs = showHidden ? "&showHidden=1" : "";
|
||||
const res = await fetch(`/api/workspace/browse?dir=${encodeURIComponent(dir)}${hiddenQs}`);
|
||||
const res = await fetch(`/api/workspace/browse?dir=${encodeURIComponent(dir)}`);
|
||||
const data = await res.json();
|
||||
if (mountedRef.current && fetchVersionRef.current === version) {
|
||||
setTree(data.entries ?? []);
|
||||
@ -82,7 +75,7 @@ export function useWorkspaceWatcher() {
|
||||
} catch {
|
||||
if (mountedRef.current && fetchVersionRef.current === version) {setLoading(false);}
|
||||
}
|
||||
}, [showHidden]);
|
||||
}, []);
|
||||
|
||||
// Smart setBrowseDir: auto-return to workspace mode when navigating to the
|
||||
// workspace root, so all virtual folders (Chats, Cron, etc.) and DuckDB
|
||||
@ -218,5 +211,5 @@ export function useWorkspaceWatcher() {
|
||||
};
|
||||
}, [browseDirRaw, fetchWorkspaceTree, sseReconnectKey]);
|
||||
|
||||
return { tree, loading, exists, refresh, reconnect, browseDir, setBrowseDir, parentDir, workspaceRoot, openclawDir, activeProfile, showHidden, setShowHidden };
|
||||
return { tree, loading, exists, refresh, reconnect, browseDir, setBrowseDir, parentDir, workspaceRoot, openclawDir, activeProfile };
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -384,35 +384,12 @@ function updateIndex(
|
||||
) {
|
||||
try {
|
||||
const idxPath = indexFile();
|
||||
let index: Array<Record<string, unknown>>;
|
||||
if (!existsSync(idxPath)) {
|
||||
// Auto-create index with a bootstrap entry for this session so
|
||||
// orphaned .jsonl files become visible in the sidebar.
|
||||
index = [{
|
||||
id: sessionId,
|
||||
title: opts.title || "New Chat",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
messageCount: opts.incrementCount || 0,
|
||||
}];
|
||||
writeFileSync(idxPath, JSON.stringify(index, null, 2));
|
||||
return;
|
||||
}
|
||||
index = JSON.parse(
|
||||
if (!existsSync(idxPath)) {return;}
|
||||
const index = JSON.parse(
|
||||
readFileSync(idxPath, "utf-8"),
|
||||
) as Array<Record<string, unknown>>;
|
||||
let session = index.find((s) => s.id === sessionId);
|
||||
if (!session) {
|
||||
// Session file exists but wasn't indexed — add it.
|
||||
session = {
|
||||
id: sessionId,
|
||||
title: opts.title || "New Chat",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
messageCount: 0,
|
||||
};
|
||||
index.unshift(session);
|
||||
}
|
||||
const session = index.find((s) => s.id === sessionId);
|
||||
if (!session) {return;}
|
||||
session.updatedAt = Date.now();
|
||||
if (opts.incrementCount) {
|
||||
session.messageCount =
|
||||
|
||||
6
apps/web/lib/utils.ts
Normal file
6
apps/web/lib/utils.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@ -19,11 +19,7 @@ const UI_STATE_FILENAME = ".ironclaw-ui-state.json";
|
||||
/** In-memory override; takes precedence over the persisted file. */
|
||||
let _uiActiveProfile: string | null | undefined;
|
||||
|
||||
type UIState = {
|
||||
activeProfile?: string | null;
|
||||
/** Maps profile names to absolute workspace paths for workspaces outside ~/.openclaw/. */
|
||||
workspaceRegistry?: Record<string, string>;
|
||||
};
|
||||
type UIState = { activeProfile?: string | null };
|
||||
|
||||
function uiStatePath(): string {
|
||||
const home = process.env.OPENCLAW_HOME?.trim() || homedir();
|
||||
@ -59,8 +55,7 @@ export function getEffectiveProfile(): string | null {
|
||||
export function setUIActiveProfile(profile: string | null): void {
|
||||
const normalized = profile?.trim() || null;
|
||||
_uiActiveProfile = normalized;
|
||||
const existing = readUIState();
|
||||
writeUIState({ ...existing, activeProfile: normalized });
|
||||
writeUIState({ activeProfile: normalized });
|
||||
}
|
||||
|
||||
/** Reset the in-memory override (re-reads from file on next call). */
|
||||
@ -68,29 +63,6 @@ export function clearUIActiveProfileCache(): void {
|
||||
_uiActiveProfile = undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workspace registry — remembers workspaces created outside ~/.openclaw/.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Read the full workspace registry (profile → absolute path). */
|
||||
export function getWorkspaceRegistry(): Record<string, string> {
|
||||
return readUIState().workspaceRegistry ?? {};
|
||||
}
|
||||
|
||||
/** Look up a single profile's registered workspace path. */
|
||||
export function getRegisteredWorkspacePath(profile: string | null): string | null {
|
||||
if (!profile) {return null;}
|
||||
return getWorkspaceRegistry()[profile] ?? null;
|
||||
}
|
||||
|
||||
/** Persist a profile → workspace-path mapping in the registry. */
|
||||
export function registerWorkspacePath(profile: string, absolutePath: string): void {
|
||||
const state = readUIState();
|
||||
const registry = state.workspaceRegistry ?? {};
|
||||
registry[profile] = absolutePath;
|
||||
writeUIState({ ...state, workspaceRegistry: registry });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profile discovery — scans the filesystem for all profiles/workspaces.
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -151,26 +123,6 @@ export function discoverProfiles(): DiscoveredProfile[] {
|
||||
}
|
||||
}
|
||||
|
||||
// Merge workspaces registered via custom paths (outside ~/.openclaw/)
|
||||
const registry = getWorkspaceRegistry();
|
||||
for (const [profileName, wsPath] of Object.entries(registry)) {
|
||||
if (seen.has(profileName)) {
|
||||
const existing = profiles.find((p) => p.name === profileName);
|
||||
if (existing && !existing.workspaceDir && existsSync(wsPath)) {
|
||||
existing.workspaceDir = wsPath;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
seen.add(profileName);
|
||||
profiles.push({
|
||||
name: profileName,
|
||||
stateDir: baseStateDir,
|
||||
workspaceDir: existsSync(wsPath) ? wsPath : null,
|
||||
isActive: activeProfile === profileName,
|
||||
hasConfig: existsSync(join(baseStateDir, "openclaw.json")),
|
||||
});
|
||||
}
|
||||
|
||||
return profiles;
|
||||
}
|
||||
|
||||
@ -221,10 +173,8 @@ export function resolveWebChatDir(): string {
|
||||
export function resolveWorkspaceRoot(): string | null {
|
||||
const stateDir = resolveOpenClawStateDir();
|
||||
const profile = getEffectiveProfile();
|
||||
const registryPath = getRegisteredWorkspacePath(profile);
|
||||
const candidates = [
|
||||
process.env.OPENCLAW_WORKSPACE,
|
||||
registryPath,
|
||||
profile && profile.toLowerCase() !== "default"
|
||||
? join(stateDir, `workspace-${profile}`)
|
||||
: null,
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import type { NextConfig } from "next";
|
||||
import path from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// Produce a self-contained standalone build so npm global installs
|
||||
@ -18,23 +17,6 @@ const nextConfig: NextConfig = {
|
||||
|
||||
// Transpile ESM-only packages so webpack can bundle them
|
||||
transpilePackages: ["react-markdown", "remark-gfm"],
|
||||
|
||||
webpack: (config, { dev }) => {
|
||||
if (dev) {
|
||||
config.watchOptions = {
|
||||
...config.watchOptions,
|
||||
ignored: [
|
||||
"**/node_modules/**",
|
||||
"**/.git/**",
|
||||
"**/dist/**",
|
||||
"**/.next/**",
|
||||
path.join(homedir(), ".openclaw", "**"),
|
||||
],
|
||||
poll: 1500,
|
||||
};
|
||||
}
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^3.0.75",
|
||||
"@base-ui/react": "^1.2.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@ -32,8 +33,10 @@
|
||||
"@tiptap/starter-kit": "^3.19.0",
|
||||
"@tiptap/suggestion": "^3.19.0",
|
||||
"ai": "^6.0.73",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.34.0",
|
||||
"fuse.js": "^7.1.0",
|
||||
"lucide-react": "^0.575.0",
|
||||
"next": "^15.3.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.1.0",
|
||||
@ -43,7 +46,8 @@
|
||||
"recharts": "^3.7.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shiki": "^3.22.0",
|
||||
"xlsx": "^0.18.5"
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"unicode-animations": "^1.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.8",
|
||||
|
||||
BIN
apps/web/public/icons/document.png
Normal file
BIN
apps/web/public/icons/document.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
BIN
apps/web/public/icons/folder-open.png
Normal file
BIN
apps/web/public/icons/folder-open.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
BIN
apps/web/public/icons/folder.png
Normal file
BIN
apps/web/public/icons/folder.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
174
pnpm-lock.yaml
generated
174
pnpm-lock.yaml
generated
@ -296,6 +296,9 @@ importers:
|
||||
'@ai-sdk/react':
|
||||
specifier: ^3.0.75
|
||||
version: 3.0.88(react@19.2.4)(zod@4.3.6)
|
||||
'@base-ui/react':
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@dnd-kit/core':
|
||||
specifier: ^6.3.1
|
||||
version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@ -359,12 +362,18 @@ importers:
|
||||
ai:
|
||||
specifier: ^6.0.73
|
||||
version: 6.0.86(zod@4.3.6)
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
framer-motion:
|
||||
specifier: ^12.34.0
|
||||
version: 12.34.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
fuse.js:
|
||||
specifier: ^7.1.0
|
||||
version: 7.1.0
|
||||
lucide-react:
|
||||
specifier: ^0.575.0
|
||||
version: 0.575.0(react@19.2.4)
|
||||
next:
|
||||
specifier: ^15.3.3
|
||||
version: 15.5.12(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@ -392,9 +401,12 @@ importers:
|
||||
shiki:
|
||||
specifier: ^3.22.0
|
||||
version: 3.22.0
|
||||
xlsx:
|
||||
specifier: ^0.18.5
|
||||
version: 0.18.5
|
||||
tailwind-merge:
|
||||
specifier: ^3.5.0
|
||||
version: 3.5.0
|
||||
unicode-animations:
|
||||
specifier: ^1.0.3
|
||||
version: 1.0.3
|
||||
devDependencies:
|
||||
'@tailwindcss/postcss':
|
||||
specifier: ^4.1.8
|
||||
@ -1100,6 +1112,27 @@ packages:
|
||||
resolution: {integrity: sha512-ubmJ6TShyaD69VE9DQrlXcdkvJbmwWPB8qYj0H2kaJi29O7vJT9ajSdBd2W8CG34pwL9pYA74fi7RHC1qbLoVQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
|
||||
'@base-ui/react@1.2.0':
|
||||
resolution: {integrity: sha512-O6aEQHcm+QyGTFY28xuwRD3SEJGZOBDpyjN2WvpfWYFVhg+3zfXPysAILqtM0C1kWC82MccOE/v1j+GHXE4qIw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
peerDependencies:
|
||||
'@types/react': ^17 || ^18 || ^19
|
||||
react: ^17 || ^18 || ^19
|
||||
react-dom: ^17 || ^18 || ^19
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@base-ui/utils@0.2.5':
|
||||
resolution: {integrity: sha512-oYC7w0gp76RI5MxprlGLV0wze0SErZaRl3AAkeP3OnNB/UBMb6RqNf6ZSIlxOc9Qp68Ab3C2VOcJQyRs7Xc7Vw==}
|
||||
peerDependencies:
|
||||
'@types/react': ^17 || ^18 || ^19
|
||||
react: ^17 || ^18 || ^19
|
||||
react-dom: ^17 || ^18 || ^19
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2':
|
||||
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
||||
engines: {node: '>=18'}
|
||||
@ -1359,6 +1392,12 @@ packages:
|
||||
'@floating-ui/dom@1.7.5':
|
||||
resolution: {integrity: sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==}
|
||||
|
||||
'@floating-ui/react-dom@2.1.7':
|
||||
resolution: {integrity: sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
|
||||
'@floating-ui/utils@0.2.10':
|
||||
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
|
||||
|
||||
@ -3916,10 +3955,6 @@ packages:
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
adler-32@1.3.1:
|
||||
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
agent-base@7.1.4:
|
||||
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
||||
engines: {node: '>= 14'}
|
||||
@ -4147,10 +4182,6 @@ packages:
|
||||
ccount@2.0.1:
|
||||
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
|
||||
|
||||
cfb@1.2.2:
|
||||
resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
chai@6.2.2:
|
||||
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
||||
engines: {node: '>=18'}
|
||||
@ -4229,10 +4260,6 @@ packages:
|
||||
engines: {node: '>= 14.15.0'}
|
||||
hasBin: true
|
||||
|
||||
codepage@1.15.0:
|
||||
resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
color-convert@2.0.1:
|
||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||
engines: {node: '>=7.0.0'}
|
||||
@ -4299,11 +4326,6 @@ packages:
|
||||
core-util-is@1.0.3:
|
||||
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||
|
||||
crc-32@1.2.2:
|
||||
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
|
||||
engines: {node: '>=0.8'}
|
||||
hasBin: true
|
||||
|
||||
crelt@1.0.6:
|
||||
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
|
||||
|
||||
@ -4731,10 +4753,6 @@ packages:
|
||||
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
frac@1.1.2:
|
||||
resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
framer-motion@12.34.0:
|
||||
resolution: {integrity: sha512-+/H49owhzkzQyxtn7nZeF4kdH++I2FWrESQ184Zbcw5cEqNHYkE5yxWxcTLSj5lNx3NWdbIRy5FHqUvetD8FWg==}
|
||||
peerDependencies:
|
||||
@ -5385,6 +5403,11 @@ packages:
|
||||
lru-memoizer@2.3.0:
|
||||
resolution: {integrity: sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==}
|
||||
|
||||
lucide-react@0.575.0:
|
||||
resolution: {integrity: sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg==}
|
||||
peerDependencies:
|
||||
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
@ -6565,10 +6588,6 @@ packages:
|
||||
sqlite-vec@0.1.7-alpha.2:
|
||||
resolution: {integrity: sha512-rNgRCv+4V4Ed3yc33Qr+nNmjhtrMnnHzXfLVPeGb28Dx5mmDL3Ngw/Wk8vhCGjj76+oC6gnkmMG8y73BZWGBwQ==}
|
||||
|
||||
ssf@0.11.2:
|
||||
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
sshpk@1.18.0:
|
||||
resolution: {integrity: sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@ -6671,10 +6690,16 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
tabbable@6.4.0:
|
||||
resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==}
|
||||
|
||||
table-layout@4.1.1:
|
||||
resolution: {integrity: sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==}
|
||||
engines: {node: '>=12.17'}
|
||||
|
||||
tailwind-merge@3.5.0:
|
||||
resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==}
|
||||
|
||||
tailwindcss@4.1.18:
|
||||
resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==}
|
||||
|
||||
@ -6856,6 +6881,10 @@ packages:
|
||||
resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
|
||||
unicode-animations@1.0.3:
|
||||
resolution: {integrity: sha512-+klB2oWwcYZjYWhwP4Pr8UZffWDFVx6jKeIahE6z0QYyM2dwDeDPyn5nevCYbyotxvtT9lh21cVURO1RX0+YMg==}
|
||||
hasBin: true
|
||||
|
||||
unified@11.0.5:
|
||||
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
|
||||
|
||||
@ -7068,14 +7097,6 @@ packages:
|
||||
win-guid@0.2.1:
|
||||
resolution: {integrity: sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A==}
|
||||
|
||||
wmf@1.0.2:
|
||||
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
word@0.3.0:
|
||||
resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
wordwrapjs@5.1.1:
|
||||
resolution: {integrity: sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==}
|
||||
engines: {node: '>=12.17'}
|
||||
@ -7103,11 +7124,6 @@ packages:
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
xlsx@0.18.5:
|
||||
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
|
||||
engines: {node: '>=0.8'}
|
||||
hasBin: true
|
||||
|
||||
y18n@5.0.8:
|
||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
||||
engines: {node: '>=10'}
|
||||
@ -7871,6 +7887,30 @@ snapshots:
|
||||
'@babel/helper-string-parser': 8.0.0-rc.2
|
||||
'@babel/helper-validator-identifier': 8.0.0-rc.1
|
||||
|
||||
'@base-ui/react@1.2.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.6
|
||||
'@base-ui/utils': 0.2.5(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@floating-ui/react-dom': 2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@floating-ui/utils': 0.2.10
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
tabbable: 6.4.0
|
||||
use-sync-external-store: 1.6.0(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@base-ui/utils@0.2.5(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.6
|
||||
'@floating-ui/utils': 0.2.10
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
reselect: 5.1.1
|
||||
use-sync-external-store: 1.6.0(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2': {}
|
||||
|
||||
'@borewit/text-codec@0.2.1': {}
|
||||
@ -8113,16 +8153,19 @@ snapshots:
|
||||
'@floating-ui/core@1.7.4':
|
||||
dependencies:
|
||||
'@floating-ui/utils': 0.2.10
|
||||
optional: true
|
||||
|
||||
'@floating-ui/dom@1.7.5':
|
||||
dependencies:
|
||||
'@floating-ui/core': 1.7.4
|
||||
'@floating-ui/utils': 0.2.10
|
||||
optional: true
|
||||
|
||||
'@floating-ui/utils@0.2.10':
|
||||
optional: true
|
||||
'@floating-ui/react-dom@2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.7.5
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
'@floating-ui/utils@0.2.10': {}
|
||||
|
||||
'@google/genai@1.41.0':
|
||||
dependencies:
|
||||
@ -10749,8 +10792,6 @@ snapshots:
|
||||
|
||||
acorn@8.15.0: {}
|
||||
|
||||
adler-32@1.3.1: {}
|
||||
|
||||
agent-base@7.1.4: {}
|
||||
|
||||
ai@6.0.86(zod@4.3.6):
|
||||
@ -10986,11 +11027,6 @@ snapshots:
|
||||
|
||||
ccount@2.0.1: {}
|
||||
|
||||
cfb@1.2.2:
|
||||
dependencies:
|
||||
adler-32: 1.3.1
|
||||
crc-32: 1.2.2
|
||||
|
||||
chai@6.2.2: {}
|
||||
|
||||
chalk-template@0.4.0:
|
||||
@ -11072,8 +11108,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
codepage@1.15.0: {}
|
||||
|
||||
color-convert@2.0.1:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
@ -11126,8 +11160,6 @@ snapshots:
|
||||
|
||||
core-util-is@1.0.3: {}
|
||||
|
||||
crc-32@1.2.2: {}
|
||||
|
||||
crelt@1.0.6: {}
|
||||
|
||||
croner@10.0.1: {}
|
||||
@ -11568,8 +11600,6 @@ snapshots:
|
||||
|
||||
forwarded@0.2.0: {}
|
||||
|
||||
frac@1.1.2: {}
|
||||
|
||||
framer-motion@12.34.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
motion-dom: 12.34.0
|
||||
@ -12263,6 +12293,10 @@ snapshots:
|
||||
lodash.clonedeep: 4.5.0
|
||||
lru-cache: 6.0.0
|
||||
|
||||
lucide-react@0.575.0(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
|
||||
magic-string@0.30.21:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
@ -13965,10 +13999,6 @@ snapshots:
|
||||
sqlite-vec-linux-x64: 0.1.7-alpha.2
|
||||
sqlite-vec-windows-x64: 0.1.7-alpha.2
|
||||
|
||||
ssf@0.11.2:
|
||||
dependencies:
|
||||
frac: 1.1.2
|
||||
|
||||
sshpk@1.18.0:
|
||||
dependencies:
|
||||
asn1: 0.2.6
|
||||
@ -14074,11 +14104,15 @@ snapshots:
|
||||
react: 19.2.4
|
||||
use-sync-external-store: 1.6.0(react@19.2.4)
|
||||
|
||||
tabbable@6.4.0: {}
|
||||
|
||||
table-layout@4.1.1:
|
||||
dependencies:
|
||||
array-back: 6.2.2
|
||||
wordwrapjs: 5.1.1
|
||||
|
||||
tailwind-merge@3.5.0: {}
|
||||
|
||||
tailwindcss@4.1.18: {}
|
||||
|
||||
tapable@2.3.0: {}
|
||||
@ -14236,6 +14270,8 @@ snapshots:
|
||||
|
||||
undici@7.22.0: {}
|
||||
|
||||
unicode-animations@1.0.3: {}
|
||||
|
||||
unified@11.0.5:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
@ -14487,10 +14523,6 @@ snapshots:
|
||||
|
||||
win-guid@0.2.1: {}
|
||||
|
||||
wmf@1.0.2: {}
|
||||
|
||||
word@0.3.0: {}
|
||||
|
||||
wordwrapjs@5.1.1: {}
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
@ -14509,16 +14541,6 @@ snapshots:
|
||||
|
||||
ws@8.19.0: {}
|
||||
|
||||
xlsx@0.18.5:
|
||||
dependencies:
|
||||
adler-32: 1.3.1
|
||||
cfb: 1.2.2
|
||||
codepage: 1.15.0
|
||||
crc-32: 1.2.2
|
||||
ssf: 0.11.2
|
||||
wmf: 1.0.2
|
||||
word: 0.3.0
|
||||
|
||||
y18n@5.0.8: {}
|
||||
|
||||
yallist@4.0.0: {}
|
||||
|
||||
@ -667,19 +667,6 @@ VALUES ('Roadmap', 'map', 'projects/roadmap.md', '<parent_doc_id>', 0);
|
||||
- **Field names**: human-readable, proper capitalization ("Email Address" not "email")
|
||||
- **Be descriptive**: "Phone Number" not "Phone"
|
||||
- **Be consistent**: Don't mix "Full Name" and "Name" in the same object
|
||||
- **TRIPLE ALIGNMENT (MANDATORY)**: The DuckDB object `name`, the filesystem directory name, and the `.object.yaml` `name` field MUST all be identical. If any one of these three diverges, the UI will fail to render the object. For example, if DuckDB has `name = 'contract'`, the directory MUST be `contract/` (in workspace) and the yaml MUST have `name: "contract"`. Never use plural for one and singular for another.
|
||||
|
||||
### Renaming / Moving Objects
|
||||
|
||||
When renaming or relocating an object, you MUST update ALL THREE in a single operation:
|
||||
|
||||
1. **DuckDB**: Update `objects.name` (if FK constraints block this, recreate the object with the new name and migrate entries)
|
||||
2. **Directory**: `mv` the old directory to the new name
|
||||
3. **`.object.yaml`**: Update the `name` field to match
|
||||
4. **PIVOT view**: `DROP VIEW IF EXISTS v_{old_name}; CREATE OR REPLACE VIEW v_{new_name} ...`
|
||||
5. **Verify**: Confirm all three match and the view returns data
|
||||
|
||||
Never rename partially. If you can't complete all steps, don't start the rename — explain the constraint to the user first.
|
||||
|
||||
## Error Handling
|
||||
|
||||
@ -885,7 +872,7 @@ After creating a `.report.json` file:
|
||||
## Critical Reminders
|
||||
|
||||
- Handle the ENTIRE CRM operation from analysis to SQL execution to filesystem projection to summary
|
||||
- **NEVER SKIP FILESYSTEM PROJECTION**: After creating/modifying any object, you MUST create/update `{object}/.object.yaml` in workspace AND the `v_{object}` view. If you skip this, the object will be invisible in the sidebar. This is NOT optional.
|
||||
- **NEVER SKIP FILESYSTEM PROJECTION**: After creating/modifying any object, you MUST create/update `~/.openclaw/workspace/{object}/.object.yaml` AND the `v_{object}` view. If you skip this, the object will be invisible in the sidebar. This is NOT optional.
|
||||
- **THREE STEPS, EVERY TIME**: (1) SQL transaction, (2) filesystem projection (.object.yaml + directory), (3) verify. An operation is NOT complete until all three are done.
|
||||
- Always check existing data before creating (`SELECT` before `INSERT`, or `ON CONFLICT`)
|
||||
- Use views (`v_{object}`) for all reads — never write raw PIVOT queries for search
|
||||
@ -903,9 +890,8 @@ After creating a `.report.json` file:
|
||||
- **workspace_context.yaml**: READ-ONLY. Never modify. Data flows from Dench UI only.
|
||||
- **Source of truth**: DuckDB for all structured data. Filesystem for document content and navigation tree. Never duplicate entry data to the filesystem.
|
||||
- **ENTRY COUNT**: After adding entries, update `entry_count` in `.object.yaml`.
|
||||
- **NAME CONSISTENCY**: The DuckDB `objects.name`, the filesystem directory name, and `.object.yaml` `name` MUST be identical. A mismatch between ANY of these three will break the UI. Before finishing any object creation or modification, verify: `objects.name == directory_name == yaml.name`. See "Renaming / Moving Objects" under Naming Conventions.
|
||||
- **NEVER POLLUTE THE WORKSPACE**: Always keep cleaning / organising the workspace to something more nicely structured. Always look out for bloat and too many random files scattered around everywhere for no reason, every time you do any actions in filesystem always try to come up with the most efficient and nice file system structure inside the workspace.
|
||||
- **TEMPORARY FILES**: All temporary scripts / code / text / other files as and when needed for processing must go into `tmp/` directory (create it in the workspace if it doesn't exist, only if needed).
|
||||
- **NEVER POLLUTE THE WORKSPACE**: Always keep cleaning / organising the workspace to something more nicely structured. Always look out for bloat and too many random files scattered around everywhere for no reason, every time you do any actions in filesystem always try to come up with the most efficient and nice file system structure inside `~/.openclaw/workspace`.
|
||||
- **TEMPORARY FILES**: All temporary scripts / code / text / other files as and when needed for processing must go into `~/.openclaw/workspace/tmp/` directory (create it if it doesn't exist, only if needed).
|
||||
|
||||
## Browser Use
|
||||
|
||||
|
||||
@ -94,6 +94,4 @@ export type SkillSnapshot = {
|
||||
/** Skills with `inject: true` whose full content should be included in the system prompt. */
|
||||
injectedSkills?: InjectedSkillContent[];
|
||||
version?: number;
|
||||
/** Workspace dir this snapshot was built for (used to invalidate on profile switch). */
|
||||
workspaceDir?: string;
|
||||
};
|
||||
|
||||
@ -265,28 +265,12 @@ export function buildWorkspaceSkillSnapshot(
|
||||
const remoteNote = opts?.eligibility?.remote?.note?.trim();
|
||||
const prompt = [remoteNote, formatSkillsForPrompt(resolvedSkills)].filter(Boolean).join("\n");
|
||||
|
||||
// Read full content of injected skills, substituting workspace path placeholders.
|
||||
// We replace both the tilde form and the expanded default path to handle
|
||||
// cases where the replacement target is a profile-specific workspace dir.
|
||||
//
|
||||
// Use regex with a negative lookahead so "~/.openclaw/workspace" doesn't
|
||||
// match inside "~/.openclaw/workspace-<profile>", which would double the
|
||||
// profile suffix (e.g. workspace-kumareth → workspace-kumareth-kumareth).
|
||||
const defaultExpandedWorkspace = resolveUserPath("~/.openclaw/workspace");
|
||||
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const tildePattern = new RegExp(escapeRegex("~/.openclaw/workspace") + "(?![\\w-])", "g");
|
||||
// Read full content of injected skills, substituting workspace path placeholders
|
||||
const injectedSkills: InjectedSkillContent[] = [];
|
||||
for (const entry of injectedEntries) {
|
||||
const rawContent = readSkillContent(entry.skill.filePath);
|
||||
if (rawContent) {
|
||||
let content = rawContent.replace(tildePattern, workspaceDir);
|
||||
if (workspaceDir !== defaultExpandedWorkspace) {
|
||||
const expandedPattern = new RegExp(
|
||||
escapeRegex(defaultExpandedWorkspace) + "(?![\\w-])",
|
||||
"g",
|
||||
);
|
||||
content = content.replace(expandedPattern, workspaceDir);
|
||||
}
|
||||
const content = rawContent.replaceAll("~/.openclaw/workspace", workspaceDir);
|
||||
injectedSkills.push({ name: entry.skill.name, content });
|
||||
}
|
||||
}
|
||||
@ -300,7 +284,6 @@ export function buildWorkspaceSkillSnapshot(
|
||||
resolvedSkills,
|
||||
injectedSkills: injectedSkills.length > 0 ? injectedSkills : undefined,
|
||||
version: opts?.snapshotVersion,
|
||||
workspaceDir,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -140,8 +140,6 @@ export async function agentViaGatewayCommand(opts: AgentCliOpts, runtime: Runtim
|
||||
const channel = normalizeMessageChannel(opts.channel) ?? DEFAULT_CHAT_CHANNEL;
|
||||
const idempotencyKey = opts.runId?.trim() || randomIdempotencyKey();
|
||||
|
||||
const workspaceOverride = process.env.OPENCLAW_WORKSPACE?.trim() || undefined;
|
||||
|
||||
const response = await withProgress(
|
||||
{
|
||||
label: "Waiting for agent reply…",
|
||||
@ -167,7 +165,6 @@ export async function agentViaGatewayCommand(opts: AgentCliOpts, runtime: Runtim
|
||||
lane: opts.lane,
|
||||
extraSystemPrompt: opts.extraSystemPrompt,
|
||||
idempotencyKey,
|
||||
workspace: workspaceOverride,
|
||||
},
|
||||
expectFinal: true,
|
||||
timeoutMs: gatewayTimeoutMs,
|
||||
@ -251,8 +248,6 @@ async function agentViaGatewayStreamJson(opts: AgentCliOpts, _runtime: RuntimeEn
|
||||
process.on("SIGTERM", onSignal);
|
||||
process.on("SIGINT", onSignal);
|
||||
|
||||
const streamWorkspaceOverride = process.env.OPENCLAW_WORKSPACE?.trim() || undefined;
|
||||
|
||||
try {
|
||||
const response = await callGateway<GatewayAgentResponse>({
|
||||
method: "agent",
|
||||
@ -272,7 +267,6 @@ async function agentViaGatewayStreamJson(opts: AgentCliOpts, _runtime: RuntimeEn
|
||||
lane: opts.lane,
|
||||
extraSystemPrompt: opts.extraSystemPrompt,
|
||||
idempotencyKey,
|
||||
workspace: streamWorkspaceOverride,
|
||||
},
|
||||
expectFinal: true,
|
||||
timeoutMs: gatewayTimeoutMs,
|
||||
|
||||
@ -217,7 +217,7 @@ export async function agentCommand(
|
||||
}
|
||||
const agentCfg = cfg.agents?.defaults;
|
||||
const sessionAgentId = agentIdOverride ?? resolveAgentIdFromSessionKey(opts.sessionKey?.trim());
|
||||
const workspaceDirRaw = opts.workspace?.trim() || resolveAgentWorkspaceDir(cfg, sessionAgentId);
|
||||
const workspaceDirRaw = resolveAgentWorkspaceDir(cfg, sessionAgentId);
|
||||
const agentDir = resolveAgentDir(cfg, sessionAgentId);
|
||||
const workspace = await ensureAgentWorkspace({
|
||||
dir: workspaceDirRaw,
|
||||
@ -332,11 +332,7 @@ export async function agentCommand(
|
||||
});
|
||||
}
|
||||
|
||||
const cachedSnapshot = sessionEntry?.skillsSnapshot;
|
||||
const needsSkillsSnapshot =
|
||||
isNewSession ||
|
||||
!cachedSnapshot ||
|
||||
(cachedSnapshot.workspaceDir && cachedSnapshot.workspaceDir !== workspaceDir);
|
||||
const needsSkillsSnapshot = isNewSession || !sessionEntry?.skillsSnapshot;
|
||||
const skillsSnapshotVersion = getSkillsSnapshotVersion(workspaceDir);
|
||||
const skillFilter = resolveAgentSkillsFilter(cfg, sessionAgentId);
|
||||
const skillsSnapshot = needsSkillsSnapshot
|
||||
|
||||
@ -78,6 +78,4 @@ export type AgentCommandOpts = {
|
||||
inputProvenance?: InputProvenance;
|
||||
/** Per-call stream param overrides (best-effort). */
|
||||
streamParams?: AgentStreamParams;
|
||||
/** Workspace directory override (passed via RPC from the web UI for profile switching). */
|
||||
workspace?: string;
|
||||
};
|
||||
|
||||
@ -73,7 +73,6 @@ export const AgentParamsSchema = Type.Object(
|
||||
timeout: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
lane: Type.Optional(Type.String()),
|
||||
extraSystemPrompt: Type.Optional(Type.String()),
|
||||
workspace: Type.Optional(Type.String()),
|
||||
inputProvenance: Type.Optional(
|
||||
Type.Object(
|
||||
{
|
||||
|
||||
@ -191,7 +191,6 @@ export const agentHandlers: GatewayRequestHandlers = {
|
||||
groupSpace?: string;
|
||||
lane?: string;
|
||||
extraSystemPrompt?: string;
|
||||
workspace?: string;
|
||||
idempotencyKey: string;
|
||||
timeout?: number;
|
||||
label?: string;
|
||||
@ -560,7 +559,6 @@ export const agentHandlers: GatewayRequestHandlers = {
|
||||
lane: request.lane,
|
||||
extraSystemPrompt: request.extraSystemPrompt,
|
||||
inputProvenance,
|
||||
workspace: typeof request.workspace === "string" ? request.workspace.trim() : undefined,
|
||||
},
|
||||
defaultRuntime,
|
||||
context.deps,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user