diff --git a/.llms/learnings.md b/.llms/learnings.md
index 35192ea5..c2b66b8c 100644
--- a/.llms/learnings.md
+++ b/.llms/learnings.md
@@ -350,3 +350,39 @@ Two traps:
- `getDriver(LSL)` throws (LSL is not in the driver registry). Any epic that
calls `getDriver()` from a search or cancel path guards
`deviceType !== LSL` itself, instead of relying on the UI never dispatching it.
+
+## Early exit = runtime teardown; incomplete = `*.incomplete.csv`
+
+Runtimes report exactly one of `onFinish(csv)` / `onAbort(csv)` per mount.
+Unmounting a running runtime is the abort, and both runtimes report the
+trials saved so far *synchronously* on teardown (lab.js datastore / jsPsych
+`data.get()`), then stop the study and ignore its later end hook. Waiting for
+the runtime's own end is unsafe: jsPsych still awaits `post_trial_gap` after
+`abortExperiment()`, and lab.js's flips run on rAF, which a hidden window pauses.
+lab.js 23 traps: a bare root `end()` does not stop the study (the flip loop
+keeps iterating) — use `controller.jump('abort', { sender: root })`, as lab.js's
+debug plugin does — and aborting between a screen's render and show frames
+hangs it, so that jump is deferred two frames. `controller.audioContext` is
+undefined in 23.x; the real one is `global.audioContext`.
+
+The run lifecycle lives in the store, not in components. The RunBar button and
+`escapeHoldEpic` (a 1 s Escape hold) both dispatch `EndRun`; the reducer sets
+`isEnding`, which unmounts the runtime, and the runtime's report becomes
+`Stop({ data, outcome })`. The reducer keeps the first `Stop` of a run as
+`runOutcome` (the result screen), and `endRunFallbackEpic` sends an empty
+incomplete `Stop` after 3 s if no runtime reports (e.g. an imported study that
+never loaded). `SetState` resets these live fields so a reopened workspace never
+shows a stale result. The stop epic closes the EEG stream, writes behavior, then
+`fs:markRecordingIncomplete` renames both files to `*.incomplete.csv`; each step
+runs even if an earlier one failed. Every discovery filter
+(`src/main/recordings.ts`) and the workflow badges skip that suffix;
+`recordingExists` still counts it so the session number is never reused. The
+result screen and RunBar say "EEG" only via `selectRecordsEEG` (EEG on *and* a
+headset connected). Before this, "End experiment early" wrote the partial run
+as a normal file and `closeEEGStream` was never called.
+
+Agent playtests: Electron's native `showMessageBox` (the no-EEG warning, the
+session-taken prompt) and file pickers cannot be driven over CDP, and a covered
+window reports `visibilityState: hidden`, which stalls lab.js and CDP
+screenshots. Use a subject/session that needs no prompt, and keep the window
+uncovered.
diff --git a/TODOS.md b/TODOS.md
index a9bc6d42..1cee62f7 100644
--- a/TODOS.md
+++ b/TODOS.md
@@ -28,8 +28,11 @@ Deferred and in-flight work. Keep this current — when something ships, delete
- Nav-state distinction: the workflow bar (Prepare/Collect/Clean/Analyze) and the local steps bar (Overview/Background/Protocol/Preview) read as one stacked nav on Prepare — Workstream 4, plan §3.2. (playtest 09-23)
- ~~Blocked areas~~ — shipped 2026-09-23 (`WorkspaceAreaGate` on /clean and /analyze, PR #269).
- ~~Device chip is display-only~~ — shipped 2026-09-23 (WS2, PR #274). The header chip opens headset setup; the RunBar chip stays status-only.
+ - ~~Early exit and incomplete runs (WS5)~~ — shipped 2026-09-24 (branch `feat/ws5-early-exit`). `End experiment early` and a held Escape both end the run with no confirm; what was recorded is kept as `*.incomplete.csv` and left out of Clean, Analyze and the badges; the result screen says `Experiment ended early`.
+ - QA (WS5): the agent playtest could not drive native dialogs or file pickers. Still to check by hand: a complete Faces/Houses run (keeps `-behavior.csv`/`-raw.csv`, shows `Clean this recording →`), the #270 "session 2" prompt after an ended-early session 1, ending an imported jsPsych study early, and a behavior-only run (complete → `Analyze results →`; ended early → only `-behavior.incomplete.csv`).
## Next (V1.5: Visual Polish and Juice)
+- [ ] **WS5 participant screens (#273)** — integrate the `participantScreens.ts` builders into each built-in `experiment.ts`; pass `isEEGEnabled` to lab.js params.
- [ ] **Import stimuli into the workspace?** — today custom experiments load images/sounds straight from wherever the student keeps them (Documents/Downloads) via the `bwfile://` allowlist; moving/renaming that folder silently breaks the study, and a workspace can't be zipped up and shared as a self-contained bundle. Alternative: copy stimuli into `BrainWaves_Workspaces/
/stimuli//` at selection time (single pre-authorized root, portable study bundles; costs disk duplication + stale copies if the source folder is edited later). **Contingent on user testing** — students may actually prefer managing their own folders in Documents/Downloads, since workspace folders are semi-private territory full of mysterious things like `appState.json`. Decide after watching a class use the current flow.
- [ ] **Hooks / function-component migration (AI-friendly).** Most student screens are still class components + `react-redux` `connect()` / `bindActionCreators` containers (`src/renderer/containers/`). Newer work (`EpochReviewer`, `LiveErpPane`, `RunComponent`, `TopNavComponent`) is hooks. Incremental, screen-by-screen; do not big-bang. Pattern to copy: hooks + `useDispatch`/`useSelector` like `App.tsx`. Not a V1 blocker.
- [ ] **Epoch reviewer Phase 3 — onboarding layer.** Plain-language explanations of epochs + each artifact type, a **guided mode** (step through auto-flagged epochs with "why we flagged this," student confirms/overrides), channel legend tied to head position (Muse 10-20), student-facing tone. Builds on the Phase 0-2 reviewer (PRs #223/#224/#225). **Open question OQ3 (onboarding depth) is still unresolved** — how much curriculum (tooltips only vs. a real walkthrough), guided-mode-as-default? This is product-shaped, not architecture.
diff --git a/package-lock.json b/package-lock.json
index fc469645..b8dffbce 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -80,7 +80,6 @@
"jspsych": "^8.3.0",
"lab.js": "23.0.0-alpha4",
"lodash": "^4.18.1",
- "mousetrap": "^1.6.5",
"muse-js": "^3.1.0",
"node-labstreaminglayer": "^0.3.0",
"papaparse": "^5.5.3",
@@ -11926,10 +11925,6 @@
"version": "3.0.2",
"license": "MIT"
},
- "node_modules/mousetrap": {
- "version": "1.6.5",
- "license": "Apache-2.0 WITH LLVM-exception"
- },
"node_modules/ms": {
"version": "2.1.3",
"license": "MIT"
diff --git a/package.json b/package.json
index 47e0394c..f877d09b 100644
--- a/package.json
+++ b/package.json
@@ -238,7 +238,6 @@
"jspsych": "^8.3.0",
"lab.js": "23.0.0-alpha4",
"lodash": "^4.18.1",
- "mousetrap": "^1.6.5",
"muse-js": "^3.1.0",
"node-labstreaminglayer": "^0.3.0",
"papaparse": "^5.5.3",
diff --git a/src/main/__tests__/recordings.test.ts b/src/main/__tests__/recordings.test.ts
new file mode 100644
index 00000000..91476bc5
--- /dev/null
+++ b/src/main/__tests__/recordings.test.ts
@@ -0,0 +1,58 @@
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import {
+ isBehaviorFile,
+ isRawEEGFile,
+ markRecordingIncomplete,
+ recordingExists,
+} from '../recordings';
+
+let dir: string;
+beforeEach(() => {
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bw-recordings-'));
+});
+afterEach(() => fs.rmSync(dir, { recursive: true, force: true }));
+
+const write = (rel: string) => {
+ const file = path.join(dir, rel);
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ fs.writeFileSync(file, 'x');
+};
+const csvFiles = () =>
+ (fs.readdirSync(dir, { recursive: true }) as string[]).filter((f) =>
+ f.endsWith('.csv')
+ );
+
+describe('recordings', () => {
+ it('complete recordings are discovered', () => {
+ write('Data/P1/Behavior/P1-A-1-behavior.csv');
+ write('Data/P1/EEG/P1-A-1-raw.csv');
+
+ expect(csvFiles().filter(isRawEEGFile)).toHaveLength(1);
+ expect(csvFiles().filter(isBehaviorFile)).toHaveLength(1);
+ });
+
+ it('an ended-early run keeps its files and its session, but drops out of discovery', () => {
+ write('Data/P1/Behavior/P1-A-1-behavior.csv');
+ write('Data/P1/EEG/P1-A-1-raw.csv');
+
+ markRecordingIncomplete(dir, 'P1', 'A', 1);
+
+ expect(csvFiles()).toHaveLength(2);
+ expect(csvFiles().filter(isRawEEGFile)).toEqual([]);
+ expect(csvFiles().filter(isBehaviorFile)).toEqual([]);
+ expect(recordingExists(dir, 'P1', 'A', 1)).toBe(true);
+ expect(recordingExists(dir, 'P1', 'A', 2)).toBe(false);
+ });
+
+ it('marks a behavior-only run that has no EEG file', () => {
+ write('Data/P1/Behavior/P1-A-1-behavior.csv');
+
+ markRecordingIncomplete(dir, 'P1', 'A', 1);
+
+ expect(csvFiles().filter(isBehaviorFile)).toEqual([]);
+ expect(recordingExists(dir, 'P1', 'A', 1)).toBe(true);
+ });
+});
diff --git a/src/main/index.ts b/src/main/index.ts
index fd9f12c6..938413be 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -44,6 +44,12 @@ import type {
LSLStatusKind,
} from '../shared/lslTypes';
import { importExperimentFile } from './importExperimentFile';
+import {
+ isBehaviorFile,
+ isRawEEGFile,
+ markRecordingIncomplete,
+ recordingExists,
+} from './recordings';
// Playtest harness: isolate smoke-test state from the user's Electron profile.
// Clear the env after reading so child processes don't inherit it.
@@ -233,7 +239,7 @@ ipcMain.handle('fs:readWorkspaceRawEEGData', (_event, title) => {
recursive: true,
}) as string[];
return files
- .filter((filepath) => filepath.slice(-7).includes('raw.csv'))
+ .filter(isRawEEGFile)
.map((filepath) => {
const fullPath = path.join(getWorkspaceDir(title), filepath);
return { name: path.basename(filepath), path: fullPath };
@@ -267,7 +273,7 @@ ipcMain.handle('fs:readWorkspaceBehaviorData', (_event, title) => {
recursive: true,
}) as string[];
return files
- .filter((filepath) => filepath.slice(-12).includes('behavior.csv'))
+ .filter(isBehaviorFile)
.map((filepath) => {
const fullPath = path.join(getWorkspaceDir(title), filepath);
return { name: path.basename(filepath), path: fullPath };
@@ -424,17 +430,18 @@ ipcMain.handle(
}
);
-/** True when either artifact of a subject/group/session run is already on disk. */
+/** True when any artifact of a subject/group/session run is on disk, including ended-early ones. */
ipcMain.handle(
'fs:recordingExists',
- (_event, title, subject, group, session) => {
- const dir = path.join(getWorkspaceDir(title), 'Data', subject);
- const stem = `${subject}-${group}-${session}`;
- return (
- fs.existsSync(path.join(dir, 'Behavior', `${stem}-behavior.csv`)) ||
- fs.existsSync(path.join(dir, 'EEG', `${stem}-raw.csv`))
- );
- }
+ (_event, title, subject, group, session) =>
+ recordingExists(getWorkspaceDir(title), subject, group, session)
+);
+
+/** Hides an ended-early run from Clean, Analyze and badges; the files stay on disk. */
+ipcMain.handle(
+ 'fs:markRecordingIncomplete',
+ (_event, title, subject, group, session) =>
+ markRecordingIncomplete(getWorkspaceDir(title), subject, group, session)
);
ipcMain.handle('fs:readFiles', (_event, filePathsArray: string[]) => {
diff --git a/src/main/recordings.ts b/src/main/recordings.ts
new file mode 100644
index 00000000..341d564f
--- /dev/null
+++ b/src/main/recordings.ts
@@ -0,0 +1,50 @@
+import fs from 'fs';
+import path from 'path';
+
+/** Suffix for ended-early runs. Neither discovery predicate below matches it. */
+const INCOMPLETE = '.incomplete.csv';
+
+const sessionFiles = (
+ workspaceDir: string,
+ subject: string,
+ group: string,
+ session: number
+) => {
+ const dir = path.join(workspaceDir, 'Data', subject);
+ const stem = `${subject}-${group}-${session}`;
+ return [
+ path.join(dir, 'Behavior', `${stem}-behavior.csv`),
+ path.join(dir, 'EEG', `${stem}-raw.csv`),
+ ];
+};
+
+const incomplete = (file: string) => file.replace(/\.csv$/, INCOMPLETE);
+
+/** Raw EEG recordings Clean and the workflow badges may offer; never incomplete ones. */
+export const isRawEEGFile = (file: string) => file.endsWith('raw.csv');
+
+/** Behavior files Analyze and the workflow badges may offer; never incomplete ones. */
+export const isBehaviorFile = (file: string) => file.endsWith('behavior.csv');
+
+/** True when any artifact of this session exists, complete or ended early. */
+export const recordingExists = (
+ workspaceDir: string,
+ subject: string,
+ group: string,
+ session: number
+) =>
+ sessionFiles(workspaceDir, subject, group, session).some(
+ (file) => fs.existsSync(file) || fs.existsSync(incomplete(file))
+ );
+
+/** Renames the session's behavior and raw EEG files to `*.incomplete.csv`. Nothing is deleted. */
+export const markRecordingIncomplete = (
+ workspaceDir: string,
+ subject: string,
+ group: string,
+ session: number
+) => {
+ for (const file of sessionFiles(workspaceDir, subject, group, session)) {
+ if (fs.existsSync(file)) fs.renameSync(file, incomplete(file));
+ }
+};
diff --git a/src/preload/index.ts b/src/preload/index.ts
index ca68dfea..f7c2d325 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -147,6 +147,20 @@ contextBridge.exposeInMainWorld('electronAPI', {
): Promise =>
ipcRenderer.invoke('fs:recordingExists', title, subject, group, session),
+ markRecordingIncomplete: (
+ title: string,
+ subject: string,
+ group: string,
+ session: number
+ ): Promise =>
+ ipcRenderer.invoke(
+ 'fs:markRecordingIncomplete',
+ title,
+ subject,
+ group,
+ session
+ ),
+
readFiles: (filePathsArray: string[]): Promise =>
ipcRenderer.invoke('fs:readFiles', filePathsArray),
diff --git a/src/renderer/actions/experimentActions.ts b/src/renderer/actions/experimentActions.ts
index 060094a0..9de861e5 100644
--- a/src/renderer/actions/experimentActions.ts
+++ b/src/renderer/actions/experimentActions.ts
@@ -8,12 +8,20 @@ import {
} from '../constants/interfaces';
import { ExperimentStateType } from '../reducers/experimentReducer';
+/** How a recorded run ended; an incomplete run is kept but hidden from Clean/Analyze. */
+export type RunOutcome = 'complete' | 'incomplete';
+
// -------------------------------------------------------------------------
// Actions
export const ExperimentActions = {
Start: createAction('START'),
- Stop: createAction<{ data: string }, 'STOP'>('STOP'),
+ Stop: createAction<{ data: string; outcome: RunOutcome }, 'STOP'>('STOP'),
+ /** Ends the live run early: the runtime unmounts and reports what it recorded. */
+ EndRun: createAction('END_RUN'),
+ SetEscapeHeld: createAction('SET_ESCAPE_HELD'),
+ /** Leaves the result screen for the Ready card. */
+ DismissRunResult: createAction('DISMISS_RUN_RESULT'),
SetType: createAction('SET_TYPE'),
SetExperimentObject: createAction(
'SET_EXPERIMENT_OBJECT'
diff --git a/src/renderer/components/AppShell/AppShell.stories.tsx b/src/renderer/components/AppShell/AppShell.stories.tsx
index 3aba836e..9259b21f 100644
--- a/src/renderer/components/AppShell/AppShell.stories.tsx
+++ b/src/renderer/components/AppShell/AppShell.stories.tsx
@@ -2,7 +2,6 @@ import React from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { fn } from 'storybook/test';
import AppShell from './AppShell';
-import RunBar from './RunBar';
import BlockedAreaPanel from './BlockedAreaEmptyState';
import { AREA_LABELS } from './WorkflowNav';
import { Area, ShellWorkspace } from './types';
@@ -237,32 +236,20 @@ export const RunBehavior: Story = {
},
};
-/**
- * S10 — Holding Esc says, in words, that letting go keeps the run going.
- * Rendered through RunBar: AppShell does not forward `escapeHeld` yet.
- */
+/** S10 — Holding Esc says, in words, that letting go keeps the run going. */
export const HoldingEscape: Story = {
args: {
location: 'collect',
workspace: facesHouses,
run: { kind: 'eeg', elapsed: '02:14', progress: 'Trial 34 of 120' },
- },
- render: ({ run, workspace, device, deviceName, onEndRun }) => (
-
-
-
+ escapeHeld: true,
+ children: (
+
-
- ),
+ ),
+ },
};
diff --git a/src/renderer/components/AppShell/AppShell.tsx b/src/renderer/components/AppShell/AppShell.tsx
index e03ba748..90c0f369 100644
--- a/src/renderer/components/AppShell/AppShell.tsx
+++ b/src/renderer/components/AppShell/AppShell.tsx
@@ -20,6 +20,8 @@ export interface AppShellProps {
onSelectArea?(area: Area): void;
onHome?(): void;
onEndRun?(): void;
+ /** Escape is being held during a run; the RunBar says so. */
+ escapeHeld?: boolean;
/** Opens headset setup from the chip; not offered during a run. */
onDeviceClick?(): void;
/** The current screen, rendered on the app gradient under the bar. */
@@ -46,6 +48,7 @@ export default function AppShell({
onSelectArea,
onHome,
onEndRun,
+ escapeHeld,
onDeviceClick,
children,
}: AppShellProps) {
@@ -59,6 +62,7 @@ export default function AppShell({
device={device}
deviceName={deviceName}
onEndRun={onEndRun}
+ escapeHeld={escapeHeld}
/>
) : (
diff --git a/src/renderer/components/AppShell/RunBar.tsx b/src/renderer/components/AppShell/RunBar.tsx
index d30bf1cd..fce7cbad 100644
--- a/src/renderer/components/AppShell/RunBar.tsx
+++ b/src/renderer/components/AppShell/RunBar.tsx
@@ -9,7 +9,7 @@ interface Props {
workspace?: ShellWorkspace;
device: DeviceState;
deviceName?: string;
- /** Should open a confirm in the container before aborting. */
+ /** Ends the run immediately — no confirm (plan §1.5); data so far is kept as incomplete. */
onEndRun?(): void;
/** Escape is being held; the run ends early once the hold completes. */
escapeHeld?: boolean;
diff --git a/src/renderer/components/CollectComponent/PreTestComponent.tsx b/src/renderer/components/CollectComponent/PreTestComponent.tsx
index a518ae10..620c8498 100644
--- a/src/renderer/components/CollectComponent/PreTestComponent.tsx
+++ b/src/renderer/components/CollectComponent/PreTestComponent.tsx
@@ -1,6 +1,5 @@
-import React, { useState, useEffect } from 'react';
+import React, { useState } from 'react';
import { Button } from '../ui/button';
-import Mousetrap from 'mousetrap';
import ViewerComponent from '../ViewerComponent';
import SignalQualityIndicatorComponent from '../SignalQualityIndicatorComponent';
import PreviewExperimentComponent from '../PreviewExperimentComponent';
@@ -44,13 +43,6 @@ export default function PreTestComponent(props: Props) {
const [isPreviewing, setIsPreviewing] = useState(false);
const [isSidebarVisible, setIsSidebarVisible] = useState(true);
- useEffect(() => {
- Mousetrap.bind('esc', props.ExperimentActions.Stop);
- return () => {
- Mousetrap.unbind('esc');
- };
- }, [props.ExperimentActions]);
-
function endPreview() {
setIsPreviewing(false);
}
diff --git a/src/renderer/components/CollectComponent/RunComponent.tsx b/src/renderer/components/CollectComponent/RunComponent.tsx
index bfdb4a27..981f3dc9 100644
--- a/src/renderer/components/CollectComponent/RunComponent.tsx
+++ b/src/renderer/components/CollectComponent/RunComponent.tsx
@@ -6,9 +6,10 @@ import React, {
useState,
} from 'react';
import { Observable } from 'rxjs';
+import { useNavigate } from 'react-router-dom';
import { Button } from '../ui/button';
import { Card, CardHeader, CardContent } from '../ui/card';
-import { Link } from 'react-router-dom';
+import RunResult from './RunResult';
import InputCollect from '../InputCollect';
import { emitMarker } from '../../utils/eeg';
import { resolveMarkerRegistry } from '../../utils/eeg/markerRegistry';
@@ -28,12 +29,18 @@ import {
SignalQualityData,
} from '../../constants/interfaces';
import { ExperimentActions as globalExperimentActions } from '../../actions';
+import type { RunOutcome } from '../../actions/experimentActions';
import SignalQualityIndicatorComponent from '../SignalQualityIndicatorComponent';
interface Props {
type: EXPERIMENTS;
title: string;
isRunning: boolean;
+ /** End early was asked for; the runtime unmounts and reports what it recorded. */
+ isEnding: boolean;
+ runOutcome: RunOutcome | null;
+ /** The run captures EEG (EEG on and a headset connected), not just key presses. */
+ recordsEEG: boolean;
params: ExperimentParameters;
subject: string;
experimentObject: ExperimentObject;
@@ -49,6 +56,9 @@ const Run: React.FC = ({
type,
title,
isRunning,
+ isEnding,
+ runOutcome,
+ recordsEEG,
params,
subject,
experimentObject,
@@ -62,10 +72,7 @@ const Run: React.FC = ({
const [isInputCollectOpen, setIsInputCollectOpen] = useState(
subject.length === 0
);
- // A run finished this session — show a completion panel that points forward to
- // Clean, instead of silently dropping back to the identical pre-run landing.
- const [hasFinished, setHasFinished] = useState(false);
-
+ const navigate = useNavigate();
// 'armed': checks passed, waiting for SPACE; nothing is recorded yet.
// 'starting': SPACE pressed, Start dispatched; the gate stays up until the
// run is live so the Ready card doesn't flash, and SPACE can't start twice.
@@ -155,38 +162,37 @@ const Run: React.FC = ({
);
const onFinish = useCallback(
- (csv) => {
- ExperimentActions.Stop({ data: csv });
- setHasFinished(true);
- },
+ (csv: string) => ExperimentActions.Stop({ data: csv, outcome: 'complete' }),
+ [ExperimentActions]
+ );
+ const onAbort = useCallback(
+ (csv: string) =>
+ ExperimentActions.Stop({ data: csv, outcome: 'incomplete' }),
[ExperimentActions]
);
+ const handleRunAnother = useCallback(() => {
+ ExperimentActions.DismissRunResult();
+ setIsInputCollectOpen(true);
+ }, [ExperimentActions]);
- const handleRunAgain = useCallback(() => {
- setHasFinished(false);
- }, []);
+ const result = isRunning ? isEnding && 'saving' : runOutcome;
return (
-
+
- {!isRunning && hasFinished && (
-
-
Recording complete 🎉
-
- Saved {subject}'s data. Ready to clean and analyze it?
-