Skip to content

Commit f062558

Browse files
fix session transcript linkage (#79)
1 parent fae9982 commit f062558

16 files changed

Lines changed: 1250 additions & 156 deletions

File tree

apps/wisp-desktop/src/app.rs

Lines changed: 208 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,39 @@ use std::collections::VecDeque;
1212
use std::path::Path;
1313
use std::time::Instant;
1414

15+
use chrono::{DateTime, Utc};
1516
use wisp_audiokit::{
1617
Event, LocalModelStatus, Permission, PermissionStatus, RecognizerBackend, SessionConfig,
1718
SessionError, SessionResult, SourceLabel, local_model_spec, local_model_status,
1819
};
1920
use wisp_core::{Session as StoredSession, SessionId};
2021

22+
#[derive(Debug, Clone)]
23+
pub enum AppError {
24+
Audio(SessionError),
25+
Persistence(String),
26+
}
27+
28+
impl From<SessionError> for AppError {
29+
fn from(error: SessionError) -> Self {
30+
Self::Audio(error)
31+
}
32+
}
33+
34+
impl std::fmt::Display for AppError {
35+
fn fmt(
36+
&self,
37+
formatter: &mut std::fmt::Formatter<'_>,
38+
) -> std::fmt::Result {
39+
match self {
40+
Self::Audio(error) => std::fmt::Display::fmt(error, formatter),
41+
Self::Persistence(error) => {
42+
write!(formatter, "session history persistence failed: {error}")
43+
},
44+
}
45+
}
46+
}
47+
2148
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2249
pub enum SessionState {
2350
Idle,
@@ -27,6 +54,25 @@ pub enum SessionState {
2754
Failed,
2855
}
2956

57+
impl SessionState {
58+
/// Whether an audio session is running or changing lifecycle state.
59+
/// While this is true the live transcript is the persistence source and
60+
/// must not be replaced by another view's segments.
61+
#[must_use]
62+
pub fn is_active(self) -> bool {
63+
matches!(
64+
self,
65+
Self::Starting | Self::Recording { .. } | Self::Stopping
66+
)
67+
}
68+
}
69+
70+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71+
pub enum PendingSessionWrite {
72+
Finalise { ended_at: DateTime<Utc> },
73+
Delete,
74+
}
75+
3076
/// Which top-level screen the desktop UI is currently showing.
3177
///
3278
/// - `Library`: list of past sessions with a "New Session" button.
@@ -218,14 +264,17 @@ pub struct AppModel {
218264
/// library.
219265
pub library: Vec<StoredSession>,
220266
/// `Some` while a session row exists in the database for the current
221-
/// recording — set when the Swift session reports `Started`, cleared
222-
/// when the user navigates away from the live view.
267+
/// live transcript. Allocated before audio starts and retained after a
268+
/// successful stop until the user navigates away or starts a new session.
223269
pub current_session_id: Option<SessionId>,
270+
/// A failed storage operation that must be retried before the live
271+
/// transcript can be discarded or another session can start.
272+
pub pending_session_write: Option<PendingSessionWrite>,
224273
/// The session being viewed in `View::History`, kept around so the
225274
/// header can render its title without re-querying.
226275
pub viewed_session: Option<StoredSession>,
227276
pub recent_log: VecDeque<String>,
228-
pub last_error: Option<SessionError>,
277+
pub last_error: Option<AppError>,
229278
pub permissions: Permissions,
230279
pub setup: Setup,
231280
pub local_mcp: LocalMcpBridge,
@@ -239,6 +288,7 @@ impl AppModel {
239288
segments: Vec::new(),
240289
library: Vec::new(),
241290
current_session_id: None,
291+
pending_session_write: None,
242292
viewed_session: None,
243293
recent_log: VecDeque::new(),
244294
last_error: None,
@@ -275,20 +325,29 @@ impl AppModel {
275325
/// Move to the library screen and drop any live/historical segments so
276326
/// the next view enter starts from a clean slate.
277327
pub fn show_library(&mut self) {
328+
if self.live_session_is_protected() {
329+
return;
330+
}
278331
self.view = View::Library;
279332
self.segments.clear();
280333
self.viewed_session = None;
334+
self.current_session_id = None;
335+
self.pending_session_write = None;
281336
self.last_error = None;
282337
}
283338

284339
/// Move to the live recording screen in idle state. Used by the
285340
/// library's "New Session" button.
286341
pub fn show_new_session(&mut self) {
342+
if self.live_session_is_protected() {
343+
return;
344+
}
287345
self.view = View::LiveSession;
288346
self.state = SessionState::Idle;
289347
self.segments.clear();
290348
self.viewed_session = None;
291349
self.current_session_id = None;
350+
self.pending_session_write = None;
292351
self.last_error = None;
293352
}
294353

@@ -299,30 +358,62 @@ impl AppModel {
299358
session: StoredSession,
300359
segments: Vec<Segment>,
301360
) {
361+
if self.live_session_is_protected() {
362+
return;
363+
}
302364
self.view = View::History {
303365
session_id: session.id,
304366
};
305367
self.segments = segments;
306368
self.viewed_session = Some(session);
369+
self.current_session_id = None;
370+
self.pending_session_write = None;
307371
// Historical segments are already finalized.
308372
self.finalize_all_segments();
309373
for seg in &mut self.segments {
310374
seg.refresh_display();
311375
}
312376
}
313377

378+
/// Prepare a fresh recording while preserving the invariant that the
379+
/// visible segments and `current_session_id` always describe the same
380+
/// live session. This also makes menu/shortcut starts from Library or
381+
/// History enter the live view before runner updates arrive.
382+
pub fn begin_session(&mut self) {
383+
if self.live_session_is_protected() {
384+
return;
385+
}
386+
self.view = View::LiveSession;
387+
self.state = SessionState::Starting;
388+
self.segments.clear();
389+
self.viewed_session = None;
390+
self.current_session_id = None;
391+
self.pending_session_write = None;
392+
self.last_error = None;
393+
}
394+
395+
/// Whether leaving the live view could discard an in-flight or not-yet
396+
/// persisted transcript. A failed finalisation keeps its session id so it
397+
/// can be retried without mixing it into a subsequent recording.
398+
#[must_use]
399+
pub fn live_session_is_protected(&self) -> bool {
400+
self.state.is_active() || self.pending_session_write.is_some()
401+
}
402+
314403
pub fn set_state(
315404
&mut self,
316405
state: SessionState,
317406
) {
318407
self.state = state;
319408
}
320409

321-
pub fn fail(
410+
pub fn fail<E>(
322411
&mut self,
323-
error: SessionError,
324-
) {
325-
self.last_error = Some(error);
412+
error: E,
413+
) where
414+
E: Into<AppError>,
415+
{
416+
self.last_error = Some(error.into());
326417
self.state = SessionState::Failed;
327418
self.finalize_all_segments();
328419
}
@@ -511,6 +602,31 @@ mod tests {
511602
}
512603
}
513604

605+
fn stored_session(id: i64) -> StoredSession {
606+
let started_at = chrono::Utc::now();
607+
StoredSession {
608+
id: SessionId::from(id),
609+
started_at,
610+
ended_at: Some(started_at),
611+
title: format!("session {id}"),
612+
mic_wav_path: format!("session-{id}/mic.wav"),
613+
system_wav_path: format!("session-{id}/system.wav"),
614+
notes: String::new(),
615+
}
616+
}
617+
618+
fn historical_segment(text: &str) -> Segment {
619+
Segment {
620+
source: SourceLabel::Mic,
621+
id: 0,
622+
text: text.into(),
623+
display_text: text.into(),
624+
start_seconds: 0.0,
625+
end_seconds: 1.0,
626+
is_final: true,
627+
}
628+
}
629+
514630
#[test]
515631
fn partial_revisions_replace_text_in_place() {
516632
let mut m = AppModel::new();
@@ -615,6 +731,91 @@ mod tests {
615731
assert!(m.segments[0].is_final);
616732
}
617733

734+
#[test]
735+
fn active_recording_cannot_replace_live_transcript_with_history() {
736+
let mut m = AppModel::new();
737+
m.show_new_session();
738+
m.state = SessionState::Recording {
739+
started_at: Instant::now(),
740+
};
741+
m.current_session_id = Some(SessionId::from(10));
742+
m.ingest(Event::Result(r(SourceLabel::Mic, 1, "live transcript")));
743+
744+
m.show_library();
745+
assert_eq!(m.view, View::LiveSession);
746+
assert_eq!(m.current_session_id, Some(SessionId::from(10)));
747+
assert_eq!(m.segments.len(), 1);
748+
assert_eq!(m.segments[0].text, "live transcript");
749+
750+
m.show_history(
751+
stored_session(20),
752+
vec![historical_segment("old transcript")],
753+
);
754+
assert_eq!(m.view, View::LiveSession);
755+
assert_eq!(m.current_session_id, Some(SessionId::from(10)));
756+
assert_eq!(m.segments.len(), 1);
757+
assert_eq!(m.segments[0].text, "live transcript");
758+
759+
m.show_new_session();
760+
assert!(matches!(m.state, SessionState::Recording { .. }));
761+
assert_eq!(m.current_session_id, Some(SessionId::from(10)));
762+
assert_eq!(m.segments[0].text, "live transcript");
763+
}
764+
765+
#[test]
766+
fn stopping_session_keeps_live_transcript_until_persistence() {
767+
let mut m = AppModel::new();
768+
m.show_new_session();
769+
m.state = SessionState::Stopping;
770+
m.current_session_id = Some(SessionId::from(10));
771+
m.ingest(Event::Result(r(SourceLabel::Mic, 1, "flushed transcript")));
772+
773+
m.show_library();
774+
775+
assert_eq!(m.view, View::LiveSession);
776+
assert_eq!(m.current_session_id, Some(SessionId::from(10)));
777+
assert_eq!(m.segments[0].text, "flushed transcript");
778+
}
779+
780+
#[test]
781+
fn failed_persistence_keeps_live_transcript_protected_for_retry() {
782+
let mut m = AppModel::new();
783+
m.show_new_session();
784+
m.state = SessionState::Failed;
785+
m.current_session_id = Some(SessionId::from(10));
786+
m.pending_session_write = Some(PendingSessionWrite::Finalise {
787+
ended_at: chrono::Utc::now(),
788+
});
789+
m.segments.push(historical_segment("not persisted yet"));
790+
791+
m.show_library();
792+
m.show_history(stored_session(20), vec![historical_segment("old")]);
793+
m.show_new_session();
794+
m.begin_session();
795+
796+
assert_eq!(m.view, View::LiveSession);
797+
assert_eq!(m.state, SessionState::Failed);
798+
assert_eq!(m.current_session_id, Some(SessionId::from(10)));
799+
assert_eq!(m.segments[0].text, "not persisted yet");
800+
}
801+
802+
#[test]
803+
fn begin_session_normalizes_history_to_fresh_live_view() {
804+
let mut m = AppModel::new();
805+
m.show_history(
806+
stored_session(20),
807+
vec![historical_segment("old transcript")],
808+
);
809+
810+
m.begin_session();
811+
812+
assert_eq!(m.view, View::LiveSession);
813+
assert_eq!(m.state, SessionState::Starting);
814+
assert!(m.segments.is_empty());
815+
assert!(m.viewed_session.is_none());
816+
assert!(m.current_session_id.is_none());
817+
}
818+
618819
#[test]
619820
fn needs_live_ui_tick_only_on_active_live_session() {
620821
let mut m = AppModel::new();

0 commit comments

Comments
 (0)