Initial commit — AI-powered coding tutor (Professor)
Next.js 16, React 19, Monaco editor, Anthropic SDK, multi-provider AI, Wandbox Python execution, iframe HTML preview, SQLite auth + session persistence. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
69
components/chat/ChatInput.tsx
Normal file
69
components/chat/ChatInput.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, type KeyboardEvent } from 'react';
|
||||
|
||||
interface Props {
|
||||
isDisabled: boolean;
|
||||
onSend: (text: string) => void;
|
||||
}
|
||||
|
||||
export default function ChatInput({ isDisabled, onSend }: Props) {
|
||||
const [text, setText] = useState('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
function handleSend() {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || isDisabled) return;
|
||||
onSend(trimmed);
|
||||
setText('');
|
||||
// Reset height
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}
|
||||
|
||||
function handleInput() {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = `${Math.min(el.scrollHeight, 160)}px`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-t border-zinc-700 bg-zinc-900 p-3">
|
||||
<div className="flex items-end gap-2 rounded-xl border border-zinc-700 bg-zinc-800 px-3 py-2 focus-within:border-zinc-500 transition-colors">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onInput={handleInput}
|
||||
disabled={isDisabled}
|
||||
placeholder="Ask a question or chat with Professor…"
|
||||
rows={1}
|
||||
className="flex-1 resize-none bg-transparent text-sm text-zinc-200 placeholder-zinc-500 outline-none disabled:opacity-50"
|
||||
style={{ maxHeight: '160px' }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={isDisabled || !text.trim()}
|
||||
className="flex-shrink-0 rounded-lg bg-blue-600 p-1.5 text-white hover:bg-blue-500 disabled:cursor-not-allowed disabled:opacity-50 transition-colors"
|
||||
aria-label="Send message"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M22 2L11 13" />
|
||||
<path d="M22 2L15 22 11 13 2 9l20-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-center text-xs text-zinc-600">Enter to send · Shift+Enter for newline</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
components/chat/ChatPane.tsx
Normal file
102
components/chat/ChatPane.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
'use client';
|
||||
|
||||
import type { Dispatch } from 'react';
|
||||
import type { AppState, AppAction, AppMode } from '@/types';
|
||||
import TaskCard from './TaskCard';
|
||||
import MessageList from './MessageList';
|
||||
import ChatInput from './ChatInput';
|
||||
|
||||
interface Props {
|
||||
state: AppState;
|
||||
dispatch: Dispatch<AppAction>;
|
||||
onSendMessage: (text: string) => void;
|
||||
onSendClassroomMessage: (text: string) => void;
|
||||
onSubmit: () => void;
|
||||
onSetAppMode: (mode: AppMode) => void;
|
||||
}
|
||||
|
||||
export default function ChatPane({ state, onSendMessage, onSendClassroomMessage, onSetAppMode }: Props) {
|
||||
const isClassroom = state.appMode === 'classroom';
|
||||
const isTaskLoading = state.phase === 'loading_task';
|
||||
|
||||
// In classroom mode "busy" only covers classroom chat replies, not lesson generation
|
||||
const isBusy = isClassroom
|
||||
? state.isStreaming && state.lessonContent !== null
|
||||
: state.isStreaming || state.phase === 'executing';
|
||||
|
||||
const messages = isClassroom ? state.classroomMessages : state.messages;
|
||||
const handleSend = isClassroom ? onSendClassroomMessage : onSendMessage;
|
||||
|
||||
const showChatStreaming = isClassroom
|
||||
? state.isStreaming && state.lessonContent !== null
|
||||
: state.isStreaming && state.phase !== 'loading_task';
|
||||
const chatStreamingContent = showChatStreaming ? state.streamingContent : '';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-zinc-900">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 border-b border-zinc-700 px-4 py-2">
|
||||
<div className="h-2 w-2 rounded-full bg-blue-400" />
|
||||
<span className="text-sm font-semibold text-zinc-200">Professor</span>
|
||||
|
||||
{isBusy && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-zinc-500">
|
||||
<div className="h-2 w-2 animate-pulse rounded-full bg-blue-400" />
|
||||
Thinking…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mode toggle */}
|
||||
<div className="ml-auto flex overflow-hidden rounded-lg border border-zinc-700">
|
||||
<button
|
||||
onClick={() => onSetAppMode('classroom')}
|
||||
className={`px-3 py-1 text-xs transition-colors ${
|
||||
isClassroom ? 'bg-zinc-700 text-zinc-100' : 'text-zinc-500 hover:bg-zinc-800 hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
Classroom
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSetAppMode('homework')}
|
||||
className={`border-l border-zinc-700 px-3 py-1 text-xs transition-colors ${
|
||||
!isClassroom ? 'bg-zinc-700 text-zinc-100' : 'text-zinc-500 hover:bg-zinc-800 hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
Homework
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task card — homework only */}
|
||||
{!isClassroom && (
|
||||
<TaskCard
|
||||
task={state.task}
|
||||
isLoading={isTaskLoading}
|
||||
streamingContent={isTaskLoading ? state.streamingContent : ''}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Error banner */}
|
||||
{state.error && (
|
||||
<div className="mx-3 mt-3 rounded-lg border border-red-800 bg-red-900/30 px-4 py-2.5 text-sm text-red-300">
|
||||
{state.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages */}
|
||||
<MessageList
|
||||
messages={messages}
|
||||
isStreaming={showChatStreaming}
|
||||
streamingContent={chatStreamingContent}
|
||||
emptyText={
|
||||
isClassroom
|
||||
? 'Ask me anything about this topic.'
|
||||
: 'Run your code or submit it for review — or ask me anything about the task.'
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Input */}
|
||||
<ChatInput isDisabled={state.isStreaming} onSend={handleSend} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
components/chat/MessageBubble.tsx
Normal file
68
components/chat/MessageBubble.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import type { Message } from '@/types';
|
||||
|
||||
interface Props {
|
||||
message: Message;
|
||||
}
|
||||
|
||||
export default function MessageBubble({ message }: Props) {
|
||||
const isUser = message.role === 'user';
|
||||
|
||||
if (isUser) {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<div className="max-w-[85%] rounded-2xl rounded-tr-sm bg-blue-600 px-4 py-3 text-sm text-white">
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-start">
|
||||
<div className="max-w-[92%] rounded-2xl rounded-tl-sm bg-zinc-800 px-4 py-3 text-sm text-zinc-200">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
code({ className, children, ...rest }) {
|
||||
const match = /language-(\w+)/.exec(className ?? '');
|
||||
const isBlock = Boolean(match);
|
||||
return isBlock ? (
|
||||
<SyntaxHighlighter
|
||||
style={vscDarkPlus as Record<string, React.CSSProperties>}
|
||||
language={match![1]}
|
||||
PreTag="div"
|
||||
customStyle={{ margin: '0.5rem 0', borderRadius: '0.375rem', fontSize: '0.8rem' }}
|
||||
>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<code className="rounded bg-zinc-700 px-1 py-0.5 text-xs font-mono text-zinc-200" {...rest}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
p: ({ children }) => <p className="mb-2 last:mb-0 leading-relaxed">{children}</p>,
|
||||
ul: ({ children }) => <ul className="mb-2 list-disc pl-5 space-y-1">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="mb-2 list-decimal pl-5 space-y-1">{children}</ol>,
|
||||
li: ({ children }) => <li className="leading-relaxed">{children}</li>,
|
||||
h1: ({ children }) => <h1 className="mb-2 text-base font-bold text-zinc-100">{children}</h1>,
|
||||
h2: ({ children }) => <h2 className="mb-2 text-sm font-bold text-zinc-100">{children}</h2>,
|
||||
h3: ({ children }) => <h3 className="mb-1 text-sm font-semibold text-zinc-200">{children}</h3>,
|
||||
strong: ({ children }) => <strong className="font-semibold text-zinc-100">{children}</strong>,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="my-2 border-l-2 border-blue-500 pl-3 text-zinc-400 italic">{children}</blockquote>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{message.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
components/chat/MessageList.tsx
Normal file
90
components/chat/MessageList.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import type { Message } from '@/types';
|
||||
import MessageBubble from './MessageBubble';
|
||||
|
||||
interface Props {
|
||||
messages: Message[];
|
||||
isStreaming: boolean;
|
||||
streamingContent: string;
|
||||
emptyText?: string;
|
||||
}
|
||||
|
||||
export default function MessageList({ messages, isStreaming, streamingContent, emptyText }: Props) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages, streamingContent]);
|
||||
|
||||
if (messages.length === 0 && !isStreaming) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center p-6 text-center">
|
||||
<p className="text-sm text-zinc-500">
|
||||
{emptyText ?? 'Run your code or submit it for review — or ask me anything about the task.'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
{messages.map((msg) => (
|
||||
<MessageBubble key={msg.id} message={msg} />
|
||||
))}
|
||||
|
||||
{/* Live streaming bubble */}
|
||||
{isStreaming && streamingContent && (
|
||||
<div className="flex justify-start">
|
||||
<div className="max-w-[92%] rounded-2xl rounded-tl-sm bg-zinc-800 px-4 py-3 text-sm text-zinc-200">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
code({ className, children, ...rest }) {
|
||||
const match = /language-(\w+)/.exec(className ?? '');
|
||||
return match ? (
|
||||
<SyntaxHighlighter
|
||||
style={vscDarkPlus as Record<string, React.CSSProperties>}
|
||||
language={match[1]}
|
||||
PreTag="div"
|
||||
customStyle={{ margin: '0.5rem 0', borderRadius: '0.375rem', fontSize: '0.8rem' }}
|
||||
>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<code className="rounded bg-zinc-700 px-1 py-0.5 text-xs font-mono text-zinc-200" {...rest}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
p: ({ children }) => <p className="mb-2 last:mb-0 leading-relaxed">{children}</p>,
|
||||
}}
|
||||
>
|
||||
{streamingContent}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Typing indicator when streaming but no content yet */}
|
||||
{isStreaming && !streamingContent && (
|
||||
<div className="flex justify-start">
|
||||
<div className="rounded-2xl rounded-tl-sm bg-zinc-800 px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="h-2 w-2 animate-bounce rounded-full bg-zinc-500 [animation-delay:-0.3s]" />
|
||||
<div className="h-2 w-2 animate-bounce rounded-full bg-zinc-500 [animation-delay:-0.15s]" />
|
||||
<div className="h-2 w-2 animate-bounce rounded-full bg-zinc-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
components/chat/TaskCard.tsx
Normal file
81
components/chat/TaskCard.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import type { Task } from '@/types';
|
||||
|
||||
interface Props {
|
||||
task: Task | null;
|
||||
isLoading: boolean;
|
||||
streamingContent: string;
|
||||
}
|
||||
|
||||
export default function TaskCard({ task, isLoading, streamingContent }: Props) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
if (!isLoading && !task) return null;
|
||||
|
||||
return (
|
||||
<div className="border-b border-zinc-700 bg-zinc-800/50">
|
||||
<button
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
className="flex w-full items-center gap-2 px-4 py-3 text-left hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
<span className="text-xs text-zinc-400">{collapsed ? '▶' : '▼'}</span>
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-blue-400">
|
||||
{isLoading ? 'Generating task…' : task?.title ?? 'Task'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="px-4 pb-4 text-sm text-zinc-300">
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{/* Skeleton shimmer while task streams in */}
|
||||
{streamingContent ? (
|
||||
<p className="whitespace-pre-wrap text-xs text-zinc-400 font-mono">{streamingContent}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="h-3 w-3/4 animate-pulse rounded bg-zinc-700" />
|
||||
<div className="h-3 w-full animate-pulse rounded bg-zinc-700" />
|
||||
<div className="h-3 w-2/3 animate-pulse rounded bg-zinc-700" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : task ? (
|
||||
<div className="space-y-3">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
p: ({ children }) => <p className="leading-relaxed text-zinc-300">{children}</p>,
|
||||
ul: ({ children }) => <ul className="list-disc pl-5 space-y-1">{children}</ul>,
|
||||
li: ({ children }) => <li className="text-zinc-400">{children}</li>,
|
||||
strong: ({ children }) => <strong className="font-semibold text-zinc-200">{children}</strong>,
|
||||
code: ({ children }) => (
|
||||
<code className="rounded bg-zinc-700 px-1 py-0.5 text-xs font-mono text-zinc-300">
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{task.description}
|
||||
</ReactMarkdown>
|
||||
|
||||
{task.hints.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-semibold text-zinc-500 uppercase tracking-wider">Hints</p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
{task.hints.map((hint, i) => (
|
||||
<li key={i} className="text-xs text-zinc-500">{hint}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user