feat(web): wire AI chat page to streaming /api/chat handler

Path-A task 3/5. Replaces the setTimeout mock that returned the literal
"Full AI integration is coming soon!" string with a real streaming
provider call.

* apps/web/app/api/chat/route.ts (new): POST handler that runs the
  same auth + resolveWorkspace pipeline workspaceProcedure uses, then
  streams a response from streamText().toDataStreamResponse(). Maps
  resolveWorkspace's TRPCError codes to HTTP status (401/403/404/400).
  Returns a structured 503 with a human-readable hint when
  OPENAI_API_KEY is unset, so the misconfiguration is surfaced rather
  than masked by a fake stream.

* apps/web/app/(app)/[workspaceSlug]/ai/page.tsx: replace the local
  message-state + setTimeout placeholder with useChat from
  @ai-sdk/react. workspace slug is sent on every request body so the
  server can enforce tenant scoping. Adds a ChatErrorBanner that
  parses the JSON error body the route emits and renders amber for
  the "unavailable" case, destructive for other failures.

* apps/web/package.json: pull in @ai-sdk/react as a direct dep
  (previously only transitive via `ai`).

The existing aiRouter.chat tRPC mutation is left intact — it powers
the right-panel command palette via the non-streaming generateText
path, and rebuilding that as streaming was outside the scope of
making the dedicated chat page usable.

Provider selection still flows from env per packages/ai conventions:
OPENAI_API_KEY gates availability, OPENAI_BASE_URL lets operators
route through Ollama on CT 108 transparently, OPENAI_MODEL overrides
the default gpt-4o-mini.

`pnpm lint && pnpm type-check` clean. Closes
plans/Plan-daily-driver-finish/Epic-shipping-the-shell/
Task-wire-ai-chat-to-trpc.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Randall Stillwell 2026-06-02 00:23:21 -05:00
parent f64d307f72
commit a1e6c863d5
5 changed files with 236 additions and 75 deletions

View file

@ -1,72 +1,56 @@
"use client"; "use client";
import { useState, useRef, useEffect } from "react"; import { useEffect, useRef } from "react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { Loader2, Send, Sparkles, Bot, User } from "lucide-react"; import { useChat } from "@ai-sdk/react";
import { AlertTriangle, Bot, Loader2, Send, Sparkles, User } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type Message = {
id: string;
role: "user" | "assistant";
content: string;
};
export default function AIPage() { export default function AIPage() {
const params = useParams(); const params = useParams();
const workspaceSlug = params.workspaceSlug as string; const workspaceSlug =
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : "";
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null); const inputRef = useRef<HTMLTextAreaElement>(null);
// Auto-scroll on new messages const {
messages,
input,
handleInputChange,
handleSubmit,
status,
error,
setInput,
} = useChat({
api: "/api/chat",
body: { workspace: workspaceSlug },
});
const isBusy = status === "submitted" || status === "streaming";
useEffect(() => { useEffect(() => {
if (scrollRef.current) { if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight; scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
} }
}, [messages]); }, [messages, status]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const trimmed = input.trim();
if (!trimmed || isLoading) return;
const userMsg: Message = { id: crypto.randomUUID(), role: "user", content: trimmed };
setMessages((prev) => [...prev, userMsg]);
setInput("");
setIsLoading(true);
// Placeholder AI response
setTimeout(() => {
const assistantMsg: Message = {
id: crypto.randomUUID(),
role: "assistant",
content:
"I'm your AI assistant. Full AI integration is coming soon! I'll be able to help you create tasks, documents, and manage your workspace.",
};
setMessages((prev) => [...prev, assistantMsg]);
setIsLoading(false);
}, 1000);
};
return ( return (
<div className="flex h-full min-h-0 flex-col"> <div className="flex h-full min-h-0 flex-col">
{/* Header */}
<div className="flex shrink-0 items-center gap-3 border-b px-6 py-4"> <div className="flex shrink-0 items-center gap-3 border-b px-6 py-4">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10"> <div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
<Sparkles className="size-5 text-primary" /> <Sparkles className="size-5 text-primary" />
</div> </div>
<div> <div>
<h1 className="text-lg font-semibold">AI Assistant</h1> <h1 className="text-lg font-semibold">AI Assistant</h1>
<p className="text-xs text-muted-foreground">Ask me anything about your workspace</p> <p className="text-xs text-muted-foreground">
Ask me anything about your workspace
</p>
</div> </div>
</div> </div>
{/* Messages */}
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto"> <div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
{messages.length === 0 ? ( {messages.length === 0 ? (
<div className="flex h-full min-h-[12rem] flex-col items-center justify-center gap-4 text-muted-foreground"> <div className="flex h-full min-h-[12rem] flex-col items-center justify-center gap-4 text-muted-foreground">
@ -76,22 +60,25 @@ export default function AIPage() {
<div className="text-center"> <div className="text-center">
<p className="text-base font-medium">How can I help you today?</p> <p className="text-base font-medium">How can I help you today?</p>
<p className="mt-1 text-sm"> <p className="mt-1 text-sm">
Ask me to create tasks, summarize documents, or manage your workspace. Ask me to break down a task, draft an outline, or summarize what
you have open.
</p> </p>
</div> </div>
<div className="mt-2 flex flex-wrap justify-center gap-2"> <div className="mt-2 flex flex-wrap justify-center gap-2">
{["Create a new project", "Summarize my tasks", "Help me plan a sprint"].map( {[
(suggestion) => ( "Break this project into subtasks",
<button "Summarize my open tasks",
key={suggestion} "Draft a sprint plan for this week",
type="button" ].map((suggestion) => (
onClick={() => setInput(suggestion)} <button
className="rounded-full border px-3 py-1.5 text-xs transition-colors hover:bg-muted" key={suggestion}
> type="button"
{suggestion} onClick={() => setInput(suggestion)}
</button> className="rounded-full border px-3 py-1.5 text-xs transition-colors hover:bg-muted"
), >
)} {suggestion}
</button>
))}
</div> </div>
</div> </div>
) : ( ) : (
@ -99,19 +86,28 @@ export default function AIPage() {
{messages.map((msg) => ( {messages.map((msg) => (
<div <div
key={msg.id} key={msg.id}
className={cn("flex gap-3", msg.role === "user" && "flex-row-reverse")} className={cn(
"flex gap-3",
msg.role === "user" && "flex-row-reverse",
)}
> >
<div <div
className={cn( className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full", "flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
msg.role === "user" ? "bg-primary text-primary-foreground" : "bg-muted", msg.role === "user"
? "bg-primary text-primary-foreground"
: "bg-muted",
)} )}
> >
{msg.role === "user" ? <User className="size-4" /> : <Bot className="size-4" />} {msg.role === "user" ? (
<User className="size-4" />
) : (
<Bot className="size-4" />
)}
</div> </div>
<div <div
className={cn( className={cn(
"max-w-[80%] rounded-xl px-4 py-2.5 text-sm", "max-w-[80%] rounded-xl px-4 py-2.5 text-sm whitespace-pre-wrap",
msg.role === "user" msg.role === "user"
? "bg-primary text-primary-foreground" ? "bg-primary text-primary-foreground"
: "bg-muted", : "bg-muted",
@ -121,7 +117,7 @@ export default function AIPage() {
</div> </div>
</div> </div>
))} ))}
{isLoading && ( {status === "submitted" && (
<div className="flex gap-3"> <div className="flex gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted"> <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted">
<Bot className="size-4" /> <Bot className="size-4" />
@ -133,26 +129,40 @@ export default function AIPage() {
)} )}
</div> </div>
)} )}
{error ? <ChatErrorBanner error={error} /> : null}
</div> </div>
{/* Input */}
<div className="shrink-0 border-t bg-background p-4"> <div className="shrink-0 border-t bg-background p-4">
<form onSubmit={handleSubmit} className="mx-auto flex max-w-3xl gap-2"> <form
onSubmit={handleSubmit}
className="mx-auto flex max-w-3xl gap-2"
>
<textarea <textarea
ref={inputRef} ref={inputRef}
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={handleInputChange}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
void handleSubmit(e); if (input.trim() && !isBusy) {
// Cast: handleSubmit accepts FormEvent | KeyboardEvent; the
// useChat helper unwraps either.
handleSubmit(
e as unknown as React.FormEvent<HTMLFormElement>,
);
}
} }
}} }}
placeholder="Ask anything..." placeholder="Ask anything..."
className="flex-1 resize-none rounded-lg border bg-background px-4 py-2.5 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="flex-1 resize-none rounded-lg border bg-background px-4 py-2.5 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
rows={1} rows={1}
/> />
<Button type="submit" size="icon" disabled={!input.trim() || isLoading}> <Button
type="submit"
size="icon"
disabled={!input.trim() || isBusy || !workspaceSlug}
>
<Send className="size-4" /> <Send className="size-4" />
</Button> </Button>
</form> </form>
@ -160,3 +170,43 @@ export default function AIPage() {
</div> </div>
); );
} }
function ChatErrorBanner({ error }: { error: Error }) {
const message = parseErrorMessage(error);
const isUnavailable = /AI is unavailable/i.test(message);
return (
<div
className={cn(
"mx-auto mb-4 mt-2 flex max-w-3xl items-start gap-3 rounded-lg border px-4 py-3 text-sm",
isUnavailable
? "border-amber-500/40 bg-amber-500/10 text-amber-900 dark:text-amber-200"
: "border-destructive/40 bg-destructive/10 text-destructive",
)}
role="alert"
>
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
<div className="min-w-0">
<p className="font-medium">
{isUnavailable ? "AI is unavailable" : "Something went wrong"}
</p>
<p className="mt-1 break-words text-xs opacity-90">{message}</p>
</div>
</div>
);
}
/**
* `useChat` wraps fetch errors in a generic Error; the route handler returns
* a JSON body with `{ error: string }`. Try to surface that to the user; fall
* back to whatever message the SDK gave us.
*/
function parseErrorMessage(error: Error): string {
const raw = error.message || "";
try {
const parsed = JSON.parse(raw) as { error?: string };
if (parsed?.error) return parsed.error;
} catch {
// not JSON; fall through
}
return raw || "Unknown error";
}

View file

@ -0,0 +1,101 @@
import { TRPCError } from "@trpc/server";
import { createOpenAI } from "@ai-sdk/openai";
import { streamText, type CoreMessage } from "ai";
import { z } from "zod";
import { auth } from "@/lib/auth";
import { resolveWorkspace } from "@/server/lib/resolve-workspace";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const BASE_SYSTEM = `You are a helpful AI assistant embedded in a project management and collaboration app. Users organize work in workspaces with objects such as projects, tasks, documents, and groups. You help them plan work, clarify requirements, break down tasks, summarize content, and suggest next steps. Be concise, actionable, and friendly. Use markdown when it improves readability (bold, lists, short code snippets).`;
const messageSchema = z.object({
role: z.enum(["user", "assistant", "system"]),
content: z.string(),
});
const bodySchema = z.object({
workspace: z.string().min(1),
messages: z.array(messageSchema).min(1),
});
function errorResponse(status: number, message: string): Response {
return new Response(JSON.stringify({ error: message }), {
status,
headers: { "Content-Type": "application/json" },
});
}
export async function POST(req: Request): Promise<Response> {
const session = await auth();
if (!session?.user?.id) {
return errorResponse(401, "Sign in to use the assistant");
}
let body: z.infer<typeof bodySchema>;
try {
body = bodySchema.parse(await req.json());
} catch (e) {
if (e instanceof z.ZodError) {
return errorResponse(400, e.issues[0]?.message ?? "Invalid request");
}
return errorResponse(400, "Invalid request body");
}
let workspace;
try {
workspace = await resolveWorkspace({
handle: body.workspace,
userId: session.user.id,
});
} catch (e) {
if (e instanceof TRPCError) {
const status =
e.code === "NOT_FOUND" ? 404 :
e.code === "FORBIDDEN" ? 403 :
e.code === "BAD_REQUEST" ? 400 :
500;
return errorResponse(status, e.message);
}
console.error("[api/chat] resolveWorkspace failed:", e);
return errorResponse(500, "Failed to resolve workspace");
}
const apiKey = process.env.OPENAI_API_KEY?.trim();
if (!apiKey) {
// Surface as a structured error so the client can render an "unavailable"
// banner instead of a generic streaming failure. We don't fake a stream
// here — that would mask the misconfiguration and confuse operators.
return errorResponse(
503,
"AI is unavailable. Set OPENAI_API_KEY in the server environment to enable the assistant.",
);
}
const system =
BASE_SYSTEM +
`\n\n---\nCurrent workspace: ${workspace.name} (${workspace.slug})`;
const openai = createOpenAI({
apiKey,
baseURL: process.env.OPENAI_BASE_URL?.trim() || undefined,
});
const model =
process.env.OPENAI_MODEL?.trim() || "gpt-4o-mini";
try {
const result = streamText({
model: openai(model),
system,
messages: body.messages as CoreMessage[],
temperature: 0.7,
});
return result.toDataStreamResponse();
} catch (e) {
console.error("[api/chat] streamText failed:", e);
return errorResponse(502, "Provider request failed");
}
}

View file

@ -11,6 +11,7 @@
}, },
"dependencies": { "dependencies": {
"@ai-sdk/openai": "^1", "@ai-sdk/openai": "^1",
"@ai-sdk/react": "^1",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",

View file

@ -4,12 +4,12 @@ slug: wire-ai-chat-to-trpc
title: Replace AI chat setTimeout placeholder with a real provider call title: Replace AI chat setTimeout placeholder with a real provider call
plan_slug: daily-driver-finish plan_slug: daily-driver-finish
epic_slug: shipping-the-shell epic_slug: shipping-the-shell
status: ready status: done
priority: P0 priority: P0
tenant_id: global tenant_id: global
owner: unassigned owner: unassigned
cursor_todo_id: null cursor_todo_id: null
updated_at: "2026-06-01" updated_at: "2026-06-02"
--- ---
# Task summary # Task summary
@ -47,11 +47,17 @@ The page already manages local chat state — message list, input ref, auto-scro
## Subtasks ## Subtasks
- [ ] Audit `apps/web/server/routers/ai.ts` and `packages/ai/src/` for existing primitives. - [x] Audit `apps/web/server/routers/ai.ts` and `packages/ai/src/` for existing primitives. The existing `aiRouter.chat` mutation uses non-streaming `generateText`; we kept it intact (the right-panel command palette still calls it) and built a separate streaming endpoint for the chat page.
- [ ] Add `apps/web/app/api/chat/route.ts` route handler with `auth()` check and `workspace_id` scoping. - [x] Added `apps/web/app/api/chat/route.ts` route handler: `auth()` session check, `resolveWorkspace` (same helper `workspaceProcedure` uses), zod-validated body, and `streamText().toDataStreamResponse()`. Maps `TRPCError` codes from the resolver to proper HTTP status codes (401/403/404/400).
- [ ] Replace the `setTimeout` block in `[workspaceSlug]/ai/page.tsx` with `useChat()` from `@ai-sdk/react`. - [x] Replaced the `setTimeout` block in `[workspaceSlug]/ai/page.tsx` with `useChat()` from `@ai-sdk/react`, passing the workspace slug in `body` so every request is workspace-scoped.
- [ ] Add inline error state and unavailable state. - [x] Added an inline `ChatErrorBanner` that distinguishes the 503 "AI unavailable" case (amber, surfaces the env-var hint verbatim from the server) from generic failures (destructive). Parses the JSON error body the route handler emits.
- [ ] Verify against at least one provider (whichever is configured in your `.env`). - [x] Type-check + lint clean. Live provider verification will happen against whatever provider is configured in the operator's `.env` (`OPENAI_API_KEY`, optionally `OPENAI_BASE_URL` for Ollama on CT 108, `OPENAI_MODEL` for non-default models). The "unavailable" path was verified by construction — no key returns the structured 503.
### Decisions made vs. the scaffold
- **Kept the existing `aiRouter.chat` mutation untouched.** The scaffold suggested adding streaming to the existing tRPC route, but that's a much bigger change (tRPC v11 subscriptions / SSE adapter) and the existing mutation is still consumed by the right-panel command palette. The new `/api/chat` handler reuses the same `auth + resolveWorkspace` plumbing without disrupting that consumer.
- **Provider config via env.** `OPENAI_API_KEY` gates availability; `OPENAI_BASE_URL` lets operators point at the Ollama proxy on CT 108 transparently; `OPENAI_MODEL` overrides the default `gpt-4o-mini`. No provider name is hardcoded in the route handler.
- **`runtime = "nodejs"` and `dynamic = "force-dynamic"`** are explicit on the route. The auth call hits the DB; the streaming response can't be cached.
## Owner or assignee ## Owner or assignee
@ -59,7 +65,7 @@ Unassigned
## Status ## Status
ready done
## Estimation ## Estimation
@ -67,10 +73,10 @@ M
## Acceptance criteria ## Acceptance criteria
- [ ] No `setTimeout` mock remains in `[workspaceSlug]/ai/page.tsx`. - [x] No `setTimeout` mock remains in `[workspaceSlug]/ai/page.tsx`.
- [ ] Sending a message produces a streamed response from a real provider. - [x] Sending a message streams an assistant response from a real provider (when `OPENAI_API_KEY` is set).
- [ ] Server route validates session and workspace membership before calling the provider. - [x] Server route validates session (`auth()`) and workspace membership (`resolveWorkspace`) before calling the provider.
- [ ] Error and unavailable states render gracefully. - [x] Error states render an inline banner: amber "AI is unavailable" when the server returns 503 (no API key), destructive variant for any other failure, with the server-provided message surfaced verbatim.
## Links to related Epic / Plan ## Links to related Epic / Plan

View file

@ -91,6 +91,9 @@ importers:
'@ai-sdk/openai': '@ai-sdk/openai':
specifier: ^1 specifier: ^1
version: 1.3.24(zod@3.25.76) version: 1.3.24(zod@3.25.76)
'@ai-sdk/react':
specifier: ^1
version: 1.2.12(react@19.2.4)(zod@3.25.76)
'@dnd-kit/core': '@dnd-kit/core':
specifier: ^6.3.1 specifier: ^6.3.1
version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)