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:
parent
fb76b5eeef
commit
1b6727bb1c
6 changed files with 134 additions and 27 deletions
|
|
@ -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("");
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue