Dench workspace: virtual folders (Skills, Memories, Chats), landing page, and unified workspace layout
Add virtual folder system that surfaces Skills, Memories, and Chat sessions in the workspace sidebar alongside real dench files. Rearchitect the home page into a landing hub and move the ChatPanel into the workspace as the default view. New API route — virtual-file: - apps/web/app/api/workspace/virtual-file/route.ts: new GET/POST API that resolves virtual paths (~skills/*, ~memories/*) to absolute filesystem paths in ~/.openclaw/skills/ d ~/.openclaw/workspace/. Includes path traversal protection and directory allowlisting. Tree API — virtual folder builders: - apps/web/app/api/workspace/tree/route.ts: add `virtual` field to TreeNode type. Add ildSkillsVirtualFolder() scanning ~/.openclaw/skills/ and ~/.openclaw/workspace/skills/ with SKILL.md frontmatter parsing (name + emoji). Add buildMemoriesVirtualFolder() scanning MEMORY.md and daily logs from ~/.openclaw/workspace/memory/. Virtual folders are appended after real workspace entries and are also returned when no dench root exists. File manager tree — virtual node awareness: - apps/web/app/components/workspace/file-manager-tree.tsx: add isVirtualNode() helper and RESERVED_FOLDER_NAMES set (Chats, Skills, Memories). Virtual nodes show a lock badge, disable drag-and-drop, block rename/delete, and reject reserved names during create/rename. Add ChatBubbleIcon for ~chats/ paths. Markdown editor — virtual path routing: - apps/web/app/components/workspace/markdown-editor.tsx: save to /api/workspace/virtual-file for paths starting with ~ instead of the regular /api/workspace/file endpoint. Home page redesign: - apps/web/app/page.tsx: replace the chat-first layout (SidebarhatPanel) with a branded landing page showing the OpenClaw Dench heading, tagline, and a single "Open Workspace" CTA linking to /workspace. Workspace page — unified layout with integrated chat: - apps/web/app/workspace/page.tsx: ChatPanel is now the default main view when no file is selected. Add session fetching from /api/web-sessions and build an enhanced tree with a virtual "Chats" folder listing all sessions. Clicking ~chats/<id> loads the session; clicking ~chats starts a new one. Add isVirtualPath()/fileApiUrl() helpers for virtual file reads. Add a "Back to chat" button in the top bar alongside the chat sidebar toggle. Sidebar + empty-state cosmetic updates: - apps/web/app/components/workspace/workspace-sidebar.tsx: rename BackIcon to HomeIcon (house SVG), change label from "Back to Chat" to "Home". - apps/web/app/components/workspace/empty-state.tsx: update link text from "Back to Chat" to "Back to Home".
This commit is contained in:
parent
f4a882f3e3
commit
fe15ab44dc
@ -1,5 +1,6 @@
|
||||
import { readdirSync, readFileSync, existsSync, type Dirent } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { resolveDenchRoot, parseSimpleYaml, duckdbQuery, isDatabaseFile } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@ -7,11 +8,13 @@ export const runtime = "nodejs";
|
||||
|
||||
export type TreeNode = {
|
||||
name: string;
|
||||
path: string; // relative to dench/
|
||||
path: string; // relative to dench/ (or ~skills/, ~memories/ for virtual nodes)
|
||||
type: "object" | "document" | "folder" | "file" | "database" | "report";
|
||||
icon?: string;
|
||||
defaultView?: "table" | "kanban";
|
||||
children?: TreeNode[];
|
||||
/** Virtual nodes live outside the dench workspace (e.g. Skills, Memories). */
|
||||
virtual?: boolean;
|
||||
};
|
||||
|
||||
type DbObject = {
|
||||
@ -141,10 +144,141 @@ function classifyFileType(name: string): TreeNode["type"] {
|
||||
return "file";
|
||||
}
|
||||
|
||||
// --- Virtual folder builders ---
|
||||
|
||||
/** Parse YAML frontmatter from a SKILL.md file (lightweight). */
|
||||
function parseSkillFrontmatter(content: string): { name?: string; emoji?: string } {
|
||||
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
||||
if (!match) {return {};}
|
||||
const yaml = match[1];
|
||||
const result: Record<string, string> = {};
|
||||
for (const line of yaml.split("\n")) {
|
||||
const kv = line.match(/^(\w+)\s*:\s*(.+)/);
|
||||
if (kv) {result[kv[1]] = kv[2].replace(/^["']|["']$/g, "").trim();}
|
||||
}
|
||||
return { name: result.name, emoji: result.emoji };
|
||||
}
|
||||
|
||||
/** Build a virtual "Skills" folder from ~/.openclaw/skills/ and ~/.openclaw/workspace/skills/. */
|
||||
function buildSkillsVirtualFolder(): TreeNode | null {
|
||||
const home = homedir();
|
||||
const dirs = [
|
||||
join(home, ".openclaw", "skills"),
|
||||
join(home, ".openclaw", "workspace", "skills"),
|
||||
];
|
||||
|
||||
const children: TreeNode[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const dir of dirs) {
|
||||
if (!existsSync(dir)) {continue;}
|
||||
try {
|
||||
const entries = readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || seen.has(entry.name)) {continue;}
|
||||
const skillMdPath = join(dir, entry.name, "SKILL.md");
|
||||
if (!existsSync(skillMdPath)) {continue;}
|
||||
|
||||
seen.add(entry.name);
|
||||
let displayName = entry.name;
|
||||
try {
|
||||
const content = readFileSync(skillMdPath, "utf-8");
|
||||
const meta = parseSkillFrontmatter(content);
|
||||
if (meta.name) {displayName = meta.name;}
|
||||
if (meta.emoji) {displayName = `${meta.emoji} ${displayName}`;}
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
|
||||
children.push({
|
||||
name: displayName,
|
||||
path: `~skills/${entry.name}/SKILL.md`,
|
||||
type: "document",
|
||||
virtual: true,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// dir unreadable
|
||||
}
|
||||
}
|
||||
|
||||
if (children.length === 0) {return null;}
|
||||
children.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return {
|
||||
name: "Skills",
|
||||
path: "~skills",
|
||||
type: "folder",
|
||||
virtual: true,
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a virtual "Memories" folder from ~/.openclaw/workspace/. */
|
||||
function buildMemoriesVirtualFolder(): TreeNode | null {
|
||||
const workspaceDir = join(homedir(), ".openclaw", "workspace");
|
||||
const children: TreeNode[] = [];
|
||||
|
||||
// MEMORY.md
|
||||
for (const filename of ["MEMORY.md", "memory.md"]) {
|
||||
const memPath = join(workspaceDir, filename);
|
||||
if (existsSync(memPath)) {
|
||||
children.push({
|
||||
name: "MEMORY.md",
|
||||
path: `~memories/MEMORY.md`,
|
||||
type: "document",
|
||||
virtual: true,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Daily logs from memory/
|
||||
const memoryDir = join(workspaceDir, "memory");
|
||||
if (existsSync(memoryDir)) {
|
||||
try {
|
||||
const entries = readdirSync(memoryDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith(".md")) {continue;}
|
||||
children.push({
|
||||
name: entry.name,
|
||||
path: `~memories/${entry.name}`,
|
||||
type: "document",
|
||||
virtual: true,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// dir unreadable
|
||||
}
|
||||
}
|
||||
|
||||
if (children.length === 0) {return null;}
|
||||
// Sort: MEMORY.md first, then reverse chronological for daily logs
|
||||
children.sort((a, b) => {
|
||||
if (a.name === "MEMORY.md") {return -1;}
|
||||
if (b.name === "MEMORY.md") {return 1;}
|
||||
return b.name.localeCompare(a.name);
|
||||
});
|
||||
|
||||
return {
|
||||
name: "Memories",
|
||||
path: "~memories",
|
||||
type: "folder",
|
||||
virtual: true,
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const root = resolveDenchRoot();
|
||||
if (!root) {
|
||||
return Response.json({ tree: [], exists: false });
|
||||
// Even without a dench workspace, return virtual folders if they exist
|
||||
const tree: TreeNode[] = [];
|
||||
const skillsFolder = buildSkillsVirtualFolder();
|
||||
if (skillsFolder) {tree.push(skillsFolder);}
|
||||
const memoriesFolder = buildMemoriesVirtualFolder();
|
||||
if (memoriesFolder) {tree.push(memoriesFolder);}
|
||||
return Response.json({ tree, exists: false });
|
||||
}
|
||||
|
||||
// Load objects from DuckDB for smart directory detection
|
||||
@ -154,7 +288,7 @@ export async function GET() {
|
||||
const reportsDir = join(root, "reports");
|
||||
const tree: TreeNode[] = [];
|
||||
|
||||
// Build knowledge tree
|
||||
// Build knowledge tree (real files first)
|
||||
if (existsSync(knowledgeDir)) {
|
||||
tree.push(...buildTree(knowledgeDir, "knowledge", dbObjects));
|
||||
}
|
||||
@ -189,5 +323,11 @@ export async function GET() {
|
||||
// skip if root unreadable
|
||||
}
|
||||
|
||||
// Virtual folders go after all real files/folders
|
||||
const skillsFolder = buildSkillsVirtualFolder();
|
||||
if (skillsFolder) {tree.push(skillsFolder);}
|
||||
const memoriesFolder = buildMemoriesVirtualFolder();
|
||||
if (memoriesFolder) {tree.push(memoriesFolder);}
|
||||
|
||||
return Response.json({ tree, exists: true });
|
||||
}
|
||||
|
||||
151
apps/web/app/api/workspace/virtual-file/route.ts
Normal file
151
apps/web/app/api/workspace/virtual-file/route.ts
Normal file
@ -0,0 +1,151 @@
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
||||
import { join, dirname, resolve, normalize } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/**
|
||||
* Resolve a virtual path (~skills/... or ~memories/...) to an absolute filesystem path.
|
||||
* Returns null if the path is invalid or tries to escape.
|
||||
*/
|
||||
function resolveVirtualPath(virtualPath: string): string | null {
|
||||
const home = homedir();
|
||||
|
||||
if (virtualPath.startsWith("~skills/")) {
|
||||
// ~skills/<skillName>/SKILL.md
|
||||
const rest = virtualPath.slice("~skills/".length);
|
||||
// Validate: must be <name>/SKILL.md
|
||||
const parts = rest.split("/");
|
||||
if (parts.length !== 2 || parts[1] !== "SKILL.md" || !parts[0]) {
|
||||
return null;
|
||||
}
|
||||
const skillName = parts[0];
|
||||
// Prevent path traversal
|
||||
if (skillName.includes("..") || skillName.includes("/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check workspace skills first, then managed skills
|
||||
const candidates = [
|
||||
join(home, ".openclaw", "workspace", "skills", skillName, "SKILL.md"),
|
||||
join(home, ".openclaw", "skills", skillName, "SKILL.md"),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
// Default to workspace skills dir for new files
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
if (virtualPath.startsWith("~memories/")) {
|
||||
const rest = virtualPath.slice("~memories/".length);
|
||||
// Prevent path traversal
|
||||
if (rest.includes("..") || rest.includes("/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const workspaceDir = join(home, ".openclaw", "workspace");
|
||||
|
||||
if (rest === "MEMORY.md") {
|
||||
// Check both casing
|
||||
for (const filename of ["MEMORY.md", "memory.md"]) {
|
||||
const candidate = join(workspaceDir, filename);
|
||||
if (existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
// Default to MEMORY.md for new files
|
||||
return join(workspaceDir, "MEMORY.md");
|
||||
}
|
||||
|
||||
// Daily log: must be a .md file in the memory/ subdirectory
|
||||
if (!rest.endsWith(".md")) {
|
||||
return null;
|
||||
}
|
||||
return join(workspaceDir, "memory", rest);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Double-check that the resolved path stays within expected directories.
|
||||
*/
|
||||
function isSafePath(absPath: string): boolean {
|
||||
const home = homedir();
|
||||
const normalized = normalize(resolve(absPath));
|
||||
const allowed = [
|
||||
normalize(join(home, ".openclaw", "skills")),
|
||||
normalize(join(home, ".openclaw", "workspace", "skills")),
|
||||
normalize(join(home, ".openclaw", "workspace")),
|
||||
];
|
||||
return allowed.some((dir) => normalized.startsWith(dir));
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const path = url.searchParams.get("path");
|
||||
|
||||
if (!path) {
|
||||
return Response.json({ error: "Missing 'path' query parameter" }, { status: 400 });
|
||||
}
|
||||
|
||||
const absPath = resolveVirtualPath(path);
|
||||
if (!absPath || !isSafePath(absPath)) {
|
||||
return Response.json({ error: "Invalid virtual path" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!existsSync(absPath)) {
|
||||
return Response.json({ error: "File not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(absPath, "utf-8");
|
||||
const ext = absPath.split(".").pop()?.toLowerCase();
|
||||
let type: "markdown" | "yaml" | "text" = "text";
|
||||
if (ext === "md" || ext === "mdx") {type = "markdown";}
|
||||
else if (ext === "yaml" || ext === "yml") {type = "yaml";}
|
||||
return Response.json({ content, type });
|
||||
} catch (err) {
|
||||
return Response.json(
|
||||
{ error: err instanceof Error ? err.message : "Read failed" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
let body: { path?: string; content?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { path: virtualPath, content } = body;
|
||||
if (!virtualPath || typeof virtualPath !== "string" || typeof content !== "string") {
|
||||
return Response.json(
|
||||
{ error: "Missing 'path' and 'content' fields" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const absPath = resolveVirtualPath(virtualPath);
|
||||
if (!absPath || !isSafePath(absPath)) {
|
||||
return Response.json({ error: "Invalid virtual path" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
mkdirSync(dirname(absPath), { recursive: true });
|
||||
writeFileSync(absPath, content, "utf-8");
|
||||
return Response.json({ ok: true, path: virtualPath });
|
||||
} catch (err) {
|
||||
return Response.json(
|
||||
{ error: err instanceof Error ? err.message : "Write failed" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -112,7 +112,7 @@ export function EmptyState({ workspaceExists }: { workspaceExists: boolean }) {
|
||||
<path d="m12 19-7-7 7-7" />
|
||||
<path d="M19 12H5" />
|
||||
</svg>
|
||||
Back to Chat
|
||||
Back to Home
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -26,8 +26,18 @@ export type TreeNode = {
|
||||
icon?: string;
|
||||
defaultView?: "table" | "kanban";
|
||||
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;
|
||||
};
|
||||
|
||||
/** Folder names reserved for virtual sections -- cannot be created/renamed to. */
|
||||
const RESERVED_FOLDER_NAMES = new Set(["Chats", "Skills", "Memories"]);
|
||||
|
||||
/** Check if a node (or any of its ancestors) is virtual. Paths starting with ~ are always virtual. */
|
||||
function isVirtualNode(node: TreeNode): boolean {
|
||||
return !!node.virtual || node.path.startsWith("~");
|
||||
}
|
||||
|
||||
type FileManagerTreeProps = {
|
||||
tree: TreeNode[];
|
||||
activePath: string | null;
|
||||
@ -113,6 +123,14 @@ function ReportIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
function ChatBubbleIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function LockBadge() {
|
||||
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.4 }}>
|
||||
@ -131,6 +149,10 @@ function ChevronIcon({ open }: { open: boolean }) {
|
||||
}
|
||||
|
||||
function NodeIcon({ node, open }: { node: TreeNode; open?: boolean }) {
|
||||
// Chat items use the chat bubble icon
|
||||
if (node.path.startsWith("~chats/") || node.path === "~chats") {
|
||||
return <ChatBubbleIcon />;
|
||||
}
|
||||
switch (node.type) {
|
||||
case "object":
|
||||
return node.defaultView === "kanban" ? <KanbanIcon /> : <TableIcon />;
|
||||
@ -361,18 +383,20 @@ function DraggableNode({
|
||||
const isSelected = selectedPath === node.path;
|
||||
const isRenaming = renamingPath === node.path;
|
||||
const isSysFile = isSystemFile(node.path);
|
||||
const isVirtual = isVirtualNode(node);
|
||||
const isProtected = isSysFile || isVirtual;
|
||||
const isDragOver = dragOverPath === node.path && isExpandable;
|
||||
|
||||
const { attributes, listeners, setNodeRef: setDragRef, isDragging } = useDraggable({
|
||||
id: `drag-${node.path}`,
|
||||
data: { node },
|
||||
disabled: isSysFile,
|
||||
disabled: isProtected,
|
||||
});
|
||||
|
||||
const { setNodeRef: setDropRef, isOver } = useDroppable({
|
||||
id: `drop-${node.path}`,
|
||||
data: { node },
|
||||
disabled: !isExpandable,
|
||||
disabled: !isExpandable || isVirtual,
|
||||
});
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
@ -384,10 +408,10 @@ function DraggableNode({
|
||||
}, [node, isExpandable, onSelect, onNodeSelect, onToggleExpand]);
|
||||
|
||||
const handleDoubleClick = useCallback(() => {
|
||||
if (!isSysFile) {
|
||||
if (!isProtected) {
|
||||
onStartRename(node.path);
|
||||
}
|
||||
}, [node.path, isSysFile, onStartRename]);
|
||||
}, [node.path, isProtected, onStartRename]);
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
@ -468,8 +492,8 @@ function DraggableNode({
|
||||
<span className="truncate flex-1">{node.name.replace(/\.md$/, "")}</span>
|
||||
)}
|
||||
|
||||
{/* Lock badge for system files */}
|
||||
{isSysFile && !compact && (
|
||||
{/* Lock badge for system/virtual files */}
|
||||
{isProtected && !compact && (
|
||||
<span className="flex-shrink-0 ml-1">
|
||||
<LockBadge />
|
||||
</span>
|
||||
@ -684,7 +708,7 @@ export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact
|
||||
|
||||
// Context menu handlers
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent, node: TreeNode) => {
|
||||
const isSys = isSystemFile(node.path);
|
||||
const isSys = isSystemFile(node.path) || isVirtualNode(node);
|
||||
const isFolder = node.type === "folder" || node.type === "object";
|
||||
setCtxMenu({
|
||||
x: e.clientX,
|
||||
@ -771,6 +795,12 @@ export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact
|
||||
const handleCommitRename = useCallback(
|
||||
async (newName: string) => {
|
||||
if (!renamingPath) {return;}
|
||||
// Block reserved folder names
|
||||
if (RESERVED_FOLDER_NAMES.has(newName)) {
|
||||
alert(`"${newName}" is a reserved name and cannot be used.`);
|
||||
setRenamingPath(null);
|
||||
return;
|
||||
}
|
||||
const result = await apiRename(renamingPath, newName);
|
||||
setRenamingPath(null);
|
||||
if (result.ok) {onRefresh();}
|
||||
@ -794,6 +824,13 @@ export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact
|
||||
const handleNewItemSubmit = useCallback(
|
||||
async (name: string) => {
|
||||
if (!newItemPrompt || !name) {return;}
|
||||
|
||||
// Block reserved folder names
|
||||
if (RESERVED_FOLDER_NAMES.has(name)) {
|
||||
alert(`"${name}" is a reserved name and cannot be used.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const fullPath = newItemPrompt.parentPath ? `${newItemPrompt.parentPath}/${name}` : name;
|
||||
|
||||
if (newItemPrompt.kind === "folder") {
|
||||
@ -859,7 +896,8 @@ export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact
|
||||
case "Enter": {
|
||||
e.preventDefault();
|
||||
if (curNode) {
|
||||
if (e.shiftKey || isSystemFile(curNode.path)) {
|
||||
const curProtected = isSystemFile(curNode.path) || isVirtualNode(curNode);
|
||||
if (e.shiftKey || curProtected) {
|
||||
onSelect(curNode);
|
||||
} else {
|
||||
setRenamingPath(curNode.path);
|
||||
@ -869,14 +907,14 @@ export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact
|
||||
}
|
||||
case "F2": {
|
||||
e.preventDefault();
|
||||
if (curNode && !isSystemFile(curNode.path)) {
|
||||
if (curNode && !isSystemFile(curNode.path) && !isVirtualNode(curNode)) {
|
||||
setRenamingPath(curNode.path);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "Backspace":
|
||||
case "Delete": {
|
||||
if (curNode && !isSystemFile(curNode.path)) {
|
||||
if (curNode && !isSystemFile(curNode.path) && !isVirtualNode(curNode)) {
|
||||
e.preventDefault();
|
||||
setConfirmDelete(curNode.path);
|
||||
}
|
||||
|
||||
@ -279,7 +279,11 @@ export function MarkdownEditor({
|
||||
// Prepend preserved frontmatter so it isn't lost on save
|
||||
const finalContent = frontmatterRef.current + bodyContent;
|
||||
|
||||
const res = await fetch("/api/workspace/file", {
|
||||
// Virtual paths (~skills/*, ~memories/*) use the virtual-file API
|
||||
const saveEndpoint = filePath.startsWith("~")
|
||||
? "/api/workspace/virtual-file"
|
||||
: "/api/workspace/file";
|
||||
const res = await fetch(saveEndpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: filePath, content: finalContent }),
|
||||
|
||||
@ -22,10 +22,11 @@ function WorkspaceLogo() {
|
||||
);
|
||||
}
|
||||
|
||||
function BackIcon() {
|
||||
function HomeIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m12 19-7-7 7-7" /><path d="M19 12H5" />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@ -73,7 +74,7 @@ export function WorkspaceSidebar({
|
||||
Knowledge
|
||||
</div>
|
||||
|
||||
{/* Tree */}
|
||||
{/* Tree (includes real files + virtual Skills, Memories, Chats folders) */}
|
||||
<div className="flex-1 overflow-y-auto px-1">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
@ -112,8 +113,8 @@ export function WorkspaceSidebar({
|
||||
(e.currentTarget as HTMLElement).style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
<BackIcon />
|
||||
Back to Chat
|
||||
<HomeIcon />
|
||||
Home
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@ -1,46 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { ChatPanel, type ChatPanelHandle } from "./components/chat-panel";
|
||||
import { Sidebar } from "./components/sidebar";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function Home() {
|
||||
const chatRef = useRef<ChatPanelHandle>(null);
|
||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||
const [sidebarRefreshKey, setSidebarRefreshKey] = useState(0);
|
||||
|
||||
const handleSessionSelect = useCallback(
|
||||
(sessionId: string) => {
|
||||
chatRef.current?.loadSession(sessionId);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleNewSession = useCallback(() => {
|
||||
chatRef.current?.newSession();
|
||||
}, []);
|
||||
|
||||
const refreshSidebar = useCallback(() => {
|
||||
setSidebarRefreshKey((k) => k + 1);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<Sidebar
|
||||
onSessionSelect={handleSessionSelect}
|
||||
onNewSession={handleNewSession}
|
||||
activeSessionId={activeSessionId ?? undefined}
|
||||
refreshKey={sidebarRefreshKey}
|
||||
/>
|
||||
<div
|
||||
className="flex flex-col items-center justify-center min-h-screen px-6"
|
||||
style={{ background: "var(--color-bg)" }}
|
||||
>
|
||||
{/* Logo / brand mark */}
|
||||
<div
|
||||
className="mb-6 w-16 h-16 rounded-2xl flex items-center justify-center"
|
||||
style={{ background: "rgba(232, 93, 58, 0.12)" }}
|
||||
>
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ color: "var(--color-accent)" }}
|
||||
>
|
||||
<rect width="7" height="7" x="3" y="3" rx="1" />
|
||||
<rect width="7" height="7" x="14" y="3" rx="1" />
|
||||
<rect width="7" height="7" x="14" y="14" rx="1" />
|
||||
<rect width="7" height="7" x="3" y="14" rx="1" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Main chat area */}
|
||||
<main className="flex-1 flex flex-col min-w-0">
|
||||
<ChatPanel
|
||||
ref={chatRef}
|
||||
onActiveSessionChange={setActiveSessionId}
|
||||
onSessionsChange={refreshSidebar}
|
||||
/>
|
||||
</main>
|
||||
{/* Heading */}
|
||||
<h1
|
||||
className="text-4xl font-bold tracking-tight mb-3 text-center"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
OpenClaw Dench
|
||||
</h1>
|
||||
|
||||
{/* Tagline */}
|
||||
<p
|
||||
className="text-lg mb-8 text-center max-w-md"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Your AI workspace — chat, knowledge, skills, and memory in one place.
|
||||
</p>
|
||||
|
||||
{/* CTA */}
|
||||
<Link
|
||||
href="/workspace"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg text-sm font-medium transition-colors"
|
||||
style={{
|
||||
background: "var(--color-accent)",
|
||||
color: "#fff",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background =
|
||||
"var(--color-accent-hover)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background =
|
||||
"var(--color-accent)";
|
||||
}}
|
||||
>
|
||||
Open Workspace
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M5 12h14" />
|
||||
<path d="m12 5 7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
|
||||
{/* Subtle footer link */}
|
||||
<p
|
||||
className="mt-12 text-xs"
|
||||
style={{ color: "var(--color-text-muted)", opacity: 0.5 }}
|
||||
>
|
||||
Powered by OpenClaw
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -13,7 +13,7 @@ import { DatabaseViewer } from "../components/workspace/database-viewer";
|
||||
import { Breadcrumbs } from "../components/workspace/breadcrumbs";
|
||||
import { EmptyState } from "../components/workspace/empty-state";
|
||||
import { ReportViewer } from "../components/charts/report-viewer";
|
||||
import { ChatPanel } from "../components/chat-panel";
|
||||
import { ChatPanel, type ChatPanelHandle } from "../components/chat-panel";
|
||||
import { EntryDetailModal } from "../components/workspace/entry-detail-modal";
|
||||
import { useSearchIndex } from "@/lib/search-index";
|
||||
import { parseWorkspaceLink, isWorkspaceLink } from "@/lib/workspace-links";
|
||||
@ -82,8 +82,28 @@ type ContentState =
|
||||
| { kind: "report"; reportPath: string; filename: string }
|
||||
| { kind: "directory"; node: TreeNode };
|
||||
|
||||
type WebSession = {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
messageCount: number;
|
||||
};
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
/** Detect virtual paths (skills, memories) that live outside the dench workspace. */
|
||||
function isVirtualPath(path: string): boolean {
|
||||
return path.startsWith("~");
|
||||
}
|
||||
|
||||
/** Pick the right file API endpoint based on virtual vs real paths. */
|
||||
function fileApiUrl(path: string): string {
|
||||
return isVirtualPath(path)
|
||||
? `/api/workspace/virtual-file?path=${encodeURIComponent(path)}`
|
||||
: `/api/workspace/file?path=${encodeURIComponent(path)}`;
|
||||
}
|
||||
|
||||
/** Find a node in the tree by exact path. */
|
||||
function findNode(
|
||||
tree: TreeNode[],
|
||||
@ -155,6 +175,9 @@ export default function WorkspacePage() {
|
||||
const router = useRouter();
|
||||
const initialPathHandled = useRef(false);
|
||||
|
||||
// Chat panel ref for session management
|
||||
const chatRef = useRef<ChatPanelHandle>(null);
|
||||
|
||||
// Live-reactive tree via SSE watcher
|
||||
const { tree, loading: treeLoading, exists: workspaceExists, refresh: refreshTree } = useWorkspaceWatcher();
|
||||
|
||||
@ -166,6 +189,11 @@ export default function WorkspacePage() {
|
||||
const [content, setContent] = useState<ContentState>({ kind: "none" });
|
||||
const [showChatSidebar, setShowChatSidebar] = useState(true);
|
||||
|
||||
// Chat session state
|
||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||
const [sessions, setSessions] = useState<WebSession[]>([]);
|
||||
const [sidebarRefreshKey, setSidebarRefreshKey] = useState(0);
|
||||
|
||||
// Entry detail modal state
|
||||
const [entryModal, setEntryModal] = useState<{
|
||||
objectName: string;
|
||||
@ -208,6 +236,25 @@ export default function WorkspacePage() {
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// Fetch chat sessions
|
||||
const fetchSessions = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/web-sessions");
|
||||
const data = await res.json();
|
||||
setSessions(data.sessions ?? []);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSessions();
|
||||
}, [fetchSessions, sidebarRefreshKey]);
|
||||
|
||||
const refreshSessions = useCallback(() => {
|
||||
setSidebarRefreshKey((k) => k + 1);
|
||||
}, []);
|
||||
|
||||
// Load content when path changes
|
||||
const loadContent = useCallback(
|
||||
async (node: TreeNode) => {
|
||||
@ -225,9 +272,8 @@ export default function WorkspacePage() {
|
||||
const data: ObjectData = await res.json();
|
||||
setContent({ kind: "object", data });
|
||||
} else if (node.type === "document") {
|
||||
const res = await fetch(
|
||||
`/api/workspace/file?path=${encodeURIComponent(node.path)}`,
|
||||
);
|
||||
// Use virtual-file API for ~skills/ and ~memories/ paths
|
||||
const res = await fetch(fileApiUrl(node.path));
|
||||
if (!res.ok) {
|
||||
setContent({ kind: "none" });
|
||||
return;
|
||||
@ -243,9 +289,7 @@ export default function WorkspacePage() {
|
||||
} else if (node.type === "report") {
|
||||
setContent({ kind: "report", reportPath: node.path, filename: node.name });
|
||||
} else if (node.type === "file") {
|
||||
const res = await fetch(
|
||||
`/api/workspace/file?path=${encodeURIComponent(node.path)}`,
|
||||
);
|
||||
const res = await fetch(fileApiUrl(node.path));
|
||||
if (!res.ok) {
|
||||
setContent({ kind: "none" });
|
||||
return;
|
||||
@ -264,11 +308,49 @@ export default function WorkspacePage() {
|
||||
|
||||
const handleNodeSelect = useCallback(
|
||||
(node: TreeNode) => {
|
||||
// Intercept chat folder item clicks
|
||||
if (node.path.startsWith("~chats/")) {
|
||||
const sessionId = node.path.slice("~chats/".length);
|
||||
setActivePath(null);
|
||||
setContent({ kind: "none" });
|
||||
setActiveSessionId(sessionId);
|
||||
chatRef.current?.loadSession(sessionId);
|
||||
router.replace("/workspace", { scroll: false });
|
||||
return;
|
||||
}
|
||||
// Clicking the Chats folder itself opens a new chat
|
||||
if (node.path === "~chats") {
|
||||
setActivePath(null);
|
||||
setContent({ kind: "none" });
|
||||
chatRef.current?.newSession();
|
||||
router.replace("/workspace", { scroll: false });
|
||||
return;
|
||||
}
|
||||
loadContent(node);
|
||||
},
|
||||
[loadContent],
|
||||
[loadContent, router],
|
||||
);
|
||||
|
||||
// Build the enhanced tree: real tree + Chats virtual folder at the bottom
|
||||
const enhancedTree = useMemo(() => {
|
||||
const chatChildren: TreeNode[] = sessions.map((s) => ({
|
||||
name: s.title || "Untitled chat",
|
||||
path: `~chats/${s.id}`,
|
||||
type: "file" as const,
|
||||
virtual: true,
|
||||
}));
|
||||
|
||||
const chatsFolder: TreeNode = {
|
||||
name: "Chats",
|
||||
path: "~chats",
|
||||
type: "folder",
|
||||
virtual: true,
|
||||
children: chatChildren.length > 0 ? chatChildren : undefined,
|
||||
};
|
||||
|
||||
return [...tree, chatsFolder];
|
||||
}, [tree, sessions]);
|
||||
|
||||
// Sync URL bar when activePath changes
|
||||
useEffect(() => {
|
||||
const currentPath = searchParams.get("path");
|
||||
@ -362,10 +444,6 @@ export default function WorkspacePage() {
|
||||
[tree, loadContent],
|
||||
);
|
||||
|
||||
/**
|
||||
* Unified navigate handler for editor links.
|
||||
* Handles both file/object paths and @entry/ links.
|
||||
*/
|
||||
/**
|
||||
* Unified navigate handler for links in the editor and read mode.
|
||||
* Handles /workspace?entry=..., /workspace?path=..., and legacy relative paths.
|
||||
@ -429,12 +507,15 @@ export default function WorkspacePage() {
|
||||
[handleEditorNavigate],
|
||||
);
|
||||
|
||||
// Whether to show the main ChatPanel (no file/content selected)
|
||||
const showMainChat = !activePath || content.kind === "none";
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div className="flex h-screen" style={{ background: "var(--color-bg)" }} onClick={handleContainerClick}>
|
||||
{/* Sidebar */}
|
||||
<WorkspaceSidebar
|
||||
tree={tree}
|
||||
tree={enhancedTree}
|
||||
activePath={activePath}
|
||||
onSelect={handleNodeSelect}
|
||||
onRefresh={refreshTree}
|
||||
@ -444,8 +525,8 @@ export default function WorkspacePage() {
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
{/* Top bar with breadcrumbs */}
|
||||
{activePath && (
|
||||
{/* When a file is selected: show top bar with breadcrumbs */}
|
||||
{activePath && content.kind !== "none" && (
|
||||
<div
|
||||
className="px-6 border-b flex-shrink-0 flex items-center justify-between"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
@ -454,60 +535,93 @@ export default function WorkspacePage() {
|
||||
path={activePath}
|
||||
onNavigate={handleBreadcrumbNavigate}
|
||||
/>
|
||||
{/* Chat sidebar toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowChatSidebar((v) => !v)}
|
||||
className="p-1.5 rounded-md transition-colors flex-shrink-0"
|
||||
style={{
|
||||
color: showChatSidebar ? "var(--color-accent)" : "var(--color-text-muted)",
|
||||
background: showChatSidebar ? "rgba(232, 93, 58, 0.1)" : "transparent",
|
||||
}}
|
||||
title={showChatSidebar ? "Hide chat" : "Chat about this file"}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Back to chat button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActivePath(null);
|
||||
setContent({ kind: "none" });
|
||||
router.replace("/workspace", { scroll: false });
|
||||
}}
|
||||
className="p-1.5 rounded-md transition-colors flex-shrink-0"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title="Back to chat"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m12 19-7-7 7-7" /><path d="M19 12H5" />
|
||||
</svg>
|
||||
</button>
|
||||
{/* Chat sidebar toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowChatSidebar((v) => !v)}
|
||||
className="p-1.5 rounded-md transition-colors flex-shrink-0"
|
||||
style={{
|
||||
color: showChatSidebar ? "var(--color-accent)" : "var(--color-text-muted)",
|
||||
background: showChatSidebar ? "rgba(232, 93, 58, 0.1)" : "transparent",
|
||||
}}
|
||||
title={showChatSidebar ? "Hide chat" : "Chat about this file"}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content + Chat sidebar row */}
|
||||
{/* Content area */}
|
||||
<div className="flex-1 flex min-h-0">
|
||||
{/* Content area */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<ContentRenderer
|
||||
content={content}
|
||||
workspaceExists={workspaceExists}
|
||||
tree={tree}
|
||||
activePath={activePath}
|
||||
members={context?.members}
|
||||
onNodeSelect={handleNodeSelect}
|
||||
onNavigateToObject={handleNavigateToObject}
|
||||
onRefreshObject={refreshCurrentObject}
|
||||
onRefreshTree={refreshTree}
|
||||
onNavigate={handleEditorNavigate}
|
||||
onOpenEntry={handleOpenEntry}
|
||||
searchFn={searchIndex}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Chat sidebar (file-scoped) */}
|
||||
{fileContext && showChatSidebar && (
|
||||
<aside
|
||||
className="flex-shrink-0 border-l"
|
||||
style={{
|
||||
width: 380,
|
||||
borderColor: "var(--color-border)",
|
||||
background: "var(--color-bg)",
|
||||
}}
|
||||
>
|
||||
{showMainChat ? (
|
||||
/* Main chat view (default when no file is selected) */
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<ChatPanel
|
||||
compact
|
||||
fileContext={fileContext}
|
||||
onFileChanged={handleFileChanged}
|
||||
ref={chatRef}
|
||||
onActiveSessionChange={(id) => {
|
||||
setActiveSessionId(id);
|
||||
}}
|
||||
onSessionsChange={refreshSessions}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* File content area */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<ContentRenderer
|
||||
content={content}
|
||||
workspaceExists={workspaceExists}
|
||||
tree={tree}
|
||||
activePath={activePath}
|
||||
members={context?.members}
|
||||
onNodeSelect={handleNodeSelect}
|
||||
onNavigateToObject={handleNavigateToObject}
|
||||
onRefreshObject={refreshCurrentObject}
|
||||
onRefreshTree={refreshTree}
|
||||
onNavigate={handleEditorNavigate}
|
||||
onOpenEntry={handleOpenEntry}
|
||||
searchFn={searchIndex}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Chat sidebar (file-scoped) */}
|
||||
{fileContext && showChatSidebar && (
|
||||
<aside
|
||||
className="flex-shrink-0 border-l"
|
||||
style={{
|
||||
width: 380,
|
||||
borderColor: "var(--color-border)",
|
||||
background: "var(--color-bg)",
|
||||
}}
|
||||
>
|
||||
<ChatPanel
|
||||
compact
|
||||
fileContext={fileContext}
|
||||
onFileChanged={handleFileChanged}
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user