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:
Aodhan Collins
2026-03-04 21:48:34 +00:00
commit f644937604
56 changed files with 14012 additions and 0 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}