diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 21880492f..e2c26f161 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -534,9 +534,9 @@ element in the card shares one keyboard ring (2px `--focus-accent-border`, 2px offset). Component geometry (card min/max width) keeps local literals like the rest of the chat surface. -**Project question row.** Every Project question appears in Main as one row, and its size follows the owner's attention. In every settled or passed state it is one line in the project chip's own language (the `--project` tints, the Project name in project ink with `↗`): status as dot + text in meta ink, then the one primary thing at `--type-body` semibold, which is the recorded answer or, for a question the task passed, the assumption it continues under (`Unanswered · continuing with:`), then the question as context, the Project and the time. The answer shows both the recorded option (including the first) and the comment, or a comment-only answer; the option and the comment are bounded separately, a cut is visibly labelled, and it wraps rather than being cut to the line. The question is cut to the line with an ellipsis, and a long status sentence yields the same way; the complete original stays one click away. A narrow column makes it two lines: status and answer, then question, Project and time. The whole line is one control (`bindContentButton`: one tab stop, the shared ring drawn inside, Enter and Space, text stays selectable) that reveals that exact question without toggling the room closed or moving the viewport on background updates. Only while the task actually waits does the row grow into a card: the project chip as its head with `Waiting for your answer`, the whole question through the chat markdown pipeline, the option labels as buttons with the `recommended` badge, and one `Details and own answer` action. Main takes a ready option in one touch; the owner's own words, the option details and the stake stay in the Project form. When the wait ends — an answer, a timeout, ordinary owner input, a newer question — the card folds back into its line, and focus follows from a removed button to the line. No timers, no grouping: the view is a pure function of the question's recorded facts, so history, reconnect and a second device paint the same thing, and consecutive rows sit closer by adjacency alone. The row and the quiz header share the lifecycle wording above. +**Project question mirror.** A Project question the owner has not answered appears in Main as the Project's own quiz card — the same `buildQuizCard` form with the question through the chat markdown pipeline, the options with their details and the `recommended` badge, the stake, the assumption or waiting line, the status and the own-answer field — inside the same assistant bubble. The one addition is a Project chip in the head beside the `Question` chip: a pill in the project chip's own language (the `--project` tints, the Project name in project ink with `↗`) that opens that exact question in its Project, with the card's shared keyboard ring. A long Project name yields first (the chip is capped and ellipsized, its title names the Project whole) so the status keeps its place; a phone column wraps the head. Every lifecycle state reads as it does in the Project: waiting, open, resumed and finished questions stay answerable, and a replaced question stays as a read-only record. An unreadable source keeps what Main already knew; with nothing known the copy says `Status unavailable`, takes no answer and keeps its chip, and a row that cannot carry the form yet shows `Open the original question for its text.` until it can. The first confirmed answer from any source — a press in Main, the Project form or another device, a history or census snapshot — shows the recorded result (the chosen option, `Owner's answer: …`, `You answered`) for five seconds and then removes only the Main copy, through the ordinary message retirement and without moving the reader's viewport; the Project keeps its card. The countdown starts once and later observations never restart it. When focus was inside the copy it stays there while the result shows, then moves to the next Main question, or to the composer for a keyboard owner, never summoning a touch keyboard. A copy that learns its form and its answer in one delivery shows that result for the same five seconds. An answered question never enters Main again: fresh history, a reconnect or a stale open snapshot cannot bring the copy back. Main remembers the lifecycle of a bounded number of questions; a question it no longer remembers mounts only once its canonical record (task detail) shows it unanswered. A failed, missing or wrong-project canonical read leaves a safe `Status unavailable` copy with its Project chip and no answer controls; it retries through explicit navigation or a later owned refresh, so an unavailable read never turns a stale open snapshot into an answerable form and never permanently suppresses a legitimate unanswered question. The mirror and the quiz header share the lifecycle wording above. -Project-lifecycle and routing actions, and the waiting card's `Details and own answer`, use the shared `createSystemMessageActions` composition. It owns token-based space above and below the controls, wrapping and clearance for the existing button focus ring; action buttons never sit in a clipped/nowrap text line. This is a row composition, not a new card framework or a global button-margin rule. +Project-lifecycle and routing actions use the shared `createSystemMessageActions` composition. It owns token-based space above and below the controls, wrapping and clearance for the existing button focus ring; action buttons never sit in a clipped/nowrap text line. This is a row composition, not a new card framework or a global button-margin rule. History with no current execution or known outcome keeps its expandable content under `Outcome unavailable`, without a task chip, typing or Stop. Before complete live-source reconciliation, it is `Activity unconfirmed`. Positive current activity restores only its proven controls. A delivery warning may coexist with a preserved task-acceptance PASS. Model metadata says `Last solve response`, naming the initial request only when the route changed. diff --git a/docs/architecture/01-high-level-architecture.md b/docs/architecture/01-high-level-architecture.md index 81e225d3d..06ef7daef 100644 --- a/docs/architecture/01-high-level-architecture.md +++ b/docs/architecture/01-high-level-architecture.md @@ -15,7 +15,7 @@ server.py (Starlette+uvicorn) ← HTTP + WebSocket on configurable host:port (de ├── web/ ← Web UI (SPA with ES modules in web/modules/; §3) │ ├── ui.css + modules/ui_primitives.js ← The one palette/control stylesheet for the SPA, onboarding and optional author pages; self-contained safe-field, escaping and tone/status functions (§3 Navigation and shared UI contracts) │ ├── modules/page_header.js, ui_interactions.js, scroll_fade.js ← Header/tab-strip binder; dialog-focus, menu and popup-geometry binders; overflow-driven scroll-edge fade — each with owned teardown (§3 Navigation and shared UI contracts) - │ ├── modules/chat_decision.js, question_presentation.js, chat_render_batch.js, task_phase_chip.js, lifecycle_card.js ← Chat helpers: the typed decision (quiz) cards and the Main question row shown to the owner, with one pure lifecycle/preview projection shared by both (its Python twin: `project_dialogue.QUESTION_STATUS`); keyed timeline items, DOM patches, history-control presentation and reading anchors; pure desired-phase chip projection where terminal truth wins; skill lifecycle card state with best-effort polling + │ ├── modules/chat_decision.js, question_presentation.js, chat_render_batch.js, task_phase_chip.js, lifecycle_card.js ← Chat helpers: the typed decision (quiz) cards, one form for a Project question in its room and in its Main mirror, with one pure lifecycle projection shared by both (its Python twin: `project_dialogue.QUESTION_STATUS`); keyed timeline items, DOM patches, history-control presentation and reading anchors; pure desired-phase chip projection where terminal truth wins; skill lifecycle card state with best-effort polling │ ├── modules/chat_history.js, chat_history_replay.js ← Per-chat history pages with bounded retention and exact return handles; source-keyed replay merging without live-task authority (§3 Timeline ownership and ordering) │ ├── modules/project_work_pointer.js ← Project-room pointer to an already loaded root card; no execution or history authority (§3 Project rooms) │ ├── modules/model_wait.js ← Model-wait views and owner actions inside existing chat cards, through the shared decision ingress (§6 Quota and auth waits) diff --git a/docs/architecture/03-web-ui-pages-and-buttons.md b/docs/architecture/03-web-ui-pages-and-buttons.md index e8f18b3a7..feb110740 100644 --- a/docs/architecture/03-web-ui-pages-and-buttons.md +++ b/docs/architecture/03-web-ui-pages-and-buttons.md @@ -78,9 +78,15 @@ History reconciliation is one synchronous two-pass transaction over existing key #### Main rows and host-stamped card rows -Main receives ordinary main-thread dialogue, Project question rows and the two host-stamped Project lifecycle rows — `project_started` and the terminal `project_completion_summary`, each with the shared "Open Project" action; all other Project traffic stays in the Project thread. Lifecycle rows are plain dashboard text: the producer strips markdown once before durable write and live send, history normalizes older rows on read (`chat.jsonl` is never rewritten), and the renderer escapes any system row without `markdown: true` (except `skill_review`'s dedicated renderer). An origin-addressed notice (`task_not_started`, `task_start_unconfirmed`, `steer_not_delivered`) stays in its issuing chat on replay even when the target is bound to another Project (`project_dialogue.room_membership` uses `ORIGIN_ADDRESSED_NOTICE_TYPES`). A steering refusal for an owner act with no owner-message receipt is one factual System row, never model-authored speech. +Main receives ordinary main-thread dialogue, Project question mirrors and the two host-stamped Project lifecycle rows — `project_started` and the terminal `project_completion_summary`, each with the shared "Open Project" action; all other Project traffic stays in the Project thread. Lifecycle rows are plain dashboard text: the producer strips markdown once before durable write and live send, history normalizes older rows on read (`chat.jsonl` is never rewritten), and the renderer escapes any system row without `markdown: true` (except `skill_review`'s dedicated renderer). An origin-addressed notice (`task_not_started`, `task_start_unconfirmed`, `steer_not_delivered`) stays in its issuing chat on replay even when the target is bound to another Project (`project_dialogue.room_membership` uses `ORIGIN_ADDRESSED_NOTICE_TYPES`). A steering refusal for an owner act with no owner-message receipt is one factual System row, never model-authored speech. -Every Project quiz, whether or not the task waits on it, additionally projects one keyed row into Main, without a second durable message. The row is one line in every settled or passed state and grows into a card with the option buttons only while the task waits (`question_presentation.questionRow`; docs/DESIGN.md "Project question row"); a press there answers through the one decision ingress and settles through the same observation a `quiz_state` frame uses, so a lost frame plus a stale open snapshot cannot reopen it, while the owner's own words, option details and stake stay in the Project form. Its task/quiz identity opens the existing Project question; task detail restores a question outside bounded history using the stored labels and optional aligned option details. Recorded answer/expiry updates the same pointer. The pointer row is complete for display: `project_dialogue.project_question_pointer` (history, the live delivery in `message_bus.send_quiz`, and the `required_question` of the activity census) carries the question, the option labels, the asker's assumption and recommendation, the recorded answer and the wait facts (`owner_wait_projection`: the task's wait record when it names this quiz, a record that moved on to another quiz — which resumes only a question the task actually waited on — and the closed bound), so Main paints it from the row alone and reads task detail only to open the original form outside the loaded Project history. `question_presentation.js` owns the lifecycle/preview projection shared with the quiz header, and history attaches the same wait facts to the Project room's quiz rows, because a wait the owner resumed by ordinary input leaves no `quiz_state` frame behind; `project_dialogue.QUESTION_STATUS` is the Python twin, and `question_presentation_parity.json` pins both sides on the rows Python emits. Waiting needs positive evidence; resumed and finished tasks stay separately answerable. History preserves the durable ask's wait evidence when its closed quiz block has left the hot projection; optional questions acquire no resumed fact from another quiz's wait. Freshness uses history, `quiz_state`, and the existing activity census: its positively named single wait folds older waiting Main cards of the same task through `chat_decision.appendActivityQuestion`, without settling their questions. OLDER is proven by the questions' own `asked_at` stamps (`ts` on every pointer row), never by the time a read started: a task publishes a new quiz BEFORE its `owner_wait` row is written, so a census taken inside that window still names the PREVIOUS question, and a named wait may fold only a question asked STRICTLY before itself. A census request begun before a card arrived cannot close that newer wait; absent wait detail, an equal stamp (the stamps carry sub-millisecond precision the browser's parse truncates, so equality is not order) and a missing or unreadable stamp on either side all prove nothing and leave the card answerable. The Python projection's own `resumed` inference — a wait record that moved on to another quiz — lives in that same window WITHOUT an order proof, so a history or detail read taken inside it can show the newest waiting question as resumed; that one is a non-live observation, which the next census naming the real wait restores. Lifecycle only moves forward in the chat instance's observation of each question: a settled state never reopens, an unavailable row keeps what is known, and once a live frame closed a wait, a snapshot that still waits (an older history row, a detail read begun before the frame) cannot reopen it. A successful answer HTTP response must contain a valid recorded answer before the UI settles; missing or malformed confirmation never substitutes the local draft, and a late answer's toast says where it went (`forwarded`). `active_chat_activities.required_question` recovers current waits through the existing stat-keyed result memo, shared with finalizing. +Every unanswered Project quiz, waited on or not, is mirrored into Main from one keyed pointer, without a second durable message. `chat_decision.buildQuizCard` renders the same Project form plus a Project chip (DESIGN “Project question mirror”); its own-answer and option controls use the existing decision ingress. The first confirmed `answered` observation from any source—answer response, 409 winner, `quiz_state`, history or census—starts one five-second `MIRROR_SETTLE_MS` countdown. Later observations do not restart it. Only Main's copy retires, via `releaseMessageNode` inside the stable-viewport transaction; Project history remains. Focus inside the retiring copy moves to the next Main question or the keyboard owner's composer. `releaseViews`/`destroy` clear timers and markdown. A partial copy upgraded to a complete answered form binds its card before settlement and gets the same countdown; an initially answered pointer never mounts. + +The instance's `OBSERVATION_LIMIT` memory evicts least-recently-touched observations, without tombstones. A mounted copy re-seeds its lifecycle; once anything was forgotten, an absent question mounts as safe unknown and requests fresh canonical `readQuestion` task detail. It never joins an older navigation read; repeated deliveries to the same owned mirror share its pending request. Failed, missing or wrong-project detail leaves “Status unavailable”, a Project chip and no answer controls, drops stale pointer observations, and permits recovery through explicit navigation or later owned refresh. Every queued continuation rechecks destruction/retirement before modifying its still-owned node. Superseded copies stay read-only; a readable canonical row can mount an unknown copy's form in place. The chip opens the exact Project question, fetching task detail if it lies outside loaded history. + +`project_dialogue.project_question_pointer`, live `message_bus.send_quiz`, and the census `required_question` carry the question, labels, aligned `option_details` (absent for legacy label-only asks), `stake`, assumption, recommendation, recorded answer and `owner_wait_projection`. A narrower redelivery never blanks known fields. `question_presentation.js` owns lifecycle wording shared with quiz headers; `project_dialogue.QUESTION_STATUS` is its Python twin, pinned by `question_presentation_parity.json`. History attaches the same wait facts because ordinary input can resume a task without a `quiz_state` frame. Waiting requires positive evidence; resumed and finished tasks remain answerable. History retains the durable ask's wait evidence after hot-projection eviction; optional questions acquire no resumed fact from another quiz's wait. + +Freshness uses history, live `quiz_state` and the existing census. `appendActivityQuestion` ends only demonstrably older waits of the same task, never settling those questions. A task publishes a new quiz BEFORE its wait record, so the census can still name the previous question. Strictly earlier `asked_at` (`ts` on pointers) proves order; request time alone, equal millisecond-rounded timestamps, missing or unreadable stamps do not. A census requested before a card arrived cannot close that card's wait. Python's non-live `resumed` inference from a wait record that moved to another quiz lacks that order proof in the publication window; the next census naming the actual wait restores it. Within an instance settled lifecycle never reopens, unavailable rows preserve known state, and a snapshot cannot reopen a wait closed by a live frame. An answer HTTP response must contain a valid recorded answer: malformed confirmations never substitute the local draft, and a late-answer toast reports actual `forwarded` delivery. `active_chat_activities.required_question` uses the existing stat-keyed result memo shared with finalizing. A host fact about a task is a row of that task's card, never a standalone bubble beside it: the producer stamps the placement (`card_row`, `timeline` or `reviews`, with `card_row_id` as the row's stable identity across live delivery, outbox replay and history) and the browser attaches the row to the task's card record as one timeline item (`reviews` also refreshes the card's Reviews group). Only a row whose task has no card record in the page renders as the standalone System row it always was, with the same words; the untyped terminal host notice and the origin-addressed notices carry no placement fact and stay ordinary rows by design. diff --git a/docs/architecture/04-server-api-endpoints.md b/docs/architecture/04-server-api-endpoints.md index a47cd9f49..0e11f54fc 100644 --- a/docs/architecture/04-server-api-endpoints.md +++ b/docs/architecture/04-server-api-endpoints.md @@ -155,7 +155,7 @@ A built-in `command` frame carries a slash command and enters the same bridge wi Built-in outbound envelopes: `chat`, `photo`, `video`, `document`, `typing`, `log`, `heartbeat`, `extension_lifecycle`, `message_annotation`, `projects_changed`, `task_named`, `update_status_ready`, and `update_progress_changed`. The latter only invalidates the process-local update observation; it is never boot-completion proof. Chat progress may carry task lineage, role, requested/effective model lane, delegated route, terminal execution evidence, review projection, cancellation eligibility, outcome axes, artifact references, and nullable cost/finality fields — additive presentation facts; consumers must not infer a missing execution receipt, cost, or task result from the absence of one optional field. -Thread routing is explicit. Project chat, typing, media, and log frames carry `chat_id`; a Project panel consumes its own thread, while Main admits Project question rows and the two host-stamped Project lifecycle rows (`project_started`, `project_completion_summary`). `projects_changed` carries a new chat id so every tab can extend its fan-out set before fetching the registry; when even that ordering loses the race, the server-stamped `project_thread` marker on the frame itself keeps Main from adopting it — set once at the message-bus broadcast choke from the registry (a membership lens, never a numeric range, so external transport ids such as Telegram stay unstamped) and enforced by Main's fan-out gate (`chat_activity.mainThreadAccepts`). Task-scoped LOG events acquire their final chat id at supervisor ingress: worker diagnostics carry only their own `task_id`, and `supervisor/log_addressing.py::address_task_event` stamps the audience from host-attested truth (the precedence chain lives in its docstring; an explicit event chat_id of 0 is the hidden partition, `HIDDEN_CHAT_ID`, never "missing"); direct turns carry their chat BY VALUE, stamped at the producer, because the registry entry dies with the turn while queued events drain later. Addressing is honest — an A2A row keeps its true audience, suppressed only at the broadcast choke (`push_log`) so machine traffic never reaches the browser; the same addressing runs in the server-process append sink and at every supervisor handler owning a suppressed type's explicit push, and a genuinely unaddressable event keeps the legacy chat-0 frame. `message_annotation` updates one canonical owner message without creating another bubble — a refused routing act's frame and its replayed annotation carry the host's `cause` sentence (the machine `reason` stays on the durable row), and the picker's 409 `dispatch_rejected` body carries `cause` beside `reason`; `task_named` updates a card only where that task already exists. Media/document consumers validate MIME, base64, and download-route shapes before building browser URLs. +Thread routing is explicit. Project chat, typing, media, and log frames carry `chat_id`; a Project panel consumes its own thread, while Main admits Project question mirrors and the two host-stamped Project lifecycle rows (`project_started`, `project_completion_summary`). `projects_changed` carries a new chat id so every tab can extend its fan-out set before fetching the registry; when even that ordering loses the race, the server-stamped `project_thread` marker on the frame itself keeps Main from adopting it — set once at the message-bus broadcast choke from the registry (a membership lens, never a numeric range, so external transport ids such as Telegram stay unstamped) and enforced by Main's fan-out gate (`chat_activity.mainThreadAccepts`). Task-scoped LOG events acquire their final chat id at supervisor ingress: worker diagnostics carry only their own `task_id`, and `supervisor/log_addressing.py::address_task_event` stamps the audience from host-attested truth (the precedence chain lives in its docstring; an explicit event chat_id of 0 is the hidden partition, `HIDDEN_CHAT_ID`, never "missing"); direct turns carry their chat BY VALUE, stamped at the producer, because the registry entry dies with the turn while queued events drain later. Addressing is honest — an A2A row keeps its true audience, suppressed only at the broadcast choke (`push_log`) so machine traffic never reaches the browser; the same addressing runs in the server-process append sink and at every supervisor handler owning a suppressed type's explicit push, and a genuinely unaddressable event keeps the legacy chat-0 frame. `message_annotation` updates one canonical owner message without creating another bubble — a refused routing act's frame and its replayed annotation carry the host's `cause` sentence (the machine `reason` stays on the durable row), and the picker's 409 `dispatch_rejected` body carries `cause` beside `reason`; `task_named` updates a card only where that task already exists. Media/document consumers validate MIME, base64, and download-route shapes before building browser URLs. Extension WebSocket traffic is structurally namespaced by `extension_loader.extension_surface_name()` so an extension cannot shadow a built-in type. On each incoming extension frame the gateway resolves the owning skill and reconciles whether its extension is still desired, reviewed, granted, enabled, and live. A missing or failed handler returns a visible log frame. Out-of-process handlers execute in their extension child off the event loop; in-process handlers first record the required execution/cost disclosure. A non-`None` result returns as `.reply`; exceptions become typed error log frames rather than terminating the socket loop. diff --git a/docs/development/11-design-system.md b/docs/development/11-design-system.md index 75d9bc1b1..3dcc5ba01 100644 --- a/docs/development/11-design-system.md +++ b/docs/development/11-design-system.md @@ -13,20 +13,28 @@ This chapter owns the engineering rules that preserve the visual and interaction - **Containment.** A control never widens its column. Horizontal overflow lives in the wrapper that owns the wide content and declares `overflow-x: auto` (code block, `.md-table-wrap`, tab strip, Costs table cells) — never in a page scroll body, whose `overflow-y: auto` alone already makes `overflow-x` compute to `auto`. The shared `select.ui-control` recipe therefore clips its own value (`overflow: hidden`): WebKit computes `overflow: visible` on a native select, so an unclipped option label becomes scrollable overflow of the page scroller. A grid track holding controls takes a minimum that yields to its container — `minmax(0, …)` or `repeat(auto-fit, minmax(min(100%, Npx), 1fr))`; a fixed px minimum rescued only by a viewport media query is review debt, because the viewport does not know how wide the content column is. The global webkit scrollbar recipe sizes both axes. Enforced by `tests/test_web_typography_static.py::test_select_control_clips_its_value` and its `::test_webkit_scrollbar_recipe_covers_both_axes` neighbour, `tests/test_ui_settings_overflow_browser.py` (WebKit, the native-select clip) and `tests/test_ui_settings_grid_tracks_browser.py` (Chromium, yielding tracks). Two gaps stay open: the wizard document loads `ui.css` without `style.css` and keeps native scrollbars, and an element setting the standard `scrollbar-width`/`scrollbar-color` opts out of the webkit recipe on Blink. - **One semantic button variant expresses one action role**: neutral Settings and onboarding controls use the existing `.btn.btn-default`; a one-action result row uses the named `.settings-action-row` contract (status first, action docked right); notifications use the shared toast host. Working, warning, error and destructive states keep one meaning across Chat, Logs, Settings and Skills. Enforced by `web/tests/settings_action_row.test.js` (the shared `.btn-default` role in both shells, the `.settings-action-row` contract and shared busy/status semantics); the toast-host and cross-page state-meaning clauses are review-only (CHECKLISTS item 30). - System-message actions use `ui_helpers.createSystemMessageActions` around the - shared button: Project lifecycle and routing receipts, and the waiting question - card's `Details and own answer` (a settled question line is itself the control). Spacing above/below, wrap and focus clearance belong to this one - composition, never a global button margin or a nowrap text ancestor. - Question lifecycle words live once in `question_presentation.js` (Main row and - quiz header) and their Python twin `project_dialogue.QUESTION_STATUS`; a new - state is added to both sides in the same commit with a row in - `question_presentation_parity.json` (the row's data flow and precedence: - ARCHITECTURE "Chat and Projects"). The Main row's own lead for a passed - question lives only in `questionRow`, because Python never emits it. The row - is judged on a realistic burst: `web/tests/question_rows.test.js` pins the - line/card projection, the one-touch answer and its races; geometry at desktop - and phone width, exact-question navigation and reload are exercised by - `test_ui_coherence_browser.py`; the one-touch answer against a real server and - the settled line by `test_ui_result_browser.py`. + shared button: Project lifecycle and routing receipts. Spacing above/below, + wrap and focus clearance belong to this one composition, never a global + button margin or a nowrap text ancestor. + Question lifecycle words live once in `question_presentation.js` (every quiz + header, the Project's and its Main mirror's) and their Python twin + `project_dialogue.QUESTION_STATUS`; a new state is added to both sides in the + same commit with a row in `question_presentation_parity.json` (the row's data + flow and precedence: ARCHITECTURE "Chat and Projects"). A Project question in + Main is the Project's own form: `chat_decision.buildQuizCard` renders the + mirror too, and its one addition is the Project chip — never a second, + reduced Main form, and a field the Project form gains reaches Main by + riding the pointer row (`project_question_pointer`, the census and the live + frame together). The settle-then-remove countdown lives in the decision + controller and leaves through chat.js's one `releaseMessageNode` path inside + the stable-viewport transaction; `releaseViews`/`destroy` own its timer and + markdown. The mirror is judged on a realistic burst: + `web/tests/question_mirrors.test.js` pins the same form node for node, every + lifecycle state, the countdown from each answer source, stale snapshots, + focus and release; `test_ui_coherence_browser.py` drives the burst, the + countdown, the reading anchor, keyboard focus, a reconnect and a reload in + Chromium and WebKit at desktop and phone width; `test_ui_result_browser.py` + answers from Main against a real server and replays the Project record. - **A list editor** reveals the entry it just added through `ui_helpers.revealNewRow(row, field)` — the one seam for "scrolled into view, caret in the first field" — and a freshly added entry shows no error before the owner tries to save. `tests/test_available_subagents_ui_static.py` pins the seam; the `ui_browser` acceptance in `tests/test_ui_smoke_agents_panel.py` pins the behaviour. - **A host fact about a task is a row of that task's card**, never a standalone bubble beside it: the producer stamps the placement (`card_row` with `card_row_id`) and the browser attaches the row to the card record through one helper shared by the live branch and replay, falling back to a standalone System row only when the task has no card record in the page (mechanism: ARCHITECTURE §3 "Main rows and host-stamped card rows"). A host fact that belongs in the card but is produced without a placement fact is review debt under this rule (CHECKLISTS item 30 scores conformance); the untyped terminal host notice and the origin-addressed routing notices stay ordinary rows by design. A client-side list of row types is not the rule (`docs/development/02`, an open default behind a closed exception list). - **Task outcome truth** stays in `log_events.js::taskOutcomeSeverity` and `taskTerminalPhase`; `taskPresentation` is the one compact factual projection consumed by chips, live completion, history replay and child terminal presentation. Its host mirror is `project_dialogue.outcome_phase`, pinned to the browser by one shared fixture (`web/tests/fixtures/outcome_phase_parity.json`): a new axis, reason or acceptance status is added to both sides in the same commit, with a row in that fixture. The detail line under the headline comes from `taskReasonDetail` alone (precedence: ARCHITECTURE §3 "Task cards, errors and reason lines") — never from a second producer. A non-terminal diagnostic may add a timeline fact but must not promote the whole task, and an unknown event name never acquires Chat severity from `error`/`crash`/`fail` keyword matching. The Chat header reports connection, the `/api/state` activity census, the owner's own unconfirmed sends and live task cards only; a failed task status does not synthesize header attention, a toast, unread state or an owner action. Never derive header liveness from a WS frame: a typing frame is a submission receipt, and the `/api/state` census is the only inserter into the client live-activity set (contract and residuals: ARCHITECTURE §3 "Liveness census and the chat header"; enforced by `web/tests/chat_header_census.test.js`). diff --git a/docs/inventories/DATA_LAYOUT_INVENTORY.md b/docs/inventories/DATA_LAYOUT_INVENTORY.md index 863cd0f14..144564991 100644 --- a/docs/inventories/DATA_LAYOUT_INVENTORY.md +++ b/docs/inventories/DATA_LAYOUT_INVENTORY.md @@ -2,7 +2,7 @@ Machine extraction of the `docs/ARCHITECTURE.md` "Data layout (`~/Ouroboros/`)" tree — the durable-file orientation carrier (this tree's counterpart of the reference PERSISTENCE_OWNERS derivation checklist) — regenerated by `python scripts/regenerate_inventories.py`. Do not edit. Every entry is probed against reality: repo entries must exist as tracked paths; data-plane entries must appear as a literal in the runtime sources that construct them. A durable file renamed or removed in code while its tree row survives = red (`tests/test_generated_inventories.py`). -Source: `docs/architecture/01-high-level-architecture.md`, physical LF lines 583-673; UTF-8 SHA-256 `5f3eb2ad578351ab574a22cc0a9e78638f6af37cd0d9e8bee10e197f2f0fc51c`. +Source: `docs/architecture/01-high-level-architecture.md`, physical LF lines 583-673; UTF-8 SHA-256 `94fbc53a6a2bc1b3fb35b7d3ed22648880e0f706fba78228b6ae1c5952725a1f`. - entries: **79** (code-ref: 72, repo-dir: 6, repo-path: 1) diff --git a/ouroboros/gateway/contracts.py b/ouroboros/gateway/contracts.py index ccad56283..81b9dc934 100644 --- a/ouroboros/gateway/contracts.py +++ b/ouroboros/gateway/contracts.py @@ -110,8 +110,7 @@ class ChatOutbound(TypedDict): is_progress: NotRequired[bool] task_id: NotRequired[str] origin_message_ref: NotRequired[Dict[str, Any]] - # X3: a repair receipt whose managed task id does not exist yet (the router - # mints it at promotion). Typed truth instead of an invented id. + # X3: a repair receipt whose managed task id the router mints only at promotion (typed truth, no invented id). task_id_pending: NotRequired[bool] # "finalizing" on a root's early final answer: the answer is delivered # while post-task synthesis still runs, so the frame is NOT the task's @@ -125,7 +124,7 @@ class ChatOutbound(TypedDict): rounds: NotRequired[int] suggested_name: NotRequired[str] model_execution: NotRequired[Dict[str, Any]] - # Project question projection into Main; the durable question stays in Project. + # Project question mirrored into Main as the Project's own form; the durable question stays in Project. quiz_id: NotRequired[str] quiz_state: NotRequired[str] project_chat_id: NotRequired[int] @@ -136,13 +135,14 @@ class ChatOutbound(TypedDict): wait_ended_at: NotRequired[str] question: NotRequired[str] options: NotRequired[List[str]] + option_details: NotRequired[List[str]] # aligned with options; absent for a legacy label-only ask + stake: NotRequired[str] assumption: NotRequired[str] recommended_index: NotRequired[int] answered_index: NotRequired[int] comment: NotRequired[str] task_incident: NotRequired[str] - # A cancellation fault names the PHYSICAL task it could not settle when that - # differs from the displayed (logical) task id. + # A cancellation fault names the PHYSICAL task it could not settle when it differs from the logical task id. cancel_physical_task_id: NotRequired[str] toast_once: NotRequired[str] # #628: the incident's valence for the one-shot toast (warn/ok/error), diff --git a/ouroboros/gateway/state.py b/ouroboros/gateway/state.py index ce8c48e99..237dc3693 100644 --- a/ouroboros/gateway/state.py +++ b/ouroboros/gateway/state.py @@ -291,8 +291,8 @@ def _task_activity_facts(drive_root: Any, task_id: str) -> dict: # The census pointer is the same complete row history and the live delivery carry # (project_dialogue.project_question_pointer): display fields ride along. "quiz": {key: quiz[key] for key in ("quiz_id", "state", "asked_at", "wait_for_answer", "question", - "options", "assumption", "recommended_index", - "answered_index", "comment", "wait_ended_at") + "options", "option_details", "stake", "assumption", + "recommended_index", "answered_index", "comment", "wait_ended_at") if isinstance(quiz, dict) and key in quiz}} if len(_FINALIZING_MEMO) >= _FINALIZING_MEMO_MAX: _FINALIZING_MEMO.clear() diff --git a/ouroboros/project_dialogue.py b/ouroboros/project_dialogue.py index d69f22c31..41d1b231e 100644 --- a/ouroboros/project_dialogue.py +++ b/ouroboros/project_dialogue.py @@ -106,9 +106,9 @@ def project_question_pointer(row: Dict[str, Any], block: Any, project: Any, owner_wait: Any = None) -> Optional[Dict[str, Any]]: """Read projection of one Project question into Main; never another ask. - The row is complete for display: question, option labels, the recorded answer and the - wait facts ride with the pointer, so the browser paints it from history or the live - frame alone and reads task detail only to open the original form.""" + The row is complete for display: question, option labels and details, stake, the recorded + answer and the wait facts ride with the pointer, so the browser paints the Project's own form + from history, the live frame or the census alone; task detail only opens the original.""" from ouroboros.contracts.chat_id_policy import WEB_UI_CHAT_ID quiz = row.get("quiz") if isinstance(row.get("quiz"), dict) else row @@ -124,14 +124,18 @@ def project_question_pointer(row: Dict[str, Any], block: Any, project: Any, still_required = bool(block.get("wait_for_answer")) if block else bool(quiz.get("wait_for_answer")) name = str(project.get("name") or "Project") lead = question_status(state if known else "unknown", facts, still_required) - options = quiz.get("options") if isinstance(quiz.get("options"), list) else block.get("options") - labels = [str(option.get("label") if isinstance(option, dict) else option or "") - for option in (options if isinstance(options, list) else [])] + options = next((value for value in (quiz.get("options"), block.get("options")) if isinstance(value, list)), []) + labels = [str(option.get("label") if isinstance(option, dict) else option or "") for option in options] + # Aligned details from the row's option objects or the block's own list (a legacy ask has none). + details = ([str(option.get("detail") or "") if isinstance(option, dict) else "" for option in options] + if any(isinstance(option, dict) for option in options) else block.get("option_details")) + details = [str(v or "") for v in details] if isinstance(details, list) and 0 < len(labels) == len(details) else None question = str(quiz.get("question") or row.get("text") or block.get("question") or "") assumption = str(quiz.get("assumption") or block.get("assumption") or "") + stake = str(quiz.get("stake") or block.get("stake") or "") recommended = block.get("recommended_index") if not isinstance(recommended, int) or isinstance(recommended, bool): - recommended = next((i for i, option in enumerate(options if isinstance(options, list) else []) + recommended = next((i for i, option in enumerate(options) if isinstance(option, dict) and option.get("recommended") is True), None) pointer: Dict[str, Any] = { "role": "system", "system_type": "project_question_pointer", "task_id": task_id, @@ -143,6 +147,8 @@ def project_question_pointer(row: Dict[str, Any], block: Any, project: Any, # Display fields only when known: a narrower producer must never blank a complete row. **({"question": question} if question else {}), **({"options": labels} if labels else {}), + **({"option_details": details} if details else {}), + **({"stake": stake} if stake else {}), **({"assumption": assumption} if assumption else {}), **({"recommended_index": recommended} if recommended is not None else {}), **facts, diff --git a/supervisor/message_bus.py b/supervisor/message_bus.py index 6ab546297..1c0ee10c6 100644 --- a/supervisor/message_bus.py +++ b/supervisor/message_bus.py @@ -1043,8 +1043,9 @@ class LocalChatBridge: "source_status": pointer.get("source_status", ""), } # The complete pointer row (ChatOutbound mirrors): present only when known. - for key in ("question", "options", "assumption", "recommended_index", "answered_index", - "comment", "wait_for_answer", "wait_ended_at", "owner_wait_resume_reason"): + for key in ("question", "options", "option_details", "stake", "assumption", "recommended_index", + "answered_index", "comment", "wait_for_answer", "wait_ended_at", + "owner_wait_resume_reason"): if key in pointer: frame[key] = pointer[key] self._broadcast_fn(frame) diff --git a/tests/test_project_question_pointer.py b/tests/test_project_question_pointer.py index d37b6869c..3aee239b2 100644 --- a/tests/test_project_question_pointer.py +++ b/tests/test_project_question_pointer.py @@ -1,4 +1,7 @@ """Project questions share their durable ask with every display lens.""" +import ast +import pathlib +import re import json import os @@ -23,7 +26,7 @@ def test_real_escalation_bridge_history_detail_and_answer(tmp_path, monkeypatch) write_task_result(tmp_path, "task-1", STATUS_RUNNING, project_id=project["id"], chat_id=project["chat_id"]) ctx = _tool_ctx(tmp_path, task_id="task-1", chat_id=project["chat_id"]) ctx.owner_wait_callback = lambda *_: None - result = _escalate(ctx, question="Which storage?", wait_for_answer=True, + result = _escalate(ctx, question="Which storage?", wait_for_answer=True, stake="Where every later write lands.", options=[{"label": "Local", "detail": "Single-machine files"}, {"label": "Shared", "detail": "Multiple writers over network"}]) assert result.startswith("OK:") @@ -45,11 +48,15 @@ def test_real_escalation_bridge_history_detail_and_answer(tmp_path, monkeypatch) # The live pointer frame is complete for display: the browser paints it without a detail read. assert pointer["question"] == "Which storage?" and pointer["options"] == ["Local", "Shared"] assert pointer["wait_for_answer"] is True and pointer["content"] == "Waiting for your answer in Question Project" + # Main mirrors the Project's own form: the option details and the stake ride along too. + details = ["Single-machine files", "Multiple writers over network"] + assert pointer["option_details"] == details and pointer["stake"] == "Where every later write lands." assert not {"comment", "answered_index"} & pointer.keys() stored = (tmp_path / "logs/chat.jsonl").read_text().splitlines() assert len(stored) == 1 and json.loads(stored[0])["chat_id"] == project["chat_id"] main = json.loads(_assemble_history_response(tmp_path, 1, 10, 0))["messages"] assert len(main) == 1 and main[0]["quiz_id"] == qid + assert main[0]["option_details"] == details and main[0]["stake"] == "Where every later write lands." assert not {"task_phase", "outcome_axes", "cost_final", "cancelable"} & main[0].keys() room = json.loads(_assemble_history_response(tmp_path, project["chat_id"], 10, 0))["messages"] assert room[0]["msg_type"] == "quiz" and room[0]["quiz"]["options"][1]["detail"] == "Multiple writers over network" @@ -148,7 +155,7 @@ def test_activity_question_uses_same_memo_and_preserves_wait_semantics(tmp_path, write_task_result(tmp_path, "t1", STATUS_RUNNING, project_id=project["id"], root_phase_checkpoint={"post_task_synthesis": "running"}) record_asked(tmp_path, "t1", quiz_id="q1", question="?", options=["a", "b"], wait_for_answer=True, - recommended_index=0) + recommended_index=0, option_details=["Alpha", ""], stake="The rest of the run") set_owner_wait(tmp_path, "t1", {"quiz_id": "q1", "wait_id": "w1", "state": "waiting"}) monkeypatch.setattr(queue, "PENDING", []) monkeypatch.setattr(queue, "RUNNING", {"t1": {"task": {"id": "t1", "project_id": project["id"], "chat_id": project["chat_id"]}}}) @@ -161,6 +168,8 @@ def test_activity_question_uses_same_memo_and_preserves_wait_semantics(tmp_path, assert rows[0]["required_question"]["text"] == "Waiting for your answer in Waiting Project" # The census pointer is as complete as the history row: the browser never paints a blank over it. assert rows[0]["required_question"]["question"] == "?" and rows[0]["required_question"]["options"] == ["a", "b"] + assert rows[0]["required_question"]["option_details"] == ["Alpha", ""] + assert rows[0]["required_question"]["stake"] == "The rest of the run" # A card painted from the census alone still badges the recommended option (index zero included). assert rows[0]["required_question"]["recommended_index"] == 0 # Folding an older Main card needs the named question's OWN asked_at, so the census @@ -231,9 +240,12 @@ def test_question_presentation_shared_fixture(): assert pointer["text"] == case["status"] + " in Project", case["case"] assert {key: pointer[key] for key in keys if key in pointer} == case["row"], case["case"] assert pointer["question"] == "Which?" and pointer["options"] == ["a", "b"] + # A legacy label-only ask retained no details; Main says so exactly as the Project card does. + assert "option_details" not in pointer and "stake" not in pointer narrow = project_question_pointer({"task_id": "task", "quiz_id": "q", "wait_for_answer": True}, {"quiz_id": "q", "state": "open"}, {"id": "p", "chat_id": 12, "name": "Project"}, None) - assert not {"question", "options"} & narrow.keys(), "unknown display fields are omitted, never blanked" + assert not {"question", "options", "option_details", "stake"} & narrow.keys(), \ + "unknown display fields are omitted, never blanked" def test_project_room_quiz_row_carries_the_wait_facts(tmp_path): @@ -260,3 +272,87 @@ def test_project_room_quiz_row_carries_the_wait_facts(tmp_path): set_owner_wait(tmp_path, "t1", {"quiz_id": "q2", "wait_id": "w2", "state": "waiting"}) room = json.loads(_assemble_history_response(tmp_path, project["chat_id"], 10, 0))["messages"] assert room[0]["quiz"]["owner_wait_state"] == "resumed" + + +from tests.test_contracts import REPO_ROOT, _dict_type_discriminator, _dict_literal_keys + + +def _string_tuple(node: ast.AST) -> tuple[str, ...] | None: + if isinstance(node, ast.Tuple) and node.elts and all( + isinstance(elt, ast.Constant) and isinstance(elt.value, str) for elt in node.elts): + return tuple(elt.value for elt in node.elts) + return None + + +def _function_node(source_path: pathlib.Path, name: str) -> ast.AST: + tree = ast.parse(source_path.read_text(encoding="utf-8")) + found = [node for node in ast.walk(tree) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == name] + assert len(found) == 1, f"expected one {name} in {source_path.name}" + return found[0] + + +def test_project_question_pointer_display_fields_share_one_contract(): + """Main mirrors a Project question as the Project's own form, so each pointer producer and + both ABI mirrors carry the same display fields, ``option_details`` and ``stake`` included. + + The live frame (``message_bus.send_quiz``) copies them in a key loop and the census + (``state._task_activity_facts``) in a key tuple, neither of which the envelope literal scan + above can see; the browser keeps what it merges in ``chat_decision.js`` MIRROR_FIELDS.""" + from typing import get_args, get_origin, get_type_hints + + from ouroboros.gateway.contracts import ChatOutbound + from ouroboros.project_dialogue import project_question_pointer + + hints = get_type_hints(ChatOutbound, include_extras=True) + assert "option_details" in hints and "stake" in hints + # Both optional: a legacy label-only ask retained no details, and most asks name no stake. + for field in ("option_details", "stake"): + assert str(hints[field]).startswith(("typing.NotRequired[", "typing_extensions.NotRequired[")), hints[field] + details = get_args(hints["option_details"])[0] + assert get_origin(details) is list and get_args(details) == (str,) + assert get_args(hints["stake"]) == (str,) + api_types = (REPO_ROOT / "web" / "modules" / "api_types.js").read_text(encoding="utf-8") + typedef = re.search(r"@typedef \{Object\} ChatOutbound\b(?P.*?)\n \*/", api_types, re.S) + assert typedef, "api_types.js missing ChatOutbound" + assert re.search(r"@property \{string\[\]=\} option_details\b", typedef.group("body")) + assert re.search(r"@property \{string=\} stake\b", typedef.group("body")) + + mirror = re.search(r"const MIRROR_FIELDS = \[([^\]]*)\]", + (REPO_ROOT / "web" / "modules" / "chat_decision.js").read_text(encoding="utf-8")) + assert mirror, "chat_decision.js MIRROR_FIELDS moved" + browser_fields = set(re.findall(r"'([a-z_]+)'", mirror.group(1))) + assert {"option_details", "stake"} <= browser_fields + assert browser_fields <= set(hints), sorted(browser_fields - set(hints)) + + # History: the producer itself emits every field the browser merges. + block = {"quiz_id": "q", "state": "open", "question": "Which?", "options": ["a", "b"], + "option_details": ["A detail", ""], "stake": "What rides on it", "assumption": "a", + "recommended_index": 0} + pointer = project_question_pointer({"task_id": "t", "quiz_id": "q"}, block, + {"id": "p", "chat_id": 12, "name": "Project"}, None) + assert browser_fields <= set(pointer), sorted(browser_fields - set(pointer)) + assert pointer["option_details"] == ["A detail", ""] and pointer["stake"] == "What rides on it" + + # Live delivery: the frame literal plus its copied key loop, all declared in ChatOutbound. + send_quiz = _function_node(REPO_ROOT / "supervisor" / "message_bus.py", "send_quiz") + frames = [node for node in ast.walk(send_quiz) if isinstance(node, ast.Dict) + and _dict_type_discriminator(node) == "chat"] + assert len(frames) == 1, "send_quiz builds one pointer frame" + literal, unknown = _dict_literal_keys(frames[0]) + assert not unknown + copied = [_string_tuple(node.iter) for node in ast.walk(send_quiz) + if isinstance(node, ast.For) and _string_tuple(node.iter) and "recommended_index" in _string_tuple(node.iter)] + assert len(copied) == 1, "send_quiz copies the pointer's display fields in one key loop" + live = literal | set(copied[0]) + assert {"option_details", "stake"} <= set(copied[0]) + assert browser_fields <= live, sorted(browser_fields - live) + assert live <= set(hints), sorted(live - set(hints)) + + # Census: the quiz facts the stat-keyed memo keeps for required_question. + facts = _function_node(REPO_ROOT / "ouroboros" / "gateway" / "state.py", "_task_activity_facts") + census = [_string_tuple(gen.iter) for node in ast.walk(facts) if isinstance(node, ast.DictComp) + for gen in node.generators if _string_tuple(gen.iter) and "question" in _string_tuple(gen.iter)] + assert len(census) == 1, "_task_activity_facts keeps the quiz display fields in one key tuple" + quiz_sourced = browser_fields - {"project_name"} + assert quiz_sourced <= set(census[0]), sorted(quiz_sourced - set(census[0])) diff --git a/tests/test_projects_v6640.py b/tests/test_projects_v6640.py index abf2fa76e..65a6ca810 100644 --- a/tests/test_projects_v6640.py +++ b/tests/test_projects_v6640.py @@ -524,8 +524,8 @@ def test_project_lifecycle_rows_render_design_system_action_static_contract(): assert "createSystemMessageAction({" in render assert "createSystemMessageActions(" in render assert "row.className = 'system-message-actions'" in helpers - for consumer in ("chat_decision.js", "chat_activity.js"): - assert "createSystemMessageActions(" in (root / "web/modules" / consumer).read_text(encoding="utf-8") + # The Main mirror of a Project question carries no system action: its Project chip is the way there. + assert "createSystemMessageActions(" in (root / "web/modules/chat_activity.js").read_text(encoding="utf-8") assert "document.createElement('br')" not in render # The custom pill is gone everywhere; the conversion-flow buttons moved to diff --git a/tests/test_ui_coherence_browser.py b/tests/test_ui_coherence_browser.py index 7dd4d4870..1e94a7b39 100644 --- a/tests/test_ui_coherence_browser.py +++ b/tests/test_ui_coherence_browser.py @@ -342,13 +342,27 @@ def test_mcp_transport_fields_use_named_shared_controls_without_changing_drafts( capture(page,'shell-mcp-shared-fields') +# One structural reading of a quiz card for the "same form" comparison: every element's class, +# its own text and its disabled state, in document order; the Main-only Project chip is skipped. +CARD_FORM_JS = """card => { const out = []; const walk = (el) => { + if (el.classList.contains('chat-quiz-project')) return; + out.push([[...el.classList].filter(name => name !== 'project-question-card').join(' '), + el.children.length ? '' : el.textContent.trim(), el.disabled ?? null, el.getAttribute('placeholder')]); + [...el.children].forEach(walk); }; walk(card); return out; }""" + + @pytest.mark.parametrize('width,height', [(1100, 722), (390, 844)]) -def test_question_rows_burst_answer_from_main_navigation_and_reload(subscription_ui, width, height): +def test_question_mirrors_full_form_settle_and_reload(subscription_ui, width, height): """Real SPA readers and WebSocket handlers; disposable source data, no owner writes. - A realistic burst: one task asked three questions in a row and another question of the same - Project was passed under its assumption. Every settled or passed question is one line; only - the question the task waits on is a card, and one touch on it answers from Main.""" + A realistic burst of one task's Project questions in Main: every unanswered, passed, finished + or replaced question is the Project's own form plus one Project chip; an answered one never + enters Main. The first confirmed answer from any source — a press in Main, the Project form + over quiz_state, a keyboard press — shows the result for five seconds and removes only the + Main copy, keeping the reading position. A copy that learns its form together with its answer + gets the same five seconds; stale snapshots and a reconnect never bring a removed copy back, + also after Main's bounded question memory forgot it (one canonical task-detail read decides).""" + import copy from urllib.parse import parse_qs ui = subscription_ui @@ -356,42 +370,80 @@ def test_question_rows_burst_answer_from_main_navigation_and_reload(subscription page.set_viewport_size({'width': width, 'height': height}) project = {'id': 'question-proof', 'name': 'Evidence project with a deliberately long descriptive name', 'chat_id': 42, 'lifecycle': 'active', 'visible_revision': 0} + details = ['Preserve the full original measurements.', 'Compare the independent run.'] blocks = { 'exact-question': {'state': 'answered', 'question': 'Which evidence should we retain?', 'answered_index': 0, - 'options': ['Keep the primary source', 'Use the replication'], 'wait_for_answer': True, - 'option_details': ['Preserve the full original measurements.', 'Compare the independent run.'], - 'comment': 'Retain the provenance and the original source.', 'asked_at': '2026-09-16T00:00:00Z'}, + 'options': ['Keep the primary source', 'Use the replication'], 'option_details': details, + 'wait_for_answer': True, 'comment': 'Retain the provenance.', 'asked_at': '2026-09-16T00:00:00Z'}, 'second': {'state': 'open', 'question': 'Second of three: publish the interim table as well?', - 'options': ['Publish it now', 'Hold it until the replication lands'], 'wait_for_answer': True, - 'asked_at': '2026-09-16T00:01:00Z'}, + 'options': ['Publish it now', 'Hold it until the replication lands'], 'option_details': ['', ''], + 'wait_for_answer': True, 'asked_at': '2026-09-16T00:01:00Z'}, 'passed': {'state': 'open', 'question': 'Which figure format keeps the appendix small?', 'options': ['PNG', 'WebP'], - 'assumption': 'WebP at quality 82', 'recommended_index': 1, 'asked_at': '2026-09-16T00:02:00Z'}, - # The longest status sentence, a long Project name and a dated time on one line. + 'option_details': ['Lossless, larger', 'Smaller at quality 82'], 'assumption': 'WebP at quality 82', + 'recommended_index': 1, 'asked_at': '2026-09-16T00:02:00Z'}, 'finished': {'state': 'expired_terminal', 'question': 'Should the archive keep the raw instrument logs?', - 'options': ['Keep them', 'Drop them'], 'wait_for_answer': True, 'asked_at': '2026-09-16T00:02:30Z'}, + 'options': ['Keep them', 'Drop them'], 'option_details': ['', ''], 'wait_for_answer': True, + 'asked_at': '2026-09-16T00:02:30Z'}, + 'replaced': {'state': 'superseded', 'question': 'Which appendix order reads best?', 'options': ['By date', 'By topic'], + 'option_details': ['', ''], 'assumption': 'By date', 'asked_at': '2026-09-16T00:02:45Z'}, 'waiting': {'state': 'open', 'wait_for_answer': True, 'recommended_index': 0, 'asked_at': '2026-09-16T00:03:00Z', 'question': 'Third of three. The **licence** of the external dataset forbids redistribution, so the archive ' 'can either ship without it and link to the source, or wait for written permission, which the ' 'maintainers usually grant within a week. Which way do we go?', - 'options': ['Ship without it and link the source', 'Wait for written permission']}, + 'options': ['Ship without it and link the source', 'Wait for written permission'], + 'option_details': ['Readers follow one extra link.', 'Publication slips by about a week.'], + 'stake': 'Whether the archive ships this week.'}, } + asked = copy.deepcopy(blocks) wait = {'quiz_id': 'waiting', 'state': 'waiting'} - decisions = [] - activities = [] - history_reads = [] - page.route_web_socket('**/ws', lambda ws: ws.send(json.dumps({'type': 'heartbeat'}))) + decisions, activities, history_reads, sockets, detail_reads = [], [], [], [], [] + mode = {'stale': False} + # A second task of the same Project whose form reaches Main only after its wait did. + late = {'state': 'open', 'question': 'Which licence notice goes on the cover?', 'options': ['Short notice', 'Full notice'], + 'option_details': ['One line with a link.', 'The whole licence text.'], 'stake': 'The cover layout.', + 'wait_for_answer': True, 'asked_at': '2026-09-16T00:06:00Z'} + + def connect(ws): + sockets.append(ws) + ws.send(json.dumps({'type': 'heartbeat'})) + page.route_web_socket('**/ws', connect) page.route('**/api/projects', lambda r: r.fulfill(json={'projects': [project]})) - page.route('**/api/state', lambda r: r.fulfill(json={'supervisor_ready': True, + # A stable served SHA: a reconnect re-reads history in place instead of reloading the window. + page.route('**/api/state', lambda r: r.fulfill(json={'sha': 'browser-fixture', 'supervisor_ready': True, 'active_chat_activities': activities, 'projects': [project], 'project_chat_ids': [42]})) - page.route('**/api/tasks/proof-task', lambda r: r.fulfill(json={'task_id': 'proof-task', 'project_id': project['id'], - 'owner_quiz': {qid: {'quiz_id': qid, **block} for qid, block in blocks.items()}, 'owner_wait': wait})) + + def detail(route): + # The canonical record (GET /api/tasks/{id}) the Project form and a revalidating Main read. + task_id = urlparse(route.request.url).path.rsplit('/', 1)[-1] + detail_reads.append(task_id) + quizzes = blocks if task_id == 'proof-task' else {'late-form': late} + route.fulfill(json={'task_id': task_id, 'project_id': project['id'], + 'owner_quiz': {qid: {'quiz_id': qid, **block} for qid, block in quizzes.items()}, + 'owner_wait': wait if task_id == 'proof-task' else {'quiz_id': 'late-form', 'state': 'resumed'}}) + page.route('**/api/tasks/proof-task', detail) + page.route('**/api/tasks/late-task', detail) + + def pointer(qid, block): + # The row the Python producer emits (project_question_pointer): complete for the form. + return {'role': 'system', 'system_type': 'project_question_pointer', 'task_id': 'proof-task', 'quiz_id': qid, + 'quiz_state': block['state'], 'project_id': project['id'], 'project_name': project['name'], + 'project_chat_id': 42, 'chat_id': 1, 'ts': block['asked_at'], + **({'owner_wait_state': wait['state']} if qid == wait['quiz_id'] else + {'owner_wait_state': 'resumed'} if activities and block.get('wait_for_answer') else {}), + **{key: block[key] for key in ('question', 'options', 'option_details', 'stake', 'assumption', + 'recommended_index', 'wait_for_answer', 'answered_index', 'comment') if key in block}} def decide(route): sent = route.request.post_data_json decisions.append(sent) - blocks['waiting'].update(state='answered', answered_index=sent['option_index']) - wait['state'] = 'resumed' - route.fulfill(json={'ok': True, 'state': 'answered', 'answered_index': sent['option_index']}) + qid = sent['decision_id'].split(':')[2] + blocks[qid].update(state='answered', answered_index=sent['option_index']) + if qid == wait['quiz_id']: + wait['state'] = 'resumed' + activities[0]['required_question'].update(quiz_state='answered', answered_index=sent['option_index'], + owner_wait_state='resumed') + route.fulfill(json={'ok': True, 'state': 'answered', 'answered_index': sent['option_index'], + **({'answered_after_terminal': True, 'forwarded': True} if qid == 'finished' else {})}) page.route('**/api/decisions', decide) def history(route): @@ -401,105 +453,196 @@ def test_question_rows_burst_answer_from_main_navigation_and_reload(subscription # Source questions are outside this window: exact navigation must read detail. rows = [{'role': 'assistant', 'text': 'Later retained project message.', 'ts': '2026-09-16T01:00:00Z'}] else: - # The rows the Python producer emits: complete for display, no detail read needed. - rows = [{'role': 'system', 'system_type': 'project_question_pointer', 'task_id': 'proof-task', - 'quiz_id': qid, 'quiz_state': block['state'], 'project_id': project['id'], - 'project_name': project['name'], 'project_chat_id': 42, 'ts': block['asked_at'], - **({'owner_wait_state': wait['state']} if qid == wait['quiz_id'] else - {'owner_wait_state': 'resumed'} if activities and block.get('wait_for_answer') else {}), - **{key: block[key] for key in ('question', 'options', 'assumption', 'recommended_index', - 'wait_for_answer', 'answered_index', 'comment') if key in block}} - for qid, block in blocks.items()] + source = asked if mode['stale'] else blocks + rows = [pointer(qid, block) for qid, block in source.items()] + # Later Main reading below the questions, so a removal can happen above the reader. + rows += [{'role': 'assistant', 'text': f'Later Main note {i}. ' + 'Reading continues below the questions. ' * 4, + 'ts': f'2026-09-16T00:1{i}:00Z'} for i in range(8)] route.fulfill(json={'messages': rows, 'progress': []}) page.route('**/api/chat/history*', history) open_app(ui) - rows = page.locator('#chat-messages .chat-bubble.project-question') - rows.nth(4).wait_for() - assert rows.evaluate_all("els => els.map(el => el.dataset.questionMode)") == ['row', 'card', 'row', 'row', 'card'] - # Both asks arrived before the single wait was published. Its next ordinary - # census names only the last quiz: the older card must fold without history. + mirrors = page.locator('#chat-messages .chat-bubble.project-question') + card = lambda qid: page.locator(f'#chat-messages .project-question-card[data-quiz-id="{qid}"]') + ids = lambda: mirrors.evaluate_all("els => els.map(el => el.querySelector('.chat-quiz-card').dataset.quizId)") + card('waiting').wait_for() + assert ids() == ['second', 'passed', 'finished', 'replaced', 'waiting'], 'an answered question never enters Main' + forms = page.evaluate("""() => [...document.querySelectorAll('#chat-messages .project-question-card')].map(el => ({ + id: el.dataset.quizId, status: el.querySelector('.chat-quiz-status-text').textContent, + chip: el.querySelector('.chat-quiz-project .chat-live-project-name')?.textContent, + question: el.querySelector('.chat-quiz-question').textContent.trim().slice(0, 24), + details: [...el.querySelectorAll('.chat-quiz-option-detail')].map(n => n.textContent), + stake: el.querySelector('.chat-quiz-stake')?.textContent.trim() || '', + own: !!el.querySelector('.chat-quiz-comment'), disabled: [...el.querySelectorAll('.chat-quiz-option')].every(b => b.disabled), + recommended: [...el.querySelectorAll('.chat-quiz-option')].findIndex(b => b.querySelector('.chat-quiz-option-recommended'))}))""") + by_id = {form['id']: form for form in forms} + assert {form['chip'] for form in forms} == {project['name']} + assert by_id['waiting']['status'] == 'Waiting for your answer' and by_id['waiting']['own'] + assert by_id['waiting']['stake'] == 'At stake: Whether the archive ships this week.' + assert by_id['waiting']['details'] == ['Readers follow one extra link.', 'Publication slips by about a week.'] + assert by_id['waiting']['recommended'] == 0 and by_id['passed']['recommended'] == 1 + assert by_id['passed']['details'] == ['Lossless, larger', 'Smaller at quality 82'] and by_id['passed']['own'] + assert by_id['finished']['status'].startswith('Unanswered · the task finished') and by_id['finished']['own'] + assert by_id['replaced']['status'] == 'Replaced by a newer question' + assert by_id['replaced']['disabled'] and not by_id['replaced']['own'], 'a replaced question is a read-only record' + assert card('waiting').locator('.chat-quiz-question strong').inner_text() == 'licence' + geometry = page.evaluate("""() => { + const scroller = document.querySelector('#chat-messages'); + const cards = [...document.querySelectorAll('#chat-messages .project-question-card')]; + const box = el => el.getBoundingClientRect(); + return {overflow: scroller.scrollWidth - scroller.clientWidth, page: document.documentElement.scrollWidth - innerWidth, + right: Math.max(...cards.map(el => box(el).right)), viewport: innerWidth, + chipInside: cards.every(el => box(el.querySelector('.chat-quiz-project')).right <= box(el).right + 0.5), + chipCut: (() => { const n = cards[0].querySelector('.chat-quiz-project .chat-live-project-name'); return n.scrollWidth > n.clientWidth; })(), + sizes: [...new Set(cards.flatMap(el => [el.querySelector('.chat-quiz-status'), el.querySelector('.chat-quiz-project')]) + .map(el => getComputedStyle(el).fontSize))]}; + }""") + print(json.dumps({'question_mirror_geometry': geometry, 'viewport': [width, height]})) + assert geometry['overflow'] <= 1 and geometry['page'] <= 0 and geometry['right'] <= width, geometry + assert geometry['chipInside'] and geometry['sizes'] == ['12px'], geometry + card('second').scroll_into_view_if_needed() + setup_browser.capture(page, f'question-mirrors-burst-{width}') + + # Both asks arrived before the single wait was published. Its next ordinary census names + # only the last quiz: the older wait ends in place, the question stays answerable. read_count = len(history_reads) activities.append({'activity_id': 'proof-task', 'chat_id': 42, 'project_id': project['id'], - 'kind': 'direct_chat', 'phase': 'working', 'required_question': { - 'task_id': 'proof-task', 'quiz_id': 'waiting', 'quiz_state': 'open', - 'project_id': project['id'], 'project_chat_id': 42, 'owner_wait_state': 'waiting', - # The producer always stamps the named question's own asked_at, and folding an - # older card needs that proof of order (project_question_pointer). - 'ts': blocks['waiting']['asked_at']}}) - page.wait_for_function("() => document.querySelectorAll('#chat-messages .chat-bubble.project-question')[1]?.dataset.questionMode === 'row'", timeout=15000) + 'kind': 'direct_chat', 'phase': 'working', 'required_question': pointer('waiting', blocks['waiting'])}) + page.wait_for_function("() => document.querySelector('[data-quiz-id=\"second\"] .chat-quiz-status-text')" + "?.textContent.includes('task continued')", timeout=15000) assert len(history_reads) == read_count, 'census freshness must not require history refetch' - assert rows.evaluate_all("els => els.map(el => el.dataset.questionMode)") == ['row', 'row', 'row', 'row', 'card'] - assert 'task continued' in rows.nth(1).inner_text() - comment = blocks['exact-question']['comment'] - first = rows.nth(0).locator('.project-question-pointer') - first.get_by_text('You answered:', exact=True).wait_for() - first.get_by_text('Keep the primary source — ' + comment, exact=True).wait_for() - rows.nth(2).get_by_text('Unanswered · continuing with:', exact=True).wait_for() - rows.nth(2).get_by_text('WebP at quality 82', exact=True).wait_for() - finished = rows.nth(3).locator('.project-question-status-text') - assert finished.inner_text() == 'Unanswered · the task finished; a late answer is accepted as your message' - card = rows.nth(4) - card.get_by_text('Waiting for your answer', exact=True).wait_for() - assert 'usually grant within a week' in card.locator('.chat-quiz-question').inner_text(), 'the waiting card shows the whole question' - assert card.locator('.chat-quiz-question strong').inner_text() == 'licence' - assert card.locator('.chat-quiz-option').count() == 2 and card.locator('.chat-quiz-comment').count() == 0 - assert card.locator('.chat-quiz-option').nth(0).locator('.chat-quiz-option-recommended').count() == 1 - geometry = page.evaluate("""() => { - const rows=[...document.querySelectorAll('#chat-messages .chat-bubble.project-question')]; - const box=(el)=>el.getBoundingClientRect(); - const lines=rows.filter(el=>el.dataset.questionMode==='row'); - const source=rows[1].querySelector('.project-question-source'), go=rows[1].querySelector('.project-question-go'); - return {heights: rows.map(el=>Math.round(box(el).height)), - burst: Math.round(box(lines[2]).bottom-box(lines[0]).top), - overflow: Math.max(...rows.map(el=>el.scrollWidth-el.clientWidth)), - page: document.documentElement.scrollWidth-innerWidth, - right: Math.max(...rows.map(el=>box(el).right)), viewport: innerWidth, - sourceCut: source.scrollWidth>source.clientWidth, arrow: box(go).width>0 && box(go).right<=innerWidth, - previewOneLine: lines.every(el=>box(el.querySelector('.project-question-preview')).height<28), - previewWidth: Math.min(...lines.map(el=>Math.round(box(el.querySelector('.project-question-preview')).width))), - statusCut: (()=>{const el=rows[3].querySelector('.project-question-status-text'); return el.scrollWidth>el.clientWidth;})(), - statusSizes: [...new Set(rows.map(el=>getComputedStyle(el.querySelector('.project-question-status, .chat-live-project-status')).fontSize))]}; - }""") - print(json.dumps({'question_rows_geometry': geometry, 'viewport': [width, height]})) - setup_browser.capture(page, f'question-rows-burst-{width}') - assert geometry['overflow'] <= 1 and geometry['page'] <= 0 and geometry['right'] <= width, geometry - assert geometry['previewOneLine'] and geometry['arrow'] and geometry['sourceCut'], geometry - assert geometry['statusSizes'] == ['12px'], geometry - # One line on a wide column; status and answer over question, project and time on a phone. - # The longest status yields with an ellipsis instead of pushing the time onto another line, - # and the question keeps a readable share of a phone line beside a dated time. - assert geometry['heights'][1] <= (48 if width >= 980 else 90), geometry - assert geometry['heights'][3] <= (48 if width >= 980 else 90) and geometry['statusCut'], geometry - assert geometry['previewWidth'] >= 70, geometry - assert geometry['burst'] <= (150 if width >= 980 else 300), geometry - # The line is one keyboard control with a visible ring. - first.focus() - # WebKit on macOS follows native keyboard navigation: Option+Tab includes - # buttons even when the OS's full-keyboard-access preference is disabled. - page.keyboard.press('Alt+Tab') - page.keyboard.press('Alt+Shift+Tab') - assert first.evaluate('el=>el===document.activeElement'), page.evaluate('document.activeElement.outerHTML') - assert first.evaluate("el=>getComputedStyle(el).outlineStyle") != 'none' - # One touch answers the waiting question from Main: one request, and the card folds into a line. - card.locator('.chat-quiz-option').nth(1).click() - page.locator('#chat-messages .chat-bubble.project-question[data-question-mode="row"]').nth(4).wait_for() - rows.nth(4).locator('.project-question-answer').get_by_text('Wait for written permission', exact=True).wait_for() - assert rows.nth(4).locator('.chat-quiz-option').count() == 0 + assert card('second').locator('.chat-quiz-comment').count() == 1 + assert card('second').locator('.chat-quiz-wait-ended').count() == 1 + + # The chip opens the exact question in its Project: the same form, node for node. + card('waiting').locator('.chat-quiz-project').click() + own = page.locator('.chat-quiz-card[data-task-id="proof-task"][data-quiz-id="waiting"]:not(.project-question-card)') + own.get_by_text('Publication slips by about a week.', exact=True).wait_for() + assert own.evaluate(CARD_FORM_JS) == card('waiting').evaluate(CARD_FORM_JS) + setup_browser.capture(page, f'question-mirror-same-form-{width}') + page.locator('#project-panel-close').click() + + # One touch answers from Main: the recorded result reads for five seconds, then only the + # Main copy goes. A duplicate live frame and the census's own answer never restart it. + card('waiting').locator('.chat-quiz-option').nth(1).click() + page.locator('#chat-messages .project-question-card[data-quiz-id="waiting"][data-state="answered"]').wait_for() + confirmed = page.evaluate('performance.now()') + assert card('waiting').locator('.chat-quiz-option.chosen').inner_text().startswith('Wait for written permission') + assert card('waiting').locator('.chat-quiz-status-text').inner_text() == 'You answered' + assert card('waiting').locator('.chat-quiz-comment').count() == 0 assert [(sent['decision_id'], sent['option_index'], 'comment' in sent) for sent in decisions] == [ ('quiz:proof-task:waiting', 1, False)] - setup_browser.capture(page, f'question-rows-answered-{width}') - # A line opens its exact question in the Project, with the details Main never shows. - first.click() - quiz = page.locator('.chat-quiz-card[data-task-id="proof-task"][data-quiz-id="exact-question"]') - quiz.get_by_text('Preserve the full original measurements.', exact=True).wait_for() - quiz.get_by_text("Owner's answer: " + comment, exact=True).wait_for() - assert quiz.locator('.chat-quiz-option.chosen').inner_text().startswith('Keep the primary source') - assert quiz.locator('.chat-quiz-comment').count() == 0 - setup_browser.capture(page, f'question-exact-navigation-{width}') + card('waiting').evaluate("el => el.scrollIntoView({block: 'center'})") + setup_browser.capture(page, f'question-mirror-answered-{width}') + page.wait_for_timeout(3000) + sockets[-1].send(json.dumps({'type': 'quiz_state', 'task_id': 'proof-task', 'quiz_id': 'waiting', 'state': 'answered', + 'answered_index': 1, 'ts': '2026-09-16T00:04:00Z'})) + assert card('waiting').count() == 1, 'the result stays readable for the whole moment' + page.wait_for_function("() => !document.querySelector('#chat-messages [data-quiz-id=\"waiting\"]')", timeout=10000) + elapsed = page.evaluate('performance.now()') - confirmed + assert 4700 <= elapsed <= 6500, elapsed + setup_browser.capture(page, f'question-mirror-removed-{width}') + # Only the Main copy went: the Project keeps its answered card. + page.evaluate("""p => window.dispatchEvent(new CustomEvent('ouro:open-project', {detail: {project: p, + task_id: 'proof-task', quiz_id: 'waiting'}}))""", project) + own.locator('.chat-quiz-option.chosen').filter(has_text='Wait for written permission').wait_for() + page.locator('#project-panel-close').click() + + # The Project form answers another question while the owner reads further down: the copy + # above the reader settles and leaves without moving the reading position. + card('finished').evaluate("el => el.scrollIntoView({block: 'start'})") + anchor = card('finished').evaluate('el => el.getBoundingClientRect().top') + frame = {'type': 'quiz_state', 'task_id': 'proof-task', 'quiz_id': 'passed', 'state': 'answered', 'answered_index': 0, + 'comment': 'PNG for the print edition.', 'ts': '2026-09-16T00:05:00Z'} + blocks['passed'].update(state='answered', answered_index=0, comment=frame['comment']) + sockets[-1].send(json.dumps(frame)) + card('passed').locator('.chat-quiz-answer').get_by_text("Owner's answer: PNG for the print edition.", exact=True).wait_for() + started = page.evaluate('performance.now()') + page.wait_for_timeout(2500) + sockets[-1].send(json.dumps(frame)) + page.wait_for_function("() => !document.querySelector('#chat-messages [data-quiz-id=\"passed\"]')", timeout=10000) + elapsed = page.evaluate('performance.now()') - started + assert 4500 <= elapsed <= 6500, elapsed + assert abs(card('finished').evaluate('el => el.getBoundingClientRect().top') - anchor) <= 2 + + # A keyboard answer: focus stays in the settled copy, then moves on to the next question. + card('finished').locator('.chat-quiz-question').focus() + page.keyboard.press('Alt+Tab') + assert page.evaluate("document.activeElement.classList.contains('chat-quiz-option')"), \ + page.evaluate('document.activeElement.outerHTML') + page.keyboard.press('Enter') + page.locator('#chat-messages .project-question-card[data-quiz-id="finished"][data-state="answered"]').wait_for() + assert page.evaluate("document.activeElement.closest('.chat-quiz-card')?.dataset.quizId") == 'finished' + page.wait_for_function("() => !document.querySelector('#chat-messages [data-quiz-id=\"finished\"]')", timeout=10000) + assert page.evaluate("document.activeElement.closest('.chat-quiz-card')?.dataset.quizId") == 'replaced' + assert ids() == ['second', 'replaced'] + + # The census names another task's wait before any row carries its form: a partial copy with + # its way to the Project. The complete row then arrives already answered (the Project form + # answered meanwhile): the whole form shows the recorded result for five seconds, then goes. + base = {'role': 'system', 'system_type': 'project_question_pointer', 'task_id': 'late-task', 'quiz_id': 'late-form', + 'project_id': project['id'], 'project_name': project['name'], 'project_chat_id': 42, 'chat_id': 1, + 'ts': late['asked_at'], 'wait_for_answer': True} + activities.append({'activity_id': 'late-task', 'chat_id': 42, 'project_id': project['id'], 'kind': 'direct_chat', + 'phase': 'working', 'required_question': {**base, 'quiz_state': 'open', 'owner_wait_state': 'waiting'}}) + card('late-form').wait_for(timeout=15000) + assert card('late-form').locator('.chat-quiz-option').count() == 0 + assert card('late-form').locator('.chat-quiz-question').inner_text() == 'Open the original question for its text.' + assert card('late-form').locator('.chat-quiz-project').count() == 1 + setup_browser.capture(page, f'question-mirror-partial-{width}') + late.update(state='answered', answered_index=1) + sockets[-1].send(json.dumps({'type': 'chat', 'content': f"You answered in {project['name']}", 'is_progress': False, + 'markdown': False, **base, **{key: late[key] for key in ('question', 'options', 'option_details', 'stake', + 'answered_index')}, + 'quiz_state': 'answered', 'owner_wait_state': 'resumed'})) + page.locator('#chat-messages .project-question-card[data-quiz-id="late-form"][data-state="answered"]' + ' .chat-quiz-option.chosen').wait_for() + started = page.evaluate('performance.now()') + assert card('late-form').locator('.chat-quiz-option').count() == 2 + assert card('late-form').locator('.chat-quiz-option.chosen').inner_text().startswith('Full notice') + assert card('late-form').locator('.chat-quiz-status-text').inner_text() == 'You answered' + assert card('late-form').locator('.chat-quiz-stake').inner_text() == 'At stake: The cover layout.' + card('late-form').evaluate("el => el.scrollIntoView({block: 'center'})") + setup_browser.capture(page, f'question-mirror-late-form-answered-{width}') + page.wait_for_function("() => !document.querySelector('#chat-messages [data-quiz-id=\"late-form\"]')", timeout=10000) + elapsed = page.evaluate('performance.now()') - started + assert 4500 <= elapsed <= 6500, elapsed + assert ids() == ['second', 'replaced'] + assert 'late-task' not in detail_reads, 'a question this tab remembers is never re-read' + + # Stale snapshots — the census and a reconnect's history re-read taken before the answers — + # never bring a removed copy back, also after Main's bounded question memory (2000) let the + # answers go: every quiz_state frame is one more remembered question. + for index in range(2001): + sockets[-1].send(json.dumps({'type': 'quiz_state', 'task_id': 'noise-task', 'quiz_id': f'noise-{index}', + 'state': 'open'})) + page.wait_for_timeout(500) + detail_count = len(detail_reads) + mode['stale'] = True + activities[0]['required_question'] = pointer('waiting', asked['waiting']) + read_count = len(history_reads) + page.evaluate('window.sameDocument = true') + sockets[-1].close() + deadline = page.evaluate('performance.now()') + 15000 + while len(history_reads) == read_count and page.evaluate('performance.now()') < deadline: + page.wait_for_timeout(200) + assert len(history_reads) > read_count and len(sockets) == 2, 'the reconnect re-read history' + page.wait_for_timeout(3500) + assert page.evaluate('window.sameDocument === true'), 'the reconnect re-read history in place' + card('second').scroll_into_view_if_needed() + setup_browser.capture(page, f'question-mirrors-after-stale-reconnect-{width}') + assert ids() == ['second', 'replaced'] + # The forgotten questions were settled by their canonical record, one read per task. + assert {'proof-task', 'late-task'} <= set(detail_reads[detail_count:]), detail_reads[detail_count:] + assert len(decisions) == 2, 'observing answers never sends one' + + # A reload paints durable history: answered questions stay out, the rest keep their form. + mode['stale'] = False + activities[0]['required_question'] = pointer('waiting', blocks['waiting']) + activities[1]['required_question'].update(quiz_state='answered', answered_index=1, owner_wait_state='resumed') page.reload() - rows.nth(4).get_by_text('Wait for written permission', exact=True).wait_for() - assert rows.evaluate_all("els => els.map(el => el.dataset.questionMode)") == ['row', 'row', 'row', 'row', 'row'] - first.click() - quiz.get_by_text("Owner's answer: " + comment, exact=True).wait_for() - setup_browser.capture(page, f'question-reloaded-{width}') - assert len(decisions) == 1, 'a reload and a navigation never answer anything' + page.wait_for_selector('#chat-input', state='attached') + card('replaced').wait_for() + assert ids() == ['second', 'replaced'] + card('second').scroll_into_view_if_needed() + setup_browser.capture(page, f'question-mirrors-reloaded-{width}') + assert len(decisions) == 2, 'a reload and a navigation never answer anything' diff --git a/tests/test_ui_result_browser.py b/tests/test_ui_result_browser.py index 0e7017a00..532131e65 100644 --- a/tests/test_ui_result_browser.py +++ b/tests/test_ui_result_browser.py @@ -143,14 +143,16 @@ def test_ui_results_and_required_question_journey(wait_clone, tmp_path, monkeypa assert task wait = wait_until(lambda: (block if (block := oracle.task_result(task["id"]).get("owner_wait", {})).get("state") == "waiting" else None), 90) assert wait - pointer = page.locator(f'.project-question-pointer[data-task-id="{task["id"]}"]') - pointer.get_by_text("Waiting for your answer").wait_for(timeout=30000) - # Main's contract for a question the task waits on: the ready options, one touch - # each; the owner's own words, the option details and the stake stay in Project. - main_question = page.locator('#chat-messages .chat-bubble.project-question').filter(has=pointer) - assert main_question.get_attribute("data-question-mode") == "card" + pointer = page.locator(f'#chat-messages .project-question-card[data-task-id="{task["id"]}"]') + pointer.get_by_text("Waiting for your answer", exact=True).wait_for(timeout=30000) + # Main mirrors the Project's own form: the options with their details, the + # stake and the own-answer field, plus the chip that opens the Project. + main_question = page.locator('#chat-messages .chat-bubble.project-question').filter( + has=page.locator(f'.project-question-card[data-task-id="{task["id"]}"]')) assert main_question.locator('.chat-quiz-option').count() == 2 - assert main_question.locator('.chat-quiz-comment, .chat-quiz-option-detail, .chat-quiz-stake').count() == 0 + assert main_question.locator('.chat-quiz-comment').count() == 1 + main_question.get_by_text("Include the independent replication too.", exact=True).wait_for() + main_question.get_by_text("At stake: The answer determines the report's evidence.", exact=True).wait_for() page.screenshot(animations="disabled", path=str(screenshots / "chromium-main-question-pointer.png"), full_page=True) # Age the original question beyond the ordinary Project # window. Navigation must reconstruct this exact task/quiz @@ -161,8 +163,9 @@ def test_ui_results_and_required_question_journey(wait_clone, tmp_path, monkeypa log_chat("in", project["chat_id"], 1, f"Retained later project note {index}", drive_root=root) project_history = _api(server.base_url, "GET", f"/api/chat/history?chat_id={project['chat_id']}")["messages"] assert not any(row.get("msg_type") == "quiz" for row in project_history) - pointer.get_by_role("button", name="Details and own answer").click() - quiz = page.locator(f'.chat-quiz-card[data-task-id="{task["id"]}"][data-quiz-id="{wait["quiz_id"]}"]') + pointer.locator('.chat-quiz-project').click() + quiz = page.locator(f'.chat-quiz-card[data-task-id="{task["id"]}"][data-quiz-id="{wait["quiz_id"]}"]' + ':not(.project-question-card)') quiz.get_by_text("Include the independent replication too.", exact=True).wait_for() # This new Project instance cold-replayed cancelable history; # current activity must restore the existing Stop control. @@ -174,17 +177,18 @@ def test_ui_results_and_required_question_journey(wait_clone, tmp_path, monkeypa # restore the quiz for the overview after its viewport assertion. quiz.scroll_into_view_if_needed() page.screenshot(animations="disabled", path=str(screenshots / "chromium-project-question.png"), full_page=True) - # One touch in Main answers it: the durable record and the Main line settle on - # the same answer (the Project card replays it below, in a fresh browser). + # One touch in Main answers it: the Main copy shows the recorded answer, then + # leaves; the Project card keeps the record (replayed below, in a fresh browser). page.locator('#project-panel-close').click() main_question.get_by_role("button", name="Both sources").click() result = wait_durable_result(oracle, task["id"], timeout=90) assert result["owner_quiz"][wait["quiz_id"]]["answered_index"] == 1 - pointer.get_by_text("You answered:", exact=True).wait_for(timeout=30000) - pointer.get_by_text("Both sources", exact=True).wait_for(timeout=30000) - assert main_question.get_attribute("data-question-mode") == "row" - # The settled line opens the same question again, now as a record. - pointer.click() + pointer.get_by_text("You answered", exact=True).wait_for(timeout=30000) + pointer.locator('.chat-quiz-option.chosen').filter(has_text="Both sources").wait_for(timeout=30000) + main_question.wait_for(state="detached", timeout=15000) + # Only the Main copy went: the exact question still opens in its Project. + page.evaluate("""([project, task, quiz]) => window.dispatchEvent(new CustomEvent('ouro:open-project', + {detail: {project, task_id: task, quiz_id: quiz}}))""", [project, task["id"], wait["quiz_id"]]) quiz.locator('.chat-quiz-option.chosen').filter(has_text="Both sources").wait_for(timeout=30000) task_card = page.locator(f'.chat-live-card[data-task-id="{task["id"]}"]') task_card.locator('[data-live-meta]').filter(has_text="Last solve response:").wait_for(timeout=30000) @@ -200,8 +204,11 @@ def test_ui_results_and_required_question_journey(wait_clone, tmp_path, monkeypa page = browser.new_page(viewport=viewport, has_touch=viewport["width"] < 500, reduced_motion="reduce") try: page.goto(server.base_url, wait_until="domcontentloaded") - pointer = page.locator('.project-question-pointer').filter(has_text="You answered") - pointer.click(timeout=30000) + # An answered question never enters Main again; its Project replays the record. + page.locator('#chat-messages .chat-live-card[data-task-id="parent1"]').wait_for(timeout=30000) + assert page.locator('#chat-messages .project-question-card').count() == 0 + page.evaluate("""([project, task, quiz]) => window.dispatchEvent(new CustomEvent('ouro:open-project', + {detail: {project, task_id: task, quiz_id: quiz}}))""", [project, task["id"], wait["quiz_id"]]) replay_quiz = page.locator('.chat-quiz-card').filter(has_text="Which evidence should the report use?") replay_quiz.wait_for() replay_quiz.locator('.chat-quiz-option.chosen').filter(has_text="Both sources").wait_for() diff --git a/web/modules/api_types.js b/web/modules/api_types.js index fd1057a83..a04de0b6e 100644 --- a/web/modules/api_types.js +++ b/web/modules/api_types.js @@ -281,6 +281,8 @@ * @property {string=} wait_ended_at * @property {string=} question * @property {string[]=} options + * @property {string[]=} option_details + * @property {string=} stake * @property {string=} assumption * @property {number=} recommended_index * @property {number=} answered_index diff --git a/web/modules/chat.js b/web/modules/chat.js index cc678b2ce..9985a01a2 100644 --- a/web/modules/chat.js +++ b/web/modules/chat.js @@ -311,6 +311,10 @@ export function createChatInstance({ onDomWrite: withStableViewport, isMain, chatId, insertMessageNode, + // A settled Main question mirror leaves through the ordinary retirement path. + removeMessageNode: (node) => withStableViewport(() => { releaseMessageNode(node); return true; }, + { excludeAnchorNode: node }), + focusAfterRemoval: () => input?.focus?.({ preventScroll: true }), }); async function loadUiPreferences() { @@ -2417,7 +2421,7 @@ export function createChatInstance({ pendingHistoryUpserts.set(row.history_id, row); return false; } pendingHistoryUpserts.delete(row.history_id); - chatMedia.release(old); chatDecision.releaseViews(old); destroyChatMarkdown(old); old.remove(); + releaseMessageNode(old); return true; }); // Retire only local echoes this source snapshot confirms. @@ -3343,6 +3347,11 @@ export function createChatInstance({ && [record.summaryButtonEl, record.reviewsHostEl].some(node => historyNodeIsProtected(node, messagesDiv))); } + // One retirement path for a message node: its media, decision views and markdown go with it. + function releaseMessageNode(node) { + chatMedia.release(node); chatDecision.releaseViews(node); destroyChatMarkdown(node); node.remove(); + } + function releaseHistoryIds(ids) { if (!(ids.size || ids.length)) return; const retained = retainedHistoryIds(); @@ -3361,11 +3370,8 @@ export function createChatInstance({ } pendingHistoryEvictions.delete(id); for (const node of nodes) { - chatMedia.release(node); - chatDecision.releaseViews(node); - destroyChatMarkdown(node); const wrapper = node.closest('.chat-bubble'); - node.remove(); + releaseMessageNode(node); if (wrapper && wrapper !== node && !wrapper.querySelector('.chat-gallery-item, .chat-file-item, .chat-quiz-card')) { chatMedia.release(wrapper); wrapper.remove(); } diff --git a/web/modules/chat_decision.js b/web/modules/chat_decision.js index 300a13d8d..9843f3373 100644 --- a/web/modules/chat_decision.js +++ b/web/modules/chat_decision.js @@ -5,12 +5,18 @@ // answer. Both read as a record after settlement. The routing picker // settles into the plain routing ack line once its dispatch is confirmed. import { MAX_DECISION_COMMENT, MAX_QUIZ_OPTIONS } from './api_types.js'; -import { bindContentButton, renderRoutingAnnotation, routingOptionLabel } from './chat_activity.js'; -import { createSystemMessageAction, createSystemMessageActions, renderProjectChip } from './ui_helpers.js'; +import { renderRoutingAnnotation, routingOptionLabel } from './chat_activity.js'; +import { renderProjectChip } from './ui_helpers.js'; -import { ANSWERABLE_QUIZ_STATES, QUIZ_LIFECYCLE, questionPresentation, questionRow, waitFacts } from './question_presentation.js'; +import { ANSWERABLE_QUIZ_STATES, QUIZ_LIFECYCLE, questionPresentation, waitFacts } from './question_presentation.js'; const WAIT_FIELDS = ['wait_for_answer', 'wait_ended_at', 'owner_wait_state', 'owner_wait_resume_reason']; +// What one observation of a question carries: its identity, lifecycle, recorded answer and wait facts. +const LIFECYCLE_FIELDS = ['task_id', 'quiz_id', 'state', 'answered_index', 'comment', ...WAIT_FIELDS]; +const lifecycleOf = (row) => Object.fromEntries(LIFECYCLE_FIELDS.filter((field) => Object.hasOwn(row, field)) + .map((field) => [field, row[field]])); +// How many questions one chat instance remembers; least recently touched first. +const OBSERVATION_LIMIT = 2000; // The signature line after a bounded wait closed says the same thing the host notice // does (DESIGN "Quiz card"): the default path the task took, and that silence was not // read as consent. The card stays answerable either way. @@ -39,14 +45,23 @@ export function createChatDecision({ isMain = false, chatId = 1, insertMessageNode = null, + // Main's own node retirement (viewport-stable; media, decision views and markdown released). + removeMessageNode = null, + focusAfterRemoval = null, }) { const observations = new Map(); const quizViews = new Map(); - const pointerViews = new Map(); + const mirrors = new Map(); const detailReads = new Map(); + // Revalidation never joins a navigation read that may predate the forgotten + // lifecycle. Pending revalidations for the same task share this fresh read. + const revalidationReads = new Map(); const questionKey = (taskId, quizId) => JSON.stringify([String(taskId || ''), String(quizId || '')]); let disposed = false; let questionNavigation = 0; + // The memory is bounded: the question touched least recently goes first. Once one has gone, + // a question absent from it is no longer proven new to this tab (buildQuestionPointer). + let forgotten = false; // One lifecycle observation per question, merged from every source (history rows, // the live quiz_state frame, a detail read). Lifecycle only moves forward: a settled // state never reopens, an answer is never downgraded to expiry, an unknown row @@ -63,29 +78,34 @@ export function createChatDecision({ if (previous && previous.state !== 'open' && frame.state === 'open') return { ...frame, ...previous }; if (previous?.state === 'answered' && frame.state === 'expired_terminal') return { ...frame, ...previous }; const next = { ...previous }; - for (const field of ['task_id', 'quiz_id', 'state', 'answered_index', 'comment', ...WAIT_FIELDS]) + for (const field of LIFECYCLE_FIELDS) if (Object.hasOwn(frame, field)) next[field] = frame[field]; if (!live && previous?.live_wait && frame.state === 'open') for (const field of WAIT_FIELDS) { if (Object.hasOwn(previous, field)) next[field] = previous[field]; else delete next[field]; } if (live && WAIT_FIELDS.some((field) => Object.hasOwn(frame, field))) next.live_wait = true; + observations.delete(key); observations.set(key, next); - if (observations.size > 2000) observations.delete(observations.keys().next().value); + if (observations.size > OBSERVATION_LIMIT) { + observations.delete(observations.keys().next().value); + forgotten = true; + } return { ...frame, ...next }; } - // The exact source of one question, read from task detail only when navigation - // needs the original form outside the loaded Project history. One in-flight read - // per task; the result is a snapshot, so observe() keeps any newer live fact. - async function readQuestion(taskId, quizId, projectId) { + // Navigation and revalidation each single-flight per task. Revalidation never + // joins an older navigation read; repeated deliveries share the owned view's + // pending validation. + async function readQuestion(taskId, quizId, projectId, { fresh = false } = {}) { if (!fetchDetail || disposed) return null; - if (!detailReads.has(taskId)) { - const promise = Promise.resolve().then(() => fetchDetail(taskId)) - .finally(() => { if (detailReads.get(taskId) === promise) detailReads.delete(taskId); }); - detailReads.set(taskId, promise); + const reads = fresh ? revalidationReads : detailReads; + if (!reads.has(taskId)) { + const promise = Promise.resolve().then(() => disposed ? null : fetchDetail(taskId)) + .finally(() => { if (reads.get(taskId) === promise) reads.delete(taskId); }); + reads.set(taskId, promise); } - const detail = await detailReads.get(taskId); + const detail = await reads.get(taskId); const block = detail?.owner_quiz?.[quizId]; if (disposed || String(detail?.task_id || detail?.id || '') !== String(taskId) || (projectId && String(detail?.project_id || '') !== String(projectId)) @@ -124,117 +144,198 @@ export function createChatDecision({ return true; } - // One Main row per Project question, and its size follows the owner's attention (DESIGN - // "Project question row"): a card with the option buttons only while the task waits on - // it, one line that opens the question in every other state. The view is a pure function - // of the row — history, the live delivery and the activity census carry the question, the - // option labels, the assumption, the recommendation, the recorded answer and the wait - // facts (project_dialogue.project_question_pointer) — so an unchanged row writes nothing. - // Freshness reuses history, quiz_state and the existing activity census; no new poller. + // The Main mirror of a Project question (DESIGN "Project question mirror") is the Project's + // own form — this module's buildQuizCard with the question, option details, recommendation, + // stake, assumption, status and own-answer field — plus one chip that opens the question in + // its Project. History, the live delivery and the activity census carry that form in the + // pointer row (project_dialogue.project_question_pointer), so an unchanged row writes + // nothing. The first confirmed answer from any source — a press here, the Project form or + // another device (quiz_state), a history or census snapshot — shows the recorded result for + // MIRROR_SETTLE_MS and then removes only this Main copy: one countdown, never restarted. A + // question already answered never enters Main, and lifecycle only moves forward, so a stale + // open snapshot cannot bring a removed copy back — while the bounded observation memory + // holds the answer, and after it let that go, through one canonical read (revalidateMirror). + // No new store, reader or poller. + const MIRROR_SETTLE_MS = 5000; + // Display fields a narrower delivery (the census, a lifecycle frame) may lack: an empty value + // there never blanks what a complete row already carried. + const MIRROR_FIELDS = ['question', 'options', 'option_details', 'stake', 'project_name', 'assumption', 'recommended_index']; + const MIRROR_SIGNATURE = ['quiz_state', ...MIRROR_FIELDS, 'answered_index', 'comment', ...WAIT_FIELDS]; const openQuestion = (row) => window.dispatchEvent(new CustomEvent('ouro:open-project', { detail: { project: { id: row.project_id, name: row.project_name, chat_id: row.project_chat_id }, task_id: row.task_id, quiz_id: row.quiz_id, } })); + // The pointer row in the shape of the Project's quiz row, so one normalizer reads both. + const mirrorQuiz = (row) => ({ ...row, type: 'quiz', role: 'assistant', state: row.quiz_state }); + // An empty recorded comment and no comment are the same fact. + const mirrorSignature = (row) => JSON.stringify(MIRROR_SIGNATURE.map((field) => (row[field] === '' ? null : row[field] ?? null))); + // What the copy can offer: the form needs the question and its options; only a known open or + // finished question takes an answer. Either may arrive later than the first delivery. + const mirrorShape = (row) => { + const complete = Boolean(row.question) && (row.options?.length || 0) >= 2; + return { complete, answerable: complete && ANSWERABLE_QUIZ_STATES.includes(row.quiz_state) }; + }; - function updatePointer(view, frame, live = false) { - const current = observe({ ...frame, state: frame.state || frame.quiz_state }, live); - // A narrower re-delivery (the activity census, a lifecycle frame) never blanks what a - // complete row already painted. - for (const field of ['question', 'options', 'project_name', 'assumption', 'recommended_index']) - if (field in current && (current[field] == null || current[field] === '' || current[field]?.length === 0)) delete current[field]; - view.row = { ...view.row, ...current, quiz_state: current.state }; - const model = { ...questionRow(view.row), state: current.state, project: view.row.project_name || 'Project', - options: (view.row.options || []).map(String), recommended: view.row.recommended_index ?? null }; - const signature = JSON.stringify(model); - if (view.signature === signature) return false; - view.signature = signature; - return onDomWrite(() => { paintPointer(view, model); return true; }); + // A copy on screen is its question's lifecycle memory too: an observation the bounded memory + // let go is taken back from the copy, so a stale snapshot still cannot move it backwards. + function rememberMirror(view) { + if (QUIZ_LIFECYCLE.includes(view.row.quiz_state) && !observations.has(view.key)) + observe({ ...lifecycleOf(view.row), state: view.row.quiz_state }); } - function paintPointer(view, model) { - const { card, bubble, time } = view; - const part = (name, text, tag = 'span') => { - const node = document.createElement(tag); node.className = `project-question-${name}`; node.textContent = text; return node; - }; - // Settling removes the option button the owner just pressed: focus follows to the row. - // A repaint that stays a card (a renamed Project, labels that arrived late) keeps the - // focus on the same option. - const focused = card.contains?.(document.activeElement); - const focusedOption = focused ? [...card.querySelectorAll('.chat-quiz-option')].indexOf(document.activeElement) : -1; - // The rendered question may own charts and timers: release them before the node goes. + function mirrorRow(view, frame, live = false) { + rememberMirror(view); + const state = frame.state || frame.quiz_state; + // After eviction, neither repeated snapshots nor a stale detail observation + // can authorize this form. Only its fresh canonical read clears the gate; + // a positive answer can still settle the navigation-only copy normally. + const current = view.needsValidation && state !== 'answered' + ? { ...frame, state: 'unknown' } : observe({ ...frame, state }, live); + for (const field of MIRROR_FIELDS) + if (field in current && (current[field] == null || current[field] === '' || current[field]?.length === 0)) delete current[field]; + view.row = { ...view.row, ...current, quiz_state: current.state }; + return view.row; + } + + function mirrorChip(view) { + const name = view.row.project_name || 'Project'; + view.chip = renderProjectChip({ name, status: '↗', className: 'chat-quiz-project', onClick: () => openQuestion(view.row) }); + view.chip.title = `Open this question in ${name}`; + view.chip.querySelector('.chat-live-project-status')?.setAttribute('aria-hidden', 'true'); + return view.chip; + } + + function mountMirror(view) { + const bubble = buildQuizCard(mirrorQuiz(view.row), view); + if (!bubble) return null; + bubble.classList.add('project-question'); + view.bubble = bubble; + view.shape = mirrorShape(view.row); + view.signature = mirrorSignature(view.row); + return bubble; + } + + function updateMirror(view, frame, live = false) { + mirrorRow(view, frame, live); + const signature = mirrorSignature(view.row); + if (view.signature === signature) return false; + view.signature = signature; + return onDomWrite(() => { + if (disposed || mirrors.get(view.key) !== view) return false; + const shape = mirrorShape(view.row); + if ((shape.complete && !view.shape.complete) || (shape.answerable && !view.shape.answerable)) { + // The form arrived, or a question of unknown state proved answerable: the whole + // card mounts in place of the partial one. + const { bubble, card } = view; + const focused = card.contains?.(document.activeElement); + view.disposeMarkdown?.(); + if (quizViews.get(view.key) === card) quizViews.delete(view.key); + const next = mountMirror(view); + if (!next) return false; + bubble.replaceWith(next); + if (focused) view.chip.focus?.({ preventScroll: true }); + return true; + } + const name = view.row.project_name || 'Project'; + const label = view.chip.querySelector('.chat-live-project-name'); + if (label && label.textContent !== name) { label.textContent = name; view.chip.title = `Open this question in ${name}`; } + buildQuizCard(mirrorQuiz(view.row), view); + return true; + }); + } + + function settleMirror(view) { + if (view.settling || mirrors.get(view.key) !== view) return; + view.settling = true; + view.timer = setTimeout(() => removeMirror(view), MIRROR_SETTLE_MS); + } + + function releaseMirror(view) { + clearTimeout(view.timer); + view.timer = null; view.disposeMarkdown?.(); view.disposeMarkdown = null; - [...card.children].forEach((node) => node.remove()); - bubble.dataset.questionMode = model.waiting ? 'card' : 'row'; - card.dataset.state = model.state; - if (!model.waiting) { - card.setAttribute('role', 'button'); - card.tabIndex = 0; - const status = part('status', ''); - const dot = document.createElement('span'); - dot.className = 'chat-quiz-dot'; - status.append(dot, part('status-text', model.lead)); - card.append(status, ...(model.detail ? [part('answer', model.detail)] : []), - part('preview', model.question || 'Open the original question for its text.'), - part('source', model.project), part('go', '↗'), ...(time ? [time] : [])); - card.querySelector('.project-question-go').setAttribute('aria-hidden', 'true'); - if (focused) card.focus?.({ preventScroll: true }); - return; - } - card.removeAttribute('role'); - card.removeAttribute('tabindex'); - const question = part('question chat-quiz-question', '', 'div'); - const text = view.row.question || 'Open the original question for its text.'; - if (renderMarkdown) question.innerHTML = renderMarkdown(text); - else question.textContent = text; - const options = part('options chat-quiz-options', '', 'div'); - model.options.forEach((label, index) => { - const button = document.createElement('button'); - button.type = 'button'; - button.className = 'chat-quiz-option'; - const name = part('option-label chat-quiz-option-label', label); - if (model.recommended === index) appendRecommendedBadge(name); - button.append(name); - // Main takes a ready option only; own words, option details and the stake stay in Project. - button.addEventListener('click', () => submitAnswer(card, - { taskId: view.row.task_id, quizId: view.row.quiz_id, options: model.options }, index, '', - (node, state, answered) => updatePointer(view, { task_id: view.row.task_id, quiz_id: view.row.quiz_id, - state, answered_index: answered, comment: node.dataset.ownerComment || '' }, true))); - options.append(button); - }); - const foot = part('foot', '', 'div'); - foot.append(createSystemMessageActions(createSystemMessageAction({ - label: 'Details and own answer', onClick: () => openQuestion(view.row) })), ...(time ? [time] : [])); - const body = part('body', '', 'div'); - body.append(question, options, foot); - card.append(renderProjectChip({ name: model.project, status: questionPresentation(view.row).status, - onClick: () => openQuestion(view.row) }), body); - if (enhanceMarkdown && renderMarkdown) view.disposeMarkdown = enhanceMarkdown(question); - if (focusedOption >= 0) card.querySelectorAll('.chat-quiz-option')[focusedOption]?.focus?.({ preventScroll: true }); + if (mirrors.get(view.key) === view) mirrors.delete(view.key); + if (quizViews.get(view.key) === view.card) quizViews.delete(view.key); + if (view.needsValidation && ![...mirrors.values()].some((other) => + other.needsValidation && other.row.task_id === view.row.task_id)) + revalidationReads.delete(view.row.task_id); + } + + function removeMirror(view) { + if (disposed || mirrors.get(view.key) !== view) return; + const { bubble } = view; + const active = document.activeElement; + const focused = bubble.contains?.(active); + // Focus that was inside moves on to the next Main question below; the composer takes a + // keyboard owner's focus only, so a touch owner never gets a keyboard it did not ask for. + const others = new Map([...mirrors.values()].filter((other) => other !== view).map((other) => [other.bubble, other])); + let next = bubble.nextElementSibling; + while (next && !others.has(next)) next = next.nextElementSibling; + const target = others.get(next)?.card.querySelector('.chat-quiz-question'); + let keyboard = focused; + try { keyboard = focused && active.matches?.(':focus-visible') !== false; } catch { /* an engine without the selector */ } + releaseMirror(view); + if (removeMessageNode) removeMessageNode(bubble); + else onDomWrite(() => { bubble.remove(); return true; }); + if (target && focused) target.focus?.({ preventScroll: true }); + else if (keyboard) focusAfterRemoval?.(); } function buildQuestionPointer(msg) { - if (!msg.task_id || !msg.quiz_id || !msg.project_id || !msg.project_chat_id) return null; + if (disposed || !msg.task_id || !msg.quiz_id || !msg.project_id || !msg.project_chat_id) return null; const key = questionKey(msg.task_id, msg.quiz_id); - const prior = pointerViews.get(key); - if (prior) { updatePointer(prior, msg); return null; } - const card = document.createElement('div'); - card.className = 'project-question-pointer'; - card.dataset.taskId = String(msg.task_id); - card.dataset.quizId = String(msg.quiz_id); - const bubble = frameNode(msg, card); - bubble.classList.remove('assistant'); - bubble.classList.add('project-question'); - bubble.querySelector('.sender')?.remove(); - const view = { row: { ...msg }, card, bubble, observedAt: Date.now(), time: bubble.querySelector('.msg-time') }; - // The whole line is one control whose text stays selectable. The waiting card is not - // one: its buttons own their clicks and the rest of it lets every event through. - bindContentButton(card, () => openQuestion(view.row), () => bubble.dataset.questionMode === 'row'); - pointerViews.set(key, view); - updatePointer(view, msg); + const prior = mirrors.get(key); + if (prior) { + updateMirror(prior, msg); + if (prior.needsValidation && prior.revalidationFailed && !prior.revalidationPending) + revalidateMirror(prior); + return null; + } + // An answered pointer never enters Main. Once memory has evicted anything, + // every unmounted non-answered question needs canonical confirmation: an + // old in-flight read can have re-seeded its stale open observation meanwhile. + if (msg.quiz_state === 'answered' || observations.get(key)?.state === 'answered') { + observe(msg); return null; + } + const view = { key, row: {}, observedAt: Date.now(), timer: null, needsValidation: forgotten, + revalidationPending: false, revalidationFailed: false }; + mirrorRow(view, msg); + const bubble = mountMirror(view); + if (bubble) { + mirrors.set(key, view); + if (view.needsValidation) revalidateMirror(view); + } return bubble; } + // Mount the safe unknown copy synchronously so history owns its normal node + // retirement and its Project chip stays accessible on a failed/foreign read. + // No late insertion, retry poller or tombstones: only this still-owned view can + // acquire the canonical form. Repeated stale pointers cannot unlock it. + function revalidateMirror(view) { + if (disposed || mirrors.get(view.key) !== view) return; + const { task_id: taskId, quiz_id: quizId, project_id: projectId } = view.row; + // Distinguish canonical answers merged by observe() from live confirmation during the read. + const answeredBeforeRead = view.row.quiz_state === 'answered' + || observations.get(view.key)?.state === 'answered'; + view.revalidationPending = true; + view.revalidationFailed = false; + readQuestion(taskId, quizId, projectId, { fresh: true }).catch(() => null).then((question) => { + if (disposed || mirrors.get(view.key) !== view) return; + view.revalidationPending = false; + if (!question) { view.revalidationFailed = true; return; } + onDomWrite(() => { + if (disposed || mirrors.get(view.key) !== view) return false; + if (question.state === 'answered' && !answeredBeforeRead + && view.row.quiz_state !== 'answered') { removeMirror(view); return true; } + view.needsValidation = false; + view.revalidationFailed = false; + return updateMirror(view, question); + }); + }); + } + function appendQuestionPointer(msg) { if (!isMain || !insertMessageNode) return false; return onDomWrite(() => { @@ -248,24 +349,19 @@ export function createChatDecision({ // nothing. A read begun before a card arrived cannot end that newer wait. if (!isMain || !msg?.task_id || !msg.quiz_id || !['waiting', 'resumed'].includes(msg.owner_wait_state)) return false; - // Ordering is proven by the questions themselves, never by the time the read - // started: the task publishes a new quiz BEFORE its owner_wait row is written, - // so a census taken in that window still names the PREVIOUS question. A named - // wait can therefore only end a question asked STRICTLY before it. The stamps - // carry sub-millisecond precision that Date.parse truncates, so two different - // questions can read as equal — equality is no order, and neither is a missing - // or unreadable stamp. An unproven card stays a card: an extra card is - // answerable, a wrongly folded one loses its buttons until a reload. + // A quiz is published before its wait record. Only strictly earlier asked_at + // proves an older wait; equal/missing stamps (including millisecond truncation) + // cannot close a newer card. Request time alone proves no question ordering. const namedAt = Date.parse(msg.ts ?? ''); return onDomWrite(() => { let changed = false; - for (const view of pointerViews.values()) { + for (const view of [...mirrors.values()]) { const viewAt = Date.parse(view.row.ts ?? ''); if (view.row.task_id !== msg.task_id || view.row.quiz_id === msg.quiz_id || view.row.project_id !== msg.project_id || view.observedAt > requestedAt || !Number.isFinite(namedAt) || !Number.isFinite(viewAt) || viewAt >= namedAt - || !questionRow(view.row).waiting) continue; - changed = updatePointer(view, { task_id: msg.task_id, quiz_id: view.row.quiz_id, + || view.row.quiz_state !== 'open' || !waitFacts(view.row).waiting) continue; + changed = updateMirror(view, { task_id: msg.task_id, quiz_id: view.row.quiz_id, state: 'open', owner_wait_state: 'resumed' }, true) || changed; } return appendQuestionPointer(msg) || changed; @@ -426,16 +522,22 @@ export function createChatDecision({ function setCardState(card, state, answeredIndex) { if (!card) return false; - const current = observe({ task_id: card.dataset.taskId, quiz_id: card.dataset.quizId, - state, answered_index: answeredIndex, comment: card.dataset.ownerComment || '' }); + const mirror = mirrors.get(questionKey(card.dataset.taskId, card.dataset.quizId)); + const frame = { task_id: card.dataset.taskId, quiz_id: card.dataset.quizId, + state, answered_index: answeredIndex, comment: card.dataset.ownerComment || '' }; + const current = state === 'unknown' && (mirror?.needsValidation || !mirror) ? frame : observe(frame); state = current.state; answeredIndex = Number.isInteger(current.answered_index) ? current.answered_index : null; if (current.comment) card.dataset.ownerComment = current.comment; else if (Object.hasOwn(current, 'comment')) delete card.dataset.ownerComment; - const pointer = pointerViews.get(questionKey(card.dataset.taskId, card.dataset.quizId)); - if (pointer) updatePointer(pointer, current); + const mirrored = mirror?.card === card; + if (mirrored) { + mirror.row = { ...mirror.row, ...current, quiz_state: state }; + mirror.signature = mirrorSignature(mirror.row); + } + const focused = mirrored && card.contains?.(document.activeElement); const answerable = ANSWERABLE_QUIZ_STATES.includes(state); - return onDomWrite(() => { + const written = onDomWrite(() => { let changed = card.dataset.state !== state; if (changed) card.dataset.state = state; if (!answerable) { @@ -474,14 +576,27 @@ export function createChatDecision({ }); return changed; }); + if (mirrored) { + // Settling disables or removes the control the owner used: focus stays in this copy + // for the moment it still shows the result. + const active = document.activeElement; + if (focused && (!card.contains?.(active) || active?.disabled)) card.querySelector('.chat-quiz-question')?.focus?.({ preventScroll: true }); + if (state === 'answered') settleMirror(mirror); + } + return written; } - function buildQuizCard(msg) { + function buildQuizCard(msg, mirror = null) { const quiz = normalizeQuiz(msg); - if (!quiz.quizId || !quiz.taskId || !quiz.question || quiz.options.length < 2) return null; + // A Main mirror keeps its way to the Project even while its row cannot carry the whole + // form yet: then it shows what is known and takes no answer (never a guessed one). + const complete = Boolean(quiz.question) && quiz.options.length >= 2; + if (!quiz.quizId || !quiz.taskId || !(complete || mirror)) return null; const key = questionKey(quiz.taskId, quiz.quizId); - const current = observe({ task_id: quiz.taskId, quiz_id: quiz.quizId, state: quiz.state, - ...quiz.waitRow, ...quiz.answerFields }); + const frame = { task_id: quiz.taskId, quiz_id: quiz.quizId, state: quiz.state, + ...quiz.waitRow, ...quiz.answerFields }; + const current = mirror?.needsValidation && quiz.state !== 'answered' + ? { ...frame, state: 'unknown' } : observe(frame); quiz.state = current.state; quiz.answeredIndex = Number.isInteger(current.answered_index) ? current.answered_index : null; quiz.comment = current.comment || ''; @@ -513,11 +628,14 @@ export function createChatDecision({ } const card = document.createElement('div'); - card.className = 'chat-quiz-card'; + card.className = mirror ? 'chat-quiz-card project-question-card' : 'chat-quiz-card'; card.dataset.quizId = quiz.quizId; card.dataset.taskId = quiz.taskId; if (quiz.assumption) card.dataset.assumption = quiz.assumption; quizViews.set(key, card); + // The copy owns its card before the card's first settlement below: a form that arrives + // already answered still starts the copy's countdown (setCardState -> settleMirror). + if (mirror) mirror.card = card; const head = document.createElement('div'); head.className = 'chat-quiz-head'; @@ -531,7 +649,8 @@ export function createChatDecision({ const statusLabel = document.createElement('span'); statusLabel.className = 'chat-quiz-status-text'; status.append(dot, statusLabel); - head.append(chip, status); + // The mirror's one addition to the Project form: the chip that opens it in its Project. + head.append(chip, ...(mirror ? [mirrorChip(mirror)] : []), status); card.append(head); // DRY with the chat surface (owner requirement): question and stake go @@ -540,8 +659,9 @@ export function createChatDecision({ const question = document.createElement('div'); question.className = 'chat-quiz-question'; question.tabIndex = -1; - if (renderMarkdown) question.innerHTML = renderMarkdown(quiz.question); - else question.textContent = quiz.question; + const questionText = quiz.question || 'Open the original question for its text.'; + if (renderMarkdown) question.innerHTML = renderMarkdown(questionText); + else question.textContent = questionText; card.append(question); if (quiz.stake) { @@ -560,7 +680,7 @@ export function createChatDecision({ const optionsBox = document.createElement('div'); optionsBox.className = 'chat-quiz-options'; - quiz.options.forEach((option, index) => { + (complete ? quiz.options : []).forEach((option, index) => { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'chat-quiz-option'; @@ -584,8 +704,8 @@ export function createChatDecision({ }); optionsBox.append(btn); }); - card.append(optionsBox); - if (quiz.detailsUnavailable) { + if (complete) card.append(optionsBox); + if (complete && quiz.detailsUnavailable) { const note = document.createElement('div'); note.className = 'chat-quiz-stake chat-quiz-details-unavailable'; note.textContent = 'Option details were not retained for this older question.'; @@ -596,7 +716,7 @@ export function createChatDecision({ // forced to pick the least wrong one. Always visible while the card // still takes an answer (no disclosure to discover), removed once it // settles — a finished task's card is still answerable. - if (ANSWERABLE_QUIZ_STATES.includes(quiz.state)) { + if (complete && ANSWERABLE_QUIZ_STATES.includes(quiz.state)) { const box = document.createElement('div'); box.className = 'chat-quiz-comment-box'; commentField = document.createElement('textarea'); @@ -652,7 +772,8 @@ export function createChatDecision({ if (quiz.comment) card.dataset.ownerComment = quiz.comment; setCardState(card, quiz.state, quiz.answeredIndex); const framed = frameNode(msg, card); - if (enhanceMarkdown && renderMarkdown) enhanceMarkdown(card); + const disposeMarkdown = enhanceMarkdown && renderMarkdown ? enhanceMarkdown(card) : null; + if (mirror) mirror.disposeMarkdown = disposeMarkdown; return framed; } @@ -829,13 +950,16 @@ export function createChatDecision({ // resumed wait, and as a live fact it outranks any snapshot that still waits. if (frame.wait_for_answer === false && frame.state === 'open') frame = { ...frame, owner_wait_state: 'resumed' }; - frame = observe(frame, true); const key = questionKey(taskId, quizId); - const pointer = pointerViews.get(key); - // Observed once above as live; the pointer repaints from the merged observation. - const changed = pointer ? updatePointer(pointer, frame) : false; + const mirror = mirrors.get(key); + if (mirror) rememberMirror(mirror); + frame = observe(frame, true); + // Observed once above as live; a Main mirror repaints from the merged observation. Only + // the lifecycle rides along: the frame's own send time is not when the question was asked. + if (mirror) return updateMirror(mirror, Object.fromEntries(LIFECYCLE_FIELDS + .filter((field) => Object.hasOwn(frame, field)).map((field) => [field, frame[field]]))); const card = quizViews.get(key); - if (!card) return changed; + if (!card) return false; const index = Number.isInteger(frame.answered_index) ? frame.answered_index : null; // The owner's recorded free-text answer rides the frame (#471) so the // live card shows `Owner's answer:` exactly as the replayed card does. @@ -856,14 +980,21 @@ export function createChatDecision({ waitChanged = true; } } - return setCardState(card, String(frame.state || ''), index) || changed || waitChanged; + return setCardState(card, String(frame.state || ''), index) || waitChanged; + } + + // A released node takes its views with it: a mirror's countdown and rendered markdown go too. + function releaseViews(root) { + for (const [key, card] of quizViews) if (root.contains(card)) quizViews.delete(key); + for (const view of [...mirrors.values()]) if (root.contains(view.bubble)) releaseMirror(view); } return { buildQuizCard, buildQuestionPointer, appendQuestionPointer, appendActivityQuestion, readQuestion, revealQuestion, setCardState, applyQuizStateFrame, renderRoutingDecision, - releaseViews(root) { - for (const [key, card] of quizViews) if (root.contains(card)) quizViews.delete(key); - for (const [key, view] of pointerViews) if (root.contains(view.card)) pointerViews.delete(key); + releaseViews, + destroy() { + disposed = true; + for (const view of [...mirrors.values()]) releaseMirror(view); + observations.clear(); quizViews.clear(); detailReads.clear(); revalidationReads.clear(); }, - destroy() { disposed = true; observations.clear(); quizViews.clear(); pointerViews.clear(); detailReads.clear(); }, }; } diff --git a/web/modules/question_presentation.js b/web/modules/question_presentation.js index e30c2090a..21160a2b8 100644 --- a/web/modules/question_presentation.js +++ b/web/modules/question_presentation.js @@ -1,5 +1,5 @@ -// Read-only presentation of one owner question, shared by the Main row and the -// quiz card header. Task liveness and answerability are separate facts: a status +// Read-only presentation of one owner question: the quiz card header, in the Project and in its +// Main mirror alike. Task liveness and answerability are separate facts: a status // leads with one word that answers "is there an unanswered question for me?" and // keeps the lifecycle context after it. The Python pointer fallback // (ouroboros/project_dialogue.py::QUESTION_STATUS) emits the same words; the shared @@ -18,10 +18,6 @@ const STATUS = { // answered/superseded — turns a card into a pure record). One JS home for both lists. export const QUIZ_LIFECYCLE = ['open', 'answered', 'expired_terminal', 'superseded']; export const ANSWERABLE_QUIZ_STATES = ['open', 'expired_terminal']; -// A passed optional question leads with the path the task took instead of a bare status. -const CONTINUING = 'Unanswered · continuing with:'; -const PREVIEW_CHARS = 280; -const PREVIEW_MARK = '… (preview; open for full text)'; // Waiting needs positive evidence: the task's live wait record, or the original // required flag before any record exists. A resumed record or a closed bound ends it. @@ -39,33 +35,3 @@ export function questionPresentation(row = {}) { : waiting ? 'waiting' : resumed ? 'resumed' : 'open'; return { status: STATUS[key] }; } - -// A cut that saves fewer characters than its own marker is pure damage: such text stays whole. -export function excerpt(text) { - const value = String(text || '').replace(/\s+/g, ' ').trim(); - return value.length > PREVIEW_CHARS + PREVIEW_MARK.length ? `${value.slice(0, PREVIEW_CHARS)}${PREVIEW_MARK}` : value; -} - -export function questionPreview(row = {}) { - const option = Number.isInteger(row.answered_index) ? row.options?.[row.answered_index] : null; - const selected = typeof option === 'string' ? option : option?.label; - // The option and the comment are bounded separately: a long label never hides the comment. - const answer = (row.quiz_state || row.state) === 'answered' - ? [selected, row.comment].filter(Boolean).map(excerpt).join(' — ') : ''; - return { question: excerpt(row.question), answer }; -} - -// The Main row of one Project question: status words, then the owner's answer or the -// assumption the task continues under, then the question as context. `waiting` is the one -// state that grows the row into a card with the option buttons. -export function questionRow(row = {}) { - const state = row.quiz_state || row.state || 'unknown'; - const { answer, question } = questionPreview(row); - const assumption = state === 'open' ? excerpt(row.assumption) : ''; - return { - waiting: state === 'open' && waitFacts(row).waiting, - lead: answer ? `${STATUS.answered}:` : assumption ? CONTINUING : questionPresentation(row).status, - detail: answer || assumption, - question, - }; -} diff --git a/web/style.css b/web/style.css index 4c347f898..8d2cdc0c7 100644 --- a/web/style.css +++ b/web/style.css @@ -310,93 +310,28 @@ body.resizing-panels { user-select: none; } overflow-wrap: anywhere; } -/* === design-system:migrated-begin (chat: Project question row) === */ -/* One Main row per Project question, in the project chip's own language (docs/DESIGN.md - "Project question row"): a line in every settled or passed state, a card with the option - buttons only while the task waits. Consecutive rows sit closer by adjacency alone. */ -.chat-bubble.project-question { - align-self: flex-start; - width: min(100%, 760px); - max-width: min(100%, 760px); - margin-right: 0; - padding: 0; +/* === design-system:migrated-begin (chat: Project question mirror) === */ +/* A Project question mirrored into Main is the Project's own quiz card (docs/DESIGN.md + "Project question mirror"). The one addition is the Project chip in its head: a pill in + the project chip's own language that opens the exact question, with the card's shared + keyboard ring. A long Project name yields first (the chip is capped and ellipsized, its + title names it whole), so the status keeps its place; a phone column wraps the head. */ +.project-question-card .chat-quiz-head { flex-wrap: wrap; } +.chat-quiz-project.chat-live-project-card-btn { + flex: 0 1 auto; width: auto; min-width: 0; max-width: min(100%, 32ch); + margin-inline-end: auto; + padding: 1px 8px; + gap: var(--space-1); border: 1px solid var(--project-18); + border-radius: var(--pill-radius); background: var(--project-08); + color: var(--project); font-size: var(--type-meta); line-height: var(--line-meta); } -.chat-bubble.project-question + .chat-bubble.project-question { margin-top: -4px; } -.project-question-pointer { - min-width: 0; - border-radius: inherit; - overflow-wrap: anywhere; - color: var(--text-primary); font-size: var(--type-body); line-height: var(--line-body); -} -[data-question-mode="row"] > .project-question-pointer { - display: flex; - flex-wrap: wrap; - align-items: baseline; - column-gap: var(--space-2); - padding: var(--space-2) 14px; - cursor: pointer; -} -[data-question-mode="row"] > .project-question-pointer:hover { background: var(--project-12); } -/* The bubble clips nothing, but the ring sits inside like the project chip's. */ -[data-question-mode="row"] > .project-question-pointer:focus-visible { - outline: 2px solid var(--focus-accent-border); - outline-offset: -2px; -} -/* The owner reads the status to act on it: meta ink, never the quieter secondary step. A long - status sentence yields to the line; the Project card keeps it whole. */ -.project-question-status { - display: inline-flex; align-items: center; gap: var(--space-2); min-width: 0; max-width: 42ch; - color: var(--text-meta); font-size: var(--type-meta); white-space: nowrap; -} -.project-question-status-text { overflow: hidden; text-overflow: ellipsis; } -.project-question-pointer[data-state="answered"] .chat-quiz-dot { background: var(--status-ok-fg); } -.project-question-pointer[data-state="superseded"] .chat-quiz-dot, -.project-question-pointer[data-state="expired_terminal"] .chat-quiz-dot { background: var(--text-disabled); } -/* The answer (or the assumption the task took) is the one primary thing and is never cut - to one line; the question is context and is. */ -.project-question-answer { color: var(--text-primary); font-size: var(--type-body); font-weight: 600; } -.project-question-preview { - flex: 1 1 6ch; min-width: 0; - color: var(--text-meta); font-size: var(--type-body); - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; -} -.project-question-pointer[data-state="superseded"] .project-question-preview { color: var(--text-disabled); } -.project-question-source { - max-width: 22ch; - color: var(--project); font-size: var(--type-meta); - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; -} -.project-question-go { color: var(--project); font-size: var(--type-meta); } -.chat-bubble.project-question .msg-time { margin-top: 0; opacity: 1; color: var(--text-meta); white-space: nowrap; } -@container chatcol (max-width: 620px) { - .chat-bubble.project-question { width: 100%; max-width: 100%; } - /* Two lines: status and answer, then question, project and time. */ - [data-question-mode="row"] > .project-question-pointer::before { content: ''; flex: 0 0 100%; height: 0; order: 2; } - [data-question-mode="row"] > .project-question-pointer > .project-question-status, - [data-question-mode="row"] > .project-question-pointer > .project-question-answer { order: 1; } - [data-question-mode="row"] > .project-question-pointer > .project-question-preview, - [data-question-mode="row"] > .project-question-pointer > .project-question-source, - [data-question-mode="row"] > .project-question-pointer > .project-question-go, - [data-question-mode="row"] > .project-question-pointer > .msg-time { order: 3; } - /* The question keeps a readable share beside the Project and a dated time. */ - .project-question-status { max-width: 100%; } - .project-question-preview { flex: 1 1 0; min-width: 12ch; font-size: var(--type-meta); } - .project-question-source { flex: 0 1 auto; max-width: 26%; } -} -/* The waiting card: the same chip, opened. Its head states the wait as dot + text. */ -.project-question-pointer .chat-live-project-status { - display: inline-flex; align-items: center; gap: var(--space-2); - color: var(--text-meta); font-size: var(--type-meta); -} -.project-question-pointer .chat-live-project-status::before { - content: ''; width: 8px; height: 8px; border-radius: 50%; background: var(--status-neutral-fg); -} -.project-question-body { display: flex; flex-direction: column; gap: var(--space-2); padding: var(--space-1) 14px var(--space-3); } -.project-question-body .chat-quiz-question { font-weight: 400; } -.project-question-foot { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); } -.project-question-foot .system-message-actions { padding-block: 0; } +.chat-quiz-project.chat-live-project-card-btn:hover { background: var(--project-12); } +.chat-quiz-project.chat-live-project-card-btn:focus-visible { outline: 2px solid var(--focus-accent-border); outline-offset: 2px; } +.chat-quiz-project .chat-live-project-icon { color: var(--project); font-size: var(--type-meta); } +.chat-quiz-project .chat-live-project-name { font-weight: 500; color: var(--project); font-size: var(--type-meta); } +.chat-quiz-project .chat-live-project-status { margin-inline-start: 0; color: var(--project); font-size: var(--type-meta); } /* === design-system:migrated-end === */ diff --git a/web/tests/chat_decision_fixture.js b/web/tests/chat_decision_fixture.js index a36da0c1d..0452db6ac 100644 --- a/web/tests/chat_decision_fixture.js +++ b/web/tests/chat_decision_fixture.js @@ -1,4 +1,4 @@ -// Shared by chat_decision.test.js and question_rows.test.js: a small DOM stub (descendant lookup +// Shared by chat_decision.test.js and question_mirrors.test.js: a small DOM stub (descendant lookup // by class), the chat-decision fixture and one quiz frame. import { createChatDecision } from '../modules/chat_decision.js'; @@ -43,6 +43,21 @@ export class NodeStub { node.parentNode = parent; parent.children.splice(parent.children.indexOf(this), 0, node); } + replaceWith(node) { + const parent = this.parentNode; + if (!parent) return; + node.parentNode = parent; + parent.children.splice(parent.children.indexOf(this), 1, node); + this.parentNode = null; + } + get nextElementSibling() { + const siblings = this.parentNode?.children || []; + return siblings[siblings.indexOf(this) + 1] || null; + } + // Like a browser, :focus-visible follows the last input modality, not the element. + matches(selector) { + return selector === ':focus-visible' && globalThis.document.activeElement === this && !globalThis.document.pointerModality; + } addEventListener(type, handler) { this.listeners.set(type, handler); } click(event = {}) { const handler = this.listeners.get('click'); @@ -79,7 +94,8 @@ export function countPropertyWrites(target, key) { return () => writes; } -export function fixture({ fetchImpl, renderMarkdown, enhanceMarkdown, onDomWrite, fetchDetail, isMain = false } = {}) { +export function fixture({ fetchImpl, renderMarkdown, enhanceMarkdown, onDomWrite, fetchDetail, isMain = false, + frameNode = (_msg, node) => node, insertMessageNode = null, removeMessageNode = null, focusAfterRemoval = null } = {}) { const prior = { document: globalThis.document, crypto: globalThis.crypto, window: globalThis.window }; globalThis.document = { createElement: (tag) => new NodeStub(tag) }; const opened = []; @@ -98,13 +114,16 @@ export function fixture({ fetchImpl, renderMarkdown, enhanceMarkdown, onDomWrite ...(Number.isInteger(sent.option_index) ? { answered_index: sent.option_index } : {}), ...(sent.comment ? { comment: sent.comment } : {}) }) }; }, - frameNode: (_msg, node) => node, + frameNode, renderMarkdown, enhanceMarkdown: enhanceMarkdown || (renderMarkdown ? () => {} : null), showToast: (text, tone) => toasts.push({ text, tone }), onDomWrite, fetchDetail, isMain, + insertMessageNode, + removeMessageNode, + focusAfterRemoval, }); return { decision, toasts, calls, opened, restore: () => { globalThis.document = prior.document; diff --git a/web/tests/question_mirrors.test.js b/web/tests/question_mirrors.test.js new file mode 100644 index 000000000..a732ed937 --- /dev/null +++ b/web/tests/question_mirrors.test.js @@ -0,0 +1,616 @@ +// Project questions in Main (docs/DESIGN.md "Project question mirror"): every open, passed, +// finished or replaced Project question is the Project's own form in Main, plus one chip that +// opens it in its Project. The first confirmed answer from any source shows the result for five +// seconds and removes only the Main copy; an answered question never enters Main again. +import assert from 'node:assert/strict'; +import test, { mock } from 'node:test'; +import { NodeStub, fixture, turn } from './chat_decision_fixture.js'; + +// The rows the producer emits (ouroboros/project_dialogue.py::project_question_pointer): complete +// for display, `ts` from the question's own asked_at. One batch is asked in sequence. +const ASKED = ['2026-09-18T22:00:00+00:00', '2026-09-18T22:00:07+00:00']; +const ROW = { role: 'system', system_type: 'project_question_pointer', task_id: 't-1', quiz_id: 'qz-1', + project_id: 'p1', project_chat_id: 23, project_name: 'Storage', ts: ASKED[0], quiz_state: 'open', + question: 'Merge **now**?', options: ['Yes', 'No'], option_details: ['Ship today', 'Wait for CI'], + stake: 'Release timing', recommended_index: 0 }; +const WAITING = { ...ROW, wait_for_answer: true, owner_wait_state: 'waiting' }; +const SETTLE_MS = 5000; +const text = (node, name) => node.querySelector(`.${name}`)?.textContent ?? null; +const options = (node) => node.querySelectorAll('.chat-quiz-option'); +const labels = (node) => options(node).map((button) => text(button, 'chat-quiz-option-label')); + +// A Main column: bubbles are framed and mounted like chat.js does, and retirement goes through +// the one removal seam the chat instance hands over. +function mainFixture(extra = {}) { + const column = new NodeStub(); + const removed = []; + const fx = fixture({ isMain: true, + frameNode: (_msg, card) => { const bubble = new NodeStub(); bubble.append(card); return bubble; }, + insertMessageNode: (node) => { column.append(node); return true; }, + removeMessageNode: (node) => { removed.push(node); node.remove(); }, + ...extra }); + const cards = () => column.children.map((bubble) => bubble.children[0]); + return { ...fx, column, removed, cards, add: (row) => fx.decision.appendQuestionPointer(row) }; +} + +function withTimers(run) { + mock.timers.enable({ apis: ['setTimeout'] }); + return Promise.resolve().then(run).finally(() => mock.timers.reset()); +} + +test('a realistic burst: every unanswered question is the full Project form, an answered one never enters Main', () => { + const fx = mainFixture(); + try { + fx.add({ ...ROW, quiz_id: 'q-answered', quiz_state: 'answered', answered_index: 0 }); + fx.add({ ...WAITING, quiz_id: 'q-resumed', owner_wait_state: 'resumed' }); + fx.add({ ...ROW, quiz_id: 'q-optional', assumption: 'Yes meanwhile' }); + fx.add({ ...ROW, quiz_id: 'q-finished', quiz_state: 'expired_terminal', wait_for_answer: true }); + fx.add({ ...ROW, quiz_id: 'q-replaced', quiz_state: 'superseded' }); + fx.add({ ...WAITING, quiz_id: 'q-waiting', ts: ASKED[1] }); + const cards = fx.cards(); + assert.deepEqual(cards.map((card) => card.dataset.quizId), ['q-resumed', 'q-optional', 'q-finished', 'q-replaced', 'q-waiting']); + assert.deepEqual(cards.map((card) => text(card, 'chat-quiz-status-text')), [ + 'Unanswered · the task continued; an answer is still accepted', + 'Unanswered · an answer is still accepted', + 'Unanswered · the task finished; a late answer is accepted as your message', + 'Replaced by a newer question', + 'Waiting for your answer', + ]); + for (const card of cards) { + assert.ok(card.classList.contains('chat-quiz-card') && card.classList.contains('project-question-card')); + assert.equal(text(card, 'chat-quiz-chip'), 'Question'); + assert.equal(text(card, 'chat-live-project-name'), 'Storage'); + assert.equal(text(card, 'chat-quiz-question'), 'Merge **now**?'); + assert.equal(text(card, 'chat-quiz-stake'), 'At stake: Release timing'); + assert.deepEqual(labels(card), ['Yes', 'No']); + assert.deepEqual(options(card).map((button) => text(button, 'chat-quiz-option-detail')), ['Ship today', 'Wait for CI']); + assert.ok(options(card)[0].querySelector('.chat-quiz-option-recommended'), 'index zero is a recommendation too'); + assert.equal(card.querySelector('.chat-quiz-details-unavailable'), null); + } + // Answerable questions carry the own-answer field; the replaced one is a read-only record. + assert.deepEqual(cards.map((card) => Boolean(card.querySelector('.chat-quiz-comment'))), [true, true, true, false, true]); + assert.deepEqual(cards.map((card) => options(card)[0].disabled), [false, false, false, true, false]); + assert.equal(text(cards[1], 'chat-quiz-assumption'), 'Continuing meanwhile: Yes meanwhile'); + assert.equal(text(cards[4], 'chat-quiz-assumption'), 'Waiting for your answer; Stop and the task deadline still apply.'); + assert.match(text(cards[0], 'chat-quiz-assumption'), /^The wait ended without an answer/); + } finally { fx.restore(); } +}); + +test('the mirror is the Project form node for node; its one addition is the Project chip', () => { + const shape = (node) => [node.className, node.textContent, node.disabled, node.children.map(shape)]; + const room = fixture(); + const main = mainFixture(); + try { + main.add({ ...WAITING, assumption: 'Yes meanwhile' }); + const mirror = main.cards()[0]; + // The Project room replays the same ask from its own chat row (option objects, stake). + const own = room.decision.buildQuizCard({ role: 'assistant', task_id: 't-1', ts: ASKED[0], text: 'Merge **now**?', + msg_type: 'quiz', quiz: { quiz_id: 'qz-1', state: 'open', wait_for_answer: true, owner_wait_state: 'waiting', + stake: 'Release timing', assumption: 'Yes meanwhile', options: [ + { label: 'Yes', detail: 'Ship today', recommended: true }, { label: 'No', detail: 'Wait for CI' }] } }); + const [head, ...body] = mirror.children; + const [ownHead, ...ownBody] = own.children; + assert.deepEqual(body.map(shape), ownBody.map(shape)); + assert.deepEqual(head.children.map((node) => node.className), + ['chat-quiz-chip', 'chat-live-project-card-btn chat-quiz-project', 'chat-quiz-status']); + assert.deepEqual(ownHead.children.map((node) => node.className), ['chat-quiz-chip', 'chat-quiz-status']); + assert.deepEqual(shape(head.children[2]), shape(ownHead.children[1])); + assert.equal(own.classList.contains('project-question-card'), false); + const chip = head.children[1]; + assert.equal(chip.querySelector('.chat-live-project-status').getAttribute('aria-hidden'), 'true'); + chip.click(); + assert.deepEqual(main.opened.map((detail) => [detail.project.id, detail.project.chat_id, detail.task_id, detail.quiz_id]), + [['p1', 23, 't-1', 'qz-1']]); + } finally { main.restore(); room.restore(); } +}); + +test('an answer pressed in Main shows the result for five seconds, then only the Main copy goes', () => withTimers(async () => { + const fx = mainFixture(); + try { + fx.add(WAITING); + const [card] = fx.cards(); + options(card)[1].click(); + options(card)[0].click(); + await turn(); + const sent = JSON.parse(fx.calls[0].init.body); + assert.deepEqual([fx.calls.length, sent.decision_id, sent.option_index, 'comment' in sent], [1, 'quiz:t-1:qz-1', 1, false]); + assert.deepEqual([card.dataset.state, text(card, 'chat-quiz-status-text')], ['answered', 'You answered']); + assert.ok(options(card)[1].classList.contains('chosen') && options(card).every((button) => button.disabled)); + assert.equal(card.querySelector('.chat-quiz-comment'), null, 'a settled copy takes no more input'); + mock.timers.tick(SETTLE_MS - 1); + assert.equal(fx.column.children.length, 1, 'the result stays readable for the whole moment'); + mock.timers.tick(1); + assert.deepEqual([fx.column.children.length, fx.removed.length], [0, 1]); + // Neither the stale open snapshot nor the answered one brings it back. + fx.add(WAITING); + fx.add({ ...ROW, quiz_state: 'answered', answered_index: 1 }); + fx.decision.appendActivityQuestion({ ...WAITING, owner_wait_state: 'waiting' }); + assert.deepEqual([fx.column.children.length, fx.calls.length], [0, 1]); + } finally { fx.restore(); } +})); + +test('an answer from any other source settles the copy once; repeated observations never restart the countdown', () => withTimers(async () => { + const fx = mainFixture(); + try { + fx.add({ ...WAITING, quiz_id: 'from-project' }); + fx.add({ ...ROW, quiz_id: 'from-history' }); + fx.add({ ...WAITING, quiz_id: 'from-census', ts: ASKED[1] }); + const [project, history, census] = fx.cards(); + // The Project form (or another device) answered: the live quiz_state frame. + fx.decision.applyQuizStateFrame({}, { type: 'quiz_state', task_id: 't-1', quiz_id: 'from-project', state: 'answered', + answered_index: 0, comment: 'From the Project form.', ts: '2026-09-18T23:59:59+00:00' }); + assert.deepEqual([text(project, 'chat-quiz-status-text'), text(project, 'chat-quiz-answer')], + ['You answered', "Owner's answer: From the Project form."]); + mock.timers.tick(3000); + // A history re-read carries the same answer, and so does the next census tick. + fx.add({ ...ROW, quiz_id: 'from-project', quiz_state: 'answered', answered_index: 0 }); + fx.add({ ...ROW, quiz_id: 'from-history', quiz_state: 'answered', comment: 'Neither — use the archive.' }); + fx.decision.appendActivityQuestion({ ...WAITING, quiz_id: 'from-census', ts: ASKED[1], quiz_state: 'answered', + answered_index: 1, owner_wait_state: 'resumed' }); + assert.equal(text(history, 'chat-quiz-answer'), "Owner's answer: Neither — use the archive."); + assert.ok(options(census)[1].classList.contains('chosen')); + mock.timers.tick(1999); + fx.decision.applyQuizStateFrame({}, { task_id: 't-1', quiz_id: 'from-project', state: 'answered', answered_index: 0 }); + mock.timers.tick(1); + assert.deepEqual(fx.cards().map((card) => card.dataset.quizId), ['from-history', 'from-census'], + 'the first answer started the only countdown'); + mock.timers.tick(3000); + assert.deepEqual([fx.column.children.length, fx.removed.length], [0, 3]); + assert.equal(fx.calls.length, 0, 'observing an answer never sends one'); + } finally { fx.restore(); } +})); + +test('own words answer from Main; a failed attempt stays answerable and a lost race settles into the winner', () => withTimers(async () => { + const replies = [ + { ok: false, status: 500, json: async () => ({}) }, + { ok: false, status: 409, json: async () => ({ state: 'answered', answered_index: 1, comment: 'Winner.' }) }, + ]; + const fx = mainFixture({ fetchImpl: async (_url, init) => replies.shift() + || { ok: true, status: 200, json: async () => ({ ok: true, state: 'answered', comment: JSON.parse(init.body).comment }) } }); + try { + fx.add(WAITING); + fx.add({ ...ROW, quiz_id: 'own-words' }); + const [raced, own] = fx.cards(); + options(raced)[0].click(); + await turn(); + assert.deepEqual([raced.dataset.state, fx.toasts[0].text], ['open', 'Could not record the answer (500).']); + mock.timers.tick(SETTLE_MS); + assert.equal(fx.column.children.length, 2, 'a refused attempt starts no countdown'); + options(raced)[0].click(); + await turn(); + assert.deepEqual([raced.dataset.state, text(raced, 'chat-quiz-answer'), fx.toasts[1].text], + ['answered', "Owner's answer: Winner.", 'Already answered.']); + assert.ok(options(raced)[1].classList.contains('chosen')); + const field = own.querySelector('.chat-quiz-comment'); + field.value = 'Neither — keep both.'; + field.listeners.get('input')(); + own.querySelector('.chat-quiz-send').click(); + await turn(); + const sent = JSON.parse(fx.calls.at(-1).init.body); + assert.deepEqual([sent.comment, 'option_index' in sent], ['Neither — keep both.', false]); + assert.equal(text(own, 'chat-quiz-answer'), "Owner's answer: Neither — keep both."); + mock.timers.tick(SETTLE_MS); + assert.equal(fx.column.children.length, 0); + } finally { fx.restore(); } +})); + +test('an unknown source keeps what is known; otherwise the copy is a safe record with its way to the Project', () => { + const fx = mainFixture(); + try { + fx.add(WAITING); + fx.add({ ...ROW, quiz_state: 'unknown', source_status: 'unavailable', question: '', options: [] }); + const [known] = fx.cards(); + assert.deepEqual([known.dataset.state, text(known, 'chat-quiz-status-text')], ['open', 'Waiting for your answer']); + assert.equal(options(known)[0].disabled, false, 'an unavailable read never disables a known question'); + fx.add({ ...ROW, quiz_id: 'cold', quiz_state: 'unknown', source_status: 'unavailable' }); + const cold = fx.cards()[1]; + assert.deepEqual([cold.dataset.state, text(cold, 'chat-quiz-status-text')], ['unknown', 'Status unavailable']); + assert.ok(options(cold).every((button) => button.disabled) && !cold.querySelector('.chat-quiz-comment'), + 'no invented invitation to answer'); + // The source becomes readable: the whole answerable form mounts where the record was. + fx.add({ ...ROW, quiz_id: 'cold', quiz_state: 'open' }); + const upgraded = fx.cards()[1]; + assert.notEqual(upgraded, cold); + assert.deepEqual([upgraded.dataset.state, Boolean(upgraded.querySelector('.chat-quiz-comment'))], ['open', true]); + // A census row that names a wait but not its form: a partial copy, then the form in place. + fx.decision.appendActivityQuestion({ task_id: 't-9', quiz_id: 'narrow', project_id: 'p1', project_chat_id: 23, + quiz_state: 'open', owner_wait_state: 'waiting', ts: ASKED[1] }); + const partial = fx.cards()[2]; + assert.deepEqual([text(partial, 'chat-quiz-question'), options(partial).length, Boolean(partial.querySelector('.chat-quiz-comment'))], + ['Open the original question for its text.', 0, false]); + partial.querySelector('.chat-quiz-project').click(); + assert.equal(fx.opened.at(-1).quiz_id, 'narrow'); + fx.add({ ...WAITING, task_id: 't-9', quiz_id: 'narrow', ts: ASKED[1], project_name: 'Storage v2' }); + const full = fx.cards()[2]; + assert.deepEqual([labels(full), text(full, 'chat-live-project-name'), fx.column.children.length], [['Yes', 'No'], 'Storage v2', 3]); + } finally { fx.restore(); } +}); + +test('a partial copy that learns its form together with the answer still shows the result, then goes', () => withTimers(() => { + const fx = mainFixture(); + const narrow = (quizId) => fx.decision.appendActivityQuestion({ task_id: 't-9', quiz_id: quizId, project_id: 'p1', + project_chat_id: 23, quiz_state: 'open', owner_wait_state: 'waiting', ts: ASKED[1] }); + try { + // The census names the wait before any row carries its form; the next history read is + // complete and already answered (the Project form or another device answered meanwhile). + narrow('at-once'); + const partial = fx.cards()[0]; + assert.equal(options(partial).length, 0); + fx.add({ ...WAITING, task_id: 't-9', quiz_id: 'at-once', ts: ASKED[1], quiz_state: 'answered', answered_index: 1, + owner_wait_state: 'resumed' }); + const full = fx.cards()[0]; + assert.notEqual(full, partial, 'the whole form mounts in place of the partial copy'); + assert.deepEqual([full.dataset.state, text(full, 'chat-quiz-status-text'), labels(full)], ['answered', 'You answered', ['Yes', 'No']]); + assert.ok(options(full)[1].classList.contains('chosen') && options(full).every((button) => button.disabled)); + mock.timers.tick(SETTLE_MS - 1); + assert.equal(fx.column.children.length, 1, 'the result stays readable for the whole moment'); + mock.timers.tick(1); + assert.deepEqual([fx.column.children.length, fx.removed.length], [0, 1]); + // An answer observed while the copy was still partial keeps its one countdown across the remount. + narrow('answered-first'); + fx.decision.applyQuizStateFrame({}, { task_id: 't-9', quiz_id: 'answered-first', state: 'answered', answered_index: 0 }); + mock.timers.tick(2000); + fx.add({ ...WAITING, task_id: 't-9', quiz_id: 'answered-first', ts: ASKED[1], quiz_state: 'answered', answered_index: 0 }); + assert.deepEqual([labels(fx.cards()[0]), options(fx.cards()[0])[0].classList.contains('chosen')], [['Yes', 'No'], true]); + mock.timers.tick(SETTLE_MS - 2001); + assert.equal(fx.column.children.length, 1); + mock.timers.tick(1); + assert.deepEqual([fx.column.children.length, fx.removed.length], [0, 2], 'never restarted by the remount'); + } finally { fx.restore(); } +})); + +test('a removed copy stays removed after the bounded memory forgets it: the canonical source decides', () => withTimers(async () => { + // The task detail the owner-quiz store serves (GET /api/tasks/{id}): the canonical lifecycle. + const canonical = { 't-1': { 'qz-1': 'open', 'fresh': 'open', 'kept': 'open' } }; + const reads = []; + let unavailable = false; + const fetchDetail = async (taskId) => { + reads.push(taskId); + if (unavailable) throw new Error('detail read failed'); + return { task_id: taskId, project_id: 'p1', owner_quiz: Object.fromEntries(Object.entries(canonical[taskId] || {}) + .map(([quizId, state]) => [quizId, { quiz_id: quizId, state, question: 'Merge **now**?', options: ['Yes', 'No'], + asked_at: ASKED[0], ...(state === 'answered' ? { answered_index: 0 } : {}) }])) }; + }; + const fx = mainFixture({ fetchDetail }); + let noise = 0; + const forget = () => { + // A long-lived Main observes many other questions (every quiz_state frame is one) past + // the memory's bound (OBSERVATION_LIMIT, 2000). + for (let i = 0; i < 2001; i += 1) + fx.decision.applyQuizStateFrame({}, { task_id: 'noise', quiz_id: `n-${noise += 1}`, state: 'open' }); + }; + try { + fx.add(WAITING); + fx.add({ ...WAITING, quiz_id: 'kept' }); + fx.decision.applyQuizStateFrame({}, { task_id: 't-1', quiz_id: 'qz-1', state: 'answered', answered_index: 0 }); + canonical['t-1']['qz-1'] = 'answered'; + mock.timers.tick(SETTLE_MS); + assert.deepEqual(fx.cards().map((card) => card.dataset.quizId), ['kept']); + assert.equal(reads.length, 0, 'while this tab still remembers, nothing is re-read'); + forget(); + // A stale open snapshot (a history page or census read begun before the answer): the + // copy is not resurrected, neither at once nor after the canonical read. + fx.add(WAITING); + fx.decision.appendActivityQuestion({ ...WAITING }); + assert.deepEqual(fx.cards().map((card) => card.dataset.state), ['open', 'unknown']); + await turn(); + assert.deepEqual([fx.cards().map((card) => card.dataset.quizId), reads], [['kept'], ['t-1']], + 'one canonical read for the question, however many snapshots named it'); + fx.add(WAITING); + assert.deepEqual([fx.cards().length, reads.length], [1, 1], 'the canonical answer is remembered again'); + // The copy still shown keeps its own lifecycle: a stale snapshot cannot move it backwards. + fx.decision.applyQuizStateFrame({}, { task_id: 't-1', quiz_id: 'kept', state: 'expired_terminal' }); + forget(); + fx.add({ ...WAITING, quiz_id: 'kept' }); + assert.equal(fx.cards()[0].dataset.state, 'expired_terminal'); + // A question this tab never saw still arrives, once the canonical read confirms it is unanswered. + fx.add({ ...WAITING, quiz_id: 'fresh', ts: ASKED[1] }); + assert.equal(fx.cards()[1].dataset.state, 'unknown'); + await turn(); + assert.deepEqual(fx.cards().map((card) => [card.dataset.quizId, card.dataset.state]), [['kept', 'expired_terminal'], ['fresh', 'open']]); + assert.equal(Boolean(fx.cards()[1].querySelector('.chat-quiz-comment')), true); + // An unavailable canonical read proves nothing: do not turn the stale open + // snapshot into an answerable card. Its chip remains a recovery path. + forget(); + unavailable = true; + fx.add({ ...WAITING, quiz_id: 'unreadable' }); + await turn(); + const unknown = fx.cards()[2]; + assert.equal(unknown.dataset.state, 'unknown'); + assert.ok(options(unknown).every(button => button.disabled)); + unknown.querySelector('.chat-quiz-project').click(); + assert.equal(fx.opened.at(-1).quiz_id, 'unreadable'); + unavailable = false; + canonical['t-1'].unreadable = 'open'; + fx.add({ ...WAITING, quiz_id: 'unreadable' }); + assert.equal(unknown.dataset.state, 'unknown', 'a stale re-delivery cannot unlock the form'); + const bubble = fx.column.children[2]; + fx.decision.releaseViews(bubble); bubble.remove(); + fx.add({ ...WAITING, quiz_id: 'unreadable' }); + await turn(); + assert.equal(fx.cards()[2].dataset.state, 'open', 'revisiting the history row can recover too'); + // An answer observed while the canonical read is in flight wins; so does a destroyed Main. + unavailable = false; + forget(); + fx.add({ ...WAITING, quiz_id: 'answered-meanwhile' }); + fx.decision.applyQuizStateFrame({}, { task_id: 't-1', quiz_id: 'answered-meanwhile', state: 'answered', answered_index: 1 }); + fx.add({ ...WAITING, quiz_id: 'torn-down' }); + fx.decision.destroy(); + await turn(); + assert.equal(fx.cards().length, 5, 'destroy never lets the pending safe copy become answerable'); + assert.equal(fx.cards()[3].dataset.state, 'answered'); + assert.equal(fx.cards()[4].dataset.state, 'unknown'); + assert.equal(fx.calls.length, 0, 'revalidation never answers anything'); + } finally { fx.restore(); } +})); + +test('failed, missing and foreign canonical reads keep a safe Project recovery path after eviction', () => withTimers(async () => { + for (const failure of ['network', '404', 'missing', 'wrong-project', 'wrong-task', 'wrong-quiz', 'unknown']) { + let recovered = false; + const detail = () => ({ task_id: 't-1', project_id: 'p1', owner_quiz: { + stale: { quiz_id: 'stale', state: 'open', question: 'Canonical source', options: ['A', 'B'] }, + } }); + const fx = mainFixture({ fetchDetail: async () => { + if (recovered) return detail(); + if (failure === 'network' || failure === '404') throw new Error(failure); + const row = detail(); + if (failure === 'missing') row.owner_quiz = {}; + if (failure === 'wrong-project') row.project_id = 'p2'; + if (failure === 'wrong-task') row.task_id = 'other'; + if (failure === 'wrong-quiz') row.owner_quiz.stale.quiz_id = 'other'; + if (failure === 'unknown') row.owner_quiz.stale.state = 'unknown'; + return row; + } }); + try { + fx.add({ ...WAITING, quiz_id: 'stale' }); + fx.decision.applyQuizStateFrame({}, { task_id: 't-1', quiz_id: 'stale', state: 'answered', answered_index: 1 }); + mock.timers.tick(SETTLE_MS); + assert.equal(fx.cards().length, 0); + for (let i = 0; i < 2001; i += 1) + fx.decision.applyQuizStateFrame({}, { task_id: 'noise', quiz_id: `n-${i}`, state: 'open' }); + fx.add({ ...WAITING, quiz_id: 'stale', question: 'Snapshot source' }); + await turn(); + const card = fx.cards()[0]; + assert.equal(text(card, 'chat-quiz-status-text'), 'Status unavailable', failure); + assert.ok(options(card).every(button => button.disabled) && !card.querySelector('.chat-quiz-comment')); + fx.add({ ...WAITING, quiz_id: 'stale' }); + await turn(); + fx.decision.applyQuizStateFrame({}, { task_id: 't-1', quiz_id: 'stale', state: 'open' }); + assert.equal(card.dataset.state, 'unknown', 'snapshots and non-answer live facts cannot unlock an unverified copy'); + card.querySelector('.chat-quiz-project').click(); + assert.equal(fx.opened.at(-1).quiz_id, 'stale'); + // An explicit retry of the owned pointer can recover the canonical Project form. + recovered = true; + fx.add({ ...WAITING, quiz_id: 'stale' }); + await turn(); + assert.equal(fx.cards()[0].dataset.state, 'open'); + assert.equal(text(fx.cards()[0], 'chat-quiz-question'), 'Canonical source'); + } finally { fx.decision.destroy(); fx.restore(); } + } +})); + +test('an answered pointer settles a pending safe copy for five seconds', () => withTimers(async () => { + const pending = []; + const fx = mainFixture({ fetchDetail: () => new Promise(resolve => pending.push(resolve)) }); + try { + for (let i = 0; i < 2001; i += 1) + fx.decision.applyQuizStateFrame({}, { task_id: 'noise', quiz_id: `n-${i}`, state: 'open' }); + fx.add({ ...WAITING, quiz_id: 'forgotten' }); + await turn(); + const unknown = fx.cards()[0]; + assert.equal(unknown.dataset.state, 'unknown'); + // This could be an already-removed answer whose observation was evicted. A positive + // answered pointer shows its recorded result for the normal Main-only settlement. + fx.add({ ...WAITING, quiz_id: 'forgotten', quiz_state: 'answered', answered_index: 1 }); + assert.equal(unknown.dataset.state, 'answered'); + assert.ok(options(unknown).every(button => button.disabled)); + pending[0]({ task_id: 't-1', project_id: 'p1', owner_quiz: { + forgotten: { quiz_id: 'forgotten', state: 'answered', answered_index: 1, + question: 'Canonical source', options: ['A', 'B'] }, + } }); + await turn(); + assert.equal(fx.column.children.length, 1, 'canonical answer cannot cut the live result short'); + mock.timers.tick(SETTLE_MS - 1); + assert.equal(fx.column.children.length, 1); + mock.timers.tick(1); + assert.equal(fx.column.children.length, 0); + } finally { fx.decision.destroy(); fx.restore(); } +})); + +test('revalidation bypasses an already-started navigation detail read', () => withTimers(async () => { + const pending = []; + const fx = mainFixture({ fetchDetail: () => new Promise(resolve => pending.push(resolve)) }); + const detail = (state) => ({ task_id: 't-1', project_id: 'p1', owner_quiz: { + stale: { quiz_id: 'stale', state, answered_index: state === 'answered' ? 1 : null, + question: 'Canonical source', options: ['A', 'B'] }, + } }); + try { + for (let i = 0; i < 2001; i += 1) + fx.decision.applyQuizStateFrame({}, { task_id: 'noise', quiz_id: `n-${i}`, state: 'open' }); + const old = fx.decision.readQuestion('t-1', 'stale', 'p1'); + await turn(); + fx.add({ ...WAITING, quiz_id: 'stale' }); + await turn(); + assert.equal(pending.length, 2, 'a read already on the wire predates the revalidation'); + pending[0](detail('open')); + await old; + await turn(); + assert.equal(fx.cards().at(-1).dataset.state, 'unknown', 'the old open observation cannot authorize the copy'); + pending[1](detail('answered')); + await turn(); + assert.ok(!fx.cards().some(card => card.dataset.quizId === 'stale')); + } finally { fx.decision.destroy(); fx.restore(); } +})); + +test('history retirement cancels pending revalidation and an old response cannot change a replacement copy', () => withTimers(async () => { + const pending = [], queued = []; + let queueNext = false; + const fx = mainFixture({ onDomWrite: mutate => { + if (queueNext) { queueNext = false; queued.push(mutate); return; } + return mutate(); + }, fetchDetail: () => new Promise(resolve => pending.push(resolve)) }); + const detail = state => ({ task_id: 't-1', project_id: 'p1', owner_quiz: { + stale: { quiz_id: 'stale', state, question: 'Canonical source', options: ['A', 'B'] }, + } }); + try { + for (let i = 0; i < 2001; i += 1) + fx.decision.applyQuizStateFrame({}, { task_id: 'noise', quiz_id: `n-${i}`, state: 'open' }); + fx.add({ ...WAITING, quiz_id: 'stale', history_id: 'page-row' }); + await turn(); + const old = fx.column.children[0]; + // Exactly the existing chat.js releaseMessageNode seam used by history eviction. + fx.decision.releaseViews(old); old.remove(); + fx.add({ ...WAITING, quiz_id: 'stale', history_id: 'page-row' }); + await turn(); + pending[0](detail('open')); + await turn(); + assert.equal(fx.cards()[0].dataset.state, 'unknown', 'retired read cannot unlock the replacement'); + queueNext = true; + pending[1](detail('open')); + await turn(); + assert.equal(queued.length, 1); + const replacement = fx.column.children[0]; + fx.decision.releaseViews(replacement); replacement.remove(); + for (const mutate of queued.splice(0)) mutate(); + assert.equal(fx.column.children.length, 0, 'even a queued DOM continuation respects retirement'); + fx.decision.destroy(); + fx.add({ ...WAITING, quiz_id: 'stale' }); + assert.equal(fx.column.children.length, 0); + } finally { fx.restore(); } +})); + +test('an unchanged row writes nothing and a narrower re-delivery never blanks the form', () => { + let writes = 0; + const fx = mainFixture({ onDomWrite: (mutate) => { writes += 1; return mutate(); } }); + try { + fx.add({ ...WAITING, assumption: 'Yes meanwhile' }); + const painted = writes; + fx.add({ ...WAITING, assumption: 'Yes meanwhile', ts: ASKED[0], history_id: 'later' }); + // The activity census re-delivers the waiting question without its display fields. + fx.decision.buildQuestionPointer({ ...WAITING, question: '', options: [], option_details: [], stake: '', + assumption: '', recommended_index: null, project_name: '' }); + assert.equal(writes, painted + 1, 'only the append attempt itself; the card is untouched'); + const [card] = fx.cards(); + assert.deepEqual([labels(card), text(card, 'chat-quiz-stake'), text(card, 'chat-live-project-name')], + [['Yes', 'No'], 'At stake: Release timing', 'Storage']); + fx.decision.buildQuestionPointer({ ...WAITING, project_name: 'Storage v2' }); + assert.equal(text(card, 'chat-live-project-name'), 'Storage v2', 'a renamed Project renames the chip in place'); + } finally { fx.restore(); } +}); + +test('a fresh single-wait census ends older waits in place without settling or removing them', () => withTimers(() => { + const fx = mainFixture(); + try { + fx.add(WAITING); + fx.add({ ...WAITING, quiz_id: 'q2', ts: ASKED[1] }); + fx.add({ ...WAITING, task_id: 'other' }); + fx.decision.appendActivityQuestion({ ...WAITING, quiz_id: 'q2', ts: ASKED[1] }, 0); + const [older, newer, foreign] = fx.cards(); + assert.equal(text(older, 'chat-quiz-status-text'), 'Waiting for your answer', 'a pre-arrival read cannot end a newer wait'); + fx.decision.appendActivityQuestion({ ...WAITING, quiz_id: 'q2', ts: ASKED[1] }); + assert.deepEqual([older, newer, foreign].map((card) => text(card, 'chat-quiz-status-text')), [ + 'Unanswered · the task continued; an answer is still accepted', 'Waiting for your answer', 'Waiting for your answer']); + assert.match(text(older, 'chat-quiz-assumption'), /^The wait ended without an answer/); + assert.deepEqual([older.dataset.state, Boolean(older.querySelector('.chat-quiz-comment'))], ['open', true]); + // Neither a missing stamp nor a sub-millisecond tie is an order. + fx.add({ ...WAITING, quiz_id: 'q3', ts: '' }); + fx.add({ ...WAITING, quiz_id: 'q4', ts: '2026-09-18T22:00:07.000100+00:00' }); + fx.decision.appendActivityQuestion({ ...WAITING, quiz_id: 'q5', ts: '2026-09-18T22:00:07.000900+00:00' }); + assert.deepEqual(fx.cards().slice(3, 5).map((card) => text(card, 'chat-quiz-status-text')), + ['Waiting for your answer', 'Waiting for your answer']); + fx.add(WAITING); + assert.equal(text(older, 'chat-quiz-status-text'), 'Unanswered · the task continued; an answer is still accepted', + 'stale history cannot restore the closed wait'); + mock.timers.tick(SETTLE_MS * 2); + assert.equal(fx.removed.length, 0, 'an unanswered question is never removed'); + } finally { fx.restore(); } +})); + +test('focus stays with the owner: inside the settled copy, then on to the next question or the composer', () => withTimers(async () => { + let composer = 0; + const fx = mainFixture({ focusAfterRemoval: () => { composer += 1; } }); + try { + fx.add(WAITING); + fx.add({ ...WAITING, quiz_id: 'q2', ts: ASKED[1] }); + const [first, second] = fx.cards(); + const pressed = options(first)[0]; + pressed.focus(); + pressed.click(); + await turn(); + // The pressed option is now a disabled record: focus stays in this copy. + assert.equal(globalThis.document.activeElement, first.querySelector('.chat-quiz-question')); + mock.timers.tick(SETTLE_MS); + assert.equal(globalThis.document.activeElement, second.querySelector('.chat-quiz-question')); + // The last copy hands a keyboard owner's focus to the composer... + options(second)[1].focus(); + options(second)[1].click(); + await turn(); + mock.timers.tick(SETTLE_MS); + assert.equal(composer, 1); + // ...and leaves focus alone when it was elsewhere, or when a pointer put it there. + fx.add({ ...WAITING, quiz_id: 'q3' }); + fx.add({ ...WAITING, quiz_id: 'q4' }); + const [, fourth] = fx.cards(); + const elsewhere = new NodeStub(); + elsewhere.focus(); + fx.decision.applyQuizStateFrame({}, { task_id: 't-1', quiz_id: 'q3', state: 'answered', answered_index: 0 }); + mock.timers.tick(SETTLE_MS); + assert.equal(globalThis.document.activeElement, elsewhere); + const tapped = options(fourth)[0]; + globalThis.document.pointerModality = true; + tapped.focus(); + fx.decision.applyQuizStateFrame({}, { task_id: 't-1', quiz_id: 'q4', state: 'superseded' }); + fx.decision.applyQuizStateFrame({}, { task_id: 't-1', quiz_id: 'q4', state: 'answered', answered_index: 0 }); + mock.timers.tick(SETTLE_MS); + assert.deepEqual([composer, fx.column.children.length], [1, 0], 'a tap never summons the keyboard'); + } finally { fx.restore(); } +})); + +test('a replaced question stays as a read-only record; a native Main quiz is never removed', () => withTimers(async () => { + const fx = mainFixture(); + try { + fx.add({ ...WAITING, quiz_id: 'replaced' }); + fx.decision.applyQuizStateFrame({}, { task_id: 't-1', quiz_id: 'replaced', state: 'superseded' }); + const native = fx.decision.buildQuizCard({ type: 'quiz', role: 'assistant', quiz_id: 'native', task_id: 't-main', + question: 'Main question?', options: [{ label: 'A' }, { label: 'B' }], assumption: 'A', state: 'open', ts: ASKED[0] }); + fx.column.append(native); + const nativeCard = native.children[0]; + assert.equal(nativeCard.querySelector('.chat-quiz-project'), null); + options(nativeCard)[0].click(); + await turn(); + mock.timers.tick(SETTLE_MS * 2); + const [replaced] = fx.cards(); + assert.deepEqual([replaced.dataset.state, text(replaced, 'chat-quiz-status-text'), options(replaced)[0].disabled], + ['superseded', 'Replaced by a newer question', true]); + assert.deepEqual([nativeCard.dataset.state, fx.column.children.length, fx.removed.length], ['answered', 2, 0]); + } finally { fx.restore(); } +})); + +test('released and destroyed copies take their countdown and rendered markdown with them', () => withTimers(async () => { + const released = []; + const fx = mainFixture({ renderMarkdown: (value) => `

${value}

`, enhanceMarkdown: (node) => () => released.push(node) }); + try { + fx.add(WAITING); + fx.add({ ...WAITING, quiz_id: 'q2' }); + const [first, second] = fx.cards(); + assert.equal(first.querySelector('.chat-quiz-question').innerHTML, '

Merge **now**?

'); + fx.decision.applyQuizStateFrame({}, { task_id: 't-1', quiz_id: 'qz-1', state: 'answered', answered_index: 0 }); + // History eviction releases the node mid-countdown through the ordinary lifecycle. + fx.decision.releaseViews(first.parentNode); + assert.deepEqual(released, [first]); + mock.timers.tick(SETTLE_MS); + assert.equal(fx.removed.length, 0, 'a released copy has no countdown left'); + fx.decision.applyQuizStateFrame({}, { task_id: 't-1', quiz_id: 'q2', state: 'answered', answered_index: 0 }); + fx.decision.destroy(); + mock.timers.tick(SETTLE_MS); + assert.deepEqual([fx.removed.length, released], [0, [first, second]]); + } finally { fx.restore(); } +})); + +test('a Project room never mirrors its own questions', () => { + const fx = fixture({ insertMessageNode: () => { throw new Error('no Main copy in a Project room'); } }); + try { + assert.equal(fx.decision.appendQuestionPointer(WAITING), false); + assert.equal(fx.decision.appendActivityQuestion(WAITING), false); + } finally { fx.restore(); } +}); diff --git a/web/tests/question_presentation.test.js b/web/tests/question_presentation.test.js index f8674ccb3..5ce1b350c 100644 --- a/web/tests/question_presentation.test.js +++ b/web/tests/question_presentation.test.js @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { readFileSync } from 'node:fs'; -import { excerpt, questionPresentation, questionPreview, waitFacts } from '../modules/question_presentation.js'; +import { questionPresentation, waitFacts } from '../modules/question_presentation.js'; // Each fixture row is the pointer row the Python producer emits for that case // (tests/test_project_question_pointer.py pins the emission); the browser must read the @@ -18,18 +18,3 @@ test('waiting needs positive evidence and a closed bound ends it', () => { assert.deepEqual(waitFacts({ owner_wait_state: 'waiting' }), { waiting: true, resumed: false }); assert.deepEqual(waitFacts({}), { waiting: false, resumed: false }); }); - -test('previews are visibly bounded, never cut for less than the marker, and never interpret markup', () => { - const long = 'a'.repeat(400); - const preview = questionPreview({ question: long, state: 'answered', - options: ['First', 'Second'], answered_index: 0, comment: 'Exact comment' }); - assert.match(preview.question, /… \(preview; open for full text\)$/); - assert.equal(preview.answer, 'First — Exact comment'); - // A 285-character text would grow if cut: it stays whole. - assert.equal(excerpt('b'.repeat(285)), 'b'.repeat(285)); - // The option and the comment are bounded separately: a long label never hides the comment. - const both = questionPreview({ state: 'answered', options: [long], answered_index: 0, comment: 'Still here' }); - assert.match(both.answer, /open for full text\) — Still here$/); - assert.equal(questionPreview({ state: 'open', comment: 'draft' }).answer, ''); - assert.equal(questionPreview({ quiz_state: 'answered', comment: 'Only words' }).answer, 'Only words'); -}); diff --git a/web/tests/question_rows.test.js b/web/tests/question_rows.test.js deleted file mode 100644 index ebf6b73d1..000000000 --- a/web/tests/question_rows.test.js +++ /dev/null @@ -1,285 +0,0 @@ -// Project questions in Main: one row per question, and its size follows the owner's attention -// (docs/DESIGN.md "Project question row"). A line in every settled or passed state; a card with -// the option buttons only while the task waits. -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { questionRow } from '../modules/question_presentation.js'; -import { fixture, turn } from './chat_decision_fixture.js'; - -// The producer always stamps `ts` from the question's own asked_at -// (ouroboros/project_dialogue.py::project_question_pointer), and one batch is asked in -// sequence, so its rows differ by seconds. Folding reads that order, never wall time. -const ASKED = ['2026-09-18T22:00:00+00:00', '2026-09-18T22:00:07+00:00']; -const ROW = { task_id: 't-1', quiz_id: 'qz-1', project_id: 'p1', project_chat_id: 23, ts: ASKED[0], - project_name: 'Storage', quiz_state: 'answered', question: 'Merge now?', options: ['Yes', 'No'] }; -const WAITING = { ...ROW, quiz_state: 'open', wait_for_answer: true, owner_wait_state: 'waiting', recommended_index: 0 }; -const text = (node, name) => node.querySelector(`.project-question-${name}`)?.textContent ?? null; -const mode = (node) => node.dataset.questionMode; -const options = (node) => node.querySelectorAll('.chat-quiz-option'); - -test('a realistic burst: three questions of one task are three lines, and only the waiting one is a card', async () => { - const fx = fixture(); - try { - const rows = [ - { ...ROW, quiz_id: 'q1', question: '1/3. Which account publishes?', answered_index: 0 }, - { ...ROW, quiz_id: 'q2', question: '2/3. Separate copy or the live install?', answered_index: 1, comment: 'Live first.' }, - { ...WAITING, quiz_id: 'q3', question: '3/3. Keep the **persistent** store?' }, - ].map((row) => fx.decision.buildQuestionPointer(row)); - assert.deepEqual(rows.map(mode), ['row', 'row', 'card']); - assert.equal(text(rows[0], 'status-text'), 'You answered:'); - assert.equal(text(rows[0], 'answer'), 'Yes'); - assert.equal(text(rows[0], 'preview'), '1/3. Which account publishes?'); - assert.equal(text(rows[0], 'source'), 'Storage'); - assert.equal(text(rows[1], 'answer'), 'No — Live first.', 'the option and the owner\'s words both ride the line'); - assert.equal(rows[0].getAttribute('role'), 'button'); - assert.equal(options(rows[0]).length, 0, 'a settled line offers no answer buttons'); - // The waiting card: the whole question, the option labels, the recommendation, one way out. - assert.equal(rows[2].getAttribute('role'), null); - assert.equal(rows[2].querySelector('.chat-live-project-name').textContent, 'Storage'); - assert.equal(rows[2].querySelector('.chat-live-project-status').textContent, 'Waiting for your answer'); - assert.equal(rows[2].querySelector('.chat-quiz-question').textContent, '3/3. Keep the **persistent** store?'); - assert.deepEqual(options(rows[2]).map((button) => button.querySelector('.chat-quiz-option-label').textContent), ['Yes', 'No']); - assert.ok(options(rows[2])[0].querySelector('.chat-quiz-option-recommended'), 'index zero is a recommendation too'); - assert.equal(options(rows[2])[1].querySelector('.chat-quiz-option-recommended'), null); - assert.equal(rows[2].querySelector('.system-message-action').textContent, 'Details and own answer'); - assert.equal(rows[2].querySelector('.chat-quiz-comment'), null, 'own words stay in the Project form'); - // One touch answers it, and the card folds into a line like the others. - options(rows[2])[1].click(); - await turn(); - const sent = JSON.parse(fx.calls[0].init.body); - assert.deepEqual([fx.calls.length, fx.calls[0].url, sent.decision_id, sent.option_index, 'comment' in sent], - [1, '/api/decisions', 'quiz:t-1:q3', 1, false]); - assert.deepEqual([mode(rows[2]), text(rows[2], 'status-text'), text(rows[2], 'answer')], ['row', 'You answered:', 'No']); - assert.equal(rows[2].dataset.state, 'answered'); - } finally { fx.restore(); } -}); - -test('every state reads as one line, and the line names what the owner needs first', () => { - const fx = fixture(); - try { - const line = (row) => { - const node = fx.decision.buildQuestionPointer({ ...ROW, quiz_id: `q-${Math.random()}`, ...row }); - return [mode(node), text(node, 'status-text'), text(node, 'answer'), node.dataset.state]; - }; - assert.deepEqual(line({ quiz_state: 'open', assumption: 'WebP meanwhile', recommended_index: 1 }), - ['row', 'Unanswered · continuing with:', 'WebP meanwhile', 'open'], 'a passed optional question names the path the task took'); - assert.deepEqual(line({ quiz_state: 'open' }), ['row', 'Unanswered · an answer is still accepted', null, 'open']); - assert.deepEqual(line({ quiz_state: 'open', wait_for_answer: true, owner_wait_state: 'resumed' }), - ['row', 'Unanswered · the task continued; an answer is still accepted', null, 'open']); - assert.deepEqual(line({ quiz_state: 'open', wait_for_answer: true, owner_wait_state: 'resumed', assumption: 'ship it' }), - ['row', 'Unanswered · continuing with:', 'ship it', 'open'], 'a wait that ended under an assumption says so'); - assert.deepEqual(line({ quiz_state: 'expired_terminal', assumption: 'ship it' }), - ['row', 'Unanswered · the task finished; a late answer is accepted as your message', null, 'expired_terminal']); - assert.deepEqual(line({ quiz_state: 'superseded' }), ['row', 'Replaced by a newer question', null, 'superseded']); - assert.deepEqual(line({ quiz_state: 'unknown', source_status: 'unavailable' }), ['row', 'Status unavailable', null, 'unknown']); - assert.deepEqual(line({ quiz_state: 'answered', comment: 'Neither — use the archive.' }), - ['row', 'You answered:', 'Neither — use the archive.', 'answered'], 'a comment-only answer is the whole answer'); - const long = 'x'.repeat(400); - assert.match(questionRow({ ...ROW, quiz_state: 'answered', comment: long }).detail, /^x{280}… \(preview; open for full text\)$/); - assert.equal(questionRow({ ...ROW, question: '' }).question, ''); - } finally { fx.restore(); } -}); - -test('the line opens the exact question; the card opens it from its head and its details action', () => { - const fx = fixture(); - try { - const settled = fx.decision.buildQuestionPointer({ ...ROW, answered_index: 0 }); - settled.click(); - const waiting = fx.decision.buildQuestionPointer({ ...WAITING, quiz_id: 'qz-2' }); - waiting.click(); - assert.equal(fx.opened.length, 1, 'the body of a waiting card is not a control'); - waiting.querySelector('.chat-live-project-card-btn').click(); - waiting.querySelector('.system-message-action').click(); - assert.deepEqual(fx.opened.map((detail) => [detail.project.id, detail.project.chat_id, detail.task_id, detail.quiz_id]), - [['p1', 23, 't-1', 'qz-1'], ['p1', 23, 't-1', 'qz-2'], ['p1', 23, 't-1', 'qz-2']]); - } finally { fx.restore(); } -}); - -test('an unchanged row writes nothing, and a narrower re-delivery never blanks a painted line or card', () => { - let writes = 0; - const fx = fixture({ onDomWrite: (mutate) => { writes += 1; return mutate(); } }); - try { - const pointer = fx.decision.buildQuestionPointer({ ...WAITING, assumption: 'Yes meanwhile' }); - const painted = writes; - assert.equal(fx.decision.buildQuestionPointer({ ...WAITING, assumption: 'Yes meanwhile', ts: 'later' }), null); - assert.equal(writes, painted, 'the same projection preserves the owner\'s selection and focus'); - // The 3-second activity census re-delivers the waiting question without its display fields. - const { question: _q, options: _o, recommended_index: _r, project_name: _p, ...narrow } = WAITING; - fx.decision.buildQuestionPointer({ ...narrow, question: '', options: [], assumption: '', recommended_index: null }); - assert.equal(writes, painted); - assert.equal(options(pointer).length, 2); - assert.ok(options(pointer)[0].querySelector('.chat-quiz-option-recommended')); - assert.equal(pointer.querySelector('.chat-live-project-name').textContent, 'Storage'); - // A card painted from the census alone still badges option zero. - const cold = fx.decision.buildQuestionPointer({ ...WAITING, quiz_id: 'cold' }); - assert.ok(options(cold)[0].querySelector('.chat-quiz-option-recommended')); - fx.decision.releaseViews({ contains: (node) => node === pointer }); - const restored = fx.decision.buildQuestionPointer({ ...ROW, answered_index: 1 }); - assert.equal(text(restored, 'answer'), 'No'); - } finally { fx.restore(); } -}); - -test('lifecycle only moves forward: a closed wait and a settled answer survive stale snapshots', () => { - const fx = fixture(); - try { - fx.decision.applyQuizStateFrame({}, { task_id: 't-1', quiz_id: 'early', state: 'answered', answered_index: 0 }); - const early = fx.decision.buildQuestionPointer({ ...ROW, quiz_id: 'early', quiz_state: 'open' }); - assert.deepEqual([mode(early), text(early, 'answer')], ['row', 'Yes'], 'an answer observed before the row wins over its open snapshot'); - const pointer = fx.decision.buildQuestionPointer(WAITING); - assert.equal(mode(pointer), 'card'); - // The production timeout frame carries only wait_for_answer:false. - fx.decision.applyQuizStateFrame({}, { task_id: 't-1', quiz_id: 'qz-1', state: 'open', wait_for_answer: false }); - assert.deepEqual([mode(pointer), text(pointer, 'status-text')], ['row', 'Unanswered · the task continued; an answer is still accepted']); - fx.decision.buildQuestionPointer({ ...WAITING, ts: 'older' }); - assert.equal(mode(pointer), 'row', 'an older history row cannot reopen a wait a live frame closed'); - fx.decision.buildQuestionPointer({ ...ROW, quiz_state: 'unknown', source_status: 'unavailable' }); - assert.equal(text(pointer, 'status-text'), 'Unanswered · the task continued; an answer is still accepted', 'an unavailable read keeps the known evidence'); - fx.decision.applyQuizStateFrame({}, { task_id: 'another-task', quiz_id: 'qz-1', state: 'expired_terminal' }); - assert.equal(pointer.dataset.state, 'open', 'identity is the task AND the quiz'); - fx.decision.applyQuizStateFrame({}, { task_id: 't-1', quiz_id: 'qz-1', state: 'answered', answered_index: 1, comment: 'No.' }); - assert.deepEqual([text(pointer, 'status-text'), text(pointer, 'answer')], ['You answered:', 'No — No.']); - fx.decision.buildQuestionPointer(WAITING); - assert.equal(mode(pointer), 'row', 'a settled question never reopens'); - } finally { fx.restore(); } -}); - -test('an answer from Main settles through the observation: a lost live frame plus a stale open snapshot cannot reopen it', async () => { - const fx = fixture(); - try { - const pointer = fx.decision.buildQuestionPointer(WAITING); - const pressed = options(pointer)[0]; - pressed.focus(); - pressed.click(); - options(pointer)[1].click(); - await turn(); - assert.equal(fx.calls.length, 1, 'a second press while the first is in flight sends nothing'); - assert.deepEqual([mode(pointer), text(pointer, 'answer')], ['row', 'Yes']); - assert.equal(globalThis.document.activeElement, pointer, 'focus follows from the removed button to the line'); - // No quiz_state frame arrived; history and the census still replay the open, waiting row. - fx.decision.buildQuestionPointer({ ...WAITING, ts: 'stale' }); - assert.deepEqual([mode(pointer), text(pointer, 'status-text')], ['row', 'You answered:']); - } finally { fx.restore(); } -}); - -test('a lost race settles into the winner\'s record, and a failed attempt leaves the card answerable', async () => { - const replies = [ - { ok: false, status: 500, json: async () => ({}) }, - { ok: false, status: 409, json: async () => ({ state: 'answered', answered_index: 1, comment: 'From the Project form.' }) }, - ]; - const fx = fixture({ fetchImpl: async () => replies.shift() }); - try { - const pointer = fx.decision.buildQuestionPointer(WAITING); - options(pointer)[0].click(); - await turn(); - assert.equal(mode(pointer), 'card'); - assert.match(fx.toasts[0].text, /Could not record the answer \(500\)/); - options(pointer)[0].click(); - await turn(); - const ids = fx.calls.map((call) => JSON.parse(call.init.body).request_id); - assert.equal(ids[0], ids[1], 'a retry replays the same request'); - assert.deepEqual([mode(pointer), text(pointer, 'answer')], ['row', 'No — From the Project form.']); - assert.equal(fx.toasts[1].text, 'Already answered.'); - assert.equal(pointer.querySelector('.chat-quiz-answer'), null, 'the Project card\'s record line never lands in a Main row'); - } finally { fx.restore(); } -}); - -test('a folding card releases what its rendered question owned, and an empty question still reads', async () => { - const released = []; - const fx = fixture({ renderMarkdown: (text) => `

${text}

`, - enhanceMarkdown: (node) => () => released.push(node) }); - try { - const pointer = fx.decision.buildQuestionPointer(WAITING); - const rendered = pointer.querySelector('.chat-quiz-question'); - assert.equal(rendered.innerHTML, '

Merge now?

'); - options(pointer)[0].click(); - await turn(); - assert.deepEqual([mode(pointer), released], ['row', [rendered]], 'charts and timers of the removed question are released'); - const blank = fx.decision.buildQuestionPointer({ ...WAITING, quiz_id: 'blank', question: '' }); - assert.equal(blank.querySelector('.chat-quiz-question').innerHTML, '

Open the original question for its text.

'); - } finally { fx.restore(); } -}); - -test('only the line is a control: a waiting card lets clicks through and keeps focus across a repaint', () => { - const fx = fixture(); - try { - let stopped = 0; - const event = { stopPropagation: () => { stopped += 1; } }; - const waiting = fx.decision.buildQuestionPointer(WAITING); - waiting.click(event); - assert.deepEqual([stopped, fx.opened.length], [0, 0], 'document-level handlers still see a click on the card'); - // A renamed Project repaints the card; the option the owner was on keeps the focus. - options(waiting)[1].focus(); - fx.decision.buildQuestionPointer({ ...WAITING, project_name: 'Storage v2' }); - assert.equal(waiting.querySelector('.chat-live-project-name').textContent, 'Storage v2'); - assert.equal(globalThis.document.activeElement, options(waiting)[1]); - const settled = fx.decision.buildQuestionPointer({ ...ROW, quiz_id: 'line', answered_index: 0 }); - settled.click(event); - assert.deepEqual([stopped, fx.opened.length], [1, 1]); - } finally { fx.restore(); } -}); - - -test('a fresh single-wait census folds older cards, not optional or foreign questions', () => { - const fx = fixture({ isMain: true }); - try { - const older = fx.decision.buildQuestionPointer(WAITING); - const newer = fx.decision.buildQuestionPointer({ ...WAITING, quiz_id: 'q2', ts: ASKED[1] }); - const optional = fx.decision.buildQuestionPointer({ ...ROW, quiz_id: 'opt', quiz_state: 'open', assumption: 'local' }); - const foreign = fx.decision.buildQuestionPointer({ ...WAITING, task_id: 'other' }); - fx.decision.appendActivityQuestion({ ...WAITING, quiz_id: 'q2', ts: ASKED[1] }, 0); - assert.deepEqual([mode(older), mode(newer)], ['card', 'card'], 'a pre-arrival request cannot close a newer card'); - fx.decision.appendActivityQuestion({ ...WAITING, quiz_id: 'q2', ts: ASKED[1], owner_wait_state: undefined }); - assert.equal(mode(older), 'card', 'absence of positive wait evidence proves nothing'); - fx.decision.appendActivityQuestion({ ...WAITING, quiz_id: 'q2', ts: ASKED[1] }); - assert.deepEqual([mode(older), mode(newer), mode(optional), mode(foreign)], ['row', 'card', 'row', 'card']); - assert.match(text(older, 'status-text'), /task continued/); - assert.equal(text(optional, 'status-text'), 'Unanswered · continuing with:'); - assert.equal(older.dataset.state, 'open', 'ending a wait does not settle the question'); - assert.equal(older.querySelector('.project-question-go').getAttribute('aria-hidden'), 'true'); - fx.decision.buildQuestionPointer(WAITING); - assert.equal(mode(older), 'row', 'stale history cannot restore the closed wait'); - older.click(); - assert.equal(fx.opened[0].quiz_id, 'qz-1', 'the unanswered question remains reachable'); - } finally { fx.restore(); } -}); - -test('a census naming an older wait cannot fold the newer waiting card', () => { - const fx = fixture({ isMain: true }); - try { - const first = fx.decision.buildQuestionPointer({ ...WAITING, quiz_id: 'q1', ts: ASKED[0] }); - const second = fx.decision.buildQuestionPointer({ ...WAITING, quiz_id: 'q2', ts: ASKED[1] }); - assert.deepEqual([mode(first), mode(second)], ['card', 'card']); - // The task published q2 and kept working: its owner_wait row still names q1, already - // resumed. This read began AFTER q2's card arrived, so request time permits the fold. - fx.decision.appendActivityQuestion( - { ...WAITING, quiz_id: 'q1', ts: ASKED[0], owner_wait_state: 'resumed' }, Date.now() + 1000); - assert.deepEqual([mode(first), mode(second)], ['card', 'card'], - 'an older named wait is no evidence about a later question'); - assert.equal(options(second).length, 2, 'the newer question keeps its option buttons'); - // The real wait arrives: now the order is proven, so q1 folds and q2 stays the card. - assert.equal(fx.decision.appendActivityQuestion({ ...WAITING, quiz_id: 'q2', ts: ASKED[1] }), true); - assert.deepEqual([mode(first), mode(second)], ['row', 'card']); - // Neither a missing stamp nor an equal one is an order: both leave the card answerable. - // The stamps carry sub-millisecond precision that Date.parse truncates, so equality - // can hold for two genuinely different questions. - const cold = fx.decision.buildQuestionPointer({ ...WAITING, quiz_id: 'q3', ts: '' }); - const tied = fx.decision.buildQuestionPointer({ ...WAITING, quiz_id: 'q4', ts: '2026-09-18T22:00:00.000100+00:00' }); - fx.decision.appendActivityQuestion({ ...WAITING, quiz_id: 'q5', ts: '2026-09-18T22:00:00.000900+00:00' }); - assert.equal(mode(cold), 'card', 'an unstamped card is never folded on an unproven order'); - assert.equal(mode(tied), 'card', 'a sub-millisecond tie is not proof that the named wait is newer'); - } finally { fx.restore(); } -}); - -test('an evicted required ask with resumed facts stays a row and an optional ask is never resumed', async () => { - const fx = fixture({ fetchDetail: async () => ({ task_id: 't-1', project_id: 'p1', - owner_quiz: { opt: { quiz_id: 'opt', state: 'open', question: 'Format?', options: ['A', 'B'], assumption: 'A' } }, - owner_wait: { quiz_id: 'new', state: 'waiting' } }) }); - try { - const pointer = fx.decision.buildQuestionPointer({ ...WAITING, owner_wait_state: 'resumed' }); - assert.equal(mode(pointer), 'row'); - const detail = await fx.decision.readQuestion('t-1', 'opt', 'p1'); - assert.equal(detail.owner_wait_state, undefined); - const card = fx.decision.buildQuizCard(detail); - assert.equal(card.querySelector('.chat-quiz-status-text').textContent, 'Unanswered · an answer is still accepted'); - } finally { fx.restore(); } -});