chore(ts-migration): migrate components/krow to TypeScript with real prop types
Phase 8, first batch: 56 files, plus three supporting edits outside the folder.
All 56 emit byte-identical JavaScript and the production bundle is byte-identical
to 543da9d.
This batch establishes the pattern for the remaining component work: real prop
interfaces built from actual call sites, reusing the generated entity types.
Fifty-four components now declare what they take -
`CandidateCard({ application: JobApplication, jobTitle?: string, rank: number })`,
`MatchedCandidates({ job: JobPosting, profiles: ProfileView[], ... })` - rather
than carrying a compatibility bag. Callbacks are optional because call sites
omit them; entity props are required because call sites always pass them. Where
a call site proved otherwise, the call site won: `ScheduleInterviewModal`'s
`position`, `CandidateCard`'s `jobTitle`, `TalentDetailModal`'s `matchScore` and
`matchReasons` are optional because real callers omit them.
Two view types absorb what the registry cannot describe. `ProfileView` and
`CourseView` narrow the `jsonb` columns these components read through -
`experience`, `completed_courses`, `earned_badges`, `capabilities`,
`score_breakdown`, `challenge`, `quiz` - to arrays and objects, while every
field backed by a real column keeps the registry's type. They are declared once
for the folder, and their narrowed fields stay REQUIRED: the registry has those
columns NOT NULL, and making them optional broke assignment back to the entity
where `ChallengeRunner` hands a course to a mutation.
TWO REAL DEFECTS FOUND, BOTH PRESERVED RATHER THAN FIXED:
1. `SuggestedTalent` calls `matchTalent.mutate({...}).then(...)`. React
Query's `mutate` returns `void`, so that `.then` throws at run time, and
`runMatch` is reachable from a button. `mutateAsync` is what the code
means. Swapping it turns a crash into a working feature, which is a
product change, not a migration one - so it is cast to compile and left
behaving exactly as it did. This one deserves a fix on its own terms.
2. `TalentPoolCard` renders a location row behind `profile.location &&`, but
`worker_profiles` has no `location` column - the registry has none and the
API cannot send one, so the row has never rendered. Recorded as an
optional field on `ProfileView` with a note, the same treatment as
`user.avatar_url` in Phase 7.
Also fixed, all type-only: the Web Speech API declared as the optional `Window`
members `AIInterviewModal` already feature-detects; four `new Promise<void>`
where `resolve()` takes no argument; `toast`'s options bag made optional on
every method, which callers had always omitted; five `krowHooks` query
arguments made optional, which callers had always omitted.
One automated pass was reverted rather than shipped. A local-component
annotator captured words out of the preceding JSDoc as prop names - producing
`interface FrameProps { one?: any; every?: any }` from a sentence about
"one surface, one padding, every step" - the same class of regex error as
Phase 6's. The whole folder was restored from the index and the sound steps
re-run, then local components were annotated with an index-spliced rewrite that
reads only the destructuring.
Verified: tsc 35 -> 22, set-difference showing thirteen removed and none added;
zero errors in any of the 56 files; all 56 emit byte-identical JavaScript;
production bundle byte-identical; npm test 1684/1691 with the same seven
failures; Owliver baseline 59/59; lint 0 errors; baseline artifacts untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
This commit is contained in:
89
src/components/krow/proving/RoleplayChallenge.tsx
Normal file
89
src/components/krow/proving/RoleplayChallenge.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { CourseView } from '../types';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Send, Loader2 } from 'lucide-react';
|
||||
import { challengeFollowUp } from '@/lib/provingGround';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
|
||||
// AI roleplay challenge — scenario prompt, worker responds, KROW follows up,
|
||||
// then the worker submits the transcript for AI evaluation.
|
||||
interface RoleplayChallengeProps {
|
||||
course: CourseView;
|
||||
onSubmit?: (...args: any[]) => void;
|
||||
submitting: boolean;
|
||||
}
|
||||
|
||||
export default function RoleplayChallenge({ course, onSubmit, submitting }: RoleplayChallengeProps) {
|
||||
const initial = course?.challenge?.prompt || course?.description || 'Demonstrate the skill.';
|
||||
const [messages, setMessages] = useState([{ role: 'ai', content: initial }]);
|
||||
const [input, setInput] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const scrollRef = useRef(null);
|
||||
|
||||
useEffect(() => { scrollRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]);
|
||||
|
||||
const send = async () => {
|
||||
const text = input.trim();
|
||||
if (!text || busy) return;
|
||||
const next = [...messages, { role: 'user', content: text }];
|
||||
setMessages(next);
|
||||
setInput('');
|
||||
setBusy(true);
|
||||
try {
|
||||
const follow = await challengeFollowUp(course, next);
|
||||
setMessages([...next, { role: 'ai', content: follow }]);
|
||||
} catch {
|
||||
setMessages([...next, { role: 'ai', content: 'Thanks. Submit your proof when ready.' }]);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const transcript = messages.map((m) => `${m.role === 'user' ? 'Worker' : 'Owliver'}: ${m.content}`).join('\n');
|
||||
const hasResponded = messages.some((m) => m.role === 'user');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<OwliverAvatar className="w-8 h-8" rounded="rounded-full" />
|
||||
<div>
|
||||
<p className="text-[13px] font-semibold text-ink-1">Owliver</p>
|
||||
<p className="text-[10px] text-ink-4">Your AI skills coach</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 mb-4 max-h-72 overflow-y-auto pr-1">
|
||||
{messages.map((m, i) => {
|
||||
const isAI = m.role !== 'user';
|
||||
return (
|
||||
<div key={i} className={`flex items-end gap-2 ${isAI ? 'justify-start' : 'justify-end'}`}>
|
||||
{isAI && <OwliverAvatar className="w-7 h-7 shrink-0" rounded="rounded-full" />}
|
||||
<div className={`max-w-[80%] px-3.5 py-2.5 rounded-xl text-[13px] leading-relaxed ${
|
||||
m.role === 'user' ? 'bg-krow-blue text-white rounded-br-sm' : 'bg-surface-sunken text-ink-1 rounded-bl-sm'
|
||||
}`}>{m.content}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={scrollRef} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-full bg-white pl-4 pr-1.5 py-1.5 shadow-[0_10px_30px_rgba(10,57,223,0.14),0_2px_6px_rgba(51,63,72,0.06)] ring-1 ring-black/5">
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && send()}
|
||||
disabled={submitting || busy}
|
||||
placeholder="Type your response…"
|
||||
className="flex-1 h-9 bg-transparent px-1 text-[13px] focus:outline-none placeholder:text-ink-4"
|
||||
/>
|
||||
<button onClick={send} disabled={busy || submitting} className="w-9 h-9 rounded-full bg-krow-blue text-white flex items-center justify-center disabled:opacity-50 shrink-0">
|
||||
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onSubmit(transcript)}
|
||||
disabled={!hasResponded || submitting || busy}
|
||||
className="mt-3 w-full h-10 rounded-xl bg-krow-navy text-white text-[13px] font-semibold disabled:opacity-40"
|
||||
>
|
||||
Submit proof for AI evaluation
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user