refactor(nexus): migrate chat state off legacy panel context

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) <noreply@anthropic.com>
This commit is contained in:
Nexus Dev 2026-04-11 15:53:31 +00:00
parent fb76b5eeef
commit 1b6727bb1c
6 changed files with 134 additions and 27 deletions

View file

@ -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("");

View file

@ -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<string, { name: string; icon: string | null; role: AgentRole | null }>;
onBookmark?: (messageId: string) => void;
bookmarkedMessageIds?: Set<string>;
/**
* 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<HTMLDivElement>(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.

View file

@ -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);

View file

@ -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 }) => (
<div data-testid="chat-conversation-list-stub" data-company-id={companyId}>
@ -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("<HistorySheet />", () => {
}
it("renders nothing when closed", () => {
render(<HistorySheet open={false} onClose={() => {}} companyId="co-1" />);
render(
<HistorySheet
open={false}
onClose={() => {}}
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(<HistorySheet open onClose={() => {}} companyId="co-1" />);
render(
<HistorySheet
open
onClose={() => {}}
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(<HistorySheet open onClose={() => {}} companyId="co-1" />);
render(
<HistorySheet
open
onClose={() => {}}
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(<HistorySheet open onClose={() => {}} companyId={null} />);
render(
<HistorySheet
open
onClose={() => {}}
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(<HistorySheet open onClose={onClose} companyId="co-1" />);
render(
<HistorySheet
open
onClose={onClose}
companyId="co-1"
activeConversationId={null}
onSelectConversation={NOOP_SELECT}
/>,
);
const backdrop = container.querySelector(
'[data-testid="history-sheet-backdrop"]',
) as HTMLButtonElement;
@ -109,7 +152,15 @@ describe("<HistorySheet />", () => {
it("calls onClose when ESC is pressed", () => {
const onClose = vi.fn();
render(<HistorySheet open onClose={onClose} companyId="co-1" />);
render(
<HistorySheet
open
onClose={onClose}
companyId="co-1"
activeConversationId={null}
onSelectConversation={NOOP_SELECT}
/>,
);
act(() => {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
});
@ -118,7 +169,15 @@ describe("<HistorySheet />", () => {
it("does not listen for ESC when closed", () => {
const onClose = vi.fn();
render(<HistorySheet open={false} onClose={onClose} companyId="co-1" />);
render(
<HistorySheet
open={false}
onClose={onClose}
companyId="co-1"
activeConversationId={null}
onSelectConversation={NOOP_SELECT}
/>,
);
act(() => {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
});
@ -126,7 +185,15 @@ describe("<HistorySheet />", () => {
});
it("uses a left-side panel positioned against the icon rail", () => {
render(<HistorySheet open onClose={() => {}} companyId="co-1" />);
render(
<HistorySheet
open
onClose={() => {}}
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("<HistorySheet />", () => {
it("renders a full-screen variant on mobile (< 768px)", () => {
mediaMatches = false;
installMatchMedia();
render(<HistorySheet open onClose={() => {}} companyId="co-1" />);
render(
<HistorySheet
open
onClose={() => {}}
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");

View file

@ -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
</header>
<div className="flex-1 min-h-0 overflow-hidden">
{companyId ? (
<ChatConversationList companyId={companyId} />
<ChatConversationList
companyId={companyId}
activeConversationId={activeConversationId}
onSelectConversation={onSelectConversation}
/>
) : (
<p className="px-4 py-6 text-xs text-muted-foreground">
Select a workspace to view conversations.

View file

@ -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<string | null>(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}
/>
<MemorySheet
open={memoryOpen}