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(
-
-
-
-
-
-
-
+
+
+
+
+