Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
36 changes: 36 additions & 0 deletions .llms/learnings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 3 additions & 0 deletions TODOS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<title>/stimuli/<condition>/` 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.
Expand Down
5 changes: 0 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
58 changes: 58 additions & 0 deletions src/main/__tests__/recordings.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
29 changes: 18 additions & 11 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@
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.
Expand Down Expand Up @@ -88,7 +94,7 @@
constructor() {
log.transports.file.level = 'info';
autoUpdater.logger = log;
autoUpdater.checkForUpdatesAndNotify();

Check warning on line 97 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 97 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 97 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (macos-latest)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 97 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (macos-latest)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 97 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (windows-latest)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 97 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (windows-latest)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator
}
}

Expand Down Expand Up @@ -232,12 +238,12 @@
const files = fs.readdirSync(getWorkspaceDir(title), {
recursive: true,
}) as string[];
return files

Check warning on line 241 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest)

Replace `⏎······.filter(isRawEEGFile)⏎······` with `.filter(isRawEEGFile)`

Check warning on line 241 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest)

Replace `⏎······.filter(isRawEEGFile)⏎······` with `.filter(isRawEEGFile)`

Check warning on line 241 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (macos-latest)

Replace `⏎······.filter(isRawEEGFile)⏎······` with `.filter(isRawEEGFile)`

Check warning on line 241 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (macos-latest)

Replace `⏎······.filter(isRawEEGFile)⏎······` with `.filter(isRawEEGFile)`

Check warning on line 241 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (windows-latest)

Replace `⏎······.filter(isRawEEGFile)⏎······` with `.filter(isRawEEGFile)`

Check warning on line 241 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (windows-latest)

Replace `⏎······.filter(isRawEEGFile)⏎······` with `.filter(isRawEEGFile)`
.filter((filepath) => filepath.slice(-7).includes('raw.csv'))
.filter(isRawEEGFile)
.map((filepath) => {
const fullPath = path.join(getWorkspaceDir(title), filepath);

Check warning on line 244 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest)

Replace `········` with `······`

Check warning on line 244 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest)

Replace `········` with `······`

Check warning on line 244 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (macos-latest)

Replace `········` with `······`

Check warning on line 244 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (macos-latest)

Replace `········` with `······`

Check warning on line 244 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (windows-latest)

Replace `········` with `······`

Check warning on line 244 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (windows-latest)

Replace `········` with `······`
return { name: path.basename(filepath), path: fullPath };

Check warning on line 245 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest)

Delete `··`

Check warning on line 245 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest)

Delete `··`

Check warning on line 245 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (macos-latest)

Delete `··`

Check warning on line 245 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (macos-latest)

Delete `··`

Check warning on line 245 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (windows-latest)

Delete `··`

Check warning on line 245 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (windows-latest)

Delete `··`
});

Check warning on line 246 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest)

Delete `··`

Check warning on line 246 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest)

Delete `··`

Check warning on line 246 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (macos-latest)

Delete `··`

Check warning on line 246 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (macos-latest)

Delete `··`

Check warning on line 246 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (windows-latest)

Delete `··`

Check warning on line 246 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (windows-latest)

Delete `··`
} catch (e: unknown) {
if ((e as NodeJS.ErrnoException).code === 'ENOENT') console.log(e);
return [];
Expand Down Expand Up @@ -266,12 +272,12 @@
const files = fs.readdirSync(getWorkspaceDir(title), {
recursive: true,
}) as string[];
return files

Check warning on line 275 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest)

Replace `⏎······.filter(isBehaviorFile)⏎······` with `.filter(isBehaviorFile)`

Check warning on line 275 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest)

Replace `⏎······.filter(isBehaviorFile)⏎······` with `.filter(isBehaviorFile)`

Check warning on line 275 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (macos-latest)

Replace `⏎······.filter(isBehaviorFile)⏎······` with `.filter(isBehaviorFile)`

Check warning on line 275 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (macos-latest)

Replace `⏎······.filter(isBehaviorFile)⏎······` with `.filter(isBehaviorFile)`

Check warning on line 275 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (windows-latest)

Replace `⏎······.filter(isBehaviorFile)⏎······` with `.filter(isBehaviorFile)`

Check warning on line 275 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (windows-latest)

Replace `⏎······.filter(isBehaviorFile)⏎······` with `.filter(isBehaviorFile)`
.filter((filepath) => filepath.slice(-12).includes('behavior.csv'))
.filter(isBehaviorFile)
.map((filepath) => {
const fullPath = path.join(getWorkspaceDir(title), filepath);

Check warning on line 278 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest)

Replace `········` with `······`

Check warning on line 278 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest)

Replace `········` with `······`

Check warning on line 278 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (macos-latest)

Replace `········` with `······`

Check warning on line 278 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (macos-latest)

Replace `········` with `······`

Check warning on line 278 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (windows-latest)

Replace `········` with `······`

Check warning on line 278 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (windows-latest)

Replace `········` with `······`
return { name: path.basename(filepath), path: fullPath };

Check warning on line 279 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest)

Delete `··`

Check warning on line 279 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest)

Delete `··`

Check warning on line 279 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (macos-latest)

Delete `··`

Check warning on line 279 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (macos-latest)

Delete `··`

Check warning on line 279 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (windows-latest)

Delete `··`

Check warning on line 279 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (windows-latest)

Delete `··`
});

Check warning on line 280 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest)

Delete `··`

Check warning on line 280 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest)

Delete `··`

Check warning on line 280 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (macos-latest)

Delete `··`

Check warning on line 280 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (macos-latest)

Delete `··`

Check warning on line 280 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (windows-latest)

Delete `··`

Check warning on line 280 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (windows-latest)

Delete `··`
} catch (e: unknown) {
if ((e as NodeJS.ErrnoException).code === 'ENOENT') console.log(e);
return [];
Expand Down Expand Up @@ -424,17 +430,18 @@
}
);

/** 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(

Check warning on line 434 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest)

Replace `⏎··'fs:recordingExists',⏎·` with `'fs:recordingExists',`

Check warning on line 434 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest)

Replace `⏎··'fs:recordingExists',⏎·` with `'fs:recordingExists',`

Check warning on line 434 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (macos-latest)

Replace `⏎··'fs:recordingExists',⏎·` with `'fs:recordingExists',`

Check warning on line 434 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (macos-latest)

Replace `⏎··'fs:recordingExists',⏎·` with `'fs:recordingExists',`

Check warning on line 434 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (windows-latest)

Replace `⏎··'fs:recordingExists',⏎·` with `'fs:recordingExists',`

Check warning on line 434 in src/main/index.ts

View workflow job for this annotation

GitHub Actions / ci (windows-latest)

Replace `⏎··'fs:recordingExists',⏎·` with `'fs:recordingExists',`
'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[]) => {
Expand Down
50 changes: 50 additions & 0 deletions src/main/recordings.ts
Original file line number Diff line number Diff line change
@@ -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));
}
};
14 changes: 14 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,20 @@ contextBridge.exposeInMainWorld('electronAPI', {
): Promise<boolean> =>
ipcRenderer.invoke('fs:recordingExists', title, subject, group, session),

markRecordingIncomplete: (
title: string,
subject: string,
group: string,
session: number
): Promise<void> =>
ipcRenderer.invoke(
'fs:markRecordingIncomplete',
title,
subject,
group,
session
),

readFiles: (filePathsArray: string[]): Promise<string[]> =>
ipcRenderer.invoke('fs:readFiles', filePathsArray),

Expand Down
10 changes: 9 additions & 1 deletion src/renderer/actions/experimentActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean, 'SET_ESCAPE_HELD'>('SET_ESCAPE_HELD'),
/** Leaves the result screen for the Ready card. */
DismissRunResult: createAction('DISMISS_RUN_RESULT'),
SetType: createAction<EXPERIMENTS, 'SET_TYPE'>('SET_TYPE'),
SetExperimentObject: createAction<ExperimentObject, 'SET_EXPERIMENT_OBJECT'>(
'SET_EXPERIMENT_OBJECT'
Expand Down
25 changes: 6 additions & 19 deletions src/renderer/components/AppShell/AppShell.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 }) => (
<div className="flex h-screen min-w-[1180px] flex-col bg-white text-ink">
<RunBar
run={run!}
workspace={workspace}
device={device}
deviceName={deviceName}
onEndRun={onEndRun}
escapeHeld
/>
<div className="flex-1 bg-app px-[56px] pb-[40px] pt-[32px]">
escapeHeld: true,
children: (
<div className="px-[56px] pb-[40px] pt-[32px]">
<Placeholder
label="experiment runtime (trials) — unchanged"
height={300}
/>
</div>
</div>
),
),
},
};
4 changes: 4 additions & 0 deletions src/renderer/components/AppShell/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -46,6 +48,7 @@ export default function AppShell({
onSelectArea,
onHome,
onEndRun,
escapeHeld,
onDeviceClick,
children,
}: AppShellProps) {
Expand All @@ -59,6 +62,7 @@ export default function AppShell({
device={device}
deviceName={deviceName}
onEndRun={onEndRun}
escapeHeld={escapeHeld}
/>
) : (
<header className="flex h-[64px] min-h-0 items-stretch border-b border-[#e5e5e5] bg-white px-[24px] py-0 text-left">
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/components/AppShell/RunBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
10 changes: 1 addition & 9 deletions src/renderer/components/CollectComponent/PreTestComponent.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
}
Expand Down
Loading
Loading