Replays all Ironclaw-specific changes (176 commits) onto the latest upstream Openclaw release (2026.2.22). Conflicts auto-resolved in favor of Ironclaw to guarantee zero change loss. Merge base: cbc3de6c9 (2026-02-16) Upstream: a37e12eab (upstream/main, 2026.2.22) Ironclaw: 3009566c9 (origin/main, 2026.2.15-1.9) Backup: ironclaw-backup-pre-sync Conflict resolutions: - 6 GitHub workflow files: deleted (Ironclaw intentionally stripped) - src/sessions/session-key-utils.test.ts: kept (Ironclaw modified) - Duplicate imports from merge: deduplicated - Unused imports from upstream code Ironclaw overrode: removed - Broken test indentation from merge: fixed Co-authored-by: Cursor <cursoragent@cursor.com>
2906 lines
104 KiB
TypeScript
2906 lines
104 KiB
TypeScript
"use client";
|
|
|
|
import { Suspense, useEffect, useState, useCallback, useRef, useMemo } from "react";
|
|
import { useSearchParams, useRouter } from "next/navigation";
|
|
import { WorkspaceSidebar } from "../components/workspace/workspace-sidebar";
|
|
import { type TreeNode } from "../components/workspace/file-manager-tree";
|
|
import { useWorkspaceWatcher } from "../hooks/use-workspace-watcher";
|
|
import { ObjectTable } from "../components/workspace/object-table";
|
|
import { ObjectKanban } from "../components/workspace/object-kanban";
|
|
import { DocumentView } from "../components/workspace/document-view";
|
|
import { FileViewer, isSpreadsheetFile } from "../components/workspace/file-viewer";
|
|
import { HtmlViewer } from "../components/workspace/html-viewer";
|
|
import { CodeViewer } from "../components/workspace/code-viewer";
|
|
import { MediaViewer, detectMediaType, type MediaType } from "../components/workspace/media-viewer";
|
|
import { DatabaseViewer, DuckDBMissing } from "../components/workspace/database-viewer";
|
|
import { Breadcrumbs } from "../components/workspace/breadcrumbs";
|
|
import { ChatSessionsSidebar } from "../components/workspace/chat-sessions-sidebar";
|
|
import { EmptyState } from "../components/workspace/empty-state";
|
|
import { ReportViewer } from "../components/charts/report-viewer";
|
|
import { ChatPanel, type ChatPanelHandle, type SubagentSpawnInfo } from "../components/chat-panel";
|
|
import { SubagentPanel } from "../components/subagent-panel";
|
|
import { EntryDetailModal } from "../components/workspace/entry-detail-modal";
|
|
import { useSearchIndex } from "@/lib/search-index";
|
|
import { parseWorkspaceLink, isWorkspaceLink } from "@/lib/workspace-links";
|
|
import { isCodeFile } from "@/lib/report-utils";
|
|
import { CronDashboard } from "../components/cron/cron-dashboard";
|
|
import { CronJobDetail } from "../components/cron/cron-job-detail";
|
|
import type { CronJob, CronJobsResponse } from "../types/cron";
|
|
import { useIsMobile } from "../hooks/use-mobile";
|
|
import { ObjectFilterBar } from "../components/workspace/object-filter-bar";
|
|
import { type FilterGroup, type SortRule, type SavedView, emptyFilterGroup, serializeFilters } from "@/lib/object-filters";
|
|
import { UnicodeSpinner } from "../components/unicode-spinner";
|
|
|
|
// --- Types ---
|
|
|
|
type WorkspaceContext = {
|
|
exists: boolean;
|
|
organization?: { id?: string; name?: string; slug?: string };
|
|
members?: Array<{ id: string; name: string; email: string; role: string }>;
|
|
};
|
|
|
|
type ReverseRelation = {
|
|
fieldName: string;
|
|
sourceObjectName: string;
|
|
sourceObjectId: string;
|
|
displayField: string;
|
|
entries: Record<string, Array<{ id: string; label: string }>>;
|
|
};
|
|
|
|
type ObjectData = {
|
|
object: {
|
|
id: string;
|
|
name: string;
|
|
description?: string;
|
|
icon?: string;
|
|
default_view?: string;
|
|
display_field?: string;
|
|
};
|
|
fields: Array<{
|
|
id: string;
|
|
name: string;
|
|
type: string;
|
|
enum_values?: string[];
|
|
enum_colors?: string[];
|
|
enum_multiple?: boolean;
|
|
related_object_id?: string;
|
|
relationship_type?: string;
|
|
related_object_name?: string;
|
|
sort_order?: number;
|
|
}>;
|
|
statuses: Array<{
|
|
id: string;
|
|
name: string;
|
|
color?: string;
|
|
sort_order?: number;
|
|
}>;
|
|
entries: Record<string, unknown>[];
|
|
relationLabels?: Record<string, Record<string, string>>;
|
|
reverseRelations?: ReverseRelation[];
|
|
effectiveDisplayField?: string;
|
|
savedViews?: import("@/lib/object-filters").SavedView[];
|
|
activeView?: string;
|
|
totalCount?: number;
|
|
page?: number;
|
|
pageSize?: number;
|
|
};
|
|
|
|
type FileData = {
|
|
content: string;
|
|
type: "markdown" | "yaml" | "code" | "text";
|
|
};
|
|
|
|
type ContentState =
|
|
| { kind: "none" }
|
|
| { kind: "loading" }
|
|
| { kind: "object"; data: ObjectData }
|
|
| { kind: "document"; data: FileData; title: string }
|
|
| { kind: "file"; data: FileData; filename: string }
|
|
| { kind: "code"; data: FileData; filename: string }
|
|
| { kind: "media"; url: string; mediaType: MediaType; filename: string; filePath: string }
|
|
| { kind: "spreadsheet"; url: string; filename: string }
|
|
| { kind: "html"; rawUrl: string; contentUrl: string; filename: string }
|
|
| { kind: "database"; dbPath: string; filename: string }
|
|
| { kind: "report"; reportPath: string; filename: string }
|
|
| { kind: "directory"; node: TreeNode }
|
|
| { kind: "cron-dashboard" }
|
|
| { kind: "cron-job"; jobId: string; job: CronJob }
|
|
| { kind: "duckdb-missing" };
|
|
|
|
type SidebarPreviewContent =
|
|
| { kind: "document"; data: FileData; title: string }
|
|
| { kind: "file"; data: FileData; filename: string }
|
|
| { kind: "code"; data: FileData; filename: string }
|
|
| { kind: "media"; url: string; mediaType: MediaType; filename: string; filePath: string }
|
|
| { kind: "database"; dbPath: string; filename: string }
|
|
| { kind: "directory"; path: string; name: string };
|
|
|
|
type ChatSidebarPreviewState =
|
|
| { status: "loading"; path: string; filename: string }
|
|
| { status: "error"; path: string; filename: string; message: string }
|
|
| { status: "ready"; path: string; filename: string; content: SidebarPreviewContent };
|
|
|
|
type WebSession = {
|
|
id: string;
|
|
title: string;
|
|
createdAt: number;
|
|
updatedAt: number;
|
|
messageCount: number;
|
|
};
|
|
|
|
// --- Helpers ---
|
|
|
|
/** Detect virtual paths (skills, memories) that live outside the main workspace. */
|
|
function isVirtualPath(path: string): boolean {
|
|
return path.startsWith("~") && !path.startsWith("~/");
|
|
}
|
|
|
|
/** Detect absolute filesystem paths (browse mode). */
|
|
function isAbsolutePath(path: string): boolean {
|
|
return path.startsWith("/");
|
|
}
|
|
|
|
/** Detect home-relative filesystem paths (e.g. ~/Desktop/file.txt). */
|
|
function isHomeRelativePath(path: string): boolean {
|
|
return path.startsWith("~/");
|
|
}
|
|
|
|
/** Pick the right file API endpoint based on virtual vs real vs absolute paths. */
|
|
function fileApiUrl(path: string): string {
|
|
if (isVirtualPath(path)) {
|
|
return `/api/workspace/virtual-file?path=${encodeURIComponent(path)}`;
|
|
}
|
|
if (isAbsolutePath(path) || isHomeRelativePath(path)) {
|
|
return `/api/workspace/browse-file?path=${encodeURIComponent(path)}`;
|
|
}
|
|
return `/api/workspace/file?path=${encodeURIComponent(path)}`;
|
|
}
|
|
|
|
/** Pick the right raw file URL for media preview. */
|
|
function rawFileUrl(path: string): string {
|
|
if (isAbsolutePath(path) || isHomeRelativePath(path)) {
|
|
return `/api/workspace/browse-file?path=${encodeURIComponent(path)}&raw=true`;
|
|
}
|
|
return `/api/workspace/raw-file?path=${encodeURIComponent(path)}`;
|
|
}
|
|
|
|
const LEFT_SIDEBAR_MIN = 200;
|
|
const LEFT_SIDEBAR_MAX = 480;
|
|
const RIGHT_SIDEBAR_MIN = 260;
|
|
const RIGHT_SIDEBAR_MAX = 900;
|
|
const STORAGE_LEFT = "ironclaw-workspace-left-sidebar-width";
|
|
const STORAGE_RIGHT = "ironclaw-workspace-right-sidebar-width";
|
|
|
|
function clamp(n: number, min: number, max: number): number {
|
|
return Math.min(max, Math.max(min, n));
|
|
}
|
|
|
|
/** Vertical resize handle; uses cursor position so the handle follows the mouse (no stuck-at-limit). */
|
|
function ResizeHandle({
|
|
mode,
|
|
containerRef,
|
|
min,
|
|
max,
|
|
onResize,
|
|
}: {
|
|
mode: "left" | "right";
|
|
containerRef: React.RefObject<HTMLElement | null>;
|
|
min: number;
|
|
max: number;
|
|
onResize: (width: number) => void;
|
|
}) {
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const onMouseDown = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
setIsDragging(true);
|
|
const move = (ev: MouseEvent) => {
|
|
const el = containerRef.current;
|
|
if (!el) {return;}
|
|
const rect = el.getBoundingClientRect();
|
|
const width =
|
|
mode === "left"
|
|
? ev.clientX - rect.left
|
|
: rect.right - ev.clientX;
|
|
onResize(clamp(width, min, max));
|
|
};
|
|
const up = () => {
|
|
setIsDragging(false);
|
|
document.removeEventListener("mousemove", move);
|
|
document.removeEventListener("mouseup", up);
|
|
document.body.style.removeProperty("user-select");
|
|
document.body.style.removeProperty("cursor");
|
|
document.body.classList.remove("resizing");
|
|
};
|
|
document.body.style.setProperty("user-select", "none");
|
|
document.body.style.setProperty("cursor", "col-resize");
|
|
document.body.classList.add("resizing");
|
|
document.addEventListener("mousemove", move);
|
|
document.addEventListener("mouseup", up);
|
|
},
|
|
[containerRef, mode, min, max, onResize],
|
|
);
|
|
const showHover = isDragging || undefined;
|
|
return (
|
|
<div
|
|
role="separator"
|
|
aria-orientation="vertical"
|
|
onMouseDown={onMouseDown}
|
|
className={`cursor-col-resize flex justify-center transition-colors ${showHover ? "bg-blue-600/30" : "hover:bg-blue-600/30"}`}
|
|
style={{ position: "absolute", [mode === "left" ? "right" : "left"]: -2, top: 0, bottom: 0, width: 4, zIndex: 20 }}
|
|
/>
|
|
);
|
|
}
|
|
|
|
/** Find a node in the tree by exact path. */
|
|
function findNode(
|
|
tree: TreeNode[],
|
|
path: string,
|
|
): TreeNode | null {
|
|
for (const node of tree) {
|
|
if (node.path === path) {return node;}
|
|
if (node.children) {
|
|
const found = findNode(node.children, path);
|
|
if (found) {return found;}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Extract the object name from a tree path (last segment). */
|
|
function objectNameFromPath(path: string): string {
|
|
const segments = path.split("/");
|
|
return segments[segments.length - 1];
|
|
}
|
|
|
|
/** Infer a tree node type from filename extension for ad-hoc path previews. */
|
|
function inferNodeTypeFromFileName(fileName: string): TreeNode["type"] {
|
|
const ext = fileName.split(".").pop()?.toLowerCase() ?? "";
|
|
if (ext === "md" || ext === "mdx") {return "document";}
|
|
if (ext === "duckdb" || ext === "sqlite" || ext === "sqlite3" || ext === "db") {return "database";}
|
|
return "file";
|
|
}
|
|
|
|
/** Normalize chat path references (supports file:// URLs). */
|
|
function normalizeChatPath(path: string): string {
|
|
const trimmed = path.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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve a path with fallback strategies:
|
|
* 1. Exact match
|
|
* 2. Try with knowledge/ prefix
|
|
* 3. Try stripping knowledge/ prefix
|
|
* 4. Match last segment against object names
|
|
*/
|
|
function resolveNode(
|
|
tree: TreeNode[],
|
|
path: string,
|
|
): TreeNode | null {
|
|
let node = findNode(tree, path);
|
|
if (node) {return node;}
|
|
|
|
if (!path.startsWith("knowledge/")) {
|
|
node = findNode(tree, `knowledge/${path}`);
|
|
if (node) {return node;}
|
|
}
|
|
|
|
if (path.startsWith("knowledge/")) {
|
|
node = findNode(tree, path.slice("knowledge/".length));
|
|
if (node) {return node;}
|
|
}
|
|
|
|
const lastSegment = path.split("/").pop();
|
|
if (lastSegment) {
|
|
function findByName(nodes: TreeNode[]): TreeNode | null {
|
|
for (const n of nodes) {
|
|
if (n.type === "object" && objectNameFromPath(n.path) === lastSegment) {return n;}
|
|
if (n.children) {
|
|
const found = findByName(n.children);
|
|
if (found) {return found;}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
node = findByName(tree);
|
|
if (node) {return node;}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// --- Main Page ---
|
|
|
|
export default function WorkspacePage() {
|
|
return (
|
|
<Suspense fallback={
|
|
<div className="flex h-screen items-center justify-center" style={{ background: "var(--color-bg)" }}>
|
|
<UnicodeSpinner name="braille" className="text-2xl" style={{ color: "var(--color-text-muted)" }} />
|
|
</div>
|
|
}>
|
|
<WorkspacePageInner />
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
function WorkspacePageInner() {
|
|
const searchParams = useSearchParams();
|
|
const router = useRouter();
|
|
const initialPathHandled = useRef(false);
|
|
|
|
// Chat panel ref for session management
|
|
const chatRef = useRef<ChatPanelHandle>(null);
|
|
// Compact (file-scoped) chat panel ref for sidebar drag-and-drop
|
|
const compactChatRef = useRef<ChatPanelHandle>(null);
|
|
// Root layout ref for resize handle position (handle follows cursor)
|
|
const layoutRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Live-reactive tree via SSE watcher (with browse-mode support)
|
|
const {
|
|
tree, loading: treeLoading, exists: workspaceExists, refresh: refreshTree,
|
|
reconnect: reconnectWorkspace,
|
|
browseDir, setBrowseDir, parentDir: browseParentDir, workspaceRoot, openclawDir,
|
|
activeProfile,
|
|
showHidden, setShowHidden,
|
|
} = useWorkspaceWatcher();
|
|
|
|
// handleProfileSwitch is defined below fetchSessions/fetchCronJobs (avoids TDZ)
|
|
|
|
// Search index for @ mention fuzzy search (files + entries)
|
|
const { search: searchIndex } = useSearchIndex();
|
|
|
|
const [context, setContext] = useState<WorkspaceContext | null>(null);
|
|
const [activePath, setActivePath] = useState<string | null>(null);
|
|
const [content, setContent] = useState<ContentState>({ kind: "none" });
|
|
const [showChatSidebar, setShowChatSidebar] = useState(true);
|
|
const [chatSidebarPreview, setChatSidebarPreview] = useState<ChatSidebarPreviewState | null>(null);
|
|
|
|
// Chat session state
|
|
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
|
const [sessions, setSessions] = useState<WebSession[]>([]);
|
|
const [sessionsLoading, setSessionsLoading] = useState(true);
|
|
const [sidebarRefreshKey, setSidebarRefreshKey] = useState(0);
|
|
const [streamingSessionIds, setStreamingSessionIds] = useState<Set<string>>(new Set());
|
|
|
|
// Subagent tracking
|
|
const [subagents, setSubagents] = useState<SubagentSpawnInfo[]>([]);
|
|
const [activeSubagentKey, setActiveSubagentKey] = useState<string | null>(null);
|
|
|
|
const handleSubagentSpawned = useCallback((info: SubagentSpawnInfo) => {
|
|
setSubagents((prev) => {
|
|
const idx = prev.findIndex((sa) => sa.childSessionKey === info.childSessionKey);
|
|
if (idx >= 0) {
|
|
// Update status if changed
|
|
if (prev[idx].status === info.status) {return prev;}
|
|
const updated = [...prev];
|
|
updated[idx] = { ...prev[idx], ...info };
|
|
return updated;
|
|
}
|
|
return [...prev, info];
|
|
});
|
|
}, []);
|
|
|
|
const handleSelectSubagent = useCallback((sessionKey: string) => {
|
|
setActiveSubagentKey(sessionKey);
|
|
}, []);
|
|
|
|
const handleBackFromSubagent = useCallback(() => {
|
|
setActiveSubagentKey(null);
|
|
}, []);
|
|
|
|
// Navigate to a subagent panel when its card is clicked in the chat
|
|
const handleSubagentClickFromChat = useCallback((task: string) => {
|
|
const match = subagents.find((sa) => sa.task === task);
|
|
if (match) {
|
|
setActiveSubagentKey(match.childSessionKey);
|
|
}
|
|
}, [subagents]);
|
|
|
|
// Find the active subagent's info for the panel
|
|
const activeSubagent = useMemo(() => {
|
|
if (!activeSubagentKey) {return null;}
|
|
return subagents.find((sa) => sa.childSessionKey === activeSubagentKey) ?? null;
|
|
}, [activeSubagentKey, subagents]);
|
|
|
|
// Cron jobs state
|
|
const [cronJobs, setCronJobs] = useState<CronJob[]>([]);
|
|
|
|
// Entry detail modal state
|
|
const [entryModal, setEntryModal] = useState<{
|
|
objectName: string;
|
|
entryId: string;
|
|
} | null>(null);
|
|
|
|
// Mobile responsive state
|
|
const isMobile = useIsMobile();
|
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
|
const [chatSessionsOpen, setChatSessionsOpen] = useState(false);
|
|
|
|
// Sidebar collapse state (desktop only).
|
|
const [leftSidebarCollapsed, setLeftSidebarCollapsed] = useState(false);
|
|
const [rightSidebarCollapsed, setRightSidebarCollapsed] = useState(false);
|
|
|
|
// Resizable sidebar widths (desktop only; persisted in localStorage).
|
|
// Use static defaults so server and client match on first render (avoid hydration mismatch).
|
|
const [leftSidebarWidth, setLeftSidebarWidth] = useState(260);
|
|
const [rightSidebarWidth, setRightSidebarWidth] = useState(320);
|
|
useEffect(() => {
|
|
const left = window.localStorage.getItem(STORAGE_LEFT);
|
|
const nLeft = left ? parseInt(left, 10) : NaN;
|
|
if (Number.isFinite(nLeft)) {
|
|
setLeftSidebarWidth(clamp(nLeft, LEFT_SIDEBAR_MIN, LEFT_SIDEBAR_MAX));
|
|
}
|
|
const right = window.localStorage.getItem(STORAGE_RIGHT);
|
|
const nRight = right ? parseInt(right, 10) : NaN;
|
|
if (Number.isFinite(nRight)) {
|
|
setRightSidebarWidth(clamp(nRight, RIGHT_SIDEBAR_MIN, RIGHT_SIDEBAR_MAX));
|
|
}
|
|
}, []);
|
|
useEffect(() => {
|
|
window.localStorage.setItem(STORAGE_LEFT, String(leftSidebarWidth));
|
|
}, [leftSidebarWidth]);
|
|
useEffect(() => {
|
|
window.localStorage.setItem(STORAGE_RIGHT, String(rightSidebarWidth));
|
|
}, [rightSidebarWidth]);
|
|
|
|
// Keyboard shortcuts: Cmd+B = toggle left sidebar, Cmd+Shift+B = toggle right sidebar
|
|
useEffect(() => {
|
|
const handler = (e: KeyboardEvent) => {
|
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "b") {
|
|
e.preventDefault();
|
|
if (e.shiftKey) {
|
|
setRightSidebarCollapsed((v) => !v);
|
|
} else {
|
|
setLeftSidebarCollapsed((v) => !v);
|
|
}
|
|
}
|
|
};
|
|
window.addEventListener("keydown", handler);
|
|
return () => window.removeEventListener("keydown", handler);
|
|
}, []);
|
|
|
|
// Derive file context for chat sidebar directly from activePath (stable across loading).
|
|
// Exclude reserved virtual paths (~chats, ~cron, etc.) where file-scoped chat is irrelevant.
|
|
const fileContext = useMemo(() => {
|
|
if (!activePath) {return undefined;}
|
|
if (isVirtualPath(activePath)) {return undefined;}
|
|
const filename = activePath.split("/").pop() || activePath;
|
|
return { path: activePath, filename, isDirectory: content.kind === "directory" };
|
|
}, [activePath, content.kind]);
|
|
|
|
// Update content state when the agent edits the file (live reload)
|
|
const handleFileChanged = useCallback((newContent: string) => {
|
|
setContent((prev) => {
|
|
if (prev.kind === "document") {
|
|
return { ...prev, data: { ...prev.data, content: newContent } };
|
|
}
|
|
if (prev.kind === "file" || prev.kind === "code") {
|
|
return { ...prev, data: { ...prev.data, content: newContent } };
|
|
}
|
|
return prev;
|
|
});
|
|
}, []);
|
|
|
|
// Fetch workspace context on mount
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
async function loadContext() {
|
|
try {
|
|
const res = await fetch("/api/workspace/context");
|
|
const data = await res.json();
|
|
if (!cancelled) {setContext(data);}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
void loadContext();
|
|
return () => { cancelled = true; };
|
|
}, []);
|
|
|
|
// Fetch chat sessions
|
|
const fetchSessions = useCallback(async () => {
|
|
setSessionsLoading(true);
|
|
try {
|
|
const res = await fetch("/api/web-sessions");
|
|
const data = await res.json();
|
|
setSessions(data.sessions ?? []);
|
|
} catch {
|
|
// ignore
|
|
} finally {
|
|
setSessionsLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void fetchSessions();
|
|
}, [fetchSessions, sidebarRefreshKey]);
|
|
|
|
const refreshSessions = useCallback(() => {
|
|
setSidebarRefreshKey((k) => k + 1);
|
|
}, []);
|
|
|
|
const handleDeleteSession = useCallback(
|
|
async (sessionId: string) => {
|
|
const res = await fetch(`/api/web-sessions/${sessionId}`, { method: "DELETE" });
|
|
if (!res.ok) {return;}
|
|
if (activeSessionId === sessionId) {
|
|
setActiveSessionId(null);
|
|
setActiveSubagentKey(null);
|
|
const remaining = sessions.filter((s) => s.id !== sessionId);
|
|
if (remaining.length > 0) {
|
|
const next = remaining[0];
|
|
setActiveSessionId(next.id);
|
|
void chatRef.current?.loadSession(next.id);
|
|
} else {
|
|
void chatRef.current?.newSession();
|
|
}
|
|
}
|
|
void fetchSessions();
|
|
},
|
|
[activeSessionId, sessions, fetchSessions],
|
|
);
|
|
|
|
const handleRenameSession = useCallback(
|
|
async (sessionId: string, newTitle: string) => {
|
|
await fetch(`/api/web-sessions/${sessionId}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ title: newTitle }),
|
|
});
|
|
void fetchSessions();
|
|
},
|
|
[fetchSessions],
|
|
);
|
|
|
|
// Poll for active (streaming) agent runs so the sidebar can show indicators.
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
const poll = async () => {
|
|
try {
|
|
const res = await fetch("/api/chat/active");
|
|
if (cancelled) {return;}
|
|
const data = await res.json();
|
|
const ids: string[] = data.sessionIds ?? [];
|
|
setStreamingSessionIds((prev) => {
|
|
// Only update state if the set actually changed (avoid re-renders).
|
|
if (prev.size === ids.length && ids.every((id) => prev.has(id))) {return prev;}
|
|
return new Set(ids);
|
|
});
|
|
} catch {
|
|
// ignore
|
|
}
|
|
};
|
|
void poll();
|
|
const id = setInterval(poll, 3_000);
|
|
return () => { cancelled = true; clearInterval(id); };
|
|
}, []);
|
|
|
|
// Fetch cron jobs for sidebar
|
|
const fetchCronJobs = useCallback(async () => {
|
|
try {
|
|
const res = await fetch("/api/cron/jobs");
|
|
const data: CronJobsResponse = await res.json();
|
|
setCronJobs(data.jobs ?? []);
|
|
} catch {
|
|
// ignore - cron might not be configured
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void fetchCronJobs();
|
|
const id = setInterval(fetchCronJobs, 30_000);
|
|
return () => clearInterval(id);
|
|
}, [fetchCronJobs]);
|
|
|
|
// After profile switch or workspace creation, reconnect SSE + refresh all data
|
|
const handleProfileSwitch = useCallback(() => {
|
|
reconnectWorkspace();
|
|
void fetchSessions();
|
|
void fetchCronJobs();
|
|
setActivePath(null);
|
|
setContent({ kind: "none" });
|
|
setActiveSessionId(null);
|
|
setSubagents([]);
|
|
setActiveSubagentKey(null);
|
|
}, [reconnectWorkspace, fetchSessions, fetchCronJobs]);
|
|
|
|
// Load content when path changes
|
|
const loadContent = useCallback(
|
|
async (node: TreeNode) => {
|
|
setActivePath(node.path);
|
|
setContent({ kind: "loading" });
|
|
|
|
try {
|
|
if (node.type === "object") {
|
|
const name = objectNameFromPath(node.path);
|
|
const res = await fetch(`/api/workspace/objects/${encodeURIComponent(name)}`);
|
|
if (!res.ok) {
|
|
const errData = await res.json().catch(() => ({}));
|
|
if (errData.code === "DUCKDB_NOT_INSTALLED") {
|
|
setContent({ kind: "duckdb-missing" });
|
|
return;
|
|
}
|
|
setContent({ kind: "none" });
|
|
return;
|
|
}
|
|
const data: ObjectData = await res.json();
|
|
setContent({ kind: "object", data });
|
|
} else if (node.type === "document") {
|
|
// Use virtual-file API for ~skills/ paths
|
|
const res = await fetch(fileApiUrl(node.path));
|
|
if (!res.ok) {
|
|
setContent({ kind: "none" });
|
|
return;
|
|
}
|
|
const data: FileData = await res.json();
|
|
setContent({
|
|
kind: "document",
|
|
data,
|
|
title: node.name.replace(/\.md$/, ""),
|
|
});
|
|
} else if (node.type === "database") {
|
|
setContent({ kind: "database", dbPath: node.path, filename: node.name });
|
|
} else if (node.type === "report") {
|
|
setContent({ kind: "report", reportPath: node.path, filename: node.name });
|
|
} else if (node.type === "file") {
|
|
// Spreadsheet files get their own binary viewer
|
|
if (isSpreadsheetFile(node.name)) {
|
|
const url = rawFileUrl(node.path);
|
|
setContent({ kind: "spreadsheet", url, filename: node.name });
|
|
return;
|
|
}
|
|
|
|
// HTML files get an iframe preview
|
|
const ext = node.name.split(".").pop()?.toLowerCase() ?? "";
|
|
if (ext === "html" || ext === "htm") {
|
|
setContent({ kind: "html", rawUrl: rawFileUrl(node.path), contentUrl: fileApiUrl(node.path), filename: node.name });
|
|
return;
|
|
}
|
|
|
|
// Check if this is a media file (image/video/audio/pdf)
|
|
const mediaType = detectMediaType(node.name);
|
|
if (mediaType) {
|
|
const url = rawFileUrl(node.path);
|
|
setContent({ kind: "media", url, mediaType, filename: node.name, filePath: node.path });
|
|
return;
|
|
}
|
|
|
|
const res = await fetch(fileApiUrl(node.path));
|
|
if (!res.ok) {
|
|
setContent({ kind: "none" });
|
|
return;
|
|
}
|
|
const data: FileData = await res.json();
|
|
// Route code files to the syntax-highlighted CodeViewer
|
|
if (isCodeFile(node.name)) {
|
|
setContent({ kind: "code", data, filename: node.name });
|
|
} else {
|
|
setContent({ kind: "file", data, filename: node.name });
|
|
}
|
|
} else if (node.type === "folder") {
|
|
setContent({ kind: "directory", node });
|
|
}
|
|
} catch {
|
|
setContent({ kind: "none" });
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
const handleNodeSelect = useCallback(
|
|
(node: TreeNode) => {
|
|
// --- Browse-mode: detect special OpenClaw directories ---
|
|
// When the user clicks a known OpenClaw folder while browsing the
|
|
// filesystem, switch back to workspace mode or show the appropriate
|
|
// dashboard instead of showing raw files.
|
|
if (browseDir && isAbsolutePath(node.path)) {
|
|
// Clicking the workspace root → restore full workspace mode
|
|
if (workspaceRoot && node.path === workspaceRoot) {
|
|
setBrowseDir(null);
|
|
return;
|
|
}
|
|
if (openclawDir) {
|
|
// Clicking the cron directory → show cron dashboard
|
|
if (node.path === openclawDir + "/cron") {
|
|
setBrowseDir(null);
|
|
setActivePath("~cron");
|
|
setContent({ kind: "cron-dashboard" });
|
|
return;
|
|
}
|
|
// Clicking any web-chat directory → switch to workspace mode & open chats
|
|
if (openclawDir && node.path.startsWith(openclawDir + "/web-chat")) {
|
|
setBrowseDir(null);
|
|
setActivePath(null);
|
|
setContent({ kind: "none" });
|
|
void chatRef.current?.newSession();
|
|
return;
|
|
}
|
|
}
|
|
// Clicking a folder in browse mode → navigate into it so the tree
|
|
// is fetched fresh, AND show it in the main panel with the chat sidebar.
|
|
// Children come from the live tree (same data source as the sidebar),
|
|
// not from the stale node snapshot.
|
|
if (node.type === "folder") {
|
|
setBrowseDir(node.path);
|
|
setActivePath(node.path);
|
|
setContent({ kind: "directory", node: { name: node.name, path: node.path, type: "folder" } });
|
|
return;
|
|
}
|
|
}
|
|
|
|
// --- Virtual path handlers (workspace mode) ---
|
|
// Intercept chat folder item clicks
|
|
if (node.path.startsWith("~chats/")) {
|
|
const sessionId = node.path.slice("~chats/".length);
|
|
setActivePath(null);
|
|
setContent({ kind: "none" });
|
|
setActiveSessionId(sessionId);
|
|
void chatRef.current?.loadSession(sessionId);
|
|
// URL is synced by the activeSessionId effect
|
|
return;
|
|
}
|
|
// Clicking the Chats folder itself opens a new chat
|
|
if (node.path === "~chats") {
|
|
setActivePath(null);
|
|
setContent({ kind: "none" });
|
|
void chatRef.current?.newSession();
|
|
router.replace("/workspace", { scroll: false });
|
|
return;
|
|
}
|
|
// Intercept cron job item clicks
|
|
if (node.path.startsWith("~cron/")) {
|
|
const jobId = node.path.slice("~cron/".length);
|
|
const job = cronJobs.find((j) => j.id === jobId);
|
|
if (job) {
|
|
setActivePath(node.path);
|
|
setContent({ kind: "cron-job", jobId, job });
|
|
router.replace("/workspace", { scroll: false });
|
|
return;
|
|
}
|
|
}
|
|
// Clicking the Cron folder itself opens the dashboard
|
|
if (node.path === "~cron") {
|
|
setActivePath(node.path);
|
|
setContent({ kind: "cron-dashboard" });
|
|
router.replace("/workspace", { scroll: false });
|
|
return;
|
|
}
|
|
void loadContent(node);
|
|
},
|
|
[loadContent, router, cronJobs, browseDir, workspaceRoot, openclawDir, setBrowseDir],
|
|
);
|
|
|
|
const loadSidebarPreviewFromNode = useCallback(
|
|
async (node: TreeNode): Promise<SidebarPreviewContent | null> => {
|
|
if (node.type === "folder") {
|
|
return { kind: "directory", path: node.path, name: node.name };
|
|
}
|
|
if (node.type === "database") {
|
|
return { kind: "database", dbPath: node.path, filename: node.name };
|
|
}
|
|
|
|
const mediaType = detectMediaType(node.name);
|
|
if (mediaType) {
|
|
return {
|
|
kind: "media",
|
|
url: rawFileUrl(node.path),
|
|
mediaType,
|
|
filename: node.name,
|
|
filePath: node.path,
|
|
};
|
|
}
|
|
|
|
const res = await fetch(fileApiUrl(node.path));
|
|
if (!res.ok) {return null;}
|
|
const data: FileData = await res.json();
|
|
|
|
if (node.type === "document" || data.type === "markdown") {
|
|
return {
|
|
kind: "document",
|
|
data,
|
|
title: node.name.replace(/\.mdx?$/, ""),
|
|
};
|
|
}
|
|
if (isCodeFile(node.name)) {
|
|
return { kind: "code", data, filename: node.name };
|
|
}
|
|
return { kind: "file", data, filename: node.name };
|
|
},
|
|
[],
|
|
);
|
|
|
|
// Open inline file-path mentions from chat.
|
|
// In chat mode, render a Dropbox-style preview in the right sidebar.
|
|
const handleFilePathClickFromChat = useCallback(
|
|
async (rawPath: string) => {
|
|
const inputPath = normalizeChatPath(rawPath);
|
|
if (!inputPath) {return false;}
|
|
|
|
// Desktop behavior: always use right-sidebar preview for chat path clicks.
|
|
const shouldPreviewInSidebar = !isMobile;
|
|
|
|
const openNode = async (node: TreeNode) => {
|
|
if (!shouldPreviewInSidebar) {
|
|
handleNodeSelect(node);
|
|
setShowChatSidebar(true);
|
|
return true;
|
|
}
|
|
|
|
// Ensure we are in main-chat layout so the preview panel is visible.
|
|
if (activePath || content.kind !== "none") {
|
|
setActivePath(null);
|
|
setContent({ kind: "none" });
|
|
router.replace("/workspace", { scroll: false });
|
|
}
|
|
|
|
setChatSidebarPreview({
|
|
status: "loading",
|
|
path: node.path,
|
|
filename: node.name,
|
|
});
|
|
const previewContent = await loadSidebarPreviewFromNode(node);
|
|
if (!previewContent) {
|
|
setChatSidebarPreview({
|
|
status: "error",
|
|
path: node.path,
|
|
filename: node.name,
|
|
message: "Could not preview this file.",
|
|
});
|
|
return false;
|
|
}
|
|
setChatSidebarPreview({
|
|
status: "ready",
|
|
path: node.path,
|
|
filename: node.name,
|
|
content: previewContent,
|
|
});
|
|
return true;
|
|
};
|
|
|
|
// For workspace-relative paths, prefer the live tree so we preserve semantics.
|
|
if (
|
|
!isAbsolutePath(inputPath) &&
|
|
!isHomeRelativePath(inputPath) &&
|
|
!inputPath.startsWith("./") &&
|
|
!inputPath.startsWith("../")
|
|
) {
|
|
const node = resolveNode(tree, inputPath);
|
|
if (node) {
|
|
return await openNode(node);
|
|
}
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(`/api/workspace/path-info?path=${encodeURIComponent(inputPath)}`);
|
|
if (!res.ok) {return false;}
|
|
const info = await res.json() as {
|
|
path?: string;
|
|
name?: string;
|
|
type?: "file" | "directory" | "other";
|
|
};
|
|
if (!info.path || !info.name || !info.type) {return false;}
|
|
|
|
// If this absolute path is inside the current workspace, map it
|
|
// back to a workspace-relative node first.
|
|
if (workspaceRoot && (info.path === workspaceRoot || info.path.startsWith(`${workspaceRoot}/`))) {
|
|
const relPath = info.path === workspaceRoot ? "" : info.path.slice(workspaceRoot.length + 1);
|
|
if (relPath) {
|
|
const node = resolveNode(tree, relPath);
|
|
if (node) {
|
|
return await openNode(node);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (info.type === "directory") {
|
|
const dirNode: TreeNode = { name: info.name, path: info.path, type: "folder" };
|
|
if (shouldPreviewInSidebar) {
|
|
return await openNode(dirNode);
|
|
}
|
|
setBrowseDir(info.path);
|
|
setActivePath(info.path);
|
|
setContent({
|
|
kind: "directory",
|
|
node: { name: info.name, path: info.path, type: "folder" },
|
|
});
|
|
setShowChatSidebar(true);
|
|
return true;
|
|
}
|
|
|
|
if (info.type === "file") {
|
|
const fileNode: TreeNode = {
|
|
name: info.name,
|
|
path: info.path,
|
|
type: inferNodeTypeFromFileName(info.name),
|
|
};
|
|
if (shouldPreviewInSidebar) {
|
|
return await openNode(fileNode);
|
|
}
|
|
const parentDir = info.path.split("/").slice(0, -1).join("/") || "/";
|
|
if (isAbsolutePath(info.path)) {
|
|
setBrowseDir(parentDir);
|
|
}
|
|
await loadContent(fileNode);
|
|
setShowChatSidebar(true);
|
|
return true;
|
|
}
|
|
} catch {
|
|
// Ignore -- chat message bubble shows inline error state.
|
|
}
|
|
|
|
return false;
|
|
},
|
|
[activePath, content.kind, isMobile, tree, handleNodeSelect, workspaceRoot, loadSidebarPreviewFromNode, setBrowseDir, loadContent, router],
|
|
);
|
|
|
|
// Build the enhanced tree: real tree + Cron virtual folder at the bottom
|
|
// (Chat sessions live in the right sidebar, not in the tree.)
|
|
// In browse mode, skip virtual folders (they only apply to workspace mode)
|
|
const enhancedTree = useMemo(() => {
|
|
if (browseDir) {
|
|
return tree;
|
|
}
|
|
|
|
const cronStatusIcon = (job: CronJob) => {
|
|
if (!job.enabled) {return "\u25CB";} // circle outline
|
|
if (job.state.runningAtMs) {return "\u25CF";} // filled circle
|
|
if (job.state.lastStatus === "error") {return "\u25C6";} // diamond
|
|
if (job.state.lastStatus === "ok") {return "\u2713";} // check
|
|
return "\u25CB";
|
|
};
|
|
|
|
const cronChildren: TreeNode[] = cronJobs.map((j) => ({
|
|
name: `${cronStatusIcon(j)} ${j.name}`,
|
|
path: `~cron/${j.id}`,
|
|
type: "file" as const,
|
|
virtual: true,
|
|
}));
|
|
|
|
const cronFolder: TreeNode = {
|
|
name: "Cron",
|
|
path: "~cron",
|
|
type: "folder",
|
|
virtual: true,
|
|
children: cronChildren.length > 0 ? cronChildren : undefined,
|
|
};
|
|
|
|
return [...tree, cronFolder];
|
|
}, [tree, cronJobs, browseDir]);
|
|
|
|
// Compute the effective parentDir for ".." navigation.
|
|
// In browse mode: use browseParentDir from the API.
|
|
// In workspace mode: use the parent of the workspace root (allows escaping workspace).
|
|
const effectiveParentDir = useMemo(() => {
|
|
if (browseDir) {
|
|
return browseParentDir;
|
|
}
|
|
// In workspace mode, allow ".." to go up from workspace root
|
|
if (workspaceRoot) {
|
|
const parent = workspaceRoot === "/" ? null : workspaceRoot.split("/").slice(0, -1).join("/") || "/";
|
|
return parent;
|
|
}
|
|
return null;
|
|
}, [browseDir, browseParentDir, workspaceRoot]);
|
|
|
|
// Handle ".." navigation
|
|
const handleNavigateUp = useCallback(() => {
|
|
if (effectiveParentDir != null) {
|
|
setBrowseDir(effectiveParentDir);
|
|
}
|
|
}, [effectiveParentDir, setBrowseDir]);
|
|
|
|
// Return to workspace mode
|
|
const handleGoHome = useCallback(() => {
|
|
setBrowseDir(null);
|
|
}, [setBrowseDir]);
|
|
|
|
// Navigate to the main chat / home panel
|
|
const handleGoToChat = useCallback(() => {
|
|
setActivePath(null);
|
|
setContent({ kind: "none" });
|
|
router.replace("/workspace", { scroll: false });
|
|
}, [router]);
|
|
|
|
// Insert a file mention into the chat editor when a sidebar item is dropped on the chat input.
|
|
// Try the main chat panel first; fall back to the compact (file-scoped) panel.
|
|
const handleSidebarExternalDrop = useCallback((node: TreeNode) => {
|
|
const target = chatRef.current ?? compactChatRef.current;
|
|
target?.insertFileMention?.(node.name, node.path);
|
|
}, []);
|
|
|
|
// Handle file search selection: navigate sidebar to the file's location and open it
|
|
const handleFileSearchSelect = useCallback(
|
|
(item: { name: string; path: string; type: string }) => {
|
|
if (item.type === "folder") {
|
|
// Navigate the sidebar into the folder and show it in the main panel.
|
|
// Children come from the live tree (same data source as the sidebar).
|
|
setBrowseDir(item.path);
|
|
setActivePath(item.path);
|
|
setContent({ kind: "directory", node: { name: item.name, path: item.path, type: "folder" } });
|
|
} else {
|
|
// Navigate the sidebar to the parent directory of the file
|
|
const parentOfFile = item.path.split("/").slice(0, -1).join("/") || "/";
|
|
setBrowseDir(parentOfFile);
|
|
// Open the file in the main panel
|
|
const node: TreeNode = {
|
|
name: item.name,
|
|
path: item.path,
|
|
type: item.type as TreeNode["type"],
|
|
};
|
|
void loadContent(node);
|
|
}
|
|
},
|
|
[setBrowseDir, loadContent],
|
|
);
|
|
|
|
// Sync URL bar with active content / chat state.
|
|
// Uses window.location instead of searchParams in the comparison to
|
|
// avoid a circular dependency (searchParams updates → effect fires →
|
|
// router.replace → searchParams updates → …).
|
|
useEffect(() => {
|
|
const current = new URLSearchParams(window.location.search);
|
|
|
|
if (activePath) {
|
|
// File / content mode — path takes priority over chat.
|
|
if (current.get("path") !== activePath || current.has("chat")) {
|
|
const params = new URLSearchParams();
|
|
params.set("path", activePath);
|
|
const entry = current.get("entry");
|
|
if (entry) {params.set("entry", entry);}
|
|
router.push(`/workspace?${params.toString()}`, { scroll: false });
|
|
}
|
|
} else if (activeSessionId) {
|
|
// Chat mode — no file selected.
|
|
if (current.get("chat") !== activeSessionId || current.has("path")) {
|
|
router.push(`/workspace?chat=${encodeURIComponent(activeSessionId)}`, { scroll: false });
|
|
}
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally excludes searchParams to avoid infinite loop
|
|
}, [activePath, activeSessionId, router]);
|
|
|
|
// Open entry modal handler
|
|
const handleOpenEntry = useCallback(
|
|
(objectName: string, entryId: string) => {
|
|
setEntryModal({ objectName, entryId });
|
|
const params = new URLSearchParams(searchParams.toString());
|
|
params.set("entry", `${objectName}:${entryId}`);
|
|
router.push(`/workspace?${params.toString()}`, { scroll: false });
|
|
},
|
|
[searchParams, router],
|
|
);
|
|
|
|
// Close entry modal handler
|
|
const handleCloseEntry = useCallback(() => {
|
|
setEntryModal(null);
|
|
const params = new URLSearchParams(searchParams.toString());
|
|
params.delete("entry");
|
|
const qs = params.toString();
|
|
router.replace(qs ? `/workspace?${qs}` : "/workspace", { scroll: false });
|
|
}, [searchParams, router]);
|
|
|
|
// Auto-navigate to path/chat from URL query params after tree loads
|
|
useEffect(() => {
|
|
if (initialPathHandled.current || treeLoading || tree.length === 0) {return;}
|
|
|
|
const pathParam = searchParams.get("path");
|
|
const entryParam = searchParams.get("entry");
|
|
const chatParam = searchParams.get("chat");
|
|
|
|
if (pathParam) {
|
|
const node = resolveNode(tree, pathParam);
|
|
if (node) {
|
|
initialPathHandled.current = true;
|
|
void loadContent(node);
|
|
}
|
|
} else if (chatParam) {
|
|
// Restore the active chat session from URL
|
|
initialPathHandled.current = true;
|
|
setActiveSessionId(chatParam);
|
|
setActivePath(null);
|
|
setContent({ kind: "none" });
|
|
void chatRef.current?.loadSession(chatParam);
|
|
}
|
|
|
|
// Also open entry modal from URL if present
|
|
if (entryParam && entryParam.includes(":")) {
|
|
const [objName, eid] = entryParam.split(":", 2);
|
|
if (objName && eid) {
|
|
setEntryModal({ objectName: objName, entryId: eid });
|
|
}
|
|
}
|
|
}, [tree, treeLoading, searchParams, loadContent]);
|
|
|
|
// Handle ?send= URL parameter: open a new chat session and auto-send the message.
|
|
// Used by the "Install DuckDB" button and similar in-app triggers.
|
|
useEffect(() => {
|
|
const sendParam = searchParams.get("send");
|
|
if (!sendParam) {return;}
|
|
|
|
// Clear the send param from the URL immediately
|
|
router.replace("/workspace", { scroll: false });
|
|
|
|
// Show the main chat (clear any active file/content)
|
|
setActivePath(null);
|
|
setContent({ kind: "none" });
|
|
|
|
// Give ChatPanel a frame to mount, then send the message
|
|
requestAnimationFrame(() => {
|
|
void chatRef.current?.sendNewMessage(sendParam);
|
|
});
|
|
}, [searchParams, router]);
|
|
|
|
const handleBreadcrumbNavigate = useCallback(
|
|
(path: string) => {
|
|
if (!path) {
|
|
setActivePath(null);
|
|
setContent({ kind: "none" });
|
|
return;
|
|
}
|
|
|
|
// Absolute paths (browse mode): navigate the sidebar directly.
|
|
// Intermediate parent folders aren't in the browse-mode tree, so
|
|
// resolveNode would fail — call setBrowseDir to update the sidebar.
|
|
if (isAbsolutePath(path)) {
|
|
const name = path.split("/").pop() || path;
|
|
setBrowseDir(path);
|
|
setActivePath(path);
|
|
setContent({ kind: "directory", node: { name, path, type: "folder" } });
|
|
return;
|
|
}
|
|
|
|
// Relative paths (workspace mode): resolve and navigate via handleNodeSelect
|
|
// so virtual paths, chat context, etc. are all handled properly.
|
|
const node = resolveNode(tree, path);
|
|
if (node) {
|
|
handleNodeSelect(node);
|
|
}
|
|
},
|
|
[tree, handleNodeSelect, setBrowseDir],
|
|
);
|
|
|
|
// Navigate to an object by name (used by relation links)
|
|
const handleNavigateToObject = useCallback(
|
|
(objectName: string) => {
|
|
function findObjectNode(nodes: TreeNode[]): TreeNode | null {
|
|
for (const node of nodes) {
|
|
if (node.type === "object" && objectNameFromPath(node.path) === objectName) {
|
|
return node;
|
|
}
|
|
if (node.children) {
|
|
const found = findObjectNode(node.children);
|
|
if (found) {return found;}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
const node = findObjectNode(tree);
|
|
if (node) {void loadContent(node);}
|
|
},
|
|
[tree, loadContent],
|
|
);
|
|
|
|
/**
|
|
* Unified navigate handler for links in the editor and read mode.
|
|
* Handles /workspace?entry=..., /workspace?path=..., and legacy relative paths.
|
|
*/
|
|
const handleEditorNavigate = useCallback(
|
|
(href: string) => {
|
|
// Try parsing as a workspace URL first (/workspace?entry=... or /workspace?path=...)
|
|
const parsed = parseWorkspaceLink(href);
|
|
if (parsed) {
|
|
if (parsed.kind === "entry") {
|
|
handleOpenEntry(parsed.objectName, parsed.entryId);
|
|
return;
|
|
}
|
|
// File/object link -- resolve using the path from the URL
|
|
const node = resolveNode(tree, parsed.path);
|
|
if (node) {
|
|
handleNodeSelect(node);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Fallback: treat as a raw relative path (legacy links)
|
|
const node = resolveNode(tree, href);
|
|
if (node) {
|
|
handleNodeSelect(node);
|
|
}
|
|
},
|
|
[tree, handleNodeSelect, handleOpenEntry],
|
|
);
|
|
|
|
// Refresh the currently displayed object (e.g. after changing display field)
|
|
const refreshCurrentObject = useCallback(async () => {
|
|
if (content.kind !== "object") {return;}
|
|
const name = content.data.object.name;
|
|
try {
|
|
const res = await fetch(`/api/workspace/objects/${encodeURIComponent(name)}`);
|
|
if (!res.ok) {return;}
|
|
const data: ObjectData = await res.json();
|
|
setContent({ kind: "object", data });
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}, [content]);
|
|
|
|
// Auto-refresh the current object view when the workspace tree updates.
|
|
// The SSE watcher triggers tree refreshes on any file change (including
|
|
// .object.yaml edits by the AI agent). We track the tree reference and
|
|
// re-fetch the object data so saved views/filters update live.
|
|
const prevTreeRef = useRef(tree);
|
|
useEffect(() => {
|
|
if (prevTreeRef.current === tree) {return;}
|
|
prevTreeRef.current = tree;
|
|
if (content.kind === "object") {
|
|
void refreshCurrentObject();
|
|
}
|
|
}, [tree, content.kind, refreshCurrentObject]);
|
|
|
|
// Top-level safety net: catch workspace link clicks anywhere in the page
|
|
// to prevent full-page navigation and handle via client-side state instead.
|
|
const handleContainerClick = useCallback(
|
|
(event: React.MouseEvent<HTMLDivElement>) => {
|
|
const target = event.target as HTMLElement;
|
|
const link = target.closest("a");
|
|
if (!link) {return;}
|
|
const href = link.getAttribute("href");
|
|
if (!href) {return;}
|
|
// Intercept /workspace?... links to handle them in-app
|
|
if (isWorkspaceLink(href)) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
handleEditorNavigate(href);
|
|
}
|
|
},
|
|
[handleEditorNavigate],
|
|
);
|
|
|
|
// Cron navigation handlers
|
|
const handleSelectCronJob = useCallback((jobId: string) => {
|
|
const job = cronJobs.find((j) => j.id === jobId);
|
|
if (job) {
|
|
setActivePath(`~cron/${jobId}`);
|
|
setContent({ kind: "cron-job", jobId, job });
|
|
router.replace("/workspace", { scroll: false });
|
|
}
|
|
}, [cronJobs, router]);
|
|
|
|
const handleBackToCronDashboard = useCallback(() => {
|
|
setActivePath("~cron");
|
|
setContent({ kind: "cron-dashboard" });
|
|
router.replace("/workspace", { scroll: false });
|
|
}, [router]);
|
|
|
|
// Derive the active session's title for the header / right sidebar
|
|
const activeSessionTitle = useMemo(() => {
|
|
if (!activeSessionId) {return undefined;}
|
|
const s = sessions.find((sess) => sess.id === activeSessionId);
|
|
return s?.title || undefined;
|
|
}, [activeSessionId, sessions]);
|
|
|
|
// 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
|
|
ref={layoutRef}
|
|
className="flex h-screen"
|
|
style={{ background: "var(--color-main-bg)" }}
|
|
onClick={handleContainerClick}
|
|
>
|
|
{/* Left sidebar — static on desktop (resizable), drawer overlay on mobile */}
|
|
{isMobile ? (
|
|
sidebarOpen && (
|
|
<WorkspaceSidebar
|
|
tree={enhancedTree}
|
|
activePath={activePath}
|
|
onSelect={(node) => { handleNodeSelect(node); setSidebarOpen(false); }}
|
|
onRefresh={refreshTree}
|
|
orgName={context?.organization?.name}
|
|
loading={treeLoading}
|
|
browseDir={browseDir}
|
|
parentDir={effectiveParentDir}
|
|
onNavigateUp={handleNavigateUp}
|
|
onGoHome={handleGoHome}
|
|
onFileSearchSelect={(item) => { handleFileSearchSelect?.(item); setSidebarOpen(false); }}
|
|
workspaceRoot={workspaceRoot}
|
|
onGoToChat={() => { handleGoToChat(); setSidebarOpen(false); }}
|
|
onExternalDrop={handleSidebarExternalDrop}
|
|
activeProfile={activeProfile}
|
|
onProfileSwitch={handleProfileSwitch}
|
|
showHidden={showHidden}
|
|
onToggleHidden={() => setShowHidden((v) => !v)}
|
|
mobile
|
|
onClose={() => setSidebarOpen(false)}
|
|
/>
|
|
)
|
|
) : (
|
|
<>
|
|
{!leftSidebarCollapsed && (
|
|
<div
|
|
className="flex shrink-0 flex-col relative"
|
|
style={{ width: leftSidebarWidth, minWidth: leftSidebarWidth }}
|
|
>
|
|
<ResizeHandle
|
|
mode="left"
|
|
containerRef={layoutRef}
|
|
min={LEFT_SIDEBAR_MIN}
|
|
max={LEFT_SIDEBAR_MAX}
|
|
onResize={setLeftSidebarWidth}
|
|
/>
|
|
<WorkspaceSidebar
|
|
tree={enhancedTree}
|
|
activePath={activePath}
|
|
onSelect={handleNodeSelect}
|
|
onRefresh={refreshTree}
|
|
orgName={context?.organization?.name}
|
|
loading={treeLoading}
|
|
browseDir={browseDir}
|
|
parentDir={effectiveParentDir}
|
|
onNavigateUp={handleNavigateUp}
|
|
onGoHome={handleGoHome}
|
|
onFileSearchSelect={handleFileSearchSelect}
|
|
workspaceRoot={workspaceRoot}
|
|
onGoToChat={handleGoToChat}
|
|
onExternalDrop={handleSidebarExternalDrop}
|
|
activeProfile={activeProfile}
|
|
onProfileSwitch={handleProfileSwitch}
|
|
showHidden={showHidden}
|
|
onToggleHidden={() => setShowHidden((v) => !v)}
|
|
width={leftSidebarWidth}
|
|
onCollapse={() => setLeftSidebarCollapsed(true)}
|
|
/>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Expand left sidebar button (shown when collapsed) */}
|
|
{!isMobile && leftSidebarCollapsed && (
|
|
<div className="shrink-0 flex flex-col items-center pt-2.5 px-1.5">
|
|
<button
|
|
type="button"
|
|
onClick={() => setLeftSidebarCollapsed(false)}
|
|
className="p-1.5 rounded-md transition-colors hover:bg-black/5"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
title="Show sidebar (⌘B)"
|
|
>
|
|
<svg width="16" height="16" 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>
|
|
)}
|
|
|
|
{/* Main content */}
|
|
<main className="flex-1 flex flex-col min-w-0 overflow-hidden" style={{ background: "var(--color-main-bg)" }}>
|
|
{/* Mobile top bar — always visible on mobile */}
|
|
{isMobile && (
|
|
<div
|
|
className="px-3 py-2 border-b flex-shrink-0 flex items-center justify-between gap-2"
|
|
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => setSidebarOpen(true)}
|
|
className="p-2 rounded-lg flex-shrink-0"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
title="Open sidebar"
|
|
>
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<line x1="4" x2="20" y1="12" y2="12" /><line x1="4" x2="20" y1="6" y2="6" /><line x1="4" x2="20" y1="18" y2="18" />
|
|
</svg>
|
|
</button>
|
|
<div className="flex-1 min-w-0 text-sm font-medium truncate" style={{ color: "var(--color-text)" }}>
|
|
{activePath ? activePath.split("/").pop() : (context?.organization?.name || "Workspace")}
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
{activePath && content.kind !== "none" && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setActivePath(null);
|
|
setContent({ kind: "none" });
|
|
router.replace("/workspace", { scroll: false });
|
|
}}
|
|
className="p-2 rounded-lg 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>
|
|
)}
|
|
{showMainChat && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setChatSessionsOpen(true)}
|
|
className="p-2 rounded-lg flex-shrink-0"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
title="Chat sessions"
|
|
>
|
|
<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>
|
|
)}
|
|
|
|
{/* When a file is selected: show top bar with breadcrumbs (desktop only, mobile has unified top bar) */}
|
|
{!isMobile && activePath && content.kind !== "none" && (
|
|
<div
|
|
className="px-6 border-b flex-shrink-0 flex items-center justify-between"
|
|
style={{ borderColor: "var(--color-border)" }}
|
|
>
|
|
<Breadcrumbs
|
|
path={activePath}
|
|
onNavigate={handleBreadcrumbNavigate}
|
|
/>
|
|
<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-lg 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 (hidden for reserved/virtual paths) */}
|
|
{fileContext && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowChatSidebar((v) => !v)}
|
|
className="p-1.5 rounded-lg flex-shrink-0"
|
|
style={{
|
|
color: showChatSidebar ? "var(--color-accent)" : "var(--color-text-muted)",
|
|
background: showChatSidebar ? "var(--color-accent-light)" : "transparent",
|
|
}}
|
|
title={showChatSidebar ? "Hide chat" : fileContext.isDirectory ? "Chat about this folder" : "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 area */}
|
|
<div className="flex-1 flex min-h-0">
|
|
{showMainChat ? (
|
|
/* Main chat view (default when no file is selected) */
|
|
<>
|
|
<div className="flex-1 flex flex-col min-w-0" style={{ background: "var(--color-main-bg)" }}>
|
|
{activeSubagent ? (
|
|
<SubagentPanel
|
|
sessionKey={activeSubagent.childSessionKey}
|
|
task={activeSubagent.task}
|
|
label={activeSubagent.label}
|
|
onBack={handleBackFromSubagent}
|
|
/>
|
|
) : (
|
|
<ChatPanel
|
|
ref={chatRef}
|
|
sessionTitle={activeSessionTitle}
|
|
initialSessionId={activeSessionId ?? undefined}
|
|
onActiveSessionChange={(id) => {
|
|
setActiveSessionId(id);
|
|
setActiveSubagentKey(null);
|
|
}}
|
|
onSessionsChange={refreshSessions}
|
|
onSubagentSpawned={handleSubagentSpawned}
|
|
onSubagentClick={handleSubagentClickFromChat}
|
|
onFilePathClick={handleFilePathClickFromChat}
|
|
onDeleteSession={handleDeleteSession}
|
|
onRenameSession={handleRenameSession}
|
|
compact={isMobile}
|
|
/>
|
|
)}
|
|
</div>
|
|
{/* Chat sessions sidebar — static on desktop, drawer overlay on mobile */}
|
|
{isMobile ? (
|
|
chatSessionsOpen && (
|
|
<ChatSessionsSidebar
|
|
sessions={sessions}
|
|
activeSessionId={activeSessionId}
|
|
activeSessionTitle={activeSessionTitle}
|
|
streamingSessionIds={streamingSessionIds}
|
|
subagents={subagents}
|
|
activeSubagentKey={activeSubagentKey}
|
|
loading={sessionsLoading}
|
|
onSelectSession={(sessionId) => {
|
|
setActiveSessionId(sessionId);
|
|
setActiveSubagentKey(null);
|
|
void chatRef.current?.loadSession(sessionId);
|
|
}}
|
|
onNewSession={() => {
|
|
setActiveSessionId(null);
|
|
setActiveSubagentKey(null);
|
|
void chatRef.current?.newSession();
|
|
router.replace("/workspace", { scroll: false });
|
|
setChatSessionsOpen(false);
|
|
}}
|
|
onSelectSubagent={handleSelectSubagent}
|
|
onDeleteSession={handleDeleteSession}
|
|
onRenameSession={handleRenameSession}
|
|
mobile
|
|
onClose={() => setChatSessionsOpen(false)}
|
|
/>
|
|
)
|
|
) : (
|
|
<>
|
|
{!rightSidebarCollapsed && (
|
|
<div
|
|
className="flex shrink-0 flex-col relative"
|
|
style={{ width: rightSidebarWidth, minWidth: rightSidebarWidth, background: "var(--color-sidebar-bg)" }}
|
|
>
|
|
<ResizeHandle
|
|
mode="right"
|
|
containerRef={layoutRef}
|
|
min={RIGHT_SIDEBAR_MIN}
|
|
max={RIGHT_SIDEBAR_MAX}
|
|
onResize={setRightSidebarWidth}
|
|
/>
|
|
{chatSidebarPreview ? (
|
|
<ChatSidebarPreview
|
|
preview={chatSidebarPreview}
|
|
onClose={() => setChatSidebarPreview(null)}
|
|
/>
|
|
) : (
|
|
<ChatSessionsSidebar
|
|
sessions={sessions}
|
|
activeSessionId={activeSessionId}
|
|
activeSessionTitle={activeSessionTitle}
|
|
streamingSessionIds={streamingSessionIds}
|
|
subagents={subagents}
|
|
activeSubagentKey={activeSubagentKey}
|
|
loading={sessionsLoading}
|
|
onSelectSession={(sessionId) => {
|
|
setActiveSessionId(sessionId);
|
|
setActiveSubagentKey(null);
|
|
void chatRef.current?.loadSession(sessionId);
|
|
}}
|
|
onNewSession={() => {
|
|
setActiveSessionId(null);
|
|
setActiveSubagentKey(null);
|
|
void chatRef.current?.newSession();
|
|
router.replace("/workspace", { scroll: false });
|
|
}}
|
|
onSelectSubagent={handleSelectSubagent}
|
|
onDeleteSession={handleDeleteSession}
|
|
onRenameSession={handleRenameSession}
|
|
onCollapse={() => setRightSidebarCollapsed(true)}
|
|
width={rightSidebarWidth}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
{rightSidebarCollapsed && (
|
|
<div className="shrink-0 flex flex-col items-center pt-2.5 px-1.5">
|
|
<button
|
|
type="button"
|
|
onClick={() => setRightSidebarCollapsed(false)}
|
|
className="p-1.5 rounded-md transition-colors hover:bg-black/5"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
title="Show chat sidebar (⌘⇧B)"
|
|
>
|
|
<svg width="16" height="16" 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>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</>
|
|
) : (
|
|
<>
|
|
{/* File content area */}
|
|
<div className="flex-1 overflow-y-auto">
|
|
<ContentRenderer
|
|
content={content}
|
|
workspaceExists={workspaceExists}
|
|
tree={tree}
|
|
activePath={activePath}
|
|
browseDir={browseDir}
|
|
treeLoading={treeLoading}
|
|
members={context?.members}
|
|
onNodeSelect={handleNodeSelect}
|
|
onNavigateToObject={handleNavigateToObject}
|
|
onRefreshObject={refreshCurrentObject}
|
|
onRefreshTree={refreshTree}
|
|
onNavigate={handleEditorNavigate}
|
|
onOpenEntry={handleOpenEntry}
|
|
searchFn={searchIndex}
|
|
onSelectCronJob={handleSelectCronJob}
|
|
onBackToCronDashboard={handleBackToCronDashboard}
|
|
onWorkspaceCreated={handleProfileSwitch}
|
|
/>
|
|
</div>
|
|
|
|
{/* Chat sidebar (file/folder-scoped) — hidden for reserved paths, hidden on mobile */}
|
|
{!isMobile && fileContext && showChatSidebar && !rightSidebarCollapsed && (
|
|
<>
|
|
<aside
|
|
className="flex-shrink-0 border-l flex flex-col relative"
|
|
style={{
|
|
width: rightSidebarWidth,
|
|
borderColor: "var(--color-border)",
|
|
background: "var(--color-bg)",
|
|
}}
|
|
>
|
|
<ResizeHandle
|
|
mode="right"
|
|
containerRef={layoutRef}
|
|
min={RIGHT_SIDEBAR_MIN}
|
|
max={RIGHT_SIDEBAR_MAX}
|
|
onResize={setRightSidebarWidth}
|
|
/>
|
|
<ChatPanel
|
|
ref={compactChatRef}
|
|
compact
|
|
fileContext={fileContext}
|
|
onFileChanged={handleFileChanged}
|
|
onFilePathClick={handleFilePathClickFromChat}
|
|
/>
|
|
</aside>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</main>
|
|
|
|
{/* Entry detail modal (rendered on top of everything) */}
|
|
{entryModal && (
|
|
<EntryDetailModal
|
|
objectName={entryModal.objectName}
|
|
entryId={entryModal.entryId}
|
|
members={context?.members}
|
|
onClose={handleCloseEntry}
|
|
onNavigateEntry={(objName, eid) => handleOpenEntry(objName, eid)}
|
|
onNavigateObject={(objName) => {
|
|
handleCloseEntry();
|
|
handleNavigateToObject(objName);
|
|
}}
|
|
onRefresh={refreshCurrentObject}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function previewFileTypeBadge(filename: string): { label: string; color: string } {
|
|
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
|
|
if (ext === "pdf") {return { label: "PDF", color: "#ef4444" };}
|
|
if (["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "heic", "avif"].includes(ext)) {return { label: "Image", color: "#3b82f6" };}
|
|
if (["mp4", "webm", "mov", "avi", "mkv"].includes(ext)) {return { label: "Video", color: "#8b5cf6" };}
|
|
if (["mp3", "wav", "ogg", "m4a", "aac", "flac"].includes(ext)) {return { label: "Audio", color: "#f59e0b" };}
|
|
if (["md", "mdx"].includes(ext)) {return { label: "Markdown", color: "#10b981" };}
|
|
if (["ts", "tsx", "js", "jsx", "py", "go", "rs", "java", "rb", "swift", "kt", "c", "cpp", "h"].includes(ext)) {return { label: ext.toUpperCase(), color: "#3b82f6" };}
|
|
if (["json", "yaml", "yml", "toml", "xml", "csv"].includes(ext)) {return { label: ext.toUpperCase(), color: "#6b7280" };}
|
|
if (["duckdb", "sqlite", "sqlite3", "db"].includes(ext)) {return { label: "Database", color: "#6366f1" };}
|
|
return { label: ext.toUpperCase() || "File", color: "#6b7280" };
|
|
}
|
|
|
|
function shortenPreviewPath(p: string): string {
|
|
return p.replace(/^\/Users\/[^/]+/, "~").replace(/^\/home\/[^/]+/, "~");
|
|
}
|
|
|
|
function ChatSidebarPreview({
|
|
preview,
|
|
onClose,
|
|
}: {
|
|
preview: ChatSidebarPreviewState;
|
|
onClose: () => void;
|
|
}) {
|
|
const badge = previewFileTypeBadge(preview.filename);
|
|
|
|
const openInFinder = useCallback(async () => {
|
|
try {
|
|
await fetch("/api/workspace/open-file", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ path: preview.path, reveal: true }),
|
|
});
|
|
} catch { /* ignore */ }
|
|
}, [preview.path]);
|
|
|
|
const openWithSystem = useCallback(async () => {
|
|
try {
|
|
await fetch("/api/workspace/open-file", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ path: preview.path }),
|
|
});
|
|
} catch { /* ignore */ }
|
|
}, [preview.path]);
|
|
|
|
const downloadUrl = preview.status === "ready" && preview.content.kind === "media"
|
|
? preview.content.url
|
|
: null;
|
|
|
|
let body: React.ReactNode;
|
|
|
|
if (preview.status === "loading") {
|
|
body = (
|
|
<div className="flex flex-col h-full items-center justify-center gap-3">
|
|
<UnicodeSpinner
|
|
name="braille"
|
|
className="text-2xl"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
/>
|
|
<p className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
|
Loading preview...
|
|
</p>
|
|
</div>
|
|
);
|
|
} else if (preview.status === "error") {
|
|
body = (
|
|
<div className="flex flex-col h-full items-center justify-center gap-4 px-6">
|
|
<div
|
|
className="w-14 h-14 rounded-2xl flex items-center justify-center"
|
|
style={{ background: "color-mix(in srgb, var(--color-error) 10%, transparent)" }}
|
|
>
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--color-error)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<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" />
|
|
</svg>
|
|
</div>
|
|
<div className="text-center">
|
|
<p className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
|
|
Preview unavailable
|
|
</p>
|
|
<p className="text-xs mt-1" style={{ color: "var(--color-text-muted)" }}>
|
|
{preview.message}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
} else {
|
|
const c = preview.content;
|
|
switch (c.kind) {
|
|
case "media":
|
|
if (c.mediaType === "pdf") {
|
|
// Hide the browser's built-in PDF toolbar for a cleaner look
|
|
const pdfUrl = c.url + (c.url.includes("#") ? "&" : "#") + "toolbar=0&navpanes=0&scrollbar=1";
|
|
body = (
|
|
<iframe
|
|
src={pdfUrl}
|
|
className="w-full h-full"
|
|
style={{ border: "none", colorScheme: "light" }}
|
|
title={`Preview: ${c.filename}`}
|
|
/>
|
|
);
|
|
} else if (c.mediaType === "image") {
|
|
body = (
|
|
<div className="flex items-center justify-center h-full p-4 overflow-auto" style={{ background: "var(--color-bg)" }}>
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src={c.url}
|
|
alt={c.filename}
|
|
className="max-w-full max-h-full object-contain rounded-lg"
|
|
style={{ boxShadow: "0 2px 16px rgba(0,0,0,0.08)" }}
|
|
draggable={false}
|
|
/>
|
|
</div>
|
|
);
|
|
} else if (c.mediaType === "video") {
|
|
body = (
|
|
<div className="flex items-center justify-center h-full p-4" style={{ background: "#000" }}>
|
|
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
|
<video src={c.url} controls className="max-w-full max-h-full rounded-lg" />
|
|
</div>
|
|
);
|
|
} else if (c.mediaType === "audio") {
|
|
body = (
|
|
<div className="flex flex-col items-center justify-center h-full gap-6 px-6">
|
|
<div
|
|
className="w-20 h-20 rounded-2xl flex items-center justify-center"
|
|
style={{ background: "linear-gradient(135deg, #f59e0b20, #f59e0b08)" }}
|
|
>
|
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" />
|
|
</svg>
|
|
</div>
|
|
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
|
<audio src={c.url} controls className="w-full" />
|
|
</div>
|
|
);
|
|
}
|
|
break;
|
|
case "document":
|
|
body = (
|
|
<div className="p-5 overflow-auto h-full">
|
|
<div className="workspace-prose text-sm">
|
|
<DocumentView
|
|
content={c.data.content}
|
|
title={c.title}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
break;
|
|
case "code":
|
|
body = (
|
|
<div className="overflow-auto h-full">
|
|
<CodeViewer content={c.data.content} filename={c.filename} />
|
|
</div>
|
|
);
|
|
break;
|
|
case "file":
|
|
body = (
|
|
<div className="overflow-auto h-full">
|
|
<FileViewer content={c.data.content} filename={c.filename} type={c.data.type === "yaml" ? "yaml" : "text"} />
|
|
</div>
|
|
);
|
|
break;
|
|
case "database":
|
|
body = (
|
|
<div className="overflow-auto h-full">
|
|
<DatabaseViewer dbPath={c.dbPath} filename={c.filename} />
|
|
</div>
|
|
);
|
|
break;
|
|
case "directory":
|
|
body = (
|
|
<div className="flex flex-col items-center justify-center h-full gap-4 px-6">
|
|
<div
|
|
className="w-14 h-14 rounded-2xl flex items-center justify-center"
|
|
style={{ background: "color-mix(in srgb, var(--color-accent) 10%, transparent)" }}
|
|
>
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent)" strokeWidth="1.5" 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>
|
|
</div>
|
|
<p className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
|
|
{c.name}
|
|
</p>
|
|
</div>
|
|
);
|
|
break;
|
|
default:
|
|
body = null;
|
|
}
|
|
}
|
|
|
|
return (
|
|
<aside
|
|
className="h-full border-l flex flex-col"
|
|
style={{
|
|
borderColor: "var(--color-border)",
|
|
background: "var(--color-bg)",
|
|
}}
|
|
>
|
|
{/* Header: close + filename + badge + actions */}
|
|
<div
|
|
className="px-3 py-2.5 flex items-center gap-2 flex-shrink-0"
|
|
style={{ borderBottom: "1px solid var(--color-border)" }}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="p-1 rounded-md transition-colors flex-shrink-0"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
title="Close preview"
|
|
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)"; }}
|
|
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = "transparent"; }}
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
|
|
</svg>
|
|
</button>
|
|
|
|
<span className="text-[13px] font-medium truncate min-w-0" style={{ color: "var(--color-text)" }}>
|
|
{preview.filename}
|
|
</span>
|
|
|
|
<span
|
|
className="text-[10px] font-medium px-1.5 py-[1px] rounded flex-shrink-0"
|
|
style={{
|
|
background: `${badge.color}14`,
|
|
color: badge.color,
|
|
}}
|
|
>
|
|
{badge.label}
|
|
</span>
|
|
|
|
<div className="flex items-center gap-0.5 ml-auto flex-shrink-0">
|
|
<button
|
|
type="button"
|
|
onClick={openWithSystem}
|
|
className="p-1.5 rounded-md transition-colors"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
title="Open with default app"
|
|
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)"; }}
|
|
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = "transparent"; }}
|
|
>
|
|
<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>
|
|
</button>
|
|
{downloadUrl && (
|
|
<a
|
|
href={downloadUrl}
|
|
download={preview.filename}
|
|
className="p-1.5 rounded-md transition-colors"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
title="Download"
|
|
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)"; }}
|
|
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = "transparent"; }}
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" x2="12" y1="15" y2="3" />
|
|
</svg>
|
|
</a>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={openInFinder}
|
|
className="p-1.5 rounded-md transition-colors"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
title="Reveal in Finder"
|
|
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)"; }}
|
|
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = "transparent"; }}
|
|
>
|
|
<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>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Preview body */}
|
|
<div className="flex-1 min-h-0 overflow-hidden">
|
|
{body}
|
|
</div>
|
|
|
|
{/* Footer path */}
|
|
<div
|
|
className="px-3 py-1.5 border-t flex-shrink-0"
|
|
style={{ borderColor: "var(--color-border)" }}
|
|
>
|
|
<p
|
|
className="text-[10px] truncate"
|
|
style={{ color: "var(--color-text-muted)", fontFamily: "'SF Mono', 'Fira Code', monospace" }}
|
|
title={preview.path}
|
|
>
|
|
{shortenPreviewPath(preview.path)}
|
|
</p>
|
|
</div>
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
// --- Content Renderer ---
|
|
|
|
function ContentRenderer({
|
|
content,
|
|
workspaceExists,
|
|
tree,
|
|
activePath,
|
|
browseDir,
|
|
treeLoading,
|
|
members,
|
|
onNodeSelect,
|
|
onNavigateToObject,
|
|
onRefreshObject,
|
|
onRefreshTree,
|
|
onNavigate,
|
|
onOpenEntry,
|
|
searchFn,
|
|
onSelectCronJob,
|
|
onBackToCronDashboard,
|
|
onWorkspaceCreated,
|
|
}: {
|
|
content: ContentState;
|
|
workspaceExists: boolean;
|
|
tree: TreeNode[];
|
|
activePath: string | null;
|
|
/** Current browse directory (absolute path), or null in workspace mode. */
|
|
browseDir?: string | null;
|
|
/** Whether the tree is currently being fetched. */
|
|
treeLoading?: boolean;
|
|
members?: Array<{ id: string; name: string; email: string; role: string }>;
|
|
onNodeSelect: (node: TreeNode) => void;
|
|
onNavigateToObject: (objectName: string) => void;
|
|
onRefreshObject: () => void;
|
|
onRefreshTree: () => void;
|
|
onNavigate: (href: string) => void;
|
|
onOpenEntry: (objectName: string, entryId: string) => void;
|
|
searchFn: (query: string, limit?: number) => import("@/lib/search-index").SearchIndexItem[];
|
|
onSelectCronJob: (jobId: string) => void;
|
|
onBackToCronDashboard: () => void;
|
|
/** Called after a new workspace is created from the empty state. */
|
|
onWorkspaceCreated?: () => void;
|
|
}) {
|
|
switch (content.kind) {
|
|
case "loading":
|
|
return (
|
|
<div className="flex items-center justify-center h-full">
|
|
<UnicodeSpinner name="braille" className="text-2xl" style={{ color: "var(--color-text-muted)" }} />
|
|
</div>
|
|
);
|
|
|
|
case "object":
|
|
return (
|
|
<ObjectView
|
|
data={content.data}
|
|
members={members}
|
|
onNavigateToObject={onNavigateToObject}
|
|
onRefreshObject={onRefreshObject}
|
|
onOpenEntry={onOpenEntry}
|
|
/>
|
|
);
|
|
|
|
case "document":
|
|
return (
|
|
<DocumentView
|
|
content={content.data.content}
|
|
title={content.title}
|
|
filePath={activePath ?? undefined}
|
|
tree={tree}
|
|
onSave={onRefreshTree}
|
|
onNavigate={onNavigate}
|
|
searchFn={searchFn}
|
|
/>
|
|
);
|
|
|
|
case "file":
|
|
return (
|
|
<FileViewer
|
|
content={content.data.content}
|
|
filename={content.filename}
|
|
type={content.data.type === "yaml" ? "yaml" : "text"}
|
|
/>
|
|
);
|
|
|
|
case "code":
|
|
return (
|
|
<CodeViewer
|
|
content={content.data.content}
|
|
filename={content.filename}
|
|
/>
|
|
);
|
|
|
|
case "media":
|
|
return (
|
|
<MediaViewer
|
|
url={content.url}
|
|
filename={content.filename}
|
|
mediaType={content.mediaType}
|
|
filePath={content.filePath}
|
|
/>
|
|
);
|
|
|
|
case "spreadsheet":
|
|
return (
|
|
<FileViewer
|
|
filename={content.filename}
|
|
type="spreadsheet"
|
|
url={content.url}
|
|
/>
|
|
);
|
|
|
|
case "html":
|
|
return (
|
|
<HtmlViewer
|
|
rawUrl={content.rawUrl}
|
|
contentUrl={content.contentUrl}
|
|
filename={content.filename}
|
|
/>
|
|
);
|
|
|
|
case "database":
|
|
return (
|
|
<DatabaseViewer
|
|
dbPath={content.dbPath}
|
|
filename={content.filename}
|
|
/>
|
|
);
|
|
|
|
case "report":
|
|
return (
|
|
<ReportViewer
|
|
reportPath={content.reportPath}
|
|
/>
|
|
);
|
|
|
|
case "directory": {
|
|
// In browse mode the top-level tree is the live listing of browseDir
|
|
// (same data source as the sidebar). Use it directly instead of the
|
|
// possibly-stale node.children stored in content state.
|
|
const isBrowseLive = browseDir != null && activePath === browseDir;
|
|
if (isBrowseLive && treeLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-full">
|
|
<UnicodeSpinner name="braille" className="text-2xl" style={{ color: "var(--color-text-muted)" }} />
|
|
</div>
|
|
);
|
|
}
|
|
const directoryNode = isBrowseLive
|
|
? { ...content.node, children: tree }
|
|
: content.node;
|
|
return (
|
|
<DirectoryListing
|
|
node={directoryNode}
|
|
onNodeSelect={onNodeSelect}
|
|
/>
|
|
);
|
|
}
|
|
|
|
case "cron-dashboard":
|
|
return (
|
|
<CronDashboard
|
|
onSelectJob={onSelectCronJob}
|
|
/>
|
|
);
|
|
|
|
case "cron-job":
|
|
return (
|
|
<CronJobDetail
|
|
job={content.job}
|
|
onBack={onBackToCronDashboard}
|
|
/>
|
|
);
|
|
|
|
case "duckdb-missing":
|
|
return <DuckDBMissing />;
|
|
|
|
case "none":
|
|
default:
|
|
if (tree.length === 0) {
|
|
return <EmptyState workspaceExists={workspaceExists} onWorkspaceCreated={onWorkspaceCreated} />;
|
|
}
|
|
return <WelcomeView tree={tree} onNodeSelect={onNodeSelect} />;
|
|
}
|
|
}
|
|
|
|
// --- Object View (header + display field selector + table/kanban) ---
|
|
|
|
function ObjectView({
|
|
data,
|
|
members,
|
|
onNavigateToObject,
|
|
onRefreshObject,
|
|
onOpenEntry,
|
|
}: {
|
|
data: ObjectData;
|
|
members?: Array<{ id: string; name: string; email: string; role: string }>;
|
|
onNavigateToObject: (objectName: string) => void;
|
|
onRefreshObject: () => void;
|
|
onOpenEntry?: (objectName: string, entryId: string) => void;
|
|
}) {
|
|
const [updatingDisplayField, setUpdatingDisplayField] = useState(false);
|
|
|
|
// --- Filter state ---
|
|
const [filters, setFilters] = useState<FilterGroup>(() => emptyFilterGroup());
|
|
const [savedViews, setSavedViews] = useState<SavedView[]>(data.savedViews ?? []);
|
|
const [activeViewName, setActiveViewName] = useState<string | undefined>(data.activeView);
|
|
|
|
// --- Server-side pagination state ---
|
|
const [serverPage, setServerPage] = useState(data.page ?? 1);
|
|
const [serverPageSize, setServerPageSize] = useState(data.pageSize ?? 100);
|
|
const [totalCount, setTotalCount] = useState(data.totalCount ?? data.entries.length);
|
|
const [entries, setEntries] = useState(data.entries);
|
|
const [serverSearch, setServerSearch] = useState("");
|
|
const [sortRules, _setSortRules] = useState<SortRule[] | undefined>(undefined);
|
|
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
// Column visibility: maps field IDs to boolean (false = hidden)
|
|
const [viewColumns, setViewColumns] = useState<string[] | undefined>(undefined);
|
|
|
|
// Convert field-name-based columns list to TanStack VisibilityState keyed by field ID
|
|
const columnVisibility = useMemo(() => {
|
|
if (!viewColumns || viewColumns.length === 0) {return undefined;}
|
|
const vis: Record<string, boolean> = {};
|
|
for (const field of data.fields) {
|
|
vis[field.id] = viewColumns.includes(field.name);
|
|
}
|
|
return vis;
|
|
}, [viewColumns, data.fields]);
|
|
|
|
// Fetch entries from server with current pagination/filter/sort/search state
|
|
const fetchEntries = useCallback(async (opts?: {
|
|
page?: number;
|
|
pageSize?: number;
|
|
filters?: FilterGroup;
|
|
sort?: SortRule[];
|
|
search?: string;
|
|
}) => {
|
|
const p = opts?.page ?? serverPage;
|
|
const ps = opts?.pageSize ?? serverPageSize;
|
|
const f = opts?.filters ?? filters;
|
|
const s = opts?.sort ?? sortRules;
|
|
const q = opts?.search ?? serverSearch;
|
|
|
|
const params = new URLSearchParams();
|
|
params.set("page", String(p));
|
|
params.set("pageSize", String(ps));
|
|
if (f && f.rules.length > 0) {
|
|
params.set("filters", serializeFilters(f));
|
|
}
|
|
if (s && s.length > 0) {
|
|
params.set("sort", JSON.stringify(s));
|
|
}
|
|
if (q) {
|
|
params.set("search", q);
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(
|
|
`/api/workspace/objects/${encodeURIComponent(data.object.name)}?${params.toString()}`
|
|
);
|
|
if (!res.ok) {return;}
|
|
const result: ObjectData = await res.json();
|
|
setEntries(result.entries);
|
|
setTotalCount(result.totalCount ?? result.entries.length);
|
|
setServerPage(result.page ?? p);
|
|
setServerPageSize(result.pageSize ?? ps);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}, [serverPage, serverPageSize, filters, sortRules, serverSearch, data.object.name]);
|
|
|
|
// Sync initial data from props (when parent refreshes via SSE)
|
|
useEffect(() => {
|
|
setEntries(data.entries);
|
|
setTotalCount(data.totalCount ?? data.entries.length);
|
|
}, [data.entries, data.totalCount]);
|
|
|
|
// Sync saved views when data changes (e.g. SSE refresh from AI editing .object.yaml)
|
|
useEffect(() => {
|
|
setSavedViews(data.savedViews ?? []);
|
|
if (data.activeView && data.activeView !== activeViewName) {
|
|
const view = (data.savedViews ?? []).find((v) => v.name === data.activeView);
|
|
if (view) {
|
|
setFilters(view.filters ?? emptyFilterGroup());
|
|
setViewColumns(view.columns);
|
|
setActiveViewName(view.name);
|
|
// Re-fetch with new filters from the view
|
|
void fetchEntries({ page: 1, filters: view.filters ?? emptyFilterGroup() });
|
|
}
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [data.savedViews, data.activeView]);
|
|
|
|
// When filters change, reset to page 1 and re-fetch
|
|
const handleFiltersChange = useCallback((newFilters: FilterGroup) => {
|
|
setFilters(newFilters);
|
|
setServerPage(1);
|
|
void fetchEntries({ page: 1, filters: newFilters });
|
|
}, [fetchEntries]);
|
|
|
|
// Server-side search with debounce
|
|
const handleServerSearch = useCallback((query: string) => {
|
|
setServerSearch(query);
|
|
if (searchTimerRef.current) {clearTimeout(searchTimerRef.current);}
|
|
searchTimerRef.current = setTimeout(() => {
|
|
setServerPage(1);
|
|
void fetchEntries({ page: 1, search: query });
|
|
}, 300);
|
|
}, [fetchEntries]);
|
|
|
|
// Page change
|
|
const handlePageChange = useCallback((page: number) => {
|
|
setServerPage(page);
|
|
void fetchEntries({ page });
|
|
}, [fetchEntries]);
|
|
|
|
// Page size change
|
|
const handlePageSizeChange = useCallback((size: number) => {
|
|
setServerPageSize(size);
|
|
setServerPage(1);
|
|
void fetchEntries({ page: 1, pageSize: size });
|
|
}, [fetchEntries]);
|
|
|
|
// Override onRefreshObject to re-fetch with current pagination state
|
|
const handleRefresh = useCallback(() => {
|
|
void fetchEntries();
|
|
onRefreshObject();
|
|
}, [fetchEntries, onRefreshObject]);
|
|
|
|
// Use entries from server (already filtered server-side)
|
|
const filteredEntries = entries;
|
|
|
|
// Save view to .object.yaml via API
|
|
const handleSaveView = useCallback(async (name: string) => {
|
|
const newView: SavedView = { name, filters, columns: viewColumns };
|
|
const updated = [...savedViews.filter((v) => v.name !== name), newView];
|
|
setSavedViews(updated);
|
|
setActiveViewName(name);
|
|
try {
|
|
await fetch(
|
|
`/api/workspace/objects/${encodeURIComponent(data.object.name)}/views`,
|
|
{
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ views: updated, activeView: name }),
|
|
},
|
|
);
|
|
} catch {
|
|
// ignore save errors
|
|
}
|
|
}, [filters, savedViews, data.object.name]);
|
|
|
|
const handleLoadView = useCallback((view: SavedView) => {
|
|
const newFilters = view.filters ?? emptyFilterGroup();
|
|
setFilters(newFilters);
|
|
setViewColumns(view.columns);
|
|
setActiveViewName(view.name);
|
|
setServerPage(1);
|
|
void fetchEntries({ page: 1, filters: newFilters });
|
|
}, [fetchEntries]);
|
|
|
|
const handleDeleteView = useCallback(async (name: string) => {
|
|
const updated = savedViews.filter((v) => v.name !== name);
|
|
setSavedViews(updated);
|
|
if (activeViewName === name) {
|
|
setActiveViewName(undefined);
|
|
setFilters(emptyFilterGroup());
|
|
setViewColumns(undefined);
|
|
}
|
|
try {
|
|
await fetch(
|
|
`/api/workspace/objects/${encodeURIComponent(data.object.name)}/views`,
|
|
{
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
views: updated,
|
|
activeView: activeViewName === name ? undefined : activeViewName,
|
|
}),
|
|
},
|
|
);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}, [savedViews, activeViewName, data.object.name]);
|
|
|
|
const handleSetActiveView = useCallback(async (name: string | undefined) => {
|
|
setActiveViewName(name);
|
|
if (!name) {setViewColumns(undefined);}
|
|
try {
|
|
await fetch(
|
|
`/api/workspace/objects/${encodeURIComponent(data.object.name)}/views`,
|
|
{
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ views: savedViews, activeView: name }),
|
|
},
|
|
);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}, [savedViews, data.object.name]);
|
|
|
|
const handleDisplayFieldChange = async (fieldName: string) => {
|
|
setUpdatingDisplayField(true);
|
|
try {
|
|
const res = await fetch(
|
|
`/api/workspace/objects/${encodeURIComponent(data.object.name)}/display-field`,
|
|
{
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ displayField: fieldName }),
|
|
},
|
|
);
|
|
if (res.ok) {
|
|
onRefreshObject();
|
|
}
|
|
} catch {
|
|
// ignore
|
|
} finally {
|
|
setUpdatingDisplayField(false);
|
|
}
|
|
};
|
|
|
|
const displayFieldCandidates = data.fields.filter(
|
|
(f) => !["relation", "boolean", "richtext"].includes(f.type),
|
|
);
|
|
|
|
const hasRelationFields = data.fields.some((f) => f.type === "relation");
|
|
const hasReverseRelations =
|
|
data.reverseRelations && data.reverseRelations.some(
|
|
(rr) => Object.keys(rr.entries).length > 0,
|
|
);
|
|
|
|
const filterBarMembers = useMemo(
|
|
() => members?.map((m) => ({ id: m.id, name: m.name })),
|
|
[members],
|
|
);
|
|
|
|
return (
|
|
<div className="p-6">
|
|
{/* Object header */}
|
|
<div className="mb-4">
|
|
<h1
|
|
className="font-instrument text-3xl tracking-tight capitalize"
|
|
style={{ color: "var(--color-text)" }}
|
|
>
|
|
{data.object.name}
|
|
</h1>
|
|
{data.object.description && (
|
|
<p
|
|
className="text-sm mt-1"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
{data.object.description}
|
|
</p>
|
|
)}
|
|
<div className="flex items-center gap-3 mt-3 flex-wrap">
|
|
<span
|
|
className="text-xs px-2 py-1 rounded-full"
|
|
style={{
|
|
background: "var(--color-surface)",
|
|
color: "var(--color-text-muted)",
|
|
border: "1px solid var(--color-border)",
|
|
}}
|
|
>
|
|
{totalCount} entries
|
|
</span>
|
|
<span
|
|
className="text-xs px-2 py-1 rounded-full"
|
|
style={{
|
|
background: "var(--color-surface)",
|
|
color: "var(--color-text-muted)",
|
|
border: "1px solid var(--color-border)",
|
|
}}
|
|
>
|
|
{data.fields.length} fields
|
|
</span>
|
|
|
|
{hasRelationFields && (
|
|
<span
|
|
className="text-xs px-2 py-1 rounded-full"
|
|
style={{
|
|
background: "var(--color-chip-document)",
|
|
color: "var(--color-chip-document-text)",
|
|
border: "1px solid var(--color-border)",
|
|
}}
|
|
>
|
|
{data.fields.filter((f) => f.type === "relation").length} relation{data.fields.filter((f) => f.type === "relation").length !== 1 ? "s" : ""}
|
|
</span>
|
|
)}
|
|
{hasReverseRelations && (
|
|
<span
|
|
className="text-xs px-2 py-1 rounded-full"
|
|
style={{
|
|
background: "var(--color-chip-database)",
|
|
color: "var(--color-chip-database-text)",
|
|
border: "1px solid var(--color-border)",
|
|
}}
|
|
>
|
|
{data.reverseRelations!.filter((rr) => Object.keys(rr.entries).length > 0).length} linked from
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{displayFieldCandidates.length > 0 && (
|
|
<div className="flex items-center gap-2 mt-3">
|
|
<span
|
|
className="text-xs"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
Display field:
|
|
</span>
|
|
<select
|
|
value={data.effectiveDisplayField ?? ""}
|
|
onChange={(e) => handleDisplayFieldChange(e.target.value)}
|
|
disabled={updatingDisplayField}
|
|
className="text-xs px-2 py-1 rounded-md outline-none transition-colors cursor-pointer"
|
|
style={{
|
|
background: "var(--color-surface)",
|
|
color: "var(--color-text)",
|
|
border: "1px solid var(--color-border)",
|
|
opacity: updatingDisplayField ? 0.5 : 1,
|
|
}}
|
|
>
|
|
{displayFieldCandidates.map((f) => (
|
|
<option key={f.id} value={f.name}>
|
|
{f.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{updatingDisplayField && (
|
|
<div
|
|
className="w-3 h-3 border border-t-transparent rounded-full animate-spin"
|
|
style={{ borderColor: "var(--color-text-muted)" }}
|
|
/>
|
|
)}
|
|
<span
|
|
className="text-[10px]"
|
|
style={{ color: "var(--color-text-muted)", opacity: 0.6 }}
|
|
>
|
|
Used when other objects link here
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Filter bar */}
|
|
<div
|
|
className="mb-4 py-3 px-4 rounded-lg border"
|
|
style={{
|
|
borderColor: "var(--color-border)",
|
|
background: "var(--color-surface)",
|
|
}}
|
|
>
|
|
<ObjectFilterBar
|
|
fields={data.fields}
|
|
filters={filters}
|
|
onFiltersChange={handleFiltersChange}
|
|
savedViews={savedViews}
|
|
activeViewName={activeViewName}
|
|
onSaveView={handleSaveView}
|
|
onLoadView={handleLoadView}
|
|
onDeleteView={handleDeleteView}
|
|
onSetActiveView={handleSetActiveView}
|
|
members={filterBarMembers}
|
|
/>
|
|
</div>
|
|
|
|
{/* Table or Kanban */}
|
|
{data.object.default_view === "kanban" ? (
|
|
<ObjectKanban
|
|
objectName={data.object.name}
|
|
fields={data.fields}
|
|
entries={filteredEntries}
|
|
statuses={data.statuses}
|
|
members={members}
|
|
relationLabels={data.relationLabels}
|
|
onEntryClick={onOpenEntry ? (entryId) => onOpenEntry(data.object.name, entryId) : undefined}
|
|
onRefresh={handleRefresh}
|
|
/>
|
|
) : (
|
|
<ObjectTable
|
|
objectName={data.object.name}
|
|
fields={data.fields}
|
|
entries={filteredEntries}
|
|
members={members}
|
|
relationLabels={data.relationLabels}
|
|
reverseRelations={data.reverseRelations}
|
|
onNavigateToObject={onNavigateToObject}
|
|
onEntryClick={onOpenEntry ? (entryId) => onOpenEntry(data.object.name, entryId) : undefined}
|
|
onRefresh={handleRefresh}
|
|
columnVisibility={columnVisibility}
|
|
serverPagination={{
|
|
totalCount,
|
|
page: serverPage,
|
|
pageSize: serverPageSize,
|
|
onPageChange: handlePageChange,
|
|
onPageSizeChange: handlePageSizeChange,
|
|
}}
|
|
onServerSearch={handleServerSearch}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- Directory Listing ---
|
|
|
|
function DirectoryListing({
|
|
node,
|
|
onNodeSelect,
|
|
}: {
|
|
node: TreeNode;
|
|
onNodeSelect: (node: TreeNode) => void;
|
|
}) {
|
|
const children = node.children ?? [];
|
|
|
|
return (
|
|
<div className="p-6 max-w-4xl mx-auto">
|
|
<h1
|
|
className="font-instrument text-3xl tracking-tight mb-1 capitalize"
|
|
style={{ color: "var(--color-text)" }}
|
|
>
|
|
{node.name}
|
|
</h1>
|
|
<p className="text-sm mb-6" style={{ color: "var(--color-text-muted)" }}>
|
|
{children.length} items
|
|
</p>
|
|
|
|
{children.length === 0 ? (
|
|
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
|
|
This folder is empty.
|
|
</p>
|
|
) : (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
{children.map((child) => (
|
|
<button
|
|
type="button"
|
|
key={child.path}
|
|
onClick={() => onNodeSelect(child)}
|
|
className="flex items-center gap-3 p-4 rounded-2xl text-left transition-all duration-100 cursor-pointer"
|
|
style={{
|
|
background: "var(--color-surface)",
|
|
border: "1px solid var(--color-border)",
|
|
boxShadow: "var(--shadow-sm)",
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
(e.currentTarget as HTMLElement).style.borderColor =
|
|
"var(--color-border-strong)";
|
|
(e.currentTarget as HTMLElement).style.transform = "translateY(-1px)";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
(e.currentTarget as HTMLElement).style.borderColor =
|
|
"var(--color-border)";
|
|
(e.currentTarget as HTMLElement).style.transform = "translateY(0)";
|
|
}}
|
|
>
|
|
<span
|
|
className="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0"
|
|
style={{
|
|
background:
|
|
child.type === "object"
|
|
? "var(--color-chip-object)"
|
|
: child.type === "document"
|
|
? "var(--color-chip-document)"
|
|
: child.type === "database"
|
|
? "var(--color-chip-database)"
|
|
: child.type === "report"
|
|
? "var(--color-chip-report)"
|
|
: "var(--color-surface-hover)",
|
|
color:
|
|
child.type === "object"
|
|
? "var(--color-chip-object-text)"
|
|
: child.type === "document"
|
|
? "var(--color-chip-document-text)"
|
|
: child.type === "database"
|
|
? "var(--color-chip-database-text)"
|
|
: child.type === "report"
|
|
? "var(--color-chip-report-text)"
|
|
: "var(--color-text-muted)",
|
|
}}
|
|
>
|
|
<NodeTypeIcon type={child.type} />
|
|
</span>
|
|
<div className="min-w-0 flex-1">
|
|
<div
|
|
className="text-sm font-medium truncate"
|
|
style={{ color: "var(--color-text)" }}
|
|
>
|
|
{child.name.replace(/\.md$/, "")}
|
|
</div>
|
|
<div
|
|
className="text-xs capitalize"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
{child.type}
|
|
{child.children ? ` (${child.children.length})` : ""}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- Welcome View (no selection) ---
|
|
|
|
function WelcomeView({
|
|
tree,
|
|
onNodeSelect,
|
|
}: {
|
|
tree: TreeNode[];
|
|
onNodeSelect: (node: TreeNode) => void;
|
|
}) {
|
|
const objects: TreeNode[] = [];
|
|
const documents: TreeNode[] = [];
|
|
|
|
function collect(nodes: TreeNode[]) {
|
|
for (const n of nodes) {
|
|
if (n.type === "object") {objects.push(n);}
|
|
else if (n.type === "document") {documents.push(n);}
|
|
if (n.children) {collect(n.children);}
|
|
}
|
|
}
|
|
collect(tree);
|
|
|
|
return (
|
|
<div className="p-8 max-w-4xl mx-auto">
|
|
<h1
|
|
className="font-instrument text-3xl tracking-tight mb-2"
|
|
style={{ color: "var(--color-text)" }}
|
|
>
|
|
Workspace
|
|
</h1>
|
|
<p className="text-sm mb-8" style={{ color: "var(--color-text-muted)" }}>
|
|
Select an item from the sidebar, or browse the sections below.
|
|
</p>
|
|
|
|
{objects.length > 0 && (
|
|
<div className="mb-8">
|
|
<h2
|
|
className="text-sm font-medium uppercase tracking-wider mb-3"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
Objects
|
|
</h2>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
{objects.map((obj) => (
|
|
<button
|
|
type="button"
|
|
key={obj.path}
|
|
onClick={() => onNodeSelect(obj)}
|
|
className="flex items-center gap-3 p-4 rounded-2xl text-left transition-all duration-100 cursor-pointer"
|
|
style={{
|
|
background: "var(--color-surface)",
|
|
border: "1px solid var(--color-border)",
|
|
boxShadow: "var(--shadow-sm)",
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
(e.currentTarget as HTMLElement).style.borderColor =
|
|
"var(--color-accent)";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
(e.currentTarget as HTMLElement).style.borderColor =
|
|
"var(--color-border)";
|
|
}}
|
|
>
|
|
<span
|
|
className="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0"
|
|
style={{
|
|
background: "var(--color-chip-object)",
|
|
color: "var(--color-chip-object-text)",
|
|
}}
|
|
>
|
|
<NodeTypeIcon type="object" />
|
|
</span>
|
|
<div className="min-w-0">
|
|
<div
|
|
className="text-sm font-medium capitalize truncate"
|
|
style={{ color: "var(--color-text)" }}
|
|
>
|
|
{obj.name}
|
|
</div>
|
|
<div className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
|
{obj.defaultView === "kanban" ? "Kanban board" : "Table view"}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{documents.length > 0 && (
|
|
<div>
|
|
<h2
|
|
className="text-sm font-medium uppercase tracking-wider mb-3"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
Documents
|
|
</h2>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
{documents.map((doc) => (
|
|
<button
|
|
type="button"
|
|
key={doc.path}
|
|
onClick={() => onNodeSelect(doc)}
|
|
className="flex items-center gap-3 p-4 rounded-2xl text-left transition-all duration-100 cursor-pointer"
|
|
style={{
|
|
background: "var(--color-surface)",
|
|
border: "1px solid var(--color-border)",
|
|
boxShadow: "var(--shadow-sm)",
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
(e.currentTarget as HTMLElement).style.borderColor = "var(--color-chip-document-text)";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
(e.currentTarget as HTMLElement).style.borderColor =
|
|
"var(--color-border)";
|
|
}}
|
|
>
|
|
<span
|
|
className="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0"
|
|
style={{
|
|
background: "var(--color-chip-document)",
|
|
color: "var(--color-chip-document-text)",
|
|
}}
|
|
>
|
|
<NodeTypeIcon type="document" />
|
|
</span>
|
|
<div className="min-w-0">
|
|
<div
|
|
className="text-sm font-medium truncate"
|
|
style={{ color: "var(--color-text)" }}
|
|
>
|
|
{doc.name.replace(/\.md$/, "")}
|
|
</div>
|
|
<div className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
|
Document
|
|
</div>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- Shared icon for node types ---
|
|
|
|
function NodeTypeIcon({ type }: { type: string }) {
|
|
switch (type) {
|
|
case "object":
|
|
return (
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M12 3v18" /><rect width="18" height="18" x="3" y="3" rx="2" /><path d="M3 9h18" /><path d="M3 15h18" />
|
|
</svg>
|
|
);
|
|
case "document":
|
|
return (
|
|
<svg width="20" height="20" 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>
|
|
);
|
|
case "folder":
|
|
return (
|
|
<svg width="20" height="20" 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>
|
|
);
|
|
case "database":
|
|
return (
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<ellipse cx="12" cy="5" rx="9" ry="3" />
|
|
<path d="M3 5V19A9 3 0 0 0 21 19V5" />
|
|
<path d="M3 12A9 3 0 0 0 21 12" />
|
|
</svg>
|
|
);
|
|
case "report":
|
|
return (
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<line x1="12" x2="12" y1="20" y2="10" />
|
|
<line x1="18" x2="18" y1="20" y2="4" />
|
|
<line x1="6" x2="6" y1="20" y2="14" />
|
|
</svg>
|
|
);
|
|
default:
|
|
return (
|
|
<svg width="20" height="20" 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>
|
|
);
|
|
}
|
|
}
|