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 && ( -
-
-