@@ -12,12 +12,39 @@ use std::collections::VecDeque;
1212use std:: path:: Path ;
1313use std:: time:: Instant ;
1414
15+ use chrono:: { DateTime , Utc } ;
1516use wisp_audiokit:: {
1617 Event , LocalModelStatus , Permission , PermissionStatus , RecognizerBackend , SessionConfig ,
1718 SessionError , SessionResult , SourceLabel , local_model_spec, local_model_status,
1819} ;
1920use 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 ) ]
2249pub 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