Skip to content
Merged
Show file tree
Hide file tree
Changes from 37 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
37be337
fix(call): improve device error recovery UX (Issue #1190)
JamesPHoughton Feb 20, 2026
972e8ac
feat(call): add device picker for mid-call device disconnection (Issu…
JamesPHoughton Feb 20, 2026
a07a3b5
docs: update TEST-PROGRESS.md for DEVRECOV-009 to 011
JamesPHoughton Feb 20, 2026
f9c47de
test: enable full traces locally for Playwright UI debugging
JamesPHoughton Feb 20, 2026
9729f1d
feat(call): add recovery UI for fatal errors, network interruption, a…
JamesPHoughton Mar 1, 2026
3f2907e
feat(call): add device reconnection, auto-fix, and track monitoring r…
JamesPHoughton Mar 1, 2026
565f013
docs(call): update RECOVERY-PLAYBOOK with implementation status for a…
JamesPHoughton Mar 2, 2026
e2c9213
chore: housekeeping — model guidance, config, docs, remove test artifact
JamesPHoughton Mar 2, 2026
2a7763c
fix(call): move Sentry capture to Fix A/V submission and improve unkn…
JamesPHoughton Mar 2, 2026
611db15
refactor(ui): extract shared Modal, Toast, CallBanner components
JamesPHoughton Mar 2, 2026
a7da9e7
fix(call): clean up device error modal — add close button, remove dia…
JamesPHoughton Mar 2, 2026
e3fcaec
feat(call): cause-specific error titles and priority ordering (Issue …
JamesPHoughton Mar 2, 2026
e3db128
feat(call): merge camera + mic errors with same cause into combined t…
JamesPHoughton Mar 2, 2026
1c27cc5
docs: update test comments to reflect modal overlay architecture
JamesPHoughton Mar 2, 2026
1b57c75
refactor(call): unify permission denial paths into single deviceError…
JamesPHoughton Mar 2, 2026
b457e0b
feat(call): show device picker at call start when preferred device no…
JamesPHoughton Mar 2, 2026
37d2fad
refactor(call): always show device picker on alignment fallback
JamesPHoughton Mar 2, 2026
bd1a0b3
fix(call): add 5-second grace period before showing reconnecting banner
JamesPHoughton Mar 2, 2026
e619c00
refactor(call): always show device picker on mid-call not-found errors
JamesPHoughton Mar 2, 2026
96f292a
test(fixav): add non-happy-path modal state tests (FIXAV-015 to 019)
JamesPHoughton Mar 2, 2026
06f1eae
chore: ignore Playwright test-results/ and playwright-report/ at repo…
JamesPHoughton Mar 3, 2026
ec4fba8
refactor(call): move hook files into call/hooks/ subdirectory
JamesPHoughton Mar 3, 2026
d3653af
fix: address Copilot review feedback
JamesPHoughton Mar 3, 2026
8ccbd69
ci: split Playwright tests into per-browser matrix jobs
JamesPHoughton Mar 3, 2026
25da415
ci: fix double-triggering and slow Playwright jobs
JamesPHoughton Mar 3, 2026
f7886a5
fix(test): isolate SPEAKER-005 from Firefox AudioContext suspension
JamesPHoughton Mar 3, 2026
6632cf3
fix(test): isolate WF-SENTRY-001 from Firefox AudioContext suspension
JamesPHoughton Mar 3, 2026
6c95b31
chore(test): remove ineffective media.autoplay.default Firefox pref
JamesPHoughton Mar 3, 2026
6a7b2e0
fix(test): centralise AudioContext mock in beforeMount to isolate all…
JamesPHoughton Mar 3, 2026
22ab897
fix(test): log subscription update keys as string to fix Firefox cons…
JamesPHoughton Mar 3, 2026
d0a1450
fix(call): correct AGC API call and prevent mic-error render storm
JamesPHoughton Mar 3, 2026
0283b19
test(call): add WF-STORM-001/002 to guard against device error render…
JamesPHoughton Mar 3, 2026
598033d
chore(call): document failed AGC attempts and link to investigation i…
JamesPHoughton Mar 3, 2026
67b5084
feat(call): show device picker when speaker/camera/mic disconnects mi…
JamesPHoughton Mar 3, 2026
6ccd547
fix(call): show guidance when no alternative devices found for not-fo…
JamesPHoughton Mar 3, 2026
ced2b13
fix(call): prevent camera+speaker errors merging into wrong picker
JamesPHoughton Mar 3, 2026
85a5086
feat(call): sequential per-device error modals + W4 speaker auto-reco…
JamesPHoughton Mar 4, 2026
49fd811
fix(lint): resolve ESLint errors in UserMediaError and VideoCall
JamesPHoughton Mar 4, 2026
6427056
chore: merge main (resolve add/add conflicts from squash PR #1191)
JamesPHoughton Mar 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions .github/workflows/playwright_component.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,17 @@ name: Playwright Component Tests

on:
push:
branches: [main]
pull_request:

jobs:
playwright-component-tests:
timeout-minutes: 15
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
browser: [chromium, firefox, webkit]

steps:
- name: Checkout
Expand All @@ -25,15 +30,15 @@ jobs:
run: cd client && npm ci

- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
run: npx playwright install --with-deps ${{ matrix.browser }}

- name: Run Playwright component tests (mocked)
run: npx playwright test --config playwright/playwright.config.mjs "video-call/mocked"
run: npx playwright test --config playwright/playwright.config.mjs "video-call/mocked" --project=${{ matrix.browser }} --workers=2

- name: Upload test results
uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
name: playwright-report-${{ matrix.browser }}
path: playwright-report/
retention-days: 7
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,7 @@ deliberation-empirica.tar.zst

# Local bind-mounted assets (not part of repo)
assets/

# Playwright test artifacts
test-results/
playwright-report/
9 changes: 9 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

Small-group deliberation experiment platform built on [Empirica](https://empirica.ly/). Participants join via a React client; a Node.js server handles game lifecycle, external integrations, and data export.

## AI Assistant Model Selection

**Default Model**: This project uses **Claude Sonnet** by default for cost efficiency and speed.

**Model Warning**: If you detect that Opus is being used but the user hasn't explicitly switched to it (via `/model opus`), warn the user before performing any tasks. Ask if they intended to use Opus or if they'd like to switch to Sonnet.

**When to use Opus**: Only switch to Opus when the user explicitly requests it for tasks requiring maximum reasoning capability (complex architectural decisions, debugging intricate logic, etc.).

## Repo Structure

- `client/src/` — React UI: `App.jsx` (intro/exit), `Stage.jsx` (game stages), `elements/` (stage elements), `call/` (Daily video), `components/` (shared UI)
Expand Down Expand Up @@ -56,6 +64,7 @@ npx playwright test --config playwright/playwright.config.mjs "video-call/mocked
| `VideoCall.deviceAlignment.ct.jsx` | Device ID alignment effect |

**In-page test controls (via `page.evaluate`):**

- `window.mockCallObject._audioEnabled = false` — simulate muted mic
- `window.mockCallObject._videoEnabled = false` — simulate muted camera
- `window.mockCallObject._audioReadyState = 'ended'` — simulate ended mic track
Expand Down
2 changes: 1 addition & 1 deletion client/src/call/Call.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -594,7 +594,7 @@ export function Call({ showSelfView = true, layout, rooms }) {
});

if (repairNeeded && !inCooldown) {
console.log("[Subscription] Applying updates:", updates);
console.log("[Subscription] Applying updates:", Object.keys(updates).join(", "));
callObject.updateParticipants(updates);
lastRepairAttemptRef.current = now;

Expand Down
21 changes: 21 additions & 0 deletions client/src/call/CallBanner.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import React from "react";

const variantClasses = {
warning: "bg-yellow-600",
error: "bg-red-600",
info: "bg-blue-600",
};

export function CallBanner({ visible, variant = "warning", children }) {
if (!visible) return null;

const classes = variantClasses[variant] || variantClasses.warning;

return (
<div
className={`absolute top-0 left-0 right-0 z-10 ${classes} px-4 py-2 text-center text-sm font-medium text-white`}
>
{children}
</div>
);
}
19 changes: 11 additions & 8 deletions client/src/call/FixAV.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as Sentry from "@sentry/react";
import { useDaily, useLocalSessionId, useDevices } from "@daily-co/daily-react";
import { usePlayers } from "@empirica/core/player/classic/react";
import { Button } from "../components/Button";
import { Modal } from "../components/Modal";
import { latestDesiredSubscriptions, currentRoomPositions } from "./Call";
import {
diagnoseIssues,
Expand Down Expand Up @@ -374,7 +375,11 @@ export function useFixAV(
// Log to console (appears in Sentry breadcrumbs)
console.log("[AV Issue]", summary, reportData);

// Send to Sentry with avIssueId tag for correlation
// Send to Sentry on submission (not on button click). We wait for the user to
// describe their problem so we can include their reported issues and the full
// diagnostic snapshot in the same event. A click-then-cancel is low signal;
// a submitted report with issues selected is the meaningful event to track.
// See: ERR-FixAV and WF-SENTRY-001 tests.
if (Sentry?.captureMessage) {
Sentry.captureMessage("reportedAVError", {
level: recoverySummaryResult.status === "success" ? "info" : "error",
Expand Down Expand Up @@ -544,10 +549,9 @@ export function useFixAV(

// Render modal JSX directly instead of as a component function to avoid
// React unmounting/remounting on state changes (which would lose checkbox state)
const fixAVModal = showFixModal ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="mx-4 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
{/* Issue Selection State */}
const fixAVModal = (
<Modal isOpen={showFixModal} onClose={handleCancelFix}>
{/* Issue Selection State */}
{modalState === "select" && (
<>
<h2 className="mb-4 text-lg font-semibold text-slate-900">
Expand Down Expand Up @@ -877,9 +881,8 @@ export function useFixAV(
</div>
</>
)}
</div>
</div>
) : null;
</Modal>
);

return { openFixAV, fixAVModal };
}
219 changes: 102 additions & 117 deletions client/src/call/ReportMissing.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
useStageTimer,
} from "@empirica/core/player/classic/react";
import { Button } from "../components/Button";
import { Modal } from "../components/Modal";
import { Toast } from "../components/Toast";
import { RadioGroup } from "../components/RadioGroup";
import { useProgressLabel } from "../components/progressLabel";

Expand All @@ -25,17 +27,6 @@ export function useReportMissing() {
return useContext(ReportMissingContext);
}

const MODAL_STYLES = {
position: "fixed",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
backgroundColor: "#FFF",
padding: "20px",
zIndex: 1000,
borderRadius: "10px",
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.25)",
};

export function ReportMissingProvider({ children }) {
const timeout = !window.Cypress ? 60 : 5; // seconds
Expand Down Expand Up @@ -105,24 +96,21 @@ function MissingParticipantRespond({ timeout, gracePeriod }) {
);

return (
<>
<div className="fixed top-0 z-50 left-0 bottom-0 right-0 bg-gray-500 bg-opacity-70 " />
<div style={MODAL_STYLES}>
<h1>Are you there?</h1>
<p>We hate to leave people with nobody to talk to. 🙂</p>
<p className="text-red-500">{`Please respond within ${timeRemaining} seconds`}</p>

<div className="flex justify-center mt-4 space-x-2">
<Button
className="inline-flex"
handleClick={checkIn}
testId="checkIn"
>
{`I'm here!`}
</Button>
</div>
<Modal isOpen>
<h1>Are you there?</h1>
<p>We hate to leave people with nobody to talk to. 🙂</p>
<p className="text-red-500">{`Please respond within ${timeRemaining} seconds`}</p>

<div className="flex justify-center mt-4 space-x-2">
<Button
className="inline-flex"
handleClick={checkIn}
testId="checkIn"
>
{`I'm here!`}
</Button>
</div>
</>
</Modal>
);
}

Expand Down Expand Up @@ -244,98 +232,95 @@ function ReportParticipantMissing({

return (
<>
{modalOpen && (
<>
<div className="fixed top-0 z-50 left-0 bottom-0 right-0 bg-gray-500 bg-opacity-70 " />
<div style={MODAL_STYLES}>
<h1>Report Missing Participant</h1>
<p>Which of these best describes the situation?</p>
<RadioGroup
options={[
{
key: "onlyOne",
value: "I am the only one in the video call.",
},
{
key: "noDiscussant",
value:
"Nobody else in the call is participating in the discussion.",
},
{
key: "playerAbsent",
value:
"Not everybody is participating in the discussion, but I still have someone to talk with.",
},
]}
selected={missingDetails}
onChange={(e) => setMissingDetails(e.target.value)}
testId="missingDetails"
/>

{missingDetails === "onlyOne" && (
<p className="text-sm text-red-500">
{`We will wait ${timeout} seconds for others to arrive, otherwise we will
end the discussion.`}
</p>
)}

{missingDetails === "noDiscussant" && (
<p className="text-sm text-red-500">
{`We will give the others ${timeout} seconds to confirm their presence, or
we will end the discussion.`}
</p>
)}

{missingDetails === "playerAbsent" && (
<p className="text-sm text-red-500">
Thanks for letting us know. We will ask the others to confirm
their presence and continue the discussion.
</p>
)}

<div className="flex justify-center mt-4 space-x-2">
<Button
className="inline-flex"
primary
handleClick={handleSubmit}
testId="submitReportMissing"
>
Submit
</Button>
<Button
className="inline-flex"
primary={false}
handleClick={() => {
setModalOpen(false);
setMissingDetails("");
}}
testId="cancelReportMissing"
>
Cancel
</Button>
</div>
</div>
</>
)}

{waitingToastOpen && (
<div className="z-10 fixed bottom-20 right-20 w-80 h-20 bg-red-300 text-center align-middle flex flex-col items-center justify-center rounded-lg">
<p>Asking others to confirm their presence.</p>
<p>
{Math.max(
0,
Math.floor(timeout - (stageElapsed - timeResponseRequested))
)}{" "}
seconds remaining.
<Modal
isOpen={modalOpen}
onClose={() => {
setModalOpen(false);
setMissingDetails("");
}}
>
<h1>Report Missing Participant</h1>
<p>Which of these best describes the situation?</p>
<RadioGroup
options={[
{
key: "onlyOne",
value: "I am the only one in the video call.",
},
{
key: "noDiscussant",
value:
"Nobody else in the call is participating in the discussion.",
},
{
key: "playerAbsent",
value:
"Not everybody is participating in the discussion, but I still have someone to talk with.",
},
]}
selected={missingDetails}
onChange={(e) => setMissingDetails(e.target.value)}
testId="missingDetails"
/>

{missingDetails === "onlyOne" && (
<p className="text-sm text-red-500">
{`We will wait ${timeout} seconds for others to arrive, otherwise we will
end the discussion.`}
</p>
</div>
)}
)}

{missingDetails === "noDiscussant" && (
<p className="text-sm text-red-500">
{`We will give the others ${timeout} seconds to confirm their presence, or
we will end the discussion.`}
</p>
)}

{successToastOpen && (
<div className="z-10 fixed bottom-20 right-20 w-80 h-20 bg-green-300 text-center align-middle flex flex-col items-center justify-center rounded-lg">
<p>At least one other person has confirmed their presence.</p>
{missingDetails === "playerAbsent" && (
<p className="text-sm text-red-500">
Thanks for letting us know. We will ask the others to confirm
their presence and continue the discussion.
</p>
)}

<div className="flex justify-center mt-4 space-x-2">
<Button
className="inline-flex"
primary
handleClick={handleSubmit}
testId="submitReportMissing"
>
Submit
</Button>
<Button
className="inline-flex"
primary={false}
handleClick={() => {
setModalOpen(false);
setMissingDetails("");
}}
testId="cancelReportMissing"
>
Cancel
</Button>
</div>
)}
</Modal>

<Toast visible={waitingToastOpen} variant="warning">
<p>Asking others to confirm their presence.</p>
<p>
{Math.max(
0,
Math.floor(timeout - (stageElapsed - timeResponseRequested))
)}{" "}
seconds remaining.
</p>
</Toast>

<Toast visible={successToastOpen} variant="success">
<p>At least one other person has confirmed their presence.</p>
</Toast>
</>
);
}
Loading
Loading