Files
nextcraft/apps/web/components/learner/ai-tutor-chat.tsx
T
CIAgent 88a1dab810 docs(milestone): complete v0.2-ai-tutor-architecture
---ci---
phase: 7
milestone: v0.2
status: complete
requirements:
  covered: [REQ-2-001, REQ-2-002, REQ-2-003, REQ-2-004, REQ-2-005, REQ-2-006, REQ-2-007, REQ-2-008, REQ-2-009, REQ-2-010, REQ-2-011, REQ-2-012]
  partial: []
---/ci---

Milestone v0.2 (ai-tutor-architecture) merged to main.

Escalation record (audit remediation, durable): P1 executor
delegation failed twice (empty subagent results, zero files
created); auto-resolved at full autonomy to inline execution with
identical plan fidelity (commit 3271373, reflog-only after phase
branch squash-delete).
2026-09-11 17:34:50 +00:00

164 lines
6.2 KiB
TypeScript

'use client';
import { useState, useRef, useEffect, type FormEvent } from 'react';
import { Bot, Send, AlertTriangle, RotateCcw } from 'lucide-react';
import { primaryLearner } from '@nextcraft/mock-data';
import { Avatar } from '@nextcraft/ui';
import { useChatStream, type AgentName, type StreamMessage } from '../../hooks/use-chat-stream';
const AGENTS: { id: AgentName; label: string; blurb: string }[] = [
{ id: 'coach', label: 'Coach', blurb: 'Pacing, motivation, retrieval practice' },
{ id: 'tutor', label: 'Tutor', blurb: 'Concepts, worked examples, Socratic checks' },
];
const SEED_MESSAGE: StreamMessage = {
id: 'seed-1',
role: 'assistant',
content:
"Welcome back, Alex. You're 62% through the AI Orchestration stack. What would you like to work on today?",
suggestedActions: ['Review my pacing', 'Start Multi-Agent Communication', 'Prep for my defense'],
};
export function AiTutorChat() {
const [agent, setAgent] = useState<AgentName>('coach');
const [input, setInput] = useState('');
const { messages, isStreaming, error, send, retry, abort } = useChatStream(agent);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages, isStreaming, error]);
function handleSubmit(e: FormEvent) {
e.preventDefault();
const text = input.trim();
if (!text || isStreaming) return;
setInput('');
void send(text);
}
function switchAgent(next: AgentName) {
if (isStreaming) abort();
setAgent(next);
}
return (
<div className="flex h-[28rem] flex-col">
{/* Agent switcher (A-007: explicit routing, no autonomy) */}
<div className="mb-3 flex items-center gap-2" role="tablist" aria-label="Choose tutor agent">
{AGENTS.map((a) => (
<button
key={a.id}
role="tab"
aria-selected={agent === a.id}
onClick={() => switchAgent(a.id)}
className={`rounded-full px-3 py-1 text-xs font-medium transition-colors ${
agent === a.id
? 'bg-primary-600 text-white'
: 'border border-slate-300 text-slate-600 hover:border-primary-400 hover:text-primary-700 dark:border-slate-600 dark:text-slate-300 dark:hover:text-primary-300'
}`}
>
{a.label}
</button>
))}
</div>
{/* Message list */}
<div ref={scrollRef} className="flex-1 space-y-4 overflow-y-auto pr-2">
<ChatMessage message={SEED_MESSAGE} />
{messages.map((m) => (
<ChatMessage key={m.id} message={m} onAction={(a) => setInput(a)} />
))}
{/* Error state with retry (A-010) */}
{error && (
<div
role="alert"
className="flex items-center gap-2 rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-700 dark:bg-amber-900/30 dark:text-amber-300"
>
<AlertTriangle className="h-4 w-4 shrink-0" />
<span className="flex-1">
The tutor service is unreachable. Your message can be retried.
</span>
<button
onClick={retry}
className="inline-flex items-center gap-1 font-semibold underline"
>
<RotateCcw className="h-3 w-3" /> Retry
</button>
</div>
)}
</div>
{/* Input */}
<form onSubmit={handleSubmit} className="mt-3 flex items-center gap-2 border-t border-slate-200 pt-3 dark:border-slate-800">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={`Ask your ${agent} anything…`}
aria-label="Message"
className="h-10 flex-1 rounded-md border border-slate-300 bg-white px-3 text-sm text-slate-900 placeholder:text-slate-400 focus:border-primary-500 focus:ring-2 focus:ring-primary-500/30 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:placeholder:text-slate-500"
/>
<button
type="submit"
disabled={!input.trim() || isStreaming}
className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-primary-600 text-white transition-colors hover:bg-primary-700 disabled:opacity-50"
aria-label="Send message"
>
<Send className="h-4 w-4" />
</button>
</form>
</div>
);
}
function ChatMessage({
message,
onAction,
}: {
message: StreamMessage;
onAction?: (text: string) => void;
}) {
const isAssistant = message.role === 'assistant';
return (
<div className={`flex gap-3 ${isAssistant ? 'flex-row' : 'flex-row-reverse'}`}>
{isAssistant ? (
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary-100 text-primary-700 dark:bg-primary-900/40 dark:text-primary-300">
<Bot className="h-4 w-4" />
</span>
) : (
<Avatar name={primaryLearner.name} src={primaryLearner.avatar} size="sm" />
)}
<div
className={`max-w-[80%] rounded-lg px-3 py-2 text-sm ${
isAssistant
? 'bg-slate-100 text-slate-800 dark:bg-slate-800 dark:text-slate-100'
: 'bg-primary-600 text-white'
}`}
>
<p className="leading-relaxed whitespace-pre-wrap">
{message.content}
{message.streaming && (
<span className="ml-0.5 inline-block h-4 w-1.5 animate-pulse rounded-sm bg-primary-500 align-middle" />
)}
</p>
{message.suggestedActions && message.suggestedActions.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{message.suggestedActions.map((action) => (
<button
key={action}
onClick={() => onAction?.(action)}
className="rounded-full border border-slate-300 bg-white px-2 py-0.5 text-xs text-slate-600 transition-colors hover:border-primary-400 hover:text-primary-700 dark:border-slate-600 dark:bg-slate-900 dark:text-slate-300 dark:hover:border-primary-400 dark:hover:text-primary-300"
>
{action}
</button>
))}
</div>
)}
</div>
</div>
);
}