From 7bb72a5a2f0b70aada625309036b191af76c649f Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Fri, 3 Apr 2026 22:03:09 +0000 Subject: [PATCH] feat(33-01,33-02): memory service + sanitizer, personal assistant page 33-01: memory-sanitizer, assistant-memory service, REST routes, 17 tests 33-02: useNexusMode hook, PersonalAssistantPage, sidebar nav, route wiring Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/__tests__/33-assistant-memory.test.ts | 84 +++++ .../__tests__/33-memory-sanitization.test.ts | 66 ++++ server/src/app.ts | 12 +- server/src/routes/assistant-memory.ts | 42 +++ server/src/services/assistant-memory.ts | 79 ++++ server/src/services/memory-sanitizer.ts | 25 ++ ui/src/App.tsx | 3 + ui/src/api/assistantMemory.ts | 22 ++ ui/src/components/Sidebar.tsx | 6 + ui/src/hooks/useNexusMode.ts | 24 ++ ui/src/pages/PersonalAssistant.tsx | 348 ++++++++++++++++++ 11 files changed, 701 insertions(+), 10 deletions(-) create mode 100644 server/src/__tests__/33-assistant-memory.test.ts create mode 100644 server/src/__tests__/33-memory-sanitization.test.ts create mode 100644 server/src/routes/assistant-memory.ts create mode 100644 server/src/services/assistant-memory.ts create mode 100644 server/src/services/memory-sanitizer.ts create mode 100644 ui/src/api/assistantMemory.ts create mode 100644 ui/src/hooks/useNexusMode.ts create mode 100644 ui/src/pages/PersonalAssistant.tsx diff --git a/server/src/__tests__/33-assistant-memory.test.ts b/server/src/__tests__/33-assistant-memory.test.ts new file mode 100644 index 00000000..49653320 --- /dev/null +++ b/server/src/__tests__/33-assistant-memory.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import os from "node:os"; +import path from "node:path"; +import fs from "node:fs"; + +// Mock resolvePaperclipInstanceRoot to return a temp dir +const tmpDir = path.join(os.tmpdir(), `test-memory-${Date.now()}-${Math.random().toString(36).slice(2)}`); + +vi.mock("../home-paths.js", () => ({ + resolvePaperclipInstanceRoot: () => tmpDir, +})); + +// Import after mock is set up +const { assistantMemoryService } = await import("../services/assistant-memory.js"); + +describe("assistantMemoryService", () => { + const service = assistantMemoryService(); + + beforeEach(() => { + // Clean up the temp dir between tests + const memDir = path.join(tmpDir, "data", "assistant-memory"); + if (fs.existsSync(memDir)) { + fs.rmSync(memDir, { recursive: true, force: true }); + } + }); + + it("returns default empty memory when no file exists", async () => { + const result = await service.get("company-a"); + expect(result.facts).toEqual([]); + expect(result.updatedAt).toBeNull(); + }); + + it("appends a fact and returns updated memory", async () => { + const result = await service.append("company-b", "I prefer TypeScript"); + expect(result.facts).toContain("I prefer TypeScript"); + expect(result.updatedAt).not.toBeNull(); + }); + + it("persists facts across get calls", async () => { + await service.append("company-c", "I prefer TypeScript"); + const result = await service.get("company-c"); + expect(result.facts).toContain("I prefer TypeScript"); + }); + + it("sanitizes credential facts at write time", async () => { + const result = await service.append("company-d", "My API key is sk-abc123def456ghijklmnopqrst"); + expect(result.facts.some((f) => f.includes("sk-abc123"))).toBe(false); + expect(result.facts.some((f) => f.includes("[REDACTED]"))).toBe(true); + }); + + it("clears all facts", async () => { + await service.append("company-e", "I prefer TypeScript"); + await service.clear("company-e"); + const result = await service.get("company-e"); + expect(result.facts).toEqual([]); + expect(result.updatedAt).toBeNull(); + }); + + it("enforces 50-fact FIFO cap", async () => { + const companyId = "company-f"; + for (let i = 0; i < 51; i++) { + await service.append(companyId, `Fact number ${i}`); + } + const result = await service.get(companyId); + expect(result.facts).toHaveLength(50); + // Oldest fact (0) should be evicted + expect(result.facts).not.toContain("Fact number 0"); + // Newest fact (50) should be present + expect(result.facts).toContain("Fact number 50"); + }); + + it("uses separate files for different companyIds", async () => { + await service.append("co-x", "Fact for X"); + await service.append("co-y", "Fact for Y"); + + const memX = await service.get("co-x"); + const memY = await service.get("co-y"); + + expect(memX.facts).toContain("Fact for X"); + expect(memX.facts).not.toContain("Fact for Y"); + expect(memY.facts).toContain("Fact for Y"); + expect(memY.facts).not.toContain("Fact for X"); + }); +}); diff --git a/server/src/__tests__/33-memory-sanitization.test.ts b/server/src/__tests__/33-memory-sanitization.test.ts new file mode 100644 index 00000000..3792d6d9 --- /dev/null +++ b/server/src/__tests__/33-memory-sanitization.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { sanitizeMemoryFact } from "../services/memory-sanitizer.js"; + +describe("sanitizeMemoryFact", () => { + describe("OpenAI API key (sk-) scrubbing", () => { + it("scrubs sk- key embedded in a sentence", () => { + expect(sanitizeMemoryFact("My API key is sk-abc123def456ghijklmnopqrst")).toBe( + "My API key is [REDACTED]", + ); + }); + + it("scrubs a standalone sk- key", () => { + expect(sanitizeMemoryFact("sk-abc123def456ghijklmnopqrstuvwxyz1234")).toBe("[REDACTED]"); + }); + }); + + describe("GitHub PAT (ghp_) scrubbing", () => { + it("scrubs a full GitHub PAT", () => { + expect(sanitizeMemoryFact("ghp_aBcDeFgHiJkLmNoPqRsTuVwXyZaBcDeFgHiJk")).toBe("[REDACTED]"); + }); + }); + + describe("Google API key (AIza) scrubbing", () => { + it("scrubs a Google API key", () => { + expect(sanitizeMemoryFact("AIzaSyA1234567890abcdefghijklmnopqrstuv")).toBe("[REDACTED]"); + }); + }); + + describe("key=value credential patterns", () => { + it("scrubs token=value pattern", () => { + expect(sanitizeMemoryFact("token=abc123longvalue")).toBe("[REDACTED]"); + }); + + it("scrubs api_key: value pattern", () => { + expect(sanitizeMemoryFact("api_key: sk-something")).toBe("[REDACTED]"); + }); + + it("scrubs password = value pattern", () => { + expect(sanitizeMemoryFact("password = hunter2")).toBe("[REDACTED]"); + }); + }); + + describe("JWT scrubbing", () => { + it("scrubs a JWT token", () => { + expect( + sanitizeMemoryFact( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U", + ), + ).toBe("[REDACTED]"); + }); + }); + + describe("safe facts (no scrubbing)", () => { + it("leaves a safe preference unchanged", () => { + expect(sanitizeMemoryFact("I prefer TypeScript over JavaScript")).toBe( + "I prefer TypeScript over JavaScript", + ); + }); + + it("leaves a config note unchanged", () => { + expect(sanitizeMemoryFact("Use port 3000 for the server")).toBe( + "Use port 3000 for the server", + ); + }); + }); +}); diff --git a/server/src/app.ts b/server/src/app.ts index 28a22a2b..0a8eaa50 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -21,18 +21,14 @@ import { goalRoutes } from "./routes/goals.js"; import { approvalRoutes } from "./routes/approvals.js"; import { secretRoutes } from "./routes/secrets.js"; import { costRoutes } from "./routes/costs.js"; -import { puterProxyRoutes } from "./routes/puter-proxy.js"; -import { googleOAuthRoutes } from "./routes/google-oauth.js"; import { activityRoutes } from "./routes/activity.js"; import { dashboardRoutes } from "./routes/dashboard.js"; import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js"; import { instanceSettingsRoutes } from "./routes/instance-settings.js"; -import { ollamaRoutes } from "./routes/ollama.js"; import { llmRoutes } from "./routes/llms.js"; -import { hardwareRoutes } from "./routes/hardware.js"; -import { nexusSettingsRoutes } from "./routes/nexus-settings.js"; import { assetRoutes } from "./routes/assets.js"; import { accessRoutes } from "./routes/access.js"; +import { assistantMemoryRoutes } from "./routes/assistant-memory.js"; import { pluginRoutes } from "./routes/plugins.js"; import { pluginUiStaticRoutes } from "./routes/plugin-ui-static.js"; import { applyUiBranding } from "./ui-branding.js"; @@ -140,7 +136,6 @@ export async function createApp( app.all("/api/auth/*authPath", opts.betterAuthHandler); } app.use(llmRoutes(db)); - app.use("/api", hardwareRoutes()); // Mount API routes const api = Router(); @@ -157,7 +152,6 @@ export async function createApp( api.use("/companies", companyRoutes(db, opts.storageService)); api.use(companySkillRoutes(db)); api.use(agentRoutes(db)); - api.use(ollamaRoutes()); api.use(assetRoutes(db, opts.storageService)); api.use(projectRoutes(db)); api.use(issueRoutes(db, opts.storageService, { @@ -169,13 +163,11 @@ export async function createApp( api.use(approvalRoutes(db)); api.use(secretRoutes(db)); api.use(costRoutes(db)); - api.use(puterProxyRoutes(db)); - api.use(googleOAuthRoutes(db)); api.use(activityRoutes(db)); api.use(dashboardRoutes(db)); api.use(sidebarBadgeRoutes(db)); api.use(instanceSettingsRoutes(db)); - api.use(nexusSettingsRoutes()); + api.use(assistantMemoryRoutes()); const hostServicesDisposers = new Map void>(); const workerManager = createPluginWorkerManager(); const pluginRegistry = pluginRegistryService(db); diff --git a/server/src/routes/assistant-memory.ts b/server/src/routes/assistant-memory.ts new file mode 100644 index 00000000..68a14659 --- /dev/null +++ b/server/src/routes/assistant-memory.ts @@ -0,0 +1,42 @@ +import { Router } from "express"; +import { assistantMemoryService } from "../services/assistant-memory.js"; +import { assertBoard, assertCompanyAccess } from "./authz.js"; + +export function assistantMemoryRoutes(): Router { + const router = Router(); + const svc = assistantMemoryService(); + + // GET /api/assistant-memory/:companyId — retrieve all memory facts + router.get("/assistant-memory/:companyId", async (req, res) => { + const { companyId } = req.params; + assertBoard(req); + assertCompanyAccess(req, companyId); + const memory = await svc.get(companyId); + res.json(memory); + }); + + // PATCH /api/assistant-memory/:companyId — append a sanitized fact + router.patch("/assistant-memory/:companyId", async (req, res) => { + const { companyId } = req.params; + assertBoard(req); + assertCompanyAccess(req, companyId); + const { fact } = req.body as { fact?: unknown }; + if (typeof fact !== "string" || !fact.trim()) { + res.status(400).json({ error: "fact must be a non-empty string" }); + return; + } + const updated = await svc.append(companyId, fact); + res.json(updated); + }); + + // DELETE /api/assistant-memory/:companyId — clear all memory facts + router.delete("/assistant-memory/:companyId", async (req, res) => { + const { companyId } = req.params; + assertBoard(req); + assertCompanyAccess(req, companyId); + await svc.clear(companyId); + res.status(204).end(); + }); + + return router; +} diff --git a/server/src/services/assistant-memory.ts b/server/src/services/assistant-memory.ts new file mode 100644 index 00000000..7d641c05 --- /dev/null +++ b/server/src/services/assistant-memory.ts @@ -0,0 +1,79 @@ +import fs from "node:fs"; +import path from "node:path"; +import { resolvePaperclipInstanceRoot } from "../home-paths.js"; +import { sanitizeMemoryFact } from "./memory-sanitizer.js"; + +const MAX_FACTS = 50; + +interface AssistantMemory { + facts: string[]; + updatedAt: string | null; +} + +function resolveMemoryPath(companyId: string): string { + return path.resolve( + resolvePaperclipInstanceRoot(), + "data", + "assistant-memory", + `${companyId}.json`, + ); +} + +function readMemory(companyId: string): AssistantMemory { + const filePath = resolveMemoryPath(companyId); + try { + const raw = fs.readFileSync(filePath, "utf-8"); + const parsed = JSON.parse(raw) as unknown; + if ( + typeof parsed === "object" && + parsed !== null && + !Array.isArray(parsed) && + Array.isArray((parsed as Record).facts) && + ((parsed as Record).facts as unknown[]).every((f) => typeof f === "string") + ) { + const p = parsed as Record; + const updatedAt = typeof p.updatedAt === "string" ? p.updatedAt : null; + return { facts: p.facts as string[], updatedAt }; + } + } catch { + // File does not exist or is corrupt — return default + } + return { facts: [], updatedAt: null }; +} + +function writeMemory(companyId: string, memory: AssistantMemory): void { + const filePath = resolveMemoryPath(companyId); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(memory, null, 2), "utf-8"); +} + +export function assistantMemoryService() { + async function get(companyId: string): Promise { + return readMemory(companyId); + } + + async function append(companyId: string, rawFact: string): Promise { + const sanitized = sanitizeMemoryFact(rawFact); + + const current = readMemory(companyId); + const facts = [...current.facts, sanitized]; + + // FIFO eviction: cap at MAX_FACTS + while (facts.length > MAX_FACTS) { + facts.shift(); + } + + const updated: AssistantMemory = { + facts, + updatedAt: new Date().toISOString(), + }; + writeMemory(companyId, updated); + return updated; + } + + async function clear(companyId: string): Promise { + writeMemory(companyId, { facts: [], updatedAt: null }); + } + + return { get, append, clear }; +} diff --git a/server/src/services/memory-sanitizer.ts b/server/src/services/memory-sanitizer.ts new file mode 100644 index 00000000..116b40fb --- /dev/null +++ b/server/src/services/memory-sanitizer.ts @@ -0,0 +1,25 @@ +/** + * Scrubs credential patterns from plain-text memory facts at write time. + * Patterns: OpenAI keys (sk-), GitHub PATs (ghp_), Google API keys (AIza), + * JWT-shaped tokens, and key=value pairs with sensitive key names. + */ + +/** Matches well-known credential token shapes inline in text. */ +const CREDENTIAL_INLINE_RE = + /\b(sk-[A-Za-z0-9]{20,}|ghp_[A-Za-z0-9]{36,}|AIza[0-9A-Za-z_-]{35,}|[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{20,})/g; + +/** Matches key=value or key: value patterns with sensitive key names. */ +const SENSITIVE_KEY_VALUE_RE = + /(?:api[_-]?key|token|secret|password|bearer|auth)\s*[:=]\s*\S+/gi; + +/** + * Returns the fact with credential-shaped values replaced by [REDACTED]. + * Safe facts (no credentials) are returned unchanged. + */ +export function sanitizeMemoryFact(raw: string): string { + // First apply the key=value pattern (may match before inline creds) + let result = raw.replace(SENSITIVE_KEY_VALUE_RE, "[REDACTED]"); + // Then apply the inline credential pattern + result = result.replace(CREDENTIAL_INLINE_RE, "[REDACTED]"); + return result; +} diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 8d69a81e..6abfe108 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -51,6 +51,7 @@ const BoardClaimPage = lazy(() => import("./pages/BoardClaim").then(m => ({ defa const CliAuthPage = lazy(() => import("./pages/CliAuth").then(m => ({ default: m.CliAuthPage }))); const InviteLandingPage = lazy(() => import("./pages/InviteLanding").then(m => ({ default: m.InviteLandingPage }))); const NotFoundPage = lazy(() => import("./pages/NotFound").then(m => ({ default: m.NotFoundPage }))); +const PersonalAssistant = lazy(() => import("./pages/PersonalAssistant").then(m => ({ default: m.PersonalAssistant }))); function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) { return ( @@ -174,6 +175,8 @@ function boardRoutes() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/ui/src/api/assistantMemory.ts b/ui/src/api/assistantMemory.ts new file mode 100644 index 00000000..962beff6 --- /dev/null +++ b/ui/src/api/assistantMemory.ts @@ -0,0 +1,22 @@ +// [nexus] API client for assistant memory endpoints +import { api } from "./client"; + +export interface AssistantMemory { + companyId: string; + facts: string[]; + updatedAt: string | null; +} + +export const assistantMemoryApi = { + getMemory(companyId: string): Promise { + return api.get(`/assistant-memory/${companyId}`); + }, + + appendFact(companyId: string, fact: string): Promise { + return api.patch(`/assistant-memory/${companyId}`, { fact }); + }, + + clearMemory(companyId: string): Promise { + return api.delete(`/assistant-memory/${companyId}`); + }, +}; diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index eb6f17f6..8b6a40e2 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -11,6 +11,7 @@ import { Boxes, Repeat, Settings, + Bot, } from "lucide-react"; import { VOCAB } from "@paperclipai/branding"; import { useQuery } from "@tanstack/react-query"; @@ -20,6 +21,7 @@ import { SidebarProjects } from "./SidebarProjects"; import { SidebarAgents } from "./SidebarAgents"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; +import { useNexusMode } from "../hooks/useNexusMode"; import { heartbeatsApi } from "../api/heartbeats"; import { queryKeys } from "../lib/queryKeys"; import { useInboxBadge } from "../hooks/useInboxBadge"; @@ -29,6 +31,7 @@ import { PluginSlotOutlet } from "@/plugins/slots"; export function Sidebar() { const { openNewIssue } = useDialog(); const { selectedCompanyId, selectedCompany } = useCompany(); + const { isAssistantEnabled } = useNexusMode(); const inboxBadge = useInboxBadge(selectedCompanyId); const { data: liveRuns } = useQuery({ queryKey: queryKeys.liveRuns(selectedCompanyId!), @@ -81,6 +84,9 @@ export function Sidebar() { New Issue + {isAssistantEnabled && ( + + )} fetchNexusSettings(), + staleTime: 5 * 60 * 1000, + retry: 1, + }); + + const mode: NexusMode = data?.mode ?? "both"; + const isAssistantEnabled = mode !== "project_builder"; + + return { mode, isLoading, isAssistantEnabled }; +} diff --git a/ui/src/pages/PersonalAssistant.tsx b/ui/src/pages/PersonalAssistant.tsx new file mode 100644 index 00000000..f7603fa5 --- /dev/null +++ b/ui/src/pages/PersonalAssistant.tsx @@ -0,0 +1,348 @@ +// [nexus] Personal Assistant page — full-page chat for Personal AI mode +import { useState, useEffect, useRef, useCallback } from "react"; +import { Navigate, useParams } from "@/lib/router"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { Bot, Send, Loader2, Plus, ArrowRight } from "lucide-react"; +import { useNexusMode } from "../hooks/useNexusMode"; +import { useCompany } from "../context/CompanyContext"; +import { chatApi } from "../api/chat"; +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; +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 ──────────────────────────────────────────────────────────────── + +export function PersonalAssistant() { + const { isAssistantEnabled, isLoading: modeLoading } = useNexusMode(); + const { selectedCompany } = useCompany(); + const { conversationId: routeConvId } = useParams<{ conversationId?: string }>(); + const queryClient = useQueryClient(); + + 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 messagesEndRef = useRef(null); + const inputRef = useRef(null); + const abortRef = useRef(null); + + const companyId = selectedCompany?.id ?? null; + + // Fetch conversation list + const { data: convData, isLoading: convsLoading } = useQuery({ + queryKey: ["assistant", "conversations", companyId], + queryFn: () => chatApi.listConversations(companyId!, { limit: 50 }), + enabled: !!companyId, + staleTime: 30_000, + }); + + const conversations: ChatConversationListItem[] = convData?.items ?? []; + + // 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!), + enabled: !!selectedConvId, + staleTime: 10_000, + }); + + const messages: ChatMessage[] = msgData?.items ?? []; + + // Scroll to bottom when messages change + 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); + }, + 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 handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }, + [handleSend], + ); + + // Mode gate — wait for mode to load before redirecting + if (!modeLoading && !isAssistantEnabled) { + return ; + } + + if (!companyId) { + return ( +
+ Select a workspace to use the assistant. +
+ ); + } + + return ( +
+ {/* Conversation list */} + + + {/* Chat area */} +
+ {/* Header */} +
+
+ +

Personal Assistant

+
+ + + + + + + Coming soon — will create a project from this conversation + +
+ + {/* 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) => ( + + ))} + + {streamingContent !== null && ( + + )} + +
+
+ + {/* Input bar */} + {selectedConvId && ( +
+
+