diff --git a/frontend/src/App.js b/frontend/src/App.js
index 5bbcb88..b4521aa 100644
--- a/frontend/src/App.js
+++ b/frontend/src/App.js
@@ -1,6 +1,6 @@
// App.js
import React, { useState, useEffect } from "react";
-import Navbar from "./components/Navbar";
+import { Navbar } from "./shared/components";
import HomePage from "./pages/HomePage";
import { VoiceChatPage } from "./features/voice";
import SwarmControlPage from "./pages/SwarmControlPage";
diff --git a/frontend/src/components/FileSystemNavigator.js b/frontend/src/components/FileSystemNavigator.js
deleted file mode 100644
index 7589614..0000000
--- a/frontend/src/components/FileSystemNavigator.js
+++ /dev/null
@@ -1,593 +0,0 @@
-import React, { useState, useEffect, useCallback } from 'react';
-import { nodeFsList, nodeFsCat, nodeFsTouch, nodeFsRm, nodeFsUpload, nodeFsDownloadBlob } from '../services/apiService';
-
-/**
- * A modular File Navigator component similar to VS Code's side panel.
- * Displays a tree structure from an Agent Node's filesystem.
- */
-const FileSystemNavigator = ({
- nodeId,
- initialPath = ".",
- sessionId = "__fs_explorer__",
- showSyncStatus = false
-}) => {
- const [tree, setTree] = useState([]);
- const [loading, setLoading] = useState(false);
- const [expanded, setExpanded] = useState({}); // { [path]: boolean }
- const [error, setError] = useState(null);
- const [selectedFile, setSelectedFile] = useState(null); // { path, content }
- const [isEditing, setIsEditing] = useState(false);
- const [newItemModal, setNewItemModal] = useState(null); // { parentPath, isDir }
- const [deleteModal, setDeleteModal] = useState(null); // path
- const [operationLoading, setOperationLoading] = useState(false);
- const [folderLoading, setFolderLoading] = useState({}); // { [path]: boolean }
- const [previewImage, setPreviewImage] = useState(null); // { path, url }
- const uploadInputRef = React.useRef(null);
- const [uploadTargetPath, setUploadTargetPath] = useState(null);
-
- const fetchLevel = useCallback(async (path) => {
- const data = await nodeFsList(nodeId, path, sessionId);
- return data.files || [];
- }, [nodeId, sessionId]);
-
- const mergeFiles = (prev, newFiles, parentPath) => {
- const parentPrefix = (parentPath === "." || parentPath === "/" || parentPath === "")
- ? ""
- : (parentPath.endsWith("/") ? parentPath : parentPath + "/");
-
- const newPaths = new Set(newFiles.map(f => f.path));
- const preserved = prev.filter(f => {
- if (newPaths.has(f.path)) return false;
- const rel = f.path.startsWith("/") ? f.path.slice(1) : f.path;
- const pRel = parentPrefix.startsWith("/") ? parentPrefix.slice(1) : parentPrefix;
-
- if (pRel === "") {
- if (!rel.includes("/")) return false;
- } else {
- if (rel.startsWith(pRel)) {
- const sub = rel.slice(pRel.length);
- if (!sub.includes("/") && sub.length > 0) return false;
- }
- }
- return true;
- });
- return [...newFiles, ...preserved];
- };
-
- const loadRoot = useCallback(async () => {
- setLoading(true);
- setError(null);
- try {
- const files = await fetchLevel(initialPath);
- setTree(prev => mergeFiles(prev, files, initialPath));
- } catch (err) {
- setError(err.message || "Failed to connect to node filesystem.");
- } finally {
- setLoading(false);
- }
- }, [initialPath, fetchLevel]);
-
- useEffect(() => {
- if (nodeId) {
- loadRoot();
-
- const shouldPoll = showSyncStatus || (sessionId && sessionId !== "__fs_explorer__");
-
- if (shouldPoll) {
- const interval = setInterval(() => {
- loadRoot();
- }, 5000);
- return () => clearInterval(interval);
- }
- }
- }, [nodeId, loadRoot, showSyncStatus, sessionId]);
-
- // Keyboard Navigation for Media
- useEffect(() => {
- const handleKeyDown = (e) => {
- if (!previewImage) return;
-
- if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
- e.preventDefault();
- // Get flat list of images in the current tree
- const imageFiles = tree.filter(f => !f.is_dir && isImageFile(f.path))
- .sort((a, b) => a.path.localeCompare(b.path));
-
- if (imageFiles.length <= 1) return;
-
- const currentIndex = imageFiles.findIndex(f => f.path === previewImage.path);
- let nextIndex;
- if (e.key === 'ArrowRight') {
- nextIndex = (currentIndex + 1) % imageFiles.length;
- } else {
- nextIndex = (currentIndex - 1 + imageFiles.length) % imageFiles.length;
- }
- handleView(imageFiles[nextIndex].path);
- } else if (e.key === 'Escape') {
- if (previewImage.url) URL.revokeObjectURL(previewImage.url);
- setPreviewImage(null);
- }
- };
-
- window.addEventListener('keydown', handleKeyDown);
- return () => window.removeEventListener('keydown', handleKeyDown);
- }, [previewImage, tree]);
-
- const toggleFolder = async (path) => {
- const isExpanded = expanded[path];
- if (!isExpanded) {
- setExpanded(prev => ({ ...prev, [path]: true }));
-
- // Normalize path for prefix check: e.g. "etc" -> "etc/"
- const target = (path === "/" || path === ".") ? "" : (path.startsWith("/") ? path.slice(1) : path);
- const prefix = target === "" ? "" : (target.endsWith("/") ? target : target + "/");
-
- const hasChildren = tree.some(node => {
- const nodeRel = node.path.startsWith("/") ? node.path.slice(1) : node.path;
- return nodeRel.startsWith(prefix) && nodeRel !== target;
- });
-
- if (!hasChildren) {
- setFolderLoading(prev => ({ ...prev, [path]: true }));
- try {
- // Node expects relative path for subdirs, or "." for root
- const fetchPath = (path === "/" || path === ".") ? "." : (path.startsWith("/") ? path.slice(1) : path);
- const children = await fetchLevel(fetchPath);
-
- setTree(prev => {
- const existingPaths = new Set(prev.map(f => f.path));
- const newOnes = children.filter(c => !existingPaths.has(c.path));
- return [...prev, ...newOnes];
- });
- } catch (err) {
- console.error("Folder expansion failed:", err);
- setError(`Failed to open folder: ${err.message}`);
- } finally {
- setFolderLoading(prev => ({ ...prev, [path]: false }));
- }
- }
- } else {
- setExpanded(prev => ({ ...prev, [path]: false }));
- }
- };
-
- const handleCreateFinal = async (name) => {
- if (!name || !newItemModal) return;
- const { parentPath, isDir } = newItemModal;
-
- let fullPath;
- if (!parentPath || parentPath === "." || parentPath === "" || parentPath === "/") {
- fullPath = name;
- } else {
- fullPath = `${parentPath}/${name}`;
- }
-
- setOperationLoading(true);
- setError(null);
- try {
- await nodeFsTouch(nodeId, fullPath, "", isDir, sessionId);
- setNewItemModal(null);
- setTimeout(loadRoot, 500);
- } catch (err) {
- setError(`Failed to create: ${err.message}`);
- } finally {
- setOperationLoading(false);
- }
- };
-
- const handleDeleteFinal = async () => {
- if (!deleteModal) return;
- const path = deleteModal;
- setOperationLoading(true);
- setError(null);
- try {
- await nodeFsRm(nodeId, path, sessionId);
- // Optimistically remove from tree to force UI update
- setTree(prev => prev.filter(f => !f.path.startsWith(path)));
- setDeleteModal(null);
- setTimeout(loadRoot, 500);
- } catch (err) {
- setError(`Failed to delete: ${err.message}`);
- } finally {
- setOperationLoading(false);
- }
- };
-
- const isBinaryFile = (path) => {
- const ext = path.split('.').pop().toLowerCase();
- const binaryExts = ['pdf', 'zip', 'gz', 'tar', 'exe', 'dll', 'so', 'bin', 'pyc', 'node', 'db', 'sqlite', 'mp3', 'mp4', 'wav', 'mov'];
- return binaryExts.includes(ext);
- };
-
- const isImageFile = (path) => {
- const ext = path.split('.').pop().toLowerCase();
- const imgExts = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico', 'webp'];
- return imgExts.includes(ext);
- };
-
- const handleView = async (path) => {
- if (isImageFile(path)) {
- setOperationLoading(true);
- try {
- const blob = await nodeFsDownloadBlob(nodeId, path, sessionId);
- const url = URL.createObjectURL(blob);
-
- setPreviewImage(prev => {
- if (prev && prev.url) URL.revokeObjectURL(prev.url);
- return { path, url };
- });
- } catch (err) {
- setError(`Failed to load image: ${err.message}`);
- } finally {
- setOperationLoading(false);
- }
- return;
- }
-
- if (isBinaryFile(path)) {
- setError(`Cannot view binary file: ${path}. Please download it instead.`);
- return;
- }
-
- setOperationLoading(true);
- setError(null);
- try {
- const res = await nodeFsCat(nodeId, path, sessionId);
- setSelectedFile({ path, content: res.content });
- setIsEditing(false);
- } catch (err) {
- setError(`Failed to read file: ${err.message}`);
- } finally {
- setOperationLoading(false);
- }
- };
-
- const handleDownload = async (path) => {
- setOperationLoading(true);
- setError(null);
- try {
- const blob = await nodeFsDownloadBlob(nodeId, path, sessionId);
- const url = URL.createObjectURL(blob);
- const link = document.createElement('a');
- link.href = url;
- link.setAttribute('download', path.split('/').pop());
- document.body.appendChild(link);
- link.click();
- link.remove();
- } catch (err) {
- setError(`Download failed: ${err.message}`);
- } finally {
- setOperationLoading(false);
- }
- };
-
- const handleUploadClick = (targetPath) => {
- setUploadTargetPath(targetPath);
- if (uploadInputRef.current) {
- uploadInputRef.current.click();
- }
- };
-
- const handleFileUpload = async (event) => {
- const file = event.target.files[0];
- if (!file || !uploadTargetPath) return;
-
- setOperationLoading(true);
- setError(null);
- try {
- // If uploadTargetPath is ".", upload to root. If a dir path, use as-is.
- await nodeFsUpload(nodeId, uploadTargetPath, file, sessionId);
- setTimeout(loadRoot, 600);
- } catch (err) {
- setError(`Upload failed: ${err.message}`);
- } finally {
- setOperationLoading(false);
- setUploadTargetPath(null);
- event.target.value = ''; // Reset input
- }
- };
-
- const handleSave = async () => {
- setOperationLoading(true);
- setError(null);
- try {
- await nodeFsTouch(nodeId, selectedFile.path, selectedFile.content, false, sessionId);
- setSelectedFile(null);
- } catch (err) {
- setError(`Failed to save: ${err.message}`);
- } finally {
- setOperationLoading(false);
- }
- };
-
- // Helper to render tree recursively
- const renderSubTree = (currentPath, depth = 0) => {
- // Normalize currentPath for filtering
- const normCurrent = (currentPath === "/" || currentPath === "." || currentPath === "")
- ? ""
- : (currentPath.startsWith("/") ? currentPath.slice(1) : currentPath);
-
- const children = tree.filter(node => {
- const nodePath = node.path.startsWith("/") ? node.path.slice(1) : node.path;
-
- if (normCurrent === "") {
- // Root level: paths without slashes
- return !nodePath.includes("/");
- }
-
- // Nested level: starts with "parent/" and has no further slashes
- const prefix = normCurrent.endsWith("/") ? normCurrent : normCurrent + "/";
- if (!nodePath.startsWith(prefix)) return false;
- if (nodePath === normCurrent) return false;
-
- const sub = nodePath.slice(prefix.length);
- return !sub.includes("/");
- });
-
- // Sort: Folders first, then Alphabetical
- const sorted = [...children].sort((a, b) => {
- if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
- return a.name.localeCompare(b.name);
- });
-
- return sorted.map(node => (
-
-
-
node.is_dir ? toggleFolder(node.path) : handleView(node.path)}
- >
- {folderLoading[node.path] ? (
-
- ) : node.is_dir ? (
-
- ) : (
-
- )}
-
-
-
node.is_dir ? toggleFolder(node.path) : handleView(node.path)}
- >
- {showSyncStatus && (
-
- )}
- {node.name}
-
-
-
- {node.is_dir ? (
- <>
-
-
-
- >
- ) : (
-
- )}
-
-
-
- {node.is_dir && expanded[node.path] && (
-
- {renderSubTree(node.path, depth + 1)}
-
- )}
-
- ));
- };
-
- return (
-
- {/* Header */}
-
-
-
- File Explorer
-
-
-
-
-
-
-
-
-
- {/* List */}
-
- {error &&
{error}
}
- {!loading && tree.length === 0 && !error && (
-
-
-
No files found or node offline
-
- )}
- {renderSubTree(initialPath)}
-
-
- {/* Create Item Modal */}
- {newItemModal && (
-
-
-
-
- {newItemModal.isDir ? 'Create New Folder' : 'Create New File'}
-
-
-
-
-
-
- )}
-
- {/* Editor Modal */}
- {selectedFile && (
-
-
-
-
-
{selectedFile.path}
-
{isEditing ? 'Editing Mode' : 'Read-Only View'}
-
-
- {!isEditing ? (
-
- ) : (
-
- )}
-
-
-
-
-
-
-
- )}
-
- {/* Delete Confirmation Modal */}
- {deleteModal && (
-
-
-
-
-
Confirm Delete
-
- Are you sure you want to delete {deleteModal}? This action cannot be undone.
-
-
-
-
-
-
-
-
- )}
- {/* Hidden File Input for Uploads */}
-
-
- {/* Image Preview Modal */}
- {previewImage && (
-
-
-
-
-
-
-

-
- {previewImage.path}
-
-
-
- )}
-
- );
-};
-
-export default FileSystemNavigator;
diff --git a/frontend/src/components/MultiNodeConsole.js b/frontend/src/components/MultiNodeConsole.js
deleted file mode 100644
index 249ff64..0000000
--- a/frontend/src/components/MultiNodeConsole.js
+++ /dev/null
@@ -1,316 +0,0 @@
-import React, { useEffect, useState, useRef } from 'react';
-import { getNodeStreamUrl } from '../services/apiService';
-import { Terminal } from '@xterm/xterm';
-import { FitAddon } from '@xterm/addon-fit';
-import '@xterm/xterm/css/xterm.css';
-
-// Sub-component for each terminal window to encapsulate Xterm.js logic
-const TerminalNodeItem = ({ nodeId, stats, onMount, onUnmount, nodeConfig, isAIProcessing, thoughtHistory = [] }) => {
- const terminalRef = useRef(null);
- const xtermRef = useRef(null);
- const fitAddonRef = useRef(null);
- const [isDetailsExpanded, setIsDetailsExpanded] = useState(false);
-
- const sandbox = nodeConfig?.skill_config?.shell?.sandbox || {};
- const mode = sandbox.mode || 'PASSIVE';
- const isStrict = mode === 'STRICT';
-
- // UI Feedback: boundary color & pulse logic
- const borderClass = isStrict
- ? (isAIProcessing ? 'border-red-500/80 ring-1 ring-red-500/40 animate-[pulse-red_2s_infinite]' : 'border-red-900/50')
- : (isAIProcessing ? 'border-blue-500/80 ring-1 ring-blue-500/40 animate-[pulse-blue_2s_infinite]' : 'border-blue-900/50');
-
- const statusDotClass = isStrict ? 'bg-rose-500' : 'bg-blue-500';
-
- useEffect(() => {
- const xterm = new Terminal({
- theme: {
- background: '#030712',
- foreground: '#e6edf3',
- cursor: isAIProcessing ? '#388bfd' : '#22c55e',
- selectionBackground: '#388bfd',
- },
- fontSize: 12,
- fontFamily: 'Menlo, Monaco, "Courier New", monospace',
- cursorBlink: isAIProcessing,
- cursorStyle: 'block',
- convertEol: true,
- scrollback: 1000,
- disableStdin: true,
- scrollOnOutput: true,
- });
-
- const fitAddon = new FitAddon();
- xterm.loadAddon(fitAddon);
- xterm.open(terminalRef.current);
- setTimeout(() => fitAddon.fit(), 10);
-
- xtermRef.current = xterm;
- fitAddonRef.current = fitAddon;
- onMount(nodeId, xterm);
-
- const observer = new ResizeObserver(() => fitAddon.fit());
- if (terminalRef.current) observer.observe(terminalRef.current);
-
- return () => {
- observer.disconnect();
- onUnmount(nodeId);
- xterm.dispose();
- };
- }, [nodeId]);
-
- const latestThought = thoughtHistory.length > 0 ? thoughtHistory[thoughtHistory.length - 1] : null;
-
- return (
-
-
-
-
-
- {nodeId}
-
-
-
- C: {stats?.cpu_usage_percent?.toFixed(1) || '0.0'}%
- M: {stats?.memory_usage_percent?.toFixed(1) || '0.0'}%
-
-
-
-
-
-
- {/* Internal Reasoning Details Panel */}
-
-
-
-
- Autonomous Analysis Trace
-
-
-
-
- {thoughtHistory.length === 0 ? (
-
-
Idle Monitoring
-
Waiting for agentic activity...
-
- ) : (
- thoughtHistory.map((t, i) => (
-
-
- {new Date(t.time * 1000).toLocaleTimeString([], { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' })}
-
-
- {t.type === 'mesh_observation' && '⚠️ '}
- {t.thought}
-
-
- ))
- )}
-
-
-
- {/* Minimalist Live Analysis Notice (Non-intrusive when collapsed) */}
- {isAIProcessing && latestThought && !isDetailsExpanded && (
-
-
-
-
-
-
- {latestThought.thought}
-
-
- )}
-
- {isAIProcessing && !latestThought && !isDetailsExpanded && (
-
-
-
-
-
-
- )}
-
-
-
-
- );
-};
-
-const MultiNodeConsole = ({ attachedNodeIds, nodes, isAIProcessing, isExpanded, onToggleExpand }) => {
- const [nodeStats, setNodeStats] = useState({}); // node_id -> stats object
- const [nodeHistory, setNodeHistory] = useState({}); // node_id -> array of {time, thought}
- const [connected, setConnected] = useState(false);
- const wsRef = useRef(null);
- const xtermsRef = useRef({});
-
- useEffect(() => {
- if (!attachedNodeIds || attachedNodeIds.length === 0) return;
-
- let reconnectTimer;
- let isClosing = false;
-
- const connect = () => {
- if (isClosing) return;
- const ws = new WebSocket(getNodeStreamUrl());
- wsRef.current = ws;
-
- ws.onopen = () => setConnected(true);
-
- ws.onmessage = (event) => {
- if (isClosing) return;
- try {
- const msg = JSON.parse(event.data);
- if (msg.node_id && !attachedNodeIds.includes(msg.node_id)) return;
-
- if ((msg.event === 'mesh_heartbeat' || msg.event === 'heartbeat')) {
- if (msg.data?.stats) setNodeStats(prev => ({ ...prev, [msg.node_id]: msg.data.stats }));
- if (msg.data?.nodes) msg.data.nodes.forEach(n => setNodeStats(prev => ({ ...prev, [n.node_id]: n.stats })));
- return;
- }
-
- // Handle Sub-Agent Thoughts & Mesh Observations
- if (msg.event === 'subagent_thought' || msg.event === 'mesh_observation') {
- setNodeHistory(prev => {
- const nodeHistory = prev[msg.node_id] || [];
- const content = msg.event === 'mesh_observation' ? msg.data.message : msg.data;
- // Avoid exact duplicates back-to-back
- if (nodeHistory.length > 0 && nodeHistory[nodeHistory.length - 1].thought === content) return prev;
- const newEntry = { time: Date.now() / 1000, thought: content, type: msg.event };
- return { ...prev, [msg.node_id]: [...nodeHistory, newEntry] };
- });
- return;
- }
-
- const xterm = xtermsRef.current[msg.node_id];
- if (xterm) {
- switch (msg.event) {
- case 'task_assigned':
- if (msg.data.command) {
- if (msg.data.command.includes('__CORTEX_FIN_SH_')) break;
- if (msg.data.command.startsWith('!RAW:')) {
- xterm.write(`\x1b[38;5;36m${msg.data.command.slice(5)}\x1b[0m\r\n`);
- } else {
- xterm.write(`\x1b[38;5;33m\x1b[1m$ ${msg.data.command}\x1b[0m\r\n`);
- }
- // NEW: Clear thought history when a new bash command starts to maintain relevance
- setNodeHistory(prev => ({ ...prev, [msg.node_id]: [] }));
- }
- break;
- case 'task_stdout':
- case 'skill_event':
- let data = msg.event === 'skill_event' ? (msg.data?.data || msg.data?.terminal_out) : msg.data;
- if (data && typeof data === 'string') {
- const stealthData = data.replace(/.*__CORTEX_FIN_SH_.*[\r\n]*/g, '');
- if (stealthData) xterm.write(stealthData);
- } else if (data) xterm.write(data);
- break;
- }
- // Always scroll to bottom on new output
- xterm.scrollToBottom();
- }
- } catch (e) { }
- };
-
- ws.onclose = () => {
- if (!isClosing) {
- setConnected(false);
- reconnectTimer = setTimeout(connect, 3000);
- }
- };
- };
-
- connect();
-
- return () => {
- isClosing = true;
- if (wsRef.current) wsRef.current.close();
- clearTimeout(reconnectTimer);
- };
- }, [JSON.stringify(attachedNodeIds)]);
-
- const handleMount = (nodeId, xterm) => { xtermsRef.current[nodeId] = xterm; };
- const handleUnmount = (nodeId) => { delete xtermsRef.current[nodeId]; };
-
- if (!attachedNodeIds || attachedNodeIds.length === 0) return null;
-
- return (
-
-
-
-
- {isAIProcessing ? 'AI Agent actively controlling node terminals...' : 'Terminal Surfaced to AI :: Context Synchronized'}
-
-
- OBSERVATION_MODE
-
-
-
-
-
-
- NODE_EXECUTION_SWARM
-
-
- {onToggleExpand && (
-
- )}
-
Attached: {attachedNodeIds.length}
-
Live Stream
-
-
-
-
- {attachedNodeIds.map(nodeId => (
- n.node_id === nodeId)}
- isAIProcessing={isAIProcessing}
- thoughtHistory={nodeHistory[nodeId]}
- />
- ))}
-
-
- );
-};
-
-export default MultiNodeConsole;
diff --git a/frontend/src/components/Navbar.js b/frontend/src/components/Navbar.js
deleted file mode 100644
index 73d8717..0000000
--- a/frontend/src/components/Navbar.js
+++ /dev/null
@@ -1,134 +0,0 @@
-import React from 'react';
-import { ReactComponent as Logo } from '../logo.svg';
-
-const Navbar = ({ isOpen, onToggle, onNavigate, onLogout, isLoggedIn, user, Icon }) => {
- const navItems = [
- { name: "Home", icon: "M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z", page: "home" },
- { name: "Voice Chat", icon: "M12 1a3 3 0 0 1 3 3v7a3 3 0 1 1-6 0V4a3 3 0 0 1 3-3zm5 10a5 5 0 0 1-10 0H5a7 7 0 0 0 14 0h-2zm-5 11v-4h-2v4h2z", page: "voice-chat" },
- { name: "Swarm Control", icon: "M12 2a3 3 0 1 0 0 6 3 3 0 0 0 0-6z M5 14a3 3 0 1 0 0 6 3 3 0 0 0 0-6z M19 14a3 3 0 1 0 0 6 3 3 0 0 0 0-6z M10.5 7.5l-4 7 M13.5 7.5l4 7 M8 17h8", page: "swarm-control" },
- { name: "Agent Nodes", icon: "M5 12h14M12 5l7 7-7 7", page: "nodes" },
- { name: "Skills & Workflows", icon: "M12 2l-1 4h-4l3 3-1 4 3-2 3 2-1-4 3-3h-4z", page: "skills" },
- { name: "History", icon: "M22 12h-4l-3 9L9 3l-3 9H2", page: "history", disabled: true },
-
- { name: "Favorites", icon: "M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z", page: "favorites", disabled: true },
- ];
-
- return (
-
- );
-};
-
-export default Navbar;
\ No newline at end of file
diff --git a/frontend/src/components/SessionSidebar.css b/frontend/src/components/SessionSidebar.css
deleted file mode 100644
index c7bc4a7..0000000
--- a/frontend/src/components/SessionSidebar.css
+++ /dev/null
@@ -1,329 +0,0 @@
-/* SessionSidebar.css
- Uses CSS custom properties so it adapts to whatever Tailwind light/dark
- the rest of the app is using, rather than forcing its own dark palette. */
-
-/* ── Layout ──────────────────────────────────────────────────────────── */
-.session-sidebar {
- position: fixed;
- top: 0;
- left: 0;
- width: 280px;
- height: 100vh;
- z-index: 1000;
- display: flex;
- flex-direction: column;
- /* Slide in/out smoothly */
- transform: translateX(-100%);
- transition: transform 0.25s ease;
-
- /* ── Theme: inherit from Tailwind's HTML colour-scheme ── */
- background-color: rgb(255 255 255 / 0.97);
- border-right: 1px solid rgb(209 213 219);
- /* gray-300 */
- color: #111827;
- /* gray-900 */
- box-shadow: 4px 0 16px rgb(0 0 0 / 0.08);
-}
-
-/* Dark-mode variant — triggered by Tailwind's .dark class on */
-@media (prefers-color-scheme: dark) {
- .session-sidebar {
- background-color: rgb(31 41 55 / 0.98);
- /* gray-800 */
- border-right-color: rgb(55 65 81);
- /* gray-700 */
- color: #f3f4f6;
- /* gray-100 */
- box-shadow: 4px 0 20px rgb(0 0 0 / 0.4);
- }
-}
-
-.session-sidebar.open {
- transform: translateX(0);
-}
-
-/* ── Toggle tab (the ▶/◀ handle sticking out to the right) ─────────── */
-.sidebar-toggle {
- position: absolute;
- top: 50%;
- right: -36px;
- transform: translateY(-50%);
- width: 36px;
- height: 72px;
-
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- gap: 4px;
-
- cursor: pointer;
- border-radius: 0 8px 8px 0;
-
- /* Use same background as panel */
- background-color: inherit;
- border: 1px solid rgb(209 213 219);
- border-left: none;
- box-shadow: 3px 0 8px rgb(0 0 0 / 0.08);
-
- /* Prevent text overflow in the narrow tab */
- overflow: hidden;
- padding: 4px 2px;
-}
-
-@media (prefers-color-scheme: dark) {
- .sidebar-toggle {
- border-color: rgb(55 65 81);
- }
-}
-
-.sidebar-toggle-arrow {
- font-size: 12px;
- line-height: 1;
- color: #6366f1;
- /* indigo-500 */
-}
-
-.sidebar-toggle-label {
- font-size: 10px;
- font-weight: 600;
- letter-spacing: 0.05em;
- writing-mode: vertical-rl;
- text-orientation: mixed;
- color: inherit;
- opacity: 0.7;
-}
-
-/* ── Panel content ───────────────────────────────────────────────────── */
-.sidebar-content {
- padding: 16px 14px 16px 16px;
- flex: 1;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- /* top space so content doesn't overlap Navbar if present */
- padding-top: 60px;
-}
-
-/* ── Header row (title + Delete All) ───────────────────────────────── */
-.sidebar-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 14px;
- padding-bottom: 10px;
- border-bottom: 1px solid rgb(209 213 219);
-}
-
-@media (prefers-color-scheme: dark) {
- .sidebar-header {
- border-bottom-color: rgb(55 65 81);
- }
-}
-
-.sidebar-header h3 {
- margin: 0;
- font-size: 14px;
- font-weight: 700;
- letter-spacing: 0.03em;
- color: inherit;
-}
-
-/* ── "Delete All" button ────────────────────────────────────────────── */
-.delete-all {
- background: transparent;
- color: #ef4444;
- /* red-500 */
- border: 1px solid #ef4444;
- padding: 3px 8px;
- font-size: 11px;
- font-weight: 600;
- border-radius: 4px;
- cursor: pointer;
- transition: background 0.15s, color 0.15s;
- white-space: nowrap;
-}
-
-.delete-all:hover {
- background-color: #ef4444;
- color: #fff;
-}
-
-/* ── Session list ───────────────────────────────────────────────────── */
-.sidebar-list {
- flex: 1;
- overflow-y: auto;
- display: flex;
- flex-direction: column;
- gap: 6px;
-}
-
-.sidebar-loading,
-.sidebar-empty {
- font-size: 13px;
- color: #9ca3af;
- /* gray-400 */
- text-align: center;
- margin-top: 24px;
-}
-
-/* ── Individual session card ─────────────────────────────────────────── */
-.sidebar-item {
- background-color: rgb(249 250 251);
- /* gray-50 */
- border: 1px solid rgb(229 231 235);
- /* gray-200 */
- padding: 10px 10px 10px 12px;
- border-radius: 8px;
- cursor: pointer;
- display: flex;
- justify-content: space-between;
- align-items: flex-start;
- gap: 6px;
- transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
- position: relative;
-}
-
-@media (prefers-color-scheme: dark) {
- .sidebar-item {
- background-color: rgb(55 65 81 / 0.6);
- /* gray-700 */
- border-color: rgb(75 85 99);
- }
-}
-
-.sidebar-item:hover {
- background-color: rgb(238 242 255);
- /* indigo-50 */
- border-color: #a5b4fc;
- /* indigo-300 */
- box-shadow: 0 1px 4px rgb(99 102 241 / 0.12);
-}
-
-@media (prefers-color-scheme: dark) {
- .sidebar-item:hover {
- background-color: rgb(49 46 129 / 0.4);
- border-color: #6366f1;
- }
-}
-
-/* Active (current) session */
-.sidebar-item.active {
- background-color: rgb(238 242 255);
- /* indigo-50 */
- border-color: #6366f1;
- /* indigo-500 */
- border-left: 3px solid #6366f1;
-}
-
-@media (prefers-color-scheme: dark) {
- .sidebar-item.active {
- background-color: rgb(49 46 129 / 0.5);
- border-color: #818cf8;
- /* indigo-400 */
- border-left-color: #818cf8;
- }
-}
-
-/* ── Card body ───────────────────────────────────────────────────────── */
-.sidebar-item-info {
- display: flex;
- flex-direction: column;
- gap: 3px;
- flex: 1;
- min-width: 0;
- /* allow text truncation */
-}
-
-.sidebar-item-title {
- font-size: 13px;
- font-weight: 600;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- color: inherit;
- line-height: 1.3;
-}
-
-.sidebar-item-meta {
- display: flex;
- align-items: center;
- gap: 6px;
- flex-wrap: wrap;
-}
-
-.sidebar-item-date {
- font-size: 11px;
- color: #9ca3af;
- /* gray-400 */
- white-space: nowrap;
-}
-
-.sidebar-item-provider {
- font-size: 10px;
- font-weight: 600;
- letter-spacing: 0.04em;
- text-transform: uppercase;
- color: #6366f1;
- /* indigo-500 */
- background: rgb(238 242 255);
- border: 1px solid #a5b4fc;
- padding: 1px 5px;
- border-radius: 3px;
-}
-
-@media (prefers-color-scheme: dark) {
- .sidebar-item-provider {
- background: rgb(49 46 129 / 0.5);
- border-color: #6366f1;
- color: #a5b4fc;
- }
-}
-
-/* ── Delete (×) button on each card ─────────────────────────────────── */
-.sidebar-item-delete {
- flex-shrink: 0;
- background: none;
- border: none;
- font-size: 17px;
- line-height: 1;
- color: #d1d5db;
- /* gray-300 */
- cursor: pointer;
- padding: 0 2px;
- transition: color 0.15s;
- margin-top: 1px;
-}
-
-.sidebar-item-delete:hover {
- color: #ef4444;
- /* red-500 */
-}
-
-/* ── Scrollbar ──────────────────────────────────────────────────────── */
-.sidebar-list::-webkit-scrollbar {
- width: 4px;
-}
-
-.sidebar-list::-webkit-scrollbar-track {
- background: transparent;
-}
-
-.sidebar-list::-webkit-scrollbar-thumb {
- background: #d1d5db;
- border-radius: 2px;
-}
-
-.sidebar-list::-webkit-scrollbar-thumb:hover {
- background: #9ca3af;
-}
-
-@media (prefers-color-scheme: dark) {
- .sidebar-list::-webkit-scrollbar-thumb {
- background: #4b5563;
- }
-
- .sidebar-list::-webkit-scrollbar-thumb:hover {
- background: #6b7280;
- }
-}
\ No newline at end of file
diff --git a/frontend/src/components/SessionSidebar.js b/frontend/src/components/SessionSidebar.js
deleted file mode 100644
index 028a60d..0000000
--- a/frontend/src/components/SessionSidebar.js
+++ /dev/null
@@ -1,198 +0,0 @@
-import React, { useState, useEffect } from 'react';
-import {
- getUserSessions,
- deleteSession,
- deleteAllSessions,
- getSessionTokenStatus
-} from '../services/apiService';
-import './SessionSidebar.css';
-
-const SessionSidebar = ({ featureName, currentSessionId, onSwitchSession, onNewSession, refreshTick }) => {
- const [isOpen, setIsOpen] = useState(false);
- const [sessions, setSessions] = useState([]);
- const [tokenHoverData, setTokenHoverData] = useState({});
- const [isLoading, setIsLoading] = useState(false);
- const [confirmModal, setConfirmModal] = useState({ isOpen: false, title: '', message: '', onConfirm: null });
-
- useEffect(() => {
- if (isOpen) fetchSessions();
- }, [isOpen, featureName, currentSessionId, refreshTick]);
-
- const fetchSessions = async () => {
- setIsLoading(true);
- try {
- const data = await getUserSessions(featureName);
- setSessions(data || []);
- } catch (err) {
- console.error('Failed to fetch sessions:', err);
- } finally {
- setIsLoading(false);
- }
- };
-
- const handleMouseEnter = async (sessionId) => {
- if (tokenHoverData[sessionId]) return;
- try {
- const data = await getSessionTokenStatus(sessionId);
- setTokenHoverData(prev => ({ ...prev, [sessionId]: data }));
- } catch (err) { /* silent */ }
- };
-
- const handleDelete = (e, sessionId) => {
- e.stopPropagation();
- setConfirmModal({
- isOpen: true,
- title: 'Delete Session',
- message: 'Are you sure you want to delete this session? This action cannot be undone.',
- onConfirm: async () => {
- try {
- await deleteSession(sessionId);
- fetchSessions();
- if (Number(currentSessionId) === sessionId) {
- localStorage.removeItem(`sessionId_${featureName}`);
- if (onNewSession) onNewSession();
- }
- } catch { alert('Failed to delete session.'); }
- }
- });
- };
-
- const handleDeleteAll = (e) => {
- if (e) e.stopPropagation();
- setConfirmModal({
- isOpen: true,
- title: 'Clear All History',
- message: 'Are you sure you want to delete ALL history for this feature? This action is permanent.',
- onConfirm: async () => {
- try {
- await deleteAllSessions(featureName);
- fetchSessions();
- if (onNewSession) onNewSession();
- } catch { alert('Failed to delete all sessions.'); }
- }
- });
- };
-
- const formatDate = (iso) => {
- const d = new Date(iso);
- const now = new Date();
- const diffDays = Math.floor((now - d) / 86400000);
- if (diffDays === 0) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
- if (diffDays === 1) return 'Yesterday';
- if (diffDays < 7) return d.toLocaleDateString([], { weekday: 'short' });
- return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
- };
-
- const prettyFeatureName = featureName
- .split('_')
- .map(w => w.charAt(0).toUpperCase() + w.slice(1))
- .join(' ');
-
- return (
-
- {/* ▶/◀ Tab handle */}
-
setIsOpen(!isOpen)}>
- {isOpen ? '◀' : '▶'}
- History
-
-
- {isOpen && (
-
-
-
{prettyFeatureName} History
-
-
-
-
- {isLoading ? (
-
Loading sessions…
- ) : sessions.length === 0 ? (
-
No past sessions yet.
- ) : (
- sessions.map(s => {
- const isActive = Number(currentSessionId) === s.id;
- const td = tokenHoverData[s.id];
- // Derive a display title: prefer session.title, fall back gracefully
- const displayTitle = s.title &&
- s.title !== 'New Chat Session'
- ? s.title
- : `Session #${s.id}`;
-
- const llmInfo = s.provider_name ? `LLM: ${s.provider_name}` : 'LLM: Default';
- const sttInfo = s.stt_provider_name ? `STT: ${s.stt_provider_name}` : 'STT: Default';
- const ttsInfo = s.tts_provider_name ? `TTS: ${s.tts_provider_name}` : 'TTS: Default';
-
- const usageInfo = td
- ? `Context: ${td.token_count.toLocaleString()} / ${td.token_limit.toLocaleString()} tokens (${td.percentage}%)`
- : 'Hover to load token usage stats';
-
- const tooltip = `${displayTitle}\n---\n${llmInfo}\n${sttInfo}\n${ttsInfo}\n---\n${usageInfo}`;
-
- return (
-
onSwitchSession(s.id)}
- onMouseEnter={() => handleMouseEnter(s.id)}
- title={tooltip}
- >
-
-
{displayTitle}
-
- {formatDate(s.created_at)}
- {s.provider_name && (
- {s.provider_name}
- )}
-
-
-
-
- );
- })
- )}
-
-
- )}
- {/* Custom Confirmation Modal */}
- {confirmModal.isOpen && (
-
-
-
-
{confirmModal.title}
-
{confirmModal.message}
-
-
-
-
-
-
- )}
-
- );
-};
-
-export default SessionSidebar;
diff --git a/frontend/src/features/nodes/pages/NodesPage.js b/frontend/src/features/nodes/pages/NodesPage.js
index d7df934..78fdc45 100644
--- a/frontend/src/features/nodes/pages/NodesPage.js
+++ b/frontend/src/features/nodes/pages/NodesPage.js
@@ -5,7 +5,7 @@
getAdminGroups, getNodeStreamUrl
} from '../../../services/apiService';
import NodeTerminal from "../components/NodeTerminal";
-import FileSystemNavigator from "../../../components/FileSystemNavigator";
+import { FileSystemNavigator } from "../../../shared/components";
const NodesPage = ({ user }) => {
const [nodes, setNodes] = useState([]);
diff --git a/frontend/src/features/voice/pages/VoiceChatPage.js b/frontend/src/features/voice/pages/VoiceChatPage.js
index 53eca38..4ae3ee9 100644
--- a/frontend/src/features/voice/pages/VoiceChatPage.js
+++ b/frontend/src/features/voice/pages/VoiceChatPage.js
@@ -1,7 +1,7 @@
import React, { useState, useRef, useEffect } from "react";
import { useVoiceChat, VoiceControls } from "..";
import { ChatWindow } from "../../chat";
-import SessionSidebar from "../../../components/SessionSidebar";
+import { SessionSidebar } from "../../../shared/components";
import { updateSession } from "../../../services/apiService";
const VoiceChatPage = () => {
diff --git a/frontend/src/pages/SwarmControlPage.js b/frontend/src/pages/SwarmControlPage.js
index 71fd1a2..0a10c22 100644
--- a/frontend/src/pages/SwarmControlPage.js
+++ b/frontend/src/pages/SwarmControlPage.js
@@ -1,14 +1,12 @@
import React, { useState, useRef, useEffect, useCallback } from "react";
import { ChatArea } from "../features/chat";
-import SessionSidebar from "../components/SessionSidebar";
-import MultiNodeConsole from "../components/MultiNodeConsole";
+import { SessionSidebar, MultiNodeConsole, FileSystemNavigator } from "../shared/components";
import useSwarmControl from "../hooks/useSwarmControl";
import {
updateSession, getSessionNodeStatus, attachNodesToSession,
detachNodeFromSession, getUserAccessibleNodes, getUserNodePreferences, nodeFsList,
clearSessionHistory
} from "../services/apiService";
-import FileSystemNavigator from "../components/FileSystemNavigator";
const CodeAssistantPage = () => {
const pageContainerRef = useRef(null);
diff --git a/frontend/src/shared/components/FileSystemNavigator.js b/frontend/src/shared/components/FileSystemNavigator.js
new file mode 100644
index 0000000..eb19b55
--- /dev/null
+++ b/frontend/src/shared/components/FileSystemNavigator.js
@@ -0,0 +1,593 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import { nodeFsList, nodeFsCat, nodeFsTouch, nodeFsRm, nodeFsUpload, nodeFsDownloadBlob } from '../../services/apiService';
+
+/**
+ * A modular File Navigator component similar to VS Code's side panel.
+ * Displays a tree structure from an Agent Node's filesystem.
+ */
+const FileSystemNavigator = ({
+ nodeId,
+ initialPath = ".",
+ sessionId = "__fs_explorer__",
+ showSyncStatus = false
+}) => {
+ const [tree, setTree] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [expanded, setExpanded] = useState({}); // { [path]: boolean }
+ const [error, setError] = useState(null);
+ const [selectedFile, setSelectedFile] = useState(null); // { path, content }
+ const [isEditing, setIsEditing] = useState(false);
+ const [newItemModal, setNewItemModal] = useState(null); // { parentPath, isDir }
+ const [deleteModal, setDeleteModal] = useState(null); // path
+ const [operationLoading, setOperationLoading] = useState(false);
+ const [folderLoading, setFolderLoading] = useState({}); // { [path]: boolean }
+ const [previewImage, setPreviewImage] = useState(null); // { path, url }
+ const uploadInputRef = React.useRef(null);
+ const [uploadTargetPath, setUploadTargetPath] = useState(null);
+
+ const fetchLevel = useCallback(async (path) => {
+ const data = await nodeFsList(nodeId, path, sessionId);
+ return data.files || [];
+ }, [nodeId, sessionId]);
+
+ const mergeFiles = (prev, newFiles, parentPath) => {
+ const parentPrefix = (parentPath === "." || parentPath === "/" || parentPath === "")
+ ? ""
+ : (parentPath.endsWith("/") ? parentPath : parentPath + "/");
+
+ const newPaths = new Set(newFiles.map(f => f.path));
+ const preserved = prev.filter(f => {
+ if (newPaths.has(f.path)) return false;
+ const rel = f.path.startsWith("/") ? f.path.slice(1) : f.path;
+ const pRel = parentPrefix.startsWith("/") ? parentPrefix.slice(1) : parentPrefix;
+
+ if (pRel === "") {
+ if (!rel.includes("/")) return false;
+ } else {
+ if (rel.startsWith(pRel)) {
+ const sub = rel.slice(pRel.length);
+ if (!sub.includes("/") && sub.length > 0) return false;
+ }
+ }
+ return true;
+ });
+ return [...newFiles, ...preserved];
+ };
+
+ const loadRoot = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const files = await fetchLevel(initialPath);
+ setTree(prev => mergeFiles(prev, files, initialPath));
+ } catch (err) {
+ setError(err.message || "Failed to connect to node filesystem.");
+ } finally {
+ setLoading(false);
+ }
+ }, [initialPath, fetchLevel]);
+
+ useEffect(() => {
+ if (nodeId) {
+ loadRoot();
+
+ const shouldPoll = showSyncStatus || (sessionId && sessionId !== "__fs_explorer__");
+
+ if (shouldPoll) {
+ const interval = setInterval(() => {
+ loadRoot();
+ }, 5000);
+ return () => clearInterval(interval);
+ }
+ }
+ }, [nodeId, loadRoot, showSyncStatus, sessionId]);
+
+ // Keyboard Navigation for Media
+ useEffect(() => {
+ const handleKeyDown = (e) => {
+ if (!previewImage) return;
+
+ if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
+ e.preventDefault();
+ // Get flat list of images in the current tree
+ const imageFiles = tree.filter(f => !f.is_dir && isImageFile(f.path))
+ .sort((a, b) => a.path.localeCompare(b.path));
+
+ if (imageFiles.length <= 1) return;
+
+ const currentIndex = imageFiles.findIndex(f => f.path === previewImage.path);
+ let nextIndex;
+ if (e.key === 'ArrowRight') {
+ nextIndex = (currentIndex + 1) % imageFiles.length;
+ } else {
+ nextIndex = (currentIndex - 1 + imageFiles.length) % imageFiles.length;
+ }
+ handleView(imageFiles[nextIndex].path);
+ } else if (e.key === 'Escape') {
+ if (previewImage.url) URL.revokeObjectURL(previewImage.url);
+ setPreviewImage(null);
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [previewImage, tree]);
+
+ const toggleFolder = async (path) => {
+ const isExpanded = expanded[path];
+ if (!isExpanded) {
+ setExpanded(prev => ({ ...prev, [path]: true }));
+
+ // Normalize path for prefix check: e.g. "etc" -> "etc/"
+ const target = (path === "/" || path === ".") ? "" : (path.startsWith("/") ? path.slice(1) : path);
+ const prefix = target === "" ? "" : (target.endsWith("/") ? target : target + "/");
+
+ const hasChildren = tree.some(node => {
+ const nodeRel = node.path.startsWith("/") ? node.path.slice(1) : node.path;
+ return nodeRel.startsWith(prefix) && nodeRel !== target;
+ });
+
+ if (!hasChildren) {
+ setFolderLoading(prev => ({ ...prev, [path]: true }));
+ try {
+ // Node expects relative path for subdirs, or "." for root
+ const fetchPath = (path === "/" || path === ".") ? "." : (path.startsWith("/") ? path.slice(1) : path);
+ const children = await fetchLevel(fetchPath);
+
+ setTree(prev => {
+ const existingPaths = new Set(prev.map(f => f.path));
+ const newOnes = children.filter(c => !existingPaths.has(c.path));
+ return [...prev, ...newOnes];
+ });
+ } catch (err) {
+ console.error("Folder expansion failed:", err);
+ setError(`Failed to open folder: ${err.message}`);
+ } finally {
+ setFolderLoading(prev => ({ ...prev, [path]: false }));
+ }
+ }
+ } else {
+ setExpanded(prev => ({ ...prev, [path]: false }));
+ }
+ };
+
+ const handleCreateFinal = async (name) => {
+ if (!name || !newItemModal) return;
+ const { parentPath, isDir } = newItemModal;
+
+ let fullPath;
+ if (!parentPath || parentPath === "." || parentPath === "" || parentPath === "/") {
+ fullPath = name;
+ } else {
+ fullPath = `${parentPath}/${name}`;
+ }
+
+ setOperationLoading(true);
+ setError(null);
+ try {
+ await nodeFsTouch(nodeId, fullPath, "", isDir, sessionId);
+ setNewItemModal(null);
+ setTimeout(loadRoot, 500);
+ } catch (err) {
+ setError(`Failed to create: ${err.message}`);
+ } finally {
+ setOperationLoading(false);
+ }
+ };
+
+ const handleDeleteFinal = async () => {
+ if (!deleteModal) return;
+ const path = deleteModal;
+ setOperationLoading(true);
+ setError(null);
+ try {
+ await nodeFsRm(nodeId, path, sessionId);
+ // Optimistically remove from tree to force UI update
+ setTree(prev => prev.filter(f => !f.path.startsWith(path)));
+ setDeleteModal(null);
+ setTimeout(loadRoot, 500);
+ } catch (err) {
+ setError(`Failed to delete: ${err.message}`);
+ } finally {
+ setOperationLoading(false);
+ }
+ };
+
+ const isBinaryFile = (path) => {
+ const ext = path.split('.').pop().toLowerCase();
+ const binaryExts = ['pdf', 'zip', 'gz', 'tar', 'exe', 'dll', 'so', 'bin', 'pyc', 'node', 'db', 'sqlite', 'mp3', 'mp4', 'wav', 'mov'];
+ return binaryExts.includes(ext);
+ };
+
+ const isImageFile = (path) => {
+ const ext = path.split('.').pop().toLowerCase();
+ const imgExts = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico', 'webp'];
+ return imgExts.includes(ext);
+ };
+
+ const handleView = async (path) => {
+ if (isImageFile(path)) {
+ setOperationLoading(true);
+ try {
+ const blob = await nodeFsDownloadBlob(nodeId, path, sessionId);
+ const url = URL.createObjectURL(blob);
+
+ setPreviewImage(prev => {
+ if (prev && prev.url) URL.revokeObjectURL(prev.url);
+ return { path, url };
+ });
+ } catch (err) {
+ setError(`Failed to load image: ${err.message}`);
+ } finally {
+ setOperationLoading(false);
+ }
+ return;
+ }
+
+ if (isBinaryFile(path)) {
+ setError(`Cannot view binary file: ${path}. Please download it instead.`);
+ return;
+ }
+
+ setOperationLoading(true);
+ setError(null);
+ try {
+ const res = await nodeFsCat(nodeId, path, sessionId);
+ setSelectedFile({ path, content: res.content });
+ setIsEditing(false);
+ } catch (err) {
+ setError(`Failed to read file: ${err.message}`);
+ } finally {
+ setOperationLoading(false);
+ }
+ };
+
+ const handleDownload = async (path) => {
+ setOperationLoading(true);
+ setError(null);
+ try {
+ const blob = await nodeFsDownloadBlob(nodeId, path, sessionId);
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.setAttribute('download', path.split('/').pop());
+ document.body.appendChild(link);
+ link.click();
+ link.remove();
+ } catch (err) {
+ setError(`Download failed: ${err.message}`);
+ } finally {
+ setOperationLoading(false);
+ }
+ };
+
+ const handleUploadClick = (targetPath) => {
+ setUploadTargetPath(targetPath);
+ if (uploadInputRef.current) {
+ uploadInputRef.current.click();
+ }
+ };
+
+ const handleFileUpload = async (event) => {
+ const file = event.target.files[0];
+ if (!file || !uploadTargetPath) return;
+
+ setOperationLoading(true);
+ setError(null);
+ try {
+ // If uploadTargetPath is ".", upload to root. If a dir path, use as-is.
+ await nodeFsUpload(nodeId, uploadTargetPath, file, sessionId);
+ setTimeout(loadRoot, 600);
+ } catch (err) {
+ setError(`Upload failed: ${err.message}`);
+ } finally {
+ setOperationLoading(false);
+ setUploadTargetPath(null);
+ event.target.value = ''; // Reset input
+ }
+ };
+
+ const handleSave = async () => {
+ setOperationLoading(true);
+ setError(null);
+ try {
+ await nodeFsTouch(nodeId, selectedFile.path, selectedFile.content, false, sessionId);
+ setSelectedFile(null);
+ } catch (err) {
+ setError(`Failed to save: ${err.message}`);
+ } finally {
+ setOperationLoading(false);
+ }
+ };
+
+ // Helper to render tree recursively
+ const renderSubTree = (currentPath, depth = 0) => {
+ // Normalize currentPath for filtering
+ const normCurrent = (currentPath === "/" || currentPath === "." || currentPath === "")
+ ? ""
+ : (currentPath.startsWith("/") ? currentPath.slice(1) : currentPath);
+
+ const children = tree.filter(node => {
+ const nodePath = node.path.startsWith("/") ? node.path.slice(1) : node.path;
+
+ if (normCurrent === "") {
+ // Root level: paths without slashes
+ return !nodePath.includes("/");
+ }
+
+ // Nested level: starts with "parent/" and has no further slashes
+ const prefix = normCurrent.endsWith("/") ? normCurrent : normCurrent + "/";
+ if (!nodePath.startsWith(prefix)) return false;
+ if (nodePath === normCurrent) return false;
+
+ const sub = nodePath.slice(prefix.length);
+ return !sub.includes("/");
+ });
+
+ // Sort: Folders first, then Alphabetical
+ const sorted = [...children].sort((a, b) => {
+ if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
+ return a.name.localeCompare(b.name);
+ });
+
+ return sorted.map(node => (
+
+
+
node.is_dir ? toggleFolder(node.path) : handleView(node.path)}
+ >
+ {folderLoading[node.path] ? (
+
+ ) : node.is_dir ? (
+
+ ) : (
+
+ )}
+
+
+
node.is_dir ? toggleFolder(node.path) : handleView(node.path)}
+ >
+ {showSyncStatus && (
+
+ )}
+ {node.name}
+
+
+
+ {node.is_dir ? (
+ <>
+
+
+
+ >
+ ) : (
+
+ )}
+
+
+
+ {node.is_dir && expanded[node.path] && (
+
+ {renderSubTree(node.path, depth + 1)}
+
+ )}
+
+ ));
+ };
+
+ return (
+
+ {/* Header */}
+
+
+
+ File Explorer
+
+
+
+
+
+
+
+
+
+ {/* List */}
+
+ {error &&
{error}
}
+ {!loading && tree.length === 0 && !error && (
+
+
+
No files found or node offline
+
+ )}
+ {renderSubTree(initialPath)}
+
+
+ {/* Create Item Modal */}
+ {newItemModal && (
+
+
+
+
+ {newItemModal.isDir ? 'Create New Folder' : 'Create New File'}
+
+
+
+
+
+
+ )}
+
+ {/* Editor Modal */}
+ {selectedFile && (
+
+
+
+
+
{selectedFile.path}
+
{isEditing ? 'Editing Mode' : 'Read-Only View'}
+
+
+ {!isEditing ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+ )}
+
+ {/* Delete Confirmation Modal */}
+ {deleteModal && (
+
+
+
+
+
Confirm Delete
+
+ Are you sure you want to delete {deleteModal}? This action cannot be undone.
+
+
+
+
+
+
+
+
+ )}
+ {/* Hidden File Input for Uploads */}
+
+
+ {/* Image Preview Modal */}
+ {previewImage && (
+
+
+
+
+
+
+

+
+ {previewImage.path}
+
+
+
+ )}
+
+ );
+};
+
+export default FileSystemNavigator;
diff --git a/frontend/src/shared/components/MultiNodeConsole.js b/frontend/src/shared/components/MultiNodeConsole.js
new file mode 100644
index 0000000..23ed117
--- /dev/null
+++ b/frontend/src/shared/components/MultiNodeConsole.js
@@ -0,0 +1,316 @@
+import React, { useEffect, useState, useRef } from 'react';
+import { getNodeStreamUrl } from '../../services/apiService';
+import { Terminal } from '@xterm/xterm';
+import { FitAddon } from '@xterm/addon-fit';
+import '@xterm/xterm/css/xterm.css';
+
+// Sub-component for each terminal window to encapsulate Xterm.js logic
+const TerminalNodeItem = ({ nodeId, stats, onMount, onUnmount, nodeConfig, isAIProcessing, thoughtHistory = [] }) => {
+ const terminalRef = useRef(null);
+ const xtermRef = useRef(null);
+ const fitAddonRef = useRef(null);
+ const [isDetailsExpanded, setIsDetailsExpanded] = useState(false);
+
+ const sandbox = nodeConfig?.skill_config?.shell?.sandbox || {};
+ const mode = sandbox.mode || 'PASSIVE';
+ const isStrict = mode === 'STRICT';
+
+ // UI Feedback: boundary color & pulse logic
+ const borderClass = isStrict
+ ? (isAIProcessing ? 'border-red-500/80 ring-1 ring-red-500/40 animate-[pulse-red_2s_infinite]' : 'border-red-900/50')
+ : (isAIProcessing ? 'border-blue-500/80 ring-1 ring-blue-500/40 animate-[pulse-blue_2s_infinite]' : 'border-blue-900/50');
+
+ const statusDotClass = isStrict ? 'bg-rose-500' : 'bg-blue-500';
+
+ useEffect(() => {
+ const xterm = new Terminal({
+ theme: {
+ background: '#030712',
+ foreground: '#e6edf3',
+ cursor: isAIProcessing ? '#388bfd' : '#22c55e',
+ selectionBackground: '#388bfd',
+ },
+ fontSize: 12,
+ fontFamily: 'Menlo, Monaco, "Courier New", monospace',
+ cursorBlink: isAIProcessing,
+ cursorStyle: 'block',
+ convertEol: true,
+ scrollback: 1000,
+ disableStdin: true,
+ scrollOnOutput: true,
+ });
+
+ const fitAddon = new FitAddon();
+ xterm.loadAddon(fitAddon);
+ xterm.open(terminalRef.current);
+ setTimeout(() => fitAddon.fit(), 10);
+
+ xtermRef.current = xterm;
+ fitAddonRef.current = fitAddon;
+ onMount(nodeId, xterm);
+
+ const observer = new ResizeObserver(() => fitAddon.fit());
+ if (terminalRef.current) observer.observe(terminalRef.current);
+
+ return () => {
+ observer.disconnect();
+ onUnmount(nodeId);
+ xterm.dispose();
+ };
+ }, [nodeId]);
+
+ const latestThought = thoughtHistory.length > 0 ? thoughtHistory[thoughtHistory.length - 1] : null;
+
+ return (
+
+
+
+
+
+ {nodeId}
+
+
+
+ C: {stats?.cpu_usage_percent?.toFixed(1) || '0.0'}%
+ M: {stats?.memory_usage_percent?.toFixed(1) || '0.0'}%
+
+
+
+
+
+
+ {/* Internal Reasoning Details Panel */}
+
+
+
+
+ Autonomous Analysis Trace
+
+
+
+
+ {thoughtHistory.length === 0 ? (
+
+
Idle Monitoring
+
Waiting for agentic activity...
+
+ ) : (
+ thoughtHistory.map((t, i) => (
+
+
+ {new Date(t.time * 1000).toLocaleTimeString([], { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' })}
+
+
+ {t.type === 'mesh_observation' && '⚠️ '}
+ {t.thought}
+
+
+ ))
+ )}
+
+
+
+ {/* Minimalist Live Analysis Notice (Non-intrusive when collapsed) */}
+ {isAIProcessing && latestThought && !isDetailsExpanded && (
+
+
+
+
+
+
+ {latestThought.thought}
+
+
+ )}
+
+ {isAIProcessing && !latestThought && !isDetailsExpanded && (
+
+
+
+
+
+
+ )}
+
+
+
+
+ );
+};
+
+const MultiNodeConsole = ({ attachedNodeIds, nodes, isAIProcessing, isExpanded, onToggleExpand }) => {
+ const [nodeStats, setNodeStats] = useState({}); // node_id -> stats object
+ const [nodeHistory, setNodeHistory] = useState({}); // node_id -> array of {time, thought}
+ const [connected, setConnected] = useState(false);
+ const wsRef = useRef(null);
+ const xtermsRef = useRef({});
+
+ useEffect(() => {
+ if (!attachedNodeIds || attachedNodeIds.length === 0) return;
+
+ let reconnectTimer;
+ let isClosing = false;
+
+ const connect = () => {
+ if (isClosing) return;
+ const ws = new WebSocket(getNodeStreamUrl());
+ wsRef.current = ws;
+
+ ws.onopen = () => setConnected(true);
+
+ ws.onmessage = (event) => {
+ if (isClosing) return;
+ try {
+ const msg = JSON.parse(event.data);
+ if (msg.node_id && !attachedNodeIds.includes(msg.node_id)) return;
+
+ if ((msg.event === 'mesh_heartbeat' || msg.event === 'heartbeat')) {
+ if (msg.data?.stats) setNodeStats(prev => ({ ...prev, [msg.node_id]: msg.data.stats }));
+ if (msg.data?.nodes) msg.data.nodes.forEach(n => setNodeStats(prev => ({ ...prev, [n.node_id]: n.stats })));
+ return;
+ }
+
+ // Handle Sub-Agent Thoughts & Mesh Observations
+ if (msg.event === 'subagent_thought' || msg.event === 'mesh_observation') {
+ setNodeHistory(prev => {
+ const nodeHistory = prev[msg.node_id] || [];
+ const content = msg.event === 'mesh_observation' ? msg.data.message : msg.data;
+ // Avoid exact duplicates back-to-back
+ if (nodeHistory.length > 0 && nodeHistory[nodeHistory.length - 1].thought === content) return prev;
+ const newEntry = { time: Date.now() / 1000, thought: content, type: msg.event };
+ return { ...prev, [msg.node_id]: [...nodeHistory, newEntry] };
+ });
+ return;
+ }
+
+ const xterm = xtermsRef.current[msg.node_id];
+ if (xterm) {
+ switch (msg.event) {
+ case 'task_assigned':
+ if (msg.data.command) {
+ if (msg.data.command.includes('__CORTEX_FIN_SH_')) break;
+ if (msg.data.command.startsWith('!RAW:')) {
+ xterm.write(`\x1b[38;5;36m${msg.data.command.slice(5)}\x1b[0m\r\n`);
+ } else {
+ xterm.write(`\x1b[38;5;33m\x1b[1m$ ${msg.data.command}\x1b[0m\r\n`);
+ }
+ // NEW: Clear thought history when a new bash command starts to maintain relevance
+ setNodeHistory(prev => ({ ...prev, [msg.node_id]: [] }));
+ }
+ break;
+ case 'task_stdout':
+ case 'skill_event':
+ let data = msg.event === 'skill_event' ? (msg.data?.data || msg.data?.terminal_out) : msg.data;
+ if (data && typeof data === 'string') {
+ const stealthData = data.replace(/.*__CORTEX_FIN_SH_.*[\r\n]*/g, '');
+ if (stealthData) xterm.write(stealthData);
+ } else if (data) xterm.write(data);
+ break;
+ }
+ // Always scroll to bottom on new output
+ xterm.scrollToBottom();
+ }
+ } catch (e) { }
+ };
+
+ ws.onclose = () => {
+ if (!isClosing) {
+ setConnected(false);
+ reconnectTimer = setTimeout(connect, 3000);
+ }
+ };
+ };
+
+ connect();
+
+ return () => {
+ isClosing = true;
+ if (wsRef.current) wsRef.current.close();
+ clearTimeout(reconnectTimer);
+ };
+ }, [JSON.stringify(attachedNodeIds)]);
+
+ const handleMount = (nodeId, xterm) => { xtermsRef.current[nodeId] = xterm; };
+ const handleUnmount = (nodeId) => { delete xtermsRef.current[nodeId]; };
+
+ if (!attachedNodeIds || attachedNodeIds.length === 0) return null;
+
+ return (
+
+
+
+
+ {isAIProcessing ? 'AI Agent actively controlling node terminals...' : 'Terminal Surfaced to AI :: Context Synchronized'}
+
+
+ OBSERVATION_MODE
+
+
+
+
+
+
+ NODE_EXECUTION_SWARM
+
+
+ {onToggleExpand && (
+
+ )}
+
Attached: {attachedNodeIds.length}
+
Live Stream
+
+
+
+
+ {attachedNodeIds.map(nodeId => (
+ n.node_id === nodeId)}
+ isAIProcessing={isAIProcessing}
+ thoughtHistory={nodeHistory[nodeId]}
+ />
+ ))}
+
+
+ );
+};
+
+export default MultiNodeConsole;
diff --git a/frontend/src/shared/components/Navbar.js b/frontend/src/shared/components/Navbar.js
new file mode 100644
index 0000000..9efda1a
--- /dev/null
+++ b/frontend/src/shared/components/Navbar.js
@@ -0,0 +1,134 @@
+import React from 'react';
+import { ReactComponent as Logo } from '../../logo.svg';
+
+const Navbar = ({ isOpen, onToggle, onNavigate, onLogout, isLoggedIn, user, Icon }) => {
+ const navItems = [
+ { name: "Home", icon: "M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z", page: "home" },
+ { name: "Voice Chat", icon: "M12 1a3 3 0 0 1 3 3v7a3 3 0 1 1-6 0V4a3 3 0 0 1 3-3zm5 10a5 5 0 0 1-10 0H5a7 7 0 0 0 14 0h-2zm-5 11v-4h-2v4h2z", page: "voice-chat" },
+ { name: "Swarm Control", icon: "M12 2a3 3 0 1 0 0 6 3 3 0 0 0 0-6z M5 14a3 3 0 1 0 0 6 3 3 0 0 0 0-6z M19 14a3 3 0 1 0 0 6 3 3 0 0 0 0-6z M10.5 7.5l-4 7 M13.5 7.5l4 7 M8 17h8", page: "swarm-control" },
+ { name: "Agent Nodes", icon: "M5 12h14M12 5l7 7-7 7", page: "nodes" },
+ { name: "Skills & Workflows", icon: "M12 2l-1 4h-4l3 3-1 4 3-2 3 2-1-4 3-3h-4z", page: "skills" },
+ { name: "History", icon: "M22 12h-4l-3 9L9 3l-3 9H2", page: "history", disabled: true },
+
+ { name: "Favorites", icon: "M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z", page: "favorites", disabled: true },
+ ];
+
+ return (
+
+ );
+};
+
+export default Navbar;
\ No newline at end of file
diff --git a/frontend/src/shared/components/SessionSidebar.css b/frontend/src/shared/components/SessionSidebar.css
new file mode 100644
index 0000000..c7bc4a7
--- /dev/null
+++ b/frontend/src/shared/components/SessionSidebar.css
@@ -0,0 +1,329 @@
+/* SessionSidebar.css
+ Uses CSS custom properties so it adapts to whatever Tailwind light/dark
+ the rest of the app is using, rather than forcing its own dark palette. */
+
+/* ── Layout ──────────────────────────────────────────────────────────── */
+.session-sidebar {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 280px;
+ height: 100vh;
+ z-index: 1000;
+ display: flex;
+ flex-direction: column;
+ /* Slide in/out smoothly */
+ transform: translateX(-100%);
+ transition: transform 0.25s ease;
+
+ /* ── Theme: inherit from Tailwind's HTML colour-scheme ── */
+ background-color: rgb(255 255 255 / 0.97);
+ border-right: 1px solid rgb(209 213 219);
+ /* gray-300 */
+ color: #111827;
+ /* gray-900 */
+ box-shadow: 4px 0 16px rgb(0 0 0 / 0.08);
+}
+
+/* Dark-mode variant — triggered by Tailwind's .dark class on */
+@media (prefers-color-scheme: dark) {
+ .session-sidebar {
+ background-color: rgb(31 41 55 / 0.98);
+ /* gray-800 */
+ border-right-color: rgb(55 65 81);
+ /* gray-700 */
+ color: #f3f4f6;
+ /* gray-100 */
+ box-shadow: 4px 0 20px rgb(0 0 0 / 0.4);
+ }
+}
+
+.session-sidebar.open {
+ transform: translateX(0);
+}
+
+/* ── Toggle tab (the ▶/◀ handle sticking out to the right) ─────────── */
+.sidebar-toggle {
+ position: absolute;
+ top: 50%;
+ right: -36px;
+ transform: translateY(-50%);
+ width: 36px;
+ height: 72px;
+
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 4px;
+
+ cursor: pointer;
+ border-radius: 0 8px 8px 0;
+
+ /* Use same background as panel */
+ background-color: inherit;
+ border: 1px solid rgb(209 213 219);
+ border-left: none;
+ box-shadow: 3px 0 8px rgb(0 0 0 / 0.08);
+
+ /* Prevent text overflow in the narrow tab */
+ overflow: hidden;
+ padding: 4px 2px;
+}
+
+@media (prefers-color-scheme: dark) {
+ .sidebar-toggle {
+ border-color: rgb(55 65 81);
+ }
+}
+
+.sidebar-toggle-arrow {
+ font-size: 12px;
+ line-height: 1;
+ color: #6366f1;
+ /* indigo-500 */
+}
+
+.sidebar-toggle-label {
+ font-size: 10px;
+ font-weight: 600;
+ letter-spacing: 0.05em;
+ writing-mode: vertical-rl;
+ text-orientation: mixed;
+ color: inherit;
+ opacity: 0.7;
+}
+
+/* ── Panel content ───────────────────────────────────────────────────── */
+.sidebar-content {
+ padding: 16px 14px 16px 16px;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ /* top space so content doesn't overlap Navbar if present */
+ padding-top: 60px;
+}
+
+/* ── Header row (title + Delete All) ───────────────────────────────── */
+.sidebar-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 14px;
+ padding-bottom: 10px;
+ border-bottom: 1px solid rgb(209 213 219);
+}
+
+@media (prefers-color-scheme: dark) {
+ .sidebar-header {
+ border-bottom-color: rgb(55 65 81);
+ }
+}
+
+.sidebar-header h3 {
+ margin: 0;
+ font-size: 14px;
+ font-weight: 700;
+ letter-spacing: 0.03em;
+ color: inherit;
+}
+
+/* ── "Delete All" button ────────────────────────────────────────────── */
+.delete-all {
+ background: transparent;
+ color: #ef4444;
+ /* red-500 */
+ border: 1px solid #ef4444;
+ padding: 3px 8px;
+ font-size: 11px;
+ font-weight: 600;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: background 0.15s, color 0.15s;
+ white-space: nowrap;
+}
+
+.delete-all:hover {
+ background-color: #ef4444;
+ color: #fff;
+}
+
+/* ── Session list ───────────────────────────────────────────────────── */
+.sidebar-list {
+ flex: 1;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.sidebar-loading,
+.sidebar-empty {
+ font-size: 13px;
+ color: #9ca3af;
+ /* gray-400 */
+ text-align: center;
+ margin-top: 24px;
+}
+
+/* ── Individual session card ─────────────────────────────────────────── */
+.sidebar-item {
+ background-color: rgb(249 250 251);
+ /* gray-50 */
+ border: 1px solid rgb(229 231 235);
+ /* gray-200 */
+ padding: 10px 10px 10px 12px;
+ border-radius: 8px;
+ cursor: pointer;
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 6px;
+ transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
+ position: relative;
+}
+
+@media (prefers-color-scheme: dark) {
+ .sidebar-item {
+ background-color: rgb(55 65 81 / 0.6);
+ /* gray-700 */
+ border-color: rgb(75 85 99);
+ }
+}
+
+.sidebar-item:hover {
+ background-color: rgb(238 242 255);
+ /* indigo-50 */
+ border-color: #a5b4fc;
+ /* indigo-300 */
+ box-shadow: 0 1px 4px rgb(99 102 241 / 0.12);
+}
+
+@media (prefers-color-scheme: dark) {
+ .sidebar-item:hover {
+ background-color: rgb(49 46 129 / 0.4);
+ border-color: #6366f1;
+ }
+}
+
+/* Active (current) session */
+.sidebar-item.active {
+ background-color: rgb(238 242 255);
+ /* indigo-50 */
+ border-color: #6366f1;
+ /* indigo-500 */
+ border-left: 3px solid #6366f1;
+}
+
+@media (prefers-color-scheme: dark) {
+ .sidebar-item.active {
+ background-color: rgb(49 46 129 / 0.5);
+ border-color: #818cf8;
+ /* indigo-400 */
+ border-left-color: #818cf8;
+ }
+}
+
+/* ── Card body ───────────────────────────────────────────────────────── */
+.sidebar-item-info {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ flex: 1;
+ min-width: 0;
+ /* allow text truncation */
+}
+
+.sidebar-item-title {
+ font-size: 13px;
+ font-weight: 600;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ color: inherit;
+ line-height: 1.3;
+}
+
+.sidebar-item-meta {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-wrap: wrap;
+}
+
+.sidebar-item-date {
+ font-size: 11px;
+ color: #9ca3af;
+ /* gray-400 */
+ white-space: nowrap;
+}
+
+.sidebar-item-provider {
+ font-size: 10px;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: #6366f1;
+ /* indigo-500 */
+ background: rgb(238 242 255);
+ border: 1px solid #a5b4fc;
+ padding: 1px 5px;
+ border-radius: 3px;
+}
+
+@media (prefers-color-scheme: dark) {
+ .sidebar-item-provider {
+ background: rgb(49 46 129 / 0.5);
+ border-color: #6366f1;
+ color: #a5b4fc;
+ }
+}
+
+/* ── Delete (×) button on each card ─────────────────────────────────── */
+.sidebar-item-delete {
+ flex-shrink: 0;
+ background: none;
+ border: none;
+ font-size: 17px;
+ line-height: 1;
+ color: #d1d5db;
+ /* gray-300 */
+ cursor: pointer;
+ padding: 0 2px;
+ transition: color 0.15s;
+ margin-top: 1px;
+}
+
+.sidebar-item-delete:hover {
+ color: #ef4444;
+ /* red-500 */
+}
+
+/* ── Scrollbar ──────────────────────────────────────────────────────── */
+.sidebar-list::-webkit-scrollbar {
+ width: 4px;
+}
+
+.sidebar-list::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.sidebar-list::-webkit-scrollbar-thumb {
+ background: #d1d5db;
+ border-radius: 2px;
+}
+
+.sidebar-list::-webkit-scrollbar-thumb:hover {
+ background: #9ca3af;
+}
+
+@media (prefers-color-scheme: dark) {
+ .sidebar-list::-webkit-scrollbar-thumb {
+ background: #4b5563;
+ }
+
+ .sidebar-list::-webkit-scrollbar-thumb:hover {
+ background: #6b7280;
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/shared/components/SessionSidebar.js b/frontend/src/shared/components/SessionSidebar.js
new file mode 100644
index 0000000..831e9ba
--- /dev/null
+++ b/frontend/src/shared/components/SessionSidebar.js
@@ -0,0 +1,198 @@
+import React, { useState, useEffect } from 'react';
+import {
+ getUserSessions,
+ deleteSession,
+ deleteAllSessions,
+ getSessionTokenStatus
+} from '../../services/apiService';
+import './SessionSidebar.css';
+
+const SessionSidebar = ({ featureName, currentSessionId, onSwitchSession, onNewSession, refreshTick }) => {
+ const [isOpen, setIsOpen] = useState(false);
+ const [sessions, setSessions] = useState([]);
+ const [tokenHoverData, setTokenHoverData] = useState({});
+ const [isLoading, setIsLoading] = useState(false);
+ const [confirmModal, setConfirmModal] = useState({ isOpen: false, title: '', message: '', onConfirm: null });
+
+ useEffect(() => {
+ if (isOpen) fetchSessions();
+ }, [isOpen, featureName, currentSessionId, refreshTick]);
+
+ const fetchSessions = async () => {
+ setIsLoading(true);
+ try {
+ const data = await getUserSessions(featureName);
+ setSessions(data || []);
+ } catch (err) {
+ console.error('Failed to fetch sessions:', err);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleMouseEnter = async (sessionId) => {
+ if (tokenHoverData[sessionId]) return;
+ try {
+ const data = await getSessionTokenStatus(sessionId);
+ setTokenHoverData(prev => ({ ...prev, [sessionId]: data }));
+ } catch (err) { /* silent */ }
+ };
+
+ const handleDelete = (e, sessionId) => {
+ e.stopPropagation();
+ setConfirmModal({
+ isOpen: true,
+ title: 'Delete Session',
+ message: 'Are you sure you want to delete this session? This action cannot be undone.',
+ onConfirm: async () => {
+ try {
+ await deleteSession(sessionId);
+ fetchSessions();
+ if (Number(currentSessionId) === sessionId) {
+ localStorage.removeItem(`sessionId_${featureName}`);
+ if (onNewSession) onNewSession();
+ }
+ } catch { alert('Failed to delete session.'); }
+ }
+ });
+ };
+
+ const handleDeleteAll = (e) => {
+ if (e) e.stopPropagation();
+ setConfirmModal({
+ isOpen: true,
+ title: 'Clear All History',
+ message: 'Are you sure you want to delete ALL history for this feature? This action is permanent.',
+ onConfirm: async () => {
+ try {
+ await deleteAllSessions(featureName);
+ fetchSessions();
+ if (onNewSession) onNewSession();
+ } catch { alert('Failed to delete all sessions.'); }
+ }
+ });
+ };
+
+ const formatDate = (iso) => {
+ const d = new Date(iso);
+ const now = new Date();
+ const diffDays = Math.floor((now - d) / 86400000);
+ if (diffDays === 0) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+ if (diffDays === 1) return 'Yesterday';
+ if (diffDays < 7) return d.toLocaleDateString([], { weekday: 'short' });
+ return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
+ };
+
+ const prettyFeatureName = featureName
+ .split('_')
+ .map(w => w.charAt(0).toUpperCase() + w.slice(1))
+ .join(' ');
+
+ return (
+
+ {/* ▶/◀ Tab handle */}
+
setIsOpen(!isOpen)}>
+ {isOpen ? '◀' : '▶'}
+ History
+
+
+ {isOpen && (
+
+
+
{prettyFeatureName} History
+
+
+
+
+ {isLoading ? (
+
Loading sessions…
+ ) : sessions.length === 0 ? (
+
No past sessions yet.
+ ) : (
+ sessions.map(s => {
+ const isActive = Number(currentSessionId) === s.id;
+ const td = tokenHoverData[s.id];
+ // Derive a display title: prefer session.title, fall back gracefully
+ const displayTitle = s.title &&
+ s.title !== 'New Chat Session'
+ ? s.title
+ : `Session #${s.id}`;
+
+ const llmInfo = s.provider_name ? `LLM: ${s.provider_name}` : 'LLM: Default';
+ const sttInfo = s.stt_provider_name ? `STT: ${s.stt_provider_name}` : 'STT: Default';
+ const ttsInfo = s.tts_provider_name ? `TTS: ${s.tts_provider_name}` : 'TTS: Default';
+
+ const usageInfo = td
+ ? `Context: ${td.token_count.toLocaleString()} / ${td.token_limit.toLocaleString()} tokens (${td.percentage}%)`
+ : 'Hover to load token usage stats';
+
+ const tooltip = `${displayTitle}\n---\n${llmInfo}\n${sttInfo}\n${ttsInfo}\n---\n${usageInfo}`;
+
+ return (
+
onSwitchSession(s.id)}
+ onMouseEnter={() => handleMouseEnter(s.id)}
+ title={tooltip}
+ >
+
+
{displayTitle}
+
+ {formatDate(s.created_at)}
+ {s.provider_name && (
+ {s.provider_name}
+ )}
+
+
+
+
+ );
+ })
+ )}
+
+
+ )}
+ {/* Custom Confirmation Modal */}
+ {confirmModal.isOpen && (
+
+
+
+
{confirmModal.title}
+
{confirmModal.message}
+
+
+
+
+
+
+ )}
+
+ );
+};
+
+export default SessionSidebar;
diff --git a/frontend/src/shared/components/index.js b/frontend/src/shared/components/index.js
new file mode 100644
index 0000000..552ce7f
--- /dev/null
+++ b/frontend/src/shared/components/index.js
@@ -0,0 +1,6 @@
+// Shared UI components used across multiple features.
+
+export { default as Navbar } from "./Navbar";
+export { default as SessionSidebar } from "./SessionSidebar";
+export { default as MultiNodeConsole } from "./MultiNodeConsole";
+export { default as FileSystemNavigator } from "./FileSystemNavigator";