diff --git a/ui/src/components/BreadcrumbBar.tsx b/ui/src/components/BreadcrumbBar.tsx deleted file mode 100644 index a4d1462a..00000000 --- a/ui/src/components/BreadcrumbBar.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { Link } from "@/lib/router"; -import { Menu } from "lucide-react"; -import { useBreadcrumbs } from "../context/BreadcrumbContext"; -import { useSidebar } from "../context/SidebarContext"; -import { useCompany } from "../context/CompanyContext"; -import { Button } from "@/components/ui/button"; -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbLink, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator, -} from "@/components/ui/breadcrumb"; -import { Fragment, useMemo } from "react"; -import { PluginSlotOutlet, usePluginSlots } from "@/plugins/slots"; -import { PluginLauncherOutlet, usePluginLaunchers } from "@/plugins/launchers"; - -type GlobalToolbarContext = { companyId: string | null; companyPrefix: string | null }; - -function GlobalToolbarPlugins({ context }: { context: GlobalToolbarContext }) { - const { slots } = usePluginSlots({ slotTypes: ["globalToolbarButton"], companyId: context.companyId }); - const { launchers } = usePluginLaunchers({ placementZones: ["globalToolbarButton"], companyId: context.companyId, enabled: !!context.companyId }); - if (slots.length === 0 && launchers.length === 0) return null; - return ( -
- - -
- ); -} - -export function BreadcrumbBar() { - const { breadcrumbs } = useBreadcrumbs(); - const { toggleSidebar, isMobile } = useSidebar(); - const { selectedCompanyId, selectedCompany } = useCompany(); - - const globalToolbarSlotContext = useMemo( - () => ({ - companyId: selectedCompanyId ?? null, - companyPrefix: selectedCompany?.issuePrefix ?? null, - }), - [selectedCompanyId, selectedCompany?.issuePrefix], - ); - - const globalToolbarSlots = ; - - if (breadcrumbs.length === 0) { - return ( -
- {globalToolbarSlots} -
- ); - } - - const menuButton = isMobile && ( - - ); - - // Single breadcrumb = page title (uppercase) - if (breadcrumbs.length === 1) { - return ( -
- {menuButton} -
-

- {breadcrumbs[0].label} -

-
- {globalToolbarSlots} -
- ); - } - - // Multiple breadcrumbs = breadcrumb trail - return ( -
- {menuButton} -
- - - {breadcrumbs.map((crumb, i) => { - const isLast = i === breadcrumbs.length - 1; - return ( - - {i > 0 && } - - {isLast || !crumb.href ? ( - {crumb.label} - ) : ( - - {crumb.label} - - )} - - - ); - })} - - -
- {globalToolbarSlots} -
- ); -} diff --git a/ui/src/components/ChatPanel.tsx b/ui/src/components/ChatPanel.tsx deleted file mode 100644 index 03917230..00000000 --- a/ui/src/components/ChatPanel.tsx +++ /dev/null @@ -1,446 +0,0 @@ -import { useState, useMemo, useEffect, useCallback } from "react"; -import { Bookmark, Download, FileJson, X } from "lucide-react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useChatPanel } from "../context/ChatPanelContext"; -import { useCompany } from "../context/CompanyContext"; -import { useToast } from "../context/ToastContext"; -import { ChatInput } from "./ChatInput"; -import { ChatConversationList } from "./ChatConversationList"; -import { ChatMessageList } from "./ChatMessageList"; -import { ChatAgentSelector } from "./ChatAgentSelector"; -import { ChatStopButton } from "./ChatStopButton"; -import { ChatSearchDialog } from "./ChatSearchDialog"; -import { ChatBranchSelector } from "./ChatBranchSelector"; -import { ChatBookmarkList } from "./ChatBookmarkList"; -import { MobileChatView } from "./MobileChatView"; -import { InstallPromptBanner } from "./InstallPromptBanner"; -import { OfflineBanner } from "./OfflineBanner"; -import { NotificationPermissionPrompt } from "./NotificationPermissionPrompt"; -import { Button } from "@/components/ui/button"; -import { chatApi } from "../api/chat"; -import { agentsApi } from "../api/agents"; -import { useChatMessages } from "../hooks/useChatMessages"; -import { useStreamingChat } from "../hooks/useStreamingChat"; -import { useBrainstormerDefault } from "../hooks/useBrainstormerDefault"; -import { useChatBookmarks, useToggleBookmark } from "../hooks/useChatBookmarks"; -import { useChatFileUpload } from "../hooks/useChatFileUpload"; -import { useMediaQuery } from "../hooks/useMediaQuery"; -import { useOfflineQueue } from "../hooks/useOfflineQueue"; -import { useOnlineStatus } from "../hooks/useOnlineStatus"; -import { resolveAgentFromContent } from "../lib/slash-commands"; -import { useVoiceMode } from "../hooks/useVoiceMode"; -import type { AgentRole } from "@paperclipai/shared"; - -export function ChatPanel() { - const isDesktop = useMediaQuery("(min-width: 768px)"); - const { chatOpen, setChatOpen, activeConversationId, setActiveConversationId, setScrollToMessageId } = useChatPanel(); - const { selectedCompanyId } = useCompany(); - const queryClient = useQueryClient(); - const { pushToast } = useToast(); - const [isSending, setIsSending] = useState(false); - const [activeAgentId, setActiveAgentId] = useState(null); - const [searchOpen, setSearchOpen] = useState(false); - const [bookmarksOpen, setBookmarksOpen] = useState(false); - - const { mode: voiceMode } = useVoiceMode(); - const { messages } = useChatMessages(activeConversationId); - const { streamingContent, isStreaming, startStream, stop } = useStreamingChat(activeConversationId); - const { pendingFiles, addFile, removeFile, clearCompleted, completedFileIds } = useChatFileUpload(activeConversationId); - const { enqueue, queuedCount } = useOfflineQueue(); - const isOnline = useOnlineStatus(); - - const brainstormerDefaultId = useBrainstormerDefault(); - - // Count assistant messages for the notification engagement gate - const agentResponseCount = useMemo( - () => messages.filter((m) => m.role === "assistant").length, - [messages], - ); - - // Listen for nexus:open-chat-search custom event (dispatched by CommandPalette) - useEffect(() => { - const handler = () => setSearchOpen(true); - window.addEventListener("nexus:open-chat-search", handler); - return () => window.removeEventListener("nexus:open-chat-search", handler); - }, []); - - // Auto-select brainstormer (general agent) for new conversations - useEffect(() => { - if (activeAgentId === null && brainstormerDefaultId !== null && !activeConversationId) { - setActiveAgentId(brainstormerDefaultId); - } - }, [activeAgentId, brainstormerDefaultId, activeConversationId]); - - // Handoff callback: call handoff API and invalidate messages cache - const handleHandoff = useCallback(async (spec: { what: string; why: string; constraints: string; success: string }) => { - if (!activeConversationId) return; - try { - await chatApi.handoffSpec(activeConversationId, spec, "pm"); - queryClient.invalidateQueries({ queryKey: ["chat", "messages", activeConversationId] }); - } catch { - pushToast({ title: "Could not send to PM. Try again.", tone: "error" }); - } - }, [activeConversationId, queryClient, pushToast]); - - // Load agents for routing and identity - const { data: agents = [], isLoading: agentsLoading } = useQuery({ - queryKey: ["agents", selectedCompanyId], - queryFn: () => agentsApi.list(selectedCompanyId!), - enabled: !!selectedCompanyId, - }); - - // Build agent map for message identity bars - const agentMap = useMemo(() => { - const map = new Map(); - for (const a of agents) { - map.set(a.id, { name: a.name, icon: a.icon, role: (a.role as AgentRole) ?? null }); - } - return map; - }, [agents]); - - // Resolve streaming agent identity - const streamingAgent = activeAgentId ? agentMap.get(activeAgentId) : undefined; - - // Fetch branches for active conversation - const { data: branchesData } = useQuery({ - queryKey: ["chat", "branches", activeConversationId], - queryFn: () => chatApi.listBranches(activeConversationId!), - enabled: !!activeConversationId, - }); - const branches = branchesData?.items ?? []; - - // Bookmark state for active conversation - const { data: bookmarkData } = useChatBookmarks(selectedCompanyId, activeConversationId ?? undefined); - const { toggleBookmark } = useToggleBookmark(); - const bookmarkedMessageIds = useMemo(() => { - const ids = new Set(); - for (const b of bookmarkData?.items ?? []) { - ids.add(b.message.id); - } - return ids; - }, [bookmarkData]); - - // Search navigation handler - const handleSearchNavigate = useCallback((conversationId: string, messageId: string) => { - setActiveConversationId(conversationId); - setScrollToMessageId(messageId); - setSearchOpen(false); - }, [setActiveConversationId, setScrollToMessageId]); - - // Bookmark navigation handler - const handleBookmarkNavigate = useCallback((conversationId: string, messageId: string) => { - setActiveConversationId(conversationId); - setScrollToMessageId(messageId); - setBookmarksOpen(false); - }, [setActiveConversationId, setScrollToMessageId]); - - // Bookmark toggle handler - const handleBookmark = useCallback((messageId: string) => { - if (!activeConversationId) return; - toggleBookmark({ conversationId: activeConversationId, messageId }); - }, [activeConversationId, toggleBookmark]); - - // Export handler - const handleExport = useCallback((format: "markdown" | "json") => { - if (!activeConversationId) return; - window.location.href = chatApi.exportConversation(activeConversationId, format); - }, [activeConversationId]); - - const handleSend = async (content: string) => { - if (!selectedCompanyId) return; - - // If offline, enqueue the message and show a toast - if (!isOnline) { - if (activeConversationId) { - await enqueue(activeConversationId, content); - pushToast({ title: "Message queued — will send when you're back online", tone: "info" }); - } - return; - } - - // Resolve agent from slash command or @mention - const resolvedAgentId = resolveAgentFromContent(content, agents, activeAgentId); - - // Capture file IDs before clearing - const fileIdsToAttach = [...completedFileIds]; - - setIsSending(true); - try { - if (!activeConversationId) { - // Path 1: No active conversation -- create one, post user message, then stream - const newConvo = await chatApi.createConversation(selectedCompanyId, { - agentId: resolvedAgentId ?? undefined, - }); - setActiveConversationId(newConvo.id); - const message = await chatApi.postMessage(newConvo.id, { role: "user", content }); - // Attach any uploaded files to the message - if (fileIdsToAttach.length > 0) { - await chatApi.attachFilesToMessage(fileIdsToAttach, message.id); - clearCompleted(); - queryClient.invalidateQueries({ queryKey: ["chat", "messages", newConvo.id] }); - } - queryClient.invalidateQueries({ queryKey: ["chat"] }); - startStream(content, resolvedAgentId ?? undefined, voiceMode); - } else { - // Path 2: Active conversation -- post user message then stream - const message = await chatApi.postMessage(activeConversationId, { role: "user", content }); - // Attach any uploaded files to the message - if (fileIdsToAttach.length > 0) { - await chatApi.attachFilesToMessage(fileIdsToAttach, message.id); - clearCompleted(); - } - queryClient.invalidateQueries({ queryKey: ["chat", "messages", activeConversationId] }); - startStream(content, resolvedAgentId ?? undefined, voiceMode); - } - } finally { - setIsSending(false); - } - }; - - // Edit handler: if editing a message that has subsequent responses, branch first - const handleEdit = async (messageId: string, newContent: string) => { - if (!activeConversationId) return; - - // Check if there are any messages after the edited message - const editedIdx = messages.findIndex((m) => m.id === messageId); - const hasSubsequentMessages = editedIdx >= 0 && editedIdx < messages.length - 1; - - if (hasSubsequentMessages) { - // Branch the conversation from this message first (CHAT-14) - try { - const newConv = await chatApi.branchConversation(activeConversationId, messageId); - setActiveConversationId(newConv.id); - // Invalidate branches list so branch selector updates - queryClient.invalidateQueries({ queryKey: ["chat", "branches"] }); - queryClient.invalidateQueries({ queryKey: ["chat"] }); - // Edit the message on the new branch - await chatApi.editMessage(newConv.id, messageId, newContent); - await chatApi.truncateMessagesAfter(newConv.id, messageId); - queryClient.invalidateQueries({ queryKey: ["chat", "messages", newConv.id] }); - queryClient.invalidateQueries({ queryKey: ["chat", "search"] }); - startStream(newContent, activeAgentId ?? undefined, voiceMode); - } catch { - pushToast({ title: "Could not create branch. Try again.", tone: "error" }); - } - } else { - // No subsequent messages — edit in place - await chatApi.editMessage(activeConversationId, messageId, newContent); - await chatApi.truncateMessagesAfter(activeConversationId, messageId); - queryClient.invalidateQueries({ queryKey: ["chat", "messages", activeConversationId] }); - queryClient.invalidateQueries({ queryKey: ["chat", "search"] }); - startStream(newContent, activeAgentId ?? undefined, voiceMode); - } - }; - - // Retry handler: find the last user message before the assistant message, - // delete the assistant message and everything after it, then re-stream - // with the actual prior user message content (not hardcoded text). - const handleRetry = async (assistantMessageId: string) => { - if (!activeConversationId || !messages) return; - - // Find the assistant message index in the messages array - const assistantIdx = messages.findIndex((m) => m.id === assistantMessageId); - if (assistantIdx < 0) return; - - // Find the last user message before this assistant message - let lastUserContent = ""; - for (let i = assistantIdx - 1; i >= 0; i--) { - if (messages[i]!.role === "user") { - lastUserContent = messages[i]!.content; - break; - } - } - if (!lastUserContent) return; // No prior user message found; nothing to retry - - // Truncate messages after the user message (this deletes the assistant msg + everything after) - // First, find the user message to truncate after - let userMessageId = ""; - for (let i = assistantIdx - 1; i >= 0; i--) { - if (messages[i]!.role === "user") { - userMessageId = messages[i]!.id; - break; - } - } - if (!userMessageId) return; - - // Delete everything after the user message (includes the assistant message itself) - await chatApi.truncateMessagesAfter(activeConversationId, userMessageId); - queryClient.invalidateQueries({ queryKey: ["chat", "messages", activeConversationId] }); - queryClient.invalidateQueries({ queryKey: ["chat", "search"] }); - - // Re-stream using the actual user message content - startStream(lastUserContent, activeAgentId ?? undefined, voiceMode); - }; - - // On mobile, render the full-screen MobileChatView instead of the desktop slide-in panel - if (!isDesktop) { - return ; - } - - return ( - - ); -} diff --git a/ui/src/components/MobileChatView.tsx b/ui/src/components/MobileChatView.tsx deleted file mode 100644 index 0520ef57..00000000 --- a/ui/src/components/MobileChatView.tsx +++ /dev/null @@ -1,307 +0,0 @@ -import { useCallback, useMemo, useState } from "react"; -import { ChevronLeft } from "lucide-react"; -import { useQueryClient } from "@tanstack/react-query"; -import { useChatPanel } from "../context/ChatPanelContext"; -import { useCompany } from "../context/CompanyContext"; -import { useToast } from "../context/ToastContext"; -import { useChatMessages } from "../hooks/useChatMessages"; -import { useStreamingChat } from "../hooks/useStreamingChat"; -import { useBrainstormerDefault } from "../hooks/useBrainstormerDefault"; -import { useChatBookmarks, useToggleBookmark } from "../hooks/useChatBookmarks"; -import { useChatFileUpload } from "../hooks/useChatFileUpload"; -import { useOfflineQueue } from "../hooks/useOfflineQueue"; -import { useOnlineStatus } from "../hooks/useOnlineStatus"; -import { useChatConversations } from "../hooks/useChatConversations"; -import { useQuery } from "@tanstack/react-query"; -import { chatApi } from "../api/chat"; -import { agentsApi } from "../api/agents"; -import { resolveAgentFromContent } from "../lib/slash-commands"; -import { ChatMessageList } from "./ChatMessageList"; -import { ChatInput } from "./ChatInput"; -import { ChatAgentSelector } from "./ChatAgentSelector"; -import { ChatConversationList } from "./ChatConversationList"; -import { ChatStopButton } from "./ChatStopButton"; -import { PullToRefresh } from "./PullToRefresh"; -import { OfflineBanner } from "./OfflineBanner"; -import { InstallPromptBanner } from "./InstallPromptBanner"; -import { NotificationPermissionPrompt } from "./NotificationPermissionPrompt"; -import { Button } from "@/components/ui/button"; -import type { AgentRole } from "@paperclipai/shared"; - -/** - * Full-screen mobile chat layout. - * - When no conversation is active: shows conversation list with pull-to-refresh - * - When a conversation is active: shows header + message list + sticky input - * - * Does NOT render any MobileNavBar — relies on the global MobileBottomNav in Layout.tsx. - */ -export function MobileChatView() { - const { activeConversationId, setActiveConversationId } = useChatPanel(); - const { selectedCompanyId } = useCompany(); - const queryClient = useQueryClient(); - const { pushToast } = useToast(); - const [isSending, setIsSending] = useState(false); - const [activeAgentId, setActiveAgentId] = useState(null); - - const { messages } = useChatMessages(activeConversationId); - const { streamingContent, isStreaming, startStream, stop } = useStreamingChat(activeConversationId); - const { pendingFiles, addFile, removeFile, clearCompleted, completedFileIds } = useChatFileUpload(activeConversationId); - const { enqueue, queuedCount } = useOfflineQueue(); - const isOnline = useOnlineStatus(); - const brainstormerDefaultId = useBrainstormerDefault(); - - // useChatConversations for refetch (pull-to-refresh) - const { refetch } = useChatConversations(selectedCompanyId); - - // Auto-select brainstormer for new conversations - const effectiveBrainstormerId = - activeAgentId === null && brainstormerDefaultId !== null && !activeConversationId - ? brainstormerDefaultId - : activeAgentId; - - // Load agents for routing and identity - const { data: agents = [], isLoading: agentsLoading } = useQuery({ - queryKey: ["agents", selectedCompanyId], - queryFn: () => agentsApi.list(selectedCompanyId!), - enabled: !!selectedCompanyId, - }); - - // Agent map for identity bars - const agentMap = useMemo(() => { - const map = new Map(); - for (const a of agents) { - map.set(a.id, { name: a.name, icon: a.icon, role: (a.role as AgentRole) ?? null }); - } - return map; - }, [agents]); - - const streamingAgent = effectiveBrainstormerId - ? agentMap.get(effectiveBrainstormerId) - : undefined; - - // Bookmark state - const { data: bookmarkData } = useChatBookmarks(selectedCompanyId, activeConversationId ?? undefined); - const { toggleBookmark } = useToggleBookmark(); - const bookmarkedMessageIds = useMemo(() => { - const ids = new Set(); - for (const b of bookmarkData?.items ?? []) ids.add(b.message.id); - return ids; - }, [bookmarkData]); - - // Get active conversation title for header - const { data: conversationsData } = useQuery({ - queryKey: ["chat", "conversations", selectedCompanyId, ""], - queryFn: () => chatApi.listConversations(selectedCompanyId!, {}), - enabled: !!selectedCompanyId && !!activeConversationId, - }); - const activeConversation = conversationsData?.items.find((c) => c.id === activeConversationId); - const conversationTitle = activeConversation?.title ?? "New Conversation"; - - const handleHandoff = useCallback( - async (spec: { what: string; why: string; constraints: string; success: string }) => { - if (!activeConversationId) return; - try { - await chatApi.handoffSpec(activeConversationId, spec, "pm"); - queryClient.invalidateQueries({ queryKey: ["chat", "messages", activeConversationId] }); - } catch { - pushToast({ title: "Could not send to PM. Try again.", tone: "error" }); - } - }, - [activeConversationId, queryClient, pushToast], - ); - - const [agentResponseCount, setAgentResponseCount] = useState(0); - - const handleSend = async (content: string) => { - if (!selectedCompanyId) return; - - // If offline, enqueue the message and show a toast - if (!isOnline) { - if (activeConversationId) { - await enqueue(activeConversationId, content); - pushToast({ title: "Message queued — will send when you're back online", tone: "info" }); - } - return; - } - - const resolvedAgentId = resolveAgentFromContent(content, agents, effectiveBrainstormerId); - const fileIdsToAttach = [...completedFileIds]; - - setIsSending(true); - try { - if (!activeConversationId) { - const newConvo = await chatApi.createConversation(selectedCompanyId, { - agentId: resolvedAgentId ?? undefined, - }); - setActiveConversationId(newConvo.id); - const message = await chatApi.postMessage(newConvo.id, { role: "user", content }); - if (fileIdsToAttach.length > 0) { - await chatApi.attachFilesToMessage(fileIdsToAttach, message.id); - clearCompleted(); - queryClient.invalidateQueries({ queryKey: ["chat", "messages", newConvo.id] }); - } - queryClient.invalidateQueries({ queryKey: ["chat"] }); - startStream(content, resolvedAgentId ?? undefined); - } else { - const message = await chatApi.postMessage(activeConversationId, { role: "user", content }); - if (fileIdsToAttach.length > 0) { - await chatApi.attachFilesToMessage(fileIdsToAttach, message.id); - clearCompleted(); - } - queryClient.invalidateQueries({ queryKey: ["chat", "messages", activeConversationId] }); - startStream(content, resolvedAgentId ?? undefined); - } - } finally { - setIsSending(false); - } - }; - - const handleEdit = async (messageId: string, newContent: string) => { - if (!activeConversationId) return; - const editedIdx = messages.findIndex((m) => m.id === messageId); - const hasSubsequentMessages = editedIdx >= 0 && editedIdx < messages.length - 1; - - if (hasSubsequentMessages) { - try { - const newConv = await chatApi.branchConversation(activeConversationId, messageId); - setActiveConversationId(newConv.id); - queryClient.invalidateQueries({ queryKey: ["chat", "branches"] }); - queryClient.invalidateQueries({ queryKey: ["chat"] }); - await chatApi.editMessage(newConv.id, messageId, newContent); - await chatApi.truncateMessagesAfter(newConv.id, messageId); - queryClient.invalidateQueries({ queryKey: ["chat", "messages", newConv.id] }); - startStream(newContent, effectiveBrainstormerId ?? undefined); - } catch { - pushToast({ title: "Could not create branch. Try again.", tone: "error" }); - } - } else { - await chatApi.editMessage(activeConversationId, messageId, newContent); - await chatApi.truncateMessagesAfter(activeConversationId, messageId); - queryClient.invalidateQueries({ queryKey: ["chat", "messages", activeConversationId] }); - startStream(newContent, effectiveBrainstormerId ?? undefined); - } - }; - - const handleRetry = async (assistantMessageId: string) => { - if (!activeConversationId || !messages) return; - const assistantIdx = messages.findIndex((m) => m.id === assistantMessageId); - if (assistantIdx < 0) return; - - let lastUserContent = ""; - let userMessageId = ""; - for (let i = assistantIdx - 1; i >= 0; i--) { - if (messages[i]!.role === "user") { - lastUserContent = messages[i]!.content; - userMessageId = messages[i]!.id; - break; - } - } - if (!lastUserContent || !userMessageId) return; - - await chatApi.truncateMessagesAfter(activeConversationId, userMessageId); - queryClient.invalidateQueries({ queryKey: ["chat", "messages", activeConversationId] }); - startStream(lastUserContent, effectiveBrainstormerId ?? undefined); - }; - - const handleBookmark = useCallback( - (messageId: string) => { - if (!activeConversationId) return; - toggleBookmark({ conversationId: activeConversationId, messageId }); - }, - [activeConversationId, toggleBookmark], - ); - - const handleRefresh = async () => { - await refetch(); - }; - - // Conversation list view (no active conversation) - if (!activeConversationId) { - return ( -
- - - {/* pb-16 accounts for the existing MobileBottomNav (h-16) at the bottom of Layout */} -
- {selectedCompanyId ? ( - - - - ) : ( -
-

No workspace selected

-
- )} -
-
- ); - } - - // Active conversation view - return ( -
- - - {/* Header: 48px tall */} -
- - {conversationTitle} - {selectedCompanyId && ( - - )} -
- - {/* Message list: fills remaining space */} -
- -
- - {/* Stop button (shown during streaming) */} - {isStreaming && } - - {/* Sticky input bar with safe area inset */} -
-
- files.forEach(addFile)} - enableVoiceInput={true} - /> -
-
-
- ); -} diff --git a/ui/src/components/PropertiesPanel.tsx b/ui/src/components/PropertiesPanel.tsx deleted file mode 100644 index 69e29482..00000000 --- a/ui/src/components/PropertiesPanel.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { X } from "lucide-react"; -import { usePanel } from "../context/PanelContext"; -import { Button } from "@/components/ui/button"; -import { ScrollArea } from "@/components/ui/scroll-area"; - -export function PropertiesPanel() { - const { panelContent, panelVisible, setPanelVisible } = usePanel(); - - if (!panelContent) return null; - - return ( - - ); -} diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx deleted file mode 100644 index 05ba551f..00000000 --- a/ui/src/components/Sidebar.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import { - Inbox, - CircleDot, - Target, - DollarSign, - History, - Search, - SquarePen, - Network, - Boxes, - Repeat, - Settings, - Bot, - Sparkles, - RefreshCw, -} from "lucide-react"; -import { VOCAB } from "@paperclipai/branding"; -import { useQuery } from "@tanstack/react-query"; -import { SidebarSection } from "./SidebarSection"; -import { SidebarNavItem } from "./SidebarNavItem"; -import { SidebarProjects } from "./SidebarProjects"; -import { SidebarAgents } from "./SidebarAgents"; -import { useDialog } from "../context/DialogContext"; -import { useCompany } from "../context/CompanyContext"; -import { useNexusMode } from "../hooks/useNexusMode"; -import { heartbeatsApi } from "../api/heartbeats"; -import { queryKeys } from "../lib/queryKeys"; -import { useInboxBadge } from "../hooks/useInboxBadge"; -import { Button } from "@/components/ui/button"; -import { PluginSlotOutlet } from "@/plugins/slots"; - -export function Sidebar() { - const { openNewIssue } = useDialog(); - const { selectedCompanyId, selectedCompany } = useCompany(); - const { mode, isAssistantEnabled } = useNexusMode(); - const showBoard = mode === "project_builder"; - const inboxBadge = useInboxBadge(selectedCompanyId); - const { data: liveRuns } = useQuery({ - queryKey: queryKeys.liveRuns(selectedCompanyId!), - queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!), - enabled: !!selectedCompanyId, - refetchInterval: 10_000, - }); - // liveRunCount currently only surfaced when the board Dashboard item is - // visible (project_builder mode). Keep the fetch for parity. - void liveRuns; - - function openSearch() { - document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true })); - } - - const pluginContext = { - companyId: selectedCompanyId, - companyPrefix: selectedCompany?.issuePrefix ?? null, - }; - - return ( - - ); -} diff --git a/ui/src/components/VoiceMicButton.tsx b/ui/src/components/VoiceMicButton.tsx deleted file mode 100644 index 6fa8a566..00000000 --- a/ui/src/components/VoiceMicButton.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { Mic, Loader2 } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { VoiceWaveform } from "./VoiceWaveform"; -import { useVadRecorder } from "../hooks/useVadRecorder"; - -interface VoiceMicButtonProps { - onTranscript: (text: string) => void; - disabled?: boolean; -} - -export function VoiceMicButton({ onTranscript, disabled }: VoiceMicButtonProps) { - const { state, start, stop, mediaStream } = useVadRecorder({ onTranscript }); - - // Idle state (also used when disabled) - if (state === "idle") { - return ( - - ); - } - - // Recording state — show waveform with primary ring, click to stop - if (state === "recording") { - return ( - - ); - } - - // Processing state — transcribing, disabled - return ( - - ); -} diff --git a/ui/src/context/ChatPanelContext.tsx b/ui/src/context/ChatPanelContext.tsx deleted file mode 100644 index bf3eed0c..00000000 --- a/ui/src/context/ChatPanelContext.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { createContext, useCallback, useContext, useState, type ReactNode } from "react"; - -const STORAGE_KEY = "nexus:chat-panel-open"; - -interface ChatPanelContextValue { - chatOpen: boolean; - activeConversationId: string | null; - scrollToMessageId: string | null; - setChatOpen: (open: boolean) => void; - toggleChat: () => void; - setActiveConversationId: (id: string | null) => void; - setScrollToMessageId: (id: string | null) => void; -} - -const ChatPanelContext = createContext(null); - -function readPreference(): boolean { - try { - const raw = localStorage.getItem(STORAGE_KEY); - return raw === "true"; - } catch { - return false; - } -} - -function writePreference(open: boolean) { - try { - localStorage.setItem(STORAGE_KEY, String(open)); - } catch { - /* ignore */ - } -} - -export function ChatPanelProvider({ children }: { children: ReactNode }) { - const [chatOpen, setChatOpenState] = useState(readPreference); - const [activeConversationId, setActiveConversationId] = useState(null); - const [scrollToMessageId, setScrollToMessageId] = useState(null); - - const setChatOpen = useCallback((open: boolean) => { - setChatOpenState(open); - writePreference(open); - }, []); - - const toggleChat = useCallback(() => { - setChatOpenState((prev) => { - const next = !prev; - writePreference(next); - return next; - }); - }, []); - - return ( - - {children} - - ); -} - -export function useChatPanel() { - const ctx = useContext(ChatPanelContext); - if (!ctx) throw new Error("useChatPanel must be used within ChatPanelProvider"); - return ctx; -} diff --git a/ui/src/hooks/useVadRecorder.ts b/ui/src/hooks/useVadRecorder.ts deleted file mode 100644 index 6debfb2c..00000000 --- a/ui/src/hooks/useVadRecorder.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { useState, useRef, useCallback } from "react"; -import { useMicVAD } from "@ricky0123/vad-react"; -import { encodeWav } from "../lib/encodeWav"; - -interface UseVadRecorderOptions { - onTranscript: (text: string) => void; -} - -interface UseVadRecorderReturn { - state: "idle" | "recording" | "processing"; - start: () => void; - stop: () => void; - mediaStream: MediaStream | null; -} - -export function useVadRecorder(opts: UseVadRecorderOptions): UseVadRecorderReturn { - const [state, setState] = useState<"idle" | "recording" | "processing">("idle"); - const mediaStreamRef = useRef(null); - - const handleSpeechEnd = useCallback( - async (audio: Float32Array) => { - vad.pause(); - setState("processing"); - - try { - const wavBlob = encodeWav(audio); - const formData = new FormData(); - formData.append("audio", wavBlob, "recording.wav"); - - const res = await fetch("/api/transcribe", { - method: "POST", - credentials: "include", - body: formData, - }); - - if (res.ok) { - const data = (await res.json()) as { text: string }; - if (data.text && data.text.length >= 2) { - opts.onTranscript(data.text.trim()); - } - } - } catch (err) { - console.error("[useVadRecorder] Transcription error:", err); - } finally { - setState("idle"); - } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [opts.onTranscript], - ); - - const vad = useMicVAD({ - startOnLoad: false, - baseAssetPath: "/", - onnxWASMBasePath: "/", - positiveSpeechThreshold: 0.8, - negativeSpeechThreshold: 0.65, - redemptionFrames: 8, - minSpeechFrames: 5, - onSpeechStart: () => { - // VAD detected start of speech — no action needed, state was set to "recording" in start() - }, - onSpeechEnd: handleSpeechEnd, - }); - - const start = useCallback(async () => { - try { - // Request a separate stream reference for VoiceWaveform AnalyserNode - const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); - mediaStreamRef.current = stream; - } catch (err) { - console.error("[useVadRecorder] Microphone access denied:", err); - return; - } - - vad.start(); - setState("recording"); - }, [vad]); - - const stop = useCallback(() => { - vad.pause(); - - // Stop the separate stream tracks - if (mediaStreamRef.current) { - mediaStreamRef.current.getTracks().forEach((t) => t.stop()); - mediaStreamRef.current = null; - } - - setState("idle"); - }, [vad]); - - return { - state, - start, - stop, - mediaStream: mediaStreamRef.current, - }; -} diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 7e4229cf..02810c5e 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -9,7 +9,6 @@ import { CompanyProvider } from "./context/CompanyContext"; import { LiveUpdatesProvider } from "./context/LiveUpdatesProvider"; import { BreadcrumbProvider } from "./context/BreadcrumbContext"; import { PanelProvider } from "./context/PanelContext"; -import { ChatPanelProvider } from "./context/ChatPanelContext"; import { SidebarProvider } from "./context/SidebarContext"; import { DialogProvider } from "./context/DialogContext"; import { ToastProvider } from "./context/ToastContext"; @@ -53,13 +52,11 @@ createRoot(document.getElementById("root")!).render( - - - - - - - + + + + +