refactor(nexus): rewire PersonalAssistant to use new frame pieces (phase 9)
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) <noreply@anthropic.com>
This commit is contained in:
parent
d10c5b991e
commit
06c6317c25
4 changed files with 267 additions and 344 deletions
|
|
@ -52,7 +52,7 @@ describe("<MemorySheet />", () => {
|
|||
if (container.parentNode) container.remove();
|
||||
});
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
function render(node: JSX.Element) {
|
||||
root = createRoot(container);
|
||||
act(() => {
|
||||
root!.render(<QueryClientProvider client={queryClient}>{node}</QueryClientProvider>);
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ function makeProject(overrides: Partial<Project> = {}): 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),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<aside className="w-64 flex-shrink-0 border-r border-border bg-background flex flex-col h-full">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<span className="text-sm font-semibold text-foreground">Conversations</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onNew}
|
||||
disabled={isCreating}
|
||||
title="New conversation"
|
||||
>
|
||||
{isCreating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<nav className="flex-1 overflow-y-auto py-2">
|
||||
{conversations.length === 0 && (
|
||||
<p className="px-4 py-3 text-xs text-muted-foreground">No conversations yet. Start one below.</p>
|
||||
)}
|
||||
{conversations.map((conv) => (
|
||||
<button
|
||||
key={conv.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(conv.id)}
|
||||
className={[
|
||||
"w-full text-left px-4 py-2.5 text-sm transition-colors truncate",
|
||||
selectedId === conv.id
|
||||
? "bg-accent text-accent-foreground font-medium"
|
||||
: "text-foreground hover:bg-accent/50",
|
||||
].join(" ")}
|
||||
>
|
||||
{conv.title || "Untitled conversation"}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<div className={["flex gap-3 py-3", isUser ? "flex-row-reverse" : "flex-row"].join(" ")}>
|
||||
{!isUser && (
|
||||
<div className="flex-shrink-0 w-7 h-7 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Bot className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={[
|
||||
"max-w-[75%] rounded-lg px-3.5 py-2 text-sm leading-relaxed",
|
||||
isUser
|
||||
? "bg-primary text-primary-foreground rounded-br-sm"
|
||||
: "bg-muted text-foreground rounded-bl-sm",
|
||||
].join(" ")}
|
||||
>
|
||||
<p className="whitespace-pre-wrap">{content}</p>
|
||||
{isStreaming && (
|
||||
<span className="inline-block w-1 h-4 ml-0.5 bg-current animate-pulse align-middle" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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<string | null>(routeConvId ?? null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [streamingContent, setStreamingContent] = useState<string | null>(null);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [isHandingOff, setIsHandingOff] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(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<HTMLInputElement>(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<ChatConversationListResponse>({
|
||||
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<string | null>(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<HTMLTextAreaElement>) => {
|
||||
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 <Navigate to="/dashboard" replace />;
|
||||
return <Navigate to="/projects" replace />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex h-full min-h-0 -m-4 md:-m-6">
|
||||
{/* Conversation list */}
|
||||
<ConversationList
|
||||
conversations={conversations}
|
||||
selectedId={selectedConvId}
|
||||
onSelect={setSelectedConvId}
|
||||
onNew={handleNewConversation}
|
||||
isCreating={isCreating}
|
||||
<div
|
||||
className="relative flex h-full min-h-0 flex-col bg-background -m-4 md:-m-6"
|
||||
data-pathname={location.pathname}
|
||||
>
|
||||
{/* Conversation thread — full-bleed, max-width 760px centered */}
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
<div className="mx-auto w-full max-w-[760px] px-6 py-8">
|
||||
{showHomeGreeting && (
|
||||
<AssistantHomeGreeting
|
||||
status={homeStatus}
|
||||
userName={selectedCompany?.name ?? null}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasActiveConversation && selectedConvId && (
|
||||
<div className="h-[calc(100vh-320px)] min-h-[320px]">
|
||||
<ChatMessageList
|
||||
conversationId={selectedConvId}
|
||||
streamingContent={streamingContent}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!showHomeGreeting && !selectedConvId && conversationCount === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No conversations yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Anchored input + action strip */}
|
||||
<div className="border-t border-border bg-background">
|
||||
<div className="mx-auto w-full max-w-[760px] px-6 py-4">
|
||||
<AssistantInputBar
|
||||
onSend={handleSend}
|
||||
isSubmitting={isStreaming}
|
||||
enableVoiceInput
|
||||
/>
|
||||
<ActionStrip
|
||||
canPromote={canPromote}
|
||||
onPromote={handlePromote}
|
||||
onAttach={handleAttach}
|
||||
onOpenMemory={() => setMemoryOpen(true)}
|
||||
onOpenHistory={() => setHistoryOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const files = Array.from(e.target.files ?? []);
|
||||
if (files.length > 0) handleFilesPicked(files);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Chat area */}
|
||||
<div className="flex flex-col flex-1 min-w-0 h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-3 border-b border-border shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="h-5 w-5 text-primary" />
|
||||
<h1 className="text-base font-semibold text-foreground">Personal Assistant</h1>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
onClick={handleHandoff}
|
||||
disabled={!selectedConvId || isHandingOff}
|
||||
title="Turn this conversation into a project"
|
||||
>
|
||||
{isHandingOff ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
)}
|
||||
Turn into project
|
||||
</Button>
|
||||
</div>
|
||||
<HistorySheet
|
||||
open={historyOpen}
|
||||
onClose={() => setHistoryOpen(false)}
|
||||
companyId={companyId}
|
||||
/>
|
||||
<MemorySheet
|
||||
open={memoryOpen}
|
||||
onClose={() => setMemoryOpen(false)}
|
||||
companyId={companyId}
|
||||
/>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
{!selectedConvId && !convsLoading && (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3 text-center">
|
||||
<Bot className="h-10 w-10 text-muted-foreground/50" />
|
||||
<p className="text-sm text-muted-foreground max-w-xs">
|
||||
Start a conversation with your personal AI assistant. It remembers context across sessions.
|
||||
</p>
|
||||
<Button onClick={handleNewConversation} disabled={isCreating} size="sm">
|
||||
{isCreating ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Plus className="h-4 w-4 mr-2" />}
|
||||
New conversation
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedConvId && msgsLoading && (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedConvId && !msgsLoading && messages.length === 0 && streamingContent === null && (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-2 text-center">
|
||||
<p className="text-sm text-muted-foreground">Send a message to start this conversation.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg) => (
|
||||
<div key={msg.id}>
|
||||
<MessageBubble message={msg} />
|
||||
{msg.role === "assistant" && msg.content && (
|
||||
<div className="flex justify-start pl-10 -mt-1 mb-1">
|
||||
<TtsButton
|
||||
status={ttsStatus}
|
||||
progress={ttsProgress}
|
||||
onSpeak={() => speak(msg.content)}
|
||||
onStop={stop}
|
||||
onPrewarm={prewarm}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{streamingContent !== null && (
|
||||
<MessageBubble message={null} streamingContent={streamingContent} />
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input bar */}
|
||||
{selectedConvId && (
|
||||
<div className="px-6 py-4 border-t border-border shrink-0">
|
||||
<div className="flex gap-3 items-end">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Message your assistant… (Enter to send, Shift+Enter for newline)"
|
||||
rows={1}
|
||||
className="flex-1 resize-none rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring min-h-[40px] max-h-[160px]"
|
||||
style={{ height: "auto" }}
|
||||
onInput={(e) => {
|
||||
const el = e.currentTarget;
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${Math.min(el.scrollHeight, 160)}px`;
|
||||
}}
|
||||
/>
|
||||
<VoiceRecordButton
|
||||
onTranscription={(text) => setInputValue((prev) => prev ? prev + " " + text : text)}
|
||||
disabled={isSending}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={!inputValue.trim() || isSending}
|
||||
size="icon"
|
||||
aria-label="Send message"
|
||||
>
|
||||
{isSending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Placeholder to silence unused-var warnings for navigate while we
|
||||
defer the promote transition to Phase 12. */}
|
||||
<span className="hidden" aria-hidden="true" data-nav={typeof navigate}>
|
||||
{null}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue