feat: a recording survives switching to the Assistant, and signing out keeps it

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
Daniel 2026-09-10 16:54:01 +02:00
parent 8e4c70aaa8
commit 80d468a85d
4 changed files with 85 additions and 5 deletions

View file

@ -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; }

View file

@ -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

View file

@ -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

View file

@ -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' : '\/';/);
});