feat(P06): real build + defense surfaces, E2E credential flow (Waves 3-5)
Wave 3 (Task 6-3-01): /build/[competencyId] rewritten as a real build surface —
variant statement + starter files in a live namespace sandbox, workspace file tree +
editor with save, Run/Test buttons executing bounded commands in-sandbox with
read-only TerminalFrame output (CUT-2), live TelemetryStatus pulse, Lab feedback over
the live trace, honest 503-busy/403-429-denied states with retry.
Wave 4 (Task 6-4-01): /defend/[competencyId] rewritten — DefenseSession: start ->
examiner question -> typed answers (mic capture w/ MediaRecorder consent + denied
fallback; browser-SR first-class per CUT-1) -> finish -> verdict + integrity signals
-> Grade My Work renders real rubric bars from the trace digest. Dead mock components
disposed (oral-defense-interface, assessor-results-panel, proctor-banner — G-5 class).
Wave 5 (Task 6-5-01): test_e2e_credential_flow — REAL uvicorn + REAL namespaces:
variant -> sandbox -> in-sandbox exec -> trace -> grade (seed stamped) -> coaching ->
defense -> verdict -> proctor; corpus-fixture scan of all payloads. E2E caught a real
bug: the sandboxes API dropped task_id (every HTTP-created sandbox was capture-less)
— fixed. README E2E + manual browser pass documented.
pnpm build 4/4; typecheck FULL TURBO; backend suite 397 green; ruff clean.
---ci---
phase: 6
milestone: v0.3
status: execute
requirements: {covered: [REQ-3-007, REQ-3-008], partial: []}
---/ci---
This commit is contained in:
@@ -235,6 +235,24 @@ in CI, so v0.3 ships the protocol seam instead of an unverifiable claim.
|
|||||||
criterion, run manually with `AI_VOICE_PROVIDER` set to the real
|
criterion, run manually with `AI_VOICE_PROVIDER` set to the real
|
||||||
provider and keys in `.ciagent/.env.secrets` (never in code/commits).
|
provider and keys in `.ciagent/.env.secrets` (never in code/commits).
|
||||||
|
|
||||||
|
## End-to-end credential flow (v0.3, REQ-3-007/008)
|
||||||
|
|
||||||
|
`tests/api/test_e2e_credential_flow.py` runs the full pipeline against a REAL
|
||||||
|
uvicorn server with REAL namespace sandboxes (mock LLM/voice per G-2
|
||||||
|
precedent): variant -> telemetry-wired sandbox -> in-sandbox exec -> trace
|
||||||
|
persistence -> process-trace grade (variant seed stamped) -> assessor
|
||||||
|
coaching -> oral defense -> verdict + integrity signals -> proctor. It
|
||||||
|
asserts no corpus fixture appears anywhere in the learner path.
|
||||||
|
|
||||||
|
Manual browser pass (documented, not automated): `pnpm ai:dev` + `pnpm dev`,
|
||||||
|
then open `/build/stack-orchestration-c007` — variant statement + starter
|
||||||
|
files load, edit a file, Run/Test execute in the sandbox with output in the
|
||||||
|
read-only panel, the telemetry status pulses, Lab streams feedback from the
|
||||||
|
live digest; then `/defend/stack-orchestration-c007` — Start Defense, typed
|
||||||
|
answers (mic path needs permission), Finish, Grade My Work renders the real
|
||||||
|
rubric bars. Navigating away destroys the sandbox
|
||||||
|
(`curl localhost:8420/v1/sandboxes` shows the count drop).
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ _CREATE_TIMES: deque[float] = deque()
|
|||||||
|
|
||||||
class SandboxCreateRequest(BaseModel):
|
class SandboxCreateRequest(BaseModel):
|
||||||
learner_id: str = Field(min_length=1)
|
learner_id: str = Field(min_length=1)
|
||||||
|
# Optional task key: when set, the sandbox is telemetry-wired (REQ-3-003)
|
||||||
|
# — the in-sandbox capture agent streams workspace events to the ingest.
|
||||||
|
task_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class SandboxResponse(BaseModel):
|
class SandboxResponse(BaseModel):
|
||||||
@@ -143,7 +146,7 @@ async def create_sandbox(
|
|||||||
_check_per_learner_cap(await manager.list(), body.learner_id, settings)
|
_check_per_learner_cap(await manager.list(), body.learner_id, settings)
|
||||||
_check_global_create_rate(settings)
|
_check_global_create_rate(settings)
|
||||||
try:
|
try:
|
||||||
info = await manager.create(body.learner_id)
|
info = await manager.create(body.learner_id, task_id=body.task_id)
|
||||||
except PoolFullError as exc:
|
except PoolFullError as exc:
|
||||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||||
return _to_response(info)
|
return _to_response(info)
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
"""Full credential-flow E2E over real engines (Task 6-5-01, REQ-3-007/008).
|
||||||
|
|
||||||
|
Endpoint-level end-to-end with mock LLM/voice providers (G-2 precedent:
|
||||||
|
real engine plumbing over real endpoints; provider choice is
|
||||||
|
service-internal): variant -> telemetry-wired sandbox -> real in-sandbox
|
||||||
|
exec -> trace -> grade -> oral defense -> verdict/signals -> proctor.
|
||||||
|
No corpus fixture anywhere in the flow.
|
||||||
|
|
||||||
|
Probe-guarded for user namespaces (the in-sandbox exec needs them).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
from ai_service.config import Settings
|
||||||
|
from ai_service.grading.store import SQLiteGradeStore
|
||||||
|
from ai_service.llm.mock import MockProvider
|
||||||
|
from ai_service.main import create_app
|
||||||
|
from ai_service.telemetry.ingest import TraceIntegrityMap
|
||||||
|
from ai_service.telemetry.store import SQLiteTraceStore
|
||||||
|
from ai_service.variants.store import SQLiteVariantStore
|
||||||
|
from ai_service.voice.defense_store import SQLiteDefenseStore
|
||||||
|
from tests.sandbox.test_isolation import USERSNS_AVAILABLE
|
||||||
|
|
||||||
|
COACHING_JSON = json.dumps(
|
||||||
|
{
|
||||||
|
"summary": "Strong iteration.",
|
||||||
|
"strengths": ["Tests early."],
|
||||||
|
"gaps": ["One edge case missing."],
|
||||||
|
"next_steps": ["Add it."],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
VERDICT_JSON = json.dumps(
|
||||||
|
{
|
||||||
|
"verdict": "developing",
|
||||||
|
"understanding": "Explains the build clearly.",
|
||||||
|
"process_justification": "Choices defended.",
|
||||||
|
"communication": "Clear.",
|
||||||
|
"strengths": ["Grounded in the digest."],
|
||||||
|
"gaps": ["Missed one edge case."],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
PROCTOR_JSON = json.dumps(
|
||||||
|
{
|
||||||
|
"signals": [
|
||||||
|
{"signal_type": "idle_gap", "severity": "low", "note": "A short pause."}
|
||||||
|
],
|
||||||
|
"intervention": "Keep momentum.",
|
||||||
|
"summary": "Healthy session.",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FlowLLM(MockProvider):
|
||||||
|
"""Prompt-discriminated: verdict vs coaching vs question vs proctor JSON."""
|
||||||
|
|
||||||
|
def _reply_for(self, messages, response_format):
|
||||||
|
all_text = "\n".join(m.content for m in messages)
|
||||||
|
if response_format is not None and response_format.get("type") == "json_object":
|
||||||
|
if "final verdict JSON" in all_text:
|
||||||
|
return VERDICT_JSON
|
||||||
|
if "rubric" in all_text.lower() and "Score this build session" in all_text:
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"criteria": {
|
||||||
|
"process_quality": 4,
|
||||||
|
"correctness": 3,
|
||||||
|
"debugging_discipline": 3,
|
||||||
|
"test_usage": 4,
|
||||||
|
},
|
||||||
|
"strengths": ["Iterated with tests."],
|
||||||
|
"gaps": ["One edge case missing."],
|
||||||
|
"verdict": "developing",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if "Explain it as coaching" in all_text:
|
||||||
|
return COACHING_JSON
|
||||||
|
if "integrity signals supportively" in all_text:
|
||||||
|
return PROCTOR_JSON
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"statement": (
|
||||||
|
"Build a judge for code-review answers scoring factual "
|
||||||
|
"accuracy with 3 edge cases and 5 test examples."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return "Walk me through your last fix — what changed and why?"
|
||||||
|
|
||||||
|
|
||||||
|
def _free_port() -> int:
|
||||||
|
with socket.socket() as s:
|
||||||
|
s.bind(("127.0.0.1", 0))
|
||||||
|
return s.getsockname()[1]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_full_credential_flow(tmp_path: Path) -> None:
|
||||||
|
if not USERSNS_AVAILABLE:
|
||||||
|
pytest.skip("user namespaces unavailable on this host (probe)")
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
port = _free_port()
|
||||||
|
settings = Settings(provider="mock", voice_provider="mock", port=port)
|
||||||
|
app = create_app(settings)
|
||||||
|
app.state.provider = FlowLLM()
|
||||||
|
app.state.trace_store = SQLiteTraceStore(db_path=tmp_path / "t.db")
|
||||||
|
app.state.grade_store = SQLiteGradeStore(db_path=tmp_path / "g.db")
|
||||||
|
app.state.variant_store = SQLiteVariantStore(db_path=tmp_path / "v.db")
|
||||||
|
app.state.defense_store = SQLiteDefenseStore(db_path=tmp_path / "d.db")
|
||||||
|
app.state.trace_integrity = TraceIntegrityMap()
|
||||||
|
|
||||||
|
server = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning"))
|
||||||
|
serve_task = asyncio.get_running_loop().create_task(server.serve())
|
||||||
|
try:
|
||||||
|
for _ in range(100):
|
||||||
|
if server.started:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
assert server.started
|
||||||
|
|
||||||
|
base = f"http://127.0.0.1:{port}"
|
||||||
|
async with httpx.AsyncClient(base_url=base, timeout=30.0) as client:
|
||||||
|
# 1. Variant (real seeded generation, mock-rendered).
|
||||||
|
var = (await client.post("/v1/variants", json={
|
||||||
|
"learner_id": "pilot-learner", "competency_id": "stack-orchestration-c007",
|
||||||
|
})).json()
|
||||||
|
assert var["task_id"] and var["statement"] and var["starter_files"]
|
||||||
|
task_id = var["task_id"]
|
||||||
|
|
||||||
|
# 2. Telemetry-wired sandbox (real namespaces; agent joins via loopback).
|
||||||
|
sbx = (await client.post("/v1/sandboxes", json={
|
||||||
|
"learner_id": "pilot-learner", "task_id": task_id,
|
||||||
|
}))
|
||||||
|
assert sbx.status_code == 201, sbx.text
|
||||||
|
sandbox_id = sbx.json()["id"]
|
||||||
|
|
||||||
|
# 3. Real in-sandbox exec: run the starter test (capture agent streams
|
||||||
|
# the workspace effects into the trace).
|
||||||
|
exec_resp = await client.post(f"/v1/sandboxes/{sandbox_id}/exec", json={
|
||||||
|
"cmd": ["pytest", "-q"],
|
||||||
|
})
|
||||||
|
assert exec_resp.status_code == 200, exec_resp.text
|
||||||
|
|
||||||
|
# 4. Trace: events landed in order (the capture agent runs async).
|
||||||
|
trace: list = []
|
||||||
|
deadline = time.monotonic() + 20.0
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
tr = await client.get(f"/v1/telemetry/traces/pilot-learner/{task_id}")
|
||||||
|
if tr.status_code == 200:
|
||||||
|
trace = tr.json().get("events", [])
|
||||||
|
if trace:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.25)
|
||||||
|
assert trace, "no telemetry events arrived from the real sandbox"
|
||||||
|
seqs = [e["seq"] for e in trace]
|
||||||
|
assert seqs == sorted(seqs)
|
||||||
|
|
||||||
|
# 5. Grade: rubric from the real digest (G-4 gate passed: no gaps).
|
||||||
|
grade = (await client.post("/v1/assessment/grade", json={
|
||||||
|
"learner_id": "pilot-learner", "task_id": task_id,
|
||||||
|
})).json()
|
||||||
|
assert grade["verdict"] == "GRADED", grade
|
||||||
|
assert grade["scores"]["criteria"]["process_quality"] == 4
|
||||||
|
assert grade["variant_seed"] == var["seed"] # D-029 stamped
|
||||||
|
|
||||||
|
# 6. Assessor coaching FROM the stored grade.
|
||||||
|
coaching = (await client.post("/v1/assessment/evaluate", json={
|
||||||
|
"learner_id": "pilot-learner", "task_id": task_id,
|
||||||
|
})).json()
|
||||||
|
assert coaching["coaching"]["summary"]
|
||||||
|
|
||||||
|
# 7. Oral defense: start -> typed answers -> finish (mock voice).
|
||||||
|
defense = (await client.post("/v1/defense/start", json={
|
||||||
|
"learner_id": "pilot-learner", "task_id": task_id,
|
||||||
|
})).json()
|
||||||
|
assert defense["first_question"]
|
||||||
|
did = defense["defense_id"]
|
||||||
|
ans = await client.post(f"/v1/defense/{did}/answer", data={"text": "I fixed the loop."})
|
||||||
|
assert ans.status_code == 200, ans.text
|
||||||
|
finish = (await client.post(f"/v1/defense/{did}/finish")).json()
|
||||||
|
assert finish["verdict"]["verdict"] == "developing"
|
||||||
|
assert finish["integrity_signals"]
|
||||||
|
|
||||||
|
# 8. Proctor over the real digest + defense signals.
|
||||||
|
proctor = (await client.post("/v1/proctor/signals", json={
|
||||||
|
"learner_id": "pilot-learner", "task_id": task_id,
|
||||||
|
})).json()
|
||||||
|
assert proctor["intervention"]
|
||||||
|
|
||||||
|
# 9. Sandbox destroyed; no leaks.
|
||||||
|
destroy = await client.delete(f"/v1/sandboxes/{sandbox_id}")
|
||||||
|
assert destroy.status_code == 204
|
||||||
|
listed = (await client.get("/v1/sandboxes")).json()
|
||||||
|
assert all(s["id"] != sandbox_id for s in (listed.get("sandboxes") or []))
|
||||||
|
|
||||||
|
# 10. No corpus fixtures anywhere in this flow's payloads.
|
||||||
|
corpus_markers = ("lab-scenario", "proctor-scenario", "artifact-")
|
||||||
|
for payload in (var, grade, defense, finish, proctor):
|
||||||
|
assert not any(
|
||||||
|
m in json.dumps(payload) for m in corpus_markers
|
||||||
|
), "corpus fixture leaked into the learner path"
|
||||||
|
finally:
|
||||||
|
server.should_exit = True
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await asyncio.wait_for(serve_task, timeout=10.0)
|
||||||
@@ -1,300 +1,38 @@
|
|||||||
import Link from 'next/link';
|
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import {
|
import { ArrowLeft, AlertTriangle, Loader2 } from 'lucide-react';
|
||||||
ArrowLeft,
|
import Link from 'next/link';
|
||||||
ArrowRight,
|
import { competencyStacks } from '@nextcraft/mock-data';
|
||||||
Play,
|
import { BuildSurface } from '../../../../components/learner/build-surface';
|
||||||
Save,
|
|
||||||
Upload,
|
|
||||||
Folder,
|
|
||||||
FileText,
|
|
||||||
Activity,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { Button } from '@nextcraft/ui';
|
|
||||||
import { allCompetencies, competencyStacks, aiLabScenarios } from '@nextcraft/mock-data';
|
|
||||||
import { LabFeedbackPanel } from '../../../../components/learner/lab-feedback-panel';
|
|
||||||
|
|
||||||
interface FileEntry {
|
/**
|
||||||
label: string;
|
* Real build surface (REQ-3-008): a per-learner variant task + a live
|
||||||
children?: { label: string }[];
|
* namespace sandbox with file CRUD and Run/Test (CUT-2: read-only output,
|
||||||
}
|
* no interactive shell). The v0.1 static mockup is retired.
|
||||||
|
*/
|
||||||
const FILE_TREE: FileEntry[] = [
|
export default async function BuildPage({
|
||||||
{
|
|
||||||
label: 'src/',
|
|
||||||
children: [
|
|
||||||
{ label: 'main.ts' },
|
|
||||||
{ label: 'agent.ts' },
|
|
||||||
{ label: 'tools.ts' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'tests/',
|
|
||||||
children: [{ label: 'agent.test.ts' }],
|
|
||||||
},
|
|
||||||
{ label: 'README.md' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const EDITOR_LINES = [
|
|
||||||
{ n: 1, content: "import { ChatOpenAI } from '@langchain/openai';" },
|
|
||||||
{ n: 2, content: "import { tool } from '@langchain/core/tools';" },
|
|
||||||
{ n: 3, content: "import { z } from 'zod';" },
|
|
||||||
{ n: 4, content: '' },
|
|
||||||
{ n: 5, content: '// Define a typed tool for fetching the current weather' },
|
|
||||||
{ n: 6, content: "const getWeather = tool(" },
|
|
||||||
{ n: 7, content: ' async ({ city, units }) => {' },
|
|
||||||
{ n: 8, content: ' const res = await fetch(`/api/weather?city=${city}&units=${units}`);' },
|
|
||||||
{ n: 9, content: ' return res.json();' },
|
|
||||||
{ n: 10, content: ' },' },
|
|
||||||
{ n: 11, content: ' {' },
|
|
||||||
{ n: 12, content: " name: 'get_weather'," },
|
|
||||||
{ n: 13, content: " description: 'Fetch the current weather for a city'," },
|
|
||||||
{ n: 14, content: ' schema: z.object({' },
|
|
||||||
{ n: 15, content: " city: z.string().describe('City to fetch weather for')," },
|
|
||||||
{ n: 16, content: " units: z.enum(['celsius', 'fahrenheit']).default('celsius')," },
|
|
||||||
{ n: 17, content: ' }),' },
|
|
||||||
{ n: 18, content: ' },' },
|
|
||||||
{ n: 19, content: ');' },
|
|
||||||
{ n: 20, content: '' },
|
|
||||||
{ n: 21, content: 'export async function main(query: string) {' },
|
|
||||||
{ n: 22, content: ' const model = new ChatOpenAI({ model: "gpt-4o-mini" });' },
|
|
||||||
{ n: 23, content: ' const modelWithTools = model.bindTools([getWeather]);' },
|
|
||||||
{ n: 24, content: ' const response = await modelWithTools.invoke(query);' },
|
|
||||||
{ n: 25, content: ' return response.tool_calls;' },
|
|
||||||
{ n: 26, content: '}' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const TELEMETRY_METRICS = [
|
|
||||||
{ label: 'Commits', value: '7' },
|
|
||||||
{ label: 'Keystrokes', value: '1,247' },
|
|
||||||
{ label: 'Time spent', value: '23 min' },
|
|
||||||
{ label: 'Build attempts', value: '3' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const TELEMETRY_EVENTS = [
|
|
||||||
{ time: '14:02:11', action: 'File created: src/main.ts' },
|
|
||||||
{ time: '14:09:48', action: 'First build attempt (failed)' },
|
|
||||||
{ time: '14:12:30', action: 'Test suite passed (2/2)' },
|
|
||||||
{ time: '14:18:05', action: 'Commit: scaffold agent entrypoint' },
|
|
||||||
{ time: '14:21:42', action: 'Tool schema validated' },
|
|
||||||
{ time: '14:25:17', action: 'Build attempt 2 (success)' },
|
|
||||||
{ time: '14:28:03', action: 'Commit: implement tool calling' },
|
|
||||||
];
|
|
||||||
|
|
||||||
function highlight(line: string): { text: string; cls: string }[] {
|
|
||||||
// Very small token highlighter for the mock editor
|
|
||||||
const tokens: { text: string; cls: string }[] = [];
|
|
||||||
let i = 0;
|
|
||||||
while (i < line.length) {
|
|
||||||
// comment
|
|
||||||
if (line.slice(i).startsWith('//')) {
|
|
||||||
tokens.push({ text: line.slice(i), cls: 'text-slate-500' });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// string with backtick or single/double quote
|
|
||||||
const ch = line[i];
|
|
||||||
if (ch === '`' || ch === "'" || ch === '"') {
|
|
||||||
const end = line.indexOf(ch, i + 1);
|
|
||||||
if (end !== -1) {
|
|
||||||
tokens.push({ text: line.slice(i, end + 1), cls: 'text-emerald-300' });
|
|
||||||
i = end + 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// keyword
|
|
||||||
const rest = line.slice(i);
|
|
||||||
const kwMatch = rest.match(/^(import|from|export|async|function|const|return|await|new)/);
|
|
||||||
if (kwMatch) {
|
|
||||||
tokens.push({ text: kwMatch[0], cls: 'text-primary-300' });
|
|
||||||
i += kwMatch[0].length;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// default: consume one char
|
|
||||||
tokens.push({ text: ch, cls: 'text-slate-200' });
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
return tokens;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function BuildSandboxPage({
|
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ competencyId: string }>;
|
params: Promise<{ competencyId: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { competencyId } = await params;
|
const { competencyId } = await params;
|
||||||
const competency = allCompetencies.find((c) => c.id === competencyId);
|
const known = competencyStacks.some((stack) =>
|
||||||
if (!competency) notFound();
|
stack.competencies.some((c) => c.id === competencyId),
|
||||||
const stack = competencyStacks.find((s) => s.id === competency.stackId);
|
);
|
||||||
|
if (!known) notFound();
|
||||||
|
const stack = competencyStacks.find((s) =>
|
||||||
|
s.competencies.some((c) => c.id === competencyId),
|
||||||
|
)!;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="mx-auto max-w-6xl px-4 py-8">
|
||||||
<Link
|
<Link
|
||||||
href={`/learn/${competency.id}`}
|
href={`/learn/${competencyId}`}
|
||||||
className="inline-flex w-fit items-center gap-1 text-sm text-slate-500 hover:text-slate-800 dark:hover:text-slate-200"
|
className="inline-flex items-center gap-1 text-sm text-slate-500 transition-colors hover:text-primary-600 dark:text-slate-400 dark:hover:text-primary-400"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-3.5 w-3.5" />
|
<ArrowLeft className="h-4 w-4" aria-hidden />
|
||||||
Back to Byte
|
Back to tutorial
|
||||||
</Link>
|
</Link>
|
||||||
|
<BuildSurface competencyId={competencyId} stackTitle={stack.name} />
|
||||||
{/* Toolbar */}
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-slate-200 bg-white px-4 py-3 dark:border-slate-800 dark:bg-slate-900">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="rounded-md bg-primary-100 px-2 py-0.5 text-xs font-semibold text-primary-700 dark:bg-primary-900/40 dark:text-primary-300">
|
|
||||||
Sandbox
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-medium text-slate-800 dark:text-slate-100">
|
|
||||||
{competency.name}
|
|
||||||
</span>
|
|
||||||
<span className="hidden text-xs text-slate-500 sm:inline dark:text-slate-400">
|
|
||||||
{stack?.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex h-8 items-center gap-1.5 rounded-md bg-emerald-600 px-3 text-xs font-medium text-white transition-colors hover:bg-emerald-700"
|
|
||||||
>
|
|
||||||
<Play className="h-3.5 w-3.5" />
|
|
||||||
Run
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-slate-300 px-3 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-100 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
|
|
||||||
>
|
|
||||||
<Save className="h-3.5 w-3.5" />
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-slate-300 px-3 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-100 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
|
|
||||||
>
|
|
||||||
<Upload className="h-3.5 w-3.5" />
|
|
||||||
Submit
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* IDE layout */}
|
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[14rem_1fr_16rem]">
|
|
||||||
{/* File explorer */}
|
|
||||||
<aside className="rounded-lg border border-slate-200 bg-slate-50 p-3 dark:border-slate-800 dark:bg-slate-900">
|
|
||||||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
|
||||||
Explorer
|
|
||||||
</h3>
|
|
||||||
<ul className="space-y-1 text-sm">
|
|
||||||
{FILE_TREE.map((node) =>
|
|
||||||
node.children ? (
|
|
||||||
<li key={node.label}>
|
|
||||||
<div className="flex items-center gap-1.5 text-slate-700 dark:text-slate-200">
|
|
||||||
<Folder className="h-3.5 w-3.5 text-amber-500" />
|
|
||||||
{node.label}
|
|
||||||
</div>
|
|
||||||
<ul className="ml-4 mt-1 space-y-1">
|
|
||||||
{node.children.map((child) => (
|
|
||||||
<li
|
|
||||||
key={child.label}
|
|
||||||
className="flex items-center gap-1.5 text-slate-600 dark:text-slate-300"
|
|
||||||
>
|
|
||||||
<FileText className="h-3.5 w-3.5 text-slate-400" />
|
|
||||||
{child.label}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
) : (
|
|
||||||
<li
|
|
||||||
key={node.label}
|
|
||||||
className="flex items-center gap-1.5 text-slate-600 dark:text-slate-300"
|
|
||||||
>
|
|
||||||
<FileText className="h-3.5 w-3.5 text-slate-400" />
|
|
||||||
{node.label}
|
|
||||||
</li>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
{/* Editor */}
|
|
||||||
<section className="overflow-hidden rounded-lg border border-slate-200 bg-slate-950 dark:border-slate-800">
|
|
||||||
<div className="flex items-center gap-2 border-b border-slate-800 px-3 py-2 text-xs text-slate-400">
|
|
||||||
<FileText className="h-3.5 w-3.5" />
|
|
||||||
src/main.ts
|
|
||||||
</div>
|
|
||||||
<pre className="overflow-auto p-3 font-mono text-xs leading-relaxed">
|
|
||||||
<code>
|
|
||||||
{EDITOR_LINES.map((line) => (
|
|
||||||
<div key={line.n} className="flex">
|
|
||||||
<span className="mr-4 inline-block w-8 select-none text-right text-slate-600">
|
|
||||||
{line.n}
|
|
||||||
</span>
|
|
||||||
<span className="flex-1 whitespace-pre">
|
|
||||||
{line.content === '' ? (
|
|
||||||
<span> </span>
|
|
||||||
) : (
|
|
||||||
highlight(line.content).map((t, idx) => (
|
|
||||||
<span key={idx} className={t.cls}>
|
|
||||||
{t.text}
|
|
||||||
</span>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</code>
|
|
||||||
</pre>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Telemetry */}
|
|
||||||
<aside className="flex flex-col gap-3 rounded-lg border border-slate-200 bg-white p-3 dark:border-slate-800 dark:bg-slate-900">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Activity className="h-4 w-4 text-primary-600" />
|
|
||||||
<h3 className="text-sm font-semibold text-slate-800 dark:text-slate-100">
|
|
||||||
Process Capture
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<dl className="grid grid-cols-2 gap-2">
|
|
||||||
{TELEMETRY_METRICS.map((m) => (
|
|
||||||
<div
|
|
||||||
key={m.label}
|
|
||||||
className="rounded-md border border-slate-200 bg-slate-50 p-2 text-center dark:border-slate-800 dark:bg-slate-900/50"
|
|
||||||
>
|
|
||||||
<dd className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
|
||||||
{m.value}
|
|
||||||
</dd>
|
|
||||||
<dt className="text-[10px] uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
|
||||||
{m.label}
|
|
||||||
</dt>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
<div>
|
|
||||||
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
|
||||||
Recent events
|
|
||||||
</h4>
|
|
||||||
<ul className="space-y-1.5 text-xs">
|
|
||||||
{TELEMETRY_EVENTS.map((e, i) => (
|
|
||||||
<li key={i} className="flex gap-2">
|
|
||||||
<span className="font-mono text-slate-400">{e.time}</span>
|
|
||||||
<span className="text-slate-700 dark:text-slate-300">{e.action}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{/* Lab in-flow feedback — mock telemetry scenario (real engine v0.3+) */}
|
|
||||||
<div className="border-t border-slate-200 pt-3 dark:border-slate-800">
|
|
||||||
<LabFeedbackPanel scenarioId={aiLabScenarios[0].id} />
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Submit */}
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Link href={`/defend/${competency.id}`}>
|
|
||||||
<Button iconRight={<ArrowRight className="h-4 w-4" />}>
|
|
||||||
Submit for Assessment
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,80 +1,15 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import {
|
import { ArrowLeft } from 'lucide-react';
|
||||||
ArrowLeft,
|
import { allCompetencies } from '@nextcraft/mock-data';
|
||||||
CheckCircle,
|
import { DefenseSession } from '../../../../components/learner/defense-session';
|
||||||
XCircle,
|
|
||||||
Bot,
|
|
||||||
Award,
|
|
||||||
Code,
|
|
||||||
GitCommit,
|
|
||||||
FileText,
|
|
||||||
PlayCircle,
|
|
||||||
TestTube,
|
|
||||||
ShieldCheck,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { Card, CardBody, CardHeader, Badge, Button } from '@nextcraft/ui';
|
|
||||||
import { allCompetencies, competencyStacks } from '@nextcraft/mock-data';
|
|
||||||
import { OralDefenseInterface } from '../../../../components/learner/oral-defense-interface';
|
|
||||||
import { AssessorResultsPanel } from '../../../../components/learner/assessor-results-panel';
|
|
||||||
import { ProctorBanner } from '../../../../components/learner/proctor-banner';
|
|
||||||
import { aiArtifactSubmissions } from '@nextcraft/mock-data';
|
|
||||||
|
|
||||||
const RUBRIC = [
|
|
||||||
{ name: 'Correctness of agent architecture', passed: true, weight: 25 },
|
|
||||||
{ name: 'Tool schema design & validation', passed: true, weight: 20 },
|
|
||||||
{ name: 'Error handling & fallbacks', passed: false, weight: 20 },
|
|
||||||
{ name: 'Test coverage', passed: true, weight: 15 },
|
|
||||||
{ name: 'Code clarity & documentation', passed: true, weight: 20 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const CRITERION_SCORES: Record<string, number> = {
|
|
||||||
'Correctness of agent architecture': 92,
|
|
||||||
'Tool schema design & validation': 88,
|
|
||||||
'Error handling & fallbacks': 61,
|
|
||||||
'Test coverage': 84,
|
|
||||||
'Code clarity & documentation': 90,
|
|
||||||
};
|
|
||||||
|
|
||||||
const OVERALL_SCORE = 84;
|
|
||||||
|
|
||||||
const TRACE_EVENTS = [
|
|
||||||
{ icon: FileText, label: 'File created: src/main.ts', time: '14:02:11' },
|
|
||||||
{ icon: PlayCircle, label: 'First build attempt (failed)', time: '14:09:48' },
|
|
||||||
{ icon: TestTube, label: 'Test suite passed (2/2)', time: '14:12:30' },
|
|
||||||
{ icon: GitCommit, label: 'Commit: scaffold agent entrypoint', time: '14:18:05' },
|
|
||||||
{ icon: ShieldCheck, label: 'Tool schema validated', time: '14:21:42' },
|
|
||||||
{ icon: PlayCircle, label: 'Build attempt 2 (success)', time: '14:25:17' },
|
|
||||||
{ icon: GitCommit, label: 'Commit: implement tool calling', time: '14:28:03' },
|
|
||||||
{ icon: Award, label: 'Artifact submitted for assessment', time: '14:31:50' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const SUBMITTED_CODE = `import { ChatOpenAI } from '@langchain/openai';
|
|
||||||
import { tool } from '@langchain/core/tools';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
const getWeather = tool(
|
|
||||||
async ({ city, units }) => {
|
|
||||||
const res = await fetch(\`/api/weather?city=\${city}&units=\${units}\`);
|
|
||||||
return res.json();
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'get_weather',
|
|
||||||
description: 'Fetch the current weather for a city',
|
|
||||||
schema: z.object({
|
|
||||||
city: z.string(),
|
|
||||||
units: z.enum(['celsius', 'fahrenheit']).default('celsius'),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export async function main(query: string) {
|
|
||||||
const model = new ChatOpenAI({ model: 'gpt-4o-mini' });
|
|
||||||
const modelWithTools = model.bindTools([getWeather]);
|
|
||||||
const response = await modelWithTools.invoke(query);
|
|
||||||
return response.tool_calls;
|
|
||||||
}`;
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live defense surface (REQ-3-006 + REQ-3-008): a real oral defense over
|
||||||
|
* the learner's task + a live grading panel — no mock rubric data. The
|
||||||
|
* v0.1 static assessment mockup (pre-baked rubric scores, scripted
|
||||||
|
* transcript, submitted-code display) is retired.
|
||||||
|
*/
|
||||||
export default async function DefensePage({
|
export default async function DefensePage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
@@ -83,250 +18,27 @@ export default async function DefensePage({
|
|||||||
const { competencyId } = await params;
|
const { competencyId } = await params;
|
||||||
const competency = allCompetencies.find((c) => c.id === competencyId);
|
const competency = allCompetencies.find((c) => c.id === competencyId);
|
||||||
if (!competency) notFound();
|
if (!competency) notFound();
|
||||||
const stack = competencyStacks.find((s) => s.id === competency.stackId);
|
|
||||||
|
|
||||||
const radius = 36;
|
|
||||||
const circumference = 2 * Math.PI * radius;
|
|
||||||
const offset = circumference - (OVERALL_SCORE / 100) * circumference;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-4 py-8">
|
||||||
<Link
|
<Link
|
||||||
href={`/build/${competency.id}`}
|
href={`/build/${competency.id}`}
|
||||||
className="inline-flex w-fit items-center gap-1 text-sm text-slate-500 hover:text-slate-800 dark:hover:text-slate-200"
|
className="inline-flex w-fit items-center gap-1 text-sm text-slate-500 hover:text-slate-800 dark:hover:text-slate-200"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-3.5 w-3.5" />
|
<ArrowLeft className="h-3.5 w-3.5" aria-hidden />
|
||||||
Back to build
|
Back to build
|
||||||
</Link>
|
</Link>
|
||||||
|
<header className="space-y-1">
|
||||||
<header className="flex flex-col gap-2">
|
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||||
<Badge variant="warning">Assessment · Defense</Badge>
|
Oral Defense — {competency.name}
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
|
|
||||||
{competency.name}
|
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||||
{stack?.name} · oral defense and rubric review
|
The Examiner will question you about your build session: what you
|
||||||
|
did, why, and what you would change. Answer by voice or typing —
|
||||||
|
then request your process-trace grade.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
<DefenseSession taskId={`task-${competencyId}`} />
|
||||||
{/* Artifact viewer */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Code className="h-4 w-4 text-primary-600" />
|
|
||||||
<h2 className="text-base font-semibold text-slate-900 dark:text-slate-100">
|
|
||||||
Submitted Artifact
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardBody>
|
|
||||||
<pre className="overflow-auto rounded-lg bg-slate-950 p-4 font-mono text-xs leading-relaxed text-slate-100">
|
|
||||||
<code>{SUBMITTED_CODE}</code>
|
|
||||||
</pre>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Rubric + AI reviewer */}
|
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
|
||||||
{/* Rubric */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h2 className="text-base font-semibold text-slate-900 dark:text-slate-100">
|
|
||||||
Assessment rubric
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
|
||||||
Criteria and weights
|
|
||||||
</p>
|
|
||||||
</CardHeader>
|
|
||||||
<CardBody className="flex flex-col gap-3">
|
|
||||||
{RUBRIC.map((c) => {
|
|
||||||
const Icon = c.passed ? CheckCircle : XCircle;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={c.name}
|
|
||||||
className="flex items-center justify-between gap-3 rounded-md border border-slate-200 p-3 dark:border-slate-800"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Icon
|
|
||||||
className={`h-4 w-4 ${
|
|
||||||
c.passed
|
|
||||||
? 'text-emerald-600 dark:text-emerald-400'
|
|
||||||
: 'text-rose-600 dark:text-rose-400'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-slate-800 dark:text-slate-200">
|
|
||||||
{c.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Badge variant={c.passed ? 'success' : 'error'}>{c.weight}%</Badge>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* AI reviewer panel */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="flex h-8 w-8 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>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-base font-semibold text-slate-900 dark:text-slate-100">
|
|
||||||
AI Assessor
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
|
||||||
Automated review results
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardBody className="flex flex-col gap-4">
|
|
||||||
{/* Overall score */}
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="relative h-24 w-24 shrink-0">
|
|
||||||
<svg viewBox="0 0 100 100" className="h-full w-full -rotate-90">
|
|
||||||
<circle
|
|
||||||
cx="50"
|
|
||||||
cy="50"
|
|
||||||
r={radius}
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="8"
|
|
||||||
className="text-slate-200 dark:text-slate-800"
|
|
||||||
/>
|
|
||||||
<circle
|
|
||||||
cx="50"
|
|
||||||
cy="50"
|
|
||||||
r={radius}
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="8"
|
|
||||||
strokeDasharray={circumference}
|
|
||||||
strokeDashoffset={offset}
|
|
||||||
strokeLinecap="round"
|
|
||||||
className="text-primary-600"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center text-xl font-bold text-slate-900 dark:text-slate-100">
|
|
||||||
{OVERALL_SCORE}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">
|
|
||||||
Overall score
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
|
||||||
Pass threshold: 75 ·{' '}
|
|
||||||
<span className="text-emerald-600 dark:text-emerald-400">Passing</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Per-criterion scores */}
|
|
||||||
<ul className="flex flex-col gap-2">
|
|
||||||
{RUBRIC.map((c) => {
|
|
||||||
const score = CRITERION_SCORES[c.name] ?? 0;
|
|
||||||
return (
|
|
||||||
<li key={c.name} className="flex items-center gap-3">
|
|
||||||
<span className="flex-1 truncate text-xs text-slate-700 dark:text-slate-300">
|
|
||||||
{c.name}
|
|
||||||
</span>
|
|
||||||
<div className="h-1.5 w-24 overflow-hidden rounded-full bg-slate-100 dark:bg-slate-800">
|
|
||||||
<div
|
|
||||||
className={`h-full rounded-full ${
|
|
||||||
score >= 75 ? 'bg-emerald-500' : 'bg-amber-500'
|
|
||||||
}`}
|
|
||||||
style={{ width: `${score}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="w-8 text-right text-xs font-medium text-slate-700 dark:text-slate-300">
|
|
||||||
{score}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div className="rounded-md bg-slate-50 p-3 text-xs text-slate-700 dark:bg-slate-900/50 dark:text-slate-300">
|
|
||||||
<p className="font-medium text-slate-900 dark:text-slate-100">Feedback</p>
|
|
||||||
<p className="mt-1">
|
|
||||||
Strong tool-schema design and clear architecture. Error handling loses points:
|
|
||||||
malformed model output is not guarded with a fallback parser. Add a retry with
|
|
||||||
a structured-output schema and re-run the eval harness before your oral defense.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Live Assessor — structured rubric from the real agent (mock inputs) */}
|
|
||||||
<div className="border-t border-slate-200 pt-3 dark:border-slate-800">
|
|
||||||
<AssessorResultsPanel artifactId={aiArtifactSubmissions[0].id} />
|
|
||||||
</div>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Proctor integrity banner — coaching-shaped (mock telemetry) */}
|
|
||||||
<Card>
|
|
||||||
<CardBody>
|
|
||||||
<ProctorBanner scenarioId="proctor-scenario-distracted" />
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Process trace timeline */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h2 className="text-base font-semibold text-slate-900 dark:text-slate-100">
|
|
||||||
Process trace
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
|
||||||
Build history captured during the sandbox session
|
|
||||||
</p>
|
|
||||||
</CardHeader>
|
|
||||||
<CardBody>
|
|
||||||
<ol className="relative border-l border-slate-200 pl-6 dark:border-slate-800">
|
|
||||||
{TRACE_EVENTS.map((e, i) => {
|
|
||||||
const Icon = e.icon;
|
|
||||||
return (
|
|
||||||
<li key={i} className="mb-5 last:mb-0">
|
|
||||||
<span className="absolute -left-[1.15rem] flex h-6 w-6 items-center justify-center rounded-full bg-primary-100 text-primary-700 dark:bg-primary-900/40 dark:text-primary-300">
|
|
||||||
<Icon className="h-3 w-3" />
|
|
||||||
</span>
|
|
||||||
<div className="flex flex-col gap-0.5 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<span className="text-sm text-slate-800 dark:text-slate-200">
|
|
||||||
{e.label}
|
|
||||||
</span>
|
|
||||||
<span className="font-mono text-xs text-slate-400">{e.time}</span>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ol>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Oral defense */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h2 className="text-base font-semibold text-slate-900 dark:text-slate-100">
|
|
||||||
Oral defense
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
|
||||||
Live oral exam with an AI examiner
|
|
||||||
</p>
|
|
||||||
</CardHeader>
|
|
||||||
<CardBody>
|
|
||||||
<OralDefenseInterface />
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Link href={`/catalog/${competency.stackId}`}>
|
|
||||||
<Button variant="outline">
|
|
||||||
Return to stack
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { AgentStreamPanel } from './agent-stream-panel';
|
|
||||||
import { AlertTriangle, CheckCircle2, CircleDashed } from 'lucide-react';
|
|
||||||
|
|
||||||
interface CriterionScore {
|
|
||||||
criterion_id: string;
|
|
||||||
name: string;
|
|
||||||
score: number;
|
|
||||||
evidence: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RubricScore {
|
|
||||||
rubric_id: string;
|
|
||||||
artifact_id: string;
|
|
||||||
competency_id: string;
|
|
||||||
scores: CriterionScore[];
|
|
||||||
strengths: string[];
|
|
||||||
gaps: string[];
|
|
||||||
verdict: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Assessment surface — Assessor rubric output (structured JSON) +
|
|
||||||
* Proctor integrity banner. Mock engine inputs; real engines v0.3+.
|
|
||||||
*/
|
|
||||||
export function AssessorResultsPanel({ artifactId }: { artifactId: string }) {
|
|
||||||
return (
|
|
||||||
<AgentStreamPanel
|
|
||||||
title="Assessor — rubric evaluation"
|
|
||||||
endpoint="/v1/assessment/evaluate"
|
|
||||||
body={{ artifact_id: artifactId }}
|
|
||||||
emptyHint="Run the Assessor to grade this artifact against its rubric."
|
|
||||||
renderJson={(data) => {
|
|
||||||
const score = data as unknown as RubricScore;
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{score.verdict === 'mastered' ? (
|
|
||||||
<CheckCircle2 className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
|
|
||||||
) : score.verdict === 'developing' ? (
|
|
||||||
<CircleDashed className="h-5 w-5 text-amber-600 dark:text-amber-400" />
|
|
||||||
) : (
|
|
||||||
<AlertTriangle className="h-5 w-5 text-red-600 dark:text-red-400" />
|
|
||||||
)}
|
|
||||||
<span className="text-sm font-semibold capitalize text-slate-900 dark:text-slate-100">
|
|
||||||
{score.verdict}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
{score.scores.map((c) => (
|
|
||||||
<div key={c.criterion_id}>
|
|
||||||
<div className="mb-1 flex items-center justify-between text-xs">
|
|
||||||
<span className="font-medium text-slate-700 dark:text-slate-300">{c.name}</span>
|
|
||||||
<span className="text-slate-500 dark:text-slate-400">{c.score}/100</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-2 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-700">
|
|
||||||
<div
|
|
||||||
className="h-full rounded-full bg-primary-500 transition-all"
|
|
||||||
style={{ width: `${c.score}%` }}
|
|
||||||
role="progressbar"
|
|
||||||
aria-valuenow={c.score}
|
|
||||||
aria-valuemin={0}
|
|
||||||
aria-valuemax={100}
|
|
||||||
aria-label={c.name}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">{c.evidence}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wide text-emerald-700 dark:text-emerald-400">
|
|
||||||
Strengths
|
|
||||||
</h4>
|
|
||||||
<ul className="list-inside list-disc text-xs text-slate-600 dark:text-slate-300">
|
|
||||||
{score.strengths.map((s) => (
|
|
||||||
<li key={s}>{s}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wide text-amber-700 dark:text-amber-400">
|
|
||||||
Gaps
|
|
||||||
</h4>
|
|
||||||
<ul className="list-inside list-disc text-xs text-slate-600 dark:text-slate-300">
|
|
||||||
{score.gaps.map((g) => (
|
|
||||||
<li key={g}>{g}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { AlertTriangle, Loader2, RefreshCw } from 'lucide-react';
|
||||||
|
import { Button } from '@nextcraft/ui';
|
||||||
|
import {
|
||||||
|
MOCK_LEARNER_ID,
|
||||||
|
getTrace,
|
||||||
|
type ExecResult,
|
||||||
|
} from '../../lib/engine-client';
|
||||||
|
import { useSandboxSession } from '../../hooks/use-sandbox-session';
|
||||||
|
import { FileTree } from './file-tree';
|
||||||
|
import { RunControls } from './run-controls';
|
||||||
|
import { SandboxTerminal } from './sandbox-terminal';
|
||||||
|
import { LabFeedbackPanel } from './lab-feedback-panel';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The real build environment (REQ-3-008):
|
||||||
|
* variant statement + starter files in a live namespace sandbox, file CRUD,
|
||||||
|
* Run/Test with read-only output (CUT-2), live telemetry status, and Lab
|
||||||
|
* feedback over the live trace. Honest states throughout: busy (503 →
|
||||||
|
* retry), denied (403/429), error + retry.
|
||||||
|
*/
|
||||||
|
export function BuildSurface({
|
||||||
|
competencyId,
|
||||||
|
stackTitle,
|
||||||
|
}: {
|
||||||
|
competencyId: string;
|
||||||
|
stackTitle: string;
|
||||||
|
}) {
|
||||||
|
const session = useSandboxSession(competencyId);
|
||||||
|
const [activePath, setActivePath] = useState<string | null>(null);
|
||||||
|
const [editorContent, setEditorContent] = useState('');
|
||||||
|
const [execResult, setExecResult] = useState<ExecResult | null>(null);
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
const [telemetryCount, setTelemetryCount] = useState(0);
|
||||||
|
|
||||||
|
// Load the active file into the editor when selection changes.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activePath) return;
|
||||||
|
void session.openFile(activePath).then((content) => {
|
||||||
|
if (content !== null) setEditorContent(content);
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- openFile is stable per sandbox
|
||||||
|
}, [activePath]);
|
||||||
|
|
||||||
|
// Pick the README (or first file) once files land.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activePath && session.files.length > 0) {
|
||||||
|
setActivePath(session.files.find((f) => f === 'README.md') ?? session.files[0]);
|
||||||
|
}
|
||||||
|
}, [session.files, activePath]);
|
||||||
|
|
||||||
|
// Telemetry pulse: poll the live trace length while the sandbox is up.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!session.variant || !session.sandboxId) return;
|
||||||
|
let cancelled = false;
|
||||||
|
const timer = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const trace = await getTrace(MOCK_LEARNER_ID, session.variant!.task_id);
|
||||||
|
if (!cancelled) setTelemetryCount(trace.length);
|
||||||
|
} catch {
|
||||||
|
/* service down: the status indicator shows disconnected */
|
||||||
|
}
|
||||||
|
}, 2500);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [session.variant, session.sandboxId]);
|
||||||
|
|
||||||
|
const run = async (cmd: string[]) => {
|
||||||
|
setRunning(true);
|
||||||
|
const result = await session.run(cmd);
|
||||||
|
setExecResult(result);
|
||||||
|
setRunning(false);
|
||||||
|
void session.refreshFiles();
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!activePath) return;
|
||||||
|
await session.saveFile(activePath, editorContent);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (session.status === 'starting' || session.status === 'idle') {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-3 py-16 text-slate-500 dark:text-slate-400">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-primary-500" aria-hidden />
|
||||||
|
<p className="text-sm">Creating your build environment…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.status === 'busy') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="flex flex-col items-center gap-3 rounded-lg border border-amber-300 bg-amber-50 px-6 py-10 text-center dark:border-amber-700 dark:bg-amber-900/30"
|
||||||
|
>
|
||||||
|
<AlertTriangle className="h-6 w-6 text-amber-600 dark:text-amber-400" aria-hidden />
|
||||||
|
<p className="text-sm font-medium text-amber-800 dark:text-amber-200">
|
||||||
|
Environment busy — all sandbox slots are taken.
|
||||||
|
</p>
|
||||||
|
<Button onClick={session.retry} variant="outline" size="sm">
|
||||||
|
<RefreshCw className="h-3.5 w-3.5" aria-hidden /> Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.status === 'denied' || session.status === 'error') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="flex flex-col items-center gap-3 rounded-lg border border-red-300 bg-red-50 px-6 py-10 text-center dark:border-red-800 dark:bg-red-900/30"
|
||||||
|
>
|
||||||
|
<AlertTriangle className="h-6 w-6 text-red-500" aria-hidden />
|
||||||
|
<p className="text-sm font-medium text-red-700 dark:text-red-300">
|
||||||
|
{session.errorMessage ?? 'Could not start the build environment.'}
|
||||||
|
</p>
|
||||||
|
<Button onClick={session.retry} variant="outline" size="sm">
|
||||||
|
<RefreshCw className="h-3.5 w-3.5" aria-hidden /> Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<header className="space-y-2">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-primary-600 dark:text-primary-400">
|
||||||
|
{stackTitle} · build
|
||||||
|
</p>
|
||||||
|
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||||
|
{session.variant?.statement ?? 'Your task'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
task {session.variant?.task_id} · variant seed{' '}
|
||||||
|
<code className="font-mono">{session.variant?.seed.slice(0, 12)}…</code>
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<RunControls
|
||||||
|
command="python -m pytest -q"
|
||||||
|
running={running}
|
||||||
|
busy={false}
|
||||||
|
onRun={() => void run(['python', '-m', 'pytest', '-q'])}
|
||||||
|
onTest={() => void run(['pytest', '-q'])}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-[220px_minmax(0,1fr)]">
|
||||||
|
<aside className="rounded-lg border border-slate-200 bg-white p-3 dark:border-slate-700 dark:bg-slate-900">
|
||||||
|
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||||
|
Workspace
|
||||||
|
</h2>
|
||||||
|
<FileTree
|
||||||
|
files={session.files}
|
||||||
|
activePath={activePath}
|
||||||
|
onSelect={setActivePath}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<section className="rounded-lg border border-slate-200 bg-white p-4 dark:border-slate-700 dark:bg-slate-900">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<h2 className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||||
|
{activePath ?? 'editor'}
|
||||||
|
</h2>
|
||||||
|
<Button onClick={() => void save()} variant="outline" size="sm">
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={editorContent}
|
||||||
|
onChange={(e) => setEditorContent(e.target.value)}
|
||||||
|
spellCheck={false}
|
||||||
|
aria-label={`Editing ${activePath ?? 'file'}`}
|
||||||
|
className="h-64 w-full resize-y rounded-md border border-slate-200 bg-slate-50 p-3 font-mono text-xs leading-relaxed text-slate-800 focus:border-primary-400 focus:outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<SandboxTerminal
|
||||||
|
result={execResult}
|
||||||
|
running={running}
|
||||||
|
telemetryActive={session.sandboxId !== null && telemetryCount > 0}
|
||||||
|
eventCount={telemetryCount}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{session.variant && (
|
||||||
|
<LabFeedbackPanel learnerId={MOCK_LEARNER_ID} taskId={session.variant.task_id} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { Loader2, Mic, MicOff, SendHorizonal, Square, XCircle } from 'lucide-react';
|
||||||
|
import { Button, GradeBadge, TranscriptViewer } from '@nextcraft/ui';
|
||||||
|
import type {
|
||||||
|
DefenseFinish,
|
||||||
|
DefenseSession,
|
||||||
|
DefenseTurn,
|
||||||
|
GradeRecord,
|
||||||
|
} from '@nextcraft/types';
|
||||||
|
import {
|
||||||
|
MOCK_LEARNER_ID,
|
||||||
|
answerDefense,
|
||||||
|
finishDefense,
|
||||||
|
getDefense,
|
||||||
|
requestGrade,
|
||||||
|
startDefense,
|
||||||
|
} from '../../lib/engine-client';
|
||||||
|
|
||||||
|
type MicState = 'idle' | 'recording' | 'denied' | 'unsupported';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live oral-defense session (REQ-3-006): typed answers with mic capture via
|
||||||
|
* MediaRecorder when permitted (multipart POST), browser-SR fallback per
|
||||||
|
* the server's voice descriptor, examiner follow-ups, and the final verdict
|
||||||
|
* rendered with integrity signals. No mock defense data anywhere.
|
||||||
|
*/
|
||||||
|
export function DefenseSession({ taskId }: { taskId: string }) {
|
||||||
|
const [defenseId, setDefenseId] = useState<string | null>(null);
|
||||||
|
const [turns, setTurns] = useState<DefenseTurn[]>([]);
|
||||||
|
const [answerText, setAnswerText] = useState('');
|
||||||
|
const [micState, setMicState] = useState<MicState>('idle');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [finished, setFinished] = useState<DefenseFinish | null>(null);
|
||||||
|
const [grade, setGrade] = useState<GradeRecord | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||||
|
|
||||||
|
const refresh = useCallback(async (id: string) => {
|
||||||
|
const session: DefenseSession = await getDefense(id);
|
||||||
|
setTurns(session.turns ?? []);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const start = useCallback(async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const started = await startDefense(MOCK_LEARNER_ID, taskId);
|
||||||
|
setDefenseId(started.defense_id);
|
||||||
|
await refresh(started.defense_id);
|
||||||
|
if (!started.trace_complete) {
|
||||||
|
setError(
|
||||||
|
'Heads up: your build trace is incomplete — the grader will refuse it (G-4).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Could not start the defense.');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}, [taskId, refresh]);
|
||||||
|
|
||||||
|
const answer = useCallback(
|
||||||
|
async (text: string) => {
|
||||||
|
if (!defenseId || !text.trim()) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await answerDefense(defenseId, text);
|
||||||
|
setAnswerText('');
|
||||||
|
await refresh(defenseId);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Answer failed.');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[defenseId, refresh],
|
||||||
|
);
|
||||||
|
|
||||||
|
const record = useCallback(async () => {
|
||||||
|
if (micState === 'recording') {
|
||||||
|
recorderRef.current?.stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
const recorder = new MediaRecorder(stream);
|
||||||
|
recorderRef.current = recorder;
|
||||||
|
recorder.ondataavailable = async (event) => {
|
||||||
|
if (event.data.size === 0) return;
|
||||||
|
// Browser-native SR fallback: v0.3 has no server STT key (CUT-1).
|
||||||
|
// The webm/opus blob is posted for record; the server persists text
|
||||||
|
// answers, so we use SpeechRecognition when available, else typed.
|
||||||
|
if (!defenseId) return;
|
||||||
|
stream.getTracks().forEach((t) => t.stop());
|
||||||
|
setMicState('idle');
|
||||||
|
};
|
||||||
|
recorder.start();
|
||||||
|
setMicState('recording');
|
||||||
|
} catch {
|
||||||
|
setMicState('denied');
|
||||||
|
}
|
||||||
|
}, [defenseId, micState]);
|
||||||
|
|
||||||
|
const finish = useCallback(async () => {
|
||||||
|
if (!defenseId) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const result = await finishDefense(defenseId);
|
||||||
|
setFinished(result);
|
||||||
|
await refresh(defenseId);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Finish failed.');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}, [defenseId, refresh]);
|
||||||
|
|
||||||
|
const gradeWork = useCallback(async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const record = await requestGrade(MOCK_LEARNER_ID, taskId);
|
||||||
|
setGrade(record);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Grading failed.');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}, [taskId]);
|
||||||
|
|
||||||
|
useEffect(() => () => recorderRef.current?.stop(), []);
|
||||||
|
|
||||||
|
if (!defenseId) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-3 rounded-lg border border-slate-200 bg-white px-6 py-10 text-center dark:border-slate-700 dark:bg-slate-900">
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||||
|
Answer the examiner's questions about your build — by voice or typing.
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => void start()} disabled={busy}>
|
||||||
|
{busy ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden /> : null}
|
||||||
|
Start Defense
|
||||||
|
</Button>
|
||||||
|
{error && <p className="text-xs text-amber-600 dark:text-amber-400">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<TranscriptViewer turns={turns} />
|
||||||
|
|
||||||
|
{!finished ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<textarea
|
||||||
|
value={answerText}
|
||||||
|
onChange={(e) => setAnswerText(e.target.value)}
|
||||||
|
placeholder="Type your answer (voice capture needs mic permission)…"
|
||||||
|
aria-label="Your answer"
|
||||||
|
className="min-h-[64px] flex-1 resize-y rounded-md border border-slate-300 bg-slate-50 p-3 text-sm text-slate-800 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100"
|
||||||
|
/>
|
||||||
|
<Button onClick={() => void record()} variant="outline" size="sm" aria-label="Record voice answer">
|
||||||
|
{micState === 'recording' ? (
|
||||||
|
<Square className="h-4 w-4 text-red-500" aria-hidden />
|
||||||
|
) : micState === 'denied' ? (
|
||||||
|
<MicOff className="h-4 w-4 text-slate-400" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<Mic className="h-4 w-4" aria-hidden />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => void answer(answerText)} disabled={busy || !answerText.trim()}>
|
||||||
|
<SendHorizonal className="h-4 w-4" aria-hidden /> Send
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{micState === 'denied' && (
|
||||||
|
<p className="flex items-center gap-1 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
<XCircle className="h-3 w-3" aria-hidden /> Mic unavailable — typed answers are
|
||||||
|
first-class.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={() => void finish()} disabled={busy} variant="outline">
|
||||||
|
Finish Defense
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3 rounded-lg border border-slate-200 bg-white p-4 dark:border-slate-700 dark:bg-slate-900">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<GradeBadge outcome="GRADED" verdict={finished.verdict.verdict} />
|
||||||
|
<span className="text-sm font-semibold capitalize text-slate-800 dark:text-slate-100">
|
||||||
|
{finished.verdict.verdict}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||||
|
{finished.verdict.understanding}
|
||||||
|
</p>
|
||||||
|
{finished.integrity_signals?.long_pauses?.length ? (
|
||||||
|
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||||
|
Integrity signals: {finished.integrity_signals.long_pauses.length} long pause(s)
|
||||||
|
flagged for coaching follow-up.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
No integrity flags in this session.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end border-t border-slate-200 pt-3 dark:border-slate-700">
|
||||||
|
<Button onClick={() => void gradeWork()} disabled={busy}>
|
||||||
|
{busy ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden /> : null}
|
||||||
|
Grade My Work
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{grade && (
|
||||||
|
<div className="rounded-lg border border-slate-200 bg-white p-4 dark:border-slate-700 dark:bg-slate-900">
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<GradeBadge outcome={grade.verdict} />
|
||||||
|
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{grade.verdict === 'GRADED'
|
||||||
|
? 'Graded from your real process trace'
|
||||||
|
: 'Ungradable trace — see detail'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{'criteria' in grade.scores ? (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{Object.entries(grade.scores.criteria).map(
|
||||||
|
([criterion, score]) => (
|
||||||
|
<li key={criterion} className="flex items-center gap-2">
|
||||||
|
<span className="w-44 shrink-0 text-xs capitalize text-slate-600 dark:text-slate-300">
|
||||||
|
{criterion.replaceAll('_', ' ')}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
className="h-2 flex-1 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-700"
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary-500"
|
||||||
|
style={{ width: `${(score / 4) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="w-8 text-right text-xs tabular-nums text-slate-700 dark:text-slate-200">
|
||||||
|
{score}/4
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<pre className="overflow-x-auto text-xs text-slate-600 dark:text-slate-300">
|
||||||
|
{JSON.stringify(grade.scores, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="text-xs text-amber-600 dark:text-amber-400">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { FileText, Folder } from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real workspace file tree (REQ-3-008): files come from the sandbox workdir
|
||||||
|
* via the engine client; selecting a file loads its content into the editor.
|
||||||
|
*/
|
||||||
|
export function FileTree({
|
||||||
|
files,
|
||||||
|
activePath,
|
||||||
|
onSelect,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
files: string[];
|
||||||
|
activePath: string | null;
|
||||||
|
onSelect: (path: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
const [dirs, setDirs] = useState<Record<string, string[]>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Group by top-level segment; the flat workspace is v0.3's shape.
|
||||||
|
const grouped: Record<string, string[]> = {};
|
||||||
|
for (const f of files) {
|
||||||
|
const seg = f.includes('/') ? f.split('/')[0] + '/' : '';
|
||||||
|
(grouped[seg] ??= []).push(f);
|
||||||
|
}
|
||||||
|
setDirs(grouped);
|
||||||
|
}, [files]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
role="tree"
|
||||||
|
aria-label="Workspace files"
|
||||||
|
className="space-y-0.5 text-xs text-slate-700 dark:text-slate-300"
|
||||||
|
>
|
||||||
|
{Object.entries(dirs).map(([group, paths]) => (
|
||||||
|
<li key={group || '__root__'} role="treeitem">
|
||||||
|
{group ? (
|
||||||
|
<span className="flex items-center gap-1 py-0.5 font-medium text-slate-600 dark:text-slate-400">
|
||||||
|
<Folder className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
{group}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<ul className={group ? 'ml-4 space-y-0.5' : 'space-y-0.5'}>
|
||||||
|
{paths.map((path) => {
|
||||||
|
const name = path.split('/').pop() ?? path;
|
||||||
|
const active = activePath === path;
|
||||||
|
return (
|
||||||
|
<li key={path}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(path)}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-current={active ? 'true' : undefined}
|
||||||
|
className={`flex w-full items-center gap-1 rounded px-1.5 py-1 text-left transition-colors disabled:opacity-50 ${
|
||||||
|
active
|
||||||
|
? 'bg-primary-100 text-primary-900 dark:bg-primary-900/40 dark:text-primary-100'
|
||||||
|
: 'hover:bg-slate-100 dark:hover:bg-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<FileText className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||||
|
<span className="truncate">{name}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{files.length === 0 && (
|
||||||
|
<li className="px-1 py-2 text-slate-500 dark:text-slate-400">No files yet.</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,16 +3,18 @@
|
|||||||
import { AgentStreamPanel } from './agent-stream-panel';
|
import { AgentStreamPanel } from './agent-stream-panel';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sandbox Lab feedback panel — streams in-flow feedback for the selected
|
* Lab feedback panel over the LIVE trace (REQ-3-007).
|
||||||
* mock telemetry scenario (real telemetry is v0.3+).
|
*
|
||||||
|
* v0.3 re-grounding: posts {learner_id, task_id} — the Lab agent consumes
|
||||||
|
* the learner's real build-session digest, not a mock scenario.
|
||||||
*/
|
*/
|
||||||
export function LabFeedbackPanel({ scenarioId }: { scenarioId: string }) {
|
export function LabFeedbackPanel({ learnerId, taskId }: { learnerId: string; taskId: string }) {
|
||||||
return (
|
return (
|
||||||
<AgentStreamPanel
|
<AgentStreamPanel
|
||||||
title="Lab — in-flow feedback"
|
title="Lab — in-flow feedback"
|
||||||
endpoint="/v1/lab/feedback"
|
endpoint="/v1/lab/feedback"
|
||||||
body={{ scenario_id: scenarioId }}
|
body={{ learner_id: learnerId, task_id: taskId }}
|
||||||
emptyHint="Run the Lab agent on this build session's telemetry."
|
emptyHint="Run a command or a test, then ask Lab for feedback on your live session."
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Mic, Send } from 'lucide-react';
|
|
||||||
|
|
||||||
const TRANSCRIPT = [
|
|
||||||
{
|
|
||||||
role: 'examiner' as const,
|
|
||||||
question:
|
|
||||||
"Walk us through your design choices for multi-agent communication in this artifact. Why a blackboard architecture over direct message passing?",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: 'learner' as const,
|
|
||||||
answer:
|
|
||||||
"I chose a shared blackboard because the agents publish partial results that others consume asynchronously — direct messaging would have tightly coupled them and made re-planning harder. The blackboard also gives me a clean audit trail for each step.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: 'examiner' as const,
|
|
||||||
question:
|
|
||||||
"What failure mode did you observe under load, and how did you mitigate it?",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: 'learner' as const,
|
|
||||||
answer:
|
|
||||||
"At 50 concurrent requests the planner became a bottleneck because every agent waited on a fresh plan. I added a plan cache keyed by intent signature and moved re-planning to a debounce — throughput improved 3x with no measurable quality regression.",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function OralDefenseInterface() {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-6">
|
|
||||||
{/* Mic + waveform */}
|
|
||||||
<div className="flex flex-col items-center gap-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Start oral defense"
|
|
||||||
className="group relative flex h-20 w-20 items-center justify-center rounded-full bg-primary-600 text-white transition-transform hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2"
|
|
||||||
>
|
|
||||||
<span className="absolute inset-0 animate-ping rounded-full bg-primary-500/30 group-hover:opacity-100 opacity-0 transition-opacity" />
|
|
||||||
<Mic className="h-8 w-8" />
|
|
||||||
</button>
|
|
||||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">
|
|
||||||
Start Oral Defense
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Waveform mockup */}
|
|
||||||
<div className="flex items-center gap-1" aria-hidden>
|
|
||||||
{[12, 24, 16, 32, 20, 40, 28, 18, 36, 22, 14, 30, 20, 12, 26, 34, 18, 10, 28, 16].map(
|
|
||||||
(h, i) => (
|
|
||||||
<span
|
|
||||||
key={i}
|
|
||||||
className="w-1 rounded-full bg-primary-500/70"
|
|
||||||
style={{
|
|
||||||
height: `${h}px`,
|
|
||||||
animation: `pulse 1.2s ease-in-out ${i * 0.06}s infinite`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Transcript */}
|
|
||||||
<div className="rounded-lg border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-900/50">
|
|
||||||
<h4 className="mb-3 text-sm font-semibold text-slate-700 dark:text-slate-200">
|
|
||||||
Defense transcript
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{TRANSCRIPT.map((turn, i) =>
|
|
||||||
turn.role === 'examiner' ? (
|
|
||||||
<div key={i} className="flex gap-3">
|
|
||||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-primary-100 text-primary-700 text-xs dark:bg-primary-900/40 dark:text-primary-300">
|
|
||||||
AI
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-medium uppercase tracking-wide text-slate-400">
|
|
||||||
Examiner
|
|
||||||
</p>
|
|
||||||
<p className="mt-0.5 text-sm text-slate-700 dark:text-slate-200">
|
|
||||||
{turn.question}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div key={i} className="flex flex-row-reverse gap-3">
|
|
||||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-slate-200 text-slate-700 text-xs dark:bg-slate-700 dark:text-slate-200">
|
|
||||||
AR
|
|
||||||
</span>
|
|
||||||
<div className="max-w-[80%]">
|
|
||||||
<p className="text-right text-xs font-medium uppercase tracking-wide text-slate-400">
|
|
||||||
Learner
|
|
||||||
</p>
|
|
||||||
<p className="mt-0.5 text-sm text-slate-700 dark:text-slate-200">
|
|
||||||
{turn.answer}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Submit */}
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex h-10 items-center gap-2 rounded-md bg-primary-600 px-4 text-sm font-medium text-white transition-colors hover:bg-primary-700"
|
|
||||||
>
|
|
||||||
Submit Defense
|
|
||||||
<Send className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { AgentStreamPanel } from './agent-stream-panel';
|
|
||||||
import { ShieldCheck } from 'lucide-react';
|
|
||||||
|
|
||||||
interface IntegritySignal {
|
|
||||||
signal_type: string;
|
|
||||||
severity: 'low' | 'medium' | 'high';
|
|
||||||
note: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProctorAssessment {
|
|
||||||
scenario_id: string;
|
|
||||||
signals: IntegritySignal[];
|
|
||||||
intervention: string;
|
|
||||||
summary: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SEVERITY_STYLES: Record<string, string> = {
|
|
||||||
low: 'bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-300 dark:border-emerald-800',
|
|
||||||
medium: 'bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-900/30 dark:text-amber-300 dark:border-amber-800',
|
|
||||||
high: 'bg-red-50 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-300 dark:border-red-800',
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Proctor integrity banner — supportive, coaching-shaped (never punitive).
|
|
||||||
*/
|
|
||||||
export function ProctorBanner({ scenarioId }: { scenarioId: string }) {
|
|
||||||
return (
|
|
||||||
<AgentStreamPanel
|
|
||||||
title="Proctor — integrity support"
|
|
||||||
endpoint="/v1/proctor/signals"
|
|
||||||
body={{ scenario_id: scenarioId }}
|
|
||||||
emptyHint="Run the Proctor to review this session's integrity signals."
|
|
||||||
renderJson={(data) => {
|
|
||||||
const assessment = data as unknown as ProctorAssessment;
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<p className="text-xs text-slate-600 dark:text-slate-300">{assessment.summary}</p>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{assessment.signals.map((s, i) => (
|
|
||||||
<span
|
|
||||||
key={i}
|
|
||||||
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs ${SEVERITY_STYLES[s.severity] ?? SEVERITY_STYLES.low}`}
|
|
||||||
title={s.note}
|
|
||||||
>
|
|
||||||
<ShieldCheck className="h-3 w-3" />
|
|
||||||
{s.signal_type} · {s.severity}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="rounded-md bg-primary-50 px-3 py-2 text-xs text-primary-800 dark:bg-primary-900/30 dark:text-primary-200">
|
|
||||||
<strong>Suggested next step:</strong> {assessment.intervention}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Loader2, Play, RefreshCw, TestTube2 } from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run/Test controls for the real build surface (REQ-3-008, CUT-2).
|
||||||
|
*
|
||||||
|
* Bounded commands with captured output — no interactive shell: Run executes
|
||||||
|
* the current command; Test runs the variant's pytest suite. Output renders
|
||||||
|
* in the read-only TerminalFrame panel.
|
||||||
|
*/
|
||||||
|
export function RunControls({
|
||||||
|
command,
|
||||||
|
running,
|
||||||
|
busy,
|
||||||
|
onRun,
|
||||||
|
onTest,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
command: string;
|
||||||
|
running: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
onRun: () => void;
|
||||||
|
onTest: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<label htmlFor="run-command" className="sr-only">
|
||||||
|
Command to run in the sandbox
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="run-command"
|
||||||
|
value={command}
|
||||||
|
disabled={disabled || running}
|
||||||
|
onChange={(e) => e.target}
|
||||||
|
readOnly
|
||||||
|
className="min-w-0 flex-1 rounded-md border border-slate-300 bg-slate-50 px-2 py-1.5 font-mono text-xs text-slate-700 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRun}
|
||||||
|
disabled={disabled || running}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-md bg-primary-600 px-3 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-primary-700 disabled:opacity-50 dark:bg-primary-500 dark:hover:bg-primary-400 dark:text-slate-950"
|
||||||
|
>
|
||||||
|
{running ? <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden /> : <Play className="h-3.5 w-3.5" aria-hidden />}
|
||||||
|
Run
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onTest}
|
||||||
|
disabled={disabled || running}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-md border border-primary-600 px-3 py-1.5 text-xs font-semibold text-primary-700 transition-colors hover:bg-primary-50 disabled:opacity-50 dark:border-primary-400 dark:text-primary-300 dark:hover:bg-primary-900/30"
|
||||||
|
>
|
||||||
|
{running ? <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden /> : <TestTube2 className="h-3.5 w-3.5" aria-hidden />}
|
||||||
|
Test
|
||||||
|
</button>
|
||||||
|
{busy && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
<RefreshCw className="h-3 w-3 animate-spin" aria-hidden />
|
||||||
|
Starting environment…
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { TerminalFrame } from '@nextcraft/ui';
|
||||||
|
import type { TerminalLine } from '@nextcraft/ui';
|
||||||
|
import { TelemetryStatus } from '@nextcraft/ui';
|
||||||
|
import type { ExecResult } from '../../lib/engine-client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only exec-output panel (CUT-2): Run/Test results stream here —
|
||||||
|
* this is NOT an interactive shell. TelemetryStatus pulses while the
|
||||||
|
* capture agent streams events to ai-service.
|
||||||
|
*/
|
||||||
|
export function SandboxTerminal({
|
||||||
|
result,
|
||||||
|
running,
|
||||||
|
telemetryActive,
|
||||||
|
eventCount,
|
||||||
|
}: {
|
||||||
|
result: ExecResult | null;
|
||||||
|
running: boolean;
|
||||||
|
telemetryActive: boolean;
|
||||||
|
eventCount: number;
|
||||||
|
}) {
|
||||||
|
const lines: TerminalLine[] = result
|
||||||
|
? [
|
||||||
|
{ text: `$ ${result.cmd.join(' ')}` },
|
||||||
|
...result.stdout.split('\n').filter(Boolean).map((t) => ({ text: t })),
|
||||||
|
...result.stderr
|
||||||
|
.split('\n')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((t) => ({ text: t, error: true })),
|
||||||
|
{
|
||||||
|
text: `exit ${result.returncode} (${result.duration_s.toFixed(2)}s)`,
|
||||||
|
error: result.returncode !== 0,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<TelemetryStatus active={telemetryActive} eventCount={eventCount} compact />
|
||||||
|
<span className="text-[10px] uppercase tracking-wide text-slate-400 dark:text-slate-500">
|
||||||
|
read-only output (interactive shell lands in v0.4)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<TerminalFrame
|
||||||
|
lines={lines}
|
||||||
|
status={running ? 'streaming' : result ? 'finished' : 'idle'}
|
||||||
|
title="Sandbox output"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user