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.

LayerComponentResponsibility
ViewInterviewPrepViewRound selector, streaming text display, loading/error states
ServiceJobAIService.sharedWraps LanguageModelSession, exposes streamResponse(prompt:instructions:)
ModelInterviewRoundround.name ("Technical", "Behavioral", etc.), links to Job for context
GateJobAIService.isAvailableReturns 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

StateConditionUI
IdleNo generation startedRound selector + Generate button
LoadingisGenerating && content is emptyProgressView centred; no content yet
StreamingisGenerating && content non-emptyPartial text + blinking cursor ▌ + small spinner
CompleteGeneration finished successfullyFull text; action row: Start Mock / Add to Notes
UnavailableisAvailable == false (iOS < 26)Error card with upgrade callout; no Generate button
ErrorStream throws (not cancelled)Error message + Retry button

Design notes

Stream text immediately — never buffer then revealStart 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 designPrep 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 < 26Check 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 dismissWrap 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 structureA 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 / FrameworkRequired for
com.apple.developer.foundation-models.defaultOn-device LanguageModelSession (Foundation Models framework, iOS 26+)
SubscriptionService — Pro entitlementGate 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.