diff --git a/apps/web/app/components/chat-panel.tsx b/apps/web/app/components/chat-panel.tsx index 91c2ec2197a..5e8b6511083 100644 --- a/apps/web/app/components/chat-panel.tsx +++ b/apps/web/app/components/chat-panel.tsx @@ -443,6 +443,8 @@ type ChatPanelProps = { fileContext?: FileContext; /** Compact mode for workspace sidebar (smaller UI, built-in session tabs). */ compact?: boolean; + /** Override the header title when a session is active (e.g. show the session's actual title). */ + sessionTitle?: string; /** Called when file content may have changed after agent edits. */ onFileChanged?: (newContent: string) => void; /** Called when active session changes (for external sidebar highlighting). */ @@ -456,6 +458,7 @@ export const ChatPanel = forwardRef( { fileContext, compact, + sessionTitle, onFileChanged, onActiveSessionChange, onSessionsChange, @@ -1155,7 +1158,7 @@ export const ChatPanel = forwardRef( }} > {currentSessionId - ? "Chat Session" + ? (sessionTitle || "Chat Session") : "New Chat"}

( "var(--color-chat-input-bg)", border: "1px solid var(--color-border)", }} + onDragOver={(e) => { + if (e.dataTransfer?.types.includes("application/x-file-mention")) { + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + } + }} + onDrop={(e) => { + const data = e.dataTransfer?.getData("application/x-file-mention"); + if (!data) {return;} + e.preventDefault(); + e.stopPropagation(); + try { + const { name, path } = JSON.parse(data) as { name: string; path: string }; + if (name && path) { + editorRef.current?.insertFileMention(name, path); + } + } catch { + // ignore malformed data + } + }} > ( const submitRef = useRef(onSubmit); submitRef.current = onSubmit; + // Ref to access the TipTap editor from within ProseMirror's handleDOMEvents + // (the handlers are defined at useEditor() call time, before the editor exists). + const editorRefInternal = useRef(null); + const editor = useEditor({ immediatelyRender: false, extensions: [ @@ -225,12 +230,61 @@ export const ChatEditor = forwardRef( } return false; }, + // Handle drag-and-drop of files from the sidebar. + // Using handleDOMEvents ensures our handler runs BEFORE + // ProseMirror's built-in drop processing, which would + // otherwise consume the event or insert the text/plain + // fallback data as raw text. + handleDOMEvents: { + dragover: (_view, event) => { + const de = event; + if (de.dataTransfer?.types.includes("application/x-file-mention")) { + de.preventDefault(); + de.dataTransfer.dropEffect = "copy"; + return true; + } + return false; + }, + drop: (_view, event) => { + const de = event; + const data = de.dataTransfer?.getData("application/x-file-mention"); + if (!data) {return false;} + + de.preventDefault(); + de.stopPropagation(); + + try { + const { name, path } = JSON.parse(data) as { name: string; path: string }; + if (name && path) { + editorRefInternal.current + ?.chain() + .focus() + .insertContent([ + { + type: "chatFileMention", + attrs: { label: name, path }, + }, + { type: "text", text: " " }, + ]) + .run(); + } + } catch { + // ignore malformed data + } + return true; + }, + }, }, onUpdate: ({ editor: ed }) => { onChange?.(ed.isEmpty); }, }); + // Keep internal ref in sync so handleDOMEvents handlers can access the editor + useEffect(() => { + editorRefInternal.current = editor ?? null; + }, [editor]); + // Handle Enter-to-submit via a keydown listener on the editor DOM useEffect(() => { if (!editor) {return;} @@ -255,53 +309,6 @@ export const ChatEditor = forwardRef( return () => el.removeEventListener("keydown", handleKeyDown); }, [editor]); - // Handle drag-and-drop of files from the sidebar - useEffect(() => { - if (!editor) {return;} - const el = editor.view.dom; - - const handleDragOver = (e: DragEvent) => { - if (e.dataTransfer?.types.includes("application/x-file-mention")) { - e.preventDefault(); - e.dataTransfer.dropEffect = "copy"; - } - }; - - const handleDrop = (e: DragEvent) => { - const data = e.dataTransfer?.getData("application/x-file-mention"); - if (!data) {return;} - - e.preventDefault(); - e.stopPropagation(); - - try { - const { name, path } = JSON.parse(data); - if (name && path) { - editor - .chain() - .focus() - .insertContent([ - { - type: "chatFileMention", - attrs: { label: name, path }, - }, - { type: "text", text: " " }, - ]) - .run(); - } - } catch { - // ignore malformed data - } - }; - - el.addEventListener("dragover", handleDragOver); - el.addEventListener("drop", handleDrop); - return () => { - el.removeEventListener("dragover", handleDragOver); - el.removeEventListener("drop", handleDrop); - }; - }, [editor]); - // Disable/enable editor useEffect(() => { if (editor) { diff --git a/apps/web/app/components/workspace/breadcrumbs.tsx b/apps/web/app/components/workspace/breadcrumbs.tsx index f5c3fba8ce0..aee05f4cf3e 100644 --- a/apps/web/app/components/workspace/breadcrumbs.tsx +++ b/apps/web/app/components/workspace/breadcrumbs.tsx @@ -6,6 +6,7 @@ type BreadcrumbsProps = { }; export function Breadcrumbs({ path, onNavigate }: BreadcrumbsProps) { + const isAbsolute = path.startsWith("/"); const segments = path.split("/").filter(Boolean); return ( @@ -30,7 +31,7 @@ export function Breadcrumbs({ path, onNavigate }: BreadcrumbsProps) { {segments.map((segment, idx) => { - const partialPath = segments.slice(0, idx + 1).join("/"); + const partialPath = (isAbsolute ? "/" : "") + segments.slice(0, idx + 1).join("/"); const isLast = idx === segments.length - 1; return ( diff --git a/apps/web/app/components/workspace/chat-sessions-sidebar.tsx b/apps/web/app/components/workspace/chat-sessions-sidebar.tsx new file mode 100644 index 00000000000..eee0566f716 --- /dev/null +++ b/apps/web/app/components/workspace/chat-sessions-sidebar.tsx @@ -0,0 +1,254 @@ +"use client"; + +import { useCallback, useState } from "react"; + +type WebSession = { + id: string; + title: string; + createdAt: number; + updatedAt: number; + messageCount: number; +}; + +type ChatSessionsSidebarProps = { + sessions: WebSession[]; + activeSessionId: string | null; + /** Title of the currently active session (shown in the header). */ + activeSessionTitle?: string; + onSelectSession: (sessionId: string) => void; + onNewSession: () => void; +}; + +/** Format a timestamp into a human-readable relative time string. */ +function timeAgo(ts: number): string { + const now = Date.now(); + const diff = now - ts; + const seconds = Math.floor(diff / 1000); + if (seconds < 60) {return "just now";} + const minutes = Math.floor(seconds / 60); + if (minutes < 60) {return `${minutes}m ago`;} + const hours = Math.floor(minutes / 60); + if (hours < 24) {return `${hours}h ago`;} + const days = Math.floor(hours / 24); + if (days < 30) {return `${days}d ago`;} + const months = Math.floor(days / 30); + if (months < 12) {return `${months}mo ago`;} + return `${Math.floor(months / 12)}y ago`; +} + +function PlusIcon() { + return ( + + + + + ); +} + +function ChatBubbleIcon() { + return ( + + + + ); +} + +export function ChatSessionsSidebar({ + sessions, + activeSessionId, + activeSessionTitle, + onSelectSession, + onNewSession, +}: ChatSessionsSidebarProps) { + const [hoveredId, setHoveredId] = useState(null); + + const handleSelect = useCallback( + (id: string) => { + onSelectSession(id); + }, + [onSelectSession], + ); + + // Group sessions: today, yesterday, this week, this month, older + const grouped = groupSessions(sessions); + + return ( +

+ ); +} + +// ── Grouping helpers ── + +type SessionGroup = { + label: string; + sessions: WebSession[]; +}; + +function groupSessions(sessions: WebSession[]): SessionGroup[] { + const now = new Date(); + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + const yesterdayStart = todayStart - 86400000; + const weekStart = todayStart - 7 * 86400000; + const monthStart = todayStart - 30 * 86400000; + + const today: WebSession[] = []; + const yesterday: WebSession[] = []; + const thisWeek: WebSession[] = []; + const thisMonth: WebSession[] = []; + const older: WebSession[] = []; + + for (const s of sessions) { + const t = s.updatedAt; + if (t >= todayStart) {today.push(s);} + else if (t >= yesterdayStart) {yesterday.push(s);} + else if (t >= weekStart) {thisWeek.push(s);} + else if (t >= monthStart) {thisMonth.push(s);} + else {older.push(s);} + } + + const groups: SessionGroup[] = []; + if (today.length > 0) {groups.push({ label: "Today", sessions: today });} + if (yesterday.length > 0) {groups.push({ label: "Yesterday", sessions: yesterday });} + if (thisWeek.length > 0) {groups.push({ label: "This Week", sessions: thisWeek });} + if (thisMonth.length > 0) {groups.push({ label: "This Month", sessions: thisMonth });} + if (older.length > 0) {groups.push({ label: "Older", sessions: older });} + return groups; +} diff --git a/apps/web/app/components/workspace/file-manager-tree.tsx b/apps/web/app/components/workspace/file-manager-tree.tsx index e39e53c2938..3c3769d47f8 100644 --- a/apps/web/app/components/workspace/file-manager-tree.tsx +++ b/apps/web/app/components/workspace/file-manager-tree.tsx @@ -50,6 +50,8 @@ type FileManagerTreeProps = { onNavigateUp?: () => void; /** Current browse directory (absolute path), or null when in workspace mode. */ browseDir?: string | null; + /** Absolute path of the workspace root. Nodes matching this path are rendered as a special non-collapsible workspace entry point. */ + workspaceRoot?: string | null; }; // --- System file detection (client-side mirror) --- @@ -154,6 +156,17 @@ function ChevronIcon({ open }: { open: boolean }) { ); } +function WorkspaceGridIcon() { + return ( + + + + + + + ); +} + function NodeIcon({ node, open }: { node: TreeNode; open?: boolean }) { // Chat items use the chat bubble icon if (node.path.startsWith("~chats/") || node.path === "~chats") { @@ -365,6 +378,7 @@ function DraggableNode({ onContextMenu, compact, dragOverPath, + workspaceRoot, }: { node: TreeNode; depth: number; @@ -381,16 +395,19 @@ function DraggableNode({ onContextMenu: (e: React.MouseEvent, node: TreeNode) => void; compact?: boolean; dragOverPath: string | null; + workspaceRoot?: string | null; }) { + // Workspace root in browse mode: non-expandable entry point back to workspace + const isWorkspaceRoot = !!workspaceRoot && node.path === workspaceRoot; const hasChildren = node.children && node.children.length > 0; - const isExpandable = hasChildren || node.type === "folder" || node.type === "object"; - const isExpanded = expandedPaths.has(node.path); + const isExpandable = isWorkspaceRoot ? false : (hasChildren || node.type === "folder" || node.type === "object"); + const isExpanded = isWorkspaceRoot ? false : expandedPaths.has(node.path); const isActive = activePath === node.path; const isSelected = selectedPath === node.path; const isRenaming = renamingPath === node.path; const isSysFile = isSystemFile(node.path); const isVirtual = isVirtualNode(node); - const isProtected = isSysFile || isVirtual; + const isProtected = isSysFile || isVirtual || isWorkspaceRoot; const isDragOver = dragOverPath === node.path && isExpandable; const { attributes, listeners, setNodeRef: setDragRef, isDragging } = useDraggable({ @@ -465,37 +482,53 @@ function DraggableNode({ className="w-full flex items-center gap-1.5 py-1 px-2 rounded-md text-left text-sm transition-colors duration-100 cursor-pointer select-none" style={{ paddingLeft: `${depth * 16 + 8}px`, - background: showDropHighlight + background: isWorkspaceRoot ? "var(--color-accent-light)" - : isSelected - ? "var(--color-surface-hover)" - : isActive + : showDropHighlight + ? "var(--color-accent-light)" + : isSelected ? "var(--color-surface-hover)" - : "transparent", - color: isActive || isSelected ? "var(--color-text)" : "var(--color-text-muted)", - outline: showDropHighlight ? "1px dashed var(--color-accent)" : "none", + : isActive + ? "var(--color-surface-hover)" + : "transparent", + color: isWorkspaceRoot + ? "var(--color-accent)" + : isActive || isSelected ? "var(--color-text)" : "var(--color-text-muted)", + outline: isWorkspaceRoot + ? "1.5px solid var(--color-accent)" + : showDropHighlight ? "1px dashed var(--color-accent)" : "none", outlineOffset: "-1px", - borderRadius: "6px", + borderRadius: isWorkspaceRoot ? "8px" : "6px", + marginTop: isWorkspaceRoot ? "2px" : undefined, + marginBottom: isWorkspaceRoot ? "2px" : undefined, }} onMouseEnter={(e) => { - if (!isActive && !isSelected && !showDropHighlight) { + if (isWorkspaceRoot) { + (e.currentTarget as HTMLElement).style.opacity = "0.8"; + } else if (!isActive && !isSelected && !showDropHighlight) { (e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)"; } }} onMouseLeave={(e) => { - if (!isActive && !isSelected && !showDropHighlight) { + if (isWorkspaceRoot) { + (e.currentTarget as HTMLElement).style.opacity = "1"; + } else if (!isActive && !isSelected && !showDropHighlight) { (e.currentTarget as HTMLElement).style.background = "transparent"; } }} > - {/* Expand/collapse chevron */} - + {/* Expand/collapse chevron – intercept click so it only toggles without navigating */} + { e.stopPropagation(); onToggleExpand(node.path); } : undefined} + > {isExpandable && } {/* Icon */} - - + + {isWorkspaceRoot ? : } {/* Label or rename input */} @@ -509,8 +542,16 @@ function DraggableNode({ {node.name.replace(/\.md$/, "")} )} - {/* Lock badge for system/virtual files */} - {isProtected && !compact && ( + {/* Workspace badge for the workspace root entry point */} + {isWorkspaceRoot && ( + + workspace + + )} + + {/* Lock badge for system/virtual files (skip for workspace root -- it has its own badge) */} + {isProtected && !isWorkspaceRoot && !compact && ( @@ -549,6 +590,7 @@ function DraggableNode({ onContextMenu={onContextMenu} compact={compact} dragOverPath={dragOverPath} + workspaceRoot={workspaceRoot} /> ))} @@ -617,7 +659,7 @@ function flattenVisible(tree: TreeNode[], expanded: Set): TreeNode[] { // --- Main Exported Component --- -export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact, parentDir, onNavigateUp, browseDir: _browseDir }: FileManagerTreeProps) { +export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact, parentDir, onNavigateUp, browseDir: _browseDir, workspaceRoot }: FileManagerTreeProps) { const [expandedPaths, setExpandedPaths] = useState>(() => new Set()); const [selectedPath, setSelectedPath] = useState(null); const [renamingPath, setRenamingPath] = useState(null); @@ -1064,6 +1106,7 @@ export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact onContextMenu={handleContextMenu} compact={compact} dragOverPath={dragOverPath} + workspaceRoot={workspaceRoot} /> ))} diff --git a/apps/web/app/components/workspace/workspace-sidebar.tsx b/apps/web/app/components/workspace/workspace-sidebar.tsx index c9db24062b4..0137337370a 100644 --- a/apps/web/app/components/workspace/workspace-sidebar.tsx +++ b/apps/web/app/components/workspace/workspace-sidebar.tsx @@ -27,6 +27,8 @@ type WorkspaceSidebarProps = { onGoHome?: () => void; /** Called when a file/folder is selected from the search dropdown. */ onFileSearchSelect?: (item: SuggestItem) => void; + /** Absolute path of the workspace root folder, used to render it as a special entry in browse mode. */ + workspaceRoot?: string | null; }; function WorkspaceLogo() { @@ -398,6 +400,7 @@ export function WorkspaceSidebar({ onNavigateUp, onGoHome, onFileSearchSelect, + workspaceRoot, }: WorkspaceSidebarProps) { const isBrowsing = browseDir != null; @@ -507,15 +510,16 @@ export function WorkspaceSidebar({ /> ) : ( - + )} @@ -525,12 +529,13 @@ export function WorkspaceSidebar({ style={{ borderColor: "var(--color-border)" }} > - - Home + ironclaw.sh diff --git a/apps/web/app/workspace/page.tsx b/apps/web/app/workspace/page.tsx index 35bb02b3be7..15ea44ecc8f 100644 --- a/apps/web/app/workspace/page.tsx +++ b/apps/web/app/workspace/page.tsx @@ -13,6 +13,7 @@ import { CodeViewer } from "../components/workspace/code-viewer"; import { MediaViewer, detectMediaType, type MediaType } from "../components/workspace/media-viewer"; import { DatabaseViewer } 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 } from "../components/chat-panel"; @@ -466,28 +467,14 @@ function WorkspacePageInner() { [loadContent, router, cronJobs, browseDir, workspaceRoot, openclawDir, setBrowseDir], ); - // Build the enhanced tree: real tree + Chats + Cron virtual folders at the bottom + // 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 chatChildren: TreeNode[] = sessions.map((s) => ({ - name: s.title || "Untitled chat", - path: `~chats/${s.id}`, - type: "file" as const, - virtual: true, - })); - - const chatsFolder: TreeNode = { - name: "Chats", - path: "~chats", - type: "folder", - virtual: true, - children: chatChildren.length > 0 ? chatChildren : undefined, - }; - const cronStatusIcon = (job: CronJob) => { if (!job.enabled) {return "\u25CB";} // circle outline if (job.state.runningAtMs) {return "\u25CF";} // filled circle @@ -511,8 +498,8 @@ function WorkspacePageInner() { children: cronChildren.length > 0 ? cronChildren : undefined, }; - return [...tree, chatsFolder, cronFolder]; - }, [tree, sessions, cronJobs, browseDir]); + return [...tree, cronFolder]; + }, [tree, cronJobs, browseDir]); // Compute the effective parentDir for ".." navigation. // In browse mode: use browseParentDir from the API. @@ -650,12 +637,26 @@ function WorkspacePageInner() { 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) { - void loadContent(node); + handleNodeSelect(node); } }, - [tree, loadContent], + [tree, handleNodeSelect, setBrowseDir], ); // Navigate to an object by name (used by relation links) @@ -758,6 +759,13 @@ function WorkspacePageInner() { 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((s) => s.id === activeSessionId); + return s?.title || undefined; + }, [activeSessionId, sessions]); + // Whether to show the main ChatPanel (no file/content selected) const showMainChat = !activePath || content.kind === "none"; @@ -777,6 +785,7 @@ function WorkspacePageInner() { onNavigateUp={handleNavigateUp} onGoHome={handleGoHome} onFileSearchSelect={handleFileSearchSelect} + workspaceRoot={workspaceRoot} /> {/* Main content */} @@ -833,15 +842,32 @@ function WorkspacePageInner() {
{showMainChat ? ( /* Main chat view (default when no file is selected) */ -
- { - setActiveSessionId(id); + <> +
+ { + setActiveSessionId(id); + }} + onSessionsChange={refreshSessions} + /> +
+ { + setActiveSessionId(sessionId); + void chatRef.current?.loadSession(sessionId); + }} + onNewSession={() => { + setActiveSessionId(null); + void chatRef.current?.newSession(); + router.replace("/workspace", { scroll: false }); }} - onSessionsChange={refreshSessions} /> -
+ ) : ( <> {/* File content area */}