New
Interview Prep Hub
On-device AI streaming that generates a focused preparation guide for each interview round — topics to review, likely questions with answer guidance, and round-specific tips. Powered by the Foundation Models framework (iOS 26+). Pro feature.
Preview
🏢
Senior iOS Engineer
Meridian AI · Round 1 of 5
Pro
Select a round type and click Generate to simulate the streaming output
Architecture
All generation runs on-device via JobAIService, which wraps the Foundation Models LanguageModelSession. No network call is ever made; the prep guide is transient — it is never stored.
| Layer | Component | Responsibility |
|---|---|---|
| View | InterviewPrepView | Round selector, streaming text display, loading/error states |
| Service | JobAIService.shared | Wraps LanguageModelSession, exposes streamResponse(prompt:instructions:) |
| Model | InterviewRound | round.name ("Technical", "Behavioral", etc.), links to Job for context |
| Gate | JobAIService.isAvailable | Returns false on iOS <26; view shows upgrade prompt |
Prompt structure
The prompt is assembled from three sources and capped at ~600 tokens to keep latency under 3 seconds on device.
round.nameSets the round type ("Technical", "Behavioral"…). Drives the entire structure of the prep guide.job.title + job.companyGrounds the advice in the specific role. The model tailors its examples to the industry and level.job.notes (first 500 chars)Optional extra context — pasted job description or personal notes. Omitted if empty.States
| State | Condition | UI |
|---|---|---|
| Idle | No generation started | Round selector + Generate button |
| Loading | isGenerating && content is empty | ProgressView centred; no content yet |
| Streaming | isGenerating && content non-empty | Partial text + blinking cursor ▌ + small spinner |
| Complete | Generation finished successfully | Full text; action row: Start Mock / Add to Notes |
| Unavailable | isAvailable == false (iOS < 26) | Error card with upgrade callout; no Generate button |
| Error | Stream throws (not cancelled) | Error message + Retry button |
Design notes
→
Stream text immediately — never buffer then reveal — Start appending tokens as soon as the first chunk arrives. Users find streaming far less anxious than a long spinner followed by a full dump. Even 2–3 words appearing immediately signals progress.
→
Transient by design — Prep guides are intentionally not persisted. The round context changes each time the user generates, and storing stale advice creates more confusion than value. Offer an 'Add to Notes' button to let the user opt into saving.
→
Gate gracefully on iOS < 26 — Check isAvailable before showing the Generate button. On unsupported OS versions, show an upgrade callout — never a hidden button or silent failure. The user should understand why the feature is absent.
→
Cancel on dismiss — Wrap generation in a .task modifier so the async work is automatically cancelled when the view disappears. Do not leave orphaned streams consuming CPU after the sheet is dismissed.
→
Round type drives prompt structure — A Technical prompt asks for code-level topics and algorithm questions. A Behavioral prompt asks for STAR stories. Treat round.name as the primary control variable — do not mix structures.
Implementation
InterviewPrepPanel.tsx
tsx
"use client";
import { useState, useRef, useEffect } from "react";
type RoundType = "technical" | "behavioral" | "system" | "hr" | "panel";
const ROUND_LABELS: Record<RoundType, string> = {
technical: "Technical",
behavioral: "Behavioral",
system: "System Design",
hr: "HR Screening",
panel: "Final Panel",
};
interface InterviewPrepPanelProps {
jobTitle: string;
company: string;
roundType: RoundType;
onRoundChange: (r: RoundType) => void;
/** Async generator that yields accumulated text chunks */
streamPrep: (roundType: RoundType) => AsyncGenerator<string, void, unknown>;
}
export function InterviewPrepPanel({
jobTitle, company, roundType, onRoundChange, streamPrep,
}: InterviewPrepPanelProps) {
const [content, setContent] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
const abortRef = useRef(false);
useEffect(() => { setContent(""); }, [roundType]);
async function generate() {
abortRef.current = false;
setIsStreaming(true);
setContent("");
for await (const chunk of streamPrep(roundType)) {
if (abortRef.current) break;
setContent(chunk);
}
setIsStreaming(false);
}
return (
<div className="flex flex-col gap-3">
{/* Job context */}
<div className="flex items-center gap-3 p-3 rounded-xl bg-[rgb(var(--surface-raised))] border border-[rgb(var(--border))]">
<div className="text-xs font-semibold text-[rgb(var(--text-primary))]">
{jobTitle} · {company}
</div>
<span className="ml-auto text-[11px] font-semibold px-2 py-0.5 rounded-full bg-[rgb(var(--accent)_/_0.1)] text-[rgb(var(--accent))]">
Pro
</span>
</div>
{/* Round tabs */}
<div className="flex flex-wrap gap-1.5">
{(Object.keys(ROUND_LABELS) as RoundType[]).map(r => (
<button key={r} onClick={() => onRoundChange(r)}
className={`px-3 py-1 rounded-full text-xs font-semibold border transition-colors ${
r === roundType
? "border-[rgb(var(--accent))] bg-[rgb(var(--accent)_/_0.1)] text-[rgb(var(--accent))]"
: "border-[rgb(var(--border))] text-[rgb(var(--text-secondary))] hover:border-[rgb(var(--accent)_/_0.5)]"
}`}
>
{ROUND_LABELS[r]}
</button>
))}
</div>
{/* CTA */}
<button onClick={generate} disabled={isStreaming}
className="w-full py-2.5 rounded-xl bg-[rgb(var(--accent))] text-white text-sm font-semibold disabled:opacity-60"
>
{isStreaming ? "Generating…" : `Generate ${ROUND_LABELS[roundType]} Prep`}
</button>
{/* Content */}
{(content || isStreaming) && (
<div className="p-4 rounded-2xl bg-[rgb(var(--surface))] border border-[rgb(var(--border))] text-sm text-[rgb(var(--text-secondary))] leading-relaxed whitespace-pre-line">
{content}
{isStreaming && <span className="text-[rgb(var(--accent))] animate-pulse"> ▌</span>}
</div>
)}
</div>
);
}Entitlements
| Key / Framework | Required for |
|---|---|
| com.apple.developer.foundation-models.default | On-device LanguageModelSession (Foundation Models framework, iOS 26+) |
| SubscriptionService — Pro entitlement | Gate the Generate button behind active Pro subscription |
Accessibility
- →The content area must use aria-live='polite' (web) or post an accessibility announcement when streaming completes — not on every token, which would flood the accessibility queue.
- →The blinking cursor ▌ must be aria-hidden so screen readers do not narrate it character-by-character during streaming.
- →The round selector tabs require role='tab' and aria-selected; the containing element requires role='tablist'.
- →The loading spinner must have an accessible label: 'Generating prep guide' — do not rely on the visible text alone, which may still show the previous state.
- →Minimum tap target for round tabs: 44×44 pt. Use padding to meet this without visually enlarging the text.