From d8464241e6e6455dc8e3a41f6348f0cf7ff6e9f7 Mon Sep 17 00:00:00 2001 From: Nick Charney Kaye Date: Sat, 3 Oct 2026 03:19:37 -0400 Subject: [PATCH 01/37] List sessions over HTTP and name reply speed in plain language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps behind the first-session feedback in issue #501, both in the core. GET /api/sessions did not exist. The web client already called it (api.listSessions) and the shared SessionCreateResponse type already documented it — "Included in list responses (GET /api/sessions)" — but the route was never registered, so the request answered 405: Settings' "Your sessions" list only ever rendered an error, and nothing in the app could discover a conversation to resume. The route now lists the profile's sessions newest first, with a `status` filter whose `in_progress` value is exactly the resumable set (started, not ended) and a bounded `limit`. Each row carries the setup it was created with, which is what lets a resumed conversation rebuild its language, meter and voice settings without the route state it was launched from. turn_count rides along; it already counted whole player+NPC exchanges rather than transcript rows. "Make the model respond faster" was not an exposed concept. The settings that exist are context length, GPU layers, CPU threads and sampling — all named after the runtime, none of them the dominant cost of a turn. The dominant cost is how many tokens the NPC generates, one at a time, so reply_speed turns that into one named choice: fast / balanced / detailed. It scales the scenario's authored NPC word cap rather than replacing it (a pack that writes terse NPCs stays terser than one that writes expansive ones) and moves the generation budget with it. 'balanced' reproduces the previously hardcoded 1024-token, unscaled behaviour exactly, so the default player experience is unchanged. It is read per turn, so a change applies to the player's next message with no restart, and the scenario data is copied rather than mutated so one player's pacing cannot leak into another session playing the same scenario. Tests cover the list endpoint (ordering, the three status filters, limit bounds, whole-turn counts), the profile table, endpoint validation, a garbage value stored by a newer build, and end-to-end assertions that the scaled word cap reaches the RESPONSE_STYLE prompt layer and the token budget reaches the runtime. Co-Authored-By: Claude Opus 5 --- .../convsim_core/routers/runtime_settings.py | 28 ++- .../convsim_core/routers/sessions.py | 60 +++++ .../convsim_core/services/reply_speed.py | 91 ++++++++ .../convsim_core/services/turn_pipeline.py | 37 ++- .../convsim-core/tests/test_reply_speed.py | 220 ++++++++++++++++++ .../tests/test_session_listing.py | 123 ++++++++++ 6 files changed, 552 insertions(+), 7 deletions(-) create mode 100644 services/convsim-core/convsim_core/services/reply_speed.py create mode 100644 services/convsim-core/tests/test_reply_speed.py create mode 100644 services/convsim-core/tests/test_session_listing.py diff --git a/services/convsim-core/convsim_core/routers/runtime_settings.py b/services/convsim-core/convsim_core/routers/runtime_settings.py index 2b1219be..08a0d8d5 100644 --- a/services/convsim-core/convsim_core/routers/runtime_settings.py +++ b/services/convsim-core/convsim_core/routers/runtime_settings.py @@ -17,6 +17,7 @@ from pydantic import BaseModel from convsim_core.errors import ConvsimError +from convsim_core.services.reply_speed import DEFAULT_REPLY_SPEED, REPLY_SPEED_PROFILES router = APIRouter() @@ -29,13 +30,20 @@ "temperature", "top_p", "repeat_penalty", + # Plain-language reply pacing (issue #501). Stored alongside the numeric + # knobs so one Settings screen and one reset path cover all of them. + "reply_speed", ) # Changing these requires restarting the llama-server sidecar to take effect. +# reply_speed is deliberately not here: it is read per turn, so a change applies +# to the player's very next message with no restart. _RESTART_REQUIRED = {"context_length", "gpu_layers"} _INT_KEYS = {"context_length", "gpu_layers", "threads"} +_STR_KEYS = {"reply_speed"} + class RuntimeSettings(BaseModel): context_length: Optional[int] = None @@ -44,6 +52,9 @@ class RuntimeSettings(BaseModel): temperature: Optional[float] = None top_p: Optional[float] = None repeat_penalty: Optional[float] = None + #: "fast" | "balanced" | "detailed". None means "never chosen", which the + #: turn pipeline reads as the default. + reply_speed: Optional[str] = None class RuntimeSettingsResponse(BaseModel): @@ -60,12 +71,17 @@ def load_runtime_settings(conn: sqlite3.Connection) -> RuntimeSettings: ).fetchall() stored = {row["key"][len(_KEY_PREFIX):]: row["value"] for row in rows} - values: dict[str, int | float | None] = {} + values: dict[str, int | float | str | None] = {} for key in _SETTING_KEYS: raw = stored.get(key) if raw is None or raw == "" or raw == "null": values[key] = None continue + if key in _STR_KEYS: + # A value a newer build wrote, or one hand-edited into the database, + # must not surface as an option the UI cannot render. + values[key] = raw if raw in REPLY_SPEED_PROFILES else None + continue try: values[key] = int(raw) if key in _INT_KEYS else float(raw) except ValueError: @@ -113,13 +129,21 @@ def _check_float(key: str, lo: float, hi: float, label: str) -> None: _check_float("temperature", 0.0, 2.0, "Temperature") _check_float("top_p", 0.0, 1.0, "Top-P") _check_float("repeat_penalty", 1.0, 2.0, "Repeat penalty") + + speed = patch.get("reply_speed") + if "reply_speed" in patch and speed is not None and speed not in REPLY_SPEED_PROFILES: + options = ", ".join(sorted(REPLY_SPEED_PROFILES)) + errors.append(f"Reply speed must be one of: {options}.") return errors def _recommended() -> RuntimeSettings: """Conservative recommendations; None means 'use the runtime default'.""" cpu = os.cpu_count() - return RuntimeSettings(threads=max(1, (cpu or 2) // 2) if cpu else None) + return RuntimeSettings( + threads=max(1, (cpu or 2) // 2) if cpu else None, + reply_speed=DEFAULT_REPLY_SPEED, + ) @router.get("/api/runtime/settings", response_model=RuntimeSettingsResponse) diff --git a/services/convsim-core/convsim_core/routers/sessions.py b/services/convsim-core/convsim_core/routers/sessions.py index b17c5e91..c5055e5f 100644 --- a/services/convsim-core/convsim_core/routers/sessions.py +++ b/services/convsim-core/convsim_core/routers/sessions.py @@ -3,6 +3,7 @@ Routes: POST /api/sessions — create session + GET /api/sessions — list sessions (newest first) GET /api/sessions/{session_id} — get session state POST /api/sessions/{session_id}/start — start session (NPC opening) POST /api/sessions/{session_id}/turn — submit player turn (full pipeline) @@ -141,6 +142,15 @@ class SessionResponse(BaseModel): state: str created_at: str setup: Dict[str, Any] + #: Only populated by the list endpoint and by sessions that have ended; a + #: freshly created session has no outcome and no completed turns yet. + ending_type: Optional[str] = None + turn_count: int = 0 + ended_at: Optional[str] = None + + +class SessionListResponse(BaseModel): + sessions: List[SessionResponse] class SessionEventPayload(BaseModel): @@ -365,9 +375,24 @@ def _row_to_response(row: Any) -> SessionResponse: state=row["flow_state"], created_at=row["created_at"], setup=json.loads(row["setup_json"]), + ending_type=row["ending_type"], + turn_count=int(row["turn_count"] or 0), + ended_at=row["ended_at"], ) +#: Flow states a session can be picked back up from. ``NotStarted`` is excluded +#: on purpose: nothing has been said yet, so there is no conversation to resume — +#: offering one would send the player to a screen that starts from scratch. +RESUMABLE_FLOW_STATES = ( + "PlayerTurnListening", + "PlayerTurnReview", + "NpcThinking", + "NpcSpeaking", + "ScenarioEvent", +) + + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -503,6 +528,41 @@ async def create_session(body: SessionCreateRequest, request: Request) -> Sessio return _row_to_response(row) +@router.get("", response_model=SessionListResponse) +async def list_sessions( + request: Request, + status: Literal["all", "in_progress", "ended"] = "all", + limit: int = 50, +) -> SessionListResponse: + """List this profile's sessions, newest first. + + ``status=in_progress`` answers the one question the UI could not ask before + (issue #501): "is there a conversation I walked away from?". Without it the + player who opened Settings mid-scenario had no way back to a live session + and started a new one instead. + """ + if limit < 1 or limit > 500: + raise HTTPException(status_code=400, detail="limit must be between 1 and 500") + + conn = request.app.state.db.connection() + sql = "SELECT * FROM turn_sessions" + params: List[Any] = [] + if status == "in_progress": + placeholders = ", ".join("?" for _ in RESUMABLE_FLOW_STATES) + sql += f" WHERE flow_state IN ({placeholders})" + params.extend(RESUMABLE_FLOW_STATES) + elif status == "ended": + sql += " WHERE flow_state = 'Ended'" + # created_at is ISO-8601/`datetime('now')` text, so lexical order is + # chronological order. session_id breaks ties for sessions created inside the + # same second, which the second-resolution SQLite default makes common. + sql += " ORDER BY created_at DESC, session_id DESC LIMIT ?" + params.append(limit) + + rows = conn.execute(sql, tuple(params)).fetchall() + return SessionListResponse(sessions=[_row_to_response(r) for r in rows]) + + @router.get("/{session_id}", response_model=SessionResponse) async def get_session(session_id: str, request: Request) -> SessionResponse: db = request.app.state.db diff --git a/services/convsim-core/convsim_core/services/reply_speed.py b/services/convsim-core/convsim_core/services/reply_speed.py new file mode 100644 index 00000000..0c52f7a3 --- /dev/null +++ b/services/convsim-core/convsim_core/services/reply_speed.py @@ -0,0 +1,91 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Reply speed — the plain-language front for "make the model respond faster". + +Issue #501: a player partway through the tutorial went to Settings to speed the +model up and was "a little unclear on how to do that". Nothing in the settings +panel was named after the thing they wanted; the knobs that exist (context +length, GPU layers, CPU threads, sampling) are all written in runtime terms and +none of them is the dominant cost of a turn on local hardware. + +The dominant cost is how many tokens the NPC generates. A local model emits +them one at a time, so halving the length of a reply roughly halves the wait. +This module turns that into one named choice with three settings: + + fast — shorter replies, the quickest option + balanced — the scenario's own pacing (default; matches pre-#501 behaviour) + detailed — longer replies, slower on most machines + +``fast`` and ``detailed`` *scale* the scenario's authored word cap rather than +replacing it, so a pack that deliberately writes terse NPCs stays terse relative +to a pack that writes expansive ones. The token budget moves with it: the +structured turn output lands in the 300–600 token range at the default 90-word +cap, so a shorter reply needs less headroom and a longer one needs more. +""" +from __future__ import annotations + +import sqlite3 +from dataclasses import dataclass +from typing import Literal + +ReplySpeed = Literal["fast", "balanced", "detailed"] + +#: Persisted under the same ``runtime_setting.`` namespace as the numeric runtime +#: settings, so one Settings screen and one reset path cover all of them. +REPLY_SPEED_SETTING_KEY = "runtime_setting.reply_speed" + +DEFAULT_REPLY_SPEED: ReplySpeed = "balanced" + + +@dataclass(frozen=True) +class ReplySpeedProfile: + """How one reply-speed choice changes a turn.""" + + #: Multiplier applied to the scenario's authored ``response_style.max_words``. + word_scale: float + #: Ceiling for the runtime's generation budget for the structured turn JSON. + max_tokens: int + + +#: Floors and ceilings for the scaled word cap. The floor keeps an NPC from +#: being cut down to a grunt on a pack that already writes short lines; the +#: ceiling keeps "detailed" from inviting the monologues RESPONSE_STYLE forbids. +MIN_SCALED_MAX_WORDS = 30 +MAX_SCALED_MAX_WORDS = 200 + +REPLY_SPEED_PROFILES: dict[str, ReplySpeedProfile] = { + "fast": ReplySpeedProfile(word_scale=0.5, max_tokens=640), + # Unchanged from the hardcoded pre-#501 behaviour, so the default player + # experience is byte-for-byte what it was. + "balanced": ReplySpeedProfile(word_scale=1.0, max_tokens=1024), + "detailed": ReplySpeedProfile(word_scale=1.5, max_tokens=1536), +} + + +def normalize_reply_speed(value: object) -> ReplySpeed: + """Coerce a stored/incoming value to a known reply speed. + + Anything unrecognised (a null row, a value written by a newer build, a typo + in a hand-edited database) falls back to the default rather than failing a + turn: reply pacing is a preference, not a correctness guarantee. + """ + if isinstance(value, str) and value in REPLY_SPEED_PROFILES: + return value # type: ignore[return-value] + return DEFAULT_REPLY_SPEED + + +def profile_for(speed: object) -> ReplySpeedProfile: + return REPLY_SPEED_PROFILES[normalize_reply_speed(speed)] + + +def load_reply_speed(conn: sqlite3.Connection) -> ReplySpeed: + """Read the persisted reply speed, defaulting when unset.""" + row = conn.execute( + "SELECT value FROM user_settings WHERE key = ?", (REPLY_SPEED_SETTING_KEY,) + ).fetchone() + return normalize_reply_speed(row["value"] if row is not None else None) + + +def scaled_max_words(authored_max_words: int, speed: object) -> int: + """Apply the reply-speed scale to a scenario's authored NPC word cap.""" + scaled = round(authored_max_words * profile_for(speed).word_scale) + return max(MIN_SCALED_MAX_WORDS, min(MAX_SCALED_MAX_WORDS, scaled)) diff --git a/services/convsim-core/convsim_core/services/turn_pipeline.py b/services/convsim-core/convsim_core/services/turn_pipeline.py index 0e3b8537..a4a1fbcc 100644 --- a/services/convsim-core/convsim_core/services/turn_pipeline.py +++ b/services/convsim-core/convsim_core/services/turn_pipeline.py @@ -20,7 +20,7 @@ import json import logging import sqlite3 -from dataclasses import dataclass +from dataclasses import dataclass, replace as dataclass_replace from datetime import datetime, timezone from typing import Any, Dict, List, Optional, Set @@ -35,7 +35,7 @@ compose_turn_prompt, parse_turn_output, ) -from convsim_prompt.types import ScenarioData +from convsim_prompt.types import ResponseStyleOverrides, ScenarioData from convsim_core.input_router import ( DEFAULT_REDIRECT_MESSAGE, @@ -55,10 +55,32 @@ initialize_state, partition_state_by_visibility, ) +from convsim_core.services.reply_speed import ( + load_reply_speed, + profile_for, + scaled_max_words, +) from convsim_core.storage.repositories.relationship_repo import get_relationship_recap logger = logging.getLogger(__name__) + +def _apply_reply_speed(scenario_data: ScenarioData, speed: str) -> ScenarioData: + """Return *scenario_data* with its NPC word cap scaled for *speed*. + + A copy, not a mutation: ``scenario_data`` is resolved once per scenario and + handed to every session playing it, so writing the current player's pacing + preference into it would leak across sessions. + """ + authored = scenario_data.response_style or ResponseStyleOverrides() + target = scaled_max_words(authored.max_words, speed) + if target == authored.max_words: + return scenario_data + return dataclass_replace( + scenario_data, + response_style=dataclass_replace(authored, max_words=target), + ) + MAX_TURN_CONTENT_CHARS = 2000 # Default safety policy used when no scenario-specific policy is configured. @@ -436,7 +458,11 @@ async def process_turn( pack_id: str = setup.get("pack_id") or scenario_data.scenario_id relationship_recap = get_relationship_recap(conn, npc_id, pack_id) - # 6. Build prompt. + # 6. Build prompt. The player's reply-speed preference (issue #501) scales + # the NPC's word budget here and its token budget at step 7, so a choice + # made in Settings mid-scenario applies to this very turn. + reply_speed = load_reply_speed(conn) + scenario_data = _apply_reply_speed(scenario_data, reply_speed) prompt_safety_policy = ( _safety_policy_config_to_prompt_policy(safety_policy_config) if safety_policy_config is not None @@ -467,8 +493,9 @@ async def process_turn( # regularly lands in the 300-600 token range; the 512 default left no # headroom and a truncated JSON object degrades to the fallback # utterance. Reasoning is disabled at the engine level, so the extra - # budget costs nothing when unused. - max_tokens=1024, + # budget costs nothing when unused. "balanced" keeps the 1024 this was + # hardcoded to before reply speed became a setting. + max_tokens=profile_for(reply_speed).max_tokens, ) logger.debug( "Calling runtime %s for session %s turn %d (estimated %d tokens, truncated=%s)", diff --git a/services/convsim-core/tests/test_reply_speed.py b/services/convsim-core/tests/test_reply_speed.py new file mode 100644 index 00000000..2ccabf4f --- /dev/null +++ b/services/convsim-core/tests/test_reply_speed.py @@ -0,0 +1,220 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Reply speed — the plain-language "make the model respond faster" setting. + +Issue #501 §1: the knobs that existed were all written in runtime terms and +none of them named the thing the player wanted. Reply speed names it, and it +has to actually do something: scale the NPC's word budget in the prompt and the +runtime's generation budget for the turn. +""" +import pytest + +from convsim_core.services.reply_speed import ( + DEFAULT_REPLY_SPEED, + MAX_SCALED_MAX_WORDS, + MIN_SCALED_MAX_WORDS, + REPLY_SPEED_PROFILES, + normalize_reply_speed, + profile_for, + scaled_max_words, +) + + +# ── The profiles themselves ────────────────────────────────────────────────── + + +def test_three_named_speeds(): + assert set(REPLY_SPEED_PROFILES) == {"fast", "balanced", "detailed"} + + +def test_balanced_is_the_default(): + assert DEFAULT_REPLY_SPEED == "balanced" + + +def test_balanced_preserves_pre_change_behaviour(): + """'balanced' must leave the turn exactly as it was before this setting.""" + assert profile_for("balanced").max_tokens == 1024 + assert scaled_max_words(90, "balanced") == 90 + + +def test_fast_shortens_and_detailed_lengthens(): + assert scaled_max_words(90, "fast") < 90 < scaled_max_words(90, "detailed") + assert ( + profile_for("fast").max_tokens + < profile_for("balanced").max_tokens + < profile_for("detailed").max_tokens + ) + + +def test_scaling_is_relative_to_the_authored_cap(): + """A pack that writes terse NPCs stays terser than one that writes long ones.""" + assert scaled_max_words(120, "fast") > scaled_max_words(80, "fast") + + +def test_scaled_cap_is_clamped_at_both_ends(): + assert scaled_max_words(10, "fast") == MIN_SCALED_MAX_WORDS + assert scaled_max_words(1000, "detailed") == MAX_SCALED_MAX_WORDS + + +@pytest.mark.parametrize("value", [None, "", "turbo", 3, "FAST"]) +def test_unknown_values_fall_back_to_the_default(value): + """Reply pacing is a preference; a bad value must never fail a turn.""" + assert normalize_reply_speed(value) == DEFAULT_REPLY_SPEED + + +# ── The settings endpoint ──────────────────────────────────────────────────── + + +def test_reply_speed_is_exposed_by_the_settings_endpoint(client): + body = client.get("/api/runtime/settings").json() + assert "reply_speed" in body["settings"] + assert body["settings"]["reply_speed"] is None + assert body["recommended"]["reply_speed"] == DEFAULT_REPLY_SPEED + + +def test_reply_speed_persists(client): + resp = client.put("/api/runtime/settings", json={"reply_speed": "fast"}) + assert resp.status_code == 200 + assert resp.json()["settings"]["reply_speed"] == "fast" + assert client.get("/api/runtime/settings").json()["settings"]["reply_speed"] == "fast" + + +def test_reply_speed_needs_no_restart(client): + """It is read per turn, so the next message already uses it.""" + resp = client.put("/api/runtime/settings", json={"reply_speed": "detailed"}) + assert resp.json()["requires_restart"] is False + + +def test_unknown_reply_speed_is_rejected(client): + resp = client.put("/api/runtime/settings", json={"reply_speed": "ludicrous"}) + assert resp.status_code == 422 + assert "Reply speed" in resp.text + + +def test_reply_speed_survives_a_partial_update_of_other_fields(client): + client.put("/api/runtime/settings", json={"reply_speed": "fast"}) + client.put("/api/runtime/settings", json={"threads": 4}) + settings = client.get("/api/runtime/settings").json()["settings"] + assert settings["reply_speed"] == "fast" + assert settings["threads"] == 4 + + +def test_reset_clears_reply_speed(client): + client.put("/api/runtime/settings", json={"reply_speed": "fast"}) + body = client.post("/api/runtime/settings/reset").json() + assert body["settings"]["reply_speed"] is None + + +def test_stored_garbage_reads_back_as_unset(client): + """A value a newer build wrote must not surface as an option the UI can't render.""" + from convsim_core.services.reply_speed import REPLY_SPEED_SETTING_KEY + + conn = client.app.state.db.connection() + conn.execute( + "INSERT INTO user_settings (key, value, updated_at) VALUES (?, ?, datetime('now'))", + (REPLY_SPEED_SETTING_KEY, "hypersonic"), + ) + conn.commit() + assert client.get("/api/runtime/settings").json()["settings"]["reply_speed"] is None + + +# ── End-to-end through a real turn ─────────────────────────────────────────── + + +_SETUP = { + "scenario_id": "behavioral_interview", + "difficulty": "standard", + "player_role_name": "Test Player", + "save_transcript": True, + "runtime_id": "fake", +} + + +def _play_one_turn(client) -> dict: + """Create → start → one turn; returns the recorded prompt metadata.""" + session_id = client.post("/api/sessions", json=_SETUP).json()["session_id"] + client.post(f"/api/sessions/{session_id}/start") + resp = client.post(f"/api/sessions/{session_id}/turn", json={"content": "Hello."}) + assert resp.status_code == 200, resp.text + + conn = client.app.state.db.connection() + row = conn.execute( + "SELECT content FROM turn_session_turns " + "WHERE session_id = ? AND role = 'player' ORDER BY turn_number DESC LIMIT 1", + (session_id,), + ).fetchone() + assert row is not None + return {"session_id": session_id} + + +def test_word_cap_reaches_the_prompt(client, monkeypatch): + """The scaled cap must land in the RESPONSE_STYLE layer the model reads.""" + captured: list[str] = [] + + from convsim_core.services import turn_pipeline as tp + + real_compose = tp.compose_turn_prompt + + def _spy(inp): + bundle = real_compose(inp) + captured.append(bundle.system_prompt) + return bundle + + monkeypatch.setattr(tp, "compose_turn_prompt", _spy) + + client.put("/api/runtime/settings", json={"reply_speed": "fast"}) + _play_one_turn(client) + assert captured, "compose_turn_prompt was never called" + assert "NPC utterance must be at most 45 words." in captured[0] + + +def test_balanced_prompt_keeps_the_authored_cap(client, monkeypatch): + captured: list[str] = [] + + from convsim_core.services import turn_pipeline as tp + + real_compose = tp.compose_turn_prompt + + def _spy(inp): + bundle = real_compose(inp) + captured.append(bundle.system_prompt) + return bundle + + monkeypatch.setattr(tp, "compose_turn_prompt", _spy) + + _play_one_turn(client) + assert "NPC utterance must be at most 90 words." in captured[0] + + +def test_token_budget_reaches_the_runtime(client, monkeypatch): + captured: list[int] = [] + + from convsim_core.services import turn_pipeline as tp + + real_collect = tp._collect_runtime_output + + async def _spy(runtime, request): + captured.append(request.max_tokens) + return await real_collect(runtime, request) + + monkeypatch.setattr(tp, "_collect_runtime_output", _spy) + + client.put("/api/runtime/settings", json={"reply_speed": "fast"}) + _play_one_turn(client) + assert captured[0] == REPLY_SPEED_PROFILES["fast"].max_tokens + + +def test_scenario_data_is_not_mutated_across_sessions(client): + """The preference belongs to the player, not to the shared scenario object.""" + from convsim_core.scenarios import resolve_scenario_info + + client.put("/api/runtime/settings", json={"reply_speed": "fast"}) + _play_one_turn(client) + + info = resolve_scenario_info("behavioral_interview") + assert info is not None + data = info.get_scenario_data("standard") + authored = data.response_style.max_words if data.response_style else 90 + assert authored == 90, ( + "process_turn must copy the scenario data before scaling its word cap; " + f"the shared object came back with max_words={authored}" + ) diff --git a/services/convsim-core/tests/test_session_listing.py b/services/convsim-core/tests/test_session_listing.py new file mode 100644 index 00000000..6109e284 --- /dev/null +++ b/services/convsim-core/tests/test_session_listing.py @@ -0,0 +1,123 @@ +# SPDX-License-Identifier: Apache-2.0 +"""GET /api/sessions — the list the resume entry point is built on (issue #501). + +The web client already called this endpoint (``api.listSessions``) and the +shared ``SessionCreateResponse`` type already documented it ("Included in list +responses (GET /api/sessions)"), but the route was never registered: the +request answered 405, so Settings' "Your sessions" list only ever showed an +error and nothing in the app could find a conversation to resume. +""" + +_SETUP = { + "scenario_id": "behavioral_interview", + "difficulty": "standard", + "player_role_name": "Test Player", + "save_transcript": True, + # Explicit model-free pin (issue #473) so no model install is needed. + "runtime_id": "fake", +} + + +def _create(client, **overrides) -> str: + resp = client.post("/api/sessions", json={**_SETUP, **overrides}) + assert resp.status_code == 201, resp.text + return resp.json()["session_id"] + + +def test_list_sessions_returns_200(client): + assert client.get("/api/sessions").status_code == 200 + + +def test_list_sessions_empty_profile(client): + assert client.get("/api/sessions").json() == {"sessions": []} + + +def test_list_sessions_includes_created_session(client): + session_id = _create(client) + sessions = client.get("/api/sessions").json()["sessions"] + assert [s["session_id"] for s in sessions] == [session_id] + entry = sessions[0] + assert entry["scenario_id"] == "behavioral_interview" + assert entry["state"] == "NotStarted" + assert entry["turn_count"] == 0 + assert entry["ending_type"] is None + assert entry["ended_at"] is None + # The setup is what lets the Conversation screen rehydrate a resumed + # session without the route state it was originally launched with. + assert entry["setup"]["player_role_name"] == "Test Player" + + +def test_list_sessions_newest_first(client): + first = _create(client) + second = _create(client) + ids = [s["session_id"] for s in client.get("/api/sessions").json()["sessions"]] + # created_at has one-second resolution, so ordering must not depend on the + # two inserts landing in different seconds. + assert ids == [second, first] + + +def test_in_progress_excludes_unstarted_sessions(client): + """A NotStarted session has nothing to go back to, so it is not resumable.""" + _create(client) + body = client.get("/api/sessions", params={"status": "in_progress"}).json() + assert body["sessions"] == [] + + +def test_in_progress_includes_started_session(client): + session_id = _create(client) + assert client.post(f"/api/sessions/{session_id}/start").status_code == 200 + sessions = client.get("/api/sessions", params={"status": "in_progress"}).json()["sessions"] + assert [s["session_id"] for s in sessions] == [session_id] + assert sessions[0]["state"] == "PlayerTurnListening" + + +def test_in_progress_excludes_ended_session(client): + session_id = _create(client) + client.post(f"/api/sessions/{session_id}/start") + assert client.post(f"/api/sessions/{session_id}/end").status_code == 200 + + in_progress = client.get("/api/sessions", params={"status": "in_progress"}).json() + assert in_progress["sessions"] == [] + + ended = client.get("/api/sessions", params={"status": "ended"}).json()["sessions"] + assert [s["session_id"] for s in ended] == [session_id] + assert ended[0]["ending_type"] == "player_exit" + assert ended[0]["ended_at"] is not None + + +def test_turn_count_reflects_whole_turns(client): + """turn_count counts player+NPC exchanges, not transcript rows (issue #501 §4).""" + session_id = _create(client) + client.post(f"/api/sessions/{session_id}/start") + client.post(f"/api/sessions/{session_id}/turn", json={"content": "Hello there."}) + + sessions = client.get("/api/sessions").json()["sessions"] + assert sessions[0]["turn_count"] == 1 + + rows = client.get(f"/api/sessions/{session_id}/transcript").json()["turns"] + # One opening + one player row + one NPC row = three rows for one turn. + assert len(rows) == 3 + + +def test_limit_caps_the_result(client): + for _ in range(3): + _create(client) + sessions = client.get("/api/sessions", params={"limit": 2}).json()["sessions"] + assert len(sessions) == 2 + + +def test_limit_out_of_range_is_rejected(client): + assert client.get("/api/sessions", params={"limit": 0}).status_code == 400 + assert client.get("/api/sessions", params={"limit": 501}).status_code == 400 + + +def test_unknown_status_is_rejected(client): + assert client.get("/api/sessions", params={"status": "nonsense"}).status_code == 422 + + +def test_get_single_session_still_carries_setup(client): + """Resume after a relaunch reads the setup back from this endpoint.""" + session_id = _create(client, show_state_meters=True, language="en") + body = client.get(f"/api/sessions/{session_id}").json() + assert body["setup"]["show_state_meters"] is True + assert body["setup"]["language"] == "en" From 3a967a3ece518856b36c0dc201dc765085827f4a Mon Sep 17 00:00:00 2001 From: Nick Charney Kaye Date: Sat, 3 Oct 2026 03:35:19 -0400 Subject: [PATCH 02/37] Make a session resumable and put the first session in plain language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three UI halves of issue #501: a way back into a conversation, a buffer between player-facing and technical language, and meters that explain themselves. All three are the same report — the app "feels both too sparse and too technical in the first few sessions". Resume. A resume strip lives in the app chrome, so it is on every screen the detour that lost the playtester could reach — including Settings, the screen they were standing on when they gave up and started a new scenario. Home's primary actions lead with it when there is one, and Settings → Your sessions grows a Resume button per unfinished session, so the banner's "the newest one" is not the only answer. The Conversation screen now reads its setup back from convsim-core when it arrives with no router state, which is exactly the resume case: navigating away and back, a reload, and a relaunch all used to fall through to defaults and drop the scene card, the chosen language and the voice settings the player picked. TTS enablement moved behind a ref because the WebSocket handler is installed once per session and would otherwise capture the pre-fetch value. Reply speed. Settings grows a named "Reply speed" section — Quick replies / Balanced / Fuller replies, each with a two- or three-word descriptor, the pattern the note asked for. It sits above the engine knobs rather than behind the advanced toggle, is anchored so the conversation screen's slow-reply notice can link straight to it ("Make replies faster →" instead of the old "you can adjust settings"), and says plainly that a smaller model is a bigger lever. Wording. A two-level buffer, `plain` by default, set by an LLM-familiarity question on the first-run Welcome step and revisitable from a card on the tutorial debrief and from Settings → Wording. Two levels, not a scale: a third intermediate level would need a different word per string with no rule saying which words belong to it. At `plain` the conversation screen drops the session id, the raw flow-state identifier, the per-turn `Flags:` line and the running `Event flags:` strip, says "Something changed: Rapport milestone" instead of "Scenario event: rapport_milestone", and names variables as words. At `technical` every identifier comes back, because that is what a bug report needs. The NPC also has a name now: `npc_name` resolves through the scenario's `npc.ref` and travels with the scenario, so the transcript says "Dana Reyes" rather than "NPC" — the most technical word on that screen, and only there because the name was never exposed. Meters. Moved above the transcript, where the tutorial's opening line has always claimed they are ("Notice the two meters at the top") and where they were not; out of a collapsed "NPC state variables" disclosure and into a labelled "Conversation meters" panel that says in one line that they are not moods. Each meter now draws the tick the tutorial talks about — "▲ +10 this turn" — derived by diffing successive snapshots rather than from the model's requested delta, so a delta the reducer clamped or rejected cannot claim credit for a move that did not happen. The NPC's emotion is gone from the parenthetical beside the turn number and from its adjacency to the bars; it reads "Mood: warm" under the NPC panel and under each reply, which is the whole of the "unclear which meter (if any) the emotions in parentheses are" complaint. Turn counting. One turn is the player's message plus the reply to it, so both rows carry the same number and the NPC's opening is labelled "Opening" rather than consuming turn 1. This is the number convsim-core already counts in turn_count, that duration.max_turns is measured in, and that debrief key moments point at — so the debrief's "#3" and the transcript's "Turn 3" now mean the same moment, and its "go to turn N" button lands on the start of turn N instead of on whichever half-turn was Nth in the list. No pack changes: max_turns was already whole turns. Tests: existing expectations updated to the new behaviour (both wording levels asserted side by side), plus new coverage for whole-turn labelling of an exchange, rollback not consuming a turn number, meter movement, humanized vs raw variable keys, meter placement above the transcript, and the npc_name resolution for every seeded scenario. Full web suite and the convsim-core scenario suites pass. Co-Authored-By: Claude Opus 5 --- apps/web/src/__tests__/App.test.tsx | 4 +- apps/web/src/__tests__/Conversation.test.tsx | 189 ++++++++- .../__tests__/RuntimeSettingsPanel.test.tsx | 5 +- apps/web/src/__tests__/Settings.test.tsx | 5 + apps/web/src/__tests__/accessibility.test.tsx | 14 +- apps/web/src/api/client.ts | 22 +- .../src/components/FamiliarityQuestion.tsx | 93 +++++ .../src/components/FamiliarityReaskCard.tsx | 90 ++++ apps/web/src/components/ReplySpeedPanel.tsx | 155 +++++++ .../src/components/ResumeSessionBanner.tsx | 106 +++++ .../src/components/RuntimeSettingsPanel.tsx | 19 +- apps/web/src/components/StateMeters.tsx | 140 +++++++ apps/web/src/components/WordingPanel.tsx | 68 +++ apps/web/src/hooks/useResumableSession.ts | 75 ++++ apps/web/src/hooks/useUiLanguageLevel.ts | 47 +++ apps/web/src/i18n/locales/de.ts | 81 +++- apps/web/src/i18n/locales/en.ts | 86 +++- apps/web/src/layout/AppLayout.tsx | 5 + apps/web/src/lib/plainLanguage.ts | 179 ++++++++ apps/web/src/lib/wholeTurns.ts | 57 +++ apps/web/src/screens/Conversation.tsx | 390 ++++++++++++------ apps/web/src/screens/Debrief.tsx | 102 +++-- apps/web/src/screens/Home.tsx | 16 + apps/web/src/screens/Settings.tsx | 89 +++- apps/web/src/setup/steps/WelcomeStep.tsx | 15 + packages/shared/src/types/models.ts | 6 + packages/shared/src/types/scenario.ts | 2 + packages/shared/src/types/session.ts | 10 +- .../convsim-core/convsim_core/packs/models.py | 5 + .../storage/repositories/scenario_repo.py | 47 ++- .../convsim-core/tests/test_scenarios_api.py | 47 +++ 31 files changed, 1968 insertions(+), 201 deletions(-) create mode 100644 apps/web/src/components/FamiliarityQuestion.tsx create mode 100644 apps/web/src/components/FamiliarityReaskCard.tsx create mode 100644 apps/web/src/components/ReplySpeedPanel.tsx create mode 100644 apps/web/src/components/ResumeSessionBanner.tsx create mode 100644 apps/web/src/components/StateMeters.tsx create mode 100644 apps/web/src/components/WordingPanel.tsx create mode 100644 apps/web/src/hooks/useResumableSession.ts create mode 100644 apps/web/src/hooks/useUiLanguageLevel.ts create mode 100644 apps/web/src/lib/plainLanguage.ts create mode 100644 apps/web/src/lib/wholeTurns.ts diff --git a/apps/web/src/__tests__/App.test.tsx b/apps/web/src/__tests__/App.test.tsx index 6b3471ab..11ccd55f 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', () => { diff --git a/apps/web/src/__tests__/Conversation.test.tsx b/apps/web/src/__tests__/Conversation.test.tsx index 99d3504f..5f1a0a20 100644 --- a/apps/web/src/__tests__/Conversation.test.tsx +++ b/apps/web/src/__tests__/Conversation.test.tsx @@ -23,6 +23,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(), @@ -127,9 +130,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' } }) }) @@ -157,10 +171,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 () => { @@ -396,8 +425,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.' }, @@ -407,19 +436,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 () => { @@ -561,15 +616,81 @@ 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('hides state variables when show_state_meters is false', async () => { @@ -606,7 +727,39 @@ 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('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 () => { @@ -1527,9 +1680,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__/RuntimeSettingsPanel.test.tsx b/apps/web/src/__tests__/RuntimeSettingsPanel.test.tsx index f97ae5db..986fa52a 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, @@ -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() @@ -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 9bb493dd..ee2ded97 100644 --- a/apps/web/src/__tests__/Settings.test.tsx +++ b/apps/web/src/__tests__/Settings.test.tsx @@ -16,6 +16,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(), @@ -75,6 +77,7 @@ const STUB_RUNTIME_SETTINGS = { temperature: null, top_p: null, repeat_penalty: null, + reply_speed: null, }, recommended: { context_length: null, @@ -83,6 +86,7 @@ const STUB_RUNTIME_SETTINGS = { temperature: null, top_p: null, repeat_penalty: null, + reply_speed: 'balanced' as const, }, requires_restart: false, } @@ -161,6 +165,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: [] } }) diff --git a/apps/web/src/__tests__/accessibility.test.tsx b/apps/web/src/__tests__/accessibility.test.tsx index 2060b4ef..539cfdf1 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' @@ -480,9 +480,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) }) @@ -496,7 +498,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() }) @@ -563,7 +565,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/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/components/FamiliarityQuestion.tsx b/apps/web/src/components/FamiliarityQuestion.tsx new file mode 100644 index 00000000..151a99ae --- /dev/null +++ b/apps/web/src/components/FamiliarityQuestion.tsx @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: Apache-2.0 +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() + const current = 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/ReplySpeedPanel.tsx b/apps/web/src/components/ReplySpeedPanel.tsx new file mode 100644 index 00000000..0ca02cf2 --- /dev/null +++ b/apps/web/src/components/ReplySpeedPanel.tsx @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: Apache-2.0 +import { useCallback, useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import { api } from '../api/client' +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. + */ +const OPTIONS: ReplySpeed[] = ['fast', 'balanced', 'detailed'] + +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 ( +
+
+ {OPTIONS.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..457a4702 --- /dev/null +++ b/apps/web/src/components/ResumeSessionBanner.tsx @@ -0,0 +1,106 @@ +// 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. + void r + refetch() + } + + return ( +
+ + {t('resume.label')}{' '} + {turns > 0 + ? t('resume.withTurns', { title, count: turns }) + : t('resume.withoutTurns', { title })} + + + +
+ ) +} diff --git a/apps/web/src/components/RuntimeSettingsPanel.tsx b/apps/web/src/components/RuntimeSettingsPanel.tsx index ffddb41d..94a69d43 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 } 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..b00662e2 100644 --- a/apps/web/src/i18n/locales/de.ts +++ b/apps/web/src/i18n/locales/de.ts @@ -46,7 +46,15 @@ export const de: LocaleMessages = { }, // Demo-Edition (Steam Next Fest Demo, Issue #495). demo: { - home: { + // "Gespräch läuft" (Issue #501 §1). + resume: { + label: 'Gespräch läuft.', + withTurns: '{{title}} — bisher {{count}} Züge.', + withoutTurns: '{{title}} — noch nicht begonnen.', + action: 'Fortsetzen', + dismiss: 'Beenden', + }, + home: { title: 'Gesprächssimulator — Demo', tagline: 'Fünf echte Gespräche. Ein KI-Modell, das vollständig auf Ihrem Computer läuft.', noModel: { @@ -87,7 +95,28 @@ export const de: LocaleMessages = { debrief: { backHome: '← Zurück zu den Gesprächen', }, - setup: { + 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.', + }, + setup: { setMeUpDescription: 'Lädt das KI-Modell der Demo herunter ({{size}} GB, {{license}}). Funktioniert danach offline.', badge: 'Demo', @@ -106,6 +135,7 @@ export const de: LocaleMessages = { tagline: 'Üben Sie Vorstellungsgespräche, Verhandlungen, Sprachen und schwierige Gespräche.', primaryActions: 'Hauptaktionen', + resumeScenario: 'Gespräch fortsetzen', yourTraining: 'Ihr Training', training: { loading: 'Wird geladen…', @@ -236,10 +266,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 +384,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 +543,7 @@ export const de: LocaleMessages = { transcriptOnlyNotice: 'Nachbesprechungserstellung fehlgeschlagen. Zeige nur Transkript.', turn: 'Zug {{number}}', + opening: 'Eröffnung', 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..da7bdaf5 100644 --- a/apps/web/src/i18n/locales/en.ts +++ b/apps/web/src/i18n/locales/en.ts @@ -41,7 +41,15 @@ export const en = { }, // Demo edition (Steam Next Fest demo, issue #495). demo: { - home: { + // "You have a conversation in progress" (issue #501 §1). + resume: { + label: 'Conversation in progress.', + withTurns: '{{title}} — {{count}} turns so far.', + withoutTurns: '{{title}} — not started yet.', + action: 'Resume', + dismiss: 'End it', + }, + home: { title: 'Conversation Simulator — Demo', tagline: 'Five real conversations. One AI model, running entirely on your computer.', noModel: { @@ -82,7 +90,30 @@ export const en = { debrief: { backHome: '← Back to conversations', }, - setup: { + // 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.', + }, + setup: { setMeUpDescription: 'Downloads the demo\'s AI model ({{size}} GB, {{license}}). Works offline afterwards.', badge: 'Demo', @@ -100,6 +131,7 @@ export const en = { title: 'Conversation Simulator', tagline: 'Practice interviews, negotiations, language, and difficult conversations.', primaryActions: 'Primary actions', + resumeScenario: 'Resume your conversation', yourTraining: 'Your training', training: { loading: 'Loading…', @@ -227,10 +259,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 +380,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 +538,7 @@ export const en = { ariaLabel: 'Conversation transcript', transcriptOnlyNotice: 'Debrief generation failed. Showing transcript only.', turn: 'Turn {{number}}', + opening: 'Opening', 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. */} + +
, +): 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/screens/Conversation.tsx b/apps/web/src/screens/Conversation.tsx index 19216508..7e462701 100644 --- a/apps/web/src/screens/Conversation.tsx +++ b/apps/web/src/screens/Conversation.tsx @@ -1,9 +1,15 @@ // SPDX-License-Identifier: Apache-2.0 import { useEffect, useRef, useState } from 'react' import type { CSSProperties } 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' @@ -14,6 +20,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 @@ -65,12 +74,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' @@ -120,11 +154,15 @@ function formatElapsed(ms: number): string { return `${minutes}m ${String(seconds).padStart(2, '0')}s` } -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 '' } @@ -143,11 +181,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() @@ -158,6 +208,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()) @@ -184,6 +242,8 @@ 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) + // Last meter snapshot, for computing what the latest turn moved. + const prevStateVarsRef = useRef | null>(null) // TTS audio queue — plays synthesized sentence chunks in order. const ttsQueueRef = useRef([]) @@ -192,10 +252,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') @@ -206,6 +276,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(() => { @@ -248,20 +319,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() { @@ -292,7 +382,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}` @@ -353,7 +443,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 @@ -367,15 +457,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(() => { @@ -418,13 +522,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) { @@ -484,6 +592,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, { @@ -492,7 +601,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) { @@ -516,7 +627,7 @@ export default function Conversation() { { id: ++bannerUidRef.current, kind: 'event', - text: event.payload.flags.join(' · '), + flags: event.payload.flags, }, ]) } @@ -555,6 +666,20 @@ export default function Conversation() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [sessionId, recordInterval]) + // Recompute the per-turn meter movement whenever the meters change. + useEffect(() => { + const previous = prevStateVarsRef.current + prevStateVarsRef.current = stateVars + // The first snapshot is where the meters start, not a change from anything. + if (previous === null) return + const moved: Record = {} + for (const [key, value] of Object.entries(stateVars)) { + const was = previous[key] + if (was !== undefined && was !== value) moved[key] = value - was + } + setStateDeltas(moved) + }, [stateVars]) + // Auto-scroll transcript useEffect(() => { const el = transcriptRef.current @@ -594,9 +719,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) @@ -644,7 +769,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. @@ -684,7 +809,10 @@ 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 + rowCountRef.current += 1 setTurns((prev) => [ ...prev, { @@ -776,7 +904,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 @@ -802,6 +931,7 @@ export default function Conversation() { setNpcEmotion(emotion ?? null) const uid = ++turnUidRef.current + rowCountRef.current += 1 setTurns((prev) => [ ...prev, { @@ -810,7 +940,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, }, ]) @@ -889,7 +1020,11 @@ export default function Conversation() { const isVerySlowResponse = phase === 'submitting' && waitElapsedMs >= VERY_SLOW_RESPONSE_MS const announcedElapsedMs = Math.floor(waitElapsedMs / ELAPSED_ANNOUNCE_INTERVAL_MS) * ELAPSED_ANNOUNCE_INTERVAL_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)}) )}

@@ -956,18 +1102,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; @@ -1034,8 +1182,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}
- {phase === 'submitting' ? 'NPC is responding…' : 'Ending session…'} + {phase === 'submitting' ? `${npcLabel} is responding…` : 'Ending session…'}
)} @@ -1175,9 +1353,26 @@ export default function Conversation() { fontSize: '0.8rem', }} > - {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 in the first place. Resuming is one + click back, via the banner in the app chrome. */} + + Make replies faster → + + + )} {isVerySlowResponse && ( // Past half a minute a static line reads as a hung app. Naming the // elapsed time shows the app is still waiting on the model rather @@ -1204,73 +1399,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' && ( +
+ Mood: {emotion} +
+ )} ) } diff --git a/apps/web/src/screens/Home.tsx b/apps/web/src/screens/Home.tsx index efd5ee86..6b4ad3ca 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/Settings.tsx b/apps/web/src/screens/Settings.tsx index 1c83be6b..73180b5b 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,8 +8,13 @@ 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 { plainFlowState } from '../lib/plainLanguage' import { RemediationCard } from '../setup/RemediationCard' import { openExternal } from '../lib/openExternal' import { useIsDemo } from '../edition' @@ -25,6 +30,16 @@ interface SessionSummary { created_at: string } +/** Flow states a session can be picked back up from (mirrors + * RESUMABLE_FLOW_STATES in convsim-core's sessions router). */ +const RESUMABLE_STATES = new Set([ + 'PlayerTurnListening', + 'PlayerTurnReview', + 'NpcThinking', + 'NpcSpeaking', + 'ScenarioEvent', +]) + interface PrivacyToggleProps { id: string label: string @@ -90,6 +105,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, @@ -129,6 +148,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) @@ -434,6 +465,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 && (
@@ -824,7 +881,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)} @@ -835,10 +895,33 @@ export default function Settings() { color: s.state === 'Ended' ? '#86efac' : '#fbbf24', }} > - {s.state} + {isPlain ? plainFlowState(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. */} + {RESUMABLE_STATES.has(s.state) && ( + + )}
+ {/* 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 */}
From 39c94fc06830c34ce6c1743759146bf65b0ed178 Mon Sep 17 00:00:00 2001 From: Nick Charney Kaye Date: Sat, 3 Oct 2026 09:46:47 -0400 Subject: [PATCH 26/37] Honour status and limit in the TypeScript session listing too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `api.listSessions` now sends `?status=in_progress&limit=1` and the resume affordances offer whatever comes back as "Conversation in progress" (issue #501 §1). convsim-core implements that filter; apps/api's sibling `GET /api/sessions` ignored both parameters and returned every session newest first — so behind that layer the banner, Home's "Resume your conversation" and the Conversation screen's setup re-fetch would all point at the most recent session whether or not it had ended, which is worse than having no resume entry point at all. Same contract as convsim-core: `status` of all / in_progress / ended, the ended set covering DebriefGenerating, DebriefReady and Error rather than only Ended, `limit` defaulting to 50 and 400 outside 1-500. The state lists are mirrored with a comment naming their counterparts, since the two implementations have to agree. reply_speed was already synced into this layer; this is the other half of the same contract. Co-Authored-By: Claude Opus 5 --- apps/api/src/routes/sessions.test.ts | 56 +++++++++++++++++++++++++ apps/api/src/routes/sessions.ts | 61 +++++++++++++++++++++++++--- 2 files changed, 112 insertions(+), 5 deletions(-) diff --git a/apps/api/src/routes/sessions.test.ts b/apps/api/src/routes/sessions.test.ts index cafc4792..2f53d90c 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, + ); + }); }); // --------------------------------------------------------------------------- diff --git a/apps/api/src/routes/sessions.ts b/apps/api/src/routes/sessions.ts index 7cfc05f0..9c8ce95d 100644 --- a/apps/api/src/routes/sessions.ts +++ b/apps/api/src/routes/sessions.ts @@ -161,18 +161,68 @@ function rejectTransition(reply: { status: (code: number) => void }, state: Sess throw err; } +/** States a session can be picked back up from — the resumable set + * (issue #501 §1). Mirrors RESUMABLE_FLOW_STATES in convsim-core's sessions + * router. `NotStarted` is excluded on purpose: nothing has been said yet, so + * there is no conversation to resume. */ +const RESUMABLE_STATES: SessionState[] = [ + 'PlayerTurnListening', + 'PlayerTurnReview', + 'NpcThinking', + 'NpcSpeaking', + 'ScenarioEvent', +]; + +/** States a session has finished in. `Ended` is only the first: generating the + * debrief moves the row to `DebriefGenerating` and then `DebriefReady`, and a + * failed debrief leaves `Error`. Mirrors ENDED_FLOW_STATES in convsim-core. */ +const ENDED_STATES: SessionState[] = [ + 'Ended', + 'DebriefGenerating', + 'DebriefReady', + 'Error', +]; + 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_STATES : status === 'ended' ? ENDED_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 +235,8 @@ export async function sessionRoutes(app: FastifyInstance) { setup: JSON.parse(row.setup_json) as SessionCreateRequest, })), }; - }); + }, + ); // POST /api/sessions app.post<{ Body: SessionCreateRequest }>( From c42abda40c97eecaf46a73fe0a523070ef4dab69 Mon Sep 17 00:00:00 2001 From: Nick Charney Kaye Date: Sat, 3 Oct 2026 09:51:17 -0400 Subject: [PATCH 27/37] Show the meters the tutorial promises, by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The First Words tutorial opens with "Above this conversation you'll see two meters: Engagement ... and Confidence ...", spends three of its six turns on them, and now answers "what are the meters?" with "the two labelled bars above the conversation". On default settings there were none: the Conversation Brief's meters toggle was the one field on that screen not derived from the scenario — difficulty, role name, language, voice and input mode all come from the scenario and the runtime, while show_state_meters sat at a hardcoded false and was only ever forced *off* again for scenarios that forbid meters. So the playtester's "default settings" first session was the tutorial narrating, for six turns, a panel that was switched off — which is the sharpest reading of issue #501 §3's "generally confused about meters and where they appear", and the one thing relocating, renaming and re-visualizing the panel could not fix. Now on wherever the scenario permits them (two player-visible variables in the tutorial's case), still forced off where it does not, and still one click away either way. The player checklist's P-M6 asked the tester to enable the toggle; it now asks them to confirm it is already on. Co-Authored-By: Claude Opus 5 --- apps/web/src/pages/ScenarioSetup.test.tsx | 17 +++++++++++++++-- apps/web/src/pages/ScenarioSetup.tsx | 12 +++++++++++- docs/acceptance/player-checklist.md | 6 +++++- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/apps/web/src/pages/ScenarioSetup.test.tsx b/apps/web/src/pages/ScenarioSetup.test.tsx index 7af2f303..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, }); @@ -634,6 +635,18 @@ describe('ScenarioSetupPage', () => { ).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 the meters toggle and says so when the scenario does not permit them', async () => { mockApi.getScenario.mockResolvedValue({ ok: true as const, data: { ...mockScenario, diff --git a/apps/web/src/pages/ScenarioSetup.tsx b/apps/web/src/pages/ScenarioSetup.tsx index 093d043a..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), } }) })(); diff --git a/docs/acceptance/player-checklist.md b/docs/acceptance/player-checklist.md index f2e15956..8243fcac 100644 --- a/docs/acceptance/player-checklist.md +++ b/docs/acceptance/player-checklist.md @@ -89,11 +89,15 @@ Start the selected scenario. ### P-M6 — Conversation meters (optional) -On the Conversation Brief, before starting, enable **Show the conversation +On the Conversation Brief, before starting, check **Show the conversation meters**. It is only offered for scenarios that permit them; a scenario that does not says so instead ("The conversation meters are hidden in this scenario, to keep it realistic"), so pick one that does. +- [ ] The toggle is already **on** without touching it — scenarios that permit + meters show them by default, because the scenarios that talk about them + (the First Words tutorial opens with "Above this conversation you'll see + two meters") have to be telling the truth - [ ] A panel headed **Conversation meters** is visible *above* the transcript — the toggle and the panel use the same name - [ ] One bar per visible variable, each labelled in everyday words rather than From 997898ff8f7dcaff5bc0479f3711fb9032eeef92 Mon Sep 17 00:00:00 2001 From: Nick Charney Kaye Date: Sat, 3 Oct 2026 10:46:46 -0400 Subject: [PATCH 28/37] Translate the plain session states on the Settings screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings is an i18n-migrated screen, but the plain wording level rendered `plainFlowState(s.state)` straight into the session list — hardcoded English prose. A German player on plain wording (the default) read "Your turn" and "Finished" in an otherwise German UI, which is the untranslated-string problem the i18n framework exists to prevent, introduced by the fix for issue #501 §2. The fourteen labels move out of a switch into FLOW_STATE_LABELS, whose keys double as locale keys under `sessionStates.`, so the catalogs and the English copy cannot say different things. Settings now renders `t(flowStateLabelKey(s.state))`; `plainFlowState` keeps returning English for the conversation screen, which is not migrated yet. An unmapped state maps to `sessionStates.unknown` rather than to a missing key, which the translator would have rendered as the literal key path. Co-Authored-By: Claude Opus 5 --- apps/web/src/__tests__/plainLanguage.test.ts | 36 ++++++++++ apps/web/src/i18n/locales/de.ts | 17 +++++ apps/web/src/i18n/locales/en.ts | 22 ++++++ apps/web/src/lib/plainLanguage.ts | 75 +++++++++++--------- apps/web/src/screens/Settings.tsx | 4 +- 5 files changed, 120 insertions(+), 34 deletions(-) diff --git a/apps/web/src/__tests__/plainLanguage.test.ts b/apps/web/src/__tests__/plainLanguage.test.ts index ac2da995..929a93da 100644 --- a/apps/web/src/__tests__/plainLanguage.test.ts +++ b/apps/web/src/__tests__/plainLanguage.test.ts @@ -2,11 +2,13 @@ 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, @@ -15,6 +17,8 @@ import { 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(() => { @@ -114,3 +118,35 @@ describe('humanizers', () => { 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/i18n/locales/de.ts b/apps/web/src/i18n/locales/de.ts index 84dc99ce..e13c75b7 100644 --- a/apps/web/src/i18n/locales/de.ts +++ b/apps/web/src/i18n/locales/de.ts @@ -131,6 +131,23 @@ export const de: LocaleMessages = { 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: diff --git a/apps/web/src/i18n/locales/en.ts b/apps/web/src/i18n/locales/en.ts index df4345e4..f7430ef2 100644 --- a/apps/web/src/i18n/locales/en.ts +++ b/apps/web/src/i18n/locales/en.ts @@ -128,6 +128,28 @@ export const en = { 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.', diff --git a/apps/web/src/lib/plainLanguage.ts b/apps/web/src/lib/plainLanguage.ts index 450f6ff5..637cf46a 100644 --- a/apps/web/src/lib/plainLanguage.ts +++ b/apps/web/src/lib/plainLanguage.ts @@ -132,45 +132,56 @@ export function humanizeFlags(flags: readonly string[]): string { return flags.map(humanizeFlag).join(' · ') } +/** + * Plain-language label per flow state, in English. + * + * The keys double as locale keys under `sessionStates.` in the i18n catalogs, + * so a screen that has been migrated to `t()` can translate the same label this + * table spells in English — see `flowStateLabelKey`. `unknown` is the label for + * a state this build does not know; an unmapped state is a bug, not a thing to + * print raw at a player. + */ +export const FLOW_STATE_LABELS: Record = { + 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 { - switch (state) { - case 'NotStarted': - return 'Not started yet' - case 'LoadingModel': - case 'LoadingScenario': - return 'Getting ready' - case 'Briefing': - return 'Briefing' - case 'NpcOpening': - return 'Opening the conversation' - case 'PlayerTurnListening': - return 'Your turn' - case 'PlayerTurnReview': - return 'Check your message' - case 'NpcThinking': - return 'Writing a reply' - case 'NpcSpeaking': - return 'Speaking' - case 'ScenarioEvent': - return 'Something is changing' - case 'DebriefGenerating': - return 'Preparing your debrief' - case 'DebriefReady': - return 'Debrief ready' - case 'Ended': - return 'Finished' - case 'Error': - return 'Something went wrong' - default: - // An unmapped state is a bug, not a thing to print raw at a player. - return 'In progress' - } + 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. */ diff --git a/apps/web/src/screens/Settings.tsx b/apps/web/src/screens/Settings.tsx index cbbf7751..9f67cfdd 100644 --- a/apps/web/src/screens/Settings.tsx +++ b/apps/web/src/screens/Settings.tsx @@ -14,7 +14,7 @@ import VoiceSettingsPanel from '../components/VoiceSettingsPanel' import { useTranslation, formatDate, SUPPORTED_LOCALES } from '../i18n' import { useScenarios } from '../api/useScenarios' import { useUiLanguageLevel } from '../hooks/useUiLanguageLevel' -import { plainFlowState } from '../lib/plainLanguage' +import { flowStateLabelKey } from '../lib/plainLanguage' import { RemediationCard } from '../setup/RemediationCard' import { openExternal } from '../lib/openExternal' import { clearTurnSamples } from '../lib/turnEstimate' @@ -900,7 +900,7 @@ export default function Settings() { color: s.state === 'Ended' ? '#86efac' : '#fbbf24', }} > - {isPlain ? plainFlowState(s.state) : s.state} + {isPlain ? t(flowStateLabelKey(s.state)) : s.state}
From 223bac0dbb0c31f6516deb9f2c01c6a015dc2c48 Mon Sep 17 00:00:00 2001 From: Nick Charney Kaye Date: Sat, 3 Oct 2026 10:46:52 -0400 Subject: [PATCH 29/37] Report the resumed conversation's meters from the TypeScript proxy too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resume path reads a session's current meter values back from GET /api/sessions/{id}, because meter values otherwise only ever arrive with a turn and a player who stepped out to Settings came back to a conversation with no meters at all. convsim-core reports them as `visible_state`; apps/api's sibling route did not, so against the TypeScript layer the same resume still drew an empty meter panel until the next message. This layer does not model per-variable visibility — its turn route already broadcasts `state_vars` wholesale — so every tracked variable is reported. Co-Authored-By: Claude Opus 5 --- apps/api/src/routes/sessions.test.ts | 23 +++++++++++++++++++++++ apps/api/src/routes/sessions.ts | 7 +++++++ 2 files changed, 30 insertions(+) diff --git a/apps/api/src/routes/sessions.test.ts b/apps/api/src/routes/sessions.test.ts index 2f53d90c..ff66b8f9 100644 --- a/apps/api/src/routes/sessions.test.ts +++ b/apps/api/src/routes/sessions.test.ts @@ -328,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 9c8ce95d..e6dc478e 100644 --- a/apps/api/src/routes/sessions.ts +++ b/apps/api/src/routes/sessions.ts @@ -350,6 +350,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, }; }, ); From 0051765c988103a1c0c0bd94e3a6e4aaca3ff63b Mon Sep 17 00:00:00 2001 From: Nick Charney Kaye Date: Sat, 3 Oct 2026 11:09:03 -0400 Subject: [PATCH 30/37] Say "engine" instead of "runtime" on the two controls a player sees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #501 §2 names 'runtime' as one of four words "the average user might not understand", and its grounding bullet quotes the Settings toggle by name. This branch renamed the section heading from "Runtime" to "AI engine" and left both of the buttons under it saying "runtime": - RuntimeSettingsPanel's disclosure read "Show runtime advanced settings". It is visible on Settings without expanding anything, so the word reached the player before any deliberate peek. Now "Show advanced engine settings"; the notes inside the disclosure stay in engine terms, which is the "deliberately peek into the settings" the issue asks for. - PerformanceWarningBanner's button read "Runtime Settings" — on the conversation screen, in the slow-reply situation §1 is about, directly above the plain-language "Make replies faster →" link this branch added. Now "Open settings", same destination. Settings.test.tsx matched its own privacy disclosure with /show advanced/i, which the new engine label also matches; both selectors are now the exact accessible name. Co-Authored-By: Claude Opus 5 --- .../src/__tests__/PerformanceWarning.test.tsx | 8 +++---- .../__tests__/RuntimeSettingsPanel.test.tsx | 24 +++++++++---------- apps/web/src/__tests__/Settings.test.tsx | 22 ++++++++--------- .../web/src/components/PerformanceWarning.tsx | 8 +++++-- .../src/components/RuntimeSettingsPanel.tsx | 15 ++++++++---- 5 files changed, 43 insertions(+), 34 deletions(-) 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__/RuntimeSettingsPanel.test.tsx b/apps/web/src/__tests__/RuntimeSettingsPanel.test.tsx index 986fa52a..ecdd7da4 100644 --- a/apps/web/src/__tests__/RuntimeSettingsPanel.test.tsx +++ b/apps/web/src/__tests__/RuntimeSettingsPanel.test.tsx @@ -131,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) @@ -146,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() }) }) @@ -180,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() }) }) @@ -361,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(), ) @@ -375,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() @@ -389,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 })) } @@ -438,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 })) } @@ -542,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 })) } @@ -584,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 })) } diff --git a/apps/web/src/__tests__/Settings.test.tsx b/apps/web/src/__tests__/Settings.test.tsx index 11cd5198..3d431015 100644 --- a/apps/web/src/__tests__/Settings.test.tsx +++ b/apps/web/src/__tests__/Settings.test.tsx @@ -874,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 }), @@ -884,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(() => @@ -901,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() @@ -928,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 }), @@ -938,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(() => @@ -955,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') @@ -964,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() }) @@ -1100,7 +1100,7 @@ describe('demo edition', () => { 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 () => { 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/RuntimeSettingsPanel.tsx b/apps/web/src/components/RuntimeSettingsPanel.tsx index 94a69d43..07921149 100644 --- a/apps/web/src/components/RuntimeSettingsPanel.tsx +++ b/apps/web/src/components/RuntimeSettingsPanel.tsx @@ -489,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 && ( )} From b3a7f8f071011163bb8b3c186db3156e64b90be9 Mon Sep 17 00:00:00 2001 From: Nick Charney Kaye Date: Sat, 3 Oct 2026 11:10:17 -0400 Subject: [PATCH 31/37] Show the debrief the resume strip's "End it" just produced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ending a conversation produces a debrief — that is what the conversation screen's End session button leads to. Ending from the resume strip left the player on whatever screen they were on, with the strip simply gone: the session had moved to DebriefGenerating and the debrief it earned was reachable only by typing its URL. That is the same dead end issue #501 is about, in the other direction. A successful end now navigates to that session's debrief; a failed one still just re-asks, so nothing claims a debrief that was never generated. Co-Authored-By: Claude Opus 5 --- .../__tests__/ResumeSessionBanner.test.tsx | 47 +++++++++++++++++++ .../src/components/ResumeSessionBanner.tsx | 7 ++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/apps/web/src/__tests__/ResumeSessionBanner.test.tsx b/apps/web/src/__tests__/ResumeSessionBanner.test.tsx index 1cf4a07c..fcf2ab8e 100644 --- a/apps/web/src/__tests__/ResumeSessionBanner.test.tsx +++ b/apps/web/src/__tests__/ResumeSessionBanner.test.tsx @@ -175,6 +175,53 @@ describe('ResumeSessionBanner', () => { 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] } }) diff --git a/apps/web/src/components/ResumeSessionBanner.tsx b/apps/web/src/components/ResumeSessionBanner.tsx index da35847c..8582b503 100644 --- a/apps/web/src/components/ResumeSessionBanner.tsx +++ b/apps/web/src/components/ResumeSessionBanner.tsx @@ -45,8 +45,13 @@ export default function ResumeSessionBanner() { 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. - void r 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 ( From 67a6cfd93f8b3095a5b8267a28b95a2022101331 Mon Sep 17 00:00:00 2001 From: Nick Charney Kaye Date: Sat, 3 Oct 2026 11:26:26 -0400 Subject: [PATCH 32/37] Keep numbering whole turns when a resumed session saves no transcript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transcript endpoint answers a session started with transcript saving off with no rows, always. The resume path only seeded its whole-turn counter from those rows, so resuming such a session restarted the labels at "Turn 1" over a session convsim-core was several turns into — the debrief-says-4 / transcript-says-1 mismatch of issue #501 §4, back on the very screen resume was added for. The session read the resume path already makes for the meters carries `turn_count`, which is the same whole-turn number the labels, max_turns and the debrief all use. Seed from it when there were no rows to count, and only while the player has not already sent a message, so a slow read cannot renumber a turn in flight. Co-Authored-By: Claude Opus 5 --- apps/web/src/__tests__/Conversation.test.tsx | 52 ++++++++++++++++++++ apps/web/src/screens/Conversation.tsx | 22 +++++++-- 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/apps/web/src/__tests__/Conversation.test.tsx b/apps/web/src/__tests__/Conversation.test.tsx index 2a2122db..80a61297 100644 --- a/apps/web/src/__tests__/Conversation.test.tsx +++ b/apps/web/src/__tests__/Conversation.test.tsx @@ -300,6 +300,58 @@ describe('Conversation screen', () => { // 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', () => { diff --git a/apps/web/src/screens/Conversation.tsx b/apps/web/src/screens/Conversation.tsx index 3ff70477..ea350dfb 100644 --- a/apps/web/src/screens/Conversation.tsx +++ b/apps/web/src/screens/Conversation.tsx @@ -517,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 @@ -531,8 +532,23 @@ export default function Conversation() { // "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 && sr.data.visible_state) { - setStateVars({ ...sr.data.visible_state }) + 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) From fd63e1f71f4841ceaea548f66d00c0179a5b3ec8 Mon Sep 17 00:00:00 2001 From: Nick Charney Kaye Date: Sat, 3 Oct 2026 11:29:17 -0400 Subject: [PATCH 33/37] Say "AI engine" and "AI model" on the status rows a player reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #501 §2 named four words that landed badly on a new player, and "runtime" was one of them. The PR renamed it on the two Settings controls, but left it on the surfaces a first session actually passes through: the status badge in the app header (every screen), the Status panel on Home, the unreachable-engine error card, and the first-run load-error step. Home also labelled three rows "LLM", "STT" and "TTS" — acronyms a first-time player has no way to read. These are labels, not identifiers, so they are renamed outright rather than gated on the wording level: "AI engine", "AI model", "Voice input" and "Voice output" are accurate for both audiences and lose nothing a technical reader needs. The engine-term diagnostics behind the advanced disclosure and in api/client.ts are left alone — that is the deliberate peek the issue asks for. SPEC's status-card example and quickstart's status sentence follow. docs/screenshots.md keeps the old wording on purpose: its captions describe captured PNGs that still show it, and that re-capture pass is already tracked as a follow-up. Co-Authored-By: Claude Opus 5 --- apps/web/src/__tests__/App.test.tsx | 4 ++-- apps/web/src/__tests__/Home.test.tsx | 22 ++++++++++---------- apps/web/src/__tests__/edition.test.tsx | 6 +++--- apps/web/src/api/errors.ts | 2 +- apps/web/src/components/OfflineIndicator.tsx | 9 +++++--- apps/web/src/i18n/locales/de.ts | 10 ++++----- apps/web/src/i18n/locales/en.ts | 15 ++++++++----- apps/web/src/screens/Logbook.tsx | 2 +- apps/web/src/setup/steps/LoadErrorStep.tsx | 2 +- docs/SPEC.md | 8 +++---- docs/quickstart.md | 2 +- 11 files changed, 45 insertions(+), 37 deletions(-) diff --git a/apps/web/src/__tests__/App.test.tsx b/apps/web/src/__tests__/App.test.tsx index 11ccd55f..8ccc5f6c 100644 --- a/apps/web/src/__tests__/App.test.tsx +++ b/apps/web/src/__tests__/App.test.tsx @@ -113,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__/Home.test.tsx b/apps/web/src/__tests__/Home.test.tsx index 100f8d78..077c4e43 100644 --- a/apps/web/src/__tests__/Home.test.tsx +++ b/apps/web/src/__tests__/Home.test.tsx @@ -106,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 () => { @@ -144,7 +144,7 @@ describe('Home — ready state', () => { it('offers no Resume link when nothing is in progress', async () => { stubFetches(makeHealth(), makePacks(1), makeLogbook(), [], { sessions: [] }) renderHome() - await screen.findByText(liText('Local runtime: Ready')) + await screen.findByText(liText('AI engine: Ready')) expect(screen.queryByTestId('home-resume-link')).not.toBeInTheDocument() }) @@ -185,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 () => { @@ -271,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') @@ -279,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') @@ -291,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 () => { @@ -316,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 () => { @@ -337,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() }) @@ -474,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__/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/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/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/i18n/locales/de.ts b/apps/web/src/i18n/locales/de.ts index e13c75b7..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.', @@ -185,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…', diff --git a/apps/web/src/i18n/locales/en.ts b/apps/web/src/i18n/locales/en.ts index f7430ef2..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.', @@ -186,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…', 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/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/docs/SPEC.md b/docs/SPEC.md index 4462c2fc..a2058941 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -1104,10 +1104,10 @@ Must show: Status card example: ```text -Local runtime: Ready -LLM: Qwen3 8B Instruct Q4_K_M -STT: Whisper small.en -TTS: Kokoro af_heart +AI engine: Ready +AI model: Qwen3 8B Instruct Q4_K_M +Voice input: Whisper small.en +Voice output: Kokoro af_heart Network required to play: No ``` diff --git a/docs/quickstart.md b/docs/quickstart.md index 08f3a225..8442b0a4 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -19,7 +19,7 @@ This guide walks through your first conversation. Complete [installation](instal .\scripts\dev.ps1 ``` -Open in your browser. The home screen shows green status indicators when convsim-core and the LLM runtime are ready. +Open in your browser. The home screen's Status panel shows green indicators — **AI engine** and **AI model** — once both are ready. --- From d74c045e5d301393947f2fc4c6a5971470d20f00 Mon Sep 17 00:00:00 2001 From: Nick Charney Kaye Date: Sat, 3 Oct 2026 11:32:10 -0400 Subject: [PATCH 34/37] Keep the resumable-state list in one place per language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resumable / ended partition was written out three times: once in convsim-core's listing route, once in the TypeScript proxy's, and once in Settings, which picks unfinished conversations out of a full listing client-side rather than through the filter. Three copies that must agree for resume to behave, with only comments saying so — add a flow state in one place and a conversation either loses its Resume button on the very screen issue #501's playtester was standing on, or keeps one after it has ended. The two TypeScript copies become one in @convsim/shared. The Python copy cannot import it, so a pytest guard parses the shared list and asserts the two still agree; it was verified to fail when one state is removed. Co-Authored-By: Claude Opus 5 --- apps/api/src/routes/sessions.ts | 31 ++++----------- apps/web/src/screens/Settings.tsx | 15 ++------ docs/architecture.md | 7 ++++ packages/shared/src/types/session.ts | 33 ++++++++++++++++ .../tests/test_session_listing.py | 38 +++++++++++++++++++ 5 files changed, 90 insertions(+), 34 deletions(-) diff --git a/apps/api/src/routes/sessions.ts b/apps/api/src/routes/sessions.ts index e6dc478e..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'; @@ -161,28 +164,6 @@ function rejectTransition(reply: { status: (code: number) => void }, state: Sess throw err; } -/** States a session can be picked back up from — the resumable set - * (issue #501 §1). Mirrors RESUMABLE_FLOW_STATES in convsim-core's sessions - * router. `NotStarted` is excluded on purpose: nothing has been said yet, so - * there is no conversation to resume. */ -const RESUMABLE_STATES: SessionState[] = [ - 'PlayerTurnListening', - 'PlayerTurnReview', - 'NpcThinking', - 'NpcSpeaking', - 'ScenarioEvent', -]; - -/** States a session has finished in. `Ended` is only the first: generating the - * debrief moves the row to `DebriefGenerating` and then `DebriefReady`, and a - * failed debrief leaves `Error`. Mirrors ENDED_FLOW_STATES in convsim-core. */ -const ENDED_STATES: SessionState[] = [ - 'Ended', - 'DebriefGenerating', - 'DebriefReady', - 'Error', -]; - export async function sessionRoutes(app: FastifyInstance) { // GET /api/sessions app.get<{ Querystring: { status?: string; limit?: string } }>( @@ -209,7 +190,11 @@ export async function sessionRoutes(app: FastifyInstance) { // 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_STATES : status === 'ended' ? ENDED_STATES : null; + 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) { diff --git a/apps/web/src/screens/Settings.tsx b/apps/web/src/screens/Settings.tsx index 9f67cfdd..ecf5528f 100644 --- a/apps/web/src/screens/Settings.tsx +++ b/apps/web/src/screens/Settings.tsx @@ -20,6 +20,9 @@ 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' @@ -31,16 +34,6 @@ interface SessionSummary { created_at: string } -/** Flow states a session can be picked back up from (mirrors - * RESUMABLE_FLOW_STATES in convsim-core's sessions router). */ -const RESUMABLE_STATES = new Set([ - 'PlayerTurnListening', - 'PlayerTurnReview', - 'NpcThinking', - 'NpcSpeaking', - 'ScenarioEvent', -]) - interface PrivacyToggleProps { id: string label: string @@ -908,7 +901,7 @@ export default function Settings() { (issue #501 §1): every unfinished conversation is one click from here, not just the newest one the banner offers. */} - {RESUMABLE_STATES.has(s.state) && ( + {isResumableSessionState(s.state) && (
- {/* 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

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.

From ea17c72ceee2ee6c0f98f69ee74586c3b3dcf321 Mon Sep 17 00:00:00 2001 From: Nick Charney Kaye Date: Sat, 3 Oct 2026 11:51:12 -0400 Subject: [PATCH 36/37] Cover the link that bridges a slow turn to the reply-speed setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The slow-reply notice is where the issue #501 detour began: the playtester hit a slow turn, went to Settings to speed the model up, "was a little unclear on how to do that", and started a new session rather than find their way back. Replacing "you can adjust settings" with a named link straight at "Settings → Reply speed" is the fix for that first step, and it had no test — neither the wording nor the #reply-speed anchor the Settings screen scrolls to, so the link could regress to bare /settings and land the player back at the top of a long page with nothing obviously changed. Co-Authored-By: Claude Opus 5 --- apps/web/src/__tests__/Conversation.test.tsx | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/apps/web/src/__tests__/Conversation.test.tsx b/apps/web/src/__tests__/Conversation.test.tsx index 80a61297..6275aaaa 100644 --- a/apps/web/src/__tests__/Conversation.test.tsx +++ b/apps/web/src/__tests__/Conversation.test.tsx @@ -1855,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 From 97557795f60dc70129f4c1a5867bc825e680964e Mon Sep 17 00:00:00 2001 From: Nick Charney Kaye Date: Sat, 3 Oct 2026 11:51:57 -0400 Subject: [PATCH 37/37] Point the screenshot capture at the renamed meters toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renaming the Conversation Brief's toggle to "Show the conversation meters" left `scripts/capture-screenshots.mjs` matching a label that no longer exists. It degrades rather than fails — it logs a warning and carries on — so the capture still shows meters now that they default to on, but the selector is dead and the warning says the toggle is missing when the only thing that moved is its name. Does not touch docs/screenshots.md or the captured PNGs: those captions describe the images, which still show the pre-rewrite screen, and rewording them without re-shooting would make them lie about what they caption. That re-capture pass stays a follow-up. Co-Authored-By: Claude Opus 5 --- scripts/capture-screenshots.mjs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/scripts/capture-screenshots.mjs b/scripts/capture-screenshots.mjs index 7576fad8..00c1e4bf 100755 --- a/scripts/capture-screenshots.mjs +++ b/scripts/capture-screenshots.mjs @@ -436,13 +436,17 @@ async function main() { const startButton = page.getByRole('button', { name: 'Start conversation' }) await startButton.waitFor({ timeout: 60_000 }) - // The meters are opt-in per session and off by default; the conversation - // capture is required to show them (issue #455). - const metersToggle = page.getByLabel(/Show NPC state meters/i) + // The meters are opt-in per session and now on by default wherever the + // scenario permits them (issue #501 §3); the conversation capture is + // required to show them (issue #455), so check the toggle regardless — + // `check()` is a no-op when it is already on. The label is the Conversation + // Brief's, renamed with the panel it switches on, so a miss here means the + // name moved again rather than that the toggle is absent. + const metersToggle = page.getByLabel(/Show the conversation meters/i) if (await metersToggle.count()) { await metersToggle.check() } else { - log('WARNING: state-meter toggle not found — capture will have no meters') + log('WARNING: conversation-meters toggle not found — capture may have no meters') } // A fixed variation seed makes the playthrough re-runnable. const seedField = page.getByLabel('Variation seed value')