"use client" import * as React from "react" import { useEffect, useRef, useState } from "react" import { Bot, ChevronRight, ChevronsDownUp, ChevronsUpDown, Copy, FilePlus, FolderPlus, HelpCircle, Mic, Network, Pencil, Plug, LoaderIcon, Settings, Square, Trash2, } from "lucide-react" import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible" import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarMenuSub, SidebarRail, useSidebar, } from "@/components/ui/sidebar" import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover" import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip" import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, } from "@/components/ui/context-menu" import { Input } from "@/components/ui/input" import { cn } from "@/lib/utils" import { type ActiveSection, useSidebarSection } from "@/contexts/sidebar-context" import { ConnectorsPopover } from "@/components/connectors-popover" import { HelpPopover } from "@/components/help-popover" import { SettingsDialog } from "@/components/settings-dialog" import { toast } from "@/lib/toast" import { ServiceEvent } from "@x/shared/src/service-events.js" import z from "zod" interface TreeNode { path: string name: string kind: "file" | "dir" children?: TreeNode[] loaded?: boolean } type KnowledgeActions = { createNote: (parentPath?: string) => void createFolder: (parentPath?: string) => void openGraph: () => void expandAll: () => void collapseAll: () => void rename: (path: string, newName: string, isDir: boolean) => Promise remove: (path: string) => Promise copyPath: (path: string) => void } type RunListItem = { id: string title?: string createdAt: string agentId: string } type BackgroundTaskItem = { name: string description?: string schedule: { type: "cron" | "window" | "once" expression?: string cron?: string startTime?: string endTime?: string runAt?: string } enabled: boolean status?: "scheduled" | "running" | "finished" | "failed" | "triggered" nextRunAt?: string | null lastRunAt?: string | null } type ServiceEventType = z.infer const MAX_SYNC_EVENTS = 1000 const RUN_STALE_MS = 2 * 60 * 60 * 1000 const SERVICE_LABELS: Record = { gmail: "Syncing Gmail", calendar: "Syncing Calendar", fireflies: "Syncing Fireflies", granola: "Syncing Granola", graph: "Updating knowledge", voice_memo: "Processing voice memo", } type TasksActions = { onNewChat: () => void onSelectRun: (runId: string) => void onDeleteRun: (runId: string) => void onSelectBackgroundTask?: (taskName: string) => void } type SidebarContentPanelProps = { tree: TreeNode[] selectedPath: string | null expandedPaths: Set onSelectFile: (path: string, kind: "file" | "dir") => void knowledgeActions: KnowledgeActions onVoiceNoteCreated?: (path: string) => void runs?: RunListItem[] currentRunId?: string | null processingRunIds?: Set tasksActions?: TasksActions backgroundTasks?: BackgroundTaskItem[] selectedBackgroundTask?: string | null } & React.ComponentProps const sectionTabs: { id: ActiveSection; label: string }[] = [ { id: "tasks", label: "Chat" }, { id: "knowledge", label: "Knowledge" }, ] function formatEventTime(ts: string): string { const date = new Date(ts) if (Number.isNaN(date.getTime())) return "" return date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }) } function formatRunTime(ts: string): string { const date = new Date(ts) if (Number.isNaN(date.getTime())) return "" const now = Date.now() const diffMs = Math.max(0, now - date.getTime()) const diffMinutes = Math.floor(diffMs / (1000 * 60)) const diffHours = Math.floor(diffMinutes / 60) const diffDays = Math.floor(diffHours / 24) const diffWeeks = Math.floor(diffDays / 7) const diffMonths = Math.floor(diffDays / 30) if (diffMinutes < 1) return "just now" if (diffMinutes < 60) return `${diffMinutes} m` if (diffHours < 24) return `${diffHours} h` if (diffDays < 7) return `${diffDays} d` if (diffWeeks < 4) return `${diffWeeks} w` return `${Math.max(1, diffMonths)} m` } function SyncStatusBar() { const { state, isMobile } = useSidebar() const [activeServices, setActiveServices] = useState>(new Map()) const [popoverOpen, setPopoverOpen] = useState(false) const [logEvents, setLogEvents] = useState([]) const [logLoading, setLogLoading] = useState(false) const runTimeoutsRef = useRef>>(new Map()) // Track active runs from real-time events useEffect(() => { const cleanup = window.ipc.on('services:events', (event) => { const nextEvent = event as ServiceEventType if (nextEvent.type === 'run_start') { setActiveServices((prev) => { const next = new Map(prev) next.set(nextEvent.runId, nextEvent.service) return next }) const existingTimeout = runTimeoutsRef.current.get(nextEvent.runId) if (existingTimeout) clearTimeout(existingTimeout) const timeout = setTimeout(() => { setActiveServices((prev) => { if (!prev.has(nextEvent.runId)) return prev const next = new Map(prev) next.delete(nextEvent.runId) return next }) runTimeoutsRef.current.delete(nextEvent.runId) }, RUN_STALE_MS) runTimeoutsRef.current.set(nextEvent.runId, timeout) } else if (nextEvent.type === 'run_complete') { setActiveServices((prev) => { const next = new Map(prev) next.delete(nextEvent.runId) return next }) const existingTimeout = runTimeoutsRef.current.get(nextEvent.runId) if (existingTimeout) { clearTimeout(existingTimeout) runTimeoutsRef.current.delete(nextEvent.runId) } } }) return cleanup }, []) useEffect(() => { return () => { runTimeoutsRef.current.forEach((timeout) => clearTimeout(timeout)) runTimeoutsRef.current.clear() } }, []) // Load logs from JSONL file when popover opens useEffect(() => { if (!popoverOpen) return let cancelled = false async function loadLogs() { setLogLoading(true) try { const result = await window.ipc.invoke('workspace:readFile', { path: 'logs/services.jsonl', encoding: 'utf8', }) if (cancelled) return const lines = result.data.trim().split('\n').filter(Boolean) const parsed: ServiceEventType[] = [] for (const line of lines) { try { parsed.push(JSON.parse(line)) } catch { // skip malformed lines } } // Newest first, limit to 1000 setLogEvents(parsed.reverse().slice(0, MAX_SYNC_EVENTS)) } catch { if (!cancelled) setLogEvents([]) } finally { if (!cancelled) setLogLoading(false) } } loadLogs() return () => { cancelled = true } }, [popoverOpen]) const isSyncing = activeServices.size > 0 const isCollapsed = state === "collapsed" // Build status label from active services const activeServiceNames = [...new Set(activeServices.values())] const statusLabel = isSyncing ? activeServiceNames.map((s) => SERVICE_LABELS[s] || s).join(", ") : "All caught up" return ( <> {!isMobile && isCollapsed && isSyncing && (
)}

Sync Activity

{isSyncing ? statusLabel : "All services up to date"}

{logLoading ? (
) : logEvents.length === 0 ? (
No recent activity.
) : (
{logEvents.map((event, idx) => (
{formatEventTime(event.ts)} {SERVICE_LABELS[event.service]?.split(" ").slice(-1)[0] || event.service} {event.message}
))}
)}
) } export function SidebarContentPanel({ tree, selectedPath, expandedPaths, onSelectFile, knowledgeActions, onVoiceNoteCreated, runs = [], currentRunId, processingRunIds, tasksActions, backgroundTasks = [], selectedBackgroundTask, ...props }: SidebarContentPanelProps) { const { activeSection, setActiveSection } = useSidebarSection() return ( {/* Top spacer to clear the traffic lights + fixed toggle row */}
{/* Tab switcher - centered below the traffic lights row */}
{sectionTabs.map((tab) => ( ))}
{activeSection === "knowledge" && ( )} {activeSection === "tasks" && ( )} {/* Bottom actions */}
) } async function transcribeWithDeepgram(audioBlob: Blob): Promise { try { const configResult = await window.ipc.invoke('workspace:readFile', { path: 'config/deepgram.json', encoding: 'utf8', }) const { apiKey } = JSON.parse(configResult.data) as { apiKey: string } if (!apiKey) throw new Error('No apiKey in deepgram.json') const response = await fetch( 'https://api.deepgram.com/v1/listen?model=nova-2&smart_format=true', { method: 'POST', headers: { Authorization: `Token ${apiKey}`, 'Content-Type': audioBlob.type, }, body: audioBlob, }, ) if (!response.ok) throw new Error(`Deepgram API error: ${response.status}`) const result = await response.json() return result.results?.channels?.[0]?.alternatives?.[0]?.transcript ?? null } catch (err) { console.error('Deepgram transcription failed:', err) return null } } // Voice Note Recording Button function VoiceNoteButton({ onNoteCreated }: { onNoteCreated?: (path: string) => void }) { const [isRecording, setIsRecording] = React.useState(false) const [hasDeepgramKey, setHasDeepgramKey] = React.useState(false) const mediaRecorderRef = React.useRef(null) const chunksRef = React.useRef([]) const notePathRef = React.useRef(null) const timestampRef = React.useRef(null) const relativePathRef = React.useRef(null) React.useEffect(() => { window.ipc.invoke('workspace:readFile', { path: 'config/deepgram.json', encoding: 'utf8', }).then((result: { data: string }) => { const { apiKey } = JSON.parse(result.data) as { apiKey: string } setHasDeepgramKey(!!apiKey) }).catch(() => { setHasDeepgramKey(false) }) }, []) const startRecording = async () => { try { // Generate timestamp and paths immediately const now = new Date() const timestamp = now.toISOString().replace(/[:.]/g, '-') const dateStr = now.toISOString().split('T')[0] // YYYY-MM-DD const noteName = `voice-memo-${timestamp}` const notePath = `knowledge/Voice Memos/${dateStr}/${noteName}.md` timestampRef.current = timestamp notePathRef.current = notePath // Relative path for linking (from knowledge/ root, without .md extension) const relativePath = `Voice Memos/${dateStr}/${noteName}` relativePathRef.current = relativePath // Create the note immediately with a "Recording..." placeholder await window.ipc.invoke('workspace:mkdir', { path: `knowledge/Voice Memos/${dateStr}`, recursive: true, }) const initialContent = `# Voice Memo **Type:** voice memo **Recorded:** ${now.toLocaleString()} **Path:** ${relativePath} ## Transcript *Recording in progress...* ` await window.ipc.invoke('workspace:writeFile', { path: notePath, data: initialContent, opts: { encoding: 'utf8' }, }) // Select the note so the user can see it onNoteCreated?.(notePath) // Start actual recording const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) const mimeType = MediaRecorder.isTypeSupported('audio/mp4') ? 'audio/mp4' : 'audio/webm' const recorder = new MediaRecorder(stream, { mimeType }) chunksRef.current = [] recorder.ondataavailable = (e) => { if (e.data.size > 0) chunksRef.current.push(e.data) } recorder.onstop = async () => { stream.getTracks().forEach((t) => t.stop()) const blob = new Blob(chunksRef.current, { type: mimeType }) const ext = mimeType === 'audio/mp4' ? 'm4a' : 'webm' const audioFilename = `voice-memo-${timestampRef.current}.${ext}` // Save audio file to voice_memos folder (for backup/reference) try { await window.ipc.invoke('workspace:mkdir', { path: 'voice_memos', recursive: true, }) const arrayBuffer = await blob.arrayBuffer() const base64 = btoa( new Uint8Array(arrayBuffer).reduce( (data, byte) => data + String.fromCharCode(byte), '', ), ) await window.ipc.invoke('workspace:writeFile', { path: `voice_memos/${audioFilename}`, data: base64, opts: { encoding: 'base64' }, }) } catch { console.error('Failed to save audio file') } // Update note to show transcribing status const currentNotePath = notePathRef.current const currentRelativePath = relativePathRef.current if (currentNotePath && currentRelativePath) { const transcribingContent = `# Voice Memo **Type:** voice memo **Recorded:** ${new Date().toLocaleString()} **Path:** ${currentRelativePath} ## Transcript *Transcribing...* ` await window.ipc.invoke('workspace:writeFile', { path: currentNotePath, data: transcribingContent, opts: { encoding: 'utf8' }, }) } // Transcribe and update the note with the transcript const transcript = await transcribeWithDeepgram(blob) if (currentNotePath && currentRelativePath) { const finalContent = transcript ? `# Voice Memo **Type:** voice memo **Recorded:** ${new Date().toLocaleString()} **Path:** ${currentRelativePath} ## Transcript ${transcript} ` : `# Voice Memo **Type:** voice memo **Recorded:** ${new Date().toLocaleString()} **Path:** ${currentRelativePath} ## Transcript *Transcription failed. Please try again.* ` await window.ipc.invoke('workspace:writeFile', { path: currentNotePath, data: finalContent, opts: { encoding: 'utf8' }, }) // Re-select to trigger refresh onNoteCreated?.(currentNotePath) if (transcript) { toast('Voice note transcribed', 'success') } else { toast('Transcription failed', 'error') } } } recorder.start() mediaRecorderRef.current = recorder setIsRecording(true) toast('Recording started', 'success') } catch { toast('Could not access microphone', 'error') } } const stopRecording = () => { if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') { mediaRecorderRef.current.stop() } mediaRecorderRef.current = null setIsRecording(false) } if (!hasDeepgramKey) return null return ( {isRecording ? 'Stop Recording' : 'New Voice Note'} ) } // Knowledge Section function KnowledgeSection({ tree, selectedPath, expandedPaths, onSelectFile, actions, onVoiceNoteCreated, }: { tree: TreeNode[] selectedPath: string | null expandedPaths: Set onSelectFile: (path: string, kind: "file" | "dir") => void actions: KnowledgeActions onVoiceNoteCreated?: (path: string) => void }) { const isExpanded = expandedPaths.size > 0 const quickActions = [ { icon: FilePlus, label: "New Note", action: () => actions.createNote() }, { icon: FolderPlus, label: "New Folder", action: () => actions.createFolder() }, { icon: Network, label: "Graph View", action: () => actions.openGraph() }, ] return (
{quickActions.map((action) => ( {action.label} ))} {isExpanded ? "Collapse All" : "Expand All"}
{tree.map((item, index) => ( ))}
actions.createNote()}> New Note actions.createFolder()}> New Folder
) } // Tree component for file browser function Tree({ item, selectedPath, expandedPaths, onSelect, actions, }: { item: TreeNode selectedPath: string | null expandedPaths: Set onSelect: (path: string, kind: "file" | "dir") => void actions: KnowledgeActions }) { const isDir = item.kind === 'dir' const isExpanded = expandedPaths.has(item.path) const isSelected = selectedPath === item.path const [isRenaming, setIsRenaming] = useState(false) const isSubmittingRef = React.useRef(false) // For files, strip .md extension for editing const baseName = !isDir && item.name.endsWith('.md') ? item.name.slice(0, -3) : item.name const [newName, setNewName] = useState(baseName) // Sync newName when baseName changes (e.g., after external rename) React.useEffect(() => { setNewName(baseName) }, [baseName]) const handleRename = async () => { // Prevent double submission if (isSubmittingRef.current) return isSubmittingRef.current = true const trimmedName = newName.trim() if (trimmedName && trimmedName !== baseName) { try { await actions.rename(item.path, trimmedName, isDir) toast('Renamed successfully', 'success') } catch (err) { toast('Failed to rename', 'error') } } setIsRenaming(false) // Reset after a small delay to prevent blur from re-triggering setTimeout(() => { isSubmittingRef.current = false }, 100) } const handleDelete = async () => { try { await actions.remove(item.path) toast('Moved to trash', 'success') } catch (err) { toast('Failed to delete', 'error') } } const handleCopyPath = () => { actions.copyPath(item.path) toast('Path copied', 'success') } const cancelRename = () => { isSubmittingRef.current = true // Prevent blur from triggering rename setIsRenaming(false) setNewName(baseName) // Reset to original name setTimeout(() => { isSubmittingRef.current = false }, 100) } const contextMenuContent = ( {isDir && ( <> actions.createNote(item.path)}> New Note actions.createFolder(item.path)}> New Folder )} Copy Path { setNewName(baseName); isSubmittingRef.current = false; setIsRenaming(true) }}> Rename Delete ) // Inline rename input if (isRenaming) { return (
setNewName(e.target.value)} onKeyDown={async (e) => { e.stopPropagation() if (e.key === 'Enter') { e.preventDefault() await handleRename() } else if (e.key === 'Escape') { e.preventDefault() cancelRename() } }} onBlur={() => { // Only trigger rename if not already submitting if (!isSubmittingRef.current) { handleRename() } }} className="h-6 text-sm flex-1" autoFocus />
) } if (!isDir) { return ( onSelect(item.path, item.kind)} > {item.name} {contextMenuContent} ) } return ( onSelect(item.path, item.kind)} className="group/collapsible [&[data-state=open]>button>svg:first-child]:rotate-90" > {item.name} {(item.children ?? []).map((subItem, index) => ( ))} {contextMenuContent} ) } // Get status indicator color function getStatusColor(status?: string, enabled?: boolean): string { // Disabled agents always show gray if (enabled === false) { return "bg-gray-400" } switch (status) { case "running": return "bg-blue-500" case "finished": return "bg-green-500" case "failed": return "bg-red-500" case "triggered": return "bg-gray-400" case "scheduled": default: return "bg-yellow-500" } } // Tasks Section function TasksSection({ runs, currentRunId, processingRunIds, actions, backgroundTasks = [], selectedBackgroundTask, }: { runs: RunListItem[] currentRunId?: string | null processingRunIds?: Set actions?: TasksActions backgroundTasks?: BackgroundTaskItem[] selectedBackgroundTask?: string | null }) { return ( {/* Background Tasks Section */} {backgroundTasks.length > 0 && ( <>
Background Tasks
{backgroundTasks.map((task) => ( actions?.onSelectBackgroundTask?.(task.name)} className="gap-2" >
{task.name}
))}
)} {runs.length > 0 && ( <>
Chat history
{runs.map((run) => ( actions?.onSelectRun(run.id)} >
{processingRunIds?.has(run.id) ? ( ) : null} {run.title || '(Untitled chat)'} {run.createdAt ? ( {formatRunTime(run.createdAt)} ) : null} {!processingRunIds?.has(run.id) && ( )}
))}
)}
) }