diff --git a/app/e2e-mock-ipc.js b/app/e2e-mock-ipc.js
index 1ed12fb5..6270a076 100644
--- a/app/e2e-mock-ipc.js
+++ b/app/e2e-mock-ipc.js
@@ -165,6 +165,35 @@ function seedPriorSegments(rec) {
];
}
+// Two notes differing ONLY in whether their original recording still exists,
+// for the overview's audio indicator (STENOAI_E2E_SEED_AUDIO_MEETINGS=1).
+// keep_recordings defaults off, so "no audio" is the normal case -- the pair
+// is what proves the icon tracks the flag rather than always rendering.
+const AUDIO_SEED_MEETINGS = [
+ {
+ session_info: {
+ name: 'With audio',
+ summary_file: 'with-audio_summary.md',
+ processed_at: '2026-08-02T12:00:00Z',
+ duration_seconds: 600,
+ },
+ summary: 'A note whose recording was kept.',
+ has_audio: true,
+ key_points: [], action_items: [], discussion_areas: [], participants: [],
+ },
+ {
+ session_info: {
+ name: 'Without audio',
+ summary_file: 'without-audio_summary.md',
+ processed_at: '2026-08-02T11:00:00Z',
+ duration_seconds: 900,
+ },
+ summary: 'A note whose recording was discarded after processing.',
+ has_audio: false,
+ key_points: [], action_items: [], discussion_areas: [], participants: [],
+ },
+];
+
function install({ ipcMain }) {
// In-memory stand-in for the org session + provider config that the real
// handlers persist to disk. Mutated by the org-login / org-logout / set-ai
@@ -377,6 +406,9 @@ function install({ ipcMain }) {
// lives in MOCKS, which shadows DEFAULTS, so it is the single source for the
// channel.
'list-meetings': async () => {
+ if (process.env.STENOAI_E2E_SEED_AUDIO_MEETINGS === '1') {
+ return { success: true, meetings: AUDIO_SEED_MEETINGS };
+ }
if (process.env.STENOAI_E2E_SEED_PENDING_NOTE === '1') {
return { success: true, meetings: [PENDING_MEETING] };
}
diff --git a/app/renderer/src/components/home/PreviousRow.tsx b/app/renderer/src/components/home/PreviousRow.tsx
index 5647cc5c..c3278f65 100644
--- a/app/renderer/src/components/home/PreviousRow.tsx
+++ b/app/renderer/src/components/home/PreviousRow.tsx
@@ -1,4 +1,4 @@
-import { Folder as FolderIcon, Loader2 } from 'lucide-react';
+import { AudioLines, Folder as FolderIcon, Loader2 } from 'lucide-react';
import type { Meeting } from '@/lib/ipc';
import { navigate } from '@/lib/router';
import { useMeetingsList } from '@/lib/meetingsListContext';
@@ -116,7 +116,28 @@ export function PreviousRow({ meeting, folderName }: PreviousRowProps) {
style={{ color: 'var(--fg-2)' }}
>
{isSynthetic ? 'Now' : (when ?? '')}
- {duration && {duration}}
+ {/* Original audio still on disk. Shown only when present, never as a
+ crossed-out "missing" marker: keep_recordings defaults off, so
+ absence is the normal case and flagging it on most rows would be
+ noise. What the icon buys is knowing, without opening the note,
+ which recordings can still be re-transcribed or listened to for
+ speaker review — the actions that silently disappear once the
+ audio is gone. Sits beside the duration because both describe
+ the recording rather than the note. */}
+ {(duration || (!isSynthetic && meeting.has_audio)) && (
+
+ {!isSynthetic && meeting.has_audio && (
+
+ Original audio still available
+
+ )}
+ {duration}
+
+ )}
);
diff --git a/app/renderer/src/lib/ipc.ts b/app/renderer/src/lib/ipc.ts
index 04f35f3b..71407b74 100644
--- a/app/renderer/src/lib/ipc.ts
+++ b/app/renderer/src/lib/ipc.ts
@@ -58,6 +58,12 @@ export interface Meeting {
is_diarised?: boolean;
diarised_text?: string | null;
folders?: string[];
+ /** Whether the ORIGINAL recording is still on disk (list-meetings only —
+ * derived from one directory listing, extension-agnostic). keep_recordings
+ * defaults off, so this is false for most notes, and everything that needs
+ * the audio (re-transcribe, speaker samples, any future re-diarization) is
+ * quietly unavailable without it. */
+ has_audio?: boolean;
/** User notes as persisted + returned by the backend (`_parse_meeting_markdown` -> `user_notes`). */
user_notes?: string | null;
/** Renderer-side notes for the in-progress / draft recording (live + processing views). */
diff --git a/e2e/specs/overview-audio-indicator.t1.spec.ts b/e2e/specs/overview-audio-indicator.t1.spec.ts
new file mode 100644
index 00000000..de342742
--- /dev/null
+++ b/e2e/specs/overview-audio-indicator.t1.spec.ts
@@ -0,0 +1,47 @@
+import { test, expect } from '../fixtures/electron';
+
+/**
+ * T1 — renderer-only, mock IPC. The overview's "original audio still exists"
+ * indicator.
+ *
+ * Why it earns a UI spec on top of the backend's own tests: the value of the
+ * icon is entirely in it being ABSENT for notes whose recording is gone.
+ * keep_recordings defaults off, so most rows have no audio — an icon that
+ * rendered unconditionally would look correct on a screenshot and tell the
+ * user nothing. Only a pair of rows differing solely in `has_audio` can show
+ * the difference.
+ */
+
+test('only a note whose recording still exists shows the audio indicator', async ({
+ launchApp,
+}) => {
+ const { page } = await launchApp({
+ mockIpc: true,
+ env: {
+ STENOAI_E2E_SEED_AUDIO_MEETINGS: '1',
+ // Without an installed model App.tsx's first-run gate redirects a
+ // neutral route to /setup before Home ever renders (same seam the
+ // pill-dock T1 uses).
+ STENOAI_E2E_MOCK_PARAKEET_INSTALLED: '1',
+ },
+ });
+
+ const rows = page.getByTestId('previous-row');
+ await expect(rows).toHaveCount(2);
+
+ const withAudio = rows.filter({ hasText: 'With audio' });
+ const withoutAudio = rows.filter({ hasText: 'Without audio' });
+
+ await expect(withAudio.getByTestId('previous-row-has-audio')).toHaveCount(1);
+ await expect(withoutAudio.getByTestId('previous-row-has-audio')).toHaveCount(0);
+
+ // The duration still renders on both -- the icon sits BESIDE it rather
+ // than replacing it.
+ await expect(withAudio).toContainText('10m');
+ await expect(withoutAudio).toContainText('15m');
+
+ // Named for screen readers, not a bare decorative glyph.
+ await expect(
+ withAudio.getByLabel('Original audio still available'),
+ ).toBeVisible();
+});
diff --git a/simple_recorder.py b/simple_recorder.py
index 0dba117f..881d4867 100644
--- a/simple_recorder.py
+++ b/simple_recorder.py
@@ -19,6 +19,7 @@
import asyncio
import logging
import json
+import os
import re
import sys
import time
@@ -2751,6 +2752,44 @@ def list_meetings():
# Ensure output directory exists
output_dir.mkdir(parents=True, exist_ok=True)
+ # Stems that still have their source recording on disk. Read as ONE
+ # directory listing rather than a per-meeting existence check, because
+ # this command is on the app's cold-start path and is deliberately
+ # "optimized for fast loading" -- with a listing the cost is a single
+ # syscall regardless of library size, and each meeting is then a set
+ # lookup. Extension-agnostic on purpose: the capture pipeline saves
+ # whatever the source produced (.webm system audio, .wav native
+ # captures, .m4a/.mp3 imports), so only the stem is meaningful.
+ def _recorded_stems(recordings_dir) -> set:
+ try:
+ # FILES only. A directory that happens to be named like a
+ # recording (`recordings/note.wav/`) would otherwise report the
+ # note as having audio it does not have. scandir keeps this a
+ # single listing -- the is_file() check comes from the entry
+ # already returned, not from an extra stat per name.
+ return {
+ Path(entry.name).stem
+ for entry in os.scandir(recordings_dir)
+ if entry.is_file()
+ }
+ except OSError:
+ # No recordings dir yet (fresh install) is not an error -- it
+ # just means nothing has audio.
+ return set()
+
+ def _summary_stem(summary_path) -> str:
+ """`_summary.{md,json}` -> ``, stripping only a TRAILING
+ marker. `str.replace` removes every occurrence, so a note whose own
+ name contains the marker (`client_summary.v1_summary.md`) came back
+ as `client.v1`. That was harmless while the stem only fed the
+ dedup set, where a wrong-but-consistent key still dedups; matching
+ it against real filenames on disk is what makes it visible. Same
+ rule reprocess and app/main.js's delete path already use."""
+ name = summary_path.stem
+ return name[:-len('_summary')] if name.endswith('_summary') else name
+
+ audio_stems = _recorded_stems(dirs["recordings"])
+
# Collect summary files from current output dir (JSON preferred over MD)
seen_files = set()
seen_stems = set()
@@ -2758,9 +2797,9 @@ def list_meetings():
# JSON first — if both .json and .md exist, JSON wins (it has structured data)
for pattern in ("*_summary.json", "*_summary.md"):
for f in output_dir.glob(pattern):
- stem = f.stem.replace('_summary', '')
+ stem = _summary_stem(f)
if stem not in seen_stems:
- summaries.append(f)
+ summaries.append((f, stem))
seen_files.add(f.resolve())
seen_stems.add(stem)
@@ -2774,18 +2813,21 @@ def list_meetings():
else:
default_output = Path(__file__).parent / "output"
if default_output.exists():
+ # Meetings found here keep their audio in the DEFAULT recordings
+ # dir, not the custom one -- they predate the path change.
+ audio_stems |= _recorded_stems(default_output.parent / "recordings")
for pattern in ("*_summary.json", "*_summary.md"):
for f in default_output.glob(pattern):
- stem = f.stem.replace('_summary', '')
+ stem = _summary_stem(f)
if f.resolve() not in seen_files and stem not in seen_stems:
- summaries.append(f)
+ summaries.append((f, stem))
seen_files.add(f.resolve())
seen_stems.add(stem)
meetings = []
# Single-pass: read each file once, extract sort key and data together
- for summary_file in summaries:
+ for summary_file, stem in summaries:
try:
if summary_file.suffix == '.md':
parsed = _parse_meeting_markdown(summary_file)
@@ -2815,6 +2857,13 @@ def list_meetings():
"folders": data.get("folders", []),
"user_notes": data.get("user_notes"),
}
+ # Whether the ORIGINAL audio is still on disk. keep_recordings
+ # defaults off, so for most notes it is not -- and everything
+ # that needs the audio (re-transcribe, speaker samples, any
+ # future re-diarization) is silently unavailable without it,
+ # with nothing in the list saying so until you open the note
+ # and find the action missing.
+ essential_meeting['has_audio'] = stem in audio_stems
meetings.append((sort_key, essential_meeting))
except Exception as e:
logger.warning(f"Failed to load {summary_file}: {e}")
diff --git a/tests/test_list_meetings_has_audio.py b/tests/test_list_meetings_has_audio.py
new file mode 100644
index 00000000..a9e8bf9c
--- /dev/null
+++ b/tests/test_list_meetings_has_audio.py
@@ -0,0 +1,149 @@
+"""`has_audio` on list-meetings: does this note's original recording still
+exist on disk?
+
+keep_recordings defaults OFF, so for most notes it does not -- and
+everything that depends on the audio (re-transcribe, the speaker panel's
+listening samples, any future re-diarization) is quietly unavailable
+without it, with nothing in the list saying so until you open the note and
+find the action missing.
+
+Derived from ONE directory listing rather than a per-meeting existence
+check: this command is on the app's cold-start path and its docstring
+calls it "optimized for fast loading", so the cost must not scale with
+library size.
+"""
+
+import json
+import os
+import tempfile
+import unittest
+from pathlib import Path
+from unittest import mock
+
+from click.testing import CliRunner
+
+import simple_recorder
+from src.config import Config
+
+
+def _run(tmp, cfg=None):
+ cfg = cfg or Config(config_path=Path(tmp) / "config.json")
+ with mock.patch("src.config.get_config", return_value=cfg), \
+ mock.patch.dict("os.environ", {"STENOAI_USER_DATA_DIR": tmp}):
+ result = CliRunner().invoke(simple_recorder.list_meetings, [])
+ return json.loads(
+ [ln for ln in result.output.splitlines() if ln.strip().startswith("[")][-1]
+ )
+
+
+def _write_note(tmp, stem, title):
+ output_dir = Path(tmp) / "output"
+ output_dir.mkdir(parents=True, exist_ok=True)
+ (output_dir / f"{stem}_summary.md").write_text(
+ f'---\ntitle: "{title}"\ndate: "2026-08-02T10:00:00"\n'
+ f'duration_seconds: 600\nlanguage: "en"\nis_diarised: false\n---\n\n'
+ "## Summary\n\nbody\n",
+ encoding="utf-8",
+ )
+
+
+def _write_recording(tmp, name):
+ recordings = Path(tmp) / "recordings"
+ recordings.mkdir(parents=True, exist_ok=True)
+ (recordings / name).write_bytes(b"stub")
+
+
+class ListMeetingsHasAudioTests(unittest.TestCase):
+ def test_flags_only_the_notes_whose_recording_survives(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ _write_note(tmp, "kept", "Kept")
+ _write_note(tmp, "gone", "Gone")
+ _write_recording(tmp, "kept.webm")
+
+ by_title = {m["session_info"]["name"]: m for m in _run(tmp)}
+ self.assertTrue(by_title["Kept"]["has_audio"])
+ self.assertFalse(by_title["Gone"]["has_audio"])
+
+ def test_any_recording_format_counts(self):
+ # The capture pipeline saves whatever the source produced -- .webm
+ # for system audio, .wav for native captures, .m4a/.mp3 for
+ # imports. Matching on the stem alone is what makes this correct
+ # for all of them; an extension whitelist would silently mark
+ # imported meetings as audio-less.
+ for extension in ("webm", "wav", "m4a", "mp3"):
+ with tempfile.TemporaryDirectory() as tmp:
+ _write_note(tmp, "note", "Note")
+ _write_recording(tmp, f"note.{extension}")
+ self.assertTrue(
+ _run(tmp)[0]["has_audio"], f".{extension} was not recognised",
+ )
+
+ def test_a_recordings_dir_that_does_not_exist_is_not_an_error(self):
+ # Fresh install: nothing has audio, and the list must still render.
+ with tempfile.TemporaryDirectory() as tmp:
+ _write_note(tmp, "note", "Note")
+ self.assertFalse(Path(tmp, "recordings").exists())
+ meetings = _run(tmp)
+ self.assertEqual(len(meetings), 1)
+ self.assertFalse(meetings[0]["has_audio"])
+
+ def test_a_similarly_named_recording_does_not_count_for_another_note(self):
+ # Prefix matching would wrongly flag "note" from "note-2.wav".
+ with tempfile.TemporaryDirectory() as tmp:
+ _write_note(tmp, "note", "Note")
+ _write_recording(tmp, "note-2.wav")
+ self.assertFalse(_run(tmp)[0]["has_audio"])
+
+ def test_a_directory_named_like_a_recording_does_not_count(self):
+ # os.listdir returns directories too, so a folder called
+ # "note.wav" would otherwise report audio that does not exist.
+ with tempfile.TemporaryDirectory() as tmp:
+ _write_note(tmp, "note", "Note")
+ (Path(tmp) / "recordings" / "note.wav").mkdir(parents=True)
+ self.assertFalse(_run(tmp)[0]["has_audio"])
+
+ def test_a_note_whose_own_name_contains_the_summary_marker(self):
+ # `str.replace` strips EVERY occurrence, so
+ # "client_summary.v1_summary.md" used to yield the stem
+ # "client.v1" -- harmless while the stem only fed the dedup set,
+ # wrong the moment it is matched against real filenames.
+ with tempfile.TemporaryDirectory() as tmp:
+ _write_note(tmp, "client_summary.v1", "Client")
+ _write_recording(tmp, "client_summary.v1.wav")
+ meetings = _run(tmp)
+ self.assertEqual(len(meetings), 1)
+ self.assertTrue(
+ meetings[0]["has_audio"],
+ "the stem must keep everything but a TRAILING _summary",
+ )
+
+ def test_the_recordings_dir_is_listed_once_not_once_per_meeting(self):
+ # The guard on the "optimized for fast loading" promise: a
+ # per-meeting existence check would make cold start scale with
+ # library size. Counting the directory listings is what actually
+ # holds the implementation to a single one.
+ with tempfile.TemporaryDirectory() as tmp:
+ for i in range(12):
+ _write_note(tmp, f"note{i}", f"Note {i}")
+ _write_recording(tmp, "note3.wav")
+
+ real_scandir = os.scandir
+ calls = []
+
+ def counting_scandir(path):
+ calls.append(str(path))
+ return real_scandir(path)
+
+ with mock.patch("os.scandir", side_effect=counting_scandir):
+ meetings = _run(tmp)
+
+ recordings_dir = str(Path(tmp) / "recordings")
+ self.assertEqual(
+ [c for c in calls if c == recordings_dir], [recordings_dir],
+ "the recordings dir must be listed exactly once for the whole list",
+ )
+ self.assertEqual(sum(1 for m in meetings if m["has_audio"]), 1)
+
+
+if __name__ == '__main__':
+ unittest.main()