From 1b6727bb1cc8b56fc4221a627183fc82af1555cc Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Sat, 11 Apr 2026 15:53:31 +0000 Subject: [PATCH] refactor(nexus): migrate chat state off legacy panel context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 9 flagged that ChatMessageList, ChatConversationList, and PersonalAssistant all read activeConversationId / scrollToMessageId from the legacy ChatPanelContext, blocking deletion of the old desktop chat drawer. Phase 16a migrates those reads off context using prop-drilling through HistorySheet so PersonalAssistant owns a local useState for the active conversation id. - ChatMessageList accepts optional scrollToMessageId/onScrollComplete props in place of useChatPanel(). - ChatConversationList accepts activeConversationId + onSelectConversation so HistorySheet can pass them through. - HistorySheet forwards the new props to its embedded list. - PersonalAssistant owns activeConversationId as local state and wires HistorySheet to it. - NexusOnboardingWizard drops its setChatOpen(true) call — the Assistant page is the chat surface now, no drawer to toggle. Leaves ChatPanel.tsx and MobileChatView.tsx as the only remaining useChatPanel consumers; both are dead chrome slated for deletion in the next commit of this phase. Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/src/components/ChatConversationList.tsx | 15 ++- ui/src/components/ChatMessageList.tsx | 17 +++- ui/src/components/NexusOnboardingWizard.tsx | 7 +- .../assistant/HistorySheet.test.tsx | 97 ++++++++++++++++--- ui/src/components/assistant/HistorySheet.tsx | 17 +++- ui/src/pages/PersonalAssistant.tsx | 8 +- 6 files changed, 134 insertions(+), 27 deletions(-) diff --git a/ui/src/components/ChatConversationList.tsx b/ui/src/components/ChatConversationList.tsx index 0b2b3624..93cdd4c7 100644 --- a/ui/src/components/ChatConversationList.tsx +++ b/ui/src/components/ChatConversationList.tsx @@ -1,7 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { GitBranch, Plus, Search, X } from "lucide-react"; import { useChatConversations } from "../hooks/useChatConversations"; -import { useChatPanel } from "../context/ChatPanelContext"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { PullToRefresh } from "./PullToRefresh"; import { ChatConversationItem } from "./ChatConversationItem"; @@ -21,10 +20,20 @@ import type { ChatConversationListItem } from "@paperclipai/shared"; interface ChatConversationListProps { companyId: string; + /** + * Currently-selected conversation id. Phase 16a: owners pass this via + * props instead of reading from the legacy `ChatPanelContext`. + */ + activeConversationId: string | null; + onSelectConversation: (id: string | null) => void; } -export function ChatConversationList({ companyId }: ChatConversationListProps) { - const { activeConversationId, setActiveConversationId } = useChatPanel(); +export function ChatConversationList({ + companyId, + activeConversationId, + onSelectConversation, +}: ChatConversationListProps) { + const setActiveConversationId = onSelectConversation; const [searchTerm, setSearchTerm] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState(""); diff --git a/ui/src/components/ChatMessageList.tsx b/ui/src/components/ChatMessageList.tsx index 69e6cbaa..df7d4a5f 100644 --- a/ui/src/components/ChatMessageList.tsx +++ b/ui/src/components/ChatMessageList.tsx @@ -1,7 +1,6 @@ import { useRef, useEffect, useCallback, useState } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { useChatMessages } from "../hooks/useChatMessages"; -import { useChatPanel } from "../context/ChatPanelContext"; import { ChatMessage } from "./ChatMessage"; import { ArrowDown } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -21,6 +20,13 @@ interface ChatMessageListProps { agentMap?: Map; onBookmark?: (messageId: string) => void; bookmarkedMessageIds?: Set; + /** + * When set to a message id, the virtualizer scrolls that message into + * view and invokes `onScrollComplete` once. Phase 16a migrated these + * off the legacy `ChatPanelContext`; callers now own the state. + */ + scrollToMessageId?: string | null; + onScrollComplete?: () => void; } export function ChatMessageList({ @@ -36,9 +42,10 @@ export function ChatMessageList({ agentMap, onBookmark, bookmarkedMessageIds, + scrollToMessageId = null, + onScrollComplete, }: ChatMessageListProps) { const { messages, isLoading } = useChatMessages(conversationId); - const { scrollToMessageId, setScrollToMessageId } = useChatPanel(); const parentRef = useRef(null); const [showJumpToBottom, setShowJumpToBottom] = useState(false); @@ -84,13 +91,15 @@ export function ChatMessageList({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [streamingContent, isStreaming]); - // Scroll to a specific message when scrollToMessageId is set + // Scroll to a specific message when scrollToMessageId is set. Phase 16a: + // the prop replaces the legacy `ChatPanelContext` getter; callers are + // responsible for clearing the id via `onScrollComplete`. useEffect(() => { if (!scrollToMessageId) return; const index = displayMessages.findIndex((m) => m.id === scrollToMessageId); if (index >= 0) { virtualizer.scrollToIndex(index, { align: "center" }); - setScrollToMessageId(null); + onScrollComplete?.(); } // TODO: if message not in current page (infinite scroll), scroll is best-effort only. // Future iteration: load pages until message found, then scroll. diff --git a/ui/src/components/NexusOnboardingWizard.tsx b/ui/src/components/NexusOnboardingWizard.tsx index e273abd2..38f8b41d 100644 --- a/ui/src/components/NexusOnboardingWizard.tsx +++ b/ui/src/components/NexusOnboardingWizard.tsx @@ -25,7 +25,6 @@ import { VoiceStep } from "./onboarding/VoiceStep"; import { TelegramStep } from "./onboarding/TelegramStep"; import { useHardwareInfo } from "../hooks/useHardwareInfo"; import { updateNexusSettings, type NexusMode } from "../api/hardware"; -import { useChatPanel } from "../context/ChatPanelContext"; import { Cpu, LayoutGrid, @@ -82,7 +81,6 @@ export function OnboardingWizard() { const location = useLocation(); const { companyPrefix } = useParams<{ companyPrefix?: string }>(); const [routeDismissed, setRouteDismissed] = useState(false); - const { setChatOpen } = useChatPanel(); // Preserve wizard-show detection logic from the original OnboardingWizard const routeOnboardingOptions = @@ -308,12 +306,11 @@ export function OnboardingWizard() { try { const company = await createWorkspace(); - // [nexus] Mode-aware landing + chat panel open — same logic as - // handleSubmit but with the chat drawer toggled on. + // [nexus] Mode-aware landing. Phase 16a removed the legacy chat drawer + // toggle — the Assistant page is the chat surface itself now. const landingPath = selectedMode === "project_builder" ? "dashboard" : "assistant"; closeOnboarding(); navigate(`/${company.issuePrefix}/${landingPath}`); - setChatOpen(true); } catch (err) { setError(err instanceof Error ? err.message : "Setup failed. Please try again."); setLoading(false); diff --git a/ui/src/components/assistant/HistorySheet.test.tsx b/ui/src/components/assistant/HistorySheet.test.tsx index 184b8906..f685b2d5 100644 --- a/ui/src/components/assistant/HistorySheet.test.tsx +++ b/ui/src/components/assistant/HistorySheet.test.tsx @@ -4,8 +4,8 @@ import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // Stub ChatConversationList to avoid pulling its dependency tree into the -// test (react-query, ChatPanelContext, etc.). We only want to assert that -// HistorySheet mounts it when companyId is present. +// test (react-query, etc.). We only want to assert that HistorySheet mounts +// it when companyId is present. vi.mock("../ChatConversationList", () => ({ ChatConversationList: ({ companyId }: { companyId: string }) => (
@@ -14,6 +14,9 @@ vi.mock("../ChatConversationList", () => ({ ), })); +// Shared defaults for the Phase 16a prop-drilled chat selection state. +const NOOP_SELECT = () => {}; + // jsdom does not ship window.matchMedia, so the Phase 15 `useMediaQuery` // hook inside HistorySheet would throw. Stub it with a controllable fake // so individual tests can toggle between the desktop slide-over and the @@ -71,33 +74,73 @@ describe("", () => { } it("renders nothing when closed", () => { - render( {}} companyId="co-1" />); + render( + {}} + companyId="co-1" + activeConversationId={null} + onSelectConversation={NOOP_SELECT} + />, + ); expect(container.querySelector('[data-testid="history-sheet-panel"]')).toBeNull(); expect(container.querySelector('[data-testid="history-sheet-backdrop"]')).toBeNull(); }); it("renders the panel and a backdrop when open", () => { - render( {}} companyId="co-1" />); + render( + {}} + companyId="co-1" + activeConversationId={null} + onSelectConversation={NOOP_SELECT} + />, + ); expect(container.querySelector('[data-testid="history-sheet-panel"]')).not.toBeNull(); expect(container.querySelector('[data-testid="history-sheet-backdrop"]')).not.toBeNull(); }); it("mounts ChatConversationList inside the panel when companyId is present", () => { - render( {}} companyId="co-1" />); + render( + {}} + companyId="co-1" + activeConversationId={null} + onSelectConversation={NOOP_SELECT} + />, + ); const stub = container.querySelector('[data-testid="chat-conversation-list-stub"]'); expect(stub).not.toBeNull(); expect(stub?.getAttribute("data-company-id")).toBe("co-1"); }); it("shows a placeholder when companyId is null", () => { - render( {}} companyId={null} />); + render( + {}} + companyId={null} + activeConversationId={null} + onSelectConversation={NOOP_SELECT} + />, + ); expect(container.querySelector('[data-testid="chat-conversation-list-stub"]')).toBeNull(); expect(container.textContent ?? "").toContain("Select a workspace"); }); it("calls onClose when the backdrop is clicked", () => { const onClose = vi.fn(); - render(); + render( + , + ); const backdrop = container.querySelector( '[data-testid="history-sheet-backdrop"]', ) as HTMLButtonElement; @@ -109,7 +152,15 @@ describe("", () => { it("calls onClose when ESC is pressed", () => { const onClose = vi.fn(); - render(); + render( + , + ); act(() => { document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); }); @@ -118,7 +169,15 @@ describe("", () => { it("does not listen for ESC when closed", () => { const onClose = vi.fn(); - render(); + render( + , + ); act(() => { document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); }); @@ -126,7 +185,15 @@ describe("", () => { }); it("uses a left-side panel positioned against the icon rail", () => { - render( {}} companyId="co-1" />); + render( + {}} + companyId="co-1" + activeConversationId={null} + onSelectConversation={NOOP_SELECT} + />, + ); const panel = container.querySelector('[data-testid="history-sheet-panel"]') as HTMLElement; // The Tailwind class string should include left-[56px] and w-[320px]. expect(panel.className).toContain("left-[56px]"); @@ -137,7 +204,15 @@ describe("", () => { it("renders a full-screen variant on mobile (< 768px)", () => { mediaMatches = false; installMatchMedia(); - render( {}} companyId="co-1" />); + render( + {}} + companyId="co-1" + activeConversationId={null} + onSelectConversation={NOOP_SELECT} + />, + ); const panel = container.querySelector('[data-testid="history-sheet-panel"]') as HTMLElement; expect(panel.getAttribute("data-variant")).toBe("mobile"); expect(panel.className).toContain("w-full"); diff --git a/ui/src/components/assistant/HistorySheet.tsx b/ui/src/components/assistant/HistorySheet.tsx index 93699cea..17eb3478 100644 --- a/ui/src/components/assistant/HistorySheet.tsx +++ b/ui/src/components/assistant/HistorySheet.tsx @@ -13,10 +13,19 @@ export interface HistorySheetProps { open: boolean; onClose: () => void; companyId: string | null; + activeConversationId: string | null; + onSelectConversation: (id: string | null) => void; className?: string; } -export function HistorySheet({ open, onClose, companyId, className }: HistorySheetProps) { +export function HistorySheet({ + open, + onClose, + companyId, + activeConversationId, + onSelectConversation, + className, +}: HistorySheetProps) { // ESC closes the sheet. useEffect(() => { if (!open) return; @@ -80,7 +89,11 @@ export function HistorySheet({ open, onClose, companyId, className }: HistoryShe
{companyId ? ( - + ) : (

Select a workspace to view conversations. diff --git a/ui/src/pages/PersonalAssistant.tsx b/ui/src/pages/PersonalAssistant.tsx index 99cef509..d1111dfb 100644 --- a/ui/src/pages/PersonalAssistant.tsx +++ b/ui/src/pages/PersonalAssistant.tsx @@ -15,7 +15,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import type { ChatMessage, ChatConversationListResponse } from "@paperclipai/shared"; import { useNexusMode } from "../hooks/useNexusMode"; import { useCompany } from "../context/CompanyContext"; -import { useChatPanel } from "../context/ChatPanelContext"; import { useToast } from "../context/ToastContext"; import { useVoice } from "../context/VoiceContext"; import { chatApi } from "../api/chat"; @@ -42,7 +41,10 @@ export function PersonalAssistant() { const location = useLocation(); const queryClient = useQueryClient(); const { pushToast } = useToast(); - const { activeConversationId, setActiveConversationId } = useChatPanel(); + // Phase 16a: `activeConversationId` is now local to the Assistant page. + // Previously this lived in the legacy `ChatPanelContext`; migrating it + // off unblocks deleting that context along with the old desktop drawer. + const [activeConversationId, setActiveConversationId] = useState(null); const voice = useVoice(); const companyId = selectedCompany?.id ?? null; @@ -368,6 +370,8 @@ export function PersonalAssistant() { open={historyOpen} onClose={() => setHistoryOpen(false)} companyId={companyId} + activeConversationId={selectedConvId} + onSelectConversation={setActiveConversationId} />