@@ -56,6 +56,12 @@ type CachedModelsPage = {
5656 contextCapValue : number ;
5757} ;
5858
59+ /** Session JSON is untrusted — only seed rows that survive parseComboList (targets always arrays). */
60+ function readCachedCombos ( value : unknown ) : ComboItem [ ] | null {
61+ if ( ! Array . isArray ( value ) ) return null ;
62+ return parseComboList ( { combos : value } ) ;
63+ }
64+
5965export default function Models ( { apiBase } : { apiBase : string } ) {
6066 const t : TFn = useT ( ) ;
6167 const cacheKey = `ocx.models.catalog.v1:${ apiBase } ` ;
@@ -106,11 +112,11 @@ export default function Models({ apiBase }: { apiBase: string }) {
106112 // null + combosError so an API error never masquerades as "no combos configured".
107113 const combosCacheKey = `ocx.models.combos.v1:${ apiBase } ` ;
108114 const seededCombos = useMemo ( ( ) => {
109- const own = readSessionListCache < ComboItem [ ] > ( combosCacheKey ) ;
110- if ( own ) return own ;
115+ const own = readCachedCombos ( readSessionListCache < unknown > ( combosCacheKey ) ) ;
116+ if ( own !== null ) return own ;
111117 // Reuse the Combos workspace session snapshot when Models opens first in the session.
112- const workspace = readSessionListCache < { combos ?: ComboItem [ ] } > ( `ocx.combos.workspace.v1:${ apiBase } ` ) ;
113- return Array . isArray ( workspace ?. combos ) ? workspace . combos : null ;
118+ const workspace = readSessionListCache < { combos ?: unknown } > ( `ocx.combos.workspace.v1:${ apiBase } ` ) ;
119+ return readCachedCombos ( workspace ?. combos ) ;
114120 } , [ apiBase , combosCacheKey ] ) ;
115121 const combosResource = useDataSurface < ComboItem [ ] > (
116122 `models-combos:${ apiBase } ` ,
@@ -127,7 +133,8 @@ export default function Models({ apiBase }: { apiBase: string }) {
127133 const combosState = combosResource . state ;
128134 // Keep a previously painted card on a later failure so the catalog does not yank down.
129135 const combos = combosState . data ?? seededCombos ;
130- const combosError = combosState . showError && combos === null ;
136+ // Announce failures even when stale/seeded rows remain (layout kept; freshness not faked).
137+ const combosError = combosState . showError ;
131138 const [ combosOpen , setCombosOpen ] = useState ( readCombosOpen ) ;
132139
133140 // App owns the in-session view mode; fallback to persisted mode for isolated renders/tests.
@@ -1053,23 +1060,35 @@ export default function Models({ apiBase }: { apiBase: string }) {
10531060 < strong > { t ( "nav.combos" ) } </ strong >
10541061 < span className = "muted text-label" role = "alert" > { t ( "models.loadFail" ) } </ span >
10551062 </ div >
1056- < a className = "btn btn-sm" href = "#combos" style = { { flexShrink : 0 } } > { t ( "models.combosSetup" ) } </ a >
1063+ < button type = "button" className = "btn btn-sm" style = { { flexShrink : 0 } } onClick = { ( ) => combosResource . refresh ( ) } >
1064+ { t ( "common.retry" ) }
1065+ </ button >
10571066 </ div >
10581067 </ div >
10591068 ) }
1060- { combos !== null && ! combosError && combos . length === 0 && (
1069+ { combos !== null && combos . length === 0 && (
10611070 < div className = "card models-combos-card" >
10621071 < div className = "row models-combos-empty-head" >
10631072 < div className = "row models-field-row" style = { { minWidth : 0 } } >
10641073 < IconShuffle width = { 14 } height = { 14 } aria-hidden = "true" style = { { flexShrink : 0 } } />
10651074 < strong > { t ( "nav.combos" ) } </ strong >
1066- < span className = "muted text-label" > { t ( "models.combosEmpty" ) } </ span >
1075+ { combosError ? (
1076+ < span className = "muted text-label" role = "alert" > { t ( "models.loadFail" ) } </ span >
1077+ ) : (
1078+ < span className = "muted text-label" > { t ( "models.combosEmpty" ) } </ span >
1079+ ) }
10671080 </ div >
1068- < a className = "btn btn-sm" href = "#combos" style = { { flexShrink : 0 } } > { t ( "models.combosSetup" ) } </ a >
1081+ { combosError ? (
1082+ < button type = "button" className = "btn btn-sm" style = { { flexShrink : 0 } } onClick = { ( ) => combosResource . refresh ( ) } >
1083+ { t ( "common.retry" ) }
1084+ </ button >
1085+ ) : (
1086+ < a className = "btn btn-sm" href = "#combos" style = { { flexShrink : 0 } } > { t ( "models.combosSetup" ) } </ a >
1087+ ) }
10691088 </ div >
10701089 </ div >
10711090 ) }
1072- { combos !== null && ! combosError && combos . length > 0 && (
1091+ { combos !== null && combos . length > 0 && (
10731092 < div className = "card models-combos-card" >
10741093 < div className = { `row group-head models-field-row${ combosOpen ? " open" : "" } ` } >
10751094 < button
@@ -1083,8 +1102,17 @@ export default function Models({ apiBase }: { apiBase: string }) {
10831102 < IconShuffle width = { 14 } height = { 14 } aria-hidden = "true" style = { { flexShrink : 0 } } />
10841103 < strong > { t ( "nav.combos" ) } </ strong >
10851104 < span className = "muted mono text-label" > { t ( "models.combosActive" , { count : combos . length } ) } </ span >
1105+ { combosError && (
1106+ < span className = "muted text-label" role = "alert" > { t ( "models.loadFail" ) } </ span >
1107+ ) }
10861108 </ button >
1087- < a className = "btn btn-sm btn-ghost" href = "#combos" style = { { flexShrink : 0 } } > { t ( "models.combosSetup" ) } </ a >
1109+ { combosError ? (
1110+ < button type = "button" className = "btn btn-sm btn-ghost" style = { { flexShrink : 0 } } onClick = { ( ) => combosResource . refresh ( ) } >
1111+ { t ( "common.retry" ) }
1112+ </ button >
1113+ ) : (
1114+ < a className = "btn btn-sm btn-ghost" href = "#combos" style = { { flexShrink : 0 } } > { t ( "models.combosSetup" ) } </ a >
1115+ ) }
10881116 </ div >
10891117 { combosOpen && (
10901118 < div >
0 commit comments