22// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
33
44import { hasAuthToken } from "@/features/auth" ;
5+ import {
6+ notifyNative ,
7+ safeNotificationLabel ,
8+ sanitizeNotificationBody ,
9+ } from "@/lib/native-notifications" ;
510import { useEffect } from "react" ;
611import {
712 getTrainingMetrics ,
813 getTrainingStatus ,
914 isAbortError ,
1015 streamTrainingProgress ,
1116} from "../api/train-api" ;
17+ import { useTrainingConfigStore } from "../stores/training-config-store" ;
1218import { useTrainingRuntimeStore } from "../stores/training-runtime-store" ;
1319import type { TrainingRuntimeStore } from "../types/runtime" ;
1420
1521const STATUS_POLL_INTERVAL_MS = 3000 ;
1622const METRICS_POLL_INTERVAL_MS = 5000 ;
1723const IDLE_POLL_INTERVAL_MS = 30000 ;
1824const STREAM_RECONNECT_DELAY_MS = 1500 ;
25+ const GENERIC_TRAINING_STREAM_ERROR = "Training stream error" ;
26+ const DEFAULT_TRAINING_ERROR_BODY = "Your training run stopped with an error." ;
1927
2028function shouldUseLiveSync ( state : TrainingRuntimeStore ) : boolean {
2129 return state . isTrainingRunning || state . phase === "training" ;
2230}
2331
32+ function getTrainingErrorBody ( state : TrainingRuntimeStore ) : string {
33+ return sanitizeNotificationBody (
34+ state . error ?? state . message ,
35+ DEFAULT_TRAINING_ERROR_BODY ,
36+ ) ;
37+ }
38+
39+ function shouldNotifyTrainingError (
40+ before : TrainingRuntimeStore ,
41+ after : TrainingRuntimeStore ,
42+ ) : boolean {
43+ if ( after . phase !== "error" ) {
44+ return false ;
45+ }
46+ if ( before . phase !== "error" || before . jobId !== after . jobId ) {
47+ return true ;
48+ }
49+ if ( before . error !== GENERIC_TRAINING_STREAM_ERROR ) {
50+ return false ;
51+ }
52+
53+ return getTrainingErrorBody ( before ) !== getTrainingErrorBody ( after ) ;
54+ }
55+
56+ function maybeNotifyTrainingTerminalTransition (
57+ before : TrainingRuntimeStore ,
58+ after : TrainingRuntimeStore ,
59+ ) : void {
60+ if ( ! after . jobId ) {
61+ return ;
62+ }
63+ if (
64+ before . stopRequested ||
65+ after . stopRequested ||
66+ after . phase === "stopped"
67+ ) {
68+ return ;
69+ }
70+
71+ const sameJob = before . jobId === after . jobId ;
72+ const samePhase = before . phase === after . phase ;
73+
74+ if ( after . phase === "completed" && ! ( sameJob && samePhase ) ) {
75+ const model = safeNotificationLabel (
76+ after . startModelName ?? useTrainingConfigStore . getState ( ) . selectedModel ,
77+ "Your training run" ,
78+ ) ;
79+ notifyNative ( {
80+ key : `training-completed:${ after . jobId } ` ,
81+ title : "Training finished" ,
82+ body : `${ model } is complete.` ,
83+ requestPermission : false ,
84+ } ) . catch ( ( ) => undefined ) ;
85+ }
86+
87+ if ( shouldNotifyTrainingError ( before , after ) ) {
88+ notifyNative ( {
89+ key : `training-error:${ after . jobId } ` ,
90+ title : "Training failed" ,
91+ body : getTrainingErrorBody ( after ) ,
92+ requestPermission : false ,
93+ } ) . catch ( ( ) => undefined ) ;
94+ }
95+ }
96+
2497export function useTrainingRuntimeLifecycle ( ) : void {
2598 useEffect ( ( ) => {
2699 let disposed = false ;
@@ -62,7 +135,9 @@ export function useTrainingRuntimeLifecycle(): void {
62135 }
63136 } ;
64137
65- const pollStatus = async ( ) => {
138+ const pollStatus = async ( options ?: {
139+ suppressNativeNotifications ?: boolean ;
140+ } ) => {
66141 if ( ! hasAuthToken ( ) ) return ;
67142 const gen = runtimeStore . getState ( ) . resetGeneration ;
68143 try {
@@ -71,9 +146,13 @@ export function useTrainingRuntimeLifecycle(): void {
71146 return ;
72147 }
73148
149+ const previousState = runtimeStore . getState ( ) ;
74150 runtimeStore . getState ( ) . applyStatus ( status ) ;
75151
76152 const nextState = runtimeStore . getState ( ) ;
153+ if ( ! options ?. suppressNativeNotifications ) {
154+ maybeNotifyTrainingTerminalTransition ( previousState , nextState ) ;
155+ }
77156 if ( shouldUseLiveSync ( nextState ) ) {
78157 void ensureStream ( ) ;
79158 } else {
@@ -124,7 +203,7 @@ export function useTrainingRuntimeLifecycle(): void {
124203 }
125204
126205 if ( event . event === "error" ) {
127- liveStore . setRuntimeError ( "Training stream error" ) ;
206+ liveStore . setRuntimeError ( GENERIC_TRAINING_STREAM_ERROR ) ;
128207 stopStream ( ) ;
129208 }
130209 } ,
@@ -154,7 +233,10 @@ export function useTrainingRuntimeLifecycle(): void {
154233 const hydrate = async ( ) => {
155234 runtimeStore . getState ( ) . setHydrating ( true ) ;
156235 try {
157- await Promise . all ( [ pollStatus ( ) , pollMetrics ( ) ] ) ;
236+ await Promise . all ( [
237+ pollStatus ( { suppressNativeNotifications : true } ) ,
238+ pollMetrics ( ) ,
239+ ] ) ;
158240 } finally {
159241 if ( ! disposed ) {
160242 runtimeStore . getState ( ) . setHydrating ( false ) ;
@@ -172,7 +254,12 @@ export function useTrainingRuntimeLifecycle(): void {
172254
173255 const statusTimer = setInterval ( ( ) => {
174256 const s = runtimeStore . getState ( ) ;
175- if ( isIdle ( ) && s . hasHydrated ) return ;
257+ if ( ! s . hasHydrated || s . isHydrating ) {
258+ return ;
259+ }
260+ if ( isIdle ( ) ) {
261+ return ;
262+ }
176263 void pollStatus ( ) ;
177264 } , STATUS_POLL_INTERVAL_MS ) ;
178265
@@ -187,7 +274,13 @@ export function useTrainingRuntimeLifecycle(): void {
187274 // Low-frequency background poll to recover from failed hydration or detect
188275 // out-of-band state changes (e.g. training started from another client).
189276 const idleTimer = setInterval ( ( ) => {
190- if ( ! isIdle ( ) ) return ;
277+ const s = runtimeStore . getState ( ) ;
278+ if ( ! s . hasHydrated || s . isHydrating ) {
279+ return ;
280+ }
281+ if ( ! isIdle ( ) ) {
282+ return ;
283+ }
191284 void pollStatus ( ) ;
192285 } , IDLE_POLL_INTERVAL_MS ) ;
193286
0 commit comments