ubiquitous-invention/apps/web/components/ai/chat-panel.tsx
Randall Stillwell c582d621ce multi-tenancy: promote workspaces to top-level table
Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`,
which made it impossible to put a real RLS-friendly tenant boundary on the schema
or to give each workspace a stable URL slug. This commit:

- Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook).
- Migrates the 8 anchor tables (objects, workspace_members, object_type_defs,
  property_definitions, templates, forms, markdown_backlog_items,
  cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`,
  with a hand-augmented data-copy migration that preserves IDs and slug-collision-
  proofs on backfill.
- Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper
  that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All
  tenant-scoped routers (objects, types, properties, templates, forms, search,
  ai, relations, favorites) now flow through it.
- Updates the web app to pass `workspace` slugs from the URL (or store) instead
  of the old `workspaceId`, including a workspace-sync layer that rewrites
  /<UUID>/... links to /<slug>/...
- Updates the MCP tools (list_objects, create_object, search_objects) and the
  workspace://{handle}/tree resource to accept either a slug or UUID so existing
  agents keep working.
- Adds a Create Workspace dialog and a Workspace Settings page (rename + slug
  rename with redirect, owner-only archive).

Verified locally against a fresh Postgres: migration applies cleanly, slug
uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution
works in both directions, and ON DELETE CASCADE cleans up child rows in the
correct workspace only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 23:02:55 -05:00

330 lines
11 KiB
TypeScript

"use client";
import * as React from "react";
import type { inferRouterOutputs } from "@trpc/server";
import { Loader2, Send, Sparkles, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { api } from "@/lib/trpc";
import type { aiRouter } from "@/server/routers/ai";
import { usePanelStore } from "@/lib/stores/panel-store";
import { useWorkspaceStore } from "@/lib/stores/workspace-store";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { AIMessage } from "@/components/ai/message";
type ChatRole = "user" | "assistant";
type ChatLine = {
id: string;
role: ChatRole;
content: string;
createdAt: Date;
};
type ObjectSummary = { title: string; type: string };
type AiOutputs = inferRouterOutputs<typeof aiRouter>;
/** Cast until `aiRouter` is merged into `appRouter` in `server/root.ts` */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const aiTrpc = (api as any).ai as {
suggestActions: {
useQuery: (
input: { workspace: string; objectId?: string; objectType?: string },
opts?: { enabled?: boolean },
) => { data: { actions: string[] } | undefined };
};
chat: {
useMutation: (opts: {
onMutate?: () => void;
onSuccess?: (data: AiOutputs["chat"]) => void;
onError?: (err: { message: string }) => void;
}) => {
mutate: (input: {
workspace: string;
messages: { role: "user" | "assistant"; content: string }[];
context?: { objectId?: string };
}) => void;
isPending: boolean;
isError: boolean;
error: { message: string } | null;
};
};
};
const MODEL_LABEL = "GPT-4o";
const MAX_CHARS = 8000;
const WELCOME_CHIPS = [
"Create a project plan",
"Summarize this document",
"Generate task descriptions",
];
function TypingDots() {
return (
<div className="flex items-center gap-1.5 px-1 py-2" aria-hidden>
{[0, 1, 2].map((i) => (
<span
key={i}
className="inline-block h-2 w-2 animate-bounce rounded-full bg-muted-foreground/70"
style={{ animationDelay: `${i * 0.15}s` }}
/>
))}
</div>
);
}
export function AIChatPanel() {
const close = usePanelStore((s) => s.close);
const objectId = usePanelStore((s) => s.objectId);
const workspace = useWorkspaceStore((s) => s.currentWorkspace);
const [input, setInput] = React.useState("");
const [messages, setMessages] = React.useState<ChatLine[]>([]);
const [sendError, setSendError] = React.useState<string | null>(null);
const bottomRef = React.useRef<HTMLDivElement>(null);
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const workspaceHandle = workspace?.slug ?? workspace?.id;
const objectQuery = api.objects.getById.useQuery(
{ id: objectId!, workspace: workspaceHandle! },
{ enabled: !!objectId && Boolean(workspaceHandle) },
);
const objectSummary = objectQuery.data as ObjectSummary | undefined;
const suggestQuery = aiTrpc.suggestActions.useQuery(
{
workspace: workspaceHandle!,
objectId: objectId ?? undefined,
objectType: objectSummary?.type,
},
{ enabled: Boolean(workspaceHandle) },
);
const chatMutation = aiTrpc.chat.useMutation({
onMutate: () => setSendError(null),
onSuccess: (data) => {
setMessages((prev) => [
...prev,
{
id: crypto.randomUUID(),
role: "assistant",
content: data.text,
createdAt: new Date(),
},
]);
},
onError: (err) => setSendError(err.message ?? "Request failed"),
});
const isLoading = chatMutation.isPending;
React.useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, isLoading]);
const send = React.useCallback(() => {
const trimmed = input.trim();
if (!trimmed || isLoading) return;
const userMsg: ChatLine = {
id: crypto.randomUUID(),
role: "user",
content: trimmed,
createdAt: new Date(),
};
const nextMessages = [...messages, userMsg];
setMessages(nextMessages);
setInput("");
const payload = nextMessages.map((m) => ({
role: m.role,
content: m.content,
}));
if (!workspaceHandle) {
setSendError("Select a workspace first.");
return;
}
chatMutation.mutate({
workspace: workspaceHandle,
messages: payload,
context: {
objectId: objectId ?? undefined,
},
});
}, [input, isLoading, messages, chatMutation, workspaceHandle, objectId]);
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
send();
}
};
const insertChip = (text: string) => {
setInput((prev) => (prev ? `${prev}\n${text}` : text));
textareaRef.current?.focus();
};
const contextLabel = objectSummary?.title
? objectSummary.title
: workspace?.name
? workspace.name
: "No context";
const suggestions = suggestQuery.data?.actions ?? [];
return (
<div className="flex h-full min-h-0 flex-col bg-background">
<header className="flex shrink-0 items-start justify-between gap-2 border-b border-border/80 px-4 py-3">
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2">
<Sparkles className="size-5 shrink-0 text-primary" />
<h2 className="truncate text-base font-semibold tracking-tight">AI Assistant</h2>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="font-normal">
{MODEL_LABEL}
</Badge>
<span
className="truncate text-xs text-muted-foreground"
title={contextLabel}
>
{objectId ? "Object: " : "Workspace: "}
<span className="text-foreground/90">{contextLabel}</span>
</span>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
onClick={() => close()}
aria-label="Close panel"
>
<X className="size-4" />
</Button>
</header>
<ScrollArea className="min-h-0 flex-1 px-3">
<div className="pb-4 pt-2">
{messages.length === 0 && !isLoading ? (
<div className="space-y-4 px-1">
<div className="rounded-2xl border border-dashed border-primary/25 bg-primary/5 px-4 py-6 text-center dark:bg-primary/10">
<p className="text-sm font-medium text-foreground">How can I help?</p>
<p className="mt-1 text-xs text-muted-foreground">
Ask about planning, tasks, or this workspace or try a suggestion below.
</p>
</div>
<div className="flex flex-col gap-2">
<p className="text-xs font-medium text-muted-foreground">Try asking</p>
<div className="flex flex-wrap gap-2">
{WELCOME_CHIPS.map((chip) => (
<button
key={chip}
type="button"
onClick={() => insertChip(chip)}
className="rounded-full border border-border bg-card px-3 py-1.5 text-left text-xs transition-colors hover:bg-accent"
>
{chip}
</button>
))}
</div>
</div>
</div>
) : null}
{messages.map((m) => (
<AIMessage
key={m.id}
role={m.role}
content={m.content}
timestamp={m.createdAt}
/>
))}
{sendError ? (
<AIMessage role="system" content={sendError} />
) : null}
{isLoading ? (
<div className="flex items-start gap-3 px-1 py-2">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-border/60 bg-teal-500/15 text-teal-600 dark:text-teal-400">
<Sparkles className="size-4" />
</div>
<div className="rounded-2xl rounded-tl-md border border-border/80 bg-card px-4 py-3">
<TypingDots />
<span className="sr-only">Assistant is thinking</span>
</div>
</div>
) : null}
<div ref={bottomRef} />
</div>
</ScrollArea>
<div className="shrink-0 border-t border-border/80 bg-muted/20 px-3 pb-3 pt-2">
{suggestions.length > 0 ? (
<div className="mb-2 flex flex-wrap gap-1.5">
{suggestions.slice(0, 6).map((action) => (
<button
key={action}
type="button"
onClick={() => insertChip(action)}
className={cn(
"rounded-full border border-primary/20 bg-background/80 px-2.5 py-1 text-xs text-foreground",
"transition-colors hover:border-primary/40 hover:bg-primary/5",
)}
>
{action}
</button>
))}
</div>
) : null}
<div className="flex gap-2">
<div className="relative min-w-0 flex-1">
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value.slice(0, MAX_CHARS))}
onKeyDown={onKeyDown}
placeholder="Ask AI anything..."
disabled={isLoading}
rows={3}
className={cn(
"w-full resize-none rounded-xl border border-input bg-background px-3 py-2.5 text-sm",
"placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"disabled:cursor-not-allowed disabled:opacity-50",
)}
/>
<div className="pointer-events-none absolute bottom-2 right-2 text-[10px] text-muted-foreground">
{input.length}/{MAX_CHARS}
</div>
</div>
<Button
type="button"
size="icon"
className="h-auto min-h-[5.5rem] shrink-0 rounded-xl bg-primary"
onClick={send}
disabled={isLoading || !input.trim()}
aria-label="Send message"
>
{isLoading ? (
<Loader2 className="size-5 animate-spin" />
) : (
<Send className="size-5" />
)}
</Button>
</div>
</div>
</div>
);
}