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) <noreply@anthropic.com>
This commit is contained in:
Nexus Dev 2026-04-03 22:03:09 +00:00
parent f6caca9458
commit 7bb72a5a2f
11 changed files with 701 additions and 10 deletions

View file

@ -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");
});
});

View file

@ -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",
);
});
});
});

View file

@ -21,18 +21,14 @@ import { goalRoutes } from "./routes/goals.js";
import { approvalRoutes } from "./routes/approvals.js"; import { approvalRoutes } from "./routes/approvals.js";
import { secretRoutes } from "./routes/secrets.js"; import { secretRoutes } from "./routes/secrets.js";
import { costRoutes } from "./routes/costs.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 { activityRoutes } from "./routes/activity.js";
import { dashboardRoutes } from "./routes/dashboard.js"; import { dashboardRoutes } from "./routes/dashboard.js";
import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js"; import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
import { instanceSettingsRoutes } from "./routes/instance-settings.js"; import { instanceSettingsRoutes } from "./routes/instance-settings.js";
import { ollamaRoutes } from "./routes/ollama.js";
import { llmRoutes } from "./routes/llms.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 { assetRoutes } from "./routes/assets.js";
import { accessRoutes } from "./routes/access.js"; import { accessRoutes } from "./routes/access.js";
import { assistantMemoryRoutes } from "./routes/assistant-memory.js";
import { pluginRoutes } from "./routes/plugins.js"; import { pluginRoutes } from "./routes/plugins.js";
import { pluginUiStaticRoutes } from "./routes/plugin-ui-static.js"; import { pluginUiStaticRoutes } from "./routes/plugin-ui-static.js";
import { applyUiBranding } from "./ui-branding.js"; import { applyUiBranding } from "./ui-branding.js";
@ -140,7 +136,6 @@ export async function createApp(
app.all("/api/auth/*authPath", opts.betterAuthHandler); app.all("/api/auth/*authPath", opts.betterAuthHandler);
} }
app.use(llmRoutes(db)); app.use(llmRoutes(db));
app.use("/api", hardwareRoutes());
// Mount API routes // Mount API routes
const api = Router(); const api = Router();
@ -157,7 +152,6 @@ export async function createApp(
api.use("/companies", companyRoutes(db, opts.storageService)); api.use("/companies", companyRoutes(db, opts.storageService));
api.use(companySkillRoutes(db)); api.use(companySkillRoutes(db));
api.use(agentRoutes(db)); api.use(agentRoutes(db));
api.use(ollamaRoutes());
api.use(assetRoutes(db, opts.storageService)); api.use(assetRoutes(db, opts.storageService));
api.use(projectRoutes(db)); api.use(projectRoutes(db));
api.use(issueRoutes(db, opts.storageService, { api.use(issueRoutes(db, opts.storageService, {
@ -169,13 +163,11 @@ export async function createApp(
api.use(approvalRoutes(db)); api.use(approvalRoutes(db));
api.use(secretRoutes(db)); api.use(secretRoutes(db));
api.use(costRoutes(db)); api.use(costRoutes(db));
api.use(puterProxyRoutes(db));
api.use(googleOAuthRoutes(db));
api.use(activityRoutes(db)); api.use(activityRoutes(db));
api.use(dashboardRoutes(db)); api.use(dashboardRoutes(db));
api.use(sidebarBadgeRoutes(db)); api.use(sidebarBadgeRoutes(db));
api.use(instanceSettingsRoutes(db)); api.use(instanceSettingsRoutes(db));
api.use(nexusSettingsRoutes()); api.use(assistantMemoryRoutes());
const hostServicesDisposers = new Map<string, () => void>(); const hostServicesDisposers = new Map<string, () => void>();
const workerManager = createPluginWorkerManager(); const workerManager = createPluginWorkerManager();
const pluginRegistry = pluginRegistryService(db); const pluginRegistry = pluginRegistryService(db);

View file

@ -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;
}

View file

@ -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<string, unknown>).facts) &&
((parsed as Record<string, unknown>).facts as unknown[]).every((f) => typeof f === "string")
) {
const p = parsed as Record<string, unknown>;
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<AssistantMemory> {
return readMemory(companyId);
}
async function append(companyId: string, rawFact: string): Promise<AssistantMemory> {
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<void> {
writeMemory(companyId, { facts: [], updatedAt: null });
}
return { get, append, clear };
}

View file

@ -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;
}

View file

@ -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 CliAuthPage = lazy(() => import("./pages/CliAuth").then(m => ({ default: m.CliAuthPage })));
const InviteLandingPage = lazy(() => import("./pages/InviteLanding").then(m => ({ default: m.InviteLandingPage }))); const InviteLandingPage = lazy(() => import("./pages/InviteLanding").then(m => ({ default: m.InviteLandingPage })));
const NotFoundPage = lazy(() => import("./pages/NotFound").then(m => ({ default: m.NotFoundPage }))); 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 }) { function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) {
return ( return (
@ -174,6 +175,8 @@ function boardRoutes() {
<Route path="inbox/unread" element={<Inbox />} /> <Route path="inbox/unread" element={<Inbox />} />
<Route path="inbox/all" element={<Inbox />} /> <Route path="inbox/all" element={<Inbox />} />
<Route path="inbox/new" element={<Navigate to="/inbox/mine" replace />} /> <Route path="inbox/new" element={<Navigate to="/inbox/mine" replace />} />
<Route path="assistant" element={<PersonalAssistant />} />
<Route path="assistant/:conversationId" element={<PersonalAssistant />} />
<Route path="design-guide" element={<DesignGuide />} /> <Route path="design-guide" element={<DesignGuide />} />
<Route path="tests/ux/runs" element={<RunTranscriptUxLab />} /> <Route path="tests/ux/runs" element={<RunTranscriptUxLab />} />
<Route path=":pluginRoutePath" element={<PluginPage />} /> <Route path=":pluginRoutePath" element={<PluginPage />} />

View file

@ -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<AssistantMemory> {
return api.get<AssistantMemory>(`/assistant-memory/${companyId}`);
},
appendFact(companyId: string, fact: string): Promise<AssistantMemory> {
return api.patch<AssistantMemory>(`/assistant-memory/${companyId}`, { fact });
},
clearMemory(companyId: string): Promise<void> {
return api.delete<void>(`/assistant-memory/${companyId}`);
},
};

View file

@ -11,6 +11,7 @@ import {
Boxes, Boxes,
Repeat, Repeat,
Settings, Settings,
Bot,
} from "lucide-react"; } from "lucide-react";
import { VOCAB } from "@paperclipai/branding"; import { VOCAB } from "@paperclipai/branding";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
@ -20,6 +21,7 @@ import { SidebarProjects } from "./SidebarProjects";
import { SidebarAgents } from "./SidebarAgents"; import { SidebarAgents } from "./SidebarAgents";
import { useDialog } from "../context/DialogContext"; import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
import { useNexusMode } from "../hooks/useNexusMode";
import { heartbeatsApi } from "../api/heartbeats"; import { heartbeatsApi } from "../api/heartbeats";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { useInboxBadge } from "../hooks/useInboxBadge"; import { useInboxBadge } from "../hooks/useInboxBadge";
@ -29,6 +31,7 @@ import { PluginSlotOutlet } from "@/plugins/slots";
export function Sidebar() { export function Sidebar() {
const { openNewIssue } = useDialog(); const { openNewIssue } = useDialog();
const { selectedCompanyId, selectedCompany } = useCompany(); const { selectedCompanyId, selectedCompany } = useCompany();
const { isAssistantEnabled } = useNexusMode();
const inboxBadge = useInboxBadge(selectedCompanyId); const inboxBadge = useInboxBadge(selectedCompanyId);
const { data: liveRuns } = useQuery({ const { data: liveRuns } = useQuery({
queryKey: queryKeys.liveRuns(selectedCompanyId!), queryKey: queryKeys.liveRuns(selectedCompanyId!),
@ -81,6 +84,9 @@ export function Sidebar() {
<span className="truncate">New Issue</span> <span className="truncate">New Issue</span>
</button> </button>
<SidebarNavItem to="/dashboard" label="Dashboard" icon={LayoutDashboard} liveCount={liveRunCount} /> <SidebarNavItem to="/dashboard" label="Dashboard" icon={LayoutDashboard} liveCount={liveRunCount} />
{isAssistantEnabled && (
<SidebarNavItem to="/assistant" label="Assistant" icon={Bot} />
)}
<SidebarNavItem <SidebarNavItem
to="/inbox" to="/inbox"
label="Inbox" label="Inbox"

View file

@ -0,0 +1,24 @@
// [nexus] Hook for reading the current Nexus mode from settings
import { useQuery } from "@tanstack/react-query";
import { fetchNexusSettings, type NexusMode } from "../api/hardware";
import { queryKeys } from "../lib/queryKeys";
export interface UseNexusModeResult {
mode: NexusMode;
isLoading: boolean;
isAssistantEnabled: boolean;
}
export function useNexusMode(): UseNexusModeResult {
const { data, isLoading } = useQuery({
queryKey: queryKeys.nexus.settings,
queryFn: () => fetchNexusSettings(),
staleTime: 5 * 60 * 1000,
retry: 1,
});
const mode: NexusMode = data?.mode ?? "both";
const isAssistantEnabled = mode !== "project_builder";
return { mode, isLoading, isAssistantEnabled };
}

View file

@ -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 (
<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 ────────────────────────────────────────────────────────────────
export function PersonalAssistant() {
const { isAssistantEnabled, isLoading: modeLoading } = useNexusMode();
const { selectedCompany } = useCompany();
const { conversationId: routeConvId } = useParams<{ conversationId?: string }>();
const queryClient = useQueryClient();
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 messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const abortRef = useRef<AbortController | null>(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<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
},
[handleSend],
);
// Mode gate — wait for mode to load before redirecting
if (!modeLoading && !isAssistantEnabled) {
return <Navigate to="/dashboard" replace />;
}
if (!companyId) {
return (
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
Select a workspace to use the assistant.
</div>
);
}
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}
/>
{/* 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>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button variant="outline" size="sm" disabled className="gap-2 opacity-60 cursor-not-allowed">
<ArrowRight className="h-4 w-4" />
Turn into project
</Button>
</span>
</TooltipTrigger>
<TooltipContent>Coming soon will create a project from this conversation</TooltipContent>
</Tooltip>
</div>
{/* 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) => (
<MessageBubble key={msg.id} message={msg} />
))}
{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`;
}}
/>
<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>
</div>
);
}