diff --git a/apps/api/src/routes/runtime-settings.test.ts b/apps/api/src/routes/runtime-settings.test.ts index 79959110..c0573cb6 100644 --- a/apps/api/src/routes/runtime-settings.test.ts +++ b/apps/api/src/routes/runtime-settings.test.ts @@ -29,6 +29,7 @@ describe('GET /api/runtime/settings', () => { expect(body.settings.temperature).toBeNull(); expect(body.settings.top_p).toBeNull(); expect(body.settings.repeat_penalty).toBeNull(); + expect(body.settings.reply_speed).toBeNull(); }); it('returns requires_restart: false on initial GET', async () => { @@ -47,6 +48,7 @@ describe('GET /api/runtime/settings', () => { temperature: null, top_p: null, repeat_penalty: null, + reply_speed: null, }); }); }); @@ -314,6 +316,79 @@ describe('POST /api/runtime/settings/reset', () => { }); }); +// ── Reply speed (issue #501) ───────────────────────────────────────────────── + +describe('runtime settings — reply_speed', () => { + it('persists a chosen speed', async () => { + const res = await app.inject({ + method: 'PUT', + url: '/api/runtime/settings', + payload: { reply_speed: 'fast' }, + }); + expect(res.statusCode).toBe(200); + expect(res.json().settings.reply_speed).toBe('fast'); + + const get = await app.inject({ method: 'GET', url: '/api/runtime/settings' }); + expect(get.json().settings.reply_speed).toBe('fast'); + }); + + it('returns requires_restart: false — it is read per turn', async () => { + const res = await app.inject({ + method: 'PUT', + url: '/api/runtime/settings', + payload: { reply_speed: 'detailed' }, + }); + expect(res.json().requires_restart).toBe(false); + }); + + it('returns 422 for a speed the UI cannot render', async () => { + const res = await app.inject({ + method: 'PUT', + url: '/api/runtime/settings', + payload: { reply_speed: 'ludicrous' }, + }); + expect(res.statusCode).toBe(422); + expect(res.json<{ message: string }>().message).toMatch(/Reply speed/); + }); + + it('survives a partial update of the numeric settings', async () => { + await app.inject({ + method: 'PUT', + url: '/api/runtime/settings', + payload: { reply_speed: 'fast' }, + }); + await app.inject({ + method: 'PUT', + url: '/api/runtime/settings', + payload: { threads: 4 }, + }); + const res = await app.inject({ method: 'GET', url: '/api/runtime/settings' }); + const body = res.json(); + expect(body.settings.reply_speed).toBe('fast'); + expect(body.settings.threads).toBe(4); + }); + + it('is cleared by reset', async () => { + await app.inject({ + method: 'PUT', + url: '/api/runtime/settings', + payload: { reply_speed: 'fast' }, + }); + const res = await app.inject({ method: 'POST', url: '/api/runtime/settings/reset' }); + expect(res.json().settings.reply_speed).toBeNull(); + }); + + it('reads a stored value it does not recognise back as unset', async () => { + // A value a newer build wrote, or one hand-edited into the database, must + // not surface as an option the UI cannot render. + getDb() + .prepare('INSERT OR REPLACE INTO model_config (key, value) VALUES (?, ?)') + .run('setting.reply_speed', 'hypersonic'); + const res = await app.inject({ method: 'GET', url: '/api/runtime/settings' }); + expect(res.json().settings.reply_speed).toBeNull(); + }); +}); + // ── Persistence across buildApp calls (simulating restarts) ────────────────── describe('runtime settings — persistence across restarts', () => { diff --git a/apps/api/src/routes/runtime-settings.ts b/apps/api/src/routes/runtime-settings.ts index bf94f207..d75c0372 100644 --- a/apps/api/src/routes/runtime-settings.ts +++ b/apps/api/src/routes/runtime-settings.ts @@ -1,7 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 import type { FastifyInstance } from 'fastify'; import { getDb } from '../db.js'; +import { REPLY_SPEEDS, isReplySpeed } from '@convsim/shared'; import type { + ReplySpeed, RuntimeSettings, RuntimeSettingsResponse, RuntimeSettingsRequest, @@ -15,8 +17,11 @@ const SETTING_KEYS: (keyof RuntimeSettings)[] = [ 'temperature', 'top_p', 'repeat_penalty', + 'reply_speed', ]; +// reply_speed is deliberately absent: it is read per turn, so a change applies +// to the player's very next message with no restart. const RESTART_REQUIRED: Set = new Set(['context_length', 'gpu_layers']); const NULL_SETTINGS: RuntimeSettings = { @@ -26,6 +31,7 @@ const NULL_SETTINGS: RuntimeSettings = { temperature: null, top_p: null, repeat_penalty: null, + reply_speed: null, }; interface ModelConfigRow { key: string; value: string } @@ -45,6 +51,13 @@ export function loadRuntimeSettings(): RuntimeSettings { return isNaN(n) ? null : n; } + function speed(k: string): ReplySpeed | null { + // A value a newer build wrote, or one hand-edited into the database, must + // not surface as an option the UI cannot render. + const v = map[k]; + return isReplySpeed(v) ? v : null; + } + return { context_length: num('context_length'), gpu_layers: num('gpu_layers'), @@ -52,6 +65,7 @@ export function loadRuntimeSettings(): RuntimeSettings { temperature: num('temperature'), top_p: num('top_p'), repeat_penalty: num('repeat_penalty'), + reply_speed: speed('reply_speed'), }; } @@ -126,6 +140,16 @@ function validate(req: RuntimeSettingsRequest): RuntimeSettingsFieldError[] { } } + const speed = req.reply_speed; + if (speed !== null && speed !== undefined) { + if (!isReplySpeed(speed)) { + errs.push({ + field: 'reply_speed', + message: `Reply speed must be one of: ${[...REPLY_SPEEDS].sort().join(', ')}.`, + }); + } + } + return errs; } @@ -159,6 +183,7 @@ export async function runtimeSettingsRoutes(app: FastifyInstance): Promise temperature: { type: ['number', 'null'] }, top_p: { type: ['number', 'null'] }, repeat_penalty: { type: ['number', 'null'] }, + reply_speed: { type: ['string', 'null'] }, }, }, }, diff --git a/apps/api/src/routes/sessions.test.ts b/apps/api/src/routes/sessions.test.ts index cafc4792..ff66b8f9 100644 --- a/apps/api/src/routes/sessions.test.ts +++ b/apps/api/src/routes/sessions.test.ts @@ -85,6 +85,62 @@ describe('GET /api/sessions', () => { const res = await app.inject({ method: 'GET', url: '/api/sessions' }); expect(res.json<{ sessions: unknown[] }>().sessions).toHaveLength(0); }); + + // ── status / limit (issue #501 §1) ──────────────────────────────────────── + // The resume affordances ask for `status=in_progress&limit=1` and offer + // whatever comes back as "Conversation in progress". A listing that ignored + // the filter would offer to resume a conversation that had already ended, so + // this proxy honours the same contract convsim-core serves. + + async function createInState(state: string): Promise { + const r = await app.inject({ method: 'POST', url: '/api/sessions', payload: validRequest }); + const { session_id } = r.json(); + getDb().prepare('UPDATE sessions SET state = ? WHERE session_id = ?').run(state, session_id); + return session_id; + } + + it('status=in_progress returns only sessions that can be resumed', async () => { + const live = await createInState('PlayerTurnListening'); + await createInState('NotStarted'); + await createInState('Ended'); + + const res = await app.inject({ method: 'GET', url: '/api/sessions?status=in_progress' }); + const { sessions } = res.json<{ sessions: SessionCreateResponse[] }>(); + expect(sessions.map((s) => s.session_id)).toEqual([live]); + }); + + it('status=ended covers the debrief states, not just Ended', async () => { + const ended = await createInState('Ended'); + const generating = await createInState('DebriefGenerating'); + const ready = await createInState('DebriefReady'); + const errored = await createInState('Error'); + await createInState('PlayerTurnListening'); + + const res = await app.inject({ method: 'GET', url: '/api/sessions?status=ended' }); + const { sessions } = res.json<{ sessions: SessionCreateResponse[] }>(); + expect(sessions.map((s) => s.session_id).sort()).toEqual( + [ended, generating, ready, errored].sort(), + ); + }); + + it('limit caps the listing, newest first', async () => { + await createInState('PlayerTurnListening'); + const newest = await createInState('PlayerTurnListening'); + + const res = await app.inject({ method: 'GET', url: '/api/sessions?limit=1' }); + const { sessions } = res.json<{ sessions: SessionCreateResponse[] }>(); + expect(sessions.map((s) => s.session_id)).toEqual([newest]); + }); + + it('rejects an out-of-range limit and an unknown status', async () => { + expect((await app.inject({ method: 'GET', url: '/api/sessions?limit=0' })).statusCode).toBe(400); + expect((await app.inject({ method: 'GET', url: '/api/sessions?limit=501' })).statusCode).toBe( + 400, + ); + expect((await app.inject({ method: 'GET', url: '/api/sessions?status=live' })).statusCode).toBe( + 400, + ); + }); }); // --------------------------------------------------------------------------- @@ -272,6 +328,29 @@ describe('GET /api/sessions/:session_id', () => { }); expect(res.statusCode).toBe(404); }); + + it('reports the meter values a resuming screen has to redraw', async () => { + // Issue #501 §1: meter values otherwise only ever arrive with a turn, so a + // player who stepped out of a conversation and came back saw no meters at + // all until they sent another message. + const createRes = await app.inject({ + method: 'POST', + url: '/api/sessions', + payload: validRequest, + }); + const { session_id } = createRes.json(); + await app.inject({ method: 'POST', url: `/api/sessions/${session_id}/start` }); + await app.inject({ + method: 'POST', + url: `/api/sessions/${session_id}/turn`, + payload: { content: 'Hello there.' }, + }); + + const getRes = await app.inject({ method: 'GET', url: `/api/sessions/${session_id}` }); + const body = getRes.json(); + expect(body.visible_state).toBeTruthy(); + expect(Object.keys(body.visible_state ?? {}).length).toBeGreaterThan(0); + }); }); // --------------------------------------------------------------------------- diff --git a/apps/api/src/routes/sessions.ts b/apps/api/src/routes/sessions.ts index 7cfc05f0..66eb9632 100644 --- a/apps/api/src/routes/sessions.ts +++ b/apps/api/src/routes/sessions.ts @@ -11,6 +11,9 @@ import type { SessionTranscriptResponse, EndingType, } from '@convsim/shared'; +// The resumable / ended partition lives in @convsim/shared so this listing and +// the Settings screen that reads it cannot drift apart (issue #501 §1). +import { ENDED_SESSION_STATES, RESUMABLE_SESSION_STATES } from '@convsim/shared'; import { findScenarioInfo } from './scenarios.js'; import { getDb, WORKBENCH_TEST_SCENARIO_ID } from '../db.js'; import { broadcast, closeSessionSockets } from '../ws/session-events.js'; @@ -163,16 +166,48 @@ function rejectTransition(reply: { status: (code: number) => void }, state: Sess export async function sessionRoutes(app: FastifyInstance) { // GET /api/sessions - app.get('/api/sessions', async (): Promise<{ sessions: SessionCreateResponse[] }> => { + app.get<{ Querystring: { status?: string; limit?: string } }>( + '/api/sessions', + async (request, reply): Promise<{ sessions: SessionCreateResponse[] }> => { const db = getDb(); + // `status` and `limit` are the same contract convsim-core serves + // (issue #501 §1). Honouring them here is not cosmetic: the resume + // affordances ask for `status=in_progress&limit=1` and offer whatever comes + // back as "Conversation in progress", so a listing that silently ignored + // the filter would offer to resume a conversation that has already ended. + const status = request.query.status ?? 'all'; + if (status !== 'all' && status !== 'in_progress' && status !== 'ended') { + reply.status(400); + throw new Error("status must be one of: all, ended, in_progress"); + } + const limit = request.query.limit === undefined ? 50 : Number(request.query.limit); + if (!Number.isInteger(limit) || limit < 1 || limit > 500) { + reply.status(400); + throw new Error('limit must be between 1 and 500'); + } + // Exclude temporary workbench test sessions so they never pollute the // player's session history (they are cleaned up on discard/reset/unmount, // but a lingering row must not surface here either). + const states = + status === 'in_progress' + ? RESUMABLE_SESSION_STATES + : status === 'ended' + ? ENDED_SESSION_STATES + : null; + const clauses = ['scenario_id != ?']; + const params: (string | number)[] = [WORKBENCH_TEST_SCENARIO_ID]; + if (states !== null) { + clauses.push(`state IN (${states.map(() => '?').join(', ')})`); + params.push(...states); + } + params.push(limit); const rows = db - .prepare<[string], SessionRow>( - 'SELECT * FROM sessions WHERE scenario_id != ? ORDER BY created_at DESC, rowid DESC', + .prepare<(string | number)[], SessionRow>( + `SELECT * FROM sessions WHERE ${clauses.join(' AND ')} ` + + 'ORDER BY created_at DESC, rowid DESC LIMIT ?', ) - .all(WORKBENCH_TEST_SCENARIO_ID); + .all(...params); return { sessions: rows.map((row) => ({ session_id: row.session_id, @@ -185,7 +220,8 @@ export async function sessionRoutes(app: FastifyInstance) { setup: JSON.parse(row.setup_json) as SessionCreateRequest, })), }; - }); + }, + ); // POST /api/sessions app.post<{ Body: SessionCreateRequest }>( @@ -299,6 +335,13 @@ export async function sessionRoutes(app: FastifyInstance) { state: row.state as SessionState, created_at: row.created_at, setup: JSON.parse(row.setup_json) as SessionCreateRequest, + // A resuming conversation screen draws its meters from here + // (issue #501 §1): meter values otherwise only ever arrive with a turn, + // so without them a player who stepped out and came back saw no meters + // until they sent another message. Mirrors convsim-core's single-session + // response; this layer does not model per-variable visibility, so every + // tracked variable is reported. + visible_state: JSON.parse(row.state_vars_json || '{}') as Record, }; }, ); diff --git a/apps/web/src/__tests__/App.test.tsx b/apps/web/src/__tests__/App.test.tsx index 6b3471ab..8ccc5f6c 100644 --- a/apps/web/src/__tests__/App.test.tsx +++ b/apps/web/src/__tests__/App.test.tsx @@ -83,7 +83,9 @@ describe('App shell', () => { it('renders Conversation at /conversation/:id', () => { renderAt('/conversation/sess-001') expect(screen.getByRole('heading', { name: /conversation/i })).toBeInTheDocument() - expect(screen.getByText(/sess-001/)).toBeInTheDocument() + // The session id is a technical-wording detail now (issue #501 §2), so the + // route is identified by the screen it mounted, not by the id in its header. + expect(screen.getByTestId('conversation-page')).toBeInTheDocument() }) it('renders Debrief at /debrief/:id', () => { @@ -111,13 +113,13 @@ describe('App shell', () => { it('shows healthy status when backend returns ok', async () => { mockFetch({ status: 'ok' }) renderAt('/') - expect(await screen.findByText('Local runtime: Ready')).toBeInTheDocument() + expect(await screen.findByText('AI engine: Ready')).toBeInTheDocument() }) it('shows unavailable status when backend is unreachable', async () => { vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new Error('Network error')))) renderAt('/') - expect(await screen.findByText('Local runtime: Unavailable')).toBeInTheDocument() + expect(await screen.findByText('AI engine: Unavailable')).toBeInTheDocument() }) }) diff --git a/apps/web/src/__tests__/Conversation.test.tsx b/apps/web/src/__tests__/Conversation.test.tsx index 1561060a..6275aaaa 100644 --- a/apps/web/src/__tests__/Conversation.test.tsx +++ b/apps/web/src/__tests__/Conversation.test.tsx @@ -24,6 +24,9 @@ vi.mock('../api/client', () => ({ getScenario: vi.fn(), getSetupInstallStatus: vi.fn(), getSessionTranscript: vi.fn(), + // Resume reads the recorded setup back when there is no route state + // (issue #501 §1), which is how most of these tests render the screen. + getSession: vi.fn(), }, apiClient: { health: vi.fn(), @@ -144,9 +147,20 @@ function renderConversation(routeState?: Record) { beforeEach(() => { vi.clearAllMocks() + // Tests that opt into technical wording (or dev mode) write to localStorage, + // and jsdom keeps it between tests in the same file — so a later test would + // inherit the previous one's wording level. + localStorage.clear() // Default: connectSession returns a no-op connection; getScenario returns null mockApi.connectSession.mockReturnValue({ close: vi.fn() }) mockApi.getScenario.mockResolvedValue({ ok: true, data: null } as never) + // Rendering without route state is the resume path, so the screen asks the + // core for the setup it recorded. Default to "unavailable": these tests are + // about the conversation, not about what the setup fetch adds. + mockApi.getSession.mockResolvedValue({ + ok: false, + error: { kind: 'network', message: 'not mocked' }, + } as never) mockApiClient.uploadAudio.mockResolvedValue({ ok: true, data: { transcript: null, status: 'unavailable' } }) // The screen reads health to learn which model the turn estimate belongs to. mockApiClient.health.mockResolvedValue({ ok: true, data: healthResponse }) @@ -179,10 +193,25 @@ describe('Conversation screen', () => { await waitFor(() => expect(screen.getByRole('log')).toBeInTheDocument()) }) - it('shows the session id in the header', async () => { + it('keeps the session id and raw flow state out of the header by default', async () => { + // Issue #501 §2: "Session: sess-… | State: PlayerTurnListening" was the + // first thing a new player read on this screen. + mockApi.startSession.mockResolvedValue({ ok: true, data: startResponse }) + renderConversation() + await waitFor(() => + expect(screen.getByTestId('conversation-subhead')).toBeInTheDocument(), + ) + expect(screen.queryByText(SESSION_ID)).not.toBeInTheDocument() + expect(screen.queryByText('PlayerTurnListening')).not.toBeInTheDocument() + expect(screen.getByTestId('conversation-subhead')).toHaveTextContent('Your turn') + }) + + it('shows the session id and raw flow state at the technical wording level', async () => { + localStorage.setItem('convsim.ui.languageLevel', 'technical') mockApi.startSession.mockResolvedValue({ ok: true, data: startResponse }) renderConversation() await waitFor(() => expect(screen.getByText(SESSION_ID)).toBeInTheDocument()) + expect(screen.getByText('PlayerTurnListening')).toBeInTheDocument() }) it('shows an error alert when startSession fails', async () => { @@ -227,6 +256,102 @@ describe('Conversation screen', () => { // … and no scary error banner covers a working conversation. expect(screen.queryByRole('alert')).not.toBeInTheDocument() }) + + it('restores the meters when resuming, not just the transcript', async () => { + // Issue #501 §1 and §3: meter values only ever arrived with a turn, so a + // player who stepped out to Settings came back to a conversation with no + // meters at all — on the screen whose opening line tells them to watch + // the two meters above it — until they sent another message. + mockApi.startSession.mockResolvedValue({ + ok: false, + error: { kind: 'network', message: 'INVALID_TRANSITION' }, + }) + mockApi.getSessionTranscript.mockResolvedValue({ + ok: true, + data: { + session_id: SESSION_ID, + scenario_id: 'behavioral_interview', + transcript_saved: true, + turns: [ + { turn_number: 0, role: 'npc_opening', content: 'Thanks for coming in.', flow_state_after: 'PlayerTurnListening' }, + { turn_number: 1, role: 'player', content: 'Happy to be here.', flow_state_after: 'NpcThinking' }, + { turn_number: 2, role: 'npc', content: 'Walk me through your background.', flow_state_after: 'PlayerTurnListening' }, + ], + }, + }) + mockApi.getSession.mockResolvedValue({ + ok: true, + data: { + session_id: SESSION_ID, + scenario_id: 'behavioral_interview', + state: 'PlayerTurnListening', + created_at: '2026-07-01T00:00:00Z', + setup: {}, + turn_count: 1, + visible_state: { trust: 62, patience: 70 }, + }, + } as never) + renderConversation() + + await waitFor(() => expect(screen.getByTestId('state-vars')).toBeInTheDocument()) + expect(screen.getByTestId('state-meter-trust')).toHaveTextContent('62') + expect(screen.getByTestId('state-meter-patience')).toHaveTextContent('70') + // Nothing moved on arrival: the values the session was left at are the + // baseline, not a change from anything. + expect(screen.getByTestId('state-meter-delta-trust')).not.toHaveTextContent('+') + }) + + it('keeps counting whole turns when resuming a session that saves no transcript', async () => { + // Issue #501 §4: the transcript endpoint answers a session started with + // transcript saving off with no rows, always, so there is nothing to count + // the resumed turns from and the labels restarted at "Turn 1" over a + // session the server was three turns into — the debrief-says-4 / + // transcript-says-1 mismatch again, on the screen resume was added for. + mockApi.startSession.mockResolvedValue({ + ok: false, + error: { kind: 'network', message: 'INVALID_TRANSITION' }, + }) + mockApi.getSessionTranscript.mockResolvedValue({ + ok: true, + data: { + session_id: SESSION_ID, + scenario_id: 'behavioral_interview', + transcript_saved: false, + message: 'Transcript saving is disabled for this session.', + turns: [], + }, + }) + mockApi.getSession.mockResolvedValue({ + ok: true, + data: { + session_id: SESSION_ID, + scenario_id: 'behavioral_interview', + state: 'PlayerTurnListening', + created_at: '2026-07-01T00:00:00Z', + setup: { save_transcript: false }, + turn_count: 3, + visible_state: { trust: 55 }, + }, + } as never) + mockApi.submitTurn.mockResolvedValue({ ok: true, data: turnResponse }) + renderConversation() + + // Wait for the session read to land before sending, so the counter is + // seeded rather than racing the player's message. + await waitFor(() => expect(screen.getByTestId('state-meter-trust')).toHaveTextContent('55')) + fireEvent.change(screen.getByRole('textbox', { name: /your response/i }), { + target: { value: 'Picking up where we left off.' }, + }) + fireEvent.click(screen.getByRole('button', { name: /submit/i })) + + await waitFor(() => + expect(screen.getByText('Hello there. I am a simulated NPC.')).toBeInTheDocument(), + ) + // Three whole turns already played, so this exchange is the fourth — both + // halves of it, and nothing numbered 1. + expect(screen.getAllByText('Turn 4')).toHaveLength(2) + expect(screen.queryByText('Turn 1')).not.toBeInTheDocument() + }) }) describe('NPC panel', () => { @@ -418,8 +543,8 @@ describe('Conversation screen', () => { // The failed message must not linger in the transcript. expect(screen.queryByText('This will fail.')).not.toBeInTheDocument() - // A successful retry should be labelled Turn 2 (opening was Turn 1), - // with no gap or duplicate from the rolled-back attempt. + // A successful retry is the first whole turn, with no gap or duplicate + // from the rolled-back attempt. mockApi.submitTurn.mockResolvedValue({ ok: true, data: turnResponse }) fireEvent.change(screen.getByRole('textbox', { name: /your response/i }), { target: { value: 'Retry message.' }, @@ -429,19 +554,45 @@ describe('Conversation screen', () => { await waitFor(() => expect(screen.getByText('Retry message.')).toBeInTheDocument(), ) - // Opening=Turn 1, retry player=Turn 2, NPC=Turn 3. The absence of a - // Turn 4 confirms the failed attempt did not consume a turn number. - expect(screen.getByText('Turn 2')).toBeInTheDocument() - expect(screen.queryByText('Turn 4')).not.toBeInTheDocument() + // Opening is labelled, the retry's message and the reply to it are both + // "Turn 1" (issue #501 §4). The absence of a Turn 2 confirms the failed + // attempt did not consume a turn number. + expect(screen.getByText('Opening')).toBeInTheDocument() + expect(screen.getAllByText('Turn 1')).toHaveLength(2) + expect(screen.queryByText('Turn 2')).not.toBeInTheDocument() }) - it('shows turn number markers in the transcript', async () => { + it('labels the opening rather than numbering it as a turn', async () => { + // Issue #501 §4: the opening answers nothing, so it is not half a turn. mockApi.submitTurn.mockResolvedValue({ ok: true, data: turnResponse }) renderConversation() await waitFor(() => expect(screen.getByText('Thanks for coming in. Tell me about yourself.')).toBeInTheDocument(), ) - expect(screen.getByText(/Turn 1/)).toBeInTheDocument() + expect(screen.getByText('Opening')).toBeInTheDocument() + expect(screen.queryByText(/Turn 1/)).not.toBeInTheDocument() + }) + + it('numbers one player message and the reply to it as a single turn', async () => { + mockApi.submitTurn.mockResolvedValue({ ok: true, data: turnResponse }) + renderConversation() + await waitFor(() => + expect(screen.getByRole('textbox', { name: /your response/i })).toBeInTheDocument(), + ) + fireEvent.change(screen.getByRole('textbox', { name: /your response/i }), { + target: { value: 'I have five years of experience.' }, + }) + fireEvent.click(screen.getByRole('button', { name: /submit/i })) + + await waitFor(() => + expect(screen.getByText('Hello there. I am a simulated NPC.')).toBeInTheDocument(), + ) + // Both halves of the exchange carry Turn 1 — which is also what + // convsim-core counts in turn_count and what max_turns is measured in. + const labels = screen.getAllByText('Turn 1') + expect(labels).toHaveLength(2) + expect(screen.queryByText('Turn 2')).not.toBeInTheDocument() + expect(screen.queryByText('Turn 3')).not.toBeInTheDocument() }) it('shows the player message immediately after submit without waiting for REST', async () => { @@ -583,15 +734,134 @@ describe('Conversation screen', () => { }) }) - describe('state variables panel', () => { - it('shows NPC state variables section when show_state_meters is true (default)', async () => { + describe('conversation meters', () => { + it('shows the meters when show_state_meters is true (default)', async () => { mockApi.startSession.mockResolvedValue({ ok: true, data: startResponse }) renderConversation() await waitFor(() => expect(screen.getByTestId('state-vars')).toBeInTheDocument(), ) - expect(screen.getByTestId('state-vars')).toHaveTextContent('trust') - expect(screen.getByTestId('state-vars')).toHaveTextContent('patience') + expect(screen.getByTestId('state-vars')).toHaveTextContent('Trust') + expect(screen.getByTestId('state-vars')).toHaveTextContent('Patience') + }) + + it('humanizes snake_case variable names in plain wording', async () => { + // Issue #501 §3: "objective_progress" is a YAML key, not a label. + mockApi.startSession.mockResolvedValue({ ok: true, data: startResponse }) + renderConversation() + await waitFor(() => expect(screen.getByTestId('state-vars')).toBeInTheDocument()) + expect(screen.getByTestId('state-meter-objective_progress')).toHaveTextContent( + 'Objective progress', + ) + }) + + it('keeps the raw variable keys at the technical wording level', async () => { + localStorage.setItem('convsim.ui.languageLevel', 'technical') + mockApi.startSession.mockResolvedValue({ ok: true, data: startResponse }) + renderConversation() + await waitFor(() => expect(screen.getByTestId('state-vars')).toBeInTheDocument()) + expect(screen.getByTestId('state-meter-objective_progress')).toHaveTextContent( + 'objective_progress', + ) + }) + + it('sits above the transcript, where the tutorial says the meters are', async () => { + mockApi.startSession.mockResolvedValue({ ok: true, data: startResponse }) + renderConversation() + await waitFor(() => expect(screen.getByTestId('state-vars')).toBeInTheDocument()) + const meters = screen.getByTestId('state-vars') + const transcript = screen.getByRole('log') + // Node.DOCUMENT_POSITION_FOLLOWING === 4: the transcript comes after. + expect(meters.compareDocumentPosition(transcript) & 4).toBeTruthy() + }) + + it('shows how far each meter moved on the latest turn', async () => { + // The tutorial says "Engagement just ticked up"; nothing on screen used + // to tick (issue #501 §3). + mockApi.startSession.mockResolvedValue({ ok: true, data: startResponse }) + mockApi.submitTurn.mockResolvedValue({ + ok: true, + data: { + ...turnResponse, + events: [ + turnResponse.events[0], + { + ...turnResponse.events[1], + payload: { + ...turnResponse.events[1].payload, + visible_state: { trust: 60, patience: 75, rapport: 50, openness: 50, objective_progress: 0 }, + }, + }, + ], + }, + }) + renderConversation() + await waitFor(() => + expect(screen.getByRole('textbox', { name: /your response/i })).toBeInTheDocument(), + ) + fireEvent.change(screen.getByRole('textbox', { name: /your response/i }), { + target: { value: 'Something encouraging.' }, + }) + fireEvent.click(screen.getByRole('button', { name: /submit/i })) + + await waitFor(() => + expect(screen.getByTestId('state-meter-delta-trust')).toHaveTextContent('+10'), + ) + // A meter that did not move says nothing. + expect(screen.getByTestId('state-meter-delta-patience')).not.toHaveTextContent('+') + }) + + it('keeps the movement when the authoritative snapshot repeats it', async () => { + // One turn produces two snapshots: npc.final applies the delta + // optimistically, then session.state delivers the same numbers as fact. + // Measured against the previous snapshot the second one is a move of + // zero, which wiped the arrow the first one had just earned — so the + // tick the tutorial talks about flashed and vanished (issue #501 §3). + let wsCallback: ((event: WsEvent) => void) | null = null + mockApi.connectSession.mockImplementation((_id, cb) => { + wsCallback = cb + return { close: vi.fn() } + }) + mockApi.startSession.mockResolvedValue({ ok: true, data: startResponse }) + mockApi.submitTurn.mockResolvedValue({ ok: true, data: turnResponse }) + renderConversation() + await waitFor(() => + expect(screen.getByRole('textbox', { name: /your response/i })).toBeInTheDocument(), + ) + fireEvent.change(screen.getByRole('textbox', { name: /your response/i }), { + target: { value: 'Something encouraging.' }, + }) + fireEvent.click(screen.getByRole('button', { name: /submit/i })) + + const moved = { trust: 60, patience: 75, rapport: 50, openness: 50, objective_progress: 0 } + act(() => { + wsCallback?.({ + type: 'npc.final', + seq: 1, + session_id: SESSION_ID, + ts: '2026-07-01T00:01:00Z', + payload: { + content: 'Good to hear.', + emotion: 'warm', + state_delta: { trust: 10 }, + event_flags: [], + }, + }) + }) + await waitFor(() => + expect(screen.getByTestId('state-meter-delta-trust')).toHaveTextContent('+10'), + ) + + act(() => { + wsCallback?.({ + type: 'session.state', + seq: 2, + session_id: SESSION_ID, + ts: '2026-07-01T00:01:01Z', + payload: { state: 'PlayerTurnListening', state_vars: moved, ending_type: null }, + }) + }) + expect(screen.getByTestId('state-meter-delta-trust')).toHaveTextContent('+10') }) it('hides state variables when show_state_meters is false', async () => { @@ -628,7 +898,79 @@ describe('Conversation screen', () => { await waitFor(() => expect(screen.getByTestId('banner-event')).toBeInTheDocument(), ) - expect(screen.getByTestId('banner-event')).toHaveTextContent('rapport_milestone') + // Issue #501 §2: "Scenario event: rapport_milestone" is two pieces of + // jargon and an identifier. Plain wording says what happened. + expect(screen.getByTestId('banner-event')).toHaveTextContent( + 'Something changed: Rapport milestone', + ) + }) + + it('announces a scenario event the engine fired on the turn', async () => { + // Engine-fired events (a meter crossing a threshold) arrive only on the + // npc_turn payload as triggered_scenario_events, and nothing read them: + // the banner was fed solely by the WebSocket scenario.event frame, which + // carries the NPC's self-declared flags and which convsim-core does not + // send. So the tutorial fired warm_moment and then told the player they + // "would have seen a short note above the transcript" (issue #501 §3). + mockApi.startSession.mockResolvedValue({ ok: true, data: startResponse }) + mockApi.submitTurn.mockResolvedValue({ + ok: true, + data: { + ...turnResponse, + events: [ + turnResponse.events[0], + { + ...turnResponse.events[1], + payload: { + ...turnResponse.events[1].payload, + triggered_scenario_events: ['warm_moment'], + }, + }, + ], + }, + }) + renderConversation() + await waitFor(() => + expect(screen.getByRole('textbox', { name: /your response/i })).toBeInTheDocument(), + ) + fireEvent.change(screen.getByRole('textbox', { name: /your response/i }), { + target: { value: 'Something encouraging.' }, + }) + fireEvent.click(screen.getByRole('button', { name: /submit/i })) + + await waitFor(() => + expect(screen.getByTestId('banner-event')).toHaveTextContent( + 'Something changed: Warm moment', + ), + ) + }) + + it('keeps the raw event flags in the banner at the technical level', async () => { + localStorage.setItem('convsim.ui.languageLevel', 'technical') + let wsCallback: ((event: WsEvent) => void) | null = null + mockApi.connectSession.mockImplementation((_id, cb) => { + wsCallback = cb + return { close: vi.fn() } + }) + mockApi.startSession.mockResolvedValue({ ok: true, data: startResponse }) + renderConversation() + await waitFor(() => expect(screen.getByTestId('npc-panel')).toBeInTheDocument()) + + act(() => { + wsCallback?.({ + type: 'scenario.event', + seq: 1, + session_id: SESSION_ID, + ts: '2026-07-01T00:01:00Z', + payload: { flags: ['rapport_milestone'] }, + }) + }) + + await waitFor(() => + expect(screen.getByTestId('banner-event')).toHaveTextContent( + 'Scenario event: rapport_milestone', + ), + ) }) it('shows a safety redirect banner when websocket delivers safety.redirect', async () => { @@ -1513,6 +1855,26 @@ describe('Conversation screen', () => { } }) + // Issue #501 §1: the playtester hit a slow turn, went to Settings to speed + // the model up, "was a little unclear on how to do that", and lost the + // session. This notice is where that detour starts, so it names the setting + // and links straight at it instead of saying "you can adjust settings". + it('points the player at the reply-speed setting by name', async () => { + mockApi.submitTurn.mockReturnValue(new Promise(() => {})) + vi.useFakeTimers({ shouldAdvanceTime: true }) + try { + await submitAndWait(6_000) + const link = screen.getByTestId('slow-response-speed-link') + expect(link).toHaveTextContent(/make replies faster/i) + // The anchor matters: /settings alone lands the player at the top of a + // long page with nothing obviously changed. + expect(link).toHaveAttribute('href', '/settings#reply-speed') + expect(screen.queryByText(/you can adjust settings/i)).not.toBeInTheDocument() + } finally { + vi.useRealTimers() + } + }) + it('announces the elapsed wait on a coarse grid so the live region is not spammed', async () => { // The visible clock ticks every second; left audible that is ~270 // announcements over a five-minute turn. It is aria-hidden, and a separate @@ -1551,9 +1913,13 @@ describe('Conversation screen', () => { ).toBeInTheDocument(), ) expect(screen.queryByRole('alert')).not.toBeInTheDocument() - // Server copy is authoritative, so the player turn stays and keeps its number. + // Server copy is authoritative, so the player turn stays and keeps its + // number — and the server's turn_number is what the whole-turn labels + // are derived from, so a reconciled transcript numbers identically to + // one built turn by turn (issue #501 §4). expect(screen.getByText('My answer.')).toBeInTheDocument() - expect(screen.getByText('Turn 3')).toBeInTheDocument() + expect(screen.getByText('Opening')).toBeInTheDocument() + expect(screen.getAllByText('Turn 1')).toHaveLength(2) expect(screen.getByRole('textbox', { name: /your response/i })).not.toBeDisabled() } finally { vi.useRealTimers() diff --git a/apps/web/src/__tests__/Debrief.test.tsx b/apps/web/src/__tests__/Debrief.test.tsx index d99aeadb..ceec9664 100644 --- a/apps/web/src/__tests__/Debrief.test.tsx +++ b/apps/web/src/__tests__/Debrief.test.tsx @@ -340,6 +340,117 @@ describe('Debrief screen', () => { }) }) + // Issue #501 §4: one turn is the player's message plus the reply to it. The + // debrief's key-moment numbers were already whole turns, so labelling the + // transcript by position made "#3" and "Turn 3" different moments. + describe('whole-turn labelling', () => { + const threeTurnExport = { + session: exportData.session, + events: [ + { event_id: 1, session_id: SESSION_ID, event_type: 'npc_opening', payload: { content: 'Thanks for coming in.' }, created_at: '2024-01-01T00:00:00Z' }, + { event_id: 2, session_id: SESSION_ID, event_type: 'player_turn', payload: { content: 'Player one.' }, created_at: '2024-01-01T00:00:01Z' }, + { event_id: 3, session_id: SESSION_ID, event_type: 'npc_turn', payload: { content: 'Npc one.', emotion: 'warm' }, created_at: '2024-01-01T00:00:02Z' }, + { event_id: 4, session_id: SESSION_ID, event_type: 'player_turn', payload: { content: 'Player two.' }, created_at: '2024-01-01T00:00:03Z' }, + { event_id: 5, session_id: SESSION_ID, event_type: 'npc_turn', payload: { content: 'Npc two.', emotion: 'neutral' }, created_at: '2024-01-01T00:00:04Z' }, + ], + } + + it('labels the opening and numbers each exchange once', async () => { + mockApi.generateDebrief.mockResolvedValue({ ok: true, data: fullDebriefResponse }) + mockApi.exportSession.mockResolvedValue({ ok: true, data: threeTurnExport }) + renderDebrief() + await waitFor(() => + expect(screen.getByTestId('transcript-section')).toBeInTheDocument(), + ) + const numbers = screen + .getAllByTestId('transcript-turn') + .map((el) => el.getAttribute('data-turn-number')) + expect(numbers).toEqual(['0', '1', '1', '2', '2']) + expect(screen.getByText('Opening')).toBeInTheDocument() + expect(screen.getAllByText('Turn 1')).toHaveLength(2) + expect(screen.getAllByText('Turn 2')).toHaveLength(2) + }) + + it('labels the NPC mood rather than bracketing it beside the turn number', async () => { + // Next to a turn number it read as one of the meters (issue #501 §3). + mockApi.generateDebrief.mockResolvedValue({ ok: true, data: fullDebriefResponse }) + mockApi.exportSession.mockResolvedValue({ ok: true, data: threeTurnExport }) + renderDebrief() + await waitFor(() => + expect(screen.getByTestId('transcript-section')).toBeInTheDocument(), + ) + expect(screen.getByText('warm')).toBeInTheDocument() + expect(screen.queryByText('(warm)')).not.toBeInTheDocument() + }) + + it('a key moment points at the start of the turn it names', async () => { + mockApi.generateDebrief.mockResolvedValue({ ok: true, data: fullDebriefResponse }) + mockApi.exportSession.mockResolvedValue({ ok: true, data: threeTurnExport }) + renderDebrief() + await waitFor(() => + expect(screen.getByTestId('transcript-section')).toBeInTheDocument(), + ) + const scrolled: Element[] = [] + for (const el of screen.getAllByTestId('transcript-turn')) { + el.scrollIntoView = () => { scrolled.push(el) } + } + fireEvent.click(screen.getByRole('button', { name: /go to turn 2/i })) + expect(scrolled).toHaveLength(1) + // The player's message, which is where turn 2 begins — not the second + // row in the list, which is the player's message of turn 1. + expect(scrolled[0]).toHaveTextContent('Player two.') + }) + }) + + // Issue #501 §3: the panel the player watches during a conversation is + // called "Conversation meters". The debrief reviews the same bars, so it has + // to use the same name — a panel that is renamed between the screen it moves + // on and the screen that reviews it is the label/naming mismatch the report + // described, one screen later. + describe('conversation meters panel', () => { + const withMetrics: SessionDebriefResponse = { + ...fullDebriefResponse, + metrics: { + metrics_version: '1', + talk_ratio: 0.5, + words_per_turn_player: 12, + words_per_turn_npc: 18, + open_questions: 1, + closed_questions: 1, + filler_word_count: 0, + interruption_count: 0, + response_latency_p50_ms: null, + response_latency_p95_ms: null, + state_arc: [ + { turn_number: 1, state: { engagement: 40, objective_progress: 20 } }, + { turn_number: 2, state: { engagement: 55, objective_progress: 35 } }, + ], + }, + } + + it('calls the meters what the conversation screen called them', async () => { + mockApi.generateDebrief.mockResolvedValue({ ok: true, data: withMetrics }) + mockApi.exportSession.mockResolvedValue({ ok: false, error: { kind: 'network', message: 'x' } }) + renderDebrief() + await waitFor(() => + expect(screen.getByTestId('telemetry-panel')).toBeInTheDocument(), + ) + expect(screen.getByText('Conversation meters across turns')).toBeInTheDocument() + expect(screen.queryByText(/state meters/i)).not.toBeInTheDocument() + }) + + it('names each meter in words rather than as a raw key', async () => { + mockApi.generateDebrief.mockResolvedValue({ ok: true, data: withMetrics }) + mockApi.exportSession.mockResolvedValue({ ok: false, error: { kind: 'network', message: 'x' } }) + renderDebrief() + await waitFor(() => + expect(screen.getByTestId('telemetry-panel')).toBeInTheDocument(), + ) + expect(screen.getByText('objective progress')).toBeInTheDocument() + expect(screen.queryByText('objective_progress')).not.toBeInTheDocument() + }) + }) + describe('transcript display', () => { it('shows transcript turns from export when available', async () => { mockApi.generateDebrief.mockResolvedValue({ ok: true, data: fullDebriefResponse }) diff --git a/apps/web/src/__tests__/Home.test.tsx b/apps/web/src/__tests__/Home.test.tsx index f03af364..077c4e43 100644 --- a/apps/web/src/__tests__/Home.test.tsx +++ b/apps/web/src/__tests__/Home.test.tsx @@ -58,7 +58,13 @@ function liText(expected: string) { // Stub fetch: routes by URL pattern to the appropriate response. // Includes text() because handleResponse now reads the body as text first. -function stubFetches(healthResp: object, packsResp: object, logbookResp: object = makeLogbook(), scenariosResp: object[] = []) { +function stubFetches( + healthResp: object, + packsResp: object, + logbookResp: object = makeLogbook(), + scenariosResp: object[] = [], + sessionsResp: object = { sessions: [] }, +) { vi.stubGlobal( 'fetch', vi.fn((url: string) => { @@ -66,12 +72,22 @@ function stubFetches(healthResp: object, packsResp: object, logbookResp: object if (url.includes('/packs')) body = packsResp else if (url.includes('/logbook')) body = logbookResp else if (url.includes('/scenarios')) body = scenariosResp + else if (url.includes('/sessions')) body = sessionsResp const text = JSON.stringify(body) return Promise.resolve({ ok: true, json: () => Promise.resolve(body), text: () => Promise.resolve(text) }) }), ) } +const IN_PROGRESS_SESSION = { + session_id: 'sess-resume01', + scenario_id: 'behavioral_interview', + state: 'PlayerTurnListening', + created_at: '2026-10-01T10:00:00.000Z', + turn_count: 2, + setup: {}, +} + function renderHome() { return render( @@ -90,10 +106,10 @@ describe('Home — ready state', () => { expect(screen.getByRole('heading', { name: /conversation simulator/i })).toBeInTheDocument() }) - it('shows Local runtime: Ready list item when health is ok', async () => { + it('shows AI engine: Ready list item when health is ok', async () => { stubFetches(makeHealth(), makePacks(0)) renderHome() - expect(await screen.findByText(liText('Local runtime: Ready'))).toBeInTheDocument() + expect(await screen.findByText(liText('AI engine: Ready'))).toBeInTheDocument() }) it('shows the active model name when LLM is ready', async () => { @@ -109,6 +125,29 @@ describe('Home — ready state', () => { expect(await screen.findByText('3 installed')).toBeInTheDocument() }) + // Issue #501 §1: "there is no 'resume in-progress session' entry point + // anywhere on Home or in the nav." + it('leads the primary actions with Resume when a conversation is in progress', async () => { + stubFetches(makeHealth(), makePacks(1), makeLogbook(), [], { + sessions: [IN_PROGRESS_SESSION], + }) + renderHome() + const resume = await screen.findByTestId('home-resume-link') + expect(resume).toHaveAttribute('href', '/conversation/sess-resume01') + // Ahead of "Start a scenario": resuming beats starting over, which is what + // the playtester did instead. + const start = screen.getByRole('link', { name: /start a scenario/i }) + // Node.DOCUMENT_POSITION_FOLLOWING === 4 + expect(resume.compareDocumentPosition(start) & 4).toBeTruthy() + }) + + it('offers no Resume link when nothing is in progress', async () => { + stubFetches(makeHealth(), makePacks(1), makeLogbook(), [], { sessions: [] }) + renderHome() + await screen.findByText(liText('AI engine: Ready')) + expect(screen.queryByTestId('home-resume-link')).not.toBeInTheDocument() + }) + it('links to the library for Start a scenario', () => { renderHome() expect(screen.getByRole('link', { name: /start a scenario/i })).toHaveAttribute('href', '/library') @@ -146,7 +185,7 @@ describe('Home — no-model state', () => { it('shows LLM list item as Not installed', async () => { stubFetches(makeHealth(), makePacks(0)) renderHome() - expect(await screen.findByText(liText('LLM: Not installed'))).toBeInTheDocument() + expect(await screen.findByText(liText('AI model: Not installed'))).toBeInTheDocument() }) it('offers Install a GGUF model option', async () => { @@ -232,7 +271,7 @@ describe('Home — status card links', () => { it('has at least five links to /settings covering LLM, STT, TTS, install, and import', async () => { stubFetches(makeHealth(), makePacks(0)) renderHome() - await screen.findByText(liText('Local runtime: Ready')) + await screen.findByText(liText('AI engine: Ready')) const settingsLinks = screen .getAllByRole('link') .filter((el) => el.getAttribute('href') === '/settings') @@ -240,7 +279,7 @@ describe('Home — status card links', () => { expect(settingsLinks.length).toBeGreaterThanOrEqual(5) }) - it('Local runtime badge links to the recovery section when offline', async () => { + it('AI engine badge links to the recovery section when offline', async () => { vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new Error('Network error')))) renderHome() await screen.findByRole('alert') @@ -252,10 +291,10 @@ describe('Home — status card links', () => { }) describe('Home — runtime-error state', () => { - it('shows Local runtime: Unavailable list item when API is unreachable', async () => { + it('shows AI engine: Unavailable list item when API is unreachable', async () => { vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new Error('Network error')))) renderHome() - expect(await screen.findByText(liText('Local runtime: Unavailable'))).toBeInTheDocument() + expect(await screen.findByText(liText('AI engine: Unavailable'))).toBeInTheDocument() }) it('shows a recovery alert when runtime is down', async () => { @@ -277,7 +316,7 @@ describe('Home — runtime-error state', () => { renderHome() const alert = await screen.findByRole('alert') expect(alert).not.toHaveTextContent(/api server/i) - expect(alert).not.toHaveTextContent(/local runtime/i) + expect(alert).not.toHaveTextContent(/AI engine/i) }) it('does not show the no-model section when runtime is unreachable', async () => { @@ -298,7 +337,7 @@ describe('Home — runtime-error state', () => { it('does not show a last_error alert when last_error is null', async () => { stubFetches(makeHealth({ last_error: null }), makePacks(0)) renderHome() - await screen.findByText(liText('Local runtime: Ready')) + await screen.findByText(liText('AI engine: Ready')) expect(screen.queryByRole('alert')).toBeNull() }) @@ -435,7 +474,7 @@ describe('Home — missing-pack section', () => { it('hides missing-pack notice when no model is configured', async () => { stubFetches(makeHealth({ llm_ready: false }), makePacks(0)) renderHome() - await screen.findByText(liText('LLM: Not installed')) + await screen.findByText(liText('AI model: Not installed')) expect( screen.queryByRole('status', { name: /no scenario packs installed/i }), ).toBeNull() diff --git a/apps/web/src/__tests__/PerformanceWarning.test.tsx b/apps/web/src/__tests__/PerformanceWarning.test.tsx index 7c8373f4..8cc9a29b 100644 --- a/apps/web/src/__tests__/PerformanceWarning.test.tsx +++ b/apps/web/src/__tests__/PerformanceWarning.test.tsx @@ -86,15 +86,15 @@ describe('PerformanceWarningBanner', () => { expect(screen.getByRole('status', { name: /performance warnings/i })).toBeInTheDocument() }) - it('navigates to /settings when Runtime Settings button is clicked', () => { + it('navigates to /settings when the settings button is clicked', () => { renderBanner([ { code: 'use_smaller_model', title: 'NPC is slow', detail: 'Token took 4s.' }, ]) - fireEvent.click(screen.getByRole('button', { name: /open runtime settings/i })) + fireEvent.click(screen.getByRole('button', { name: /open settings/i })) expect(mockNavigate).toHaveBeenCalledWith('/settings') }) - it('shows the warning without the Runtime Settings button in the demo edition', () => { + it('shows the warning without the settings button in the demo edition', () => { // The demo hides the runtime section of Settings (issue #495); the button // would land the player on a page with nothing to adjust. vi.stubEnv('VITE_CONVSIM_EDITION', 'demo') @@ -103,7 +103,7 @@ describe('PerformanceWarningBanner', () => { { code: 'use_smaller_model', title: 'NPC is slow', detail: 'Token took 4s.' }, ]) expect(screen.getByText(/npc is slow/i)).toBeInTheDocument() - expect(screen.queryByRole('button', { name: /open runtime settings/i })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /open settings/i })).not.toBeInTheDocument() } finally { vi.unstubAllEnvs() } diff --git a/apps/web/src/__tests__/ReplySpeedPanel.test.tsx b/apps/web/src/__tests__/ReplySpeedPanel.test.tsx new file mode 100644 index 00000000..2596faee --- /dev/null +++ b/apps/web/src/__tests__/ReplySpeedPanel.test.tsx @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor, fireEvent } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { I18nProvider } from '../i18n' +import ReplySpeedPanel from '../components/ReplySpeedPanel' +import type { ReplySpeed, RuntimeSettingsResponse } from '@convsim/shared' + +vi.mock('../api/client', () => ({ + api: { + getRuntimeSettings: vi.fn(), + updateRuntimeSettings: vi.fn(), + }, +})) + +import { api } from '../api/client' +const mockApi = vi.mocked(api) + +function settings(replySpeed: ReplySpeed | null): RuntimeSettingsResponse { + const nulls = { + context_length: null, + gpu_layers: null, + threads: null, + temperature: null, + top_p: null, + repeat_penalty: null, + } + return { + settings: { ...nulls, reply_speed: replySpeed }, + recommended: { ...nulls, reply_speed: 'balanced' }, + requires_restart: false, + } +} + +function renderPanel() { + return render( + + + + + , + ) +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +/** + * Issue #501 §1: "I'm currently … trying to change my settings to attempt to + * speed the model up. I was a little unclear on how to do that in the Settings + * tab." Nothing in Settings was named after what they wanted. + */ +describe('ReplySpeedPanel', () => { + it('offers three named speeds, each with a short descriptor', async () => { + mockApi.getRuntimeSettings.mockResolvedValue({ ok: true, data: settings(null) }) + renderPanel() + await waitFor(() => expect(screen.getByTestId('reply-speed-options')).toBeInTheDocument()) + expect(screen.getByTestId('reply-speed-fast')).toHaveTextContent('Quick replies') + expect(screen.getByTestId('reply-speed-fast')).toHaveTextContent('Shortest answers') + expect(screen.getByTestId('reply-speed-balanced')).toHaveTextContent('Balanced') + expect(screen.getByTestId('reply-speed-detailed')).toHaveTextContent('Fuller replies') + }) + + it('shows balanced as the choice when none was ever made', async () => { + mockApi.getRuntimeSettings.mockResolvedValue({ ok: true, data: settings(null) }) + renderPanel() + await waitFor(() => + expect(screen.getByTestId('reply-speed-balanced')).toHaveAttribute('aria-checked', 'true'), + ) + expect(screen.getByTestId('reply-speed-fast')).toHaveAttribute('aria-checked', 'false') + }) + + it('reflects the persisted choice', async () => { + mockApi.getRuntimeSettings.mockResolvedValue({ ok: true, data: settings('detailed') }) + renderPanel() + await waitFor(() => + expect(screen.getByTestId('reply-speed-detailed')).toHaveAttribute('aria-checked', 'true'), + ) + }) + + it('persists a new choice and says it applies to the next message', async () => { + mockApi.getRuntimeSettings.mockResolvedValue({ ok: true, data: settings(null) }) + mockApi.updateRuntimeSettings.mockResolvedValue({ ok: true, data: settings('fast') }) + renderPanel() + await waitFor(() => expect(screen.getByTestId('reply-speed-fast')).toBeInTheDocument()) + + fireEvent.click(screen.getByTestId('reply-speed-fast')) + await waitFor(() => + expect(mockApi.updateRuntimeSettings).toHaveBeenCalledWith({ reply_speed: 'fast' }), + ) + await waitFor(() => + expect(screen.getByText(/next message uses the new speed/i)).toBeInTheDocument(), + ) + }) + + it('does not re-save the choice that is already active', async () => { + mockApi.getRuntimeSettings.mockResolvedValue({ ok: true, data: settings('fast') }) + renderPanel() + await waitFor(() => + expect(screen.getByTestId('reply-speed-fast')).toHaveAttribute('aria-checked', 'true'), + ) + fireEvent.click(screen.getByTestId('reply-speed-fast')) + expect(mockApi.updateRuntimeSettings).not.toHaveBeenCalled() + }) + + it('rolls the selection back when saving fails', async () => { + mockApi.getRuntimeSettings.mockResolvedValue({ ok: true, data: settings('balanced') }) + mockApi.updateRuntimeSettings.mockResolvedValue({ + ok: false, + error: { kind: 'network', message: 'nope' }, + }) + renderPanel() + await waitFor(() => expect(screen.getByTestId('reply-speed-fast')).toBeInTheDocument()) + + fireEvent.click(screen.getByTestId('reply-speed-fast')) + await waitFor(() => + expect(screen.getByTestId('reply-speed-balanced')).toHaveAttribute('aria-checked', 'true'), + ) + expect(screen.getByTestId('reply-speed-fast')).toHaveAttribute('aria-checked', 'false') + }) + + it('names the bigger lever honestly and links to it', async () => { + mockApi.getRuntimeSettings.mockResolvedValue({ ok: true, data: settings(null) }) + renderPanel() + await waitFor(() => expect(screen.getByTestId('reply-speed-options')).toBeInTheDocument()) + expect(screen.getByText(/a smaller model is the biggest change/i)).toBeInTheDocument() + expect(screen.getByRole('link', { name: /choose a model/i })).toHaveAttribute( + 'href', + '/model-manager', + ) + }) + + it('steps aside on a bundled runtime that predates the settings endpoint', async () => { + // RuntimeSettingsPanel already explains the 404 and points at the update; + // a second notice saying the same thing is noise. + mockApi.getRuntimeSettings.mockResolvedValue({ + ok: false, + error: { kind: 'http-error', status: 404, message: 'Not Found' }, + }) + const { container } = renderPanel() + await waitFor(() => expect(mockApi.getRuntimeSettings).toHaveBeenCalled()) + expect(container).toBeEmptyDOMElement() + }) + + it('surfaces a real load failure with a retry', async () => { + mockApi.getRuntimeSettings.mockResolvedValue({ + ok: false, + error: { kind: 'network', message: 'unreachable' }, + }) + renderPanel() + await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument()) + }) +}) diff --git a/apps/web/src/__tests__/ResumeSessionBanner.test.tsx b/apps/web/src/__tests__/ResumeSessionBanner.test.tsx new file mode 100644 index 00000000..fcf2ab8e --- /dev/null +++ b/apps/web/src/__tests__/ResumeSessionBanner.test.tsx @@ -0,0 +1,245 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor, fireEvent } from '@testing-library/react' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { I18nProvider } from '../i18n' +import ResumeSessionBanner from '../components/ResumeSessionBanner' +import type { ScenarioInfo } from '@convsim/shared' + +vi.mock('../api/client', () => ({ + api: { + listSessions: vi.fn(), + listScenarios: vi.fn(), + endSession: vi.fn(), + }, +})) + +import { api } from '../api/client' +const mockApi = vi.mocked(api) + +const SESSION = { + session_id: 'sess-abc123', + scenario_id: 'behavioral_interview', + state: 'PlayerTurnListening' as const, + created_at: '2026-10-01T10:00:00Z', + turn_count: 3, + ending_type: null, + ended_at: null, + setup: { + scenario_id: 'behavioral_interview', + difficulty: 'standard' as const, + player_role_name: 'Candidate', + language: 'en', + input_mode: 'text-only' as const, + tts_enabled: false, + show_state_meters: true, + save_transcript: true, + seed: null, + }, +} + +const SCENARIO = { + scenario_id: 'behavioral_interview', + title: 'The Behavioral Interview', +} as unknown as ScenarioInfo + +function renderAt(path: string) { + return render( + + + +
} /> + + + , + ) +} + +beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + mockApi.listScenarios.mockResolvedValue({ ok: true, data: [SCENARIO] }) + mockApi.endSession.mockResolvedValue({ + ok: true, + data: { session_id: SESSION.session_id, state: 'Ended', ending_type: 'player_exit' }, + }) +}) + +/** + * Issue #501 §1: "I couldn't easily figure out how to resume my ongoing + * scenario and resorted to starting a new one." The banner lives in the app + * chrome so it is on every screen the detour can reach — Settings included, + * which is where the playtester actually was. + */ +describe('ResumeSessionBanner', () => { + it('offers the in-progress conversation by scenario title and turn count', async () => { + mockApi.listSessions.mockResolvedValue({ ok: true, data: { sessions: [SESSION] } }) + renderAt('/settings') + await waitFor(() => + expect(screen.getByTestId('resume-session-banner')).toBeInTheDocument(), + ) + const banner = screen.getByTestId('resume-session-banner') + expect(banner).toHaveTextContent('The Behavioral Interview') + expect(banner).toHaveTextContent('3 turns') + }) + + it('says "1 turn" after a single exchange', async () => { + mockApi.listSessions.mockResolvedValue({ + ok: true, + data: { sessions: [{ ...SESSION, turn_count: 1 }] }, + }) + renderAt('/settings') + await waitFor(() => + expect(screen.getByTestId('resume-session-banner')).toBeInTheDocument(), + ) + const banner = screen.getByTestId('resume-session-banner') + expect(banner).toHaveTextContent('1 turn so far') + expect(banner).not.toHaveTextContent('1 turns') + }) + + it('resumes into the conversation it names', async () => { + mockApi.listSessions.mockResolvedValue({ ok: true, data: { sessions: [SESSION] } }) + render( + + + + } /> + } /> + + + , + ) + await waitFor(() => expect(screen.getByTestId('resume-session-button')).toBeInTheDocument()) + fireEvent.click(screen.getByTestId('resume-session-button')) + await waitFor(() => expect(screen.getByTestId('conversation')).toBeInTheDocument()) + }) + + it('asks only for the resumable set, and only for the newest one', async () => { + mockApi.listSessions.mockResolvedValue({ ok: true, data: { sessions: [SESSION] } }) + renderAt('/settings') + await waitFor(() => expect(mockApi.listSessions).toHaveBeenCalled()) + expect(mockApi.listSessions).toHaveBeenCalledWith('in_progress', 1) + }) + + it('renders nothing when there is no conversation to resume', async () => { + mockApi.listSessions.mockResolvedValue({ ok: true, data: { sessions: [] } }) + renderAt('/settings') + await waitFor(() => expect(mockApi.listSessions).toHaveBeenCalled()) + expect(screen.queryByTestId('resume-session-banner')).not.toBeInTheDocument() + }) + + it('stays quiet rather than stacking an error when the core is unreachable', async () => { + mockApi.listSessions.mockResolvedValue({ + ok: false, + error: { kind: 'network', message: 'unreachable' }, + }) + renderAt('/settings') + await waitFor(() => expect(mockApi.listSessions).toHaveBeenCalled()) + expect(screen.queryByTestId('resume-session-banner')).not.toBeInTheDocument() + }) + + it('tolerates a response shape this build does not expect', async () => { + mockApi.listSessions.mockResolvedValue({ ok: true, data: {} as never }) + renderAt('/settings') + await waitFor(() => expect(mockApi.listSessions).toHaveBeenCalled()) + expect(screen.queryByTestId('resume-session-banner')).not.toBeInTheDocument() + }) + + it('does not ask while the player is already in the conversation', async () => { + mockApi.listSessions.mockResolvedValue({ ok: true, data: { sessions: [SESSION] } }) + renderAt('/conversation/sess-abc123') + await waitFor(() => expect(screen.getByTestId('page')).toBeInTheDocument()) + expect(mockApi.listSessions).not.toHaveBeenCalled() + expect(screen.queryByTestId('resume-session-banner')).not.toBeInTheDocument() + }) + + it('does not ask while the player is reading a debrief', async () => { + mockApi.listSessions.mockResolvedValue({ ok: true, data: { sessions: [SESSION] } }) + renderAt('/debrief/sess-abc123') + await waitFor(() => expect(screen.getByTestId('page')).toBeInTheDocument()) + expect(mockApi.listSessions).not.toHaveBeenCalled() + }) + + it('ending the session clears the offer', async () => { + mockApi.listSessions + .mockResolvedValueOnce({ ok: true, data: { sessions: [SESSION] } }) + .mockResolvedValue({ ok: true, data: { sessions: [] } }) + renderAt('/settings') + await waitFor(() => + expect(screen.getByTestId('resume-session-banner')).toBeInTheDocument(), + ) + fireEvent.click(screen.getByTestId('resume-session-end-button')) + await waitFor(() => + expect(screen.queryByTestId('resume-session-banner')).not.toBeInTheDocument(), + ) + expect(mockApi.endSession).toHaveBeenCalledWith('sess-abc123') + }) + + it('ending the session shows the debrief it just produced', async () => { + // Ending a conversation always produces a debrief. Left on Settings, the + // player would have no way to the one they just earned. + mockApi.listSessions + .mockResolvedValueOnce({ ok: true, data: { sessions: [SESSION] } }) + .mockResolvedValue({ ok: true, data: { sessions: [] } }) + render( + + + + } /> + } /> + + + , + ) + await waitFor(() => + expect(screen.getByTestId('resume-session-end-button')).toBeInTheDocument(), + ) + fireEvent.click(screen.getByTestId('resume-session-end-button')) + await waitFor(() => expect(screen.getByTestId('debrief')).toBeInTheDocument()) + }) + + it('stays put when ending fails, so nothing claims a debrief exists', async () => { + mockApi.listSessions.mockResolvedValue({ ok: true, data: { sessions: [SESSION] } }) + mockApi.endSession.mockResolvedValue({ + ok: false, + error: { kind: 'network', message: 'unreachable' }, + }) + render( + + + + } /> + } /> + + + , + ) + await waitFor(() => + expect(screen.getByTestId('resume-session-end-button')).toBeInTheDocument(), + ) + fireEvent.click(screen.getByTestId('resume-session-end-button')) + await waitFor(() => expect(mockApi.endSession).toHaveBeenCalled()) + expect(screen.queryByTestId('debrief')).not.toBeInTheDocument() + }) + + it('re-asks when the window regains focus, so it cannot go stale', async () => { + // The session can be ended on another screen, or by another window. + mockApi.listSessions.mockResolvedValue({ ok: true, data: { sessions: [SESSION] } }) + renderAt('/settings') + await waitFor(() => expect(mockApi.listSessions).toHaveBeenCalledTimes(1)) + window.dispatchEvent(new Event('focus')) + await waitFor(() => expect(mockApi.listSessions).toHaveBeenCalledTimes(2)) + }) + + it('falls back to the scenario id when the title has not loaded', async () => { + mockApi.listSessions.mockResolvedValue({ ok: true, data: { sessions: [SESSION] } }) + mockApi.listScenarios.mockResolvedValue({ ok: true, data: [] }) + renderAt('/settings') + await waitFor(() => + expect(screen.getByTestId('resume-session-banner')).toBeInTheDocument(), + ) + expect(screen.getByTestId('resume-session-banner')).toHaveTextContent( + 'behavioral_interview', + ) + }) +}) diff --git a/apps/web/src/__tests__/RuntimeSettingsPanel.test.tsx b/apps/web/src/__tests__/RuntimeSettingsPanel.test.tsx index f97ae5db..ecdd7da4 100644 --- a/apps/web/src/__tests__/RuntimeSettingsPanel.test.tsx +++ b/apps/web/src/__tests__/RuntimeSettingsPanel.test.tsx @@ -48,6 +48,7 @@ function makeSettings(overrides: Partial = {}): Runtime temperature: null, top_p: null, repeat_penalty: null, + reply_speed: null, }, recommended: { context_length: null, @@ -56,6 +57,7 @@ function makeSettings(overrides: Partial = {}): Runtime temperature: null, top_p: null, repeat_penalty: null, + reply_speed: null, }, requires_restart: false, ...overrides, @@ -129,7 +131,7 @@ describe('RuntimeSettingsPanel — loading', () => { it('explains that an update is needed and links to the instructions', async () => { await renderPanel() const notice = await screen.findByRole('status', { - name: /runtime advanced settings unavailable/i, + name: /advanced engine settings unavailable/i, }) expect(notice).toHaveTextContent(/not available in this version of ConversationSimulator/i) expect(notice).toHaveTextContent(/update to the latest version/i) @@ -144,9 +146,9 @@ describe('RuntimeSettingsPanel — loading', () => { it('hides the advanced settings toggle, which the missing endpoint backs', async () => { await renderPanel() - await screen.findByRole('status', { name: /runtime advanced settings unavailable/i }) + await screen.findByRole('status', { name: /advanced engine settings unavailable/i }) expect( - screen.queryByRole('button', { name: /show runtime advanced settings/i }), + screen.queryByRole('button', { name: /show advanced engine settings/i }), ).not.toBeInTheDocument() }) }) @@ -178,7 +180,7 @@ describe('RuntimeSettingsPanel — loading', () => { await renderPanel() await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument()) expect( - screen.queryByRole('status', { name: /runtime advanced settings unavailable/i }), + screen.queryByRole('status', { name: /advanced engine settings unavailable/i }), ).not.toBeInTheDocument() }) }) @@ -359,13 +361,13 @@ describe('RuntimeSettingsPanel — advanced settings (hidden by default)', () => it('shows a "Show advanced runtime settings" button', async () => { await renderPanel() expect( - screen.getByRole('button', { name: /show runtime advanced settings/i }), + screen.getByRole('button', { name: /show advanced engine settings/i }), ).toBeInTheDocument() }) it('reveals advanced inputs after clicking show advanced', async () => { await renderPanel() - fireEvent.click(screen.getByRole('button', { name: /show runtime advanced settings/i })) + fireEvent.click(screen.getByRole('button', { name: /show advanced engine settings/i })) await waitFor(() => expect(screen.getByRole('spinbutton', { name: /context length/i })).toBeInTheDocument(), ) @@ -373,9 +375,9 @@ describe('RuntimeSettingsPanel — advanced settings (hidden by default)', () => it('collapses advanced section when hide advanced is clicked', async () => { await renderPanel() - fireEvent.click(screen.getByRole('button', { name: /show runtime advanced settings/i })) + fireEvent.click(screen.getByRole('button', { name: /show advanced engine settings/i })) await waitFor(() => screen.getByRole('spinbutton', { name: /context length/i })) - fireEvent.click(screen.getByRole('button', { name: /hide runtime advanced settings/i })) + fireEvent.click(screen.getByRole('button', { name: /hide advanced engine settings/i })) expect( screen.queryByRole('spinbutton', { name: /context length/i }), ).not.toBeInTheDocument() @@ -387,7 +389,7 @@ describe('RuntimeSettingsPanel — advanced settings (hidden by default)', () => describe('RuntimeSettingsPanel — advanced settings inputs', () => { async function openAdvanced() { await renderPanel() - fireEvent.click(screen.getByRole('button', { name: /show runtime advanced settings/i })) + fireEvent.click(screen.getByRole('button', { name: /show advanced engine settings/i })) await waitFor(() => screen.getByRole('spinbutton', { name: /context length/i })) } @@ -410,6 +412,7 @@ describe('RuntimeSettingsPanel — advanced settings inputs', () => { temperature: 0.7, top_p: 0.9, repeat_penalty: 1.1, + reply_speed: null, }, }) }) await openAdvanced() @@ -435,7 +438,7 @@ describe('RuntimeSettingsPanel — advanced settings inputs', () => { describe('RuntimeSettingsPanel — client-side validation', () => { async function openAdvanced() { await renderPanel() - fireEvent.click(screen.getByRole('button', { name: /show runtime advanced settings/i })) + fireEvent.click(screen.getByRole('button', { name: /show advanced engine settings/i })) await waitFor(() => screen.getByRole('spinbutton', { name: /context length/i })) } @@ -539,7 +542,7 @@ describe('RuntimeSettingsPanel — client-side validation', () => { describe('RuntimeSettingsPanel — restart required warning', () => { async function openAdvanced() { await renderPanel() - fireEvent.click(screen.getByRole('button', { name: /show runtime advanced settings/i })) + fireEvent.click(screen.getByRole('button', { name: /show advanced engine settings/i })) await waitFor(() => screen.getByRole('spinbutton', { name: /context length/i })) } @@ -581,7 +584,7 @@ describe('RuntimeSettingsPanel — restart required warning', () => { describe('RuntimeSettingsPanel — apply advanced and reset', () => { async function openAdvanced() { await renderPanel() - fireEvent.click(screen.getByRole('button', { name: /show runtime advanced settings/i })) + fireEvent.click(screen.getByRole('button', { name: /show advanced engine settings/i })) await waitFor(() => screen.getByRole('spinbutton', { name: /context length/i })) } @@ -602,7 +605,7 @@ describe('RuntimeSettingsPanel — apply advanced and reset', () => { it('clears form fields after reset', async () => { mockApi.getRuntimeSettings.mockResolvedValue({ ok: true, data: makeSettings({ - settings: { context_length: 4096, gpu_layers: null, threads: null, temperature: null, top_p: null, repeat_penalty: null }, + settings: { context_length: 4096, gpu_layers: null, threads: null, temperature: null, top_p: null, repeat_penalty: null, reply_speed: null }, }) }) await openAdvanced() await waitFor(() => expect(screen.getByRole('spinbutton', { name: /context length/i })).toHaveValue(4096)) diff --git a/apps/web/src/__tests__/Settings.test.tsx b/apps/web/src/__tests__/Settings.test.tsx index 9039eab3..3d431015 100644 --- a/apps/web/src/__tests__/Settings.test.tsx +++ b/apps/web/src/__tests__/Settings.test.tsx @@ -17,6 +17,8 @@ vi.mock('../api/client', () => ({ getFolders: vi.fn(), clearLocalData: vi.fn(), listSessions: vi.fn(), + // Session rows are labelled by scenario title now, not slug (issue #501 §2). + listScenarios: vi.fn(), deleteSession: vi.fn(), exportSession: vi.fn(), getModels: vi.fn(), @@ -76,6 +78,7 @@ const STUB_RUNTIME_SETTINGS = { temperature: null, top_p: null, repeat_penalty: null, + reply_speed: null, }, recommended: { context_length: null, @@ -84,6 +87,7 @@ const STUB_RUNTIME_SETTINGS = { temperature: null, top_p: null, repeat_penalty: null, + reply_speed: 'balanced' as const, }, requires_restart: false, } @@ -162,6 +166,7 @@ beforeEach(() => { mockApi.getDataFolder.mockResolvedValue({ ok: true, data: { path: '/home/user/.convsim/db' } }) mockApi.getFolders.mockResolvedValue({ ok: true, data: STUB_FOLDERS }) mockApi.listSessions.mockResolvedValue({ ok: true, data: { sessions: [] } }) + mockApi.listScenarios.mockResolvedValue({ ok: true, data: [] }) mockApi.listPacks.mockResolvedValue({ ok: true, data: { packs: [], total: 0 } }) mockApi.importPack.mockResolvedValue({ ok: true, data: { pack_id: 'pack-alpha', name: 'Alpha Scenarios', version: '1.0.0', dest: '/tmp/pack-alpha' } }) mockApi.validatePack.mockResolvedValue({ ok: true, data: { pack_id: 'pack-alpha', valid: true, errors: [] } }) @@ -646,7 +651,88 @@ describe('clear local data', () => { // Your sessions // --------------------------------------------------------------------------- +// ── Issue #501 §1/§2: the two sections a lost first-time player needed ─────── + +describe('reply speed and wording sections', () => { + it('names reply speed as its own section, anchored for a deep link', async () => { + await renderSettings() + await waitFor(() => + expect(screen.getByTestId('settings-reply-speed-section')).toBeInTheDocument(), + ) + // The conversation screen's slow-reply notice links to /settings#reply-speed. + expect(screen.getByTestId('settings-reply-speed-section')).toHaveAttribute( + 'id', + 'reply-speed', + ) + expect(screen.getByRole('heading', { name: /reply speed/i })).toBeInTheDocument() + }) + + it('offers the wording levels', async () => { + await renderSettings() + await waitFor(() => expect(screen.getByTestId('wording-options')).toBeInTheDocument()) + expect(screen.getByTestId('wording-plain')).toHaveAttribute('aria-checked', 'true') + }) + + it('calls the engine section what it is rather than "Runtime"', async () => { + await renderSettings() + await waitFor(() => + expect(screen.getByRole('heading', { name: /^ai engine$/i })).toBeInTheDocument(), + ) + expect(screen.queryByRole('heading', { name: /^runtime$/i })).not.toBeInTheDocument() + }) +}) + describe('your sessions', () => { + it('offers Resume for an unfinished session', async () => { + // Issue #501 §1: Settings is the screen the playtester got lost on, so + // every unfinished conversation is one click from here — not only the + // newest one the chrome banner offers. + mockApi.listSessions.mockResolvedValue({ + ok: true, + data: { sessions: [{ ...SESSION_A, state: 'PlayerTurnListening' as const }] }, + }) + await renderSettings() + await waitFor(() => + expect(screen.getByRole('button', { name: /resume session sess-aaa/i })).toBeInTheDocument(), + ) + }) + + it('does not offer Resume for a finished session', async () => { + mockApi.listSessions.mockResolvedValue({ ok: true, data: { sessions: [SESSION_A] } }) + await renderSettings() + await waitFor(() => + expect(screen.getByRole('button', { name: /export session sess-aaa/i })).toBeInTheDocument(), + ) + expect(screen.queryByRole('button', { name: /resume session/i })).not.toBeInTheDocument() + }) + + it('does not offer Resume for a session that never started', async () => { + // Nothing has been said yet, so there is no conversation to go back to. + mockApi.listSessions.mockResolvedValue({ ok: true, data: { sessions: [SESSION_B] } }) + await renderSettings() + await waitFor(() => + expect(screen.getByRole('button', { name: /export session sess-bbb/i })).toBeInTheDocument(), + ) + expect(screen.queryByRole('button', { name: /resume session/i })).not.toBeInTheDocument() + }) + + it('labels a session by its scenario title and plain state', async () => { + mockApi.listScenarios.mockResolvedValue({ + ok: true, + data: [{ scenario_id: 'behavioral_interview', title: 'The Behavioral Interview' } as never], + }) + mockApi.listSessions.mockResolvedValue({ + ok: true, + data: { sessions: [{ ...SESSION_A, state: 'PlayerTurnListening' as const }] }, + }) + await renderSettings() + await waitFor(() => + expect(screen.getByText('The Behavioral Interview')).toBeInTheDocument(), + ) + expect(screen.getByText('Your turn')).toBeInTheDocument() + expect(screen.queryByText('PlayerTurnListening')).not.toBeInTheDocument() + }) + it('shows "No sessions yet." when the list is empty', async () => { mockApi.listSessions.mockResolvedValue({ ok: true, data: { sessions: [] } }) await renderSettings() @@ -788,7 +874,7 @@ describe('advanced: raw audio saving', () => { it('advanced section appears after clicking show advanced', async () => { await renderSettings() - fireEvent.click(screen.getByRole('button', { name: /show advanced/i })) + fireEvent.click(screen.getByRole('button', { name: 'Show advanced' })) await waitFor(() => expect( screen.getByRole('checkbox', { name: /save raw audio recordings/i }), @@ -798,14 +884,14 @@ describe('advanced: raw audio saving', () => { it('raw audio saving is off by default', async () => { await renderSettings() - fireEvent.click(screen.getByRole('button', { name: /show advanced/i })) + fireEvent.click(screen.getByRole('button', { name: 'Show advanced' })) await waitFor(() => screen.getByRole('checkbox', { name: /save raw audio recordings/i })) expect(screen.getByRole('checkbox', { name: /save raw audio recordings/i })).not.toBeChecked() }) it('shows a warning when raw audio saving is enabled', async () => { await renderSettings() - fireEvent.click(screen.getByRole('button', { name: /show advanced/i })) + fireEvent.click(screen.getByRole('button', { name: 'Show advanced' })) await waitFor(() => screen.getByRole('checkbox', { name: /save raw audio recordings/i })) fireEvent.click(screen.getByRole('checkbox', { name: /save raw audio recordings/i })) await waitFor(() => @@ -815,9 +901,9 @@ describe('advanced: raw audio saving', () => { it('advanced section collapses when hide advanced is clicked', async () => { await renderSettings() - fireEvent.click(screen.getByRole('button', { name: /show advanced/i })) + fireEvent.click(screen.getByRole('button', { name: 'Show advanced' })) await waitFor(() => screen.getByRole('checkbox', { name: /save raw audio recordings/i })) - fireEvent.click(screen.getByRole('button', { name: /hide advanced/i })) + fireEvent.click(screen.getByRole('button', { name: 'Hide advanced' })) expect( screen.queryByRole('checkbox', { name: /save raw audio recordings/i }), ).not.toBeInTheDocument() @@ -842,7 +928,7 @@ describe('advanced: developer debug mode', () => { it('developer debug toggle appears after clicking Show advanced', async () => { await renderSettings() - fireEvent.click(screen.getByRole('button', { name: /show advanced/i })) + fireEvent.click(screen.getByRole('button', { name: 'Show advanced' })) await waitFor(() => expect( screen.getByRole('checkbox', { name: /developer debug mode/i }), @@ -852,14 +938,14 @@ describe('advanced: developer debug mode', () => { it('developer debug mode is off by default', async () => { await renderSettings() - fireEvent.click(screen.getByRole('button', { name: /show advanced/i })) + fireEvent.click(screen.getByRole('button', { name: 'Show advanced' })) await waitFor(() => screen.getByRole('checkbox', { name: /developer debug mode/i })) expect(screen.getByRole('checkbox', { name: /developer debug mode/i })).not.toBeChecked() }) it('shows a warning when developer debug mode is enabled', async () => { await renderSettings() - fireEvent.click(screen.getByRole('button', { name: /show advanced/i })) + fireEvent.click(screen.getByRole('button', { name: 'Show advanced' })) await waitFor(() => screen.getByRole('checkbox', { name: /developer debug mode/i })) fireEvent.click(screen.getByRole('checkbox', { name: /developer debug mode/i })) await waitFor(() => @@ -869,7 +955,7 @@ describe('advanced: developer debug mode', () => { it('writes devMode to localStorage when toggled on', async () => { await renderSettings() - fireEvent.click(screen.getByRole('button', { name: /show advanced/i })) + fireEvent.click(screen.getByRole('button', { name: 'Show advanced' })) await waitFor(() => screen.getByRole('checkbox', { name: /developer debug mode/i })) fireEvent.click(screen.getByRole('checkbox', { name: /developer debug mode/i })) expect(localStorage.getItem('convsim.devMode')).toBe('true') @@ -878,7 +964,7 @@ describe('advanced: developer debug mode', () => { it('initialises as checked when convsim.devMode is set in localStorage', async () => { localStorage.setItem('convsim.devMode', 'true') await renderSettings() - fireEvent.click(screen.getByRole('button', { name: /show advanced/i })) + fireEvent.click(screen.getByRole('button', { name: 'Show advanced' })) await waitFor(() => screen.getByRole('checkbox', { name: /developer debug mode/i })) expect(screen.getByRole('checkbox', { name: /developer debug mode/i })).toBeChecked() }) @@ -1004,14 +1090,17 @@ describe('demo edition', () => { it('hides model tiers, voice, Steam Cloud, packs, NPC memory, system health and advanced', async () => { await renderDemoSettings() - expect(screen.queryByRole('heading', { name: /^runtime$/i })).not.toBeInTheDocument() + expect(screen.queryByRole('heading', { name: /^ai engine$/i })).not.toBeInTheDocument() + // Reply speed is a model setting, so it follows the engine section out of + // the demo; wording is readability and stays (issue #501). + expect(screen.queryByTestId('settings-reply-speed-section')).not.toBeInTheDocument() expect(screen.queryByRole('link', { name: /open model manager/i })).not.toBeInTheDocument() expect(screen.queryByRole('heading', { name: /voice output/i })).not.toBeInTheDocument() expect(screen.queryByTestId('steam-cloud-section')).not.toBeInTheDocument() expect(screen.queryByRole('heading', { name: /pack management/i })).not.toBeInTheDocument() expect(screen.queryByTestId('relationship-memory-section')).not.toBeInTheDocument() expect(screen.queryByTestId('settings-system-health')).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: /show advanced/i })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Show advanced' })).not.toBeInTheDocument() }) it('does not call the full-app-only endpoints', async () => { @@ -1021,6 +1110,11 @@ describe('demo edition', () => { expect(mockApi.listVoices).not.toHaveBeenCalled() }) + it('keeps the wording control, which is readability rather than full-app depth', async () => { + await renderDemoSettings() + expect(screen.getByTestId('settings-wording-section')).toBeInTheDocument() + }) + it('says that clearing local data also clears the full version, which shares the folder', async () => { await renderDemoSettings() expect(screen.getByText(/shared with the full version of conversation simulator/i)).toBeInTheDocument() diff --git a/apps/web/src/__tests__/accessibility.test.tsx b/apps/web/src/__tests__/accessibility.test.tsx index 277d85c2..38a5d8a7 100644 --- a/apps/web/src/__tests__/accessibility.test.tsx +++ b/apps/web/src/__tests__/accessibility.test.tsx @@ -15,7 +15,7 @@ */ import React from 'react' import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, fireEvent } from '@testing-library/react' +import { render, fireEvent, screen } from '@testing-library/react' import { Link, MemoryRouter, Route, Routes } from 'react-router-dom' import axe from 'axe-core' @@ -509,9 +509,11 @@ describe('Accessibility: FirstRunWizard', () => { , ) - // Clicking "Get started" transitions from welcome → loading; getModels is a pending - // promise in this test suite so the wizard stays on the loading step. - fireEvent.click(container.querySelector('button')!) + // Clicking the primary CTA transitions from welcome → loading; getModels is a + // pending promise in this test suite so the wizard stays on the loading step. + // Targeted by name, not position: the welcome step opens with the + // LLM-familiarity question (issue #501 §2). + fireEvent.click(screen.getByRole('button', { name: /set me up/i })) const newH1 = container.querySelector('h1') expect(document.activeElement).toBe(newH1) }) @@ -525,7 +527,7 @@ describe('Accessibility: FirstRunWizard', () => { , ) - fireEvent.click(container.querySelector('button')!) + fireEvent.click(screen.getByRole('button', { name: /set me up/i })) const busyEl = container.querySelector('[aria-busy="true"]') expect(busyEl).not.toBeNull() }) @@ -592,7 +594,7 @@ describe('Accessibility: FirstRunWizard', () => { // Click "Set me up" — with an empty registry there is no starter model, // so the loading step falls back to the choose step. - fireEvent.click(container.querySelector('button')!) + fireEvent.click(screen.getByRole('button', { name: /set me up/i })) // Wait for the choose step to appear. await vi.waitFor(() => { diff --git a/apps/web/src/__tests__/edition.test.tsx b/apps/web/src/__tests__/edition.test.tsx index e59a40d3..3042a2fc 100644 --- a/apps/web/src/__tests__/edition.test.tsx +++ b/apps/web/src/__tests__/edition.test.tsx @@ -423,8 +423,8 @@ describe('Home — demo edition', () => { await screen.findAllByTestId('demo-conversation-card') expect(screen.queryByRole('heading', { name: /training plan/i })).not.toBeInTheDocument() expect(screen.queryByRole('heading', { name: /your training/i })).not.toBeInTheDocument() - expect(screen.queryByText(/^STT:/)).not.toBeInTheDocument() - expect(screen.queryByText(/^TTS:/)).not.toBeInTheDocument() + expect(screen.queryByText(/^Voice input:/)).not.toBeInTheDocument() + expect(screen.queryByText(/^Voice output:/)).not.toBeInTheDocument() expect(screen.queryByText(/^Packs:/)).not.toBeInTheDocument() }) @@ -454,7 +454,7 @@ describe('Home — demo edition', () => { it('keeps the engine status and help sections', async () => { stubFetches(DEMO_HEALTH, FIVE) renderHome() - expect(await screen.findByText((_, el) => el?.tagName === 'LI' && el.textContent?.trim() === 'Local runtime: Ready')).toBeInTheDocument() + expect(await screen.findByText((_, el) => el?.tagName === 'LI' && el.textContent?.trim() === 'AI engine: Ready')).toBeInTheDocument() expect(screen.getByRole('heading', { name: /^help$/i })).toBeInTheDocument() }) }) diff --git a/apps/web/src/__tests__/plainLanguage.test.ts b/apps/web/src/__tests__/plainLanguage.test.ts new file mode 100644 index 00000000..929a93da --- /dev/null +++ b/apps/web/src/__tests__/plainLanguage.test.ts @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, it, expect, beforeEach } from 'vitest' +import { + DEFAULT_UI_LANGUAGE_LEVEL, + FLOW_STATE_LABELS, + UI_LANGUAGE_KEYS, + hasSeenFamiliarityReask, + humanizeEndingType, + humanizeFlags, + humanizeIdentifier, + flowStateLabelKey, + levelForFamiliarity, + markFamiliarityReaskShown, + plainFlowState, + readLlmFamiliarity, + readUiLanguageLevel, + writeLlmFamiliarity, + writeUiLanguageLevel, +} from '../lib/plainLanguage' +import { en } from '../i18n/locales/en' +import { de } from '../i18n/locales/de' + +/** Issue #501 §2: the buffer between player-facing and technical language. */ +beforeEach(() => { + localStorage.clear() +}) + +describe('wording level', () => { + it('defaults to plain for a player who was never asked', () => { + // Plain is the safe direction: Settings is one click away, but a first + // session reading `PlayerTurnListening` is not recoverable. + expect(readUiLanguageLevel()).toBe('plain') + expect(DEFAULT_UI_LANGUAGE_LEVEL).toBe('plain') + }) + + it('round-trips a chosen level', () => { + writeUiLanguageLevel('technical') + expect(readUiLanguageLevel()).toBe('technical') + writeUiLanguageLevel('plain') + expect(readUiLanguageLevel()).toBe('plain') + }) + + it('reads an unrecognised stored value as plain', () => { + localStorage.setItem(UI_LANGUAGE_KEYS.level, 'expert-mode') + expect(readUiLanguageLevel()).toBe('plain') + }) + + it('notifies this document so a mounted screen re-reads it', () => { + // `storage` only fires in other documents, so flipping the switch in + // Settings would never reach a Conversation mounted behind it. + let fired = 0 + const listener = () => { fired += 1 } + window.addEventListener('convsim:ui-language-level', listener) + writeUiLanguageLevel('technical') + window.removeEventListener('convsim:ui-language-level', listener) + expect(fired).toBe(1) + }) +}) + +describe('familiarity answers', () => { + it('only the player who works with language models gets the identifiers', () => { + expect(levelForFamiliarity('new')).toBe('plain') + expect(levelForFamiliarity('some')).toBe('plain') + expect(levelForFamiliarity('expert')).toBe('technical') + }) + + it('an answer applies the level it implies', () => { + writeLlmFamiliarity('expert') + expect(readUiLanguageLevel()).toBe('technical') + writeLlmFamiliarity('new') + expect(readUiLanguageLevel()).toBe('plain') + }) + + it('remembers the answer so the re-ask can preselect it', () => { + expect(readLlmFamiliarity()).toBeNull() + writeLlmFamiliarity('some') + expect(readLlmFamiliarity()).toBe('some') + }) + + it('tracks whether the post-tutorial re-ask has been shown', () => { + expect(hasSeenFamiliarityReask()).toBe(false) + markFamiliarityReaskShown() + expect(hasSeenFamiliarityReask()).toBe(true) + }) +}) + +describe('humanizers', () => { + it('turns a snake_case identifier into a sentence-cased label', () => { + expect(humanizeIdentifier('objective_progress')).toBe('Objective progress') + expect(humanizeIdentifier('trust')).toBe('Trust') + expect(humanizeIdentifier('preparation-score')).toBe('Preparation score') + }) + + it('leaves an empty identifier alone rather than producing an empty label', () => { + expect(humanizeIdentifier('')).toBe('') + }) + + it('joins several flags into one readable phrase', () => { + expect(humanizeFlags(['honesty_demonstrated', 'player_demonstrates_knowledge'])).toBe( + 'Honesty demonstrated · Player demonstrates knowledge', + ) + }) + + it('says what the NPC is doing instead of naming the flow state', () => { + expect(plainFlowState('PlayerTurnListening')).toBe('Your turn') + expect(plainFlowState('NpcThinking')).toBe('Writing a reply') + expect(plainFlowState('ScenarioEvent')).toBe('Something is changing') + expect(plainFlowState('Ended')).toBe('Finished') + }) + + it('never prints an unmapped flow state raw at a player', () => { + expect(plainFlowState('SomeFutureState')).toBe('In progress') + expect(plainFlowState('SomeFutureState')).not.toContain('SomeFutureState') + }) + + it('reads an ending type as words', () => { + expect(humanizeEndingType('player_exit')).toBe('player exit') + expect(humanizeEndingType('safety_stop')).toBe('safety stop') + }) +}) + +describe('flow-state labels reach a translated screen', () => { + // Settings is an i18n-migrated screen, so the session list must render these + // through t() — printing the English table there would show a German player + // "Your turn" in an otherwise German UI. + it('maps a known state to its own locale key', () => { + expect(flowStateLabelKey('PlayerTurnListening')).toBe('sessionStates.PlayerTurnListening') + }) + + it('maps a state this build does not know to the unknown key', () => { + expect(flowStateLabelKey('SomeFutureState')).toBe('sessionStates.unknown') + // `unknown` is the fallback, never a state identifier a caller can ask for. + expect(flowStateLabelKey('unknown')).toBe('sessionStates.unknown') + }) + + it('every label in the English table has a key in every catalog', () => { + const states = Object.keys(FLOW_STATE_LABELS) + expect(states).toContain('unknown') + for (const catalog of [en, de]) { + const entries = (catalog as unknown as { sessionStates: Record }) + .sessionStates + expect(Object.keys(entries).sort()).toEqual(states.sort()) + for (const state of states) expect(entries[state]).toBeTruthy() + } + }) + + it('the English catalog says exactly what the English table says', () => { + expect((en as unknown as { sessionStates: Record }).sessionStates).toEqual( + FLOW_STATE_LABELS, + ) + }) +}) diff --git a/apps/web/src/__tests__/wholeTurns.test.ts b/apps/web/src/__tests__/wholeTurns.test.ts new file mode 100644 index 00000000..c361d38a --- /dev/null +++ b/apps/web/src/__tests__/wholeTurns.test.ts @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, it, expect } from 'vitest' +import { wholeTurnNumbersFor } from '../lib/wholeTurns' + +/** + * Issue #501 §4: one turn is the player's message plus the reply to it. The + * transcript used to number half-turns, so a debrief key moment at "turn 3" + * and the transcript's "Turn 3" pointed at different moments. + */ +describe('wholeTurnNumbersFor', () => { + it('returns nothing for an empty transcript', () => { + expect(wholeTurnNumbersFor([])).toEqual([]) + }) + + it('gives the opening turn 0', () => { + expect(wholeTurnNumbersFor([{ event_type: 'npc_opening' }])).toEqual([0]) + }) + + it('numbers a player message and the reply to it as one turn', () => { + expect( + wholeTurnNumbersFor([ + { event_type: 'npc_opening' }, + { event_type: 'player_turn' }, + { event_type: 'npc_turn' }, + ]), + ).toEqual([0, 1, 1]) + }) + + it('counts successive exchanges', () => { + expect( + wholeTurnNumbersFor([ + { event_type: 'npc_opening' }, + { event_type: 'player_turn' }, + { event_type: 'npc_turn' }, + { event_type: 'player_turn' }, + { event_type: 'npc_turn' }, + { event_type: 'player_turn' }, + { event_type: 'npc_turn' }, + ]), + ).toEqual([0, 1, 1, 2, 2, 3, 3]) + }) + + it('numbers a turn still awaiting its reply', () => { + expect( + wholeTurnNumbersFor([ + { event_type: 'npc_opening' }, + { event_type: 'player_turn' }, + { event_type: 'npc_turn' }, + { event_type: 'player_turn' }, + ]), + ).toEqual([0, 1, 1, 2]) + }) + + it('reads transcript rows as well as export events', () => { + // GET /sessions/{id}/transcript says role; the session export says + // event_type. Both have to label the same way. + expect( + wholeTurnNumbersFor([ + { role: 'npc_opening' }, + { role: 'player' }, + { role: 'npc' }, + { role: 'player' }, + { role: 'npc' }, + ]), + ).toEqual([0, 1, 1, 2, 2]) + }) + + it('does not merge two replies into one turn', () => { + // Two NPC rows in a row must stay distinguishable: they are separate + // moments, and a key moment pointing at either has to land on the right one. + expect( + wholeTurnNumbersFor([ + { event_type: 'player_turn' }, + { event_type: 'npc_turn' }, + { event_type: 'npc_turn' }, + ]), + ).toEqual([1, 1, 2]) + }) + + it('counts a transcript with no opening from turn 1', () => { + expect( + wholeTurnNumbersFor([{ event_type: 'player_turn' }, { event_type: 'npc_turn' }]), + ).toEqual([1, 1]) + }) +}) diff --git a/apps/web/src/__tests__/wording.test.tsx b/apps/web/src/__tests__/wording.test.tsx new file mode 100644 index 00000000..ef96f703 --- /dev/null +++ b/apps/web/src/__tests__/wording.test.tsx @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, it, expect, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { I18nProvider } from '../i18n' +import WordingPanel from '../components/WordingPanel' +import FamiliarityQuestion from '../components/FamiliarityQuestion' +import FamiliarityReaskCard from '../components/FamiliarityReaskCard' +import { UI_LANGUAGE_KEYS, readLlmFamiliarity, readUiLanguageLevel } from '../lib/plainLanguage' + +function renderIn(ui: React.ReactElement) { + return render({ui}) +} + +beforeEach(() => { + localStorage.clear() +}) + +/** + * Issue #501 §2: "customizable buffers between user and technical language, + * perhaps chosen by an initial LLM familiarity question. That question might + * be asked again after completing the tutorial." + */ +describe('WordingPanel', () => { + it('shows plain wording as the active level by default', () => { + renderIn() + expect(screen.getByTestId('wording-plain')).toHaveAttribute('aria-checked', 'true') + expect(screen.getByTestId('wording-technical')).toHaveAttribute('aria-checked', 'false') + }) + + it('switching to technical persists and takes effect', () => { + renderIn() + fireEvent.click(screen.getByTestId('wording-technical')) + expect(readUiLanguageLevel()).toBe('technical') + expect(screen.getByTestId('wording-technical')).toHaveAttribute('aria-checked', 'true') + }) + + it('reflects a level chosen elsewhere', () => { + localStorage.setItem(UI_LANGUAGE_KEYS.level, 'technical') + renderIn() + expect(screen.getByTestId('wording-technical')).toHaveAttribute('aria-checked', 'true') + }) +}) + +describe('FamiliarityQuestion', () => { + it('asks about the player, not about the setting', () => { + // Nobody arriving for the first time knows whether they want "technical + // wording"; everyone knows whether they work with language models. + renderIn() + expect( + screen.getByText('How familiar are you with AI language models?'), + ).toBeInTheDocument() + expect(screen.getByTestId('familiarity-new')).toHaveTextContent('New to this') + expect(screen.getByTestId('familiarity-expert')).toHaveTextContent('I work with them') + }) + + it('"new to this" keeps plain wording', () => { + renderIn() + fireEvent.click(screen.getByTestId('familiarity-new')) + expect(readLlmFamiliarity()).toBe('new') + expect(readUiLanguageLevel()).toBe('plain') + }) + + it('"I work with them" turns the identifiers on', () => { + renderIn() + fireEvent.click(screen.getByTestId('familiarity-expert')) + expect(readUiLanguageLevel()).toBe('technical') + }) + + it('leaves plain wording in place when nobody answers', () => { + renderIn() + expect(readLlmFamiliarity()).toBeNull() + expect(readUiLanguageLevel()).toBe('plain') + }) + + it('shows the answer as selected the moment it is clicked', () => { + // On the first-run Welcome step nothing else re-renders when an answer is + // written, so a storage-only read left every option unselected and the + // player's answer looked ignored. + renderIn() + fireEvent.click(screen.getByTestId('familiarity-new')) + expect(screen.getByTestId('familiarity-new')).toHaveAttribute('aria-checked', 'true') + expect(screen.getByTestId('familiarity-expert')).toHaveAttribute('aria-checked', 'false') + }) + + it('preselects a previous answer', () => { + renderIn() + fireEvent.click(screen.getByTestId('familiarity-some')) + // Re-render as the post-tutorial re-ask would. + renderIn() + const options = screen.getAllByTestId('familiarity-some') + expect(options[options.length - 1]).toHaveAttribute('aria-checked', 'true') + }) +}) + +describe('FamiliarityReaskCard', () => { + it('appears on the tutorial debrief', () => { + renderIn() + expect(screen.getByTestId('familiarity-reask')).toBeInTheDocument() + }) + + it('does not appear on any other debrief', () => { + const { container } = renderIn() + expect(container).toBeEmptyDOMElement() + }) + + it('does not appear when the scenario is unknown', () => { + const { container } = renderIn() + expect(container).toBeEmptyDOMElement() + }) + + it('appears once — ignoring it counts as an answer', () => { + // The debrief's action buttons navigate away, so most players will never + // click this. Re-nagging after every later session is the failure mode. + const first = renderIn() + expect(screen.getByTestId('familiarity-reask')).toBeInTheDocument() + first.unmount() + + const second = renderIn() + expect(second.container).toBeEmptyDOMElement() + }) + + it('applies an answer and confirms it in place', () => { + renderIn() + fireEvent.click(screen.getByTestId('familiarity-expert')) + expect(readUiLanguageLevel()).toBe('technical') + expect(screen.getByText(/technical detail is on/i)).toBeInTheDocument() + }) + + it('can be dismissed without answering', () => { + renderIn() + fireEvent.click(screen.getByTestId('familiarity-reask-dismiss')) + expect(screen.queryByTestId('familiarity-reask')).not.toBeInTheDocument() + expect(readUiLanguageLevel()).toBe('plain') + }) +}) diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 8733d3af..24c88b30 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -18,6 +18,8 @@ import type { PackValidationResult, SessionCreateRequest, SessionCreateResponse, + SessionListResponse, + SessionListStatus, SessionStartResponse, TurnResponse, SessionEndResponse, @@ -468,8 +470,24 @@ export const api = { validatePack(packId: string): Promise> { return post(`/packs/${packId}/validate`) }, - listSessions(): Promise> { - return get<{ sessions: SessionCreateResponse[] }>('/sessions') + /** Sessions in this profile, newest first. + * `status: 'in_progress'` is the resumable set — started and not ended — + * which is what the resume entry points offer (issue #501). */ + listSessions( + status: SessionListStatus = 'all', + limit?: number, + ): Promise> { + const params = new URLSearchParams() + if (status !== 'all') params.set('status', status) + if (limit != null) params.set('limit', String(limit)) + const query = params.toString() + return get(`/sessions${query ? `?${query}` : ''}`) + }, + /** One session's row, including the setup it was created with. Resuming reads + * the setup back from here when the launch-time route state is gone — after + * navigating away, a reload, or a relaunch (issue #501). */ + getSession(sessionId: string): Promise> { + return get(`/sessions/${sessionId}`) }, getLogbookProfile(): Promise> { return get('/logbook/profile') diff --git a/apps/web/src/api/errors.ts b/apps/web/src/api/errors.ts index 33b2e45a..66aca10b 100644 --- a/apps/web/src/api/errors.ts +++ b/apps/web/src/api/errors.ts @@ -23,7 +23,7 @@ interface ErrorCopy { export const ERROR_COPY: Record = { 'runtime-unreachable': { - title: 'Local runtime is unavailable', + title: 'The AI engine is unavailable', description: 'The local service is not responding. Make sure the application started correctly, then try again.', action: 'Try again', diff --git a/apps/web/src/components/FamiliarityQuestion.tsx b/apps/web/src/components/FamiliarityQuestion.tsx new file mode 100644 index 00000000..806f26c9 --- /dev/null +++ b/apps/web/src/components/FamiliarityQuestion.tsx @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: Apache-2.0 +import { useState } from 'react' +import { useTranslation } from '../i18n' +import { + readLlmFamiliarity, + writeLlmFamiliarity, + type LlmFamiliarity, +} from '../lib/plainLanguage' + +const ANSWERS: LlmFamiliarity[] = ['new', 'some', 'expert'] + +/** + * "How familiar are you with AI language models?" (issue #501 §2). + * + * The note asked for the wording buffer to be "chosen by an initial LLM + * familiarity question", so this is a question about the player rather than a + * setting about the app: nobody arriving for the first time knows whether they + * want "technical wording", but everyone knows whether they work with language + * models. + * + * Deliberately not a wizard step and never blocking. Answering is one click, + * skipping it is free, and the unanswered default is plain wording — the safe + * direction, since Settings is one click away and a first session reading + * `PlayerTurnListening` is not recoverable. + */ +export default function FamiliarityQuestion({ + heading, + onAnswered, + compact = false, +}: { + /** Which phrasing to use — first run, or the post-tutorial re-ask. */ + heading: string + onAnswered?: (answer: LlmFamiliarity) => void + compact?: boolean +}) { + const { t } = useTranslation() + // Held in state, not read fresh from storage each render: on the first-run + // Welcome step nothing else re-renders when an answer is written, so a + // storage-only read left every option unselected after the click — the + // player tells the app how familiar they are and the app appears to ignore + // them. + const [current, setCurrent] = useState(readLlmFamiliarity) + + return ( +
+

+ {heading} +

+
+ {ANSWERS.map((answer) => { + const selected = answer === current + return ( + + ) + })} +
+
+ ) +} diff --git a/apps/web/src/components/FamiliarityReaskCard.tsx b/apps/web/src/components/FamiliarityReaskCard.tsx new file mode 100644 index 00000000..6c415d45 --- /dev/null +++ b/apps/web/src/components/FamiliarityReaskCard.tsx @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: Apache-2.0 +import { useEffect, useState } from 'react' +import { useTranslation } from '../i18n' +import FamiliarityQuestion from './FamiliarityQuestion' +import { + hasSeenFamiliarityReask, + levelForFamiliarity, + markFamiliarityReaskShown, +} from '../lib/plainLanguage' + +/** The scenario whose debrief triggers the re-ask. */ +export const TUTORIAL_SCENARIO_ID = 'first_words_tutorial' + +/** + * The post-tutorial re-ask (issue #501 §2). + * + * "That question might be asked again after completing the tutorial" — because + * a player who answered "new to this" before seeing anything now knows what + * the app actually shows them, and may want more or less of it. + * + * Automatic but not blocking, and shown once: a card on the tutorial debrief, + * among the other things that debrief offers, rather than a modal in front of + * it. It marks itself seen when it renders, so ignoring it — which is what + * most players will do, since the debrief's buttons navigate away — counts as + * an answer of "leave it alone" instead of re-nagging after the next session. + */ +export default function FamiliarityReaskCard({ scenarioId }: { scenarioId?: string | null }) { + const { t } = useTranslation() + const [visible, setVisible] = useState( + () => scenarioId === TUTORIAL_SCENARIO_ID && !hasSeenFamiliarityReask(), + ) + const [applied, setApplied] = useState<'plain' | 'technical' | null>(null) + + useEffect(() => { + if (visible) markFamiliarityReaskShown() + }, [visible]) + + if (!visible) return null + + return ( +
+ setApplied(levelForFamiliarity(answer))} + /> +
+

+ {applied === 'plain' + ? t('familiarity.appliedPlain') + : applied === 'technical' + ? t('familiarity.appliedTechnical') + : t('familiarity.reaskHint')} +

+ +
+
+ ) +} diff --git a/apps/web/src/components/OfflineIndicator.tsx b/apps/web/src/components/OfflineIndicator.tsx index 14d64841..355817ca 100644 --- a/apps/web/src/components/OfflineIndicator.tsx +++ b/apps/web/src/components/OfflineIndicator.tsx @@ -2,10 +2,13 @@ import { StatusBadge } from '@convsim/ui' import { useApiHealth } from '../api/useApiHealth' +// "AI engine", not "runtime": this badge is in the app header on every screen, +// so it is the single most-seen label in the app, and 'runtime' is one of the +// four words the issue #501 playtest named as unexplained jargon. const LABELS = { - loading: 'Local runtime: Checking…', - healthy: 'Local runtime: Ready', - unavailable: 'Local runtime: Unavailable', + loading: 'AI engine: Checking…', + healthy: 'AI engine: Ready', + unavailable: 'AI engine: Unavailable', } as const export default function OfflineIndicator() { diff --git a/apps/web/src/components/PerformanceWarning.tsx b/apps/web/src/components/PerformanceWarning.tsx index bd19133d..e44d0c7c 100644 --- a/apps/web/src/components/PerformanceWarning.tsx +++ b/apps/web/src/components/PerformanceWarning.tsx @@ -47,9 +47,13 @@ export default function PerformanceWarningBanner({ warnings }: PerformanceWarnin {w.title}: {w.detail} {!isDemo && ( + // "Runtime" is one of the four words the issue #501 playtest named as + // unexplained jargon, and this button renders on the conversation + // screen — right above the slow-reply notice that now offers "Make + // replies faster →". Plain wording, and the same destination. )}
diff --git a/apps/web/src/components/ReplySpeedPanel.tsx b/apps/web/src/components/ReplySpeedPanel.tsx new file mode 100644 index 00000000..8d8441b8 --- /dev/null +++ b/apps/web/src/components/ReplySpeedPanel.tsx @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: Apache-2.0 +import { useCallback, useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import { api } from '../api/client' +import { REPLY_SPEEDS } from '@convsim/shared' +import type { ReplySpeed } from '@convsim/shared' +import type { ApiError } from '../api/errors' +import { ApiErrorView } from './ApiErrorView' +import { useTranslation } from '../i18n' + +/** + * "Make the model respond faster", as a thing the app actually offers + * (issue #501 §1). + * + * The playtester went to Settings mid-tutorial to speed the model up and was + * "a little unclear on how to do that": the only knobs were context length, + * GPU layers, CPU threads and sampling, all behind a "Show runtime advanced + * settings" toggle and all written in runtime terms. None of them is named + * after what they wanted. + * + * Three named options, each with a two- or three-word descriptor — the pattern + * the note asked for ("3 Flash, 3 Pro" paired with "all-around thinking, + * advanced reasoning"). What they do lives in convsim-core's reply_speed + * setting; this only has to name them honestly and say what the trade is. + */ +export default function ReplySpeedPanel() { + const { t } = useTranslation() + const [speed, setSpeed] = useState('balanced') + const [loadError, setLoadError] = useState(null) + const [unavailable, setUnavailable] = useState(false) + const [saving, setSaving] = useState(false) + const [saved, setSaved] = useState(false) + const [saveError, setSaveError] = useState(null) + + const load = useCallback(async () => { + setLoadError(null) + const r = await api.getRuntimeSettings() + if (!r.ok) { + // A bundled runtime that predates /api/runtime/settings answers 404. + // RuntimeSettingsPanel already explains that and points at the update, so + // this control just steps aside rather than stacking a second notice. + if (r.error.kind === 'http-error' && r.error.status === 404) { + setUnavailable(true) + return + } + setLoadError(r.error) + return + } + setSpeed(r.data.settings.reply_speed ?? 'balanced') + }, []) + + useEffect(() => { + void load() + }, [load]) + + async function choose(next: ReplySpeed) { + if (next === speed) return + const previous = speed + // Optimistic: the point of this control is that it feels immediate. + setSpeed(next) + setSaving(true) + setSaved(false) + setSaveError(null) + const r = await api.updateRuntimeSettings({ reply_speed: next }) + setSaving(false) + if (!r.ok) { + setSpeed(previous) + setSaveError(r.error) + return + } + setSpeed(r.data.settings.reply_speed ?? next) + setSaved(true) + } + + if (unavailable) return null + if (loadError) { + return void load()} context="ReplySpeedPanel" /> + } + + return ( +
+
+ {REPLY_SPEEDS.map((option) => { + const selected = option === speed + return ( + + ) + })} +
+ +

+ {saving + ? t('settings.replySpeed.saving') + : saved + ? t('settings.replySpeed.saved') + : t('settings.replySpeed.noRestart')} +

+ + {saveError && ( +
+ +
+ )} + + {/* Honest about the ceiling: reply length is the lever this control has, + and on slow hardware a smaller model is a bigger one. */} +

+ {t('settings.replySpeed.biggerWin')}{' '} + + {t('settings.replySpeed.biggerWinLink')} + +

+
+ ) +} diff --git a/apps/web/src/components/ResumeSessionBanner.tsx b/apps/web/src/components/ResumeSessionBanner.tsx new file mode 100644 index 00000000..8582b503 --- /dev/null +++ b/apps/web/src/components/ResumeSessionBanner.tsx @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: Apache-2.0 +import { useNavigate, useLocation } from 'react-router-dom' +import { api } from '../api/client' +import { useTranslation } from '../i18n' +import { useResumableSession } from '../hooks/useResumableSession' +import { useScenarios } from '../api/useScenarios' + +/** + * "You have a conversation in progress — Resume" (issue #501 §1). + * + * Lives in the app chrome rather than on Home, because the screen the player + * got lost on was Settings: they went there to speed the model up and then + * could not find the way back. A strip under the header is on every screen the + * detour can reach, so there is always one click back into the conversation. + * + * Hidden on the conversation itself (you are already there), on the debrief for + * that session (you have left it on purpose), and during first-run setup. + */ +export default function ResumeSessionBanner() { + const { t } = useTranslation() + const navigate = useNavigate() + const location = useLocation() + const scenarios = useScenarios() + + // Nothing to resume *to* while the player is mid-conversation or reading the + // debrief, so do not spend a request asking. + const onConversationRoute = + location.pathname.startsWith('/conversation/') || + location.pathname.startsWith('/debrief/') + const { result, refetch } = useResumableSession(!onConversationRoute) + + if (result.state !== 'ready') return null + + const session = result.session + const title = + scenarios.scenarios.find((s) => s.scenario_id === session.scenario_id)?.title ?? + session.scenario_id + const turns = session.turn_count ?? 0 + + function handleResume() { + navigate(`/conversation/${session.session_id}`) + } + + async function handleEnd() { + const r = await api.endSession(session.session_id) + // Either way the banner has to re-ask: on success the session is no longer + // resumable, and on failure the row may have been ended by another window. + refetch() + // Ending a conversation produces a debrief, here exactly as it does from + // the conversation screen's End session button. Without this the player + // who ends from the strip is left where they were and the debrief they + // just earned is reachable only by typing its URL — the same kind of + // dead end issue #501 is about, in the other direction. + if (r.ok) navigate(`/debrief/${session.session_id}`) + } + + return ( +
+ + {t('resume.label')}{' '} + {turns === 0 + ? t('resume.withoutTurns', { title }) + : turns === 1 + ? t('resume.withTurns_one', { title }) + : t('resume.withTurns_other', { title, count: turns })} + + + +
+ ) +} diff --git a/apps/web/src/components/RuntimeSettingsPanel.tsx b/apps/web/src/components/RuntimeSettingsPanel.tsx index ffddb41d..07921149 100644 --- a/apps/web/src/components/RuntimeSettingsPanel.tsx +++ b/apps/web/src/components/RuntimeSettingsPanel.tsx @@ -38,7 +38,16 @@ function parseInt2(s: string): number | null { return isNaN(n) ? null : n } -function settingsToForm(s: RuntimeSettings): Record { +/** The numeric knobs this panel edits. + * + * `reply_speed` is part of the same persisted settings object but is not one + * of these: it is a named plain-language choice with its own control above + * this panel (issue #501 §1), not a number behind an "advanced" toggle. */ +type AdvancedSettingKey = Exclude + +type AdvancedForm = Record + +function settingsToForm(s: RuntimeSettings): AdvancedForm { return { context_length: s.context_length !== null ? String(s.context_length) : '', gpu_layers: s.gpu_layers !== null ? String(s.gpu_layers) : '', @@ -49,7 +58,7 @@ function settingsToForm(s: RuntimeSettings): Record): FieldError[] { +function validateForm(form: AdvancedForm): FieldError[] { const errs: FieldError[] = [] const cl = parseInt2(form.context_length) @@ -85,7 +94,7 @@ function validateForm(form: Record): FieldError[] return errs } -function formToRequest(form: Record): Partial { +function formToRequest(form: AdvancedForm): Partial { return { context_length: parseInt2(form.context_length), gpu_layers: parseInt2(form.gpu_layers), @@ -206,7 +215,7 @@ export default function RuntimeSettingsPanel() { const [basicApplySuccess, setBasicApplySuccess] = useState(false) const [showAdvanced, setShowAdvanced] = useState(false) - const [form, setForm] = useState>({ + const [form, setForm] = useState({ context_length: '', gpu_layers: '', threads: '', @@ -271,7 +280,7 @@ export default function RuntimeSettingsPanel() { loadData() } - function handleFieldChange(field: keyof RuntimeSettings, value: string) { + function handleFieldChange(field: AdvancedSettingKey, value: string) { setForm((prev) => ({ ...prev, [field]: value })) setFieldErrors((prev) => { const next = { ...prev } @@ -480,7 +489,7 @@ export default function RuntimeSettingsPanel() { {settingsUnavailable && (
- Advanced runtime settings are not available in this version of ConversationSimulator. + Advanced engine settings are not available in this version of ConversationSimulator. Update to the latest version to change context length, GPU layers, CPU threads and sampling. The provider and model settings above still work.{' '} )} - {/* Advanced toggle */} + {/* Advanced toggle. Named after the section above it ("AI engine") rather + than after the runtime: "runtime" is one of the four words the + issue #501 playtest named as unexplained jargon, and this label is + visible on Settings without expanding anything. The notes inside the + disclosure stay in engine terms — that is the "deliberately peek into + the settings" the issue asks for. */} {!settingsUnavailable && ( )} diff --git a/apps/web/src/components/StateMeters.tsx b/apps/web/src/components/StateMeters.tsx new file mode 100644 index 00000000..ed11abde --- /dev/null +++ b/apps/web/src/components/StateMeters.tsx @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: Apache-2.0 +import { humanizeIdentifier } from '../lib/plainLanguage' + +/** + * The conversation meters (issue #501 §3). + * + * What was wrong, from the playtest: + * + * - The panel was collapsed behind a summary reading "NPC state variables", + * below the transcript, while the tutorial's opening line says "Notice the + * two meters at the top". The label, the name and the placement all + * disagreed, so "generally confused about meters and where they appear". + * - Each bar showed a number and nothing else. The tutorial says "Engagement + * just ticked up" and "See how Engagement just ticked up?", but nothing on + * screen ever ticked: the number was simply different than the last time + * you looked at it. The change is the whole mechanic, so it is now drawn. + * - The NPC's emotion was rendered right next to the bars, so it read as one + * of them — "unclear which meter (if any) the emotions in parentheses are". + * Emotion now lives in the NPC panel under an explicit "Mood" label and + * never inside this component. + * + * Caller places this ABOVE the transcript so "the meters at the top" is true. + */ +export default function StateMeters({ + stateVars, + deltas, + isPlain, +}: { + stateVars: Record + /** Change applied by the most recent turn, per variable. */ + deltas: Record + /** Plain wording hides the raw snake_case variable keys. */ + isPlain: boolean +}) { + const entries = Object.entries(stateVars) + if (entries.length === 0) return null + + return ( +
+
+

+ Conversation meters +

+

+ These move with every turn. They are not moods. +

+
+
+ {entries.map(([key, value]) => { + const delta = deltas[key] ?? 0 + const label = isPlain ? humanizeIdentifier(key) : key + return ( +
+
+ {label} + {value} +
+
0 ? 'up' : 'down'} ${Math.abs(delta)} this turn` + } + style={{ + width: '100%', + height: 5, + borderRadius: 2, + background: '#27272a', + marginTop: 5, + }} + > + + {/* The tick the tutorial talks about. aria-hidden because the + meter's aria-valuetext above already says it, and announcing + both would read every change twice. */} + +
+ ) + })} +
+
+ ) +} diff --git a/apps/web/src/components/WordingPanel.tsx b/apps/web/src/components/WordingPanel.tsx new file mode 100644 index 00000000..89c06a17 --- /dev/null +++ b/apps/web/src/components/WordingPanel.tsx @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: Apache-2.0 +import { useTranslation } from '../i18n' +import { useUiLanguageLevel } from '../hooks/useUiLanguageLevel' +import type { UiLanguageLevel } from '../lib/plainLanguage' + +const LEVELS: UiLanguageLevel[] = ['plain', 'technical'] + +/** + * The "deliberately peek into the settings" half of issue #501 §2. + * + * The familiarity question sets this on first run and offers to revisit it + * after the tutorial; this is where it lives permanently, so a player who + * answered "new to this" and then wanted the session id for a bug report is + * one switch away from it. + */ +export default function WordingPanel() { + const { t } = useTranslation() + const { level, setLevel } = useUiLanguageLevel() + + return ( +
+ {LEVELS.map((option) => { + const selected = option === level + return ( + + ) + })} +
+ ) +} diff --git a/apps/web/src/hooks/useResumableSession.ts b/apps/web/src/hooks/useResumableSession.ts new file mode 100644 index 00000000..a42d1e03 --- /dev/null +++ b/apps/web/src/hooks/useResumableSession.ts @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: Apache-2.0 +import { useCallback, useEffect, useState } from 'react' +import { api } from '../api/client' +import type { SessionCreateResponse } from '@convsim/shared' + +/** + * The conversation the player walked away from, if there is one (issue #501 §1). + * + * A player partway through the tutorial opened Settings to speed the model up, + * then "couldn't easily figure out how to resume my ongoing scenario and + * resorted to starting a new one" — because nothing in the app knew the session + * existed. convsim-core has known all along: the row sits in `turn_sessions` + * with a non-terminal `flow_state`. This asks for it. + * + * Deliberately the *newest* in-progress session rather than a list: more than + * one is possible (every abandoned session stays in-progress until it is ended), + * but "take me back to what I was just doing" has exactly one answer. The + * others remain reachable from Settings → Your sessions. + */ +export type ResumableSessionState = + | { state: 'loading' } + | { state: 'none' } + | { state: 'ready'; session: SessionCreateResponse } + +export function useResumableSession(enabled = true): { + result: ResumableSessionState + refetch: () => void +} { + const [result, setResult] = useState( + enabled ? { state: 'loading' } : { state: 'none' }, + ) + + const refetch = useCallback(() => { + if (!enabled) { + setResult({ state: 'none' }) + return + } + void api.listSessions('in_progress', 1).then((r) => { + if (!r.ok) { + // An unreachable core is reported by the status panel and the startup + // guard; a resume affordance that cannot be loaded is simply absent + // rather than another error card on top of those. + setResult({ state: 'none' }) + return + } + // Tolerate a core that answers a shape this build does not expect (an + // older bundled runtime, a proxy): no resumable session is the safe read. + const session = r.data?.sessions?.[0] + setResult(session ? { state: 'ready', session } : { state: 'none' }) + }) + }, [enabled]) + + useEffect(() => { + refetch() + }, [refetch]) + + // Ending a conversation, or starting one, happens on another screen. Refresh + // when the window regains focus so the banner never offers to resume a + // session that has since ended (mirrors the setup-status revalidation in + // App.tsx, added for the same reason in issue #380). + useEffect(() => { + if (!enabled) return + function onFocus() { + refetch() + } + window.addEventListener('focus', onFocus) + document.addEventListener('visibilitychange', onFocus) + return () => { + window.removeEventListener('focus', onFocus) + document.removeEventListener('visibilitychange', onFocus) + } + }, [enabled, refetch]) + + return { result, refetch } +} diff --git a/apps/web/src/hooks/useUiLanguageLevel.ts b/apps/web/src/hooks/useUiLanguageLevel.ts new file mode 100644 index 00000000..df821541 --- /dev/null +++ b/apps/web/src/hooks/useUiLanguageLevel.ts @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: Apache-2.0 +import { useCallback, useEffect, useState } from 'react' +import { + UI_LANGUAGE_CHANGE_EVENT, + UI_LANGUAGE_KEYS, + readUiLanguageLevel, + writeUiLanguageLevel, + type UiLanguageLevel, +} from '../lib/plainLanguage' + +/** + * The player's plain-vs-technical language level, live (issue #501 §2). + * + * Subscribes to both the in-document change event (a switch flipped in + * Settings while a Conversation is mounted behind it) and `storage` (the same + * profile open in a second window), so no screen is left showing the old + * vocabulary until it happens to re-mount. + */ +export function useUiLanguageLevel(): { + level: UiLanguageLevel + isPlain: boolean + setLevel: (level: UiLanguageLevel) => void +} { + const [level, setLevelState] = useState(readUiLanguageLevel) + + useEffect(() => { + function sync() { + setLevelState(readUiLanguageLevel()) + } + function onStorage(e: StorageEvent) { + if (e.key === null || e.key === UI_LANGUAGE_KEYS.level) sync() + } + window.addEventListener(UI_LANGUAGE_CHANGE_EVENT, sync) + window.addEventListener('storage', onStorage) + return () => { + window.removeEventListener(UI_LANGUAGE_CHANGE_EVENT, sync) + window.removeEventListener('storage', onStorage) + } + }, []) + + const setLevel = useCallback((next: UiLanguageLevel) => { + writeUiLanguageLevel(next) + setLevelState(next) + }, []) + + return { level, isPlain: level === 'plain', setLevel } +} diff --git a/apps/web/src/i18n/locales/de.ts b/apps/web/src/i18n/locales/de.ts index 5bd2e632..02f14b7d 100644 --- a/apps/web/src/i18n/locales/de.ts +++ b/apps/web/src/i18n/locales/de.ts @@ -13,7 +13,7 @@ export const de: LocaleMessages = { modelNotLoaded: 'Kein KI-Modell geladen. Öffnen Sie die Einstellungen, um ein Modell zu installieren.', runtimeUnavailable: - 'Die lokale Laufzeitumgebung ist nicht verfügbar. Prüfen Sie die Protokolle.', + 'Die KI-Engine ist nicht verfügbar. Prüfen Sie die Protokolle.', safetyViolation: 'Der Inhalt wurde durch die Sicherheitsrichtlinie blockiert.', turnLimitExceeded: 'Das Zuglimit für dieses Szenario wurde erreicht.', turnTimeout: 'Der Zug hat das Zeitlimit überschritten. Bitte versuchen Sie es erneut.', @@ -101,11 +101,59 @@ export const de: LocaleMessages = { }, }, }, + // "Gespräch läuft" (Issue #501 §1). + resume: { + label: 'Gespräch läuft.', + withTurns_one: '{{title}} — bisher 1 Zug.', + withTurns_other: '{{title}} — bisher {{count}} Züge.', + withoutTurns: '{{title}} — noch nicht begonnen.', + action: 'Fortsetzen', + dismiss: 'Beenden', + }, + familiarity: { + question: 'Wie gut kennen Sie KI-Sprachmodelle?', + reaskQuestion: 'Jetzt, da Sie ein Gespräch gespielt haben — welche Wortwahl passt?', + reaskHint: 'Sie können das jederzeit in den Einstellungen ändern.', + skip: 'Überspringen', + keep: 'So lassen', + new: { + label: 'Neu für mich', + detail: 'Einfache Wortwahl', + }, + some: { + label: 'Ich habe KI-Chat-Apps genutzt', + detail: 'Einfache Wortwahl', + }, + expert: { + label: 'Ich arbeite damit', + detail: 'Technische Details anzeigen', + }, + appliedPlain: 'Einfache Wortwahl ist aktiv.', + appliedTechnical: 'Technische Details sind aktiv.', + }, + sessionStates: { + NotStarted: 'Noch nicht begonnen', + LoadingModel: 'Wird vorbereitet', + LoadingScenario: 'Wird vorbereitet', + Briefing: 'Einweisung', + NpcOpening: 'Eröffnet das Gespräch', + PlayerTurnListening: 'Sie sind dran', + PlayerTurnReview: 'Nachricht prüfen', + NpcThinking: 'Schreibt eine Antwort', + NpcSpeaking: 'Spricht', + ScenarioEvent: 'Etwas verändert sich', + DebriefGenerating: 'Nachbesprechung wird erstellt', + DebriefReady: 'Nachbesprechung bereit', + Ended: 'Beendet', + Error: 'Etwas ist schiefgelaufen', + unknown: 'Läuft', + }, home: { title: 'Gesprächssimulator', tagline: 'Üben Sie Vorstellungsgespräche, Verhandlungen, Sprachen und schwierige Gespräche.', primaryActions: 'Hauptaktionen', + resumeScenario: 'Gespräch fortsetzen', yourTraining: 'Ihr Training', training: { loading: 'Wird geladen…', @@ -137,10 +185,10 @@ export const de: LocaleMessages = { readDocs: 'Dokumentation lesen', status: { heading: 'Status', - localRuntime: 'Lokale Laufzeitumgebung', - llm: 'LLM', - stt: 'STT', - tts: 'TTS', + localRuntime: 'KI-Engine', + llm: 'KI-Modell', + stt: 'Spracheingabe', + tts: 'Sprachausgabe', networkRequired: 'Netzwerk zum Spielen erforderlich', packs: 'Pakete', checking: 'Überprüfe…', @@ -236,10 +284,47 @@ export const de: LocaleMessages = { notSavedWarning: 'Nicht gespeichert – das Transkript geht verloren, wenn diese Sitzung endet.', }, + replySpeed: { + heading: 'Antwortgeschwindigkeit', + description: + 'Wie viel die andere Person pro Zug schreibt. Kürzere Antworten kommen schneller, weil Ihr Computer sie Wort für Wort erzeugt.', + label: 'Antwortgeschwindigkeit', + saving: 'Wird gespeichert…', + saved: 'Gespeichert. Ihre nächste Nachricht nutzt die neue Geschwindigkeit.', + noRestart: 'Gilt ab Ihrer nächsten Nachricht — kein Neustart nötig.', + biggerWin: + 'Immer noch langsam? Ein kleineres Modell bringt die größte Verbesserung.', + biggerWinLink: 'Modell auswählen →', + fast: { + label: 'Kurze Antworten', + detail: 'Kürzeste Antworten, kürzeste Wartezeit', + }, + balanced: { + label: 'Ausgewogen', + detail: 'Das Tempo des Szenarios', + }, + detailed: { + label: 'Ausführliche Antworten', + detail: 'Längere Antworten, längere Wartezeit', + }, + }, + wording: { + heading: 'Wortwahl', + description: + 'Wie viel Technik die App zeigt. Einfache Wortwahl verbirgt Sitzungs-IDs, interne Zustandsnamen und Ereignis-Flags; technische Wortwahl zeigt sie.', + plain: { + label: 'Einfache Wortwahl', + detail: 'Nur Alltagssprache', + }, + technical: { + label: 'Technische Wortwahl', + detail: 'IDs, Zustände und Flags anzeigen', + }, + }, runtime: { - heading: 'Laufzeitumgebung', + heading: 'KI-Modul', description: - 'Wählen Sie den aktiven KI-Anbieter und das Modell. Erweiterte Einstellungen sind standardmäßig ausgeblendet.', + 'Welches KI-Modell als die andere Person antwortet. Technische Einstellungen sind standardmäßig ausgeblendet.', openModelManagerLink: 'Modellverwaltung öffnen →', openModelManagerLabel: 'Modellverwaltung öffnen', }, @@ -317,12 +402,15 @@ export const de: LocaleMessages = { }, sessions: { heading: 'Ihre Sitzungen', - description: 'Sitzung als JSON exportieren oder dauerhaft löschen.', + description: + 'Ein unvollendetes Gespräch fortsetzen, eine Sitzung als JSON exportieren oder dauerhaft löschen.', loadError: 'Sitzungen konnten nicht geladen werden.', deleteError: 'Sitzung konnte nicht gelöscht werden.', exportError: 'Sitzung konnte nicht exportiert werden.', loading: 'Lade…', noSessions: 'Noch keine Sitzungen.', + resume: 'Fortsetzen', + resumeLabel: 'Sitzung {{id}} fortsetzen', export: 'Exportieren', exportLabel: 'Sitzung {{id}} exportieren', delete: 'Löschen', @@ -473,6 +561,8 @@ export const de: LocaleMessages = { transcriptOnlyNotice: 'Nachbesprechungserstellung fehlgeschlagen. Zeige nur Transkript.', turn: 'Zug {{number}}', + opening: 'Eröffnung', + moodLabel: 'Stimmung:', you: 'Sie', npc: 'NPC', goToTurn: 'Zu Zug {{number}} gehen', diff --git a/apps/web/src/i18n/locales/en.ts b/apps/web/src/i18n/locales/en.ts index cedad1bb..64821af0 100644 --- a/apps/web/src/i18n/locales/en.ts +++ b/apps/web/src/i18n/locales/en.ts @@ -8,7 +8,7 @@ export const en = { scenarioNotFound: 'The requested scenario was not found.', sessionNotFound: 'Session not found.', modelNotLoaded: 'No AI model is loaded. Open Settings to install a model.', - runtimeUnavailable: 'The local runtime is unavailable. Check the logs for details.', + runtimeUnavailable: 'The AI engine is unavailable. Check the logs for details.', safetyViolation: 'The content was blocked by the safety policy.', turnLimitExceeded: 'The turn limit for this scenario has been reached.', turnTimeout: 'The turn timed out. Please try again.', @@ -96,10 +96,65 @@ export const en = { }, }, }, + // "You have a conversation in progress" (issue #501 §1). + resume: { + label: 'Conversation in progress.', + withTurns_one: '{{title}} — 1 turn so far.', + withTurns_other: '{{title}} — {{count}} turns so far.', + withoutTurns: '{{title}} — not started yet.', + action: 'Resume', + dismiss: 'End it', + }, + // The initial LLM-familiarity question and its post-tutorial re-ask + // (issue #501 §2). Three answers, two wording levels. + familiarity: { + question: 'How familiar are you with AI language models?', + reaskQuestion: 'Now that you have played a conversation — how should we word things?', + reaskHint: 'You can change this any time in Settings.', + skip: 'Skip', + keep: 'Keep it as it is', + new: { + label: 'New to this', + detail: 'Plain wording', + }, + some: { + label: 'I have used AI chat apps', + detail: 'Plain wording', + }, + expert: { + label: 'I work with them', + detail: 'Show technical detail', + }, + appliedPlain: 'Plain wording it is.', + appliedTechnical: 'Technical detail is on.', + }, + // What a session is doing, in plain language rather than the flow-state + // identifier (issue #501 §2). Keyed by the identifier itself; `unknown` + // covers a state this build does not know about. Mirrors FLOW_STATE_LABELS + // in lib/plainLanguage.ts, which is the English copy the screens that are + // not on t() yet use. + sessionStates: { + NotStarted: 'Not started yet', + LoadingModel: 'Getting ready', + LoadingScenario: 'Getting ready', + Briefing: 'Briefing', + NpcOpening: 'Opening the conversation', + PlayerTurnListening: 'Your turn', + PlayerTurnReview: 'Check your message', + NpcThinking: 'Writing a reply', + NpcSpeaking: 'Speaking', + ScenarioEvent: 'Something is changing', + DebriefGenerating: 'Preparing your debrief', + DebriefReady: 'Debrief ready', + Ended: 'Finished', + Error: 'Something went wrong', + unknown: 'In progress', + }, home: { title: 'Conversation Simulator', tagline: 'Practice interviews, negotiations, language, and difficult conversations.', primaryActions: 'Primary actions', + resumeScenario: 'Resume your conversation', yourTraining: 'Your training', training: { loading: 'Loading…', @@ -131,10 +186,15 @@ export const en = { readDocs: 'Read docs', status: { heading: 'Status', - localRuntime: 'Local runtime', - llm: 'LLM', - stt: 'STT', - tts: 'TTS', + // Named in the player's words, not the engine's (issue #501 §2): + // 'runtime' is one of the four words the playtest called out, and + // 'LLM' / 'STT' / 'TTS' are three acronyms a first-time player has no + // way to read. These are the first labels anyone sees — they are on + // Home — so the plain names are the only names, at both wording levels. + localRuntime: 'AI engine', + llm: 'AI model', + stt: 'Voice input', + tts: 'Voice output', networkRequired: 'Network required to play', packs: 'Packs', checking: 'Checking…', @@ -227,10 +287,50 @@ export const en = { "Transcripts will not be saved. This session's conversation cannot be exported or searched after it ends.", notSavedWarning: 'Not saved — transcript will be lost when this session ends.', }, + // Plain-language reply pacing (issue #501 §1): "make the model respond + // faster" was not an exposed concept anywhere in Settings. + replySpeed: { + heading: 'Reply speed', + description: + 'How much the other person writes each turn. Shorter replies arrive sooner, because your computer writes them one word at a time.', + label: 'Reply speed', + saving: 'Saving…', + saved: 'Saved. Your next message uses the new speed.', + noRestart: 'Takes effect on your next message — nothing to restart.', + biggerWin: + 'Still slow? A smaller model is the biggest change you can make.', + biggerWinLink: 'Choose a model →', + fast: { + label: 'Quick replies', + detail: 'Shortest answers, least waiting', + }, + balanced: { + label: 'Balanced', + detail: 'The scenario’s own pacing', + }, + detailed: { + label: 'Fuller replies', + detail: 'Longer answers, more waiting', + }, + }, + // The buffer between player-facing and technical language (issue #501 §2). + wording: { + heading: 'Wording', + description: + 'How much of the machinery the app shows you. Plain wording hides session ids, internal state names and event flags; technical wording puts them back.', + plain: { + label: 'Plain wording', + detail: 'Everyday language only', + }, + technical: { + label: 'Technical wording', + detail: 'Show ids, states and flags', + }, + }, runtime: { - heading: 'Runtime', + heading: 'AI engine', description: - 'Select the active AI provider and model. Advanced knobs are hidden by default.', + 'Which AI model answers as the other person. Technical knobs are hidden by default.', openModelManagerLink: 'Open model manager →', openModelManagerLabel: 'Open model manager', }, @@ -308,12 +408,15 @@ export const en = { }, sessions: { heading: 'Your sessions', - description: 'Export a session as JSON or delete it permanently.', + description: + 'Pick up an unfinished conversation, export a session as JSON, or delete one permanently.', loadError: 'Could not load sessions.', deleteError: 'Failed to delete session.', exportError: 'Failed to export session.', loading: 'Loading…', noSessions: 'No sessions yet.', + resume: 'Resume', + resumeLabel: 'Resume session {{id}}', export: 'Export', exportLabel: 'Export session {{id}}', delete: 'Delete', @@ -463,6 +566,8 @@ export const en = { ariaLabel: 'Conversation transcript', transcriptOnlyNotice: 'Debrief generation failed. Showing transcript only.', turn: 'Turn {{number}}', + opening: 'Opening', + moodLabel: 'Mood:', you: 'You', npc: 'NPC', goToTurn: 'Go to turn {{number}}', diff --git a/apps/web/src/layout/AppLayout.tsx b/apps/web/src/layout/AppLayout.tsx index fc7259c6..60401c39 100644 --- a/apps/web/src/layout/AppLayout.tsx +++ b/apps/web/src/layout/AppLayout.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react' import { NavLink, Outlet, useLocation } from 'react-router-dom' import OfflineIndicator from '../components/OfflineIndicator' +import ResumeSessionBanner from '../components/ResumeSessionBanner' import { useTranslation } from '../i18n' import { useGamepadNavigation } from '../hooks/useGamepadNavigation' import { useSteamKeyboard } from '../hooks/useSteamKeyboard' @@ -181,6 +182,10 @@ export default function AppLayout() { + {/* One click back into a conversation the player navigated away from + (issue #501). Renders nothing when there is none. */} + +
= { + NotStarted: 'Not started yet', + LoadingModel: 'Getting ready', + LoadingScenario: 'Getting ready', + Briefing: 'Briefing', + NpcOpening: 'Opening the conversation', + PlayerTurnListening: 'Your turn', + PlayerTurnReview: 'Check your message', + NpcThinking: 'Writing a reply', + NpcSpeaking: 'Speaking', + ScenarioEvent: 'Something is changing', + DebriefGenerating: 'Preparing your debrief', + DebriefReady: 'Debrief ready', + Ended: 'Finished', + Error: 'Something went wrong', + unknown: 'In progress', +} + +const UNKNOWN_FLOW_STATE = 'unknown' + +/** + * What the NPC is doing, in words rather than flow-state identifiers. + * + * The header used to print the raw identifier (`State: PlayerTurnListening`). + * At `technical` the caller still shows the identifier alongside this. + * + * English only — for the screens that have not been migrated to `t()` yet. + * A migrated screen must use `flowStateLabelKey` instead, or a German player + * reads "Your turn" in an otherwise German UI. + */ +export function plainFlowState(state: string): string { + return ( + (state === UNKNOWN_FLOW_STATE ? undefined : FLOW_STATE_LABELS[state]) ?? + FLOW_STATE_LABELS[UNKNOWN_FLOW_STATE] + ) +} + +/** The locale key for a flow state's plain-language label. */ +export function flowStateLabelKey(state: string): string { + const known = state !== UNKNOWN_FLOW_STATE && state in FLOW_STATE_LABELS + return `sessionStates.${known ? state : UNKNOWN_FLOW_STATE}` +} + +/** "player_exit" → "player exit"; used in the "Outcome: …" lines. */ +export function humanizeEndingType(endingType: string): string { + return endingType.replace(/_/g, ' ') +} diff --git a/apps/web/src/lib/wholeTurns.ts b/apps/web/src/lib/wholeTurns.ts new file mode 100644 index 00000000..e28fc598 --- /dev/null +++ b/apps/web/src/lib/wholeTurns.ts @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Whole-turn numbering for a transcript (issue #501 §4). + * + * "In the conversational LLM space, the term 'turn' would indicate one message + * from the user and one reply from the model. Currently, each response from the + * user *and* the model is counted as a turn, when they should constitute halves + * of one whole turn." + * + * One turn is the player's message plus the NPC's reply to it; the NPC's + * opening line belongs to neither and is turn 0, labelled rather than numbered. + * + * This is also the number convsim-core already counts: `turn_count` increments + * once per exchange, `duration.max_turns` is measured in it, and the debrief's + * key moments point at it. Only the transcript labels disagreed, so "Turn 3" in + * a debrief's key moments and "Turn 3" in the transcript below it were + * different moments. + */ + +type TurnRole = 'npc_opening' | 'player' | 'npc' + +/** Infer the role of one transcript row from an event type or a role field. */ +function roleOf(entry: { event_type?: string; role?: string }): TurnRole { + const raw = entry.event_type ?? entry.role ?? '' + if (raw === 'npc_opening') return 'npc_opening' + if (raw === 'player_turn' || raw === 'player') return 'player' + return 'npc' +} + +/** + * Whole-turn number per row, aligned with the input array. + * + * Derived from the sequence rather than from a stored index so it works for + * both shapes the UI has to label: `npc_opening` / `player_turn` / `npc_turn` + * event rows from the session export, and `npc_opening` / `player` / `npc` + * transcript rows. A player message opens a turn; an NPC reply closes the one + * that is open, or — if none is (a transcript that somehow starts with a reply) + * — opens and closes its own. + */ +export function wholeTurnNumbersFor( + rows: ReadonlyArray<{ event_type?: string; role?: string }>, +): number[] { + let current = 0 + let openTurnClosed = true + return rows.map((row) => { + const role = roleOf(row) + if (role === 'npc_opening') return 0 + if (role === 'player') { + current += 1 + openTurnClosed = false + return current + } + if (openTurnClosed) current += 1 + openTurnClosed = true + return current + }) +} diff --git a/apps/web/src/pages/ScenarioSetup.test.tsx b/apps/web/src/pages/ScenarioSetup.test.tsx index e172717e..9625f5c0 100644 --- a/apps/web/src/pages/ScenarioSetup.test.tsx +++ b/apps/web/src/pages/ScenarioSetup.test.tsx @@ -439,7 +439,7 @@ describe('ScenarioSetupPage', () => { input_mode: 'push-to-talk', tts_enabled: true, tts_voice_id: 'af_heart', - show_state_meters: false, + show_state_meters: true, save_transcript: true, seed: null, }, @@ -461,7 +461,8 @@ describe('ScenarioSetupPage', () => { input_mode: 'push-to-talk', tts_enabled: true, tts_voice_id: 'af_heart', - show_state_meters: false, + // On by default wherever the scenario permits them (issue #501 §3). + show_state_meters: true, save_transcript: true, seed: null, }); @@ -620,15 +621,33 @@ describe('ScenarioSetupPage', () => { ); }); - it('shows state meters toggle when scenario permits it', async () => { + it('names the meters toggle the way the panel it switches on is named', async () => { + // Issue #501 §3 is a label/naming mismatch; "Show NPC state meters" + // against a panel headed "Conversation meters" is the same mismatch, and + // this is the label the player reads first. renderSetup(); await waitFor(() => screen.getByText('Behavioral Interview')); expect( - screen.getByRole('checkbox', { name: /show npc state meters/i }), + screen.getByRole('checkbox', { name: /show the conversation meters/i }), ).toBeInTheDocument(); + expect( + screen.queryByRole('checkbox', { name: /state meters/i }), + ).not.toBeInTheDocument(); + }); + + it('starts the meters toggle on when the scenario permits them', async () => { + // Issue #501 §3. The First Words tutorial opens with "Above this + // conversation you'll see two meters" and spends three of its six turns + // on them; with the toggle defaulting off, a player on default settings + // played that whole tour against a screen with no meters on it. + renderSetup(); + await waitFor(() => screen.getByText('Behavioral Interview')); + expect( + screen.getByRole('checkbox', { name: /show the conversation meters/i }), + ).toBeChecked(); }); - it('hides state meters toggle and shows note when scenario does not permit it', async () => { + it('hides the meters toggle and says so when the scenario does not permit them', async () => { mockApi.getScenario.mockResolvedValue({ ok: true as const, data: { ...mockScenario, state_meters_permitted: false, @@ -636,10 +655,10 @@ describe('ScenarioSetupPage', () => { renderSetup(); await waitFor(() => screen.getByText('Behavioral Interview')); expect( - screen.queryByRole('checkbox', { name: /show npc state meters/i }), + screen.queryByRole('checkbox', { name: /conversation meters/i }), ).not.toBeInTheDocument(); expect( - screen.getByText(/state meters are hidden in this scenario/i), + screen.getByText(/the conversation meters are hidden in this scenario/i), ).toBeInTheDocument(); }); }); diff --git a/apps/web/src/pages/ScenarioSetup.tsx b/apps/web/src/pages/ScenarioSetup.tsx index 58dfc758..237bc2ef 100644 --- a/apps/web/src/pages/ScenarioSetup.tsx +++ b/apps/web/src/pages/ScenarioSetup.tsx @@ -207,7 +207,17 @@ export function ScenarioSetupPage({ scenarioId, onSessionCreated, onBack, onInst ? prev.voice_id : defaultVoiceId, input_mode: rt.stt_ready ? 'push-to-talk' : 'text-only', - show_state_meters: scenarioData.state_meters_permitted ? prev.show_state_meters : false, + // On by default wherever the scenario permits them (issue #501 §3). + // Every other field here is derived from the scenario; this one sat + // at a hardcoded `false`, so a player on default settings started the + // First Words tutorial — whose opening line is "Above this + // conversation you'll see two meters" and three of whose six turns + // are about them — with no meters on screen at all. "Generally + // confused about meters and where they appear" is the report; a + // tutorial narrating a panel that was switched off is the sharpest + // version of it. A scenario that withholds them for realism still + // forces false, and the toggle is one click away either way. + show_state_meters: Boolean(scenarioData.state_meters_permitted), } }) })(); @@ -650,14 +660,24 @@ export function ScenarioSetupPage({ scenarioId, onSessionCreated, onBack, onInst checked={form.show_state_meters} onChange={(e) => setField('show_state_meters', e.target.checked)} /> + {/* Named to match the panel it switches on — the + conversation screen's "Conversation meters", above the + transcript. It used to read "Show NPC state meters", + so a player ticking it met a differently-named panel + one screen later: the same label/naming mismatch + issue #501 §3 reported, handed over in the other + direction, and the first of the two they read. */} - Show NPC state meters during conversation + Show the conversation meters + + {' '}— how engaged and open the other person is, updated every turn + )} {!scenario.state_meters_permitted && (

- State meters are hidden in this scenario to preserve realism. + The conversation meters are hidden in this scenario, to keep it realistic.

)}
diff --git a/apps/web/src/screens/Conversation.tsx b/apps/web/src/screens/Conversation.tsx index fa787bff..ea350dfb 100644 --- a/apps/web/src/screens/Conversation.tsx +++ b/apps/web/src/screens/Conversation.tsx @@ -1,8 +1,14 @@ // SPDX-License-Identifier: Apache-2.0 import { useEffect, useRef, useState } from 'react' -import { useParams, useNavigate, useLocation } from 'react-router-dom' +import { useParams, useNavigate, useLocation, Link } from 'react-router-dom' import { api } from '../api/client' -import type { InputMode, ScenarioInfo, TurnResponse, WsEvent } from '@convsim/shared' +import type { + InputMode, + ScenarioInfo, + SessionCreateRequest, + TurnResponse, + WsEvent, +} from '@convsim/shared' import VoiceInput, { type SttReviewMeta } from '../components/VoiceInput' import DebugDrawer, { type DebugTurnEntry } from '../components/DebugDrawer' import PerformanceWarningBanner from '../components/PerformanceWarning' @@ -21,6 +27,9 @@ import type { ApiError } from '../api/errors' import type { ApiResult } from '../api/client' import { ApiErrorView } from '../components/ApiErrorView' import { useIsDemo } from '../edition' +import { useUiLanguageLevel } from '../hooks/useUiLanguageLevel' +import { humanizeEndingType, humanizeFlags, plainFlowState } from '../lib/plainLanguage' +import StateMeters from '../components/StateMeters' // How long a wait is allowed to look normal before the UI says something. const SLOW_RESPONSE_MS = 5_000 @@ -69,12 +78,37 @@ type TurnEntry = { content: string emotion?: string eventFlags?: string[] + /** + * Whole-turn number (issue #501 §4): one turn is the player's message plus + * the NPC's reply, so both rows of an exchange carry the same number. The + * opening line is turn 0 — it belongs to no exchange and is labelled + * "Opening" rather than "Turn 1". + * + * This is the same number convsim-core counts in `turn_count`, that + * `duration.max_turns` is measured in, and that debrief key moments point + * at. The transcript used to number half-turns, so "Turn 3" in a debrief and + * "Turn 3" in the transcript were different moments. + */ turnNum: number } +/** Whole-turn number for a persisted transcript row. + * + * convsim-core stores the opening at turn_number 0 and then alternates: + * player at 2n-1, NPC at 2n for whole turn n. */ +function wholeTurnFromRow(rowTurnNumber: number): number { + return Math.ceil(rowTurnNumber / 2) +} + type Phase = 'starting' | 'active' | 'submitting' | 'ending' | 'ended' | 'error' -type Banner = { id: number; kind: 'event' | 'safety'; text: string } +type Banner = { + id: number + kind: 'event' | 'safety' + /** Pre-formatted text (safety reasons), or raw flags to humanize at render. */ + text?: string + flags?: string[] +} /** Result of one post-deadline check of what the session actually recorded. */ type ReconcileOutcome = 'adopted' | 'pending' | 'unavailable' @@ -103,11 +137,15 @@ function NpcAvatar() { ) } -function npcStatusLabel(sessionState: string, phase: Phase): string { +function npcStatusLabel(sessionState: string, phase: Phase, isPlain: boolean): string { if (phase === 'submitting') return 'Thinking…' if (sessionState === 'NpcThinking') return 'Thinking…' if (sessionState === 'NpcSpeaking') return 'Speaking…' - if (sessionState === 'ScenarioEvent') return 'Event in progress…' + // "Event" is one of the four words the playtest named as unexplained jargon + // (issue #501 §2), so the plain level says what is happening instead. + if (sessionState === 'ScenarioEvent') { + return isPlain ? 'Something is changing…' : 'Event in progress…' + } if (sessionState === 'PlayerTurnListening') return 'Listening' return '' } @@ -126,11 +164,23 @@ export default function Conversation() { tts_enabled?: boolean } | null - const language = routeState?.language - const showStateMeters = routeState?.show_state_meters ?? true - const scenarioIdFromRoute = routeState?.scenario_id - const inputMode = routeState?.input_mode ?? 'text-only' - const ttsEnabled = routeState?.tts_enabled ?? false + // Wording level (issue #501 §2): `plain` keeps session ids, flow-state + // identifiers and event flags off this screen entirely. + const { isPlain } = useUiLanguageLevel() + + // The setup convsim-core recorded when the session was created. Only fetched + // when the route state is missing — which is exactly the resume case + // (issue #501 §1): navigating to Settings and back, a reload, or a relaunch + // all arrive here with no router state, and before this the screen fell back + // to defaults and dropped the scene card, the chosen language and the voice + // settings the player had picked. + const [serverSetup, setServerSetup] = useState(null) + + const language = routeState?.language ?? serverSetup?.language + const showStateMeters = routeState?.show_state_meters ?? serverSetup?.show_state_meters ?? true + const scenarioId = routeState?.scenario_id ?? serverSetup?.scenario_id + const inputMode = routeState?.input_mode ?? serverSetup?.input_mode ?? 'text-only' + const ttsEnabled = routeState?.tts_enabled ?? serverSetup?.tts_enabled ?? false // Voice timing preferences (issue #308) — read once at mount from localStorage. const voiceTimingPrefs = getVoiceTimingPrefs() @@ -141,6 +191,14 @@ export default function Conversation() { const [turns, setTurns] = useState([]) // Populated from the server's visible_state snapshots (scenario-specific). const [stateVars, setStateVars] = useState>({}) + // Change the most recent turn applied, per variable — the "tick" the tutorial + // talks about and that nothing on screen used to show (issue #501 §3). + // Derived by diffing successive snapshots rather than read from the model's + // requested state_delta: a delta the reducer clamped at 0 or 100, or asked for + // a variable this scenario does not track, did not move the meter, and an + // arrow claiming it did is exactly the kind of thing the playtest called + // hallucinated. + const [stateDeltas, setStateDeltas] = useState>({}) const [allEventFlags, setAllEventFlags] = useState([]) const [error, setError] = useState(null) const [devMode] = useState(() => isDevModeEnabled()) @@ -149,6 +207,9 @@ export default function Conversation() { const [npcEmotion, setNpcEmotion] = useState(null) const [streamingText, setStreamingText] = useState('') const [banners, setBanners] = useState([]) + // Scenario events already announced, so one cannot be banner'd twice if it + // arrives over both the socket and the REST reply. + const announcedEventsRef = useRef>(new Set()) // Milliseconds the current turn has been waiting on the NPC. Drives the // progress indicator and both stages of the slow-response notice, so one clock // answers "how long has the player been waiting on this turn" — and, once the @@ -177,6 +238,14 @@ export default function Conversation() { const reconcileWakeRef = useRef<(() => void) | null>(null) // False once the screen unmounts, so the reconcile loop stops touching state. const mountedRef = useRef(true) + // What the meters read at the start of the current exchange. The movement + // shown on each meter is measured against THIS, not against the previous + // snapshot, because one turn produces two snapshots: the WebSocket npc.final + // applies the delta optimistically, and then session.state (and, without a + // socket, the REST reply) delivers the authoritative numbers. Diffing + // consecutive snapshots makes the second one a move of zero, which wiped the + // arrow the first one had just earned. + const turnBaselineRef = useRef | null>(null) // TTS audio queue — plays synthesized sentence chunks in order. const ttsQueueRef = useRef([]) @@ -185,10 +254,20 @@ export default function Conversation() { const ttsHoldTimerRef = useRef | null>(null) // True when the player started recording while TTS was playing (barge-in). const bargedInRef = useRef(false) + // The WebSocket handler is installed once per session, so it captures the + // first render's _enqueueTtsChunk. A resumed session learns it had voice + // enabled only when the setup fetch lands, after that render — hence a ref + // rather than the render-scope value (issue #501 §1). + const ttsEnabledRef = useRef(false) const transcriptRef = useRef(null) const turnUidRef = useRef(0) - const turnNumRef = useRef(0) + // Transcript ROWS this view holds (opening + every player and NPC message). + // The reconcile loop compares this against the server's row count, so it must + // stay a row count even though the labels now number whole turns. + const rowCountRef = useRef(0) + // Whole turns played — the number shown in the labels (issue #501 §4). + const gameTurnRef = useRef(0) const bannerUidRef = useRef(0) const streamingRef = useRef('') const phaseRef = useRef('starting') @@ -199,6 +278,7 @@ export default function Conversation() { const sessionStateRef = useRef(sessionState) phaseRef.current = phase sessionStateRef.current = sessionState + ttsEnabledRef.current = ttsEnabled // Clean up any pending timers and TTS audio when the component unmounts. useEffect(() => { @@ -253,20 +333,39 @@ export default function Conversation() { setWaitElapsedMs(0) } - /** Replace the transcript with the server's copy, renumbering from the top. */ + /** Replace the transcript with the server's copy, renumbering from the top. + * + * The server's own `turn_number` is authoritative for the whole-turn labels: + * it stores the opening at 0 and then alternates player (2n-1) / NPC (2n) + * for whole turn n. Deriving the label from it keeps a resumed or reconciled + * transcript numbered identically to one built turn by turn, and identically + * to the debrief's key moments. */ function _hydrateTurnsFromServer( - serverTurns: Array<{ role: TurnEntry['role']; content: string; emotion?: string | null }>, + serverTurns: Array<{ + turn_number?: number + role: TurnEntry['role'] + content: string + emotion?: string | null + }>, ) { - turnNumRef.current = 0 - setTurns( - serverTurns.map((t) => ({ + rowCountRef.current = serverTurns.length + let highestTurn = 0 + const rows = serverTurns.map((t, idx) => { + // Fall back to position when a row arrives without its number: the + // opening is first, and every pair after it is one whole turn. + const rowNumber = t.turn_number ?? idx + const turnNum = t.role === 'npc_opening' ? 0 : wholeTurnFromRow(rowNumber) + if (turnNum > highestTurn) highestTurn = turnNum + return { id: ++turnUidRef.current, role: t.role, content: t.content, emotion: t.emotion ?? undefined, - turnNum: ++turnNumRef.current, - })), - ) + turnNum, + } + }) + gameTurnRef.current = highestTurn + setTurns(rows) } function _playNextTtsChunk() { @@ -297,7 +396,7 @@ export default function Conversation() { function _enqueueTtsChunk(cachePath: string, thinkingPauseMs?: number) { // Only play TTS audio when the session was started with TTS enabled. // The text transcript remains authoritative regardless of this flag. - if (!ttsEnabled) return + if (!ttsEnabledRef.current) return const filename = cachePath.replace(/\\/g, '/').split('/').pop() if (!filename) return const url = `/api/tts/audio/${filename}` @@ -358,7 +457,7 @@ export default function Conversation() { } function handleBargeIn() { - if (!ttsEnabled || !voiceTimingPrefs.bargeInEnabled) return + if (!ttsEnabledRef.current || !voiceTimingPrefs.bargeInEnabled) return const isTtsActive = ttsPlayingRef.current !== null || ttsHoldTimerRef.current !== null // Reset the flag on every recording start so it always reflects whether THIS // recording began during NPC playback. Otherwise a barge-in the player then @@ -372,15 +471,29 @@ export default function Conversation() { }) } + // Resume (issue #501 §1): with no router state — after a trip to Settings, a + // reload, or a relaunch — read the setup back from the session convsim-core + // already holds. Best effort: the conversation itself still works from the + // defaults if this fails. + const hasRouteState = routeState != null + useEffect(() => { + if (!sessionId || hasRouteState) return + let cancelled = false + void api.getSession(sessionId).then((r) => { + if (!cancelled && r.ok) setServerSetup(r.data.setup) + }) + return () => { cancelled = true } + }, [sessionId, hasRouteState]) + // Fetch scenario for NPC panel and scene card — best effort useEffect(() => { - if (!scenarioIdFromRoute) return + if (!scenarioId) return let cancelled = false - api.getScenario(scenarioIdFromRoute).then( + api.getScenario(scenarioId).then( (r) => { if (!cancelled && r.ok) setScenario(r.data) }, ) return () => { cancelled = true } - }, [scenarioIdFromRoute]) + }, [scenarioId]) // Start session useEffect(() => { @@ -404,7 +517,8 @@ export default function Conversation() { if (alreadyStarted) { const tr = await api.getSessionTranscript(sessionId) if (cancelled) return - if (tr.ok && tr.data.turns.length > 0) { + const hydrated = tr.ok && tr.data.turns.length > 0 + if (hydrated) { _hydrateTurnsFromServer(tr.data.turns) } const lastState = tr.ok @@ -412,6 +526,30 @@ export default function Conversation() { : null setSessionState(lastState ?? 'PlayerTurnListening') setPhase('active') + // Meter values only ever arrived with a turn, so a resumed session + // came back with the meters missing entirely until the player sent + // another message — on the very screen whose opening line says + // "above this conversation you'll see two meters" (issue #501 §1). + // After setPhase so a slow or failing read never holds up the input. + const sr = await api.getSession(sessionId) + if (!cancelled && sr.ok) { + if (sr.data.visible_state) { + setStateVars({ ...sr.data.visible_state }) + } + // A session started with transcript saving off answers the + // transcript endpoint with no rows, always, so there is nothing to + // count the resumed turns from and the labels would restart at + // "Turn 1" over a session the server is several turns into — the + // debrief-says-3-transcript-says-1 mismatch of issue #501 §4, back + // again on the screen resume was added for. `turn_count` is the + // same whole-turn number the labels, `max_turns` and the debrief + // all use. Skipped once the player has sent a message, which owns + // the counter from then on, so a slow read cannot renumber a turn + // already in flight. + if (!hydrated && gameTurnRef.current === 0 && rowCountRef.current === 0) { + gameTurnRef.current = sr.data.turn_count ?? 0 + } + } } else { setError(e) setPhase('error') @@ -423,13 +561,17 @@ export default function Conversation() { const opening = startData.events.find((e) => e.event_type === 'npc_opening') if (opening) { const uid = ++turnUidRef.current + rowCountRef.current = 1 + gameTurnRef.current = 0 setTurns([ { id: uid, role: 'npc_opening', content: opening.payload['content'] as string, emotion: opening.payload['emotion'] as string | undefined, - turnNum: ++turnNumRef.current, + // Turn 0: the opening precedes the first exchange, so it is labelled + // "Opening" rather than consuming a turn number (issue #501 §4). + turnNum: 0, }, ]) if (devMode) { @@ -489,6 +631,7 @@ export default function Conversation() { npcTurnCommittedRef.current = true const { content, emotion: npcEmotion, state_delta, event_flags } = event.payload const flags = event_flags ?? [] + rowCountRef.current += 1 setTurns((prev) => [ ...prev, { @@ -497,7 +640,9 @@ export default function Conversation() { content, emotion: npcEmotion !== 'neutral' ? npcEmotion : undefined, eventFlags: flags.length > 0 ? flags : undefined, - turnNum: ++turnNumRef.current, + // The reply closes the turn the player's message opened, so + // it shares that number rather than taking a new one. + turnNum: gameTurnRef.current, }, ]) if (Object.keys(state_delta ?? {}).length > 0) { @@ -521,7 +666,7 @@ export default function Conversation() { { id: ++bannerUidRef.current, kind: 'event', - text: event.payload.flags.join(' · '), + flags: event.payload.flags, }, ]) } @@ -560,6 +705,22 @@ export default function Conversation() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [sessionId, recordInterval]) + // Recompute this turn's meter movement whenever the meters change. + useEffect(() => { + const baseline = turnBaselineRef.current + // The first snapshot is where the meters start, not a change from anything. + if (baseline === null) { + turnBaselineRef.current = stateVars + return + } + const moved: Record = {} + for (const [key, value] of Object.entries(stateVars)) { + const was = baseline[key] + if (was !== undefined && was !== value) moved[key] = value - was + } + setStateDeltas(moved) + }, [stateVars]) + // Auto-scroll transcript useEffect(() => { const el = transcriptRef.current @@ -599,9 +760,9 @@ export default function Conversation() { if (!tr.data.transcript_saved) return 'unavailable' const serverTurns = tr.data.turns const last = serverTurns[serverTurns.length - 1] - // turnNumRef counts every row this view holds, the optimistic player turn + // rowCountRef counts every row this view holds, the optimistic player turn // included, so the reply only landed if the server is ahead of that. - if (!last || last.role !== 'npc' || serverTurns.length <= turnNumRef.current) return 'pending' + if (!last || last.role !== 'npc' || serverTurns.length <= rowCountRef.current) return 'pending' _hydrateTurnsFromServer(serverTurns) setNpcEmotion(last.emotion ?? null) @@ -649,7 +810,7 @@ export default function Conversation() { let askTheTranscript = true while (mountedRef.current && !hasAnswered()) { // A WebSocket npc.final may already have put the reply on screen, in which - // case the turn is done. turnNumRef counts that NPC row too, so the + // case the turn is done. rowCountRef counts that NPC row too, so the // transcript comparison could never see the server pull ahead: left to the // loop this would poll to the ceiling and then report a timeout over a // reply the player is looking at. @@ -689,7 +850,14 @@ export default function Conversation() { // Add the player turn immediately so it appears before the NPC response // regardless of whether the NPC turn is committed by WebSocket or REST. const playerTurnId = ++turnUidRef.current - const playerTurnNum = ++turnNumRef.current + // The player's message opens a new whole turn; the NPC's reply will close + // it under the same number (issue #501 §4). + const playerTurnNum = ++gameTurnRef.current + // Every meter movement from here belongs to this turn, however many + // snapshots arrive to report it. + turnBaselineRef.current = stateVars + setStateDeltas({}) + rowCountRef.current += 1 setTurns((prev) => [ ...prev, { @@ -788,7 +956,8 @@ export default function Conversation() { // retry doesn't leave an orphaned failed message or skip a turn number. if (!npcTurnCommittedRef.current) { setTurns((prev) => prev.filter((t) => t.id !== playerTurnId)) - turnNumRef.current -= 1 + rowCountRef.current -= 1 + gameTurnRef.current -= 1 } setPhase('active') return @@ -804,6 +973,12 @@ export default function Conversation() { const npcEvent = turnData.events.find((e) => e.event_type === 'npc_turn') + // Outside the commit guard below: the reply itself may already be on screen + // from the socket, but the scenario events it fired only ever travel on + // this payload. + const firedEvents = npcEvent?.payload['triggered_scenario_events'] + if (Array.isArray(firedEvents)) announceScenarioEvents(firedEvents as string[]) + // Only commit the NPC turn from REST if the WebSocket npc.final handler // has not already done so (to avoid duplicate transcript entries). if (npcEvent && !npcTurnCommittedRef.current) { @@ -815,6 +990,7 @@ export default function Conversation() { setNpcEmotion(emotion ?? null) const uid = ++turnUidRef.current + rowCountRef.current += 1 setTurns((prev) => [ ...prev, { @@ -823,7 +999,8 @@ export default function Conversation() { content: payload['content'] as string, emotion: emotion !== 'neutral' ? emotion : undefined, eventFlags: flags.length > 0 ? flags : undefined, - turnNum: ++turnNumRef.current, + // Closes the turn the player's message opened. + turnNum: gameTurnRef.current, }, ]) @@ -891,6 +1068,27 @@ export default function Conversation() { } } + /** Raise the "Something changed" note for scenario events that just fired. + * + * The engine's own events — a meter crossing a threshold, a flag being set + * — arrive on the npc_turn payload as `triggered_scenario_events`, and + * nothing read them: the banner was fed only by the WebSocket + * `scenario.event` frame, which carries the NPC's self-declared + * `event_flags` and which convsim-core never sends at all. So the First + * Words tutorial crossed Engagement 60, fired `warm_moment`, and then told + * the player "you would have seen a short note above the transcript saying + * something changed" about a note that never appeared — the tutorial + * claiming something happened that did not, which is issue #501 §3. */ + function announceScenarioEvents(ids: string[]) { + const fresh = ids.filter((id) => !announcedEventsRef.current.has(id)) + if (fresh.length === 0) return + for (const id of fresh) announcedEventsRef.current.add(id) + setBanners((prev) => [ + ...prev, + { id: ++bannerUidRef.current, kind: 'event', flags: fresh }, + ]) + } + function dismissBanner(id: number) { setBanners((prev) => prev.filter((b) => b.id !== id)) } @@ -907,7 +1105,11 @@ export default function Conversation() { const isAwaitingNpc = phase === 'submitting' && !npcReplyOnScreen const isSlowResponse = isAwaitingNpc && waitElapsedMs >= SLOW_RESPONSE_MS const isVerySlowResponse = isAwaitingNpc && waitElapsedMs >= VERY_SLOW_RESPONSE_MS - const npcStatus = npcStatusLabel(sessionState, phase) + const npcStatus = npcStatusLabel(sessionState, phase, isPlain) + // The character's name when the pack provides one. "NPC" is the most + // technical word on this screen and the only reason it was here is that the + // name was never exposed over the API (issue #501 §2). + const npcLabel = scenario?.npc_name ?? 'NPC' return (
-

Conversation

+

{scenario?.title ?? 'Conversation'}

-

- Session: {sessionId}  |  State:{' '} - {sessionState} + {/* Plain wording keeps the session id and the raw flow-state + identifier off the screen entirely: "Session: sess-a1b2c3d4 | + State: PlayerTurnListening" was the first thing a new player read + (issue #501 §2). Technical wording puts both back, because the + id is what a bug report needs. */} +

+ {isPlain ? ( + {plainFlowState(sessionState)} + ) : ( + <> + Session: {sessionId}  |  State:{' '} + {sessionState} + + )} {endingType && ( - ({endingType.replace(/_/g, ' ')}) + ({humanizeEndingType(endingType)}) )}

@@ -974,18 +1187,20 @@ export default function Conversation() { >
-
NPC
+
+ {scenario?.npc_name ?? 'NPC'} +
+ {/* "Mood:" is the whole point: the emotion used to be rendered as a + bare parenthetical next to the meters, so it read as one of them + — "unclear which meter (if any) the emotions in parentheses + are" (issue #501 §3). */} {npcEmotion && npcEmotion !== 'neutral' && (
- {npcEmotion} + Mood: + {npcEmotion}
)} {/* Always mounted so screen readers announce status transitions; @@ -1052,8 +1267,16 @@ export default function Conversation() { }} > - {banner.kind === 'safety' ? 'Safety redirect: ' : 'Scenario event: '} - {banner.text} + {banner.kind === 'safety' + ? 'Safety redirect: ' + : isPlain + ? 'Something changed: ' + : 'Scenario event: '} + {banner.flags + ? isPlain + ? humanizeFlags(banner.flags) + : banner.flags.join(' · ') + : banner.text}
- {isDemo - ? 'NPC is taking longer than usual. The model may be slow on this hardware; closing other apps usually helps.' - : 'NPC is taking longer than usual. The model may be slow on this hardware. You can adjust settings or try a smaller model.'} + {isDemo ? ( + `${npcLabel} is taking longer than usual. The model may be slow on this hardware; closing other apps usually helps.` + ) : ( + <> + {npcLabel} is taking longer than usual. The model may be slow on + this hardware.{' '} + {/* The moment the player actually wants the speed setting is this + one. Before #501 this sentence said "you can adjust settings" + and left them to find out which — the detour that lost them + their session. Resuming is one click back, via the banner in + the app chrome. */} + + Make replies faster → + + + )} {isVerySlowResponse && ( // Past half a minute, say outright that the turn has not been // thrown away — the panel above already reports the clock, so @@ -1231,73 +1493,12 @@ export default function Conversation() {
)} - {/* State meters — shown only when enabled in setup and hydrated */} - {showStateMeters && Object.keys(stateVars).length > 0 && ( -
- - NPC state variables - -
- {Object.entries(stateVars).map(([key, value]) => ( -
- {key} - {value} -
- -
- ))} -
-
- )} - - {allEventFlags.length > 0 && ( + {/* The running list of raw flag identifiers is a developer's view of the + session, not a player's — "Event flags: honesty_demonstrated, + player_demonstrates_knowledge" told the playtester nothing. The + per-turn "Something changed" banner already reports the ones that + matter in words (issue #501 §2). */} + {!isPlain && allEventFlags.length > 0 && (

Session ended. - {endingType ? ` Outcome: ${endingType.replace(/_/g, ' ')}.` : ''} + {endingType ? ` Outcome: ${humanizeEndingType(endingType)}.` : ''}

) : ( @@ -717,17 +738,29 @@ export default function Debrief() { gap: '0.75rem', }} > - {transcript.map((event, idx) => ( - 0 ? event.event_id : idx} - event={event} - turnNumber={idx + 1} - registerRef={(el) => { - if (el) turnRefs.current.set(idx + 1, el) - else turnRefs.current.delete(idx + 1) - }} - /> - ))} + {transcript.map((event, idx) => { + const turnNumber = wholeTurnNumbers[idx] ?? 0 + return ( + 0 ? event.event_id : idx} + event={event} + turnNumber={turnNumber} + registerRef={(el) => { + // Keyed by whole turn, and only the first row of each — + // the player's message — so "go to turn 3" lands on the + // start of turn 3 rather than on whichever half-turn + // happened to be third in the list (issue #501 §4). + if (turnNumber === 0) return + const existing = turnRefs.current.get(turnNumber) + if (el) { + if (existing === undefined) turnRefs.current.set(turnNumber, el) + } else if (existing !== undefined) { + turnRefs.current.delete(turnNumber) + } + }} + /> + ) + })}
)} @@ -760,6 +793,13 @@ export default function Debrief() { {isDemo && } + {/* The post-tutorial re-ask of the familiarity question + (issue #501 §2). Renders nothing outside the tutorial debrief, or + once it has been shown. */} + {!isDemo && ( + + )} + {/* Voice invite card — shown once after the first real AI conversation */} {voiceInviteVisible && !isDemo && (
- {t('debrief.transcript.turn', { number: turnNumber })} + + {turnNumber === 0 + ? t('debrief.transcript.opening') + : t('debrief.transcript.turn', { number: turnNumber })} + {' · '} {isPlayer ? t('debrief.transcript.you') : t('debrief.transcript.npc')} - {emotion && emotion !== 'neutral' && ( - ({emotion}) - )}
{content}
+ {/* Labelled rather than a bare parenthetical: next to a turn number it + read as one of the meters (issue #501 §3). */} + {emotion && emotion !== 'neutral' && ( +
+ {t('debrief.transcript.moodLabel')}{' '} + {emotion} +
+ )}
) } @@ -1255,11 +1304,15 @@ function TelemetryPanel({ metrics }: { metrics: DebriefMetrics }) { )} - {/* State arc sparklines */} + {/* Meter arc sparklines. Named after the panel the player watched during + the conversation — "Conversation meters" — rather than after the state + variables behind it: a panel that changes name between the screen it + moves on and the screen that reviews it is the label/naming mismatch + of issue #501 §3, one screen later. */} {stateVarNames.length > 0 && (

- State meters across turns + Conversation meters across turns

{stateVarNames.map((varName) => ( diff --git a/apps/web/src/screens/Home.tsx b/apps/web/src/screens/Home.tsx index 9d032b90..9c055318 100644 --- a/apps/web/src/screens/Home.tsx +++ b/apps/web/src/screens/Home.tsx @@ -16,6 +16,7 @@ import { useAppUpdate } from '../hooks/useAppUpdate' import { useIsDemo } from '../edition' import DemoConversations from '../components/DemoConversations' import DemoUpsellCard from '../components/DemoUpsellCard' +import { useResumableSession } from '../hooks/useResumableSession' import type { BadgeStatus } from '@convsim/ui' const DOCS_URL = 'https://docs.conversationsimulator.com/' @@ -38,6 +39,10 @@ export default function Home() { // and model surfaces are not part of the demo. const isDemo = useIsDemo() + // Home had no way back into a conversation the player walked away from + // (issue #501 §1) — not here and not in the nav. + const resumable = useResumableSession() + const [isRestartingSidecar, setIsRestartingSidecar] = useState(false) const [reseeding, setReseeding] = useState(false) const [reseedDone, setReseedDone] = useState(false) @@ -178,6 +183,17 @@ export default function Home() { aria-label={t('home.primaryActions')} style={{ marginTop: '2rem', display: 'flex', flexDirection: 'column', gap: '0.75rem', maxWidth: '20rem' }} > + {/* First in the list, and only when there is one: resuming beats + starting over, which is what the playtester did instead. */} + {resumable.result.state === 'ready' && ( + + {t('home.resumeScenario')} + + )} {t('home.startScenario')} {t('home.createEdit')} {t('home.installModel')} diff --git a/apps/web/src/screens/Logbook.tsx b/apps/web/src/screens/Logbook.tsx index 2b9a6d5f..3b1b44e5 100644 --- a/apps/web/src/screens/Logbook.tsx +++ b/apps/web/src/screens/Logbook.tsx @@ -116,7 +116,7 @@ export default function Logbook() {

Logbook

- Could not load logbook data. Check that the local runtime is running. + Could not load logbook data. Check that the AI engine is running.

diff --git a/apps/web/src/screens/Settings.tsx b/apps/web/src/screens/Settings.tsx index a1c80210..ecf5528f 100644 --- a/apps/web/src/screens/Settings.tsx +++ b/apps/web/src/screens/Settings.tsx @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 import { useState, useEffect, useCallback, useRef, type ReactNode } from 'react' -import { Link, useNavigate } from 'react-router-dom' +import { Link, useLocation, useNavigate } from 'react-router-dom' import { api, type ImportPackResponse, type PackSummary, type RelationshipRecapSummary } from '../api/client' import type { ApiError } from '../api/errors' import { errorHeadline } from '../api/errors' @@ -8,13 +8,21 @@ import { ApiErrorView } from '../components/ApiErrorView' import { readPrivacyPref, writePrivacyPref, PRIVACY_KEYS, isDevModeEnabled } from '../privacyPrefs' import { useSteamStatus } from '../hooks/useSteamStatus' import RuntimeSettingsPanel from '../components/RuntimeSettingsPanel' +import ReplySpeedPanel from '../components/ReplySpeedPanel' +import WordingPanel from '../components/WordingPanel' import VoiceSettingsPanel from '../components/VoiceSettingsPanel' import { useTranslation, formatDate, SUPPORTED_LOCALES } from '../i18n' +import { useScenarios } from '../api/useScenarios' +import { useUiLanguageLevel } from '../hooks/useUiLanguageLevel' +import { flowStateLabelKey } from '../lib/plainLanguage' import { RemediationCard } from '../setup/RemediationCard' import { openExternal } from '../lib/openExternal' import { clearTurnSamples } from '../lib/turnEstimate' import { useIsDemo } from '../edition' import type { PreflightResponse, PreflightFixAction } from '@convsim/shared' +// The resumable set lives in @convsim/shared so this list and the +// `status=in_progress` filter it mirrors cannot drift apart (issue #501 §1). +import { isResumableSessionState } from '@convsim/shared' type ClearState = 'idle' | 'confirming' | 'clearing' | 'done' | 'error' type PackImportState = 'idle' | 'uploading' | 'success' | 'error' @@ -91,6 +99,10 @@ const LOCALE_DISPLAY_NAMES: Record = { export default function Settings() { const { t, locale, setLocale } = useTranslation() const navigate = useNavigate() + const { isPlain } = useUiLanguageLevel() + // Titles for the session list, so it does not have to print scenario slugs. + const scenariosResult = useScenarios() + const { hash } = useLocation() // Demo edition (issue #495): Settings keeps language, transcript saving, // local folders, sessions and clear-data — the privacy controls every // edition must have (gate F-06). Runtime/model tiers, voice, Steam Cloud, @@ -130,6 +142,18 @@ export default function Settings() { } } + // A router navigation to /settings#reply-speed does not scroll on its own, so + // the slow-response notice's "Make replies faster" link would land the player + // at the top of a long settings page with nothing obviously changed + // (issue #501 §1). + useEffect(() => { + if (!hash) return + const target = document.getElementById(hash.slice(1)) + if (target && typeof target.scrollIntoView === 'function') { + target.scrollIntoView({ block: 'start' }) + } + }, [hash]) + function handleSaveTranscriptsChange(v: boolean) { setSaveTranscripts(v) writePrivacyPref(PRIVACY_KEYS.saveTranscripts, v) @@ -439,6 +463,32 @@ export default function Settings() { )} + {/* Wording level — the buffer between player-facing and technical + language (issue #501 §2). Above the engine settings on purpose: it + changes what the rest of this screen and the conversation screen + call things. */} +
+ {t('settings.wording.heading')} +

+ {t('settings.wording.description')} +

+ +
+ + {/* Reply speed — the plain-language answer to "make the model respond + faster" (issue #501 §1). Deliberately its own section above the + engine knobs, and anchored so the conversation screen's slow-response + notice can link straight here. */} + {!isDemo && ( +
+ {t('settings.replySpeed.heading')} +

+ {t('settings.replySpeed.description')} +

+ +
+ )} + {/* Runtime settings */} {!isDemo && (
@@ -829,7 +879,10 @@ export default function Settings() { }} > - {s.scenario_id} + + {scenariosResult.scenarios.find((sc) => sc.scenario_id === s.scenario_id) + ?.title ?? s.scenario_id} + {formatDate(s.created_at, locale)} @@ -840,10 +893,33 @@ export default function Settings() { color: s.state === 'Ended' ? '#86efac' : '#fbbf24', }} > - {s.state} + {isPlain ? t(flowStateLabelKey(s.state)) : s.state}
+ {/* The screen the lost player was already standing on + (issue #501 §1): every unfinished conversation is one + click from here, not just the newest one the banner + offers. */} + {isResumableSessionState(s.state) && ( + + )}
diff --git a/apps/web/src/setup/steps/ConfirmInstallStep.tsx b/apps/web/src/setup/steps/ConfirmInstallStep.tsx index 0dea4087..5ef766d8 100644 --- a/apps/web/src/setup/steps/ConfirmInstallStep.tsx +++ b/apps/web/src/setup/steps/ConfirmInstallStep.tsx @@ -59,7 +59,7 @@ export function ConfirmInstallStep({ flow, mode }: ConfirmInstallStepProps) { {mode === 'wizard' ? 'Checksum not yet confirmed — the download will be rejected if verification fails.' - : 'Checksum not yet confirmed — install may be rejected by the runtime.'} + : 'Checksum not yet confirmed — install may be rejected by the AI engine.'} ) : mode === 'wizard' ? ( @@ -97,7 +97,7 @@ export function ConfirmInstallStep({ flow, mode }: ConfirmInstallStepProps) {

{needsMoreVram ? `This model requires ${m.min_vram_gb} GB VRAM. If your hardware is limited, try a smaller model or ` - : 'If your hardware or runtime cannot install this model, try a smaller model or '} + : 'If your hardware or AI engine cannot install this model, try a smaller model or '} check the setup docs.

diff --git a/apps/web/src/setup/steps/LoadErrorStep.tsx b/apps/web/src/setup/steps/LoadErrorStep.tsx index 12371a83..13a0d4fb 100644 --- a/apps/web/src/setup/steps/LoadErrorStep.tsx +++ b/apps/web/src/setup/steps/LoadErrorStep.tsx @@ -33,7 +33,7 @@ export function LoadErrorStep({ flow, mode }: LoadErrorStepProps) { <>

{errMsg}

-

Could not connect to the local runtime

+

Could not connect to the AI engine

The API server may not be running. Make sure you launched the app correctly, then try again.

diff --git a/apps/web/src/setup/steps/WelcomeStep.tsx b/apps/web/src/setup/steps/WelcomeStep.tsx index d0e2443a..ffe6ab4a 100644 --- a/apps/web/src/setup/steps/WelcomeStep.tsx +++ b/apps/web/src/setup/steps/WelcomeStep.tsx @@ -5,6 +5,7 @@ import { ActionButton } from '../primitives' import type { UseSetupFlowReturn } from '../useSetupFlow' import { SETUP_DOCS_URL } from '../docsUrls' import { useIsDemo } from '../../edition' +import FamiliarityQuestion from '../../components/FamiliarityQuestion' const cardBase: React.CSSProperties = { display: 'flex', flexDirection: 'column', alignItems: 'flex-start', @@ -49,6 +50,20 @@ export function WelcomeStep({ flow }: { flow: UseSetupFlowReturn }) {

+ {/* The initial LLM-familiarity question (issue #501 §2). One row, three + answers, no step of its own: the single road to a first conversation + below must stay the single road, so this never blocks it and an + unanswered question means plain wording. */} +
+ +
+
{/* Set me up — the single road to a real first conversation */}