refactor(nexus): delete dead chrome files (phase 16a)
Phase 16a removes the legacy chrome surfaces that the Phase 8 frame skeleton rewrite replaced: - ChatPanel.tsx — 380px desktop slide-in drawer; superseded by the full-bleed /assistant page and HistorySheet slide-over. - ChatPanelContext.tsx — drawer open/close + conversation-id state; migrated to prop drilling in the preceding commit. - MobileChatView.tsx — only consumed by ChatPanel, orphaned with it. - Sidebar.tsx — old left sidebar frame; replaced by IconRail. - BreadcrumbBar.tsx — old top breadcrumb strip; replaced by TopStrip + ModeBreadcrumb. - PropertiesPanel.tsx — orphan placeholder from the pre-Nexus three-pane layout. - VoiceMicButton.tsx + useVadRecorder.ts — Phase 14 orphaned these when voice routing moved into VoiceContext + GlobalMicButton. main.tsx drops the now-unused ChatPanelProvider wrapper. Grep for each symbol across ui/src returns zero surviving consumers. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1b6727bb1c
commit
5957290073
9 changed files with 5 additions and 1273 deletions
|
|
@ -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 (
|
|
||||||
<div className="flex items-center gap-1 ml-auto shrink-0 pl-2">
|
|
||||||
<PluginSlotOutlet slotTypes={["globalToolbarButton"]} context={context} className="flex items-center gap-1" />
|
|
||||||
<PluginLauncherOutlet placementZones={["globalToolbarButton"]} context={context} className="flex items-center gap-1" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = <GlobalToolbarPlugins context={globalToolbarSlotContext} />;
|
|
||||||
|
|
||||||
if (breadcrumbs.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="border-b border-border px-4 md:px-6 h-12 shrink-0 flex items-center justify-end">
|
|
||||||
{globalToolbarSlots}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const menuButton = isMobile && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
className="mr-2 shrink-0"
|
|
||||||
onClick={toggleSidebar}
|
|
||||||
aria-label="Open sidebar"
|
|
||||||
>
|
|
||||||
<Menu className="h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Single breadcrumb = page title (uppercase)
|
|
||||||
if (breadcrumbs.length === 1) {
|
|
||||||
return (
|
|
||||||
<div className="border-b border-border px-4 md:px-6 h-12 shrink-0 flex items-center">
|
|
||||||
{menuButton}
|
|
||||||
<div className="min-w-0 overflow-hidden flex-1">
|
|
||||||
<h1 className="text-sm font-semibold uppercase tracking-wider truncate">
|
|
||||||
{breadcrumbs[0].label}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
{globalToolbarSlots}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Multiple breadcrumbs = breadcrumb trail
|
|
||||||
return (
|
|
||||||
<div className="border-b border-border px-4 md:px-6 h-12 shrink-0 flex items-center">
|
|
||||||
{menuButton}
|
|
||||||
<div className="min-w-0 overflow-hidden flex-1">
|
|
||||||
<Breadcrumb className="min-w-0 overflow-hidden">
|
|
||||||
<BreadcrumbList className="flex-nowrap">
|
|
||||||
{breadcrumbs.map((crumb, i) => {
|
|
||||||
const isLast = i === breadcrumbs.length - 1;
|
|
||||||
return (
|
|
||||||
<Fragment key={i}>
|
|
||||||
{i > 0 && <BreadcrumbSeparator />}
|
|
||||||
<BreadcrumbItem className={isLast ? "min-w-0" : "shrink-0"}>
|
|
||||||
{isLast || !crumb.href ? (
|
|
||||||
<BreadcrumbPage className="truncate">{crumb.label}</BreadcrumbPage>
|
|
||||||
) : (
|
|
||||||
<BreadcrumbLink asChild>
|
|
||||||
<Link to={crumb.href}>{crumb.label}</Link>
|
|
||||||
</BreadcrumbLink>
|
|
||||||
)}
|
|
||||||
</BreadcrumbItem>
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</BreadcrumbList>
|
|
||||||
</Breadcrumb>
|
|
||||||
</div>
|
|
||||||
{globalToolbarSlots}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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<string | null>(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<string, { name: string; icon: string | null; role: AgentRole | null }>();
|
|
||||||
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<string>();
|
|
||||||
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 <MobileChatView />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<aside
|
|
||||||
aria-label="Chat"
|
|
||||||
className="hidden md:flex overflow-hidden transition-[width] duration-100 ease-out flex-shrink-0 border-l border-border flex-col bg-background"
|
|
||||||
style={{ width: chatOpen ? 380 : 0 }}
|
|
||||||
>
|
|
||||||
{/* Offline status banner */}
|
|
||||||
<OfflineBanner queuedCount={queuedCount} />
|
|
||||||
|
|
||||||
{/* Header with agent selector */}
|
|
||||||
<div className="flex items-center justify-between border-b border-border px-4 py-2 min-w-[380px]">
|
|
||||||
<span className="text-sm font-medium">Chat</span>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{selectedCompanyId && activeConversationId && (
|
|
||||||
<>
|
|
||||||
{/* Bookmarks toggle */}
|
|
||||||
<Button
|
|
||||||
variant={bookmarksOpen ? "secondary" : "ghost"}
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6"
|
|
||||||
onClick={() => setBookmarksOpen((v) => !v)}
|
|
||||||
aria-label="Bookmarks"
|
|
||||||
title="Bookmarks"
|
|
||||||
>
|
|
||||||
<Bookmark className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
{/* Export buttons */}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6"
|
|
||||||
onClick={() => handleExport("markdown")}
|
|
||||||
aria-label="Export as Markdown"
|
|
||||||
title="Export as Markdown"
|
|
||||||
>
|
|
||||||
<Download className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6"
|
|
||||||
onClick={() => handleExport("json")}
|
|
||||||
aria-label="Export as JSON"
|
|
||||||
title="Export as JSON"
|
|
||||||
>
|
|
||||||
<FileJson className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{selectedCompanyId && (
|
|
||||||
<ChatAgentSelector
|
|
||||||
companyId={selectedCompanyId}
|
|
||||||
conversationId={activeConversationId}
|
|
||||||
agentId={activeAgentId}
|
|
||||||
onAgentChange={setActiveAgentId}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6"
|
|
||||||
onClick={() => setChatOpen(false)}
|
|
||||||
aria-label="Close chat"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Two-column layout */}
|
|
||||||
<div className="flex flex-1 min-h-0 min-w-[380px]">
|
|
||||||
{/* Left column: conversation list */}
|
|
||||||
<div className="w-[160px] flex-shrink-0 border-r border-border bg-card overflow-hidden">
|
|
||||||
{selectedCompanyId ? (
|
|
||||||
<ChatConversationList companyId={selectedCompanyId} />
|
|
||||||
) : (
|
|
||||||
<div className="p-3 text-center text-xs text-muted-foreground">
|
|
||||||
No workspace selected
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right column: message thread + stop button + input */}
|
|
||||||
<div className="flex flex-1 flex-col min-w-0">
|
|
||||||
{/* Bookmarks panel (shown when toggled) */}
|
|
||||||
{bookmarksOpen && selectedCompanyId && (
|
|
||||||
<div className="border-b border-border" style={{ maxHeight: 200, overflow: "hidden" }}>
|
|
||||||
<ChatBookmarkList
|
|
||||||
companyId={selectedCompanyId}
|
|
||||||
onNavigate={handleBookmarkNavigate}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Branch selector */}
|
|
||||||
{activeConversationId && branches.length > 0 && (
|
|
||||||
<ChatBranchSelector
|
|
||||||
conversationId={activeConversationId}
|
|
||||||
branches={branches}
|
|
||||||
activeBranchId={activeConversationId}
|
|
||||||
onSelectBranch={setActiveConversationId}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Message area */}
|
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
{activeConversationId ? (
|
|
||||||
<ChatMessageList
|
|
||||||
conversationId={activeConversationId}
|
|
||||||
streamingContent={streamingContent}
|
|
||||||
isStreaming={isStreaming}
|
|
||||||
streamingAgentName={streamingAgent?.name ?? null}
|
|
||||||
streamingAgentIcon={streamingAgent?.icon ?? null}
|
|
||||||
streamingAgentRole={streamingAgent?.role ?? null}
|
|
||||||
onEdit={handleEdit}
|
|
||||||
onRetry={handleRetry}
|
|
||||||
onHandoff={handleHandoff}
|
|
||||||
agentMap={agentMap}
|
|
||||||
onBookmark={handleBookmark}
|
|
||||||
bookmarkedMessageIds={bookmarkedMessageIds}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center justify-center h-full p-3">
|
|
||||||
<p className="text-sm text-muted-foreground text-center">
|
|
||||||
Send a message to start this conversation.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stop button (shown during streaming) */}
|
|
||||||
{isStreaming && <ChatStopButton onStop={stop} />}
|
|
||||||
|
|
||||||
{/* Input area */}
|
|
||||||
<div className="border-t border-border px-3 py-2">
|
|
||||||
<ChatInput
|
|
||||||
onSend={handleSend}
|
|
||||||
isSubmitting={isSending}
|
|
||||||
disabled={isStreaming}
|
|
||||||
placeholder={isStreaming ? "Waiting for response..." : "Message your agent..."}
|
|
||||||
agents={agents}
|
|
||||||
agentsLoading={agentsLoading}
|
|
||||||
pendingFiles={pendingFiles}
|
|
||||||
onRemoveFile={removeFile}
|
|
||||||
onFilesPicked={(files) => files.forEach(addFile)}
|
|
||||||
enableVoiceInput={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search dialog */}
|
|
||||||
<ChatSearchDialog
|
|
||||||
open={searchOpen}
|
|
||||||
onOpenChange={setSearchOpen}
|
|
||||||
companyId={selectedCompanyId}
|
|
||||||
onNavigate={handleSearchNavigate}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* PWA install prompt banner (self-contained show/hide logic) */}
|
|
||||||
<InstallPromptBanner />
|
|
||||||
|
|
||||||
{/* Push notification permission prompt (shows after 3 agent responses) */}
|
|
||||||
<NotificationPermissionPrompt agentResponseCount={agentResponseCount} />
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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<string | null>(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<string, { name: string; icon: string | null; role: AgentRole | null }>();
|
|
||||||
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<string>();
|
|
||||||
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 (
|
|
||||||
<div className="fixed inset-0 z-40 flex h-[100dvh] flex-col bg-background">
|
|
||||||
<OfflineBanner queuedCount={queuedCount} />
|
|
||||||
<InstallPromptBanner />
|
|
||||||
{/* pb-16 accounts for the existing MobileBottomNav (h-16) at the bottom of Layout */}
|
|
||||||
<div className="flex-1 overflow-hidden pb-16">
|
|
||||||
{selectedCompanyId ? (
|
|
||||||
<PullToRefresh onRefresh={handleRefresh} enabled={true}>
|
|
||||||
<ChatConversationList companyId={selectedCompanyId} />
|
|
||||||
</PullToRefresh>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full items-center justify-center p-4">
|
|
||||||
<p className="text-sm text-muted-foreground">No workspace selected</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Active conversation view
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-40 flex h-[100dvh] flex-col bg-background">
|
|
||||||
<OfflineBanner queuedCount={queuedCount} />
|
|
||||||
<NotificationPermissionPrompt agentResponseCount={agentResponseCount} />
|
|
||||||
{/* Header: 48px tall */}
|
|
||||||
<div className="flex h-12 flex-shrink-0 items-center gap-2 border-b border-border px-3">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8"
|
|
||||||
onClick={() => setActiveConversationId(null)}
|
|
||||||
aria-label="Back to conversations"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
<span className="flex-1 truncate text-sm font-medium">{conversationTitle}</span>
|
|
||||||
{selectedCompanyId && (
|
|
||||||
<ChatAgentSelector
|
|
||||||
companyId={selectedCompanyId}
|
|
||||||
conversationId={activeConversationId}
|
|
||||||
agentId={effectiveBrainstormerId}
|
|
||||||
onAgentChange={setActiveAgentId}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Message list: fills remaining space */}
|
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
<ChatMessageList
|
|
||||||
conversationId={activeConversationId}
|
|
||||||
streamingContent={streamingContent}
|
|
||||||
isStreaming={isStreaming}
|
|
||||||
streamingAgentName={streamingAgent?.name ?? null}
|
|
||||||
streamingAgentIcon={streamingAgent?.icon ?? null}
|
|
||||||
streamingAgentRole={streamingAgent?.role ?? null}
|
|
||||||
onEdit={handleEdit}
|
|
||||||
onRetry={handleRetry}
|
|
||||||
onHandoff={handleHandoff}
|
|
||||||
agentMap={agentMap}
|
|
||||||
onBookmark={handleBookmark}
|
|
||||||
bookmarkedMessageIds={bookmarkedMessageIds}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stop button (shown during streaming) */}
|
|
||||||
{isStreaming && <ChatStopButton onStop={stop} />}
|
|
||||||
|
|
||||||
{/* Sticky input bar with safe area inset */}
|
|
||||||
<div className="sticky bottom-0 border-t border-border bg-background pb-[env(safe-area-inset-bottom)]">
|
|
||||||
<div className="px-3 py-2">
|
|
||||||
<ChatInput
|
|
||||||
onSend={handleSend}
|
|
||||||
isSubmitting={isSending}
|
|
||||||
disabled={isStreaming}
|
|
||||||
placeholder={isStreaming ? "Waiting for response..." : "Message your agent..."}
|
|
||||||
agents={agents}
|
|
||||||
agentsLoading={agentsLoading}
|
|
||||||
pendingFiles={pendingFiles}
|
|
||||||
onRemoveFile={removeFile}
|
|
||||||
onFilesPicked={(files) => files.forEach(addFile)}
|
|
||||||
enableVoiceInput={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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 (
|
|
||||||
<aside
|
|
||||||
className="hidden md:flex border-l border-border bg-card flex-col shrink-0 overflow-hidden transition-[width,opacity] duration-200 ease-in-out"
|
|
||||||
style={{ width: panelVisible ? 320 : 0, opacity: panelVisible ? 1 : 0 }}
|
|
||||||
>
|
|
||||||
<div className="w-80 flex-1 flex flex-col min-w-[320px]">
|
|
||||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border">
|
|
||||||
<span className="text-sm font-medium">Properties</span>
|
|
||||||
<Button variant="ghost" size="icon-xs" onClick={() => setPanelVisible(false)}>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<ScrollArea className="flex-1">
|
|
||||||
<div className="p-4">{panelContent}</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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 (
|
|
||||||
<aside className="w-60 h-full min-h-0 border-r border-border bg-background flex flex-col">
|
|
||||||
{/* Top bar: single workspace name (static) + search. No switcher. */}
|
|
||||||
<div className="flex items-center gap-1 px-3 h-12 shrink-0">
|
|
||||||
{selectedCompany?.brandColor && (
|
|
||||||
<div
|
|
||||||
className="w-4 h-4 rounded-sm shrink-0 ml-1"
|
|
||||||
style={{ backgroundColor: selectedCompany.brandColor }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span className="flex-1 text-sm font-bold text-foreground truncate pl-1">
|
|
||||||
{selectedCompany?.name ?? "Nexus"}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
className="text-muted-foreground shrink-0"
|
|
||||||
onClick={openSearch}
|
|
||||||
>
|
|
||||||
<Search className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide flex flex-col gap-4 px-3 py-2">
|
|
||||||
{/* Nexus essentials: always visible in every mode. */}
|
|
||||||
<div className="flex flex-col gap-0.5">
|
|
||||||
<button
|
|
||||||
onClick={() => openNewIssue()}
|
|
||||||
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium text-muted-foreground hover:bg-accent/50 hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
<SquarePen className="h-4 w-4 shrink-0" />
|
|
||||||
<span className="truncate">New Issue</span>
|
|
||||||
</button>
|
|
||||||
{isAssistantEnabled && (
|
|
||||||
<SidebarNavItem to="/assistant" label="Assistant" icon={Bot} />
|
|
||||||
)}
|
|
||||||
<SidebarNavItem to="/content-studio" label="Content Studio" icon={Sparkles} />
|
|
||||||
<SidebarNavItem to="/convert" label="Convert" icon={RefreshCw} />
|
|
||||||
<SidebarNavItem
|
|
||||||
to="/inbox"
|
|
||||||
label="Inbox"
|
|
||||||
icon={Inbox}
|
|
||||||
badge={inboxBadge.inbox}
|
|
||||||
badgeTone={inboxBadge.failedRuns > 0 ? "danger" : "default"}
|
|
||||||
alert={inboxBadge.failedRuns > 0}
|
|
||||||
/>
|
|
||||||
<SidebarNavItem to="/skills" label="Skills" icon={Boxes} />
|
|
||||||
<SidebarNavItem to="/company/settings" label="Settings" icon={Settings} />
|
|
||||||
<PluginSlotOutlet
|
|
||||||
slotTypes={["sidebar"]}
|
|
||||||
context={pluginContext}
|
|
||||||
className="flex flex-col gap-0.5"
|
|
||||||
itemClassName="text-[13px] font-medium"
|
|
||||||
missingBehavior="placeholder"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Board-mode sections: only visible when the user has explicitly
|
|
||||||
chosen project_builder mode. Hidden in personal_ai and both
|
|
||||||
(default) so first-time users never see the board chrome. */}
|
|
||||||
{showBoard && (
|
|
||||||
<>
|
|
||||||
<SidebarSection label="Work">
|
|
||||||
<SidebarNavItem to="/issues" label="Issues" icon={CircleDot} />
|
|
||||||
<SidebarNavItem to="/routines" label="Routines" icon={Repeat} textBadge="Beta" textBadgeTone="amber" />
|
|
||||||
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
|
|
||||||
</SidebarSection>
|
|
||||||
|
|
||||||
<SidebarProjects />
|
|
||||||
|
|
||||||
<SidebarAgents />
|
|
||||||
|
|
||||||
<SidebarSection label={VOCAB.company}>
|
|
||||||
<SidebarNavItem to="/org" label="Org" icon={Network} />
|
|
||||||
<SidebarNavItem to="/costs" label="Costs" icon={DollarSign} />
|
|
||||||
<SidebarNavItem to="/activity" label="Activity" icon={History} />
|
|
||||||
</SidebarSection>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<PluginSlotOutlet
|
|
||||||
slotTypes={["sidebarPanel"]}
|
|
||||||
context={pluginContext}
|
|
||||||
className="flex flex-col gap-3"
|
|
||||||
itemClassName="rounded-lg border border-border p-3"
|
|
||||||
missingBehavior="placeholder"
|
|
||||||
/>
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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 (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8"
|
|
||||||
onClick={start}
|
|
||||||
disabled={disabled}
|
|
||||||
aria-label="Start voice input"
|
|
||||||
title="Start voice input"
|
|
||||||
>
|
|
||||||
<Mic className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recording state — show waveform with primary ring, click to stop
|
|
||||||
if (state === "recording") {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 ring-2 ring-primary"
|
|
||||||
onClick={stop}
|
|
||||||
aria-label="Recording — speak now"
|
|
||||||
title="Recording — speak now"
|
|
||||||
>
|
|
||||||
<VoiceWaveform stream={mediaStream} active={true} />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Processing state — transcribing, disabled
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8"
|
|
||||||
disabled
|
|
||||||
aria-label="Transcribing..."
|
|
||||||
title="Transcribing..."
|
|
||||||
>
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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<ChatPanelContextValue | null>(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<string | null>(null);
|
|
||||||
const [scrollToMessageId, setScrollToMessageId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const setChatOpen = useCallback((open: boolean) => {
|
|
||||||
setChatOpenState(open);
|
|
||||||
writePreference(open);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleChat = useCallback(() => {
|
|
||||||
setChatOpenState((prev) => {
|
|
||||||
const next = !prev;
|
|
||||||
writePreference(next);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ChatPanelContext.Provider
|
|
||||||
value={{ chatOpen, activeConversationId, scrollToMessageId, setChatOpen, toggleChat, setActiveConversationId, setScrollToMessageId }}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</ChatPanelContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useChatPanel() {
|
|
||||||
const ctx = useContext(ChatPanelContext);
|
|
||||||
if (!ctx) throw new Error("useChatPanel must be used within ChatPanelProvider");
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
|
|
@ -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<MediaStream | null>(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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -9,7 +9,6 @@ import { CompanyProvider } from "./context/CompanyContext";
|
||||||
import { LiveUpdatesProvider } from "./context/LiveUpdatesProvider";
|
import { LiveUpdatesProvider } from "./context/LiveUpdatesProvider";
|
||||||
import { BreadcrumbProvider } from "./context/BreadcrumbContext";
|
import { BreadcrumbProvider } from "./context/BreadcrumbContext";
|
||||||
import { PanelProvider } from "./context/PanelContext";
|
import { PanelProvider } from "./context/PanelContext";
|
||||||
import { ChatPanelProvider } from "./context/ChatPanelContext";
|
|
||||||
import { SidebarProvider } from "./context/SidebarContext";
|
import { SidebarProvider } from "./context/SidebarContext";
|
||||||
import { DialogProvider } from "./context/DialogContext";
|
import { DialogProvider } from "./context/DialogContext";
|
||||||
import { ToastProvider } from "./context/ToastContext";
|
import { ToastProvider } from "./context/ToastContext";
|
||||||
|
|
@ -53,13 +52,11 @@ createRoot(document.getElementById("root")!).render(
|
||||||
<BreadcrumbProvider>
|
<BreadcrumbProvider>
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<PanelProvider>
|
<PanelProvider>
|
||||||
<ChatPanelProvider>
|
<PluginLauncherProvider>
|
||||||
<PluginLauncherProvider>
|
<DialogProvider>
|
||||||
<DialogProvider>
|
<App />
|
||||||
<App />
|
</DialogProvider>
|
||||||
</DialogProvider>
|
</PluginLauncherProvider>
|
||||||
</PluginLauncherProvider>
|
|
||||||
</ChatPanelProvider>
|
|
||||||
</PanelProvider>
|
</PanelProvider>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
</BreadcrumbProvider>
|
</BreadcrumbProvider>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue