From 80d468a85d23966bab093e7aa53b4c2855d9163d Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 10 Sep 2026 16:54:01 +0200 Subject: [PATCH] feat: a recording survives switching to the Assistant, and signing out keeps it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching to the Assistant set window.location, which reloads the document and silently ended any running recording. The assistant is a tab in the same page and activateTab already rewrites the URL to /assistant, so the switch now happens in place; the reload stays as a fallback. Verified in Chromium: recorder still running, no reload, URL /assistant, assistant visible. Signing out mid-recording used to end it with nothing kept. It now says so first — "the audio will be saved for 24 hours so you can transcribe it later" — and stores the audio either way, tagged with the module that produced it ('encounter', 'soap', 'dictation'), which is what makes it findable in Settings afterwards. Sign-out completes whether or not the save worked, and the rescue never rejects, because the caller is on its way out. Verified: the warning appears and the upload carries module=encounter. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU --- public/js/app.js | 34 ++++++++++++++++++++++-- public/js/auth.js | 18 +++++++++++-- test/assistant-workspace-layout.test.js | 6 ++++- test/transcription-memory-policy.test.js | 32 ++++++++++++++++++++++ 4 files changed, 85 insertions(+), 5 deletions(-) diff --git a/public/js/app.js b/public/js/app.js index b2f59b5d..7434ba5b 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -184,8 +184,14 @@ document.addEventListener('DOMContentLoaded', function() { return; } if (!onAssistant) { - // In the app you ARE the workspace, so only the Assistant pill navigates. - if (wantsAssistant) window.location.href = '/assistant'; + // In the app you ARE the workspace, so only the Assistant pill moves. + // This used to set window.location, which reloads the document and kills + // a running recording. The assistant is a tab in this same page, and + // activateTab already rewrites the URL to /assistant, so switching keeps + // the recorder — and everything else — alive. + if (!wantsAssistant) return; + if (typeof window.activateTab === 'function') window.activateTab('assistant'); + else window.location.href = '/assistant'; return; } // Inside the assistant, Workspace opens the launcher in place rather than @@ -1015,6 +1021,28 @@ window.addEventListener('account-boundary', function() { if (_wakeLock) { try { _wakeLock.release(); } catch (e) {} _wakeLock = null; } }); +// Every running recorder, so a logout or a navigation can find one and save it +// rather than discarding minutes of a consultation. +var _activeRecorders = new Set(); +window.activeRecordingCount = function() { return _activeRecorders.size; }; + +// Stops each running recording and stores it, tagged with the module it came +// from, so it can be picked up and transcribed later. Resolves once every one +// is stored (or has failed to store) — never rejects, because the caller is +// usually on its way out of the app. +window.rescueActiveRecordings = function() { + var recorders = Array.from(_activeRecorders); + if (!recorders.length) return Promise.resolve([]); + return Promise.all(recorders.map(function(recorder) { + var module = recorder._module || 'recording'; + return recorder.stop().then(function(blob) { + if (!blob || !blob.size || typeof saveAudioBackup !== 'function') return null; + return saveAudioBackup(blob, module); + }).then(function(id) { return { module: module, id: id }; }) + .catch(function() { return { module: module, id: null }; }); + })); +}; + function AudioRecorder() { this.mediaRecorder = null; this.chunks = []; this.stream = null; } AudioRecorder.prototype.start = function() { var self = this; @@ -1052,6 +1080,7 @@ AudioRecorder.prototype.start = function() { }); }); self.mediaRecorder.start(1000); + _activeRecorders.add(self); self.heldWakeLock = true; holdWakeLock(); }); @@ -1072,6 +1101,7 @@ AudioRecorder.prototype.notifyFailure = function() { AudioRecorder.prototype.stop = function() { var self = this; + _activeRecorders.delete(self); if (self.heldWakeLock) { self.heldWakeLock = false; releaseWakeLock(); } return new Promise(function(resolve) { if (!self.mediaRecorder || self.mediaRecorder.state === 'inactive') { resolve(null); return; } diff --git a/public/js/auth.js b/public/js/auth.js index c869f48b..0244f2a6 100644 --- a/public/js/auth.js +++ b/public/js/auth.js @@ -563,8 +563,22 @@ document.addEventListener('DOMContentLoaded', function() { // Logout button if (target.id === 'btn-logout' || target.closest('#btn-logout')) { e.preventDefault(); - exitApp(); - showToast('Logged out', 'info'); + // Signing out mid-recording used to end it with nothing kept. Say so, and + // store the audio either way, tagged with the encounter it came from, so + // it can be transcribed from Settings afterwards. + var recording = typeof activeRecordingCount === 'function' && activeRecordingCount() > 0; + if (!recording) { exitApp(); showToast('Logged out', 'info'); return; } + showConfirm('A recording is still running. Signing out stops it — the audio will be saved for 24 hours so you can transcribe it later. Sign out?', function() { + showToast('Saving the recording...', 'info'); + rescueActiveRecordings().then(function(saved) { + var kept = saved.filter(function(entry) { return entry && entry.id; }); + if (kept.length) showToast('Recording saved (' + kept[0].module + '). Find it in Settings → Audio backups.', 'success'); + else showToast('The recording could not be saved', 'error'); + }).finally(function() { + exitApp(); + showToast('Logged out', 'info'); + }); + }); } // About modal diff --git a/test/assistant-workspace-layout.test.js b/test/assistant-workspace-layout.test.js index 9d01d13c..61b60a8e 100644 --- a/test/assistant-workspace-layout.test.js +++ b/test/assistant-workspace-layout.test.js @@ -253,7 +253,11 @@ test('the switch navigates from the app and opens the launcher inside the assist const app = read('public/js/app.js'); const handler = app.slice(app.indexOf('// Assistant / Workspace switch'), app.indexOf('window.activateTab = activateTab;')); // In the app you ARE the workspace, so only the Assistant pill goes anywhere. - assert.match(handler, /if \(wantsAssistant\) window\.location\.href = '\/assistant';/); + // It switches in place rather than setting window.location, which reloads the + // document and would end a running recording. + assert.match(handler, /if \(!wantsAssistant\) return;/); + assert.match(handler, /if \(typeof window\.activateTab === 'function'\) window\.activateTab\('assistant'\);/); + assert.match(handler, /else window\.location\.href = '\/assistant';/); assert.match(handler, /assistantShowWorkspaceLauncher\(!wantsAssistant\)/, 'in the assistant it swaps the view in place'); // The in-page mode machinery is gone, and with it the whole class of diff --git a/test/transcription-memory-policy.test.js b/test/transcription-memory-policy.test.js index 7a4e3eaa..d8871404 100644 --- a/test/transcription-memory-policy.test.js +++ b/test/transcription-memory-policy.test.js @@ -172,3 +172,35 @@ test('every recording is kept for 24 hours, not only the failures', () => { assert.match(db, /ALTER TABLE audio_backups ADD COLUMN IF NOT EXISTS storage_key TEXT;/, 'existing installations get the column too'); }); + +test('signing out mid-recording warns, and keeps the audio with its encounter', () => { + const app = read('public/js/app.js'); + const auth = read('public/js/auth.js'); + // Every running recorder is registered, so anything about to end the session + // can find one instead of discarding minutes of a consultation. + assert.match(app, /var _activeRecorders = new Set\(\);/); + assert.match(app, /_activeRecorders\.add\(self\);/); + assert.match(app, /_activeRecorders\.delete\(self\);/); + assert.match(app, /window\.rescueActiveRecordings = function\(\)/); + // Tagged with the module that produced it, which is what makes it findable + // afterwards: 'encounter', 'soap', 'dictation'. + assert.match(app, /var module = recorder\._module \|\| 'recording';/); + assert.match(app, /return saveAudioBackup\(blob, module\);/); + // The caller is on its way out of the app, so this must never reject. + assert.match(app, /\.catch\(function\(\) \{ return \{ module: module, id: null \}; \}\);/); + + assert.match(auth, /activeRecordingCount\(\) > 0/); + assert.match(auth, /showConfirm\('A recording is still running\./); + assert.match(auth, /rescueActiveRecordings\(\)\.then/); + assert.match(auth, /\.finally\(function\(\) \{\s*\n\s*exitApp\(\);/, 'sign-out completes whether or not the save worked'); +}); + +test('switching to the Assistant does not reload the page out from under a recording', () => { + const app = read('public/js/app.js'); + const handler = app.slice(app.indexOf('if (!onAssistant) {'), app.indexOf('// Inside the assistant, Workspace opens the launcher')); + // window.location reloads the document, which ends any running recording. + assert.match(handler, /if \(typeof window\.activateTab === 'function'\) window\.activateTab\('assistant'\);/); + assert.match(handler, /else window\.location\.href = '\/assistant';/, 'the reload stays as a fallback'); + // activateTab already rewrites the URL, so the address still reads /assistant. + assert.match(app, /var target = tabName === 'assistant' \? '\/assistant' : '\/';/); +});