88a1dab810
---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).
164 lines
6.2 KiB
TypeScript
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>
|
|
);
|
|
} |