From 41238eef639de47823ff6ad9d812779f5a5fab62 Mon Sep 17 00:00:00 2001 From: BhuvanArn Date: Wed, 15 Jul 2026 08:36:16 +0200 Subject: [PATCH 01/16] feat: add recruiter persona roster constant --- web/src/config/personas.spec.ts | 44 ++++++++++++ web/src/config/personas.ts | 115 ++++++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 web/src/config/personas.spec.ts create mode 100644 web/src/config/personas.ts diff --git a/web/src/config/personas.spec.ts b/web/src/config/personas.spec.ts new file mode 100644 index 00000000..982c96c4 --- /dev/null +++ b/web/src/config/personas.spec.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; + +import { DEFAULT_PERSONA, PERSONAS, getPersonaById } from './personas'; + +describe('personas', () => { + it('exposes exactly four personas with unique ids', () => { + expect(PERSONAS).toHaveLength(4); + const ids = PERSONAS.map((persona) => persona.id); + expect(new Set(ids).size).toBe(4); + }); + + it('defaults to Sophie Martin with the values shipped today', () => { + expect(DEFAULT_PERSONA.id).toBe('sophie-martin'); + expect(DEFAULT_PERSONA.name).toBe('Sophie Martin'); + expect(DEFAULT_PERSONA.role).toBe('Recruteuse IT'); + }); + + it('returns the matching persona by id', () => { + expect(getPersonaById('marc-bernard').name).toBe('Marc Bernard'); + }); + + it('falls back to the default for null, undefined or unknown ids', () => { + expect(getPersonaById(null)).toBe(DEFAULT_PERSONA); + expect(getPersonaById(undefined)).toBe(DEFAULT_PERSONA); + expect(getPersonaById('does-not-exist')).toBe(DEFAULT_PERSONA); + }); + + it('ranks Marc Bernard as the sole hard persona', () => { + const hard = PERSONAS.filter((persona) => persona.difficulty === 'hard'); + expect(hard.map((persona) => persona.id)).toEqual(['marc-bernard']); + }); + + // The speech-to-speech pipeline is strict turn-taking: VAD only trims silence + // (ai/microservices/speech-to-speech/engine/pipeline.py:99-102), there is no + // barge-in. Copy promising interruption would describe behaviour the system + // cannot produce. + it('never claims a persona interrupts the candidate', () => { + const forbidden = + /interrupt|cut you off|cuts you off|talk over|talks over|rush/i; + for (const persona of PERSONAS) { + expect(persona.description).not.toMatch(forbidden); + } + }); +}); diff --git a/web/src/config/personas.ts b/web/src/config/personas.ts new file mode 100644 index 00000000..b404f3ad --- /dev/null +++ b/web/src/config/personas.ts @@ -0,0 +1,115 @@ +/** + * Recruiter personas offered before a simulation. + * + * Difficulty describes *what gets asked* — question depth, follow-ups, scrutiny. + * It must never describe pacing: the speech-to-speech pipeline is strict + * turn-taking (VAD trims silence only, no barge-in), so the AI cannot interrupt + * the candidate. `personas.spec.ts` enforces this. + * + * Role labels are French on purpose: they belong to the French interview fiction + * like the names themselves, and `Recruteuse IT` is the value already shipped. + */ +export type PersonaDifficulty = 'easy' | 'medium' | 'hard'; + +export interface RecruiterPersona { + /** Stable slug persisted in sessionStorage. */ + id: string; + name: string; + /** Displayed under the name. French — see file header. */ + role: string; + /** Card body copy. English. Must not claim interruption. */ + description: string; + difficulty: PersonaDifficulty; + /** Picker portrait initials. */ + initials: string; + /** + * Portrait background classes. Only resolves at runtime because + * `tailwind.css:5-9` safelists these via `@source inline(...)` — the scanner + * never sees this string. Keep within the safelisted set. + */ + accentClass: string; + /** + * French system prompt, authored for the AI developer who will wire the + * pipeline. Intentionally unconsumed by the web app today. + */ + systemPrompt: string; +} + +const BASE_PROMPT_RULES = + 'Tu parles de facon naturelle comme dans une vraie conversation. ' + + 'Tu dois repondre uniquement a la derniere prise de parole du candidat, ' + + 'en une seule reponse courte et naturelle. ' + + "N'ecris jamais un dialogue multi-tours, n'imite jamais des balises comme system: ou user:, " + + "et ne recopie jamais l'historique de conversation."; + +export const PERSONAS: readonly RecruiterPersona[] = [ + { + id: 'sophie-martin', + name: 'Sophie Martin', + role: 'Recruteuse IT', + description: + 'Warm and encouraging. Broad questions, accepts an answer at face value.', + difficulty: 'easy', + initials: 'SM', + accentClass: 'bg-primary', + systemPrompt: + 'Tu es Sophie Martin, recruteuse senior IT chez une ESN francaise. ' + + 'Tu es chaleureuse, professionnelle, patiente et humaine. ' + + BASE_PROMPT_RULES, + }, + { + id: 'thomas-leroy', + name: 'Thomas Leroy', + role: 'Tech Lead', + description: + 'Technical depth. Follows up on vague answers and asks you to justify choices.', + difficulty: 'medium', + initials: 'TL', + accentClass: 'bg-neutral', + systemPrompt: + 'Tu es Thomas Leroy, tech lead dans une equipe produit francaise. ' + + 'Tu es direct et precis. Tu creuses la profondeur technique des reponses ' + + 'et tu demandes de justifier les choix techniques. ' + + BASE_PROMPT_RULES, + }, + { + id: 'claire-dubois', + name: 'Claire Dubois', + role: 'DRH', + description: + 'Structured behavioural questions on motivation and teamwork. Expects concrete examples.', + difficulty: 'medium', + initials: 'CD', + accentClass: 'bg-success', + systemPrompt: + 'Tu es Claire Dubois, directrice des ressources humaines. ' + + 'Tu poses des questions comportementales structurees sur la motivation ' + + 'et le travail en equipe, et tu attends des exemples concrets. ' + + BASE_PROMPT_RULES, + }, + { + id: 'marc-bernard', + name: 'Marc Bernard', + role: 'Directeur', + description: + 'Blunt and demanding. Challenges weak reasoning and pushes back on your answers.', + difficulty: 'hard', + initials: 'MB', + accentClass: 'bg-error', + systemPrompt: + "Tu es Marc Bernard, directeur d'une entreprise francaise. " + + 'Tu es direct et exigeant. Tu remets en question les raisonnements faibles ' + + 'et tu challenges les reponses du candidat. ' + + BASE_PROMPT_RULES, + }, +]; + +/** Sophie carries the values shipped before the picker existed. */ +export const DEFAULT_PERSONA: RecruiterPersona = PERSONAS[0]; + +/** Resolves a persisted id, falling back to the default for null/unknown. */ +export function getPersonaById( + id: string | null | undefined, +): RecruiterPersona { + return PERSONAS.find((persona) => persona.id === id) ?? DEFAULT_PERSONA; +} From 803ca1ee64263e78d9294be5f910b8279030e23c Mon Sep 17 00:00:00 2001 From: BhuvanArn Date: Wed, 15 Jul 2026 08:39:47 +0200 Subject: [PATCH 02/16] feat: add persona selection store on sessionStorage --- web/src/stores/usePersonaStore.spec.ts | 39 ++++++++++++++++++++++++ web/src/stores/usePersonaStore.ts | 42 ++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 web/src/stores/usePersonaStore.spec.ts create mode 100644 web/src/stores/usePersonaStore.ts diff --git a/web/src/stores/usePersonaStore.spec.ts b/web/src/stores/usePersonaStore.spec.ts new file mode 100644 index 00000000..c44c5707 --- /dev/null +++ b/web/src/stores/usePersonaStore.spec.ts @@ -0,0 +1,39 @@ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import usePersonaStore from './usePersonaStore'; + +describe('usePersonaStore', () => { + beforeEach(() => { + act(() => usePersonaStore.getState().clearPersona()); + window.sessionStorage.clear(); + }); + + it('starts with no persona selected', () => { + const { result } = renderHook(() => usePersonaStore()); + expect(result.current.selectedPersonaId).toBeNull(); + }); + + it('stores a selected persona id', () => { + const { result } = renderHook(() => usePersonaStore()); + act(() => result.current.setPersona('marc-bernard')); + expect(result.current.selectedPersonaId).toBe('marc-bernard'); + }); + + it('clears the selection', () => { + const { result } = renderHook(() => usePersonaStore()); + act(() => result.current.setPersona('marc-bernard')); + act(() => result.current.clearPersona()); + expect(result.current.selectedPersonaId).toBeNull(); + }); + + it('persists to sessionStorage, not localStorage', () => { + const { result } = renderHook(() => usePersonaStore()); + act(() => result.current.setPersona('claire-dubois')); + + expect(window.sessionStorage.getItem('persona-storage')).toContain( + 'claire-dubois', + ); + expect(window.localStorage.getItem('persona-storage')).toBeNull(); + }); +}); diff --git a/web/src/stores/usePersonaStore.ts b/web/src/stores/usePersonaStore.ts new file mode 100644 index 00000000..710d9032 --- /dev/null +++ b/web/src/stores/usePersonaStore.ts @@ -0,0 +1,42 @@ +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; + +/** + * Persona store state and actions. + */ +interface PersonaStore { + /** Persona id chosen for the current tab, or null when unchosen. */ + selectedPersonaId: string | null; + setPersona: (id: string) => void; + clearPersona: () => void; +} + +/** + * Zustand store for the recruiter persona chosen before a simulation. + * + * Backed by **sessionStorage**, not localStorage. The choice only needs to + * survive a mid-interview reload; in localStorage it would outlive the tab and + * silently re-use a long-forgotten pick on a later visit, with no modal shown. + * sessionStorage dies with the tab, so the purge is free. + * + * @example + * ```tsx + * const selectedPersonaId = usePersonaStore((state) => state.selectedPersonaId); + * const persona = getPersonaById(selectedPersonaId); + * ``` + */ +const usePersonaStore = create()( + persist( + (set) => ({ + selectedPersonaId: null, + setPersona: (id) => set({ selectedPersonaId: id }), + clearPersona: () => set({ selectedPersonaId: null }), + }), + { + name: 'persona-storage', + storage: createJSONStorage(() => sessionStorage), + }, + ), +); + +export default usePersonaStore; From 24a33495bdcb83d544248c63b742533f3927ec22 Mon Sep 17 00:00:00 2001 From: BhuvanArn Date: Wed, 15 Jul 2026 08:44:46 +0200 Subject: [PATCH 03/16] feat: add reusable focus trap hook --- web/src/hooks/useFocusTrap.spec.tsx | 77 +++++++++++++++++++++++++ web/src/hooks/useFocusTrap.ts | 88 +++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 web/src/hooks/useFocusTrap.spec.tsx create mode 100644 web/src/hooks/useFocusTrap.ts diff --git a/web/src/hooks/useFocusTrap.spec.tsx b/web/src/hooks/useFocusTrap.spec.tsx new file mode 100644 index 00000000..5199414b --- /dev/null +++ b/web/src/hooks/useFocusTrap.spec.tsx @@ -0,0 +1,77 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { useState } from 'react'; +import { describe, expect, it } from 'vitest'; + +import { useFocusTrap } from './useFocusTrap'; + +function TrapHarness({ isActive }: { isActive: boolean }) { + const ref = useFocusTrap(isActive); + return ( +
+ + {isActive ? ( +
+ + +
+ ) : null} + +
+ ); +} + +function ToggleHarness() { + const [open, setOpen] = useState(false); + return ( +
+ + + {open ? ( + + ) : null} +
+ ); +} + +describe('useFocusTrap', () => { + it('moves focus to the first focusable element when activated', () => { + render(); + expect(screen.getByText('first')).toHaveFocus(); + }); + + it('does not steal focus when inactive', () => { + render(); + expect(document.body).toHaveFocus(); + }); + + it('wraps Tab from the last element back to the first', () => { + render(); + const last = screen.getByText('last'); + last.focus(); + fireEvent.keyDown(last, { key: 'Tab' }); + expect(screen.getByText('first')).toHaveFocus(); + }); + + it('wraps Shift+Tab from the first element back to the last', () => { + render(); + const first = screen.getByText('first'); + first.focus(); + fireEvent.keyDown(first, { key: 'Tab', shiftKey: true }); + expect(screen.getByText('last')).toHaveFocus(); + }); + + it('restores focus to the previously focused element on deactivate', () => { + render(); + const trigger = screen.getByText('trigger'); + trigger.focus(); + fireEvent.click(trigger); + expect(screen.getByText('first')).toHaveFocus(); + + fireEvent.click(screen.getByText('close')); + expect(trigger).toHaveFocus(); + }); +}); diff --git a/web/src/hooks/useFocusTrap.ts b/web/src/hooks/useFocusTrap.ts new file mode 100644 index 00000000..551ffa21 --- /dev/null +++ b/web/src/hooks/useFocusTrap.ts @@ -0,0 +1,88 @@ +import { useEffect, useRef } from 'react'; + +const FOCUSABLE_SELECTOR = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', +].join(','); + +function getFocusable(container: HTMLElement): HTMLElement[] { + return Array.from( + container.querySelectorAll(FOCUSABLE_SELECTOR), + ); +} + +/** + * Traps keyboard focus inside a container while `isActive`. + * + * The codebase has no focus-trap library and no other hand-rolled trap, so this + * is the shared primitive: attach the returned ref to a dialog container. + * + * - Moves focus to the first focusable child on activate. + * - Cycles Tab / Shift+Tab within the container. + * - Restores focus to the previously focused element on deactivate. + * + * Roving selection *within* a radiogroup is the consumer's job — this hook only + * owns the Tab boundary. + * + * @example + * ```tsx + * const dialogRef = useFocusTrap(isOpen); + * return isOpen ?
: null; + * ``` + */ +export function useFocusTrap(isActive: boolean) { + const containerRef = useRef(null); + const previouslyFocusedRef = useRef(null); + + useEffect(() => { + if (!isActive) return; + + previouslyFocusedRef.current = document.activeElement as HTMLElement | null; + + const container = containerRef.current; + if (container) { + const focusable = getFocusable(container); + (focusable[0] ?? container).focus(); + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Tab') return; + + const currentContainer = containerRef.current; + if (!currentContainer) return; + + const focusable = getFocusable(currentContainer); + if (focusable.length === 0) return; + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const active = document.activeElement; + + if (event.shiftKey && active === first) { + event.preventDefault(); + last.focus(); + return; + } + + if (!event.shiftKey && active === last) { + event.preventDefault(); + first.focus(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.removeEventListener('keydown', handleKeyDown); + previouslyFocusedRef.current?.focus(); + }; + }, [isActive]); + + return containerRef; +} + +export default useFocusTrap; From 8e7450668df595015c085e97ef64bdcd0ffbb520 Mon Sep 17 00:00:00 2001 From: BhuvanArn Date: Wed, 15 Jul 2026 09:03:57 +0200 Subject: [PATCH 04/16] feat: add recruiter persona picker modal --- .../persona-picker-modal/index.spec.tsx | 124 +++++++++ .../organisms/persona-picker-modal/index.tsx | 260 ++++++++++++++++++ .../organisms/persona-picker-modal/types.ts | 10 + 3 files changed, 394 insertions(+) create mode 100644 web/src/components/organisms/persona-picker-modal/index.spec.tsx create mode 100644 web/src/components/organisms/persona-picker-modal/index.tsx create mode 100644 web/src/components/organisms/persona-picker-modal/types.ts diff --git a/web/src/components/organisms/persona-picker-modal/index.spec.tsx b/web/src/components/organisms/persona-picker-modal/index.spec.tsx new file mode 100644 index 00000000..5c2004a4 --- /dev/null +++ b/web/src/components/organisms/persona-picker-modal/index.spec.tsx @@ -0,0 +1,124 @@ +import { DEFAULT_PERSONA, PERSONAS } from '@/config/personas'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { PersonaPickerModal } from './index'; + +function renderModal( + overrides: Partial[0]> = {}, +) { + const onSelect = vi.fn(); + const onDismiss = vi.fn(); + render( + , + ); + return { onSelect, onDismiss }; +} + +describe('PersonaPickerModal', () => { + it('renders nothing when closed', () => { + render( + , + ); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('renders every persona with its name and role', () => { + renderModal(); + for (const persona of PERSONAS) { + expect(screen.getByText(persona.name)).toBeInTheDocument(); + expect(screen.getByText(persona.role)).toBeInTheDocument(); + } + }); + + it('states the difficulty as a word, not colour alone', () => { + renderModal(); + expect(screen.getByText('EASY')).toBeInTheDocument(); + expect(screen.getAllByText('MEDIUM')).toHaveLength(2); + expect(screen.getByText('HARD')).toBeInTheDocument(); + }); + + it('states the turn-taking guarantee in the subtitle', () => { + renderModal(); + expect( + screen.getByText(/you always get to finish your answer/i), + ).toBeInTheDocument(); + }); + + it('is a labelled modal dialog', () => { + renderModal(); + const dialog = screen.getByRole('dialog'); + expect(dialog).toHaveAttribute('aria-modal', 'true'); + expect(dialog).toHaveAccessibleName('Choose your interviewer'); + }); + + it('selects a persona on click', () => { + const { onSelect } = renderModal(); + fireEvent.click(screen.getByText('Marc Bernard')); + fireEvent.click( + screen.getByRole('button', { name: /start with marc bernard/i }), + ); + expect(onSelect).toHaveBeenCalledWith( + PERSONAS.find((persona) => persona.id === 'marc-bernard'), + ); + }); + + it('dismisses on Escape', () => { + const { onDismiss } = renderModal(); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(onDismiss).toHaveBeenCalled(); + }); + + it('dismisses when the skip button is used', () => { + const { onDismiss } = renderModal(); + fireEvent.click(screen.getByRole('button', { name: /skip/i })); + expect(onDismiss).toHaveBeenCalled(); + }); + + // Roving tabindex: only the checked radio is reachable by Tab, so focus it + // first and dispatch from there. Keying a node the user cannot reach would + // assert the handler wiring rather than the keyboard path. + it('exposes only the checked radio to Tab', () => { + renderModal(); + const radios = screen.getAllByRole('radio'); + expect(radios).toHaveLength(4); + expect(radios[0]).toHaveAttribute('aria-checked', 'true'); + expect(radios[0]).toHaveAttribute('tabindex', '0'); + expect(radios[1]).toHaveAttribute('tabindex', '-1'); + }); + + it('moves the radio highlight with arrow keys', () => { + renderModal(); + const radios = screen.getAllByRole('radio'); + radios[0].focus(); + expect(radios[0]).toHaveFocus(); + + fireEvent.keyDown(radios[0], { key: 'ArrowRight' }); + expect(screen.getAllByRole('radio')[1]).toHaveAttribute( + 'aria-checked', + 'true', + ); + }); + + it('commits the highlighted radio with Enter', () => { + const { onSelect } = renderModal(); + const radios = screen.getAllByRole('radio'); + radios[0].focus(); + fireEvent.keyDown(radios[0], { key: 'Enter' }); + expect(onSelect).toHaveBeenCalledWith(DEFAULT_PERSONA); + }); + + it('locks body scroll while open', () => { + renderModal(); + expect(document.body.style.overflow).toBe('hidden'); + }); +}); diff --git a/web/src/components/organisms/persona-picker-modal/index.tsx b/web/src/components/organisms/persona-picker-modal/index.tsx new file mode 100644 index 00000000..1d9f9701 --- /dev/null +++ b/web/src/components/organisms/persona-picker-modal/index.tsx @@ -0,0 +1,260 @@ +import { Button } from '@/components/atoms/button'; +import { Icon } from '@/components/atoms/icon'; +import { + DEFAULT_PERSONA, + PERSONAS, + type PersonaDifficulty, + type RecruiterPersona, +} from '@/config/personas'; +import { useFocusTrap } from '@/hooks/useFocusTrap'; +import { cn } from '@/utils/cn'; +import { + type KeyboardEvent as ReactKeyboardEvent, + useEffect, + useRef, + useState, +} from 'react'; + +import type { PersonaPickerModalProps } from './types'; + +/** + * Difficulty is always carried by the level word — the gauge's width and + * colour only reinforce it, so the meaning survives greyscale and + * colour-blindness. + */ +const DIFFICULTY_META: Record< + PersonaDifficulty, + { label: string; width: string; barClass: string; textClass: string } +> = { + easy: { + label: 'EASY', + width: '33%', + barClass: 'bg-success', + textClass: 'text-success', + }, + medium: { + label: 'MEDIUM', + width: '66%', + barClass: 'bg-warning', + textClass: 'text-warning', + }, + hard: { + label: 'HARD', + width: '100%', + barClass: 'bg-error', + textClass: 'text-error', + }, +}; + +/** + * Modal for casting the AI recruiter before a simulation. + * + * Controlled component: `isOpen`/`onSelect`/`onDismiss` only — persistence is + * the caller's job. Highlighting (roving radio) and committing (`onSelect`) + * are deliberately separate steps so arrowing through the cast never starts + * a simulation by accident. + */ +export function PersonaPickerModal({ + isOpen, + onSelect, + onDismiss, +}: PersonaPickerModalProps) { + const [highlighted, setHighlighted] = + useState(DEFAULT_PERSONA); + const dialogRef = useFocusTrap(isOpen); + const radioRefs = useRef>({}); + + useEffect(() => { + if (!isOpen) return; + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + onDismiss(); + } + }; + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, [isOpen, onDismiss]); + + useEffect(() => { + if (!isOpen) return; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = 'unset'; + }; + }, [isOpen]); + + if (!isOpen) return null; + + const move = (delta: number) => { + const index = PERSONAS.findIndex( + (persona) => persona.id === highlighted.id, + ); + const next = (index + delta + PERSONAS.length) % PERSONAS.length; + const persona = PERSONAS[next]; + setHighlighted(persona); + // Roving tabindex: keyboard focus follows the checked radio. + radioRefs.current[persona.id]?.focus(); + }; + + const handleKeyDown = (event: ReactKeyboardEvent) => { + if (event.key === 'ArrowRight' || event.key === 'ArrowDown') { + event.preventDefault(); + move(1); + } else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') { + event.preventDefault(); + move(-1); + } else if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onSelect(highlighted); + } + }; + + return ( +
+ +
+ + {/* + onKeyDown lives on the group, not each radio: roving tabindex means + only the checked radio is reachable, so the group is the one element + guaranteed to receive the key event from any reachable focus point. + This is the WAI-ARIA radiogroup pattern. + */} +
+ {PERSONAS.map((persona) => { + const meta = DIFFICULTY_META[persona.difficulty]; + const isHighlighted = persona.id === highlighted.id; + return ( + // Key events are handled by the radiogroup above (roving + // tabindex), not per radio — see the comment on the group. + // eslint-disable-next-line jsx-a11y/click-events-have-key-events +
{ + radioRefs.current[persona.id] = element; + }} + role="radio" + aria-checked={isHighlighted} + tabIndex={isHighlighted ? 0 : -1} + onClick={() => setHighlighted(persona)} + className={cn( + 'flex cursor-pointer flex-col rounded-xl border p-4 transition-colors duration-150', + 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent', + isHighlighted + ? 'border-accent bg-accent-weaker ring-1 ring-accent' + : 'border-border bg-surface hover:border-border-hover hover:bg-surface-hover', + )} + > +
+ + +

+ {persona.name} +

+

+ {persona.role} +

+
+
+ +

+ {persona.description} +

+ +
+
+ + Difficulty + + + {meta.label} + +
+ +
+ ); + })} +
+ +
+ + +
+
+ + ); +} + +export default PersonaPickerModal; diff --git a/web/src/components/organisms/persona-picker-modal/types.ts b/web/src/components/organisms/persona-picker-modal/types.ts new file mode 100644 index 00000000..ff99c6da --- /dev/null +++ b/web/src/components/organisms/persona-picker-modal/types.ts @@ -0,0 +1,10 @@ +import type { RecruiterPersona } from '@/config/personas'; + +export interface PersonaPickerModalProps { + /** Renders nothing when false. */ + isOpen: boolean; + /** Called with the committed persona. */ + onSelect: (persona: RecruiterPersona) => void; + /** Esc, scrim click, close button, or Skip. Caller falls back to DEFAULT_PERSONA. */ + onDismiss: () => void; +} From 25966af1379da8b1d8d3cc38dc97d7ca7d5c043e Mon Sep 17 00:00:00 2001 From: BhuvanArn Date: Wed, 15 Jul 2026 09:20:23 +0200 Subject: [PATCH 05/16] feat: drive recruiter identity from the chosen persona --- .../organisms/persona-picker-modal/index.tsx | 3 +- .../organisms/persona-picker-modal/types.ts | 7 ++ .../organisms/recruiter-avatar/index.tsx | 7 +- .../recruiter-avatar/recruiter-avatar-3d.tsx | 13 +-- .../recruiter-avatar-fallback.tsx | 15 ++- .../recruiter-avatar-loading.tsx | 17 ++-- .../organisms/recruiter-avatar/types.ts | 3 + .../organisms/simulation-video-area/index.tsx | 4 + .../simulation-workspace/index.spec.tsx | 95 +++++++++++++++++++ .../organisms/simulation-workspace/index.tsx | 53 +++++++++-- web/src/config/recruiter-avatar.ts | 3 - web/src/routes/-simulations.spec.tsx | 10 ++ 12 files changed, 191 insertions(+), 39 deletions(-) create mode 100644 web/src/components/organisms/simulation-workspace/index.spec.tsx diff --git a/web/src/components/organisms/persona-picker-modal/index.tsx b/web/src/components/organisms/persona-picker-modal/index.tsx index 1d9f9701..ae79b149 100644 --- a/web/src/components/organisms/persona-picker-modal/index.tsx +++ b/web/src/components/organisms/persona-picker-modal/index.tsx @@ -56,11 +56,12 @@ const DIFFICULTY_META: Record< */ export function PersonaPickerModal({ isOpen, + initialHighlight = DEFAULT_PERSONA, onSelect, onDismiss, }: PersonaPickerModalProps) { const [highlighted, setHighlighted] = - useState(DEFAULT_PERSONA); + useState(initialHighlight); const dialogRef = useFocusTrap(isOpen); const radioRefs = useRef>({}); diff --git a/web/src/components/organisms/persona-picker-modal/types.ts b/web/src/components/organisms/persona-picker-modal/types.ts index ff99c6da..58f12e16 100644 --- a/web/src/components/organisms/persona-picker-modal/types.ts +++ b/web/src/components/organisms/persona-picker-modal/types.ts @@ -3,6 +3,13 @@ import type { RecruiterPersona } from '@/config/personas'; export interface PersonaPickerModalProps { /** Renders nothing when false. */ isOpen: boolean; + /** + * Card highlighted on mount. Defaults to DEFAULT_PERSONA (Sophie). Pass the + * currently selected persona on the "Change recruiter" path — combine with + * `key={persona.id}` at the call site so the component remounts and re-reads + * this initializer instead of keeping a stale highlight across reopens. + */ + initialHighlight?: RecruiterPersona; /** Called with the committed persona. */ onSelect: (persona: RecruiterPersona) => void; /** Esc, scrim click, close button, or Skip. Caller falls back to DEFAULT_PERSONA. */ diff --git a/web/src/components/organisms/recruiter-avatar/index.tsx b/web/src/components/organisms/recruiter-avatar/index.tsx index 15366f30..bd4a8da1 100644 --- a/web/src/components/organisms/recruiter-avatar/index.tsx +++ b/web/src/components/organisms/recruiter-avatar/index.tsx @@ -15,6 +15,7 @@ const RecruiterAvatar3D = lazy(() => */ export function RecruiterAvatarPanel({ active, + persona, isAiSpeaking, isAwaitingAiResponse, speechTurn, @@ -37,12 +38,13 @@ export function RecruiterAvatarPanel({ const use3d = wants3d && !runtimeFallback; if (avatarMode === 'loading' && !runtimeFallback) { - return ; + return ; } if (!use3d) { return ( }> + }>
-

- {RECRUITER_DISPLAY_NAME} -

-

{RECRUITER_DISPLAY_ROLE}

+

{persona.name}

+

{persona.role}

{initState === 'loading' ? ( diff --git a/web/src/components/organisms/recruiter-avatar/recruiter-avatar-fallback.tsx b/web/src/components/organisms/recruiter-avatar/recruiter-avatar-fallback.tsx index d1bcbbb2..1d5d5c86 100644 --- a/web/src/components/organisms/recruiter-avatar/recruiter-avatar-fallback.tsx +++ b/web/src/components/organisms/recruiter-avatar/recruiter-avatar-fallback.tsx @@ -1,10 +1,8 @@ -import { - RECRUITER_DISPLAY_NAME, - RECRUITER_DISPLAY_ROLE, -} from '@/config/recruiter-avatar'; +import type { RecruiterPersona } from '@/config/personas'; import { cn } from '@/utils/cn'; interface RecruiterAvatarFallbackProps { + persona: RecruiterPersona; isAiSpeaking: boolean; isAwaitingAiResponse: boolean; fallbackReason?: string | null; @@ -16,6 +14,7 @@ interface RecruiterAvatarFallbackProps { * Uses the existing interviewer photo asset. */ export function RecruiterAvatarFallback({ + persona, isAiSpeaking, isAwaitingAiResponse, fallbackReason, @@ -25,15 +24,13 @@ export function RecruiterAvatarFallback({
{`${RECRUITER_DISPLAY_NAME},
-

- {RECRUITER_DISPLAY_NAME} -

-

{RECRUITER_DISPLAY_ROLE}

+

{persona.name}

+

{persona.role}

{isAiSpeaking ? ( diff --git a/web/src/components/organisms/recruiter-avatar/recruiter-avatar-loading.tsx b/web/src/components/organisms/recruiter-avatar/recruiter-avatar-loading.tsx index 57bc535a..fcf7aaf6 100644 --- a/web/src/components/organisms/recruiter-avatar/recruiter-avatar-loading.tsx +++ b/web/src/components/organisms/recruiter-avatar/recruiter-avatar-loading.tsx @@ -1,23 +1,22 @@ -import { - RECRUITER_DISPLAY_NAME, - RECRUITER_DISPLAY_ROLE, -} from '@/config/recruiter-avatar'; +import { DEFAULT_PERSONA, type RecruiterPersona } from '@/config/personas'; import RecruiterAvatarBackdrop from './recruiter-avatar-backdrop'; /** Loading placeholder while WebGL capability or the 3D engine initializes. */ -export function RecruiterAvatarLoading() { +export function RecruiterAvatarLoading({ + persona = DEFAULT_PERSONA, +}: { + persona?: RecruiterPersona; +}) { return (

- {RECRUITER_DISPLAY_NAME} -

-

- {RECRUITER_DISPLAY_ROLE} + {persona.name}

+

{persona.role}

Loading 3D avatar…

diff --git a/web/src/components/organisms/recruiter-avatar/types.ts b/web/src/components/organisms/recruiter-avatar/types.ts index 89030a7c..092b98db 100644 --- a/web/src/components/organisms/recruiter-avatar/types.ts +++ b/web/src/components/organisms/recruiter-avatar/types.ts @@ -1,9 +1,11 @@ +import type { RecruiterPersona } from '@/config/personas'; import type { AiSpeechTurn } from '@/hooks/simulation/useAudioPlayback'; import type { RecruiterAvatarMode } from '@/hooks/simulation/useRecruiterAvatarCapability'; export interface RecruiterAvatarPanelProps { /** Whether the simulation stream is active (user started the call). */ active: boolean; + persona: RecruiterPersona; isAiSpeaking: boolean; isAwaitingAiResponse: boolean; speechTurn: AiSpeechTurn | null; @@ -15,6 +17,7 @@ export interface RecruiterAvatarPanelProps { export interface RecruiterAvatar3DProps { active: boolean; + persona: RecruiterPersona; avatarUrl: string; isAiSpeaking: boolean; isAwaitingAiResponse: boolean; diff --git a/web/src/components/organisms/simulation-video-area/index.tsx b/web/src/components/organisms/simulation-video-area/index.tsx index 0bc0b77b..5bd82386 100644 --- a/web/src/components/organisms/simulation-video-area/index.tsx +++ b/web/src/components/organisms/simulation-video-area/index.tsx @@ -1,6 +1,7 @@ import { Icon } from '@/components/atoms/icon'; import VideoAreaControlsBar from '@/components/molecules/video-area-controls-bar'; import RecruiterAvatarPanel from '@/components/organisms/recruiter-avatar'; +import { DEFAULT_PERSONA, type RecruiterPersona } from '@/config/personas'; import type { AiSpeechTurn } from '@/hooks/simulation/useAudioPlayback'; import type { RecruiterAvatarMode } from '@/hooks/simulation/useRecruiterAvatarCapability'; import { cn } from '@/utils/cn'; @@ -12,6 +13,7 @@ import { useStreamControls } from '../../../hooks/streams/useStreamControls'; import { useVideoStream } from '../../../hooks/streams/useVideoStream'; interface SimulationVideoAreaProps { + persona?: RecruiterPersona; isAiSpeaking?: boolean; isAwaitingAiResponse?: boolean; speechTurn?: AiSpeechTurn | null; @@ -29,6 +31,7 @@ interface SimulationVideoAreaProps { * @returns The SimulationVideoArea component. */ const SimulationVideoArea = ({ + persona = DEFAULT_PERSONA, isAiSpeaking = false, isAwaitingAiResponse = false, speechTurn = null, @@ -140,6 +143,7 @@ const SimulationVideoArea = ({ > ({ + useInterviewSession: () => ({ + isCallActive: false, + isQueued: false, + queuePosition: 0, + estimatedWaitSec: undefined, + inputUrl: '', + interviewID: null, + handleStreamToggle: vi.fn(), + }), + useSimulationWebSocket: () => ({ + sendMessage: vi.fn(), + sendJsonMessage: vi.fn(), + sendPing: vi.fn(), + sendSessionStart: vi.fn(), + lastMessage: null, + lastJsonMessage: null, + readyState: 3, + connect: vi.fn(), + disconnect: vi.fn(), + }), + useAudioPlayback: () => ({ isAiSpeaking: false, speechTurn: 0 }), + useAudioStreaming: () => ({ + isListening: false, + isSpeaking: false, + isRecording: false, + packetsSent: 0, + supportedMimeType: '', + audioError: null, + }), + useVerbalAnalysis: () => ({ + analysis: { latest: null, aggregate: null, history: [] }, + }), + useRecruiterAvatarCapability: () => ({ + avatarMode: 'fallback', + capabilityFallbackReason: null, + }), +})); + +describe('SimulationWorkspace persona picker', () => { + beforeEach(() => { + act(() => usePersonaStore.getState().clearPersona()); + }); + + it('shows the picker when no persona is chosen', () => { + render(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('hides the picker once a persona is chosen', () => { + act(() => usePersonaStore.getState().setPersona('marc-bernard')); + render(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('shows the chosen persona name and role in the info box', () => { + act(() => usePersonaStore.getState().setPersona('marc-bernard')); + render(); + const marc = PERSONAS.find((persona) => persona.id === 'marc-bernard')!; + expect(screen.getByText(marc.name)).toBeInTheDocument(); + expect(screen.getByText(new RegExp(marc.role))).toBeInTheDocument(); + }); + + it('offers Change while idle', () => { + act(() => usePersonaStore.getState().setPersona('marc-bernard')); + render(); + expect( + screen.getByRole('button', { name: /change recruiter/i }), + ).toBeInTheDocument(); + }); + + // Task 5's explicit highlight-on-reopen decision: reopening via "Change + // recruiter" must highlight the currently selected persona, not a stale + // highlight or an unconditional Sophie (the modal's own internal default). + it('highlights the currently selected persona when reopened via Change', () => { + act(() => usePersonaStore.getState().setPersona('marc-bernard')); + render(); + + fireEvent.click(screen.getByRole('button', { name: /change recruiter/i })); + + const marc = PERSONAS.find((persona) => persona.id === 'marc-bernard')!; + const radios = screen.getAllByRole('radio'); + const marcRadio = radios.find( + (radio) => radio.getAttribute('aria-checked') === 'true', + ); + expect(marcRadio).toHaveAccessibleName(new RegExp(marc.name)); + }); +}); diff --git a/web/src/components/organisms/simulation-workspace/index.tsx b/web/src/components/organisms/simulation-workspace/index.tsx index 726410b8..f6fa0cc5 100644 --- a/web/src/components/organisms/simulation-workspace/index.tsx +++ b/web/src/components/organisms/simulation-workspace/index.tsx @@ -1,16 +1,18 @@ import InfoBox from '@/components/molecules/info-box'; import NotesEditor from '@/components/molecules/notes-editor/notes-editor'; import SimulationQueueBanner from '@/components/molecules/simulation-queue-banner'; +import { PersonaPickerModal } from '@/components/organisms/persona-picker-modal'; import SimulationTranscriptionArea from '@/components/organisms/simulation-transcription-area'; import { TranscriptionProps } from '@/components/organisms/simulation-transcription-area/types'; import SimulationVideoArea from '@/components/organisms/simulation-video-area'; import VerbalAnalysisPanel from '@/components/organisms/verbal-analysis-panel'; import { WebSocketDebugPanel } from '@/components/organisms/websocket-debug-panel'; import { - RECRUITER_DISPLAY_NAME, - RECRUITER_DISPLAY_ROLE, - clearAvatarFallbackForced, -} from '@/config/recruiter-avatar'; + DEFAULT_PERSONA, + type RecruiterPersona, + getPersonaById, +} from '@/config/personas'; +import { clearAvatarFallbackForced } from '@/config/recruiter-avatar'; import { WebSocketPacket, useAudioPlayback, @@ -21,6 +23,7 @@ import { useVerbalAnalysis, } from '@/hooks/simulation'; import type { RecruiterAvatarMode } from '@/hooks/simulation/useRecruiterAvatarCapability'; +import usePersonaStore from '@/stores/usePersonaStore'; import { useCallback, useEffect, useRef, useState } from 'react'; import toast from 'react-hot-toast'; import { ReadyState } from 'react-use-websocket'; @@ -39,6 +42,26 @@ export function SimulationWorkspace({ description = 'Practice interview scenarios in a safe environment.', contextLabel, }: SimulationWorkspaceProps) { + const selectedPersonaId = usePersonaStore((state) => state.selectedPersonaId); + const setPersona = usePersonaStore((state) => state.setPersona); + const [isPickerOpen, setIsPickerOpen] = useState(selectedPersonaId === null); + const persona = getPersonaById(selectedPersonaId); + + const handlePersonaSelect = useCallback( + (chosen: RecruiterPersona) => { + setPersona(chosen.id); + setIsPickerOpen(false); + }, + [setPersona], + ); + + // Dismiss keeps today's behaviour: fall back to the default and persist it so + // the modal does not nag on the next mount. + const handlePersonaDismiss = useCallback(() => { + setPersona(DEFAULT_PERSONA.id); + setIsPickerOpen(false); + }, [setPersona]); + const [mediaStream, setMediaStream] = useState(null); const [wsError, setWsError] = useState(null); const [connectionAttempts, setConnectionAttempts] = useState(0); @@ -306,6 +329,7 @@ export function SimulationWorkspace({
+ {!isCallActive && ( + + )}
+ +
); } diff --git a/web/src/config/recruiter-avatar.ts b/web/src/config/recruiter-avatar.ts index 5c7c6528..632908dc 100644 --- a/web/src/config/recruiter-avatar.ts +++ b/web/src/config/recruiter-avatar.ts @@ -46,9 +46,6 @@ export const RECRUITER_AVATAR_SHOW_OPTIONS = { }, }; -export const RECRUITER_DISPLAY_NAME = 'Sophie Martin'; -export const RECRUITER_DISPLAY_ROLE = 'Recruteuse IT'; - const FORCE_FALLBACK_STORAGE_KEY = 'talkup.avatar.forceFallback'; /** diff --git a/web/src/routes/-simulations.spec.tsx b/web/src/routes/-simulations.spec.tsx index eec14f62..9356568f 100644 --- a/web/src/routes/-simulations.spec.tsx +++ b/web/src/routes/-simulations.spec.tsx @@ -4,6 +4,7 @@ import { useAudioStreaming, useInterviewSession, } from '@/hooks/simulation'; +import usePersonaStore from '@/stores/usePersonaStore'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { RouterProvider, @@ -178,6 +179,11 @@ describe('Simulations', () => { // The debug panel is gated behind VITE_SHOW_WS_DEBUG; enable it so the // panel-rendering assertions below have something to find. vi.stubEnv('VITE_SHOW_WS_DEBUG', 'true'); + // These tests exercise the established call/stream flow, not the persona + // picker itself (that flow is covered by simulation-workspace's own + // spec) — pre-select the default persona so the picker modal doesn't + // intercept the "start" button or duplicate the recruiter's name. + act(() => usePersonaStore.getState().setPersona('sophie-martin')); router.history.push('/simulations'); await act(async () => { @@ -185,6 +191,10 @@ describe('Simulations', () => { }); }); + afterEach(() => { + act(() => usePersonaStore.getState().clearPersona()); + }); + afterEach(() => { vi.unstubAllEnvs(); }); From 24bbb0bce0f90f008198eae111e6d1d8fee8dab3 Mon Sep 17 00:00:00 2001 From: BhuvanArn Date: Wed, 15 Jul 2026 09:27:34 +0200 Subject: [PATCH 06/16] tests: guard change-recruiter against the per-turn ai flag --- .../simulation-workspace/index.spec.tsx | 36 +++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/web/src/components/organisms/simulation-workspace/index.spec.tsx b/web/src/components/organisms/simulation-workspace/index.spec.tsx index 9dc5e52a..7d87ad00 100644 --- a/web/src/components/organisms/simulation-workspace/index.spec.tsx +++ b/web/src/components/organisms/simulation-workspace/index.spec.tsx @@ -1,4 +1,5 @@ import { PERSONAS } from '@/config/personas'; +import * as simulationHooks from '@/hooks/simulation'; import usePersonaStore from '@/stores/usePersonaStore'; import { act, fireEvent, render, screen } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -6,7 +7,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { SimulationWorkspace } from './index'; vi.mock('@/hooks/simulation', () => ({ - useInterviewSession: () => ({ + useInterviewSession: vi.fn(() => ({ isCallActive: false, isQueued: false, queuePosition: 0, @@ -14,7 +15,7 @@ vi.mock('@/hooks/simulation', () => ({ inputUrl: '', interviewID: null, handleStreamToggle: vi.fn(), - }), + })), useSimulationWebSocket: () => ({ sendMessage: vi.fn(), sendJsonMessage: vi.fn(), @@ -92,4 +93,35 @@ describe('SimulationWorkspace persona picker', () => { ); expect(marcRadio).toHaveAccessibleName(new RegExp(marc.name)); }); + + // Regression guard: the "Change recruiter" button is gated on !isCallActive, + // NOT isAwaitingAiResponse. These are distinct states: isCallActive is the + // session-wide interview status (should hide during live call), while + // isAwaitingAiResponse flips true/false on every conversational turn (would + // cause the button to flicker visibly if used as the gate). A future refactor + // might conflate them; this test locks the distinction in place. + it('shows Change recruiter when idle (isCallActive: false)', () => { + act(() => usePersonaStore.getState().setPersona('marc-bernard')); + render(); + expect( + screen.getByRole('button', { name: /change recruiter/i }), + ).toBeInTheDocument(); + }); + + it('hides Change recruiter when call is active (isCallActive: true)', () => { + act(() => usePersonaStore.getState().setPersona('marc-bernard')); + vi.mocked(simulationHooks.useInterviewSession).mockReturnValueOnce({ + isCallActive: true, + isQueued: false, + queuePosition: 0, + estimatedWaitSec: undefined, + inputUrl: '', + interviewID: null, + handleStreamToggle: vi.fn(), + }); + render(); + expect( + screen.queryByRole('button', { name: /change recruiter/i }), + ).not.toBeInTheDocument(); + }); }); From 534b74352e94ca7e89cbd6b46f66f1d0259956cc Mon Sep 17 00:00:00 2001 From: BhuvanArn Date: Wed, 15 Jul 2026 09:27:34 +0200 Subject: [PATCH 07/16] chore: ignore playwright mcp scratch output --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 97be8ec3..e230bf42 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ ai/microservices/**/llm/ # superpowers brainstorm companion artifacts .superpowers/ + +# playwright mcp scratch (console logs, page snapshots, ad-hoc screenshots) +.playwright-mcp/ From 400e3fc7adba26ae49110313c9e7086f7d651512 Mon Sep 17 00:00:00 2001 From: BhuvanArn Date: Wed, 15 Jul 2026 09:31:43 +0200 Subject: [PATCH 08/16] feat: clear the chosen persona when an interview ends --- .../simulation/useInterviewSession.spec.ts | 37 ++++++++++++++++++- .../hooks/simulation/useInterviewSession.ts | 6 +++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/web/src/hooks/simulation/useInterviewSession.spec.ts b/web/src/hooks/simulation/useInterviewSession.spec.ts index 1c6f3ea2..3647f2f1 100644 --- a/web/src/hooks/simulation/useInterviewSession.spec.ts +++ b/web/src/hooks/simulation/useInterviewSession.spec.ts @@ -1,4 +1,5 @@ -import { renderHook, waitFor } from '@testing-library/react'; +import usePersonaStore from '@/stores/usePersonaStore'; +import { act, renderHook, waitFor } from '@testing-library/react'; import axios from 'axios'; import toast from 'react-hot-toast'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -161,4 +162,38 @@ describe('useInterviewSession restore', () => { 'Impossible de reprendre la simulation pour le moment. Rechargez la page pour réessayer.', ); }); + + it('clears the persona even when updateInterview rejects on end', async () => { + act(() => usePersonaStore.getState().setPersona('marc-bernard')); + localStorageMock.store.set('currentInterviewID', 'interview-1'); + localStorageMock.store.set('currentInterviewURL', 'wss://example.test/ws'); + mockGetInterviewSession.mockResolvedValue({ + interviewID: 'interview-1', + dbStatus: 'in_progress', + sessionStatus: 'active', + queuePosition: 0, + entrypoint: 'wss://example.test/ws', + }); + mockUpdateInterview.mockRejectedValueOnce(new Error('network down')); + + const { result } = renderHook(() => + useInterviewSession({ + onConnect, + onDisconnect, + }), + ); + + await waitFor(() => { + expect(onConnect).toHaveBeenCalledWith('wss://example.test/ws'); + }); + + await act(async () => { + await result.current.handleStreamToggle(false); + }); + + expect(mockUpdateInterview).toHaveBeenCalledWith('interview-1', { + status: 'completed', + }); + expect(usePersonaStore.getState().selectedPersonaId).toBeNull(); + }); }); diff --git a/web/src/hooks/simulation/useInterviewSession.ts b/web/src/hooks/simulation/useInterviewSession.ts index ebcc44db..5c92d526 100644 --- a/web/src/hooks/simulation/useInterviewSession.ts +++ b/web/src/hooks/simulation/useInterviewSession.ts @@ -5,6 +5,7 @@ import { heartbeatInterview, updateInterview, } from '@/services/ai/http'; +import usePersonaStore from '@/stores/usePersonaStore'; import axios from 'axios'; import { useCallback, useEffect, useRef, useState } from 'react'; import toast from 'react-hot-toast'; @@ -389,6 +390,11 @@ export function useInterviewSession({ clearInterviewStorage(); } catch (error) { console.error('Failed to update interview status:', error); + } finally { + // Outside the try: a rejected updateInterview must not strand the + // persona. Leaving it set would silently skip the picker on the + // next visit. + usePersonaStore.getState().clearPersona(); } } From 1621a911ea976d18d2eb8103679b952456e07a25 Mon Sep 17 00:00:00 2001 From: BhuvanArn Date: Wed, 15 Jul 2026 09:54:19 +0200 Subject: [PATCH 09/16] tests: cover the persona picker end to end --- web/tests/persona-picker.spec.ts | 109 +++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 web/tests/persona-picker.spec.ts diff --git a/web/tests/persona-picker.spec.ts b/web/tests/persona-picker.spec.ts new file mode 100644 index 00000000..22167f78 --- /dev/null +++ b/web/tests/persona-picker.spec.ts @@ -0,0 +1,109 @@ +import { expect, test } from '@playwright/test'; + +test.beforeEach(async ({ page }) => { + // /auth/status gates the /simulations route (createAuthGuard). Mirror the + // real payload shape — see applications.spec.ts for why 'none' + null org + // is the right stub for a route with no role gate. + await page.route('**/v1/api/auth/status', (route) => + route.fulfill({ + json: { authenticated: true, role: 'none', organizationId: null }, + }), + ); + // The sidebar's account switcher mounts on every authenticated page and + // fetches the current user. Left unmocked it hits the real backend (e2e CI + // runs one since #151), gets a 401, and the axios interceptor's + // failed-refresh path redirects the whole app to /login — so /simulations + // never renders. Serve a stub profile so the switcher is satisfied. + await page.route('**/v1/api/users/me', (route) => + route.fulfill({ + json: { + username: 'qa', + email: 'qa@talkup.ai', + firstName: null, + lastName: null, + profilePicture: null, + avatarAccentColor: null, + }, + }), + ); +}); + +test('asks for a persona on first entry and remembers the choice', async ({ + page, +}) => { + await page.goto('/simulations'); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + await expect(dialog).toContainText('Choose your interviewer'); + + // Four personas are offered as radio cards. + await expect(dialog.getByRole('radio')).toHaveCount(4); + await expect( + dialog.getByRole('radio', { name: /sophie martin/i }), + ).toBeVisible(); + await expect( + dialog.getByRole('radio', { name: /thomas leroy/i }), + ).toBeVisible(); + await expect( + dialog.getByRole('radio', { name: /claire dubois/i }), + ).toBeVisible(); + await expect( + dialog.getByRole('radio', { name: /marc bernard/i }), + ).toBeVisible(); + + // Picking a card highlights it and re-labels the primary footer button. + await dialog.getByRole('radio', { name: /marc bernard/i }).click(); + const startButton = dialog.getByRole('button', { + name: /start with marc bernard/i, + }); + await expect(startButton).toBeVisible(); + await startButton.click(); + + // Committing closes the modal and the chosen name appears on the page + // (InfoBox renders unconditionally; the avatar panel only mounts once a + // media stream is active, which this test does not grant). + await expect(dialog).not.toBeVisible(); + await expect( + page.getByRole('heading', { name: 'Marc Bernard' }), + ).toBeVisible(); + + // sessionStorage survives a reload, so the picker must not re-ask. + await page.reload(); + await expect(page.getByRole('dialog')).not.toBeVisible(); + await expect( + page.getByRole('heading', { name: 'Marc Bernard' }), + ).toBeVisible(); + + // The choice is keyed under 'persona-storage' in sessionStorage. + const stored = await page.evaluate(() => + window.sessionStorage.getItem('persona-storage'), + ); + expect(stored).not.toBeNull(); + expect(JSON.parse(stored as string)).toMatchObject({ + state: { selectedPersonaId: 'marc-bernard' }, + }); +}); + +test('dismissing with Escape falls back to the default recruiter', async ({ + page, +}) => { + await page.goto('/simulations'); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + + await page.keyboard.press('Escape'); + + await expect(dialog).not.toBeVisible(); + await expect( + page.getByRole('heading', { name: 'Sophie Martin' }), + ).toBeVisible(); + + const stored = await page.evaluate(() => + window.sessionStorage.getItem('persona-storage'), + ); + expect(JSON.parse(stored as string)).toMatchObject({ + state: { selectedPersonaId: 'sophie-martin' }, + }); +}); From 77cb193c7b60aae62f4a32900f9f92c9b440e10a Mon Sep 17 00:00:00 2001 From: BhuvanArn Date: Wed, 15 Jul 2026 10:31:09 +0200 Subject: [PATCH 10/16] fix: add optional back control inside persona picker modal --- .../persona-picker-modal/index.spec.tsx | 21 +++++++++++++ .../organisms/persona-picker-modal/index.tsx | 30 ++++++++++++++----- .../organisms/persona-picker-modal/types.ts | 7 +++++ .../organisms/simulation-workspace/index.tsx | 8 +++++ .../$applicationId/simulations.tsx | 14 ++++++++- web/tests/application-simulation-back.spec.ts | 28 ++++++++++++++++- 6 files changed, 98 insertions(+), 10 deletions(-) diff --git a/web/src/components/organisms/persona-picker-modal/index.spec.tsx b/web/src/components/organisms/persona-picker-modal/index.spec.tsx index 5c2004a4..e22aef8f 100644 --- a/web/src/components/organisms/persona-picker-modal/index.spec.tsx +++ b/web/src/components/organisms/persona-picker-modal/index.spec.tsx @@ -121,4 +121,25 @@ describe('PersonaPickerModal', () => { renderModal(); expect(document.body.style.overflow).toBe('hidden'); }); + + // #195 regression: on /applications/:id/simulations the modal's scrim + // covers the route's own "Back to roadmap" link, so the modal must offer + // its own back control instead when the caller provides one. + it('renders no back control when backTo is omitted', () => { + renderModal(); + expect( + screen.queryByRole('link', { name: /back/i }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /back/i }), + ).not.toBeInTheDocument(); + }); + + it('renders the back control and fires onNavigate when backTo is given', () => { + const onNavigate = vi.fn(); + renderModal({ backTo: { label: 'Back to roadmap', onNavigate } }); + const back = screen.getByRole('button', { name: /back to roadmap/i }); + fireEvent.click(back); + expect(onNavigate).toHaveBeenCalledTimes(1); + }); }); diff --git a/web/src/components/organisms/persona-picker-modal/index.tsx b/web/src/components/organisms/persona-picker-modal/index.tsx index ae79b149..526e868f 100644 --- a/web/src/components/organisms/persona-picker-modal/index.tsx +++ b/web/src/components/organisms/persona-picker-modal/index.tsx @@ -59,6 +59,7 @@ export function PersonaPickerModal({ initialHighlight = DEFAULT_PERSONA, onSelect, onDismiss, + backTo, }: PersonaPickerModalProps) { const [highlighted, setHighlighted] = useState(initialHighlight); @@ -236,14 +237,27 @@ export function PersonaPickerModal({
- +
+ {backTo ? ( + + ) : null} + +
); diff --git a/web/src/routes/applications/$applicationId/simulations.tsx b/web/src/routes/applications/$applicationId/simulations.tsx index 30ed87a1..1425e56f 100644 --- a/web/src/routes/applications/$applicationId/simulations.tsx +++ b/web/src/routes/applications/$applicationId/simulations.tsx @@ -2,7 +2,7 @@ import { iconMap } from '@/components/atoms/icon/icon-map'; import { SimulationWorkspace } from '@/components/organisms/simulation-workspace'; import { useApplications } from '@/services/applications/hooks'; import { createAuthGuard } from '@/utils/auth.guards'; -import { Link, createFileRoute } from '@tanstack/react-router'; +import { Link, createFileRoute, useNavigate } from '@tanstack/react-router'; const BackIcon = iconMap['arrow-left']; @@ -37,11 +37,22 @@ function BackToRoadmap({ applicationId }: { applicationId: string }) { function ApplicationSimulations() { const { applicationId } = Route.useParams(); + const navigate = useNavigate(); const { data: applications, isLoading } = useApplications(); const application = applications?.find( (item) => item.applicationId === applicationId, ); + // Same destination as the BackToRoadmap link below — this one lives inside + // the persona picker modal, whose scrim otherwise blocks that link on + // first entry (#195 regression from PR #165). + const handleBackToRoadmap = () => { + void navigate({ + to: '/applications/$applicationId/roadmap', + params: { applicationId }, + }); + }; + const contextLabel = application ? [application.jobTitle, application.companyName] .filter(Boolean) @@ -73,6 +84,7 @@ function ApplicationSimulations() { title="" description="Practice with the AI recruiter for this job offer." contextLabel={contextLabel} + backTo={{ label: 'Back to roadmap', onNavigate: handleBackToRoadmap }} />
); diff --git a/web/tests/application-simulation-back.spec.ts b/web/tests/application-simulation-back.spec.ts index 1fc1ee6b..7d153c76 100644 --- a/web/tests/application-simulation-back.spec.ts +++ b/web/tests/application-simulation-back.spec.ts @@ -40,11 +40,37 @@ test.beforeEach(async ({ page }) => { ); }); -test('back control on the simulation page returns to the application roadmap', async ({ +test('back control inside the persona picker returns to the application roadmap', async ({ page, }) => { await page.goto(`/applications/${app.applicationId}/simulations`); + // The persona picker modal is up on first entry and its scrim covers the + // page's own "Back to roadmap" link (#195 regression from PR #165) — so + // the real user path back is the modal's own back control. + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + + const back = dialog.getByRole('button', { name: /back to roadmap/i }); + await expect(back).toBeVisible(); + + await back.click(); + + await expect(page).toHaveURL( + new RegExp(`/applications/${app.applicationId}/roadmap$`), + ); +}); + +test('back link on the page returns to the roadmap once the modal is dismissed', async ({ + page, +}) => { + await page.goto(`/applications/${app.applicationId}/simulations`); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(dialog).not.toBeVisible(); + const back = page.getByRole('link', { name: /back to roadmap/i }); await expect(back).toBeVisible(); From befc9be2906961f46960a3b0c236c9949d96bbdf Mon Sep 17 00:00:00 2001 From: BhuvanArn Date: Wed, 15 Jul 2026 10:32:29 +0200 Subject: [PATCH 11/16] chore: ignore root-level playwright output --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index e230bf42..44f7aa3a 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,7 @@ ai/microservices/**/llm/ # playwright mcp scratch (console logs, page snapshots, ad-hoc screenshots) .playwright-mcp/ + +# playwright output when run from the repo root (web/.gitignore anchors these to web/) +/test-results/ +/playwright-report/ From 45825c8698c807722957147fbe3166383a7c4b09 Mon Sep 17 00:00:00 2001 From: BhuvanArn Date: Wed, 15 Jul 2026 10:43:23 +0200 Subject: [PATCH 12/16] bug: reopen the persona picker when the choice is cleared --- .../simulation-workspace/index.spec.tsx | 19 +++++++++++++++++++ .../organisms/simulation-workspace/index.tsx | 15 +++++++++++---- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/web/src/components/organisms/simulation-workspace/index.spec.tsx b/web/src/components/organisms/simulation-workspace/index.spec.tsx index 7d87ad00..8e6457e1 100644 --- a/web/src/components/organisms/simulation-workspace/index.spec.tsx +++ b/web/src/components/organisms/simulation-workspace/index.spec.tsx @@ -124,4 +124,23 @@ describe('SimulationWorkspace persona picker', () => { screen.queryByRole('button', { name: /change recruiter/i }), ).not.toBeInTheDocument(); }); + + // Regression for the picker/store desync: useInterviewSession's finally + // block calls clearPersona() when an interview ends ("Leaving it set would + // silently skip the picker on the next visit"), but the workspace stayed + // mounted throughout that call. If isPickerOpen is only ever seeded from a + // useState initializer, this reset is invisible to the UI: the store goes + // back to null, persona quietly resolves to the default via + // getPersonaById(null), and the modal never reappears to let the user + // re-choose. The picker's open state must be derived from the store, not + // snapshotted once at mount. + it('reopens the picker when the store is cleared while mounted', () => { + act(() => usePersonaStore.getState().setPersona('marc-bernard')); + render(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + + act(() => usePersonaStore.getState().clearPersona()); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); }); diff --git a/web/src/components/organisms/simulation-workspace/index.tsx b/web/src/components/organisms/simulation-workspace/index.tsx index 5cb489b6..79f3f3de 100644 --- a/web/src/components/organisms/simulation-workspace/index.tsx +++ b/web/src/components/organisms/simulation-workspace/index.tsx @@ -51,13 +51,20 @@ export function SimulationWorkspace({ }: SimulationWorkspaceProps) { const selectedPersonaId = usePersonaStore((state) => state.selectedPersonaId); const setPersona = usePersonaStore((state) => state.setPersona); - const [isPickerOpen, setIsPickerOpen] = useState(selectedPersonaId === null); + // Explicit "Change recruiter" clicks reopen the picker even though a + // persona is already selected. This is intentionally NOT the sole source + // of truth: it is OR-ed with `selectedPersonaId === null` below so the + // picker also reopens whenever the store is cleared out from under a + // mounted workspace (useInterviewSession.clearPersona() on hang-up), not + // just at mount time. See simulation-workspace/index.spec.tsx. + const [wantsPickerOpen, setWantsPickerOpen] = useState(false); + const isPickerOpen = selectedPersonaId === null || wantsPickerOpen; const persona = getPersonaById(selectedPersonaId); const handlePersonaSelect = useCallback( (chosen: RecruiterPersona) => { setPersona(chosen.id); - setIsPickerOpen(false); + setWantsPickerOpen(false); }, [setPersona], ); @@ -66,7 +73,7 @@ export function SimulationWorkspace({ // the modal does not nag on the next mount. const handlePersonaDismiss = useCallback(() => { setPersona(DEFAULT_PERSONA.id); - setIsPickerOpen(false); + setWantsPickerOpen(false); }, [setPersona]); const [mediaStream, setMediaStream] = useState(null); @@ -385,7 +392,7 @@ export function SimulationWorkspace({ {!isCallActive && (
+ + + ); + } + + render(); + expect(screen.getByText('preferred')).toHaveFocus(); + }); + + it('falls back to the first focusable child when getInitialFocus returns null', () => { + function EmptyTargetHarness() { + const ref = useFocusTrap(true, { + getInitialFocus: () => null, + }); + return ( +
+ + +
+ ); + } + + render(); + expect(screen.getByText('first')).toHaveFocus(); + }); }); diff --git a/web/src/hooks/useFocusTrap.ts b/web/src/hooks/useFocusTrap.ts index 551ffa21..a78ef701 100644 --- a/web/src/hooks/useFocusTrap.ts +++ b/web/src/hooks/useFocusTrap.ts @@ -21,12 +21,15 @@ function getFocusable(container: HTMLElement): HTMLElement[] { * The codebase has no focus-trap library and no other hand-rolled trap, so this * is the shared primitive: attach the returned ref to a dialog container. * - * - Moves focus to the first focusable child on activate. + * - Moves focus into the container on activate: `getInitialFocus()` when given, + * otherwise the first focusable child. * - Cycles Tab / Shift+Tab within the container. * - Restores focus to the previously focused element on deactivate. * * Roving selection *within* a radiogroup is the consumer's job — this hook only - * owns the Tab boundary. + * owns the Tab boundary. Pass `getInitialFocus` when the first focusable child + * is not where the user should land (e.g. a leading close button). It is a + * getter, not a ref, so it resolves after children have mounted. * * @example * ```tsx @@ -34,9 +37,16 @@ function getFocusable(container: HTMLElement): HTMLElement[] { * return isOpen ?
: null; * ``` */ -export function useFocusTrap(isActive: boolean) { +export function useFocusTrap( + isActive: boolean, + options?: { getInitialFocus?: () => HTMLElement | null }, +) { const containerRef = useRef(null); const previouslyFocusedRef = useRef(null); + // Read through a ref so a caller's inline arrow does not re-run the effect + // (and re-steal focus) on every render. + const getInitialFocusRef = useRef(options?.getInitialFocus); + getInitialFocusRef.current = options?.getInitialFocus; useEffect(() => { if (!isActive) return; @@ -46,7 +56,7 @@ export function useFocusTrap(isActive: boolean) { const container = containerRef.current; if (container) { const focusable = getFocusable(container); - (focusable[0] ?? container).focus(); + (getInitialFocusRef.current?.() ?? focusable[0] ?? container).focus(); } const handleKeyDown = (event: KeyboardEvent) => { From 342f1f9529c2873377cb73b6f54ba79709845f0f Mon Sep 17 00:00:00 2001 From: BhuvanArn Date: Wed, 15 Jul 2026 14:26:14 +0200 Subject: [PATCH 16/16] bug: keep the chosen persona when a reopened picker is dismissed --- .../organisms/persona-picker-modal/index.tsx | 12 +++++ .../simulation-workspace/index.spec.tsx | 46 ++++++++++++++++++- .../organisms/simulation-workspace/index.tsx | 15 ++++-- 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/web/src/components/organisms/persona-picker-modal/index.tsx b/web/src/components/organisms/persona-picker-modal/index.tsx index efd90f7f..593c4f91 100644 --- a/web/src/components/organisms/persona-picker-modal/index.tsx +++ b/web/src/components/organisms/persona-picker-modal/index.tsx @@ -73,6 +73,18 @@ export function PersonaPickerModal({ getInitialFocus: () => radioRefs.current[highlighted.id] ?? null, }); + // `isOpen: false` hides the modal without unmounting it, so `highlighted` + // survives a close and would reopen showing whatever was last arrowed to — + // contradicting the committed persona, one Enter away from starting the wrong + // recruiter. Re-seed on every closed -> open transition; the caller's `key` + // cannot do this, because it is derived from an id that does not change when + // a dismiss keeps the current pick. + const wasOpenRef = useRef(isOpen); + useEffect(() => { + if (isOpen && !wasOpenRef.current) setHighlighted(initialHighlight); + wasOpenRef.current = isOpen; + }, [isOpen, initialHighlight]); + useEffect(() => { if (!isOpen) return; const handleEscape = (event: KeyboardEvent) => { diff --git a/web/src/components/organisms/simulation-workspace/index.spec.tsx b/web/src/components/organisms/simulation-workspace/index.spec.tsx index 8e6457e1..8cb84789 100644 --- a/web/src/components/organisms/simulation-workspace/index.spec.tsx +++ b/web/src/components/organisms/simulation-workspace/index.spec.tsx @@ -1,4 +1,4 @@ -import { PERSONAS } from '@/config/personas'; +import { DEFAULT_PERSONA, PERSONAS } from '@/config/personas'; import * as simulationHooks from '@/hooks/simulation'; import usePersonaStore from '@/stores/usePersonaStore'; import { act, fireEvent, render, screen } from '@testing-library/react'; @@ -143,4 +143,48 @@ describe('SimulationWorkspace persona picker', () => { expect(screen.getByRole('dialog')).toBeInTheDocument(); }); + + // Dismiss is overloaded: on first entry it means "use the default", but once + // a persona is committed, reopening via "Change recruiter" and backing out + // means *cancel*. Writing the default on that path silently downgraded a + // chosen Marc Bernard to Sophie Martin. + it('keeps the committed persona when a reopened picker is dismissed', () => { + act(() => usePersonaStore.getState().setPersona('marc-bernard')); + render(); + + fireEvent.click(screen.getByRole('button', { name: /change recruiter/i })); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + fireEvent.keyDown(document, { key: 'Escape' }); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(usePersonaStore.getState().selectedPersonaId).toBe('marc-bernard'); + expect(screen.getByText(/Marc Bernard/)).toBeInTheDocument(); + }); + + it('still commits the default when the first-entry picker is dismissed', () => { + render(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + fireEvent.keyDown(document, { key: 'Escape' }); + + expect(usePersonaStore.getState().selectedPersonaId).toBe( + DEFAULT_PERSONA.id, + ); + }); + + // The modal is hidden, not unmounted, so a stale highlight survives a close. + it('re-seeds the highlight to the committed persona on reopen', () => { + act(() => usePersonaStore.getState().setPersona('marc-bernard')); + render(); + + fireEvent.click(screen.getByRole('button', { name: /change recruiter/i })); + fireEvent.keyDown(screen.getByRole('radiogroup'), { key: 'Home' }); + fireEvent.keyDown(document, { key: 'Escape' }); + fireEvent.click(screen.getByRole('button', { name: /change recruiter/i })); + + const checked = screen + .getAllByRole('radio') + .find((radio) => radio.getAttribute('aria-checked') === 'true'); + expect(checked).toHaveTextContent('Marc Bernard'); + }); }); diff --git a/web/src/components/organisms/simulation-workspace/index.tsx b/web/src/components/organisms/simulation-workspace/index.tsx index 79f3f3de..48d9fd15 100644 --- a/web/src/components/organisms/simulation-workspace/index.tsx +++ b/web/src/components/organisms/simulation-workspace/index.tsx @@ -69,12 +69,18 @@ export function SimulationWorkspace({ [setPersona], ); - // Dismiss keeps today's behaviour: fall back to the default and persist it so - // the modal does not nag on the next mount. + // Dismiss means two different things depending on why the picker is open. + // First entry (nothing chosen): fall back to the default and persist it, so + // the modal does not nag on the next mount — today's shipped behaviour. + // Reopened via "Change recruiter": the user already has a pick, so dismiss + // means *cancel*. Writing the default here would silently downgrade a chosen + // Marc Bernard back to Sophie on an Esc. const handlePersonaDismiss = useCallback(() => { - setPersona(DEFAULT_PERSONA.id); + if (selectedPersonaId === null) { + setPersona(DEFAULT_PERSONA.id); + } setWantsPickerOpen(false); - }, [setPersona]); + }, [selectedPersonaId, setPersona]); const [mediaStream, setMediaStream] = useState(null); const [wsError, setWsError] = useState(null); @@ -405,7 +411,6 @@ export function SimulationWorkspace({