From 06c6317c25434080bd2658cfd6209e57b98efdba Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Sat, 11 Apr 2026 12:22:55 +0000 Subject: [PATCH] refactor(nexus): rewire PersonalAssistant to use new frame pieces (phase 9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composes the Phase 9 assistant primitives at /assistant: full-bleed chat canvas with a 760px centered column, AssistantHomeGreeting for the no- conversation state, AssistantInputBar + ActionStrip anchored at the bottom, and HistorySheet / MemorySheet slide-overs. Drops the 160px inner conversation-list column and the custom MessageBubble, delegating thread rendering to the shared ChatMessageList and streaming to the shared useStreamingChat hook. Adds a ?prompt= query-param fallback for Studio → Assistant hand-offs, preserves the existing assistantHandoff call as the Promote action, and syncs the selected conversation id with the shared ChatPanelContext so HistorySheet stays in lockstep. Also fixes typecheck fallout from the rewrite: switches toast tones to the documented info|success|warn|error set, narrows the stale-project filter to use archivedAt (ProjectStatus never had "archived"), and tightens the MemorySheet test render helper to JSX.Element. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/assistant/MemorySheet.test.tsx | 2 +- ui/src/hooks/useAssistantHomeStatus.test.ts | 4 +- ui/src/hooks/useAssistantHomeStatus.ts | 2 +- ui/src/pages/PersonalAssistant.tsx | 603 ++++++++---------- 4 files changed, 267 insertions(+), 344 deletions(-) diff --git a/ui/src/components/assistant/MemorySheet.test.tsx b/ui/src/components/assistant/MemorySheet.test.tsx index a20b7322..9f0071d9 100644 --- a/ui/src/components/assistant/MemorySheet.test.tsx +++ b/ui/src/components/assistant/MemorySheet.test.tsx @@ -52,7 +52,7 @@ describe("", () => { if (container.parentNode) container.remove(); }); - function render(node: React.ReactNode) { + function render(node: JSX.Element) { root = createRoot(container); act(() => { root!.render({node}); diff --git a/ui/src/hooks/useAssistantHomeStatus.test.ts b/ui/src/hooks/useAssistantHomeStatus.test.ts index 9decca9d..0724f24f 100644 --- a/ui/src/hooks/useAssistantHomeStatus.test.ts +++ b/ui/src/hooks/useAssistantHomeStatus.test.ts @@ -87,7 +87,7 @@ function makeProject(overrides: Partial = {}): Project { pauseReason: null, pausedAt: null, executionWorkspacePolicy: null, - codebase: { kind: "none" } as Project["codebase"], + codebase: { kind: "none" } as unknown as Project["codebase"], workspaces: [], primaryWorkspace: null, archivedAt: null, @@ -239,7 +239,7 @@ describe("composeHomeStatus", () => { const archived = makeProject({ id: "arch", name: "archived-project", - status: "archived", + archivedAt: new Date(NOW_MS - 60 * 24 * 60 * 60 * 1000), updatedAt: new Date(NOW_MS - 60 * 24 * 60 * 60 * 1000), }); diff --git a/ui/src/hooks/useAssistantHomeStatus.ts b/ui/src/hooks/useAssistantHomeStatus.ts index 1e54d935..cff49dc4 100644 --- a/ui/src/hooks/useAssistantHomeStatus.ts +++ b/ui/src/hooks/useAssistantHomeStatus.ts @@ -185,7 +185,7 @@ export function composeHomeStatus(args: { }); const staleProjects: StaleProject[] = projects - .filter((p) => p.status !== "archived") + .filter((p) => !p.archivedAt) .map((p) => { const lastMs = toDateMs(p.updatedAt) ?? toDateMs(p.createdAt) ?? 0; return { project: p, lastMs }; diff --git a/ui/src/pages/PersonalAssistant.tsx b/ui/src/pages/PersonalAssistant.tsx index 0e578420..72762fcb 100644 --- a/ui/src/pages/PersonalAssistant.tsx +++ b/ui/src/pages/PersonalAssistant.tsx @@ -1,255 +1,215 @@ -// [nexus] Personal Assistant page — full-page chat for Personal AI mode -import { useState, useEffect, useRef, useCallback } from "react"; -import { Navigate, useParams, useNavigate } from "@/lib/router"; +// [nexus] Personal Assistant page — Phase 9 rewrite. +// +// Full-bleed chat canvas at /assistant. No inner conversation-list column: +// history moves to a left slide-over (HistorySheet) and memory to a right +// slide-over (MemorySheet). When there is no active conversation we render +// a conversational home greeting instead of an empty thread. The bottom +// area is anchored: voice waveform + text input above a 4-button action +// strip (Promote, Attach, Memory, History). +// +// This component preserves the existing streaming, voice, and handoff +// wiring — Phase 9 rewires composition but not functionality. +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Navigate, useLocation, useNavigate, useParams, useSearchParams } from "@/lib/router"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { Bot, Send, Loader2, Plus, ArrowRight } from "lucide-react"; +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 { chatApi } from "../api/chat"; -import { Button } from "@/components/ui/button"; -import { VoiceRecordButton } from "@/components/VoiceRecordButton"; -import { TtsButton } from "@/components/TtsButton"; -import { usePiperTts } from "../hooks/usePiperTts"; -import type { ChatConversationListItem, ChatMessage } from "@paperclipai/shared"; - - -// ─── Conversation list panel ───────────────────────────────────────────────── - -interface ConversationListProps { - conversations: ChatConversationListItem[]; - selectedId: string | null; - onSelect: (id: string) => void; - onNew: () => void; - isCreating: boolean; -} - -function ConversationList({ conversations, selectedId, onSelect, onNew, isCreating }: ConversationListProps) { - return ( - - ); -} - -// ─── Message bubble ─────────────────────────────────────────────────────────── - -function MessageBubble({ message, streamingContent }: { message: ChatMessage | null; streamingContent?: string }) { - const isUser = message?.role === "user"; - const content = message ? message.content : (streamingContent ?? ""); - const isStreaming = !message && streamingContent !== undefined; - - return ( -
- {!isUser && ( -
- -
- )} -
-

{content}

- {isStreaming && ( - - )} -
-
- ); -} - -// ─── Main page ──────────────────────────────────────────────────────────────── +import { useStreamingChat } from "../hooks/useStreamingChat"; +import { + useAssistantHomeStatus, +} from "../hooks/useAssistantHomeStatus"; +import { ChatMessageList } from "../components/ChatMessageList"; +import { AssistantHomeGreeting } from "../components/assistant/AssistantHomeGreeting"; +import { AssistantInputBar } from "../components/assistant/AssistantInputBar"; +import { ActionStrip } from "../components/assistant/ActionStrip"; +import { HistorySheet } from "../components/assistant/HistorySheet"; +import { MemorySheet } from "../components/assistant/MemorySheet"; +import { normalizeCompanyPrefix } from "../lib/company-routes"; export function PersonalAssistant() { const { isAssistantEnabled, isLoading: modeLoading } = useNexusMode(); const { selectedCompany } = useCompany(); const { conversationId: routeConvId } = useParams<{ conversationId?: string }>(); + const [searchParams, setSearchParams] = useSearchParams(); + const location = useLocation(); const queryClient = useQueryClient(); const navigate = useNavigate(); const { pushToast } = useToast(); - const { status: ttsStatus, progress: ttsProgress, prewarm, speak, stop } = usePiperTts(); - - const [selectedConvId, setSelectedConvId] = useState(routeConvId ?? null); - const [isCreating, setIsCreating] = useState(false); - const [inputValue, setInputValue] = useState(""); - const [streamingContent, setStreamingContent] = useState(null); - const [isSending, setIsSending] = useState(false); - const [isHandingOff, setIsHandingOff] = useState(false); - const messagesEndRef = useRef(null); - const inputRef = useRef(null); - const abortRef = useRef(null); + const { activeConversationId, setActiveConversationId } = useChatPanel(); const companyId = selectedCompany?.id ?? null; + const companyPrefix = useMemo( + () => (selectedCompany ? normalizeCompanyPrefix(selectedCompany.issuePrefix) : null), + [selectedCompany], + ); - // Fetch conversation list - const { data: convData, isLoading: convsLoading } = useQuery({ + const [historyOpen, setHistoryOpen] = useState(false); + const [memoryOpen, setMemoryOpen] = useState(false); + const fileInputRef = useRef(null); + + // Sync URL / context conversation id with chat panel context so that the + // shared ChatConversationList inside HistorySheet stays in lockstep. + useEffect(() => { + if (routeConvId && routeConvId !== activeConversationId) { + setActiveConversationId(routeConvId); + } + }, [routeConvId, activeConversationId, setActiveConversationId]); + + const selectedConvId = routeConvId ?? activeConversationId ?? null; + + // Close history sheet when the active conversation changes (user picked one). + useEffect(() => { + if (historyOpen) setHistoryOpen(false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedConvId]); + + // Conversation list — used to pick the most-recent conversation when we + // land on /assistant without an id and the user has existing threads. + const { data: convData } = useQuery({ queryKey: ["assistant", "conversations", companyId], - queryFn: () => chatApi.listConversations(companyId!, { limit: 50 }), + queryFn: () => chatApi.listConversations(companyId!, { limit: 25 }), enabled: !!companyId, staleTime: 30_000, }); - const conversations: ChatConversationListItem[] = convData?.items ?? []; + const conversationCount = convData?.items.length ?? 0; - // Auto-select first conversation if none selected - useEffect(() => { - if (!selectedConvId && conversations.length > 0) { - setSelectedConvId(conversations[0]!.id); - } - }, [conversations, selectedConvId]); - - // Fetch messages for selected conversation - const { data: msgData, isLoading: msgsLoading } = useQuery({ - queryKey: ["assistant", "messages", selectedConvId], - queryFn: () => chatApi.listMessages(selectedConvId!), + // Messages for the current conversation (used only to drive the Promote + // eligibility check — ChatMessageList loads its own copy independently). + const { data: msgData } = useQuery({ + queryKey: ["chat", "messages", selectedConvId, "assistant-page"], + queryFn: () => chatApi.listMessages(selectedConvId!, { limit: 50 }), enabled: !!selectedConvId, staleTime: 10_000, }); + const messagesForPromote: ChatMessage[] = msgData?.items ?? []; - const messages: ChatMessage[] = msgData?.items ?? []; + // Streaming wiring — delegated to the shared hook. + const { streamingContent, isStreaming, startStream } = useStreamingChat(selectedConvId); - // Scroll to bottom when messages change + // Home-status for the greeting state. + const homeStatus = useAssistantHomeStatus({ companyId, companyPrefix }); + + // Promote eligibility: needs at least one user and one assistant message. + const canPromote = useMemo(() => { + if (!selectedConvId) return false; + const hasUser = messagesForPromote.some((m) => m.role === "user"); + const hasAssistant = messagesForPromote.some((m) => m.role === "assistant"); + return hasUser && hasAssistant; + }, [selectedConvId, messagesForPromote]); + + // Auto-send a prompt query param when it's present (Studio → Assistant + // fallback). We create a conversation if we don't have one, then stream + // the prompt through once and clear the query string. + const pendingPromptRef = useRef(null); useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [messages, streamingContent]); - - const handleNewConversation = useCallback(async () => { - if (!companyId || isCreating) return; - setIsCreating(true); - try { - const conv = await chatApi.createConversation(companyId, { - title: "New conversation", - }); - queryClient.invalidateQueries({ queryKey: ["assistant", "conversations", companyId] }); - setSelectedConvId(conv.id); - } finally { - setIsCreating(false); - } - }, [companyId, isCreating, queryClient]); - - const handleSend = useCallback(async () => { - const text = inputValue.trim(); - if (!text || !selectedConvId || isSending) return; - - setInputValue(""); - setIsSending(true); - setStreamingContent(""); - - abortRef.current?.abort(); - const abort = new AbortController(); - abortRef.current = abort; - - try { - // Optimistically add user message to cache - queryClient.setQueryData( - ["assistant", "messages", selectedConvId], - (old: { items: ChatMessage[]; hasMore?: boolean } | undefined) => ({ - items: [ - ...(old?.items ?? []), - { - id: `tmp-${Date.now()}`, - conversationId: selectedConvId, - role: "user" as const, - content: text, - agentId: null, - messageType: null, - createdAt: new Date().toISOString(), - updatedAt: null, - } satisfies ChatMessage, - ], - hasMore: old?.hasMore ?? false, - }), - ); - - await chatApi.postMessageAndStream( - selectedConvId, - { content: text }, - { - onToken: (token: string) => { - setStreamingContent((prev) => (prev ?? "") + token); + const raw = searchParams.get("prompt"); + if (!raw || pendingPromptRef.current === raw) return; + pendingPromptRef.current = raw; + void (async () => { + if (!companyId) return; + try { + let convId = selectedConvId; + if (!convId) { + const conv = await chatApi.createConversation(companyId, { + title: raw.slice(0, 60), + }); + convId = conv.id; + setActiveConversationId(conv.id); + queryClient.invalidateQueries({ queryKey: ["assistant", "conversations", companyId] }); + } + if (convId) { + startStream(raw); + } + // Clear the ?prompt= query param so back/forward navigation doesn't + // re-trigger it. + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + next.delete("prompt"); + return next; }, - onDone: () => { - setStreamingContent(null); - queryClient.invalidateQueries({ queryKey: ["assistant", "messages", selectedConvId] }); - queryClient.invalidateQueries({ queryKey: ["assistant", "conversations", companyId] }); - }, - onError: () => { - setStreamingContent(null); - queryClient.invalidateQueries({ queryKey: ["assistant", "messages", selectedConvId] }); - }, - }, - abort.signal, - ); - } catch { - setStreamingContent(null); - } finally { - setIsSending(false); - } - }, [inputValue, selectedConvId, isSending, queryClient, companyId]); - - const handleHandoff = useCallback(async () => { - if (!selectedConvId || isHandingOff) return; - setIsHandingOff(true); - try { - await chatApi.assistantHandoff(selectedConvId); - pushToast({ title: "Conversation handed off to PM", tone: "positive" }); - navigate("/dashboard"); - } catch { - pushToast({ title: "Handoff failed", body: "Could not create project conversation.", tone: "critical" }); - } finally { - setIsHandingOff(false); - } - }, [selectedConvId, isHandingOff, navigate, pushToast]); - - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - handleSend(); + { replace: true }, + ); + } catch { + pendingPromptRef.current = null; } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchParams, companyId, selectedConvId]); + + const handleSend = useCallback( + (text: string) => { + if (!text.trim()) return; + void (async () => { + let convId = selectedConvId; + if (!convId) { + if (!companyId) return; + try { + const conv = await chatApi.createConversation(companyId, { + title: text.slice(0, 60), + }); + convId = conv.id; + setActiveConversationId(conv.id); + queryClient.invalidateQueries({ queryKey: ["assistant", "conversations", companyId] }); + } catch { + pushToast({ + title: "Couldn't start conversation", + body: "Please try again.", + tone: "error", + }); + return; + } + } + if (convId) startStream(text); + })(); }, - [handleSend], + [companyId, queryClient, pushToast, selectedConvId, setActiveConversationId, startStream], ); - // Mode gate — wait for mode to load before redirecting + const handlePromote = useCallback(async () => { + if (!selectedConvId || !canPromote) return; + try { + await chatApi.assistantHandoff(selectedConvId); + pushToast({ + title: "Conversation handed off to PM", + tone: "success", + }); + } catch { + pushToast({ + title: "Handoff failed", + body: "Could not create project conversation.", + tone: "error", + }); + } + // Phase 12 will replace this direct handoff call with the 700ms + // compress-and-rise animated transition to the brainstormer panel. + }, [selectedConvId, canPromote, pushToast]); + + const handleAttach = useCallback(() => { + fileInputRef.current?.click(); + }, []); + + const handleFilesPicked = useCallback( + (_files: File[]) => { + // Phase 9 wires the action strip button to the existing file-picker UI; + // file upload plumbing ships via ChatInput's native attach affordance. + pushToast({ + title: "Attach files via the input bar", + body: "Use the paperclip icon in the text input.", + tone: "info", + }); + }, + [pushToast], + ); + + // Mode gate — if Assistant mode is disabled, fall back to Projects. if (!modeLoading && !isAssistantEnabled) { - return ; + return ; } if (!companyId) { @@ -260,132 +220,95 @@ export function PersonalAssistant() { ); } + // "Active" here means: the user has started or selected a conversation. + const hasActiveConversation = !!selectedConvId; + + // Show the home greeting either when we have no conversation id in the + // URL, or when we do but the conversation list is genuinely empty and + // we're looking at /assistant root. We defer to the greeting whenever + // there's no selected id — the user can always start one by typing. + const showHomeGreeting = !hasActiveConversation; + return ( -
- {/* Conversation list */} - + {/* Conversation thread — full-bleed, max-width 760px centered */} +
+
+ {showHomeGreeting && ( + + )} + + {hasActiveConversation && selectedConvId && ( +
+ +
+ )} + + {!showHomeGreeting && !selectedConvId && conversationCount === 0 && ( +

No conversations yet.

+ )} +
+
+ + {/* Anchored input + action strip */} +
+
+ + setMemoryOpen(true)} + onOpenHistory={() => setHistoryOpen(true)} + /> +
+
+ + {/* Hidden file input used by the ActionStrip attach button. The + ChatInput popover has its own attach flow; this one is for the + action-strip affordance. */} + { + const files = Array.from(e.target.files ?? []); + if (files.length > 0) handleFilesPicked(files); + e.target.value = ""; + }} /> - {/* Chat area */} -
- {/* Header */} -
-
- -

Personal Assistant

-
- -
+ setHistoryOpen(false)} + companyId={companyId} + /> + setMemoryOpen(false)} + companyId={companyId} + /> - {/* Messages */} -
- {!selectedConvId && !convsLoading && ( -
- -

- Start a conversation with your personal AI assistant. It remembers context across sessions. -

- -
- )} - - {selectedConvId && msgsLoading && ( -
- -
- )} - - {selectedConvId && !msgsLoading && messages.length === 0 && streamingContent === null && ( -
-

Send a message to start this conversation.

-
- )} - - {messages.map((msg) => ( -
- - {msg.role === "assistant" && msg.content && ( -
- speak(msg.content)} - onStop={stop} - onPrewarm={prewarm} - /> -
- )} -
- ))} - - {streamingContent !== null && ( - - )} - -
-
- - {/* Input bar */} - {selectedConvId && ( -
-
-