diff --git a/docs/configurations.md b/docs/configurations.md index 71416247..f8fcfce0 100644 --- a/docs/configurations.md +++ b/docs/configurations.md @@ -106,6 +106,10 @@ auto_save_conversations = true history_retention_days = -1 # Set true after the one-shot auto-save chat notice is dismissed ("Acknowledge"). auto_save_notice_acknowledged = false +# Weights SHA-256 of models you chose to load past the mild "may not fit in +# memory" warning. Managed from the warning card and removable in Settings; +# freeze-risk loads still warn. Sanitized on load (64-hex only, deduped, capped). +dismissed_memory_fit_models = [] [debug] # Records every chat conversation, including its built-in web-search turns, to @@ -255,6 +259,16 @@ Controls rewrite/replace dismiss behavior and whether the built-in engine may op | `auto_save_conversations` | `true` | Yes | — | — | On (default): each completed turn is written to local history without a bookmark click. Off: only an explicit Save persists the chat. Toggle from Settings › Behavior. | | `history_retention_days` | `-1` | Yes | n/a | `-1` or `1`..`3650` | How many days saved chats are kept by last activity (`updated_at`) before a startup or confirmed retention-change prune deletes them. Raise to keep history longer; lower to reclaim disk sooner; `-1` to keep forever. `0` and other out-of-range values reset to the default. Changing to a finite window asks for confirmation before write + prune. | | `auto_save_notice_acknowledged` | `false` | Yes | — | — | When false, after the first auto-saved turn Thuki can show a one-shot chat-header notice that chats are being saved. Set true on Acknowledge; the notice never returns. Independent of `auto_save_conversations`. | +| `dismissed_memory_fit_models` | `[]` | Yes | — | up to 64 entries | Weights SHA-256 of models you chose to load past the mild "may not fit in memory" warning ("Don't warn me about this model again"). A listed model skips the warning only in the mild over-limit band; a freeze-risk load (estimate at or above free memory) still warns. Managed from the warning card and removable per row in Settings › Behavior. Sanitized on load: only 64-char lowercase-hex entries are kept, duplicates are dropped, and the list is capped FIFO at 64. | + +### Memory-fit gate + +Loading a model estimates its resident footprint and compares it to the memory free right now, refusing an un-forced load that would not fit. The two fractions below bound that decision; both are baked-in safety constants, not preferences. + +| Constant | Value | Tunable? | Why not tunable | Description | +| :------- | :---- | :------- | :-------------- | :---------- | +| `MODEL_FIT_HARD_BLOCK_FRACTION` | `3.00` | No | Defense-in-depth freeze bound | Freeze-band floor: at or above 3x free memory, the "may not fit" warning always fires and can never be suppressed by a per-model remember (`dismissed_memory_fit_models`), so the "Always allow this model" action is hidden and only the single force plus "Switch model" remain. A load needing at least triple the free memory is a gross over-commit that risks a non-pageable Metal wiring and a hard freeze. Below this floor a merely-over-the-ceiling load (up to 3x free RAM: the common "squeeze it out" case where the estimate is conservative and it usually still fits) can be remembered. A per-turn "Load once" still bypasses the floor; a persisted remember never does, because free RAM is dynamic. | +| `MAX_DISMISSED_MEMORY_FIT_MODELS` | `64` | No | Bound on a user-editable list | Cap on how many remembered memory-fit overrides are kept in config and memory. Adding beyond the cap evicts the oldest entry FIFO, so repeated opt-ins can never grow the list without bound. | ### Web search diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 9ac835af..a6d82dfd 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -306,6 +306,15 @@ pub fn builtin_target( /// rather than blocking a load on a database hiccup. Coverage-off: pure wiring /// over tested functions; the gate logic lives in `models::memory`. /// +/// `dismissed_shas` is the caller's snapshot of +/// `behavior.dismissed_memory_fit_models` (read from managed config at the +/// command boundary). The target's weights SHA-256 comes from the same manifest +/// row that sizes it, so no extra lookup is needed; when that sha is in the +/// list, the model is treated as `dismissed` and +/// [`memory::apply_dismissed_override`] downgrades a MILD over-limit block to +/// Proceed. A freeze-band block is never downgraded, so the remember can never +/// defeat the guardrail on the dangerous case. +/// /// [`MemoryGate::Proceed`]: crate::models::memory::MemoryGate::Proceed #[cfg_attr(coverage_nightly, coverage(off))] pub(crate) fn preflight_memory_gate( @@ -315,6 +324,7 @@ pub(crate) fn preflight_memory_gate( model_id: &str, target_path: &std::path::Path, forced: bool, + dismissed_shas: &[String], ) -> crate::models::memory::MemoryGate { use crate::models::memory; // Read the live engine status once: it feeds both the already-loading bypass @@ -322,11 +332,15 @@ pub(crate) fn preflight_memory_gate( let status = engine.current_status(); // Cannot size the target -> do not block on a database hiccup. Fold mmproj // blob length (or registry hint) when present so vision loads estimate true - // footprint. - let target_weights = match crate::models::manifest::get(conn, model_id) { + // footprint. The same row carries the weights SHA-256 that keys the + // remembered-override list, so no extra lookup is needed. + let (target_weights, dismissed) = match crate::models::manifest::get(conn, model_id) { Ok(Some(row)) => { let mm = crate::models::memory::mmproj_bytes_for_model(store, &row); - memory::model_load_bytes(&row, mm) + ( + memory::model_load_bytes(&row, mm), + memory::is_model_remembered(&row.sha256, dismissed_shas), + ) } _ => return memory::MemoryGate::Proceed, }; @@ -350,7 +364,7 @@ pub(crate) fn preflight_memory_gate( // Single source of the block decision, shared with `estimate_model_fit`'s // `build_model_fit_estimate` so the gate and the frontend fit affordance can // never diverge (issue #296). It folds in the already-loading bypass. - memory::decide_load_gate( + let gate = memory::decide_load_gate( &status.state, &status.model_path, target_weights, @@ -359,7 +373,10 @@ pub(crate) fn preflight_memory_gate( target_path, &installed, forced, - ) + ); + // Apply the per-model remember: a mild over-limit block for a dismissed + // model becomes Proceed, while a freeze-band block always stands. + memory::apply_dismissed_override(gate, dismissed) } /// Parses llama-server's `GET /props` response and reports whether the @@ -1669,6 +1686,11 @@ pub enum TransportError { /// swallow. When the model fits, or the exact model is already resident, the /// load proceeds exactly as before. Non-builtin routes ignore `policy`. /// +/// `dismissed_shas` is the caller's snapshot of the remembered memory-fit +/// overrides (`behavior.dismissed_memory_fit_models`), threaded to +/// [`preflight_memory_gate`] on the builtin arm so a mild over-limit load of a +/// remembered model proceeds silently while a freeze-band load still refuses. +/// /// [`Target`]: crate::engine::state::Target #[allow(clippy::too_many_arguments)] pub(crate) async fn resolve_llm_transport( @@ -1680,6 +1702,7 @@ pub(crate) async fn resolve_llm_transport( num_ctx: u32, cancel_token: &CancellationToken, policy: OversizePolicy, + dismissed_shas: &[String], ) -> Result { match route { ChatRoute::OllamaNative { endpoint } => Ok(LlmTransport::OllamaNative { endpoint }), @@ -1713,6 +1736,7 @@ pub(crate) async fn resolve_llm_transport( &model_id, &target.model_path, forced, + dismissed_shas, ); (target, gate) }; @@ -2538,6 +2562,7 @@ pub async fn ask_model( &model_id, &target.model_path, allow_oversized == Some(true), + &config.behavior.dismissed_memory_fit_models, ); (target, gate) }, @@ -6294,6 +6319,7 @@ mod tests { // Non-builtin route: the memory gate never runs, so the policy is // irrelevant here. OversizePolicy::Block { forced: false }, + &[], ) .await .unwrap(); @@ -6331,6 +6357,7 @@ mod tests { &CancellationToken::new(), // Non-builtin route: the memory gate never runs. OversizePolicy::Block { forced: false }, + &[], ) .await .unwrap(); @@ -6376,6 +6403,7 @@ mod tests { // Force past the memory gate: this test exercises the ensure path, // orthogonal to the gate (covered separately below). OversizePolicy::Block { forced: true }, + &[], ) .await .unwrap(); @@ -6415,6 +6443,7 @@ mod tests { // `builtin_target` errors on the missing row before the gate runs, // so the policy is irrelevant. OversizePolicy::Block { forced: false }, + &[], ) .await .unwrap_err(); @@ -6463,6 +6492,7 @@ mod tests { &CancellationToken::new(), // Force past the gate: this test exercises poisoned-lock recovery. OversizePolicy::Block { forced: true }, + &[], ) .await .unwrap(); @@ -6509,6 +6539,7 @@ mod tests { // Force past the gate: this asserts the spawn StartFailed mapping, // which a gate Block would preempt on a low-memory runner. OversizePolicy::Block { forced: true }, + &[], ) .await .unwrap_err(); @@ -6553,6 +6584,7 @@ mod tests { // Force past the gate: this asserts the unload-preempt // Superseded mapping, orthogonal to the memory gate. OversizePolicy::Block { forced: true }, + &[], ) .await }) @@ -6608,6 +6640,7 @@ mod tests { // Force past the gate: this asserts the cancel-during-ensure // mapping, orthogonal to the memory gate. OversizePolicy::Block { forced: true }, + &[], ) .await }) @@ -6665,6 +6698,7 @@ mod tests { DEFAULT_NUM_CTX, &CancellationToken::new(), OversizePolicy::Block { forced: false }, + &[], ) .await .unwrap_err(); @@ -6704,6 +6738,7 @@ mod tests { DEFAULT_NUM_CTX, &CancellationToken::new(), OversizePolicy::Block { forced: true }, + &[], ) .await .unwrap(); @@ -6747,6 +6782,7 @@ mod tests { DEFAULT_NUM_CTX, &CancellationToken::new(), OversizePolicy::SilentSkip, + &[], ) .await .unwrap_err(); @@ -6788,6 +6824,7 @@ mod tests { DEFAULT_NUM_CTX, &CancellationToken::new(), OversizePolicy::Block { forced: true }, + &[], ) .await .unwrap(); @@ -6807,6 +6844,7 @@ mod tests { DEFAULT_NUM_CTX, &CancellationToken::new(), OversizePolicy::Block { forced: false }, + &[], ) .await .unwrap(); @@ -6821,6 +6859,7 @@ mod tests { DEFAULT_NUM_CTX, &CancellationToken::new(), OversizePolicy::SilentSkip, + &[], ) .await .unwrap(); diff --git a/src-tauri/src/config/defaults.rs b/src-tauri/src/config/defaults.rs index 80b9e8b8..1b375aa4 100644 --- a/src-tauri/src/config/defaults.rs +++ b/src-tauri/src/config/defaults.rs @@ -520,6 +520,32 @@ pub const MODEL_FIT_COMFORT_FRACTION: f64 = 0.60; /// only clearly above the ceiling and a user `force` always bypasses it. pub const MODEL_FIT_CEILING_FRACTION: f64 = 0.80; +/// Freeze-band floor: the fit ratio (estimated footprint over live available +/// memory) at or above which the memory warning ALWAYS fires and can never be +/// suppressed by a per-model "remember" override. At `3.00` the estimate needs +/// at least triple the memory the machine can hand out right now, a gross +/// over-commit that would force macOS to wire non-pageable Metal memory it does +/// not have, risking a hard freeze and a reboot. Below this floor a load merely +/// over the mild ceiling (needing up to 3x free RAM: the common "squeeze it +/// out" case where the estimate is conservative and it usually still fits) is +/// offered the per-model "Always allow this model" override, so the user is not +/// re-warned. A per-turn `force` ("load anyway") still bypasses this floor, but +/// a persisted remember never does: free RAM is dynamic, so a model that fit +/// when RAM was free must re-warn if RAM later drops it into this band. +/// +/// Not user-tunable: a defense-in-depth freeze bound on the most dangerous +/// load, not a preference. +pub const MODEL_FIT_HARD_BLOCK_FRACTION: f64 = 3.00; + +/// Cap on the number of remembered memory-fit overrides +/// (`behavior.dismissed_memory_fit_models`) kept in config and memory. Beyond +/// this the oldest entry is evicted FIFO when a new one is added, so the list +/// can never grow without bound from repeated opt-ins. +/// +/// Not user-tunable: a defense-in-depth bound on a user-editable list, not a +/// preference. +pub const MAX_DISMISSED_MEMORY_FIT_MODELS: usize = 64; + /// Maximum accepted byte length for a Hugging Face search query before it is /// sent upstream. Defense-in-depth bound on attacker-influenced input: the /// query reaches the fixed Hub host (no SSRF) and is percent-encoded by the diff --git a/src-tauri/src/config/loader.rs b/src-tauri/src/config/loader.rs index 4e50179d..bf8d77a9 100644 --- a/src-tauri/src/config/loader.rs +++ b/src-tauri/src/config/loader.rs @@ -18,6 +18,7 @@ //! fatal (the app cannot boot in a writable-hostile environment and the user //! cannot fix that from the UI). +use std::collections::{HashSet, VecDeque}; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -33,8 +34,8 @@ use super::defaults::{ DEFAULT_QUOTE_MAX_DISPLAY_LINES, DEFAULT_SYSTEM_PROMPT_BASE, DEFAULT_TEXT_BASE_PX, DEFAULT_TEXT_FONT_WEIGHT, DEFAULT_TEXT_LETTER_SPACING_PX, DEFAULT_TEXT_LINE_HEIGHT, DEFAULT_TRACE_RETENTION_DAYS, DEFAULT_UPDATER_CHECK_INTERVAL_HOURS, - DEFAULT_UPDATER_MANIFEST_URL, HISTORY_RETENTION_FOREVER, SLASH_COMMAND_PROMPT_APPENDIX, - TRACE_RETENTION_FOREVER, + DEFAULT_UPDATER_MANIFEST_URL, HISTORY_RETENTION_FOREVER, MAX_DISMISSED_MEMORY_FIT_MODELS, + SLASH_COMMAND_PROMPT_APPENDIX, TRACE_RETENTION_FOREVER, }; use super::error::ConfigError; use super::schema::AppConfig; @@ -222,6 +223,12 @@ pub(crate) fn resolve(config: &mut AppConfig) { BOUNDS_HISTORY_RETENTION_DAYS, "behavior.history_retention_days", ); + // The remembered memory-fit override list is user-editable TOML: drop any + // entry that is not a well-formed weights SHA-256, dedupe, and cap the + // length so a hand-edited file can never inject garbage or grow unbounded. + config.behavior.dismissed_memory_fit_models = sanitize_dismissed_memory_fit_models( + std::mem::take(&mut config.behavior.dismissed_memory_fit_models), + ); // Debug section: trace_enabled is a boolean (any value valid); the // retention window is clamped to its sentinel-or-range contract. @@ -487,6 +494,72 @@ fn clamp_font_weight(value: &mut u32, default: u32, field: &str) { } } +/// True when `sha` is a well-formed weights SHA-256 blob id: exactly 64 +/// lowercase hexadecimal characters. The content-addressed blob store keys its +/// files on this digest, so only strings that could name a real blob are +/// accepted; anything else (wrong length, uppercase, non-hex) is rejected. Used +/// both to sanitize the loaded list and to normalize an id before it is added. +pub(crate) fn is_valid_weights_sha(sha: &str) -> bool { + sha.len() == 64 + && sha + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +/// Sanitizes a user-supplied `dismissed_memory_fit_models` list: keeps only +/// entries that are well-formed weights SHA-256 ids (see [`is_valid_weights_sha`]), +/// removes duplicates preserving the first occurrence, then caps the length to +/// [`MAX_DISMISSED_MEMORY_FIT_MODELS`] by dropping the oldest (front) entries so +/// the most recently added survive. Never panics: any malformed input simply +/// yields a shorter (possibly empty) list. +/// +/// Dedupe goes through a hash set rather than a linear scan of what has been +/// kept: this list is read straight off a hand-editable TOML file, and a linear +/// membership test would make a pathological file cost O(n^2) string +/// comparisons on the startup path. The cap is enforced inside the loop so the +/// retained queue never grows past it, whatever the file holds. +pub(crate) fn sanitize_dismissed_memory_fit_models(raw: Vec) -> Vec { + let mut seen: HashSet = HashSet::new(); + let mut kept: VecDeque = VecDeque::with_capacity(MAX_DISMISSED_MEMORY_FIT_MODELS); + for entry in raw { + // `insert` returning false means this digest was already accepted + // earlier, so the first occurrence is the one that counts. + if !is_valid_weights_sha(&entry) || !seen.insert(entry.clone()) { + continue; + } + kept.push_back(entry); + // Cap keeping the most recent: drop from the front (oldest) so a list + // that grew past the cap evicts FIFO. `seen` deliberately keeps the + // evicted digest, so a later duplicate of it does not resurrect the + // entry and the result matches a dedupe-then-truncate pass exactly. + if kept.len() > MAX_DISMISSED_MEMORY_FIT_MODELS { + kept.pop_front(); + } + } + kept.into() +} + +/// Returns `existing` with `sha` appended as a remembered memory-fit override, +/// then re-sanitized (see [`sanitize_dismissed_memory_fit_models`]) so the add +/// is idempotent (a sha already present is not duplicated) and the list stays +/// capped FIFO. The incoming `sha` is lowercased and trimmed first so a caller +/// passing an upper-case or padded digest still lands a valid entry; a `sha` +/// that is not a valid weights digest is dropped by the sanitize pass and +/// leaves the list unchanged. Pure and unit-tested. +pub(crate) fn with_dismissed_model_added(mut existing: Vec, sha: &str) -> Vec { + existing.push(sha.trim().to_ascii_lowercase()); + sanitize_dismissed_memory_fit_models(existing) +} + +/// Returns `existing` with every entry equal to `sha` removed (keyed by the +/// digest so an orphaned entry whose model is no longer installed is still +/// removable). The incoming `sha` is lowercased and trimmed to match the stored +/// form. Pure and unit-tested. +pub(crate) fn with_dismissed_model_removed(existing: Vec, sha: &str) -> Vec { + let target = sha.trim().to_ascii_lowercase(); + existing.into_iter().filter(|s| *s != target).collect() +} + fn clamp_u32(value: &mut u32, bounds: (u32, u32), default: u32, field: &str) { if !(bounds.0..=bounds.1).contains(value) { eprintln!( diff --git a/src-tauri/src/config/schema.rs b/src-tauri/src/config/schema.rs index 6f12e466..2ffde7c5 100644 --- a/src-tauri/src/config/schema.rs +++ b/src-tauri/src/config/schema.rs @@ -317,6 +317,16 @@ pub struct BehaviorSection { /// When `true`, the one-shot auto-save chat notice has been dismissed and /// should not show again. Default `false` until the user acknowledges it. pub auto_save_notice_acknowledged: bool, + /// Weights SHA-256 (content-addressed blob id) of models the user chose to + /// load over the mild memory-fit limit without being re-warned. A model + /// listed here suppresses the "may not fit" card only in the mild + /// over-limit band; a freeze-band load (estimate at or above available + /// memory) still warns regardless. Managed via the warning card's opt-in + /// and removable per row in Settings. Not a flat `set_config_field` key: + /// the loader sanitizes it and the dedicated add/remove commands own the + /// writes, so it is intentionally absent from `ALLOWED_FIELDS`. + #[serde(default)] + pub dismissed_memory_fit_models: Vec, } impl Default for BehaviorSection { @@ -329,6 +339,7 @@ impl Default for BehaviorSection { auto_save_conversations: DEFAULT_AUTO_SAVE_CONVERSATIONS, history_retention_days: DEFAULT_HISTORY_RETENTION_DAYS, auto_save_notice_acknowledged: DEFAULT_AUTO_SAVE_NOTICE_ACKNOWLEDGED, + dismissed_memory_fit_models: Vec::new(), } } } diff --git a/src-tauri/src/config/tests.rs b/src-tauri/src/config/tests.rs index fcfb3b29..2d8314df 100644 --- a/src-tauri/src/config/tests.rs +++ b/src-tauri/src/config/tests.rs @@ -12,6 +12,7 @@ use std::path::PathBuf; +use super::defaults::MAX_DISMISSED_MEMORY_FIT_MODELS; use super::defaults::{ DEFAULT_ACTIVE_PROVIDER, DEFAULT_AUTO_CLOSE, DEFAULT_AUTO_REPLACE, DEFAULT_AUTO_SAVE_CONVERSATIONS, DEFAULT_AUTO_SAVE_NOTICE_ACKNOWLEDGED, DEFAULT_AUTO_SEARCH, @@ -27,7 +28,10 @@ use super::defaults::{ SLASH_COMMAND_PROMPT_APPENDIX, }; use super::error::ConfigError; -use super::loader::{compose_system_prompt, load_from_path, resolve}; +use super::loader::{ + compose_system_prompt, is_valid_weights_sha, load_from_path, resolve, + sanitize_dismissed_memory_fit_models, with_dismissed_model_added, with_dismissed_model_removed, +}; use super::migrate::{attach_legacy_active_model, toml_has_providers}; use super::schema::{ ollama_provider, openai_provider, AppConfig, BehaviorSection, DebugSection, InferenceSection, @@ -1152,6 +1156,10 @@ fn behavior_section_default_matches_compiled_defaults() { b.auto_save_notice_acknowledged, DEFAULT_AUTO_SAVE_NOTICE_ACKNOWLEDGED ); + assert!( + b.dismissed_memory_fit_models.is_empty(), + "dismissed memory-fit list defaults to empty" + ); } #[test] @@ -1176,6 +1184,7 @@ fn app_config_default_includes_behavior_section_with_compiled_defaults() { c.behavior.auto_save_notice_acknowledged, DEFAULT_AUTO_SAVE_NOTICE_ACKNOWLEDGED ); + assert!(c.behavior.dismissed_memory_fit_models.is_empty()); } #[test] @@ -1214,6 +1223,145 @@ fn behavior_search_notice_acknowledged_round_trips_through_load() { assert!(loaded.behavior.search_notice_acknowledged); } +/// A valid 64-char lowercase-hex weights SHA built from a single hex nibble, +/// so distinct test entries stay easy to read and compare. +fn sha(nibble: char) -> String { + std::iter::repeat(nibble).take(64).collect() +} + +#[test] +fn is_valid_weights_sha_accepts_only_64_lowercase_hex() { + assert!(is_valid_weights_sha(&sha('a'))); + assert!(is_valid_weights_sha(&sha('0'))); + assert!(is_valid_weights_sha(&sha('f'))); + // Too short / too long. + assert!(!is_valid_weights_sha(&"a".repeat(63))); + assert!(!is_valid_weights_sha(&"a".repeat(65))); + // Uppercase hex is rejected (blob ids are lowercase). + assert!(!is_valid_weights_sha(&"A".repeat(64))); + // Non-hex character. + assert!(!is_valid_weights_sha(&"g".repeat(64))); + // Empty. + assert!(!is_valid_weights_sha("")); +} + +#[test] +fn sanitize_dismissed_keeps_valid_drops_invalid_and_dedupes() { + let raw = vec![ + sha('a'), + "not-a-sha".to_string(), + sha('a'), // duplicate of the first + "A".repeat(64), // uppercase, dropped + sha('b'), + ]; + let cleaned = sanitize_dismissed_memory_fit_models(raw); + // First occurrence of `a` kept, duplicate collapsed, invalids dropped, + // order preserved. + assert_eq!(cleaned, vec![sha('a'), sha('b')]); +} + +#[test] +fn sanitize_dismissed_empty_is_ok() { + assert!(sanitize_dismissed_memory_fit_models(Vec::new()).is_empty()); +} + +#[test] +fn sanitize_dismissed_caps_length_keeping_most_recent() { + // Build MAX + 3 distinct valid shas; the three oldest (front) must drop. + let over = MAX_DISMISSED_MEMORY_FIT_MODELS + 3; + let raw: Vec = (0..over).map(|i| format!("{i:0>64x}")).collect(); + let cleaned = sanitize_dismissed_memory_fit_models(raw.clone()); + assert_eq!(cleaned.len(), MAX_DISMISSED_MEMORY_FIT_MODELS); + // The most recent MAX entries survive; the first three are evicted. + assert_eq!(cleaned, raw[3..].to_vec()); +} + +#[test] +fn with_dismissed_added_is_idempotent_and_normalizes_case() { + let base = vec![sha('a')]; + // Adding an existing sha does not duplicate it. + assert_eq!(with_dismissed_model_added(base.clone(), &sha('a')), base); + // A new sha appends at the end. + assert_eq!( + with_dismissed_model_added(base.clone(), &sha('b')), + vec![sha('a'), sha('b')] + ); + // An upper-case / padded digest is normalized before it lands. + assert_eq!( + with_dismissed_model_added(Vec::new(), &format!(" {} ", "A".repeat(64))), + vec![sha('a')] + ); + // An invalid digest is dropped by the sanitize pass, leaving the list as-is. + assert_eq!(with_dismissed_model_added(base.clone(), "not-a-sha"), base); +} + +#[test] +fn with_dismissed_added_evicts_oldest_at_cap() { + let full: Vec = (0..MAX_DISMISSED_MEMORY_FIT_MODELS) + .map(|i| format!("{i:0>64x}")) + .collect(); + let after = with_dismissed_model_added(full.clone(), &sha('f')); + assert_eq!(after.len(), MAX_DISMISSED_MEMORY_FIT_MODELS); + // Oldest (front) evicted; the new sha is now last. + assert_eq!(after.first(), full.get(1)); + assert_eq!(after.last(), Some(&sha('f'))); +} + +#[test] +fn with_dismissed_removed_deletes_by_sha() { + let base = vec![sha('a'), sha('b')]; + assert_eq!( + with_dismissed_model_removed(base.clone(), &sha('a')), + vec![sha('b')] + ); + // Case-insensitive / trimmed match on the removal key. + assert_eq!( + with_dismissed_model_removed(base.clone(), &format!(" {} ", "B".repeat(64))), + vec![sha('a')] + ); + // Removing an absent sha is a no-op. + assert_eq!(with_dismissed_model_removed(base.clone(), &sha('c')), base); +} + +#[test] +fn behavior_dismissed_memory_fit_models_round_trips_through_load() { + let dir = fresh_temp_dir(); + let path = config_path_in(&dir); + std::fs::write( + &path, + format!( + "[behavior]\ndismissed_memory_fit_models = [\"{}\", \"{}\"]\n", + sha('a'), + sha('b') + ), + ) + .unwrap(); + let loaded = load_from_path(&path).unwrap(); + assert_eq!( + loaded.behavior.dismissed_memory_fit_models, + vec![sha('a'), sha('b')] + ); +} + +#[test] +fn behavior_dismissed_memory_fit_models_sanitized_on_load() { + let dir = fresh_temp_dir(); + let path = config_path_in(&dir); + // A hand-edited file with a valid sha, an invalid entry, and a duplicate: + // the loader must drop the junk, dedupe, and never panic. + std::fs::write( + &path, + format!( + "[behavior]\ndismissed_memory_fit_models = [\"{}\", \"garbage\", \"{}\"]\n", + sha('a'), + sha('a') + ), + ) + .unwrap(); + let loaded = load_from_path(&path).unwrap(); + assert_eq!(loaded.behavior.dismissed_memory_fit_models, vec![sha('a')]); +} + #[test] fn toml_without_behavior_section_deserializes_to_defaults() { let dir = fresh_temp_dir(); diff --git a/src-tauri/src/history.rs b/src-tauri/src/history.rs index 4fcebe17..6995865d 100644 --- a/src-tauri/src/history.rs +++ b/src-tauri/src/history.rs @@ -631,6 +631,7 @@ pub async fn generate_title( app_config.inference.num_ctx, &tokio_util::sync::CancellationToken::new(), crate::commands::OversizePolicy::SilentSkip, + &app_config.behavior.dismissed_memory_fit_models, ) .await else { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b46c7dd9..e8940747 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3032,6 +3032,8 @@ pub fn run() { settings_commands::update_provider_field, settings_commands::add_openai_provider, settings_commands::remove_openai_provider, + settings_commands::remember_model_memory_fit, + settings_commands::forget_model_memory_fit, settings_commands::reset_config, settings_commands::reload_config_from_disk, settings_commands::get_corrupt_marker, diff --git a/src-tauri/src/models/memory.rs b/src-tauri/src/models/memory.rs index 00e47c5f..91bb1c5e 100644 --- a/src-tauri/src/models/memory.rs +++ b/src-tauri/src/models/memory.rs @@ -36,7 +36,8 @@ use serde::Serialize; use super::manifest::InstalledModel; use crate::config::defaults::{ - MODEL_FIT_CEILING_FRACTION, MODEL_FIT_COMFORT_FRACTION, RUNTIME_OVERHEAD_GB, + MODEL_FIT_CEILING_FRACTION, MODEL_FIT_COMFORT_FRACTION, MODEL_FIT_HARD_BLOCK_FRACTION, + RUNTIME_OVERHEAD_GB, }; /// Bytes in one gibibyte (2^30), the unit `RUNTIME_OVERHEAD_GB` is expressed in. @@ -134,6 +135,54 @@ pub fn assess_fit(required_bytes: u64, available_bytes: u64) -> MemoryFit { } } +/// True when `required_bytes` lands in the freeze band: at or above +/// [`MODEL_FIT_HARD_BLOCK_FRACTION`] times `available_bytes` (the estimate needs +/// at least that multiple of the memory free right now). A load in this band can +/// wire non-pageable Metal memory the machine does not have and hard-freeze +/// macOS, so the memory warning here can never be suppressed by a per-model +/// remember (only a per-turn `force` still bypasses it, upstream). The threshold +/// lives entirely in the constant so this predicate can never drift from its +/// value. +/// +/// `available_bytes == 0` is a failed/unknown reader, not a real over-commit, +/// so it is never treated as the freeze band; the gate fails open on a 0 read +/// elsewhere and this keeps that decision coherent. +pub fn is_freeze_band(required_bytes: u64, available_bytes: u64) -> bool { + if available_bytes == 0 { + return false; + } + required_bytes as f64 >= MODEL_FIT_HARD_BLOCK_FRACTION * available_bytes as f64 +} + +/// True when `sha` (a model's weights SHA-256) is present in `dismissed_shas`, +/// the caller's snapshot of `behavior.dismissed_memory_fit_models`. The single +/// membership predicate the admission gate consults to decide whether a mild +/// over-limit load was already remembered by the user. +pub fn is_model_remembered(sha: &str, dismissed_shas: &[String]) -> bool { + dismissed_shas.iter().any(|entry| entry == sha) +} + +/// Applies a per-model "remember this model" override to a gate outcome. +/// +/// The remember only rescues the MILD over-limit band: a [`MemoryGate::Block`] +/// whose figures are below the freeze band (see [`is_freeze_band`]) is +/// downgraded to [`MemoryGate::Proceed`] when the model is `dismissed`. A block +/// in the freeze band is returned unchanged even when `dismissed`, because free +/// RAM is dynamic and a gross over-commit must always re-warn (the reliability +/// floor). [`MemoryGate::Proceed`] passes through untouched: a forced load and +/// a comfortable/tight fit both already resolved to Proceed upstream, so there +/// is nothing to override. Pure and unit-tested; this is the single place the +/// dismissed flag changes a load decision. +pub fn apply_dismissed_override(gate: MemoryGate, dismissed: bool) -> MemoryGate { + match gate { + MemoryGate::Block { + required_bytes, + available_bytes, + } if dismissed && !is_freeze_band(required_bytes, available_bytes) => MemoryGate::Proceed, + other => other, + } +} + /// Total on-disk primary weights bytes for an installed model: `size_bytes` for /// a single-file model, or the sum of every shard for a split model (whose /// `size_bytes` records only the first shard). Does not include the mmproj; @@ -454,6 +503,13 @@ pub struct ModelFitEstimate { /// would still admit (a resident-exact or already-loading model), so a /// control decision must never be derived from `verdict`. pub would_block: bool, + /// Whether a per-model "remember" could suppress this warning: true unless + /// the load is in the freeze band ([`is_freeze_band`]). The single source of + /// truth for the frontend's "Always allow this model" action, so the + /// freeze-floor fraction is never duplicated as a magic number on the + /// frontend. A freeze-band load is never remember-able, so that action is + /// hidden for it. + pub can_remember: bool, } /// Pure core of [`estimate_model_fit`]: assembles the full [`ModelFitEstimate`] @@ -512,6 +568,10 @@ pub fn build_model_fit_estimate( available_bytes: effective_available, verdict: assess_fit(required_bytes, effective_available), would_block, + // Computed against the SAME credited available the gate blocks on, so the + // remember action is hidden for exactly the loads + // `apply_dismissed_override` refuses to suppress. + can_remember: !is_freeze_band(required_bytes, effective_available), } } @@ -1091,6 +1151,8 @@ mod tests { assert_eq!(estimate.available_bytes, 24 * BYTES_PER_GIB); assert_eq!(estimate.verdict, MemoryFit::Comfortable); assert!(!estimate.would_block); + // Well under the freeze floor, so a remember could suppress it. + assert!(estimate.can_remember); } #[test] @@ -1195,6 +1257,25 @@ mod tests { ); assert_eq!(estimate.verdict, MemoryFit::Insufficient); assert!(estimate.would_block); + // 22 GiB needed / 10 GiB free = ratio 2.20: over the ceiling (so it + // blocks) but under the 3x freeze floor, so it stays remember-able. + // `would_block` and `can_remember` are independent by design. + assert!(estimate.can_remember); + + // Push the same load into the freeze band (42 GiB needed / 10 GiB free + // = ratio 4.20): still blocked, and no longer remember-able, so the + // frontend hides the "Always allow this model" action. + let freeze = build_model_fit_estimate( + "loaded", + "/blobs/other", + 40 * BYTES_PER_GIB, + 10 * BYTES_PER_GIB, + None, + &target, + &[], + ); + assert!(freeze.would_block); + assert!(!freeze.can_remember); } #[test] @@ -1285,4 +1366,119 @@ mod tests { ); assert!(estimate.would_block); } + + #[test] + fn is_freeze_band_triggers_at_or_above_triple_available() { + // Ratio exactly 3.00 (required == 3x available) is the freeze floor + // (>=, so the boundary itself is freeze). + assert!(is_freeze_band(30 * BYTES_PER_GIB, 10 * BYTES_PER_GIB)); + // Above 3.00. + assert!(is_freeze_band(40 * BYTES_PER_GIB, 10 * BYTES_PER_GIB)); + // Just under the floor (ratio 2.90) is still rememberable. + assert!(!is_freeze_band(29 * BYTES_PER_GIB, 10 * BYTES_PER_GIB)); + // Mid-band (ratio 2.00) and the user's "squeeze it out" case (1.10): + // over the mild ceiling but below the freeze floor, so NOT freeze. + assert!(!is_freeze_band(20 * BYTES_PER_GIB, 10 * BYTES_PER_GIB)); + assert!(!is_freeze_band(11 * BYTES_PER_GIB, 10 * BYTES_PER_GIB)); + // A failed/unknown reader (0 available) is never treated as freeze. + assert!(!is_freeze_band(30 * BYTES_PER_GIB, 0)); + } + + #[test] + fn is_model_remembered_matches_list_membership() { + let sha_a = "a".repeat(64); + let sha_b = "b".repeat(64); + let list = vec![sha_a.clone()]; + assert!(is_model_remembered(&sha_a, &list)); + assert!(!is_model_remembered(&sha_b, &list)); + // Empty list: nothing is remembered. + assert!(!is_model_remembered(&sha_a, &[])); + } + + #[test] + fn apply_dismissed_override_proceeds_pass_through() { + // Proceed (forced load, or a comfortable/tight fit) is never touched, + // regardless of the dismissed flag. + assert_eq!( + apply_dismissed_override(MemoryGate::Proceed, true), + MemoryGate::Proceed + ); + assert_eq!( + apply_dismissed_override(MemoryGate::Proceed, false), + MemoryGate::Proceed + ); + } + + #[test] + fn apply_dismissed_override_rescues_mild_band_only_when_dismissed() { + // The user's "squeeze it out" case: over the mild ceiling at ratio 1.10 + // (11 needed / 10 free), below the 3x freeze floor. Dismissed + // downgrades to Proceed; not-dismissed still blocks. + let mild = MemoryGate::Block { + required_bytes: 11 * BYTES_PER_GIB, + available_bytes: 10 * BYTES_PER_GIB, + }; + assert_eq!(apply_dismissed_override(mild, true), MemoryGate::Proceed); + assert_eq!(apply_dismissed_override(mild, false), mild); + + // The newly-widened middle: ratio 2.00 is still under the 3x floor, so + // it is rememberable too. + let mid = MemoryGate::Block { + required_bytes: 20 * BYTES_PER_GIB, + available_bytes: 10 * BYTES_PER_GIB, + }; + assert_eq!(apply_dismissed_override(mid, true), MemoryGate::Proceed); + assert_eq!(apply_dismissed_override(mid, false), mid); + } + + #[test] + fn apply_dismissed_override_never_rescues_freeze_band() { + // RELIABILITY FLOOR (issue: memory-fit override): a freeze-band block + // (ratio >= 3x free RAM, here 30 needed / 10 free) must stand even + // when the model is dismissed. The remember can never defeat the + // guardrail on the dangerous case. + let freeze = MemoryGate::Block { + required_bytes: 30 * BYTES_PER_GIB, + available_bytes: 10 * BYTES_PER_GIB, + }; + assert_eq!(apply_dismissed_override(freeze, true), freeze); + assert_eq!(apply_dismissed_override(freeze, false), freeze); + } + + #[test] + fn dismissed_override_end_to_end_bands() { + let target = PathBuf::from("/blobs/target"); + let available = 10 * BYTES_PER_GIB; + // Comfortable: well under the ceiling -> Proceed regardless of dismissed. + let comfy = evaluate_load_gate(BYTES_PER_GIB, available, None, &target, &[], false); + assert_eq!(apply_dismissed_override(comfy, false), MemoryGate::Proceed); + + // Mild band, the "squeeze it out" case: weights sized so required lands + // just OVER available but far below the 3x freeze floor. + // `estimate_required_bytes` adds RUNTIME_OVERHEAD_GB (2 GiB), so 9 GiB + // weights -> 11 GiB required / 10 GiB available = ratio 1.10. + let mild_weights = 9 * BYTES_PER_GIB; + let mild = evaluate_load_gate(mild_weights, available, None, &target, &[], false); + // Unforced + not dismissed blocks; dismissed rescues it. + assert!(matches!(mild, MemoryGate::Block { .. })); + assert_eq!(apply_dismissed_override(mild, false), mild); + assert_eq!(apply_dismissed_override(mild, true), MemoryGate::Proceed); + + // Middle of the widened rememberable band: 18 GiB weights -> 20 GiB + // required / 10 GiB available = ratio 2.00, still under the floor. + let mid = evaluate_load_gate(18 * BYTES_PER_GIB, available, None, &target, &[], false); + assert!(matches!(mid, MemoryGate::Block { .. })); + assert_eq!(apply_dismissed_override(mid, true), MemoryGate::Proceed); + + // Freeze band: 40 GiB weights -> 42 GiB required / 10 GiB available = + // ratio 4.20, at or above the 3x floor. + let freeze = evaluate_load_gate(40 * BYTES_PER_GIB, available, None, &target, &[], false); + assert!(matches!(freeze, MemoryGate::Block { .. })); + // Dismissed still blocks in the freeze band. + assert_eq!(apply_dismissed_override(freeze, true), freeze); + // Forced proceeds upstream, and the override passes Proceed through. + let forced = evaluate_load_gate(40 * BYTES_PER_GIB, available, None, &target, &[], true); + assert_eq!(forced, MemoryGate::Proceed); + assert_eq!(apply_dismissed_override(forced, true), MemoryGate::Proceed); + } } diff --git a/src-tauri/src/settings_commands.rs b/src-tauri/src/settings_commands.rs index c56e1d75..f299f31e 100644 --- a/src-tauri/src/settings_commands.rs +++ b/src-tauri/src/settings_commands.rs @@ -345,6 +345,159 @@ pub fn set_ollama_url( Ok(resolved) } +/// Remembers the built-in model `model_id` as an accepted over-the-mild-limit +/// load: its weights SHA-256 is appended to `behavior.dismissed_memory_fit_models` +/// so the "may not fit" card is not shown again for it in the mild band. A +/// freeze-band load still warns regardless. Idempotent (a model already +/// remembered is a no-op), FIFO-capped, and persisted through the exact same +/// lock + atomic-write + broadcast path as every other config mutation. +/// +/// Thin Tauri wrapper (coverage-off): it resolves the model's weights SHA from +/// the manifest (a missing/unreadable row means nothing to remember, so the +/// current config is returned unchanged) and delegates the list mutation to the +/// unit-tested [`write_dismissed_memory_fit_added_to_disk`]. +#[tauri::command] +#[cfg_attr(coverage_nightly, coverage(off))] +pub fn remember_model_memory_fit( + model_id: String, + app: AppHandle, + state: State<'_, RwLock>, + db: State<'_, crate::history::Database>, +) -> Result { + let path = config_path(&app)?; + // Resolve the weights SHA-256 that keys the override list. A poisoned lock is + // recovered (an unrelated panic does not invalidate the connection). No row + // means the model is not installed, so there is nothing to remember. + let sha = { + let conn = match db.0.lock() { + Ok(conn) => conn, + Err(poisoned) => poisoned.into_inner(), + }; + match crate::models::manifest::get(&conn, &model_id) { + Ok(Some(row)) => row.sha256, + _ => return Ok(state.read().clone()), + } + }; + let resolved = { + let mut guard = state.write(); + let resolved = write_dismissed_memory_fit_added_to_disk(&path, &sha)?; + *guard = resolved.clone(); + resolved + }; + emit_config_updated(&app); + Ok(resolved) +} + +/// Removes the weights SHA-256 `model_sha` from +/// `behavior.dismissed_memory_fit_models`, so the "may not fit" card warns again +/// for that model. Keyed by sha (not model id) so an orphaned entry whose model +/// is no longer installed is still removable from Settings. Persisted through +/// the same lock + atomic-write + broadcast path as every other config +/// mutation. +/// +/// Thin Tauri wrapper (coverage-off): delegates the list mutation to the +/// unit-tested [`write_dismissed_memory_fit_removed_to_disk`]. +#[tauri::command] +#[cfg_attr(coverage_nightly, coverage(off))] +pub fn forget_model_memory_fit( + model_sha: String, + app: AppHandle, + state: State<'_, RwLock>, +) -> Result { + let path = config_path(&app)?; + let resolved = { + let mut guard = state.write(); + let resolved = write_dismissed_memory_fit_removed_to_disk(&path, &model_sha)?; + *guard = resolved.clone(); + resolved + }; + emit_config_updated(&app); + Ok(resolved) +} + +/// Reads the current `behavior.dismissed_memory_fit_models` array out of a +/// parsed config document as plain strings, or an empty vec when the key (or +/// the whole section) is absent. Non-string array entries are skipped. The +/// loader re-sanitizes the list on the reload that follows every write, so this +/// only needs to recover the raw stored strings. +fn read_dismissed_memory_fit_models(doc: &DocumentMut) -> Vec { + doc.get("behavior") + .and_then(Item::as_table) + .and_then(|table| table.get("dismissed_memory_fit_models")) + .and_then(Item::as_array) + .map(|array| { + array + .iter() + .filter_map(|value| value.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() +} + +/// Writes a `Vec` back into the `[behavior]` table's +/// `dismissed_memory_fit_models` key as a TOML array, materializing an empty +/// `[behavior]` table first when the on-disk file predates the section (mirrors +/// [`write_field_to_disk`]). +fn set_dismissed_memory_fit_models(doc: &mut DocumentMut, models: Vec) { + if doc.get("behavior").and_then(Item::as_table).is_none() { + doc.insert("behavior", Item::Table(Table::new())); + } + let mut array = Array::new(); + for sha in models { + array.push(sha); + } + // A `[behavior]` table is guaranteed present by the block above. + if let Some(table) = doc.get_mut("behavior").and_then(Item::as_table_mut) { + table.insert( + "dismissed_memory_fit_models", + Item::Value(TomlValue::Array(array)), + ); + } +} + +/// Appends `sha` to the on-disk `behavior.dismissed_memory_fit_models` list +/// (deduped + FIFO-capped via [`crate::config::loader::with_dismissed_model_added`]), +/// then reloads + resolves. Pulled out of the Tauri wrapper so the read, list +/// mutation, atomic write, and post-write reload are exercised without an +/// `AppHandle`. +pub(crate) fn write_dismissed_memory_fit_added_to_disk( + path: &Path, + sha: &str, +) -> Result { + let mut doc = read_document(path)?; + let current = read_dismissed_memory_fit_models(&doc); + let updated = crate::config::loader::with_dismissed_model_added(current, sha); + set_dismissed_memory_fit_models(&mut doc, updated); + config::atomic_write_bytes(path, doc.to_string().as_bytes()).map_err(|source| { + ConfigError::IoError { + path: path.to_path_buf(), + source, + } + })?; + config::load_from_path(path) +} + +/// Removes `sha` from the on-disk `behavior.dismissed_memory_fit_models` list +/// (via [`crate::config::loader::with_dismissed_model_removed`]), then reloads + +/// resolves. Pulled out of the Tauri wrapper so the read, list mutation, atomic +/// write, and post-write reload are exercised without an `AppHandle`. +pub(crate) fn write_dismissed_memory_fit_removed_to_disk( + path: &Path, + sha: &str, +) -> Result { + let mut doc = read_document(path)?; + let current = read_dismissed_memory_fit_models(&doc); + let updated = crate::config::loader::with_dismissed_model_removed(current, sha); + set_dismissed_memory_fit_models(&mut doc, updated); + config::atomic_write_bytes(path, doc.to_string().as_bytes()).map_err(|source| { + ConfigError::IoError { + path: path.to_path_buf(), + source, + } + })?; + config::load_from_path(path) +} + /// Patches one `(section, key)` to disk and returns the resolved `AppConfig` /// the loader produces from the new file. Pulled out of the Tauri wrapper so /// the allowlist guard, document patch, atomic write, and post-write reload diff --git a/src-tauri/src/settings_commands/tests.rs b/src-tauri/src/settings_commands/tests.rs index 7f02a3bd..dd2c0ad6 100644 --- a/src-tauri/src/settings_commands/tests.rs +++ b/src-tauri/src/settings_commands/tests.rs @@ -19,7 +19,8 @@ use super::{ prune_traces_for_retention, prune_traces_older_than, read_document, remove_openai_provider_from_disk, reset_section_on_disk, trace_enabled_changed, trace_retention_days_changed, traces_stats_for, validate_provider_value, - write_active_provider_to_disk, write_field_to_disk, write_provider_field_to_disk, + write_active_provider_to_disk, write_dismissed_memory_fit_added_to_disk, + write_dismissed_memory_fit_removed_to_disk, write_field_to_disk, write_provider_field_to_disk, }; use crate::config::defaults::{ALLOWED_FIELDS, ALLOWED_SECTIONS}; use crate::config::{AppConfig, ConfigError}; @@ -120,9 +121,12 @@ fn allowed_fields_count_matches_schema_field_count() { // because they have no perceptible effect across their usable range. // `prompt.system_customized` is an internal migration flag co-written by // set_config_field when prompt.system is saved; it is not directly user-tunable - // and is intentionally absent from ALLOWED_FIELDS. If this assertion fails, the - // schema has drifted from the allowlist and someone added a field without - // extending ALLOWED_FIELDS. + // and is intentionally absent from ALLOWED_FIELDS. + // `behavior.dismissed_memory_fit_models` is an array, not a flat scalar: it is + // written through the dedicated remember_model_memory_fit / forget_model_memory_fit + // commands (mirroring the providers array), so it too is intentionally absent + // from ALLOWED_FIELDS. If this assertion fails, the schema has drifted from the + // allowlist and someone added a flat field without extending ALLOWED_FIELDS. assert_eq!(ALLOWED_FIELDS.len(), 25); } @@ -738,6 +742,138 @@ fn write_field_to_disk_creates_section_absent_from_older_file() { assert!(on_disk.contains("auto_replace = true")); } +// ─── dismissed_memory_fit_models add / remove ─────────────────────────────── + +/// A valid 64-char lowercase-hex weights SHA from a single hex nibble. +fn fit_sha(nibble: char) -> String { + std::iter::repeat(nibble).take(64).collect() +} + +#[test] +fn write_dismissed_added_persists_and_creates_section() { + // SAMPLE_CONFIG has no [behavior] table: adding must materialize it. + let dir = tempdir(); + let path = dir.join("config.toml"); + std::fs::write(&path, SAMPLE_CONFIG).unwrap(); + + let resolved = write_dismissed_memory_fit_added_to_disk(&path, &fit_sha('a')).unwrap(); + assert_eq!( + resolved.behavior.dismissed_memory_fit_models, + vec![fit_sha('a')] + ); + + let on_disk = std::fs::read_to_string(&path).unwrap(); + assert!(on_disk.contains("[behavior]")); + assert!(on_disk.contains(&fit_sha('a'))); +} + +#[test] +fn write_dismissed_added_is_idempotent_and_appends_new() { + let dir = tempdir(); + let path = dir.join("config.toml"); + std::fs::write(&path, SAMPLE_CONFIG).unwrap(); + + write_dismissed_memory_fit_added_to_disk(&path, &fit_sha('a')).unwrap(); + // Re-adding the same sha does not duplicate it. + let again = write_dismissed_memory_fit_added_to_disk(&path, &fit_sha('a')).unwrap(); + assert_eq!( + again.behavior.dismissed_memory_fit_models, + vec![fit_sha('a')] + ); + // A different sha appends. + let two = write_dismissed_memory_fit_added_to_disk(&path, &fit_sha('b')).unwrap(); + assert_eq!( + two.behavior.dismissed_memory_fit_models, + vec![fit_sha('a'), fit_sha('b')] + ); +} + +#[test] +fn write_dismissed_removed_deletes_by_sha() { + let dir = tempdir(); + let path = dir.join("config.toml"); + std::fs::write( + &path, + format!( + "[behavior]\ndismissed_memory_fit_models = [\"{}\", \"{}\"]\n", + fit_sha('a'), + fit_sha('b') + ), + ) + .unwrap(); + + let resolved = write_dismissed_memory_fit_removed_to_disk(&path, &fit_sha('a')).unwrap(); + assert_eq!( + resolved.behavior.dismissed_memory_fit_models, + vec![fit_sha('b')] + ); + + let on_disk = std::fs::read_to_string(&path).unwrap(); + assert!(!on_disk.contains(&fit_sha('a'))); + assert!(on_disk.contains(&fit_sha('b'))); +} + +#[test] +fn write_dismissed_removed_on_absent_sha_is_noop() { + // Removing a sha the list never had (orphan-safe) leaves the file valid and + // the list unchanged; the [behavior] section is materialized either way. + let dir = tempdir(); + let path = dir.join("config.toml"); + std::fs::write(&path, SAMPLE_CONFIG).unwrap(); + + let resolved = write_dismissed_memory_fit_removed_to_disk(&path, &fit_sha('c')).unwrap(); + assert!(resolved.behavior.dismissed_memory_fit_models.is_empty()); +} + +#[test] +fn write_dismissed_added_propagates_io_error_when_parent_dir_is_readonly() { + use std::os::unix::fs::PermissionsExt; + let dir = tempdir(); + let path = dir.join("config.toml"); + std::fs::write(&path, SAMPLE_CONFIG).unwrap(); + + // Read-only directory: the existing file reads, but the temp file for the + // atomic rename swap cannot be created, so the write fails. + let mut perms = std::fs::metadata(&dir).unwrap().permissions(); + perms.set_mode(0o500); + std::fs::set_permissions(&dir, perms.clone()).unwrap(); + + let err = write_dismissed_memory_fit_added_to_disk(&path, &fit_sha('a')).unwrap_err(); + + let mut restore = perms; + restore.set_mode(0o700); + std::fs::set_permissions(&dir, restore).unwrap(); + + matches!(err, ConfigError::IoError { .. }); +} + +#[test] +fn write_dismissed_removed_propagates_io_error_when_parent_dir_is_readonly() { + use std::os::unix::fs::PermissionsExt; + let dir = tempdir(); + let path = dir.join("config.toml"); + std::fs::write( + &path, + format!( + "[behavior]\ndismissed_memory_fit_models = [\"{}\"]\n", + fit_sha('a') + ), + ) + .unwrap(); + + let mut perms = std::fs::metadata(&dir).unwrap().permissions(); + perms.set_mode(0o500); + std::fs::set_permissions(&dir, perms.clone()).unwrap(); + + let err = write_dismissed_memory_fit_removed_to_disk(&path, &fit_sha('a')).unwrap_err(); + + let mut restore = perms; + restore.set_mode(0o700); + std::fs::set_permissions(&dir, restore).unwrap(); + + matches!(err, ConfigError::IoError { .. }); +} + #[test] fn write_field_to_disk_writing_prompt_system_co_writes_customized_flag() { // Saving prompt.system must atomically set system_customized=true so a diff --git a/src-tauri/src/warmup.rs b/src-tauri/src/warmup.rs index 1dd33971..6d9c4307 100644 --- a/src-tauri/src/warmup.rs +++ b/src-tauri/src/warmup.rs @@ -424,6 +424,31 @@ pub(crate) struct BuiltinSkippedPayload { /// blocked against, echoed to the frontend so the ambient warning strip can /// state the actual headroom rule instead of a hardcoded percentage. pub ceiling_fraction: f64, + /// Whether a per-model "remember" could suppress this warning: true unless + /// the skip is in the freeze band + /// ([`crate::models::memory::is_freeze_band`]). The single source of truth + /// for the freeze-floor fraction so the ambient strip's opt-in checkbox is + /// shown or hidden without the frontend duplicating a magic number, matching + /// the `estimate_model_fit` payload the chat card reads. + pub can_remember: bool, +} + +impl BuiltinSkippedPayload { + /// Builds the ambient-skip payload from the memory figures the gate blocked + /// on, deriving `ceiling_fraction` and `can_remember` so both stay in lock + /// step with the gate. `can_remember` is `!is_freeze_band(required, + /// available)`, computed against the SAME figures the skip used, so the + /// strip offers the remember action for exactly the loads a remember can + /// suppress. + pub(crate) fn new(model_id: String, required_bytes: u64, available_bytes: u64) -> Self { + Self { + model_id, + required_bytes, + available_bytes, + ceiling_fraction: MODEL_FIT_CEILING_FRACTION, + can_remember: !crate::models::memory::is_freeze_band(required_bytes, available_bytes), + } + } } /// Maps a resolved `(Target, MemoryGate)` - or a resolve error - to the @@ -516,6 +541,18 @@ pub(crate) fn spawn_gated_builtin_warmup( if !builtin_should_warm(&model_id) { return; } + // Snapshot the remembered memory-fit overrides before taking the DB lock so a + // model the user chose to load over the mild limit auto-primes silently + // instead of being skipped by the gate. Read outside the conn scope to avoid + // nesting the config and DB locks. + let dismissed_shas = { + use tauri::Manager; + app.state::>() + .read() + .behavior + .dismissed_memory_fit_models + .clone() + }; // Resolve the manifest row to an engine Target and run the pre-load memory // gate inside one scope so the connection guard drops before the spawned // load. A poisoned lock is recovered: an unrelated panic does not @@ -538,6 +575,7 @@ pub(crate) fn spawn_gated_builtin_warmup( &model_id, &target.model_path, force, + &dismissed_shas, ); (target, gate) }) @@ -590,12 +628,7 @@ pub(crate) fn spawn_gated_builtin_warmup( } let _ = app.emit( "warmup:builtin-skipped", - BuiltinSkippedPayload { - model_id, - required_bytes, - available_bytes, - ceiling_fraction: MODEL_FIT_CEILING_FRACTION, - }, + BuiltinSkippedPayload::new(model_id, required_bytes, available_bytes), ); } BuiltinWarmupAction::Skip => {} @@ -1035,6 +1068,24 @@ mod tests { } } + #[test] + fn builtin_skipped_payload_can_remember_reflects_freeze_band() { + const GIB: u64 = 1 << 30; + // Mild band: 11 GiB needed / 10 GiB available (ratio 1.10, below the 3x + // freeze floor) is remember-able, so the ambient strip offers the + // "Always allow this model" action. + let mild = BuiltinSkippedPayload::new("model".to_string(), 11 * GIB, 10 * GIB); + assert!(mild.can_remember); + assert_eq!(mild.ceiling_fraction, MODEL_FIT_CEILING_FRACTION); + // Mid-band: 20 GiB / 10 GiB (ratio 2.00) is still under the floor. + let mid = BuiltinSkippedPayload::new("model".to_string(), 20 * GIB, 10 * GIB); + assert!(mid.can_remember); + // Freeze band: 30 GiB / 10 GiB (ratio 3.00, the boundary) is never + // remember-able, so the strip hides "Always allow this model". + let freeze = BuiltinSkippedPayload::new("model".to_string(), 30 * GIB, 10 * GIB); + assert!(!freeze.can_remember); + } + #[tokio::test] async fn success_resets_in_flight() { let mut server = Server::new_async().await; diff --git a/src/App.tsx b/src/App.tsx index 25db415b..fa2c0fdb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -420,6 +420,7 @@ function App() { requiredBytes: number; availableBytes: number; ceilingFraction: number; + canRemember: boolean; } | null>(null); /** @@ -3663,6 +3664,7 @@ function App() { would_block: boolean; required_bytes: number; available_bytes: number; + can_remember: boolean; } | undefined; try { @@ -3681,6 +3683,7 @@ function App() { updateErroredMessageModel(retry.assistantMessageId, model, { requiredBytes: estimate.required_bytes, availableBytes: estimate.available_bytes, + canRemember: estimate.can_remember, }); } else { retryMessage(retry, model); @@ -3747,6 +3750,29 @@ function App() { [refreshModels, refreshModelCapabilities], ); + /** + * "Load anyway" on the `InsufficientMemory` card. Replays the turn past the + * per-turn memory gate; when `remember` is true (the "Always allow this + * model" split action) it also persists the per-model override so the mild + * over-limit warning is suppressed on future loads. Persistence is + * fire-and-forget: a failed `remember_model_memory_fit` must never block the + * load the user just asked for, and a freeze-band load still re-warns because + * the backend never suppresses that band (and never offers the remember). + */ + const handleLoadAnywayWithRemember = useCallback( + (snapshot: RetrySnapshot, remember: boolean) => { + if (remember) { + // The active model is always the one this card describes. The backend + // no-ops on an empty / uninstalled id, so no extra guard is needed. + void invoke('remember_model_memory_fit', { + modelId: activeModel, + }).catch(() => {}); + } + retryMessageWithOversized(snapshot); + }, + [activeModel, retryMessageWithOversized], + ); + /** * Props for the ambient memory-gate warning strip (issue #296), or `null` * when there is nothing to warn about. Suppressed while the download strip @@ -3766,12 +3792,13 @@ function App() { requiredBytes: autoPrimeSkipped.requiredBytes, availableBytes: autoPrimeSkipped.availableBytes, ceilingFraction: autoPrimeSkipped.ceilingFraction, + canRemember: autoPrimeSkipped.canRemember, onSwitchModel: () => handleSwitchModelFromError(), // "Load anyway" (issue #296): force-prime the model past the memory // gate. `force: true` routes through the SAME `preflight_memory_gate` // decision the auto-prime runs, admitting the load the user just - // consented to. - onLoadAnyway: () => { + // consented to. `remember` carries the stage-2 opt-in. + onLoadAnyway: (remember: boolean) => { // why: the user has consented, so the affordance is dismissed // optimistically - clear the ambient warning synchronously BEFORE // firing the IPC so the strip disappears on the click, with no wait @@ -3782,7 +3809,16 @@ function App() { // never by resurrecting this strip. `invoke` can reject (IO // boundary), so the rejection is swallowed rather than left // unhandled. + const modelId = autoPrimeSkipped.modelId; setAutoPrimeSkipped(null); + // Persist the per-model remember when the box was checked, before + // the force-load. Fire-and-forget: a persist failure must never + // block the load the user just consented to. + if (remember) { + void invoke('remember_model_memory_fit', { modelId }).catch( + () => {}, + ); + } void invoke('warm_up_model', { force: true }).catch(() => {}); }, } @@ -3871,12 +3907,14 @@ function App() { required_bytes: number; available_bytes: number; ceiling_fraction: number; + can_remember: boolean; }>('warmup:builtin-skipped', ({ payload }) => { setAutoPrimeSkipped({ modelId: payload.model_id, requiredBytes: payload.required_bytes, availableBytes: payload.available_bytes, ceilingFraction: payload.ceiling_fraction, + canRemember: payload.can_remember, }); }); // Settings cleared SQLite history: drop local conversation identity only @@ -4268,7 +4306,7 @@ function App() { } isModelPickerOpen={isModelPickerOpen} onSwitchModel={handleSwitchModelFromError} - onLoadAnyway={retryMessageWithOversized} + onLoadAnyway={handleLoadAnywayWithRemember} onMinimize={handleMinimize} onExportToggle={ messages.length > 0 diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index dc6395fa..3b8c3ed9 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -769,6 +769,9 @@ describe('App', () => { estimate_model_fit: { required_bytes: 8 * 1024 ** 3, available_bytes: 4 * 1024 ** 3, + // Mild band: these cases assert retry/attribution wiring, not the + // freeze-band presentation (covered in ErrorCard's own tests). + can_remember: true, }, }); render(); @@ -814,6 +817,112 @@ describe('App', () => { ); }); + it('remembers the model on the "Always allow this model" split action', async () => { + enableChannelCaptureWithResponses({ + get_model_picker_state: { + active: 'gemma4:e2b', + all: ['gemma4:e2b', 'qwen2.5:7b'], + ollamaReachable: true, + }, + estimate_model_fit: { + required_bytes: 8 * 1024 ** 3, + available_bytes: 4 * 1024 ** 3, + // Mild band (not freeze): the "Always allow this model" action shows. + can_remember: true, + }, + }); + render(); + await act(async () => {}); + await showOverlay(); + + const textarea = getAskInput(); + setAskValue('hi'); + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + await act(async () => {}); + + act(() => { + getLastChannel()?.simulateMessage({ + type: 'Error', + data: { kind: 'InsufficientMemory', message: 'may not fit' }, + }); + }); + await act(async () => {}); + await screen.findByText(/may not fit in memory/); + + // Reject the persistence so the fire-and-forget `.catch` runs: a failed + // remember must be swallowed and must never block the load. + const prior = invoke.getMockImplementation(); + invoke.mockImplementation( + async (cmd: string, args?: Record) => { + if (cmd === 'remember_model_memory_fit') { + throw new Error('persist failed'); + } + return prior?.(cmd, args); + }, + ); + invoke.mockClear(); + fireEvent.click( + await screen.findByRole('button', { name: 'Always allow this model' }), + ); + await act(async () => {}); + + // The active model is remembered, and the turn replays with the gate + // bypassed for this load even though persistence rejected. + expect(invoke).toHaveBeenCalledWith('remember_model_memory_fit', { + modelId: 'gemma4:e2b', + }); + expect(invoke).toHaveBeenCalledWith( + 'ask_model', + expect.objectContaining({ message: 'hi', allowOversized: true }), + ); + }); + + it('does not remember the model on the "Load once" split action', async () => { + enableChannelCaptureWithResponses({ + get_model_picker_state: { + active: 'gemma4:e2b', + all: ['gemma4:e2b', 'qwen2.5:7b'], + ollamaReachable: true, + }, + estimate_model_fit: { + required_bytes: 8 * 1024 ** 3, + available_bytes: 4 * 1024 ** 3, + can_remember: true, + }, + }); + render(); + await act(async () => {}); + await showOverlay(); + + const textarea = getAskInput(); + setAskValue('hi'); + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + await act(async () => {}); + + act(() => { + getLastChannel()?.simulateMessage({ + type: 'Error', + data: { kind: 'InsufficientMemory', message: 'may not fit' }, + }); + }); + await act(async () => {}); + await screen.findByText(/may not fit in memory/); + + invoke.mockClear(); + // "Load once" is the non-remembering force. + fireEvent.click(await screen.findByRole('button', { name: 'Load once' })); + await act(async () => {}); + + expect(invoke).not.toHaveBeenCalledWith( + 'remember_model_memory_fit', + expect.anything(), + ); + expect(invoke).toHaveBeenCalledWith( + 'ask_model', + expect.objectContaining({ message: 'hi', allowOversized: true }), + ); + }); + it('does not replay the abandoned turn when the model switch itself fails', async () => { let capturedChannel: { simulateMessage: (data: unknown) => void } | null = null; @@ -833,6 +942,7 @@ describe('App', () => { return { required_bytes: 8 * 1024 ** 3, available_bytes: 4 * 1024 ** 3, + can_remember: true, }; } if (cmd === 'set_active_model') { @@ -885,6 +995,9 @@ describe('App', () => { estimate_model_fit: { required_bytes: 8 * 1024 ** 3, available_bytes: 4 * 1024 ** 3, + // Mild band: these cases assert retry/attribution wiring, not the + // freeze-band presentation (covered in ErrorCard's own tests). + can_remember: true, }, }); render(); @@ -946,6 +1059,7 @@ describe('App', () => { available_bytes: 24 * 1024 ** 3, verdict: 'comfortable', would_block: false, + can_remember: true, }, }); render(); @@ -1026,6 +1140,9 @@ describe('App', () => { estimate_model_fit: { required_bytes: 8 * 1024 ** 3, available_bytes: 4 * 1024 ** 3, + // Mild band: these cases assert retry/attribution wiring, not the + // freeze-band presentation (covered in ErrorCard's own tests). + can_remember: true, }, }); render(); @@ -1082,6 +1199,9 @@ describe('App', () => { estimate_model_fit: { required_bytes: 8 * 1024 ** 3, available_bytes: 4 * 1024 ** 3, + // Mild band: these cases assert retry/attribution wiring, not the + // freeze-band presentation (covered in ErrorCard's own tests). + can_remember: true, }, }); render(); @@ -1180,6 +1300,7 @@ describe('App', () => { available_bytes: 4 * 1024 ** 3, verdict: 'insufficient', would_block: true, + can_remember: true, }, }); render(); @@ -1238,6 +1359,7 @@ describe('App', () => { available_bytes: 24 * 1024 ** 3, verdict: 'comfortable', would_block: false, + can_remember: true, }, }); render(); @@ -1302,6 +1424,7 @@ describe('App', () => { available_bytes: 4 * 1024 ** 3, verdict: 'insufficient', would_block: true, + can_remember: true, }; } return undefined; @@ -11220,6 +11343,8 @@ describe('App', () => { required_bytes: 8 * 1024 ** 3, available_bytes: 4 * 1024 ** 3, ceiling_fraction: 0.8, + // Mild band (not freeze): the stage-2 opt-in checkbox is offered. + can_remember: true, }; it('shows the ambient strip with the friendly model name and GB figures after warmup:builtin-skipped fires', async () => { @@ -11289,7 +11414,7 @@ describe('App', () => { ).toBeInTheDocument(); }); - it('force-primes and dismisses the strip synchronously on the two-stage "Acknowledge" confirm', async () => { + it('force-primes and dismisses the strip synchronously on the two-stage "Load once" confirm', async () => { render(); await act(async () => {}); await showOverlay(); @@ -11318,14 +11443,11 @@ describe('App', () => { expect.anything(), ); - // Stage 2: the confirmed "Acknowledge" click clears the strip + // Stage 2 (mild band): the "Load once" click clears the strip // synchronously (asserted WITHOUT awaiting the invoke round trip) and - // fires the force-prime. Scope to the strip: VersionAnnouncement also - // ships an "Acknowledge" primary CTA on first paint. + // fires the force-prime. const strip = screen.getByTestId('auto-prime-skipped-strip'); - fireEvent.click( - within(strip).getByRole('button', { name: 'Acknowledge' }), - ); + fireEvent.click(within(strip).getByRole('button', { name: 'Load once' })); expect( screen.queryByTestId('auto-prime-skipped-strip'), ).not.toBeInTheDocument(); @@ -11339,6 +11461,124 @@ describe('App', () => { ).not.toBeInTheDocument(); }); + it('threads can_remember to the strip: stage 2 shows the "Always allow this model" action', async () => { + render(); + await act(async () => {}); + await showOverlay(); + + act(() => emitTauriEvent('warmup:builtin-skipped', SKIPPED_PAYLOAD)); + await act(async () => {}); + + // Stage 1: no split action yet. + expect( + screen.queryByRole('button', { name: 'Always allow this model' }), + ).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: 'Load anyway' })); + await act(async () => {}); + // Stage 2: the mild-band skip offers the remember split action. + expect( + screen.getByRole('button', { name: 'Always allow this model' }), + ).toBeInTheDocument(); + }); + + it('remembers the model on the strip\'s "Always allow this model" action', async () => { + render(); + await act(async () => {}); + await showOverlay(); + + act(() => emitTauriEvent('warmup:builtin-skipped', SKIPPED_PAYLOAD)); + await act(async () => {}); + + invoke.mockClear(); + // Reject the persistence so the fire-and-forget `.catch` runs: a failed + // remember must never block the force-load. + invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'remember_model_memory_fit') { + throw new Error('persist failed'); + } + }); + + fireEvent.click(screen.getByRole('button', { name: 'Load anyway' })); + await act(async () => {}); + const strip = screen.getByTestId('auto-prime-skipped-strip'); + fireEvent.click( + within(strip).getByRole('button', { name: 'Always allow this model' }), + ); + await act(async () => {}); + + // The active model is remembered (by its id from the event), and the + // force-prime still fires even though persistence rejected. + expect(invoke).toHaveBeenCalledWith('remember_model_memory_fit', { + modelId: 'gemma4:e2b', + }); + expect(invoke).toHaveBeenCalledWith('warm_up_model', { force: true }); + }); + + it('does not remember on the strip\'s "Load once" action, only warm force', async () => { + render(); + await act(async () => {}); + await showOverlay(); + + act(() => emitTauriEvent('warmup:builtin-skipped', SKIPPED_PAYLOAD)); + await act(async () => {}); + + invoke.mockClear(); + fireEvent.click(screen.getByRole('button', { name: 'Load anyway' })); + await act(async () => {}); + const strip = screen.getByTestId('auto-prime-skipped-strip'); + fireEvent.click(within(strip).getByRole('button', { name: 'Load once' })); + await act(async () => {}); + + expect(invoke).not.toHaveBeenCalledWith( + 'remember_model_memory_fit', + expect.anything(), + ); + expect(invoke).toHaveBeenCalledWith('warm_up_model', { force: true }); + }); + + it('freeze-band skip (can_remember false) confirms before force-loading and never remembers', async () => { + render(); + await act(async () => {}); + await showOverlay(); + + act(() => + emitTauriEvent('warmup:builtin-skipped', { + ...SKIPPED_PAYLOAD, + can_remember: false, + }), + ); + await act(async () => {}); + + // Severity stated up front, with no advance click and no remember action. + expect(screen.getByTestId('memory-critical-chip')).toBeInTheDocument(); + expect(screen.getByTestId('memory-freeze-note')).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Always allow this model' }), + ).toBeNull(); + + invoke.mockClear(); + fireEvent.click(screen.getByRole('button', { name: 'Load anyway' })); + await act(async () => {}); + + // The first click only advances: the riskiest load in the app must never + // be reachable from a single stray click on an unprompted strip. + expect(invoke).not.toHaveBeenCalledWith( + 'warm_up_model', + expect.anything(), + ); + + fireEvent.click(screen.getByRole('button', { name: 'Load once' })); + await act(async () => {}); + + // The stage-2 click force-primes, and still never persists a remember. + expect(invoke).toHaveBeenCalledWith('warm_up_model', { force: true }); + expect(invoke).not.toHaveBeenCalledWith( + 'remember_model_memory_fit', + expect.anything(), + ); + }); + it('clears when the active model changes', async () => { enableChannelCaptureWithResponses({ get_model_picker_state: { diff --git a/src/components/AutoPrimeSkippedStrip.tsx b/src/components/AutoPrimeSkippedStrip.tsx index 14eb2784..86dd5392 100644 --- a/src/components/AutoPrimeSkippedStrip.tsx +++ b/src/components/AutoPrimeSkippedStrip.tsx @@ -20,7 +20,11 @@ */ import { useState } from 'react'; import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'; -import { INSUFFICIENT_MEMORY_CONSEQUENCE } from './ErrorCard'; +import { + INSUFFICIENT_MEMORY_CONSEQUENCE, + MEMORY_FREEZE_NOTE, + MemoryCriticalChip, +} from './ErrorCard'; /** * Shared ease for height expands elsewhere in the ask bar (command suggestion). @@ -56,34 +60,65 @@ export interface AutoPrimeSkippedStripProps { * 80%-of-available gate invisible in the copy. */ ceilingFraction: number; + /** + * Whether a per-model "remember" could suppress this warning (backend + * `!is_freeze_band`, from the skip event's `can_remember`). When true, stage 2 + * offers the "Always allow this model" split action; when false (freeze band) + * only the single "Acknowledge" force is shown, because the backend never + * honors a remember for such a load. The frontend keeps no freeze-floor + * number of its own. + */ + canRemember: boolean; /** Opens the model picker so the user can pick a different model. */ onSwitchModel: () => void; /** * Force-loads the oversized model past the memory gate. Called only on the * stage-2 "Acknowledge" click, so this fires as a deliberate, confirmed - * action, never on the first click. + * action, never on the first click. `remember` carries the stage-2 opt-in + * checkbox value so the host can persist the per-model override alongside the + * force-load; always `false` when the checkbox was hidden (freeze band). */ - onLoadAnyway: () => void; + onLoadAnyway: (remember: boolean) => void; } -/** Outlined primary CTA, matching SearchTrustNotice "Got it". */ +/** Outlined primary CTA, matching SearchTrustNotice "Got it". Used for "Switch + * model" (stage 1), "Acknowledge" (freeze force), and "Load once" (mild). */ const PRIMARY_BTN_CLASS = 'cursor-pointer rounded-lg border border-primary/45 bg-transparent px-3 py-1.5 text-[11.5px] font-semibold text-primary transition-colors hover:bg-primary/10 w-fit'; +/** Emphasized "Always allow this model" CTA: the outlined primary plus a soft + * primary fill, marking it as the remember choice among the split actions. */ +const ALWAYS_ALLOW_BTN_CLASS = + 'cursor-pointer rounded-lg border border-primary/45 bg-primary/[0.14] px-3 py-1.5 text-[11.5px] font-semibold text-primary transition-colors hover:bg-primary/20 w-fit'; + /** Ghost secondary CTA, matching SearchTrustNotice "Turn off in Settings". */ const GHOST_BTN_CLASS = 'cursor-pointer border-0 bg-transparent px-1 py-1.5 text-[11.5px] font-medium text-white/50 transition-colors hover:text-white/75 w-fit'; /** - * Renders the two-stage ambient memory warning. Stage 1 shows the model name - * and need-vs-available GB figures; stage 2 keeps that line and adds the - * consequence as muted text, with Acknowledge as the deliberate force-load. + * Renders the ambient memory warning. + * + * BOTH bands gate the force behind two clicks, and the dangerous one is never + * the cheaper click. The first "Load anyway" only advances the stage; the load + * fires on the second. + * + * The FREEZE band (`canRemember` false) leads with the severity chip, the + * free-vs-needed title, and the blunt note, then confirms with a single + * "Acknowledge". The second click is not there to inform (the chip already + * did that): it is there so a stray click on a strip that appears unprompted + * cannot wire memory the machine does not have and lock up the Mac. No + * remember is offered, because the backend refuses to honor one at this ratio. + * + * The MILD band shows the fit figures, then stage 2 adds the consequence copy + * and splits the force into "Load once" / "Always allow this model", with + * "Switch model" as the ghost escape. */ export function AutoPrimeSkippedStrip({ modelName, requiredBytes, availableBytes, ceilingFraction, + canRemember, onSwitchModel, onLoadAnyway, }: AutoPrimeSkippedStripProps) { @@ -91,39 +126,75 @@ export function AutoPrimeSkippedStrip({ // pure presentation detail. The first "Load anyway" click only flips this; // the actual force-load fires on the stage-2 click. Keeping it internal also // means the strip resets to stage 1 whenever the host remounts it (a fresh - // skip event), so a stale confirm never carries over to a new warning. + // skip event), so a stale confirm never carries over to a new warning. Both + // bands read it, so neither can be force-loaded on a single click. const [confirming, setConfirming] = useState(false); const reduceMotion = useReducedMotion(); // Keep fit line always; ceilingFraction is this branch's 80% headroom copy. const fitMessage = `${modelName} may not fit in memory (~${formatGb(requiredBytes)} GB needed, ~${formatGb(availableBytes)} GB available, over the ${Math.round(ceilingFraction * 100)}% safe limit)`; - // Stage 1: Switch model = primary (safe path). Load anyway = ghost. - // Stage 2: Acknowledge = primary (deliberate force). Switch model = ghost. - const primaryLabel = confirming ? 'Acknowledge' : 'Switch model'; - const secondaryLabel = confirming ? 'Switch model' : 'Load anyway'; - - /** - * Handles the primary button: Switch model in stage 1, force-load in stage 2. - */ - function onPrimaryClick(): void { - if (confirming) { - onLoadAnyway(); - } else { - onSwitchModel(); - } - } - - /** - * Handles the secondary button: advance to confirm in stage 1, or Switch - * model in stage 2. - */ - function onSecondaryClick(): void { - if (confirming) { - onSwitchModel(); - } else { - setConfirming(true); - } + if (!canRemember) { + return ( +
+ {/* No status dot in this band: the red severity tag IS the indicator, + so rendering the amber dot as well would read as two signals. */} +
+
+ +

+ {`Only ~${formatGb(availableBytes)} GB free. ${modelName} needs ~${formatGb(requiredBytes)} GB.`} +

+

+ {MEMORY_FREEZE_NOTE} +

+
+ {!confirming ? ( + // Stage 1: "Load anyway" only advances, so the riskiest load in + // the app is never one click away. + + ) : ( + // Stage 2: the deliberate force. Same "Load once" wording as + // the mild band, and for the same reason (a one-time force + // that persists nothing), but without the "Always allow this + // model" half, which the backend refuses to honor here. + + )} + +
+
+
+
+ ); } return ( @@ -174,22 +245,60 @@ export function AutoPrimeSkippedStrip({ ) : null}
- - + {!confirming ? ( + // Stage 1: Switch model (safe, primary) or Load anyway, which only + // advances to the consequence-shown stage 2, never loads yet. + <> + + + + ) : ( + // Stage 2, mild band (the only band that reaches here): split the + // force into "Load once" and the emphasized "Always allow this + // model" (persists the per-model override), with Switch model + // demoted to the ghost escape. Only offered here, after the + // consequence copy has been shown. + <> + + + + + )}
diff --git a/src/components/ChatBubble.tsx b/src/components/ChatBubble.tsx index 918cbe4e..6f80fcdd 100644 --- a/src/components/ChatBubble.tsx +++ b/src/components/ChatBubble.tsx @@ -149,9 +149,10 @@ interface ChatBubbleProps { * model load is never a dead end. Forwarded to the ErrorCard. */ onSwitchModel?: () => void; /** Replays the turn with the pre-load memory gate bypassed (issue #296). - * Forwarded to the ErrorCard's `InsufficientMemory` branch as the "Load - * anyway" action. */ - onLoadAnyway?: () => void; + * Forwarded to the ErrorCard's `InsufficientMemory` branch. `remember` + * carries the split "Load once" (`false`) vs "Always allow this model" + * (`true`) choice so the host can persist the per-model override. */ + onLoadAnyway?: (remember: boolean) => void; /** Accumulated thinking/reasoning content from the model, if thinking mode was used. */ thinkingContent?: string; /** Whether a `/think` turn is waiting for the first thinking tokens. */ @@ -210,7 +211,11 @@ interface ChatBubbleProps { * skipped, so the model name and GB figures never disagree during a "Switch * model" swap. Absent on the initial failure, where the fetch supplies them. */ - memoryFit?: { requiredBytes: number; availableBytes: number }; + memoryFit?: { + requiredBytes: number; + availableBytes: number; + canRemember: boolean; + }; } /** @@ -425,7 +430,12 @@ export function ChatBubble({ * there and this stale value can never leak onto a re-attributed card. */ const [insufficientMemoryInfo, setInsufficientMemoryInfo] = useState< - | { modelName: string; requiredBytes: number; availableBytes: number } + | { + modelName: string; + requiredBytes: number; + availableBytes: number; + canRemember: boolean; + } | undefined >(undefined); @@ -440,6 +450,7 @@ export function ChatBubble({ required_bytes: number; available_bytes: number; verdict: string; + can_remember: boolean; }>('estimate_model_fit', { modelId: modelName }) .then((estimate) => { if (cancelled) return; @@ -447,6 +458,7 @@ export function ChatBubble({ modelName: resolveMemoryModelName(modelName, displayNames), requiredBytes: estimate.required_bytes, availableBytes: estimate.available_bytes, + canRemember: estimate.can_remember, }); }) .catch(() => { @@ -469,6 +481,7 @@ export function ChatBubble({ modelName: resolveMemoryModelName(modelName, displayNames), requiredBytes: memoryFit.requiredBytes, availableBytes: memoryFit.availableBytes, + canRemember: memoryFit.canRemember, } : insufficientMemoryInfo; diff --git a/src/components/ErrorCard.tsx b/src/components/ErrorCard.tsx index 125c6da6..8f0b0ce4 100644 --- a/src/components/ErrorCard.tsx +++ b/src/components/ErrorCard.tsx @@ -12,10 +12,13 @@ interface ErrorCardProps { onSwitchModel?: () => void; /** * Replays the turn with the pre-load memory gate bypassed (issue #296). - * Wired only for `InsufficientMemory`: it renders the "Load anyway" - * recovery button beside "Switch model". + * Wired only for `InsufficientMemory`. `remember` carries the user's choice + * between the split "Load once" (`false`) and "Always allow this model" + * (`true`) actions, so the host can persist the per-model override on the + * remember variant; in the freeze band only the single "Load anyway" + * (`false`) force button is offered. */ - onLoadAnyway?: () => void; + onLoadAnyway?: (remember: boolean) => void; /** * Machine-readable figures backing the `InsufficientMemory` card copy, * sourced from the `estimate_model_fit` command. When absent (the fetch @@ -26,6 +29,15 @@ interface ErrorCardProps { modelName: string; requiredBytes: number; availableBytes: number; + /** + * Whether a per-model "remember" could suppress this warning (backend + * `!is_freeze_band`). True in the mild band, where the card offers the + * "Always allow this model" action; false in the freeze band, where only + * the single "Load anyway" force is offered because the backend never + * honors a remember for such a load. The single source of truth for the + * freeze-floor fraction; the frontend keeps no magic number of its own. + */ + canRemember: boolean; }; } @@ -56,6 +68,51 @@ const barColors: Record = { export const INSUFFICIENT_MEMORY_CONSEQUENCE = 'To fit this model, your Mac may compress memory, which can slow things down or, in extreme cases, freeze the entire machine and require a reboot.'; +/** + * The single freeze-band explainer, shown to everyone in that band. It speaks + * only to severity: naming a saved setting explains nothing to a user who does + * not recall choosing it months earlier, so this deliberately makes no mention + * of any remembered override. Exported so the ambient `AutoPrimeSkippedStrip` + * and this in-chat card render byte-identical wording from one source. + */ +export const MEMORY_FREEZE_NOTE = + 'That is far too tight to load on its own. Thuki always asks at this level, because loading can slow your Mac badly or freeze it.'; + +/** + * Red "Memory critically low" severity tag that heads the freeze-band warning. + * Exported as a component so both memory surfaces render identical severity + * chrome rather than duplicating the token values. + * + * Text only: it carries no dot of its own, because each surface already shows + * exactly one indicator in this band (the strip drops its status dot here, and + * the card tints its accent bar red to match). Squared corners at 5px, just + * under the card's own 6px radius, so it reads as a tag rather than a pill. + */ +export const MEMORY_CRITICAL_RED = '#f87171'; + +export function MemoryCriticalChip() { + return ( + + Memory critically low + + ); +} + /** Bytes per gigabyte, matching the Rust gate's `1u64 << 30` GiB divisor. */ const BYTES_PER_GB = 1024 ** 3; @@ -69,6 +126,20 @@ function formatGb(bytes: number): string { * raw llama-server output. */ const ENGINE_START_FAILED_TITLE = "Thuki's engine couldn't start this model"; +/** Outlined primary action button (transparent fill). Used by "Switch model" + * on other cards, and by "Load once" / "Load anyway" on the memory card. */ +const OUTLINE_BTN_CLASS = + 'text-[11.5px] font-semibold text-primary bg-transparent border border-primary/45 rounded-lg px-3 py-1.5 cursor-pointer'; + +/** Emphasized "Always allow this model" button: the same outline plus a soft + * primary fill, marking it as the remember choice among the split actions. */ +const ALWAYS_ALLOW_BTN_CLASS = + 'text-[11.5px] font-semibold text-primary bg-primary/[0.14] border border-primary/45 rounded-lg px-3 py-1.5 cursor-pointer'; + +/** Ghost secondary action button (no border, muted text). */ +const GHOST_BTN_CLASS = + 'text-[11.5px] font-medium text-white/50 bg-transparent border-0 px-1 py-1.5 cursor-pointer'; + /** * Renders a Minimal Line error callout inline in the chat thread. * @@ -89,14 +160,20 @@ export function ErrorCard({ onLoadAnyway, insufficientMemoryInfo, }: ErrorCardProps) { - const bar = ( + /** + * Renders the card's left accent bar in `color`. Factored so the freeze-band + * body can tint it red to match its severity tag without duplicating the bar + * markup or mutating `barColors`, which every other kind still reads. + */ + const accentBar = (color: string) => (
); + const bar = accentBar(barColors[kind]); if (kind === 'EngineStartFailed') { return ( @@ -138,7 +215,55 @@ export function ErrorCard({ } if (kind === 'InsufficientMemory' && insufficientMemoryInfo) { - const { modelName, requiredBytes, availableBytes } = insufficientMemoryInfo; + const { modelName, requiredBytes, availableBytes, canRemember } = + insufficientMemoryInfo; + // Freeze band: severity-first. The chip plus a single blunt note replace the + // usual fit/estimate lines and the consequence copy, because at this ratio + // the only useful message is how bad it is. No remember is possible here, so + // "Always allow this model" is never offered. + if (!canRemember) { + return ( +
+ {/* Red accent, not the shared amber: it must read as one severity + signal with the tag rather than clashing with it. */} + {accentBar(MEMORY_CRITICAL_RED)} +
+ +

+ {`Only ~${formatGb(availableBytes)} GB free. ${modelName} needs ~${formatGb(requiredBytes)} GB.`} +

+

+ {MEMORY_FREEZE_NOTE} +

+ {(onSwitchModel || onLoadAnyway) && ( +
+ {onLoadAnyway && ( + + )} + {onSwitchModel && ( + + )} +
+ )} +
+
+ ); + } return (
{bar} @@ -153,25 +278,37 @@ export function ErrorCard({ {INSUFFICIENT_MEMORY_CONSEQUENCE}

{(onSwitchModel || onLoadAnyway) && ( -
+
+ {/* Mild band: split the force into "Load once" and the emphasized + "Always allow this model" (persists the per-model override), + with Switch model demoted to the ghost escape. */} + {onLoadAnyway && ( + <> + + + + )} {onSwitchModel && ( )} - {onLoadAnyway && ( - - )}
)}
diff --git a/src/components/__tests__/AutoPrimeSkippedStrip.test.tsx b/src/components/__tests__/AutoPrimeSkippedStrip.test.tsx index 2c16f45c..af1a32ad 100644 --- a/src/components/__tests__/AutoPrimeSkippedStrip.test.tsx +++ b/src/components/__tests__/AutoPrimeSkippedStrip.test.tsx @@ -16,6 +16,7 @@ describe('AutoPrimeSkippedStrip', () => { requiredBytes={8 * 1024 ** 3} availableBytes={4 * 1024 ** 3} ceilingFraction={0.8} + canRemember={true} onSwitchModel={vi.fn()} onLoadAnyway={vi.fn()} />, @@ -34,6 +35,7 @@ describe('AutoPrimeSkippedStrip', () => { requiredBytes={8 * 1024 ** 3} availableBytes={4 * 1024 ** 3} ceilingFraction={0.6} + canRemember={true} onSwitchModel={vi.fn()} onLoadAnyway={vi.fn()} />, @@ -52,6 +54,7 @@ describe('AutoPrimeSkippedStrip', () => { requiredBytes={1} availableBytes={1} ceilingFraction={0.8} + canRemember={true} onSwitchModel={vi.fn()} onLoadAnyway={vi.fn()} />, @@ -69,6 +72,7 @@ describe('AutoPrimeSkippedStrip', () => { requiredBytes={1} availableBytes={1} ceilingFraction={0.8} + canRemember={true} onSwitchModel={vi.fn()} onLoadAnyway={vi.fn()} />, @@ -89,6 +93,7 @@ describe('AutoPrimeSkippedStrip', () => { requiredBytes={1} availableBytes={1} ceilingFraction={0.8} + canRemember={true} onSwitchModel={vi.fn()} onLoadAnyway={vi.fn()} />, @@ -109,6 +114,7 @@ describe('AutoPrimeSkippedStrip', () => { requiredBytes={1} availableBytes={1} ceilingFraction={0.8} + canRemember={true} onSwitchModel={onSwitchModel} onLoadAnyway={vi.fn()} />, @@ -117,7 +123,7 @@ describe('AutoPrimeSkippedStrip', () => { expect(onSwitchModel).toHaveBeenCalledTimes(1); }); - it('keeps the fit warning and adds muted consequence on "Load anyway" without loading', () => { + it('advances to stage 2 on "Load anyway" without loading, showing the consequence and split actions (mild band)', () => { const onLoadAnyway = vi.fn(); render( { requiredBytes={1} availableBytes={1} ceilingFraction={0.8} + canRemember={true} onSwitchModel={vi.fn()} onLoadAnyway={onLoadAnyway} />, @@ -140,18 +147,41 @@ describe('AutoPrimeSkippedStrip', () => { screen.getByTestId('auto-prime-skipped-consequence'), ).toHaveTextContent(INSUFFICIENT_MEMORY_CONSEQUENCE); expect(onLoadAnyway).not.toHaveBeenCalled(); + // Stage-1 "Load anyway" is gone; the mild-band split now shows. expect( screen.queryByRole('button', { name: 'Load anyway' }), ).not.toBeInTheDocument(); expect( - screen.getByRole('button', { name: 'Acknowledge' }), + screen.getByRole('button', { name: 'Load once' }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Always allow this model' }), ).toBeInTheDocument(); expect( screen.getByRole('button', { name: 'Switch model' }), ).toBeInTheDocument(); }); - it('force-loads on the stage-2 "Acknowledge" click', () => { + it('mild band stage 2: "Load once" fires onLoadAnyway(false)', () => { + const onLoadAnyway = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: 'Load anyway' })); + fireEvent.click(screen.getByRole('button', { name: 'Load once' })); + expect(onLoadAnyway).toHaveBeenCalledTimes(1); + expect(onLoadAnyway).toHaveBeenCalledWith(false); + }); + + it('mild band stage 2: "Always allow this model" fires onLoadAnyway(true)', () => { const onLoadAnyway = vi.fn(); render( { requiredBytes={1} availableBytes={1} ceilingFraction={0.8} + canRemember={true} onSwitchModel={vi.fn()} onLoadAnyway={onLoadAnyway} />, ); fireEvent.click(screen.getByRole('button', { name: 'Load anyway' })); - fireEvent.click(screen.getByRole('button', { name: 'Acknowledge' })); + fireEvent.click( + screen.getByRole('button', { name: 'Always allow this model' }), + ); + expect(onLoadAnyway).toHaveBeenCalledTimes(1); + expect(onLoadAnyway).toHaveBeenCalledWith(true); + }); + + it('freeze band states the danger up front: chip, title, note and both actions with no click', () => { + render( + , + ); + // Everything is stated up front: no "Load anyway" advance step first. + const chip = screen.getByTestId('memory-critical-chip') as HTMLElement; + expect(chip).toHaveTextContent('Memory critically low'); + expect(chip.style.color).toBe('rgb(248, 113, 113)'); + expect(chip.style.background).toBe('rgba(248, 113, 113, 0.12)'); + expect(chip.style.border).toBe('1px solid rgba(248, 113, 113, 0.35)'); + expect(chip.style.borderRadius).toBe('5px'); + // The tag carries no dot, and it replaces the strip's amber status dot, so + // this band shows exactly one indicator. + expect(chip.querySelector('span')).toBeNull(); + expect(screen.queryByTestId('auto-prime-skipped-dot')).toBeNull(); + expect( + screen.getByText('Only ~4.0 GB free. Qwen3.5 9B needs ~8.0 GB.'), + ).toBeInTheDocument(); + expect(screen.getByTestId('memory-freeze-note')).toHaveTextContent( + 'That is far too tight to load on its own. Thuki always asks at this level, because loading can slow your Mac badly or freeze it.', + ); + // The old staged copy is gone in this band. + expect(screen.queryByTestId('auto-prime-skipped-consequence')).toBeNull(); + expect(screen.queryByText(/may not fit in memory \(/)).toBeNull(); + // Exactly Load anyway + Switch model; no Acknowledge, no Always allow. + expect(screen.getAllByRole('button')).toHaveLength(2); + expect( + screen.queryByRole('button', { name: 'Always allow this model' }), + ).toBeNull(); + expect(screen.queryByRole('button', { name: 'Acknowledge' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Load once' })).toBeNull(); + }); + + it('freeze band "Load anyway" only advances, so the riskiest load is never one click', () => { + const onLoadAnyway = vi.fn(); + const onSwitchModel = vi.fn(); + render( + , + ); + // First click must NOT load: a stray click on an unprompted strip cannot + // wire memory the machine does not have. + fireEvent.click(screen.getByRole('button', { name: 'Load anyway' })); + expect(onLoadAnyway).not.toHaveBeenCalled(); + // Stage 2 offers the one-time force only: no remember at this ratio. + expect(screen.queryByRole('button', { name: 'Load anyway' })).toBeNull(); + expect( + screen.queryByRole('button', { name: 'Always allow this model' }), + ).toBeNull(); + // The severity copy stays put across both stages. + expect(screen.getByTestId('memory-critical-chip')).toBeInTheDocument(); + expect(screen.getByTestId('memory-freeze-note')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Load once' })); expect(onLoadAnyway).toHaveBeenCalledTimes(1); + expect(onLoadAnyway).toHaveBeenCalledWith(false); + fireEvent.click(screen.getByRole('button', { name: 'Switch model' })); + expect(onSwitchModel).toHaveBeenCalledTimes(1); + }); + + it('mild band renders no chip and no freeze note in either stage', () => { + render( + , + ); + expect(screen.queryByTestId('memory-freeze-note')).toBeNull(); + expect(screen.queryByTestId('memory-critical-chip')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'Load anyway' })); + expect( + screen.getByTestId('auto-prime-skipped-consequence'), + ).toBeInTheDocument(); + expect(screen.queryByTestId('memory-freeze-note')).toBeNull(); + expect(screen.queryByTestId('memory-critical-chip')).toBeNull(); }); it('calls onSwitchModel from the stage-2 "Switch model" action', () => { @@ -176,6 +306,7 @@ describe('AutoPrimeSkippedStrip', () => { requiredBytes={1} availableBytes={1} ceilingFraction={0.8} + canRemember={true} onSwitchModel={onSwitchModel} onLoadAnyway={vi.fn()} />, @@ -193,6 +324,7 @@ describe('AutoPrimeSkippedStrip', () => { requiredBytes={1} availableBytes={1} ceilingFraction={0.8} + canRemember={true} onSwitchModel={vi.fn()} onLoadAnyway={vi.fn()} />, diff --git a/src/components/__tests__/ChatBubble.test.tsx b/src/components/__tests__/ChatBubble.test.tsx index ed854cd6..a9352c19 100644 --- a/src/components/__tests__/ChatBubble.test.tsx +++ b/src/components/__tests__/ChatBubble.test.tsx @@ -600,6 +600,9 @@ describe('ChatBubble', () => { required_bytes: 8 * 1024 ** 3, available_bytes: 4 * 1024 ** 3, verdict: 'insufficient', + // Mild band: these cases assert the model-name copy, not the + // freeze-band presentation (covered in ErrorCard's own tests). + can_remember: true, })); render( { required_bytes: 13.3 * 1024 ** 3, available_bytes: 4 * 1024 ** 3, verdict: 'insufficient', + // Mild band: these cases assert the model-name copy, not the + // freeze-band presentation (covered in ErrorCard's own tests). + can_remember: true, })); const { rerender } = render( { required_bytes: 13.3 * 1024 ** 3, available_bytes: 4 * 1024 ** 3, verdict: 'insufficient', + // Mild band: these cases assert the model-name copy, not the + // freeze-band presentation (covered in ErrorCard's own tests). + can_remember: true, })); const displayNames = { 'model-a-slug': 'Model A', @@ -714,6 +723,7 @@ describe('ChatBubble', () => { memoryFit={{ requiredBytes: 6 * 1024 ** 3, availableBytes: 4 * 1024 ** 3, + canRemember: true, }} />, ); @@ -741,6 +751,9 @@ describe('ChatBubble', () => { required_bytes: 1, available_bytes: 1, verdict: 'insufficient', + // Mild band: these cases assert the model-name copy, not the + // freeze-band presentation (covered in ErrorCard's own tests). + can_remember: true, })); render( { required_bytes: 1, available_bytes: 1, verdict: 'insufficient', + // Mild band: these cases assert the model-name copy, not the + // freeze-band presentation (covered in ErrorCard's own tests). + can_remember: true, })); render( { required_bytes: 1, available_bytes: 1, verdict: 'insufficient', + // Mild band: these cases assert the model-name copy, not the + // freeze-band presentation (covered in ErrorCard's own tests). + can_remember: true, })); const onLoadAnyway = vi.fn(); render( @@ -791,10 +810,10 @@ describe('ChatBubble', () => { onLoadAnyway={onLoadAnyway} />, ); - fireEvent.click( - await screen.findByRole('button', { name: 'Load anyway' }), - ); + // Mild band: the non-remembering half of the split forwards `false`. + fireEvent.click(await screen.findByRole('button', { name: 'Load once' })); expect(onLoadAnyway).toHaveBeenCalledTimes(1); + expect(onLoadAnyway).toHaveBeenCalledWith(false); }); it('falls back to the generic message render when estimate_model_fit rejects', async () => { @@ -840,6 +859,9 @@ describe('ChatBubble', () => { required_bytes: 1, available_bytes: 1, verdict: 'insufficient', + // Mild band: these cases assert the model-name copy, not the + // freeze-band presentation (covered in ErrorCard's own tests). + can_remember: true, }); // Flush the now-ignored resolution; nothing should throw. await Promise.resolve(); diff --git a/src/components/__tests__/ErrorCard.test.tsx b/src/components/__tests__/ErrorCard.test.tsx index fb7f8f5a..9206896c 100644 --- a/src/components/__tests__/ErrorCard.test.tsx +++ b/src/components/__tests__/ErrorCard.test.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { describe, it, expect, vi } from 'vitest'; -import { ErrorCard } from '../ErrorCard'; +import { ErrorCard, INSUFFICIENT_MEMORY_CONSEQUENCE } from '../ErrorCard'; describe('ErrorCard', () => { it('renders the title (first line of message)', () => { @@ -251,6 +251,7 @@ describe('ErrorCard', () => { modelName: 'Qwen3.5 9B', requiredBytes: 8 * 1024 ** 3, availableBytes: 4 * 1024 ** 3, + canRemember: true, }; const FALLBACK_MESSAGE = 'This model may not fit in memory\nClose some apps, pick a smaller model, or load it anyway.'; @@ -298,7 +299,7 @@ describe('ErrorCard', () => { ).toBeInTheDocument(); }); - it('applies the amber accent bar', () => { + it('applies the amber accent bar in the mild band', () => { const { container } = render( { ); }); - it('renders only Switch model when onLoadAnyway is absent, and fires it', () => { - const onSwitchModel = vi.fn(); + it('tints the accent bar red in the freeze band so it matches the severity tag', () => { + const { container } = render( + , + ); + const bar = container.querySelector('[data-error-bar]'); + expect((bar as HTMLElement | null)?.style.background).toBe( + 'rgb(248, 113, 113)', + ); + }); + + it('renders the three split actions in the mild band (canRemember true)', () => { render( , ); + expect( + screen.getByRole('button', { name: 'Load once' }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Always allow this model' }), + ).toBeInTheDocument(); expect( screen.getByRole('button', { name: 'Switch model' }), ).toBeInTheDocument(); + // No single "Load anyway" in the mild band; it is split. expect(screen.queryByRole('button', { name: 'Load anyway' })).toBeNull(); + }); + + it('mild band: "Load once" fires onLoadAnyway(false), "Always allow this model" fires onLoadAnyway(true), Switch fires onSwitchModel', () => { + const onLoadAnyway = vi.fn(); + const onSwitchModel = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: 'Load once' })); + expect(onLoadAnyway).toHaveBeenLastCalledWith(false); + fireEvent.click( + screen.getByRole('button', { name: 'Always allow this model' }), + ); + expect(onLoadAnyway).toHaveBeenLastCalledWith(true); fireEvent.click(screen.getByRole('button', { name: 'Switch model' })); expect(onSwitchModel).toHaveBeenCalledTimes(1); }); - it('renders only Load anyway when onSwitchModel is absent, and fires it', () => { + it('freeze band (canRemember false): chip, free-vs-needed title, single note, two buttons', () => { const onLoadAnyway = vi.fn(); + const onSwitchModel = vi.fn(); + render( + , + ); + // Severity tag: red tokens, squared corners, and no dot of its own. + const chip = screen.getByTestId('memory-critical-chip') as HTMLElement; + expect(chip).toHaveTextContent('Memory critically low'); + expect(chip.style.color).toBe('rgb(248, 113, 113)'); + expect(chip.style.background).toBe('rgba(248, 113, 113, 0.12)'); + expect(chip.style.border).toBe('1px solid rgba(248, 113, 113, 0.35)'); + // Squared-off tag, not a pill. + expect(chip.style.borderRadius).toBe('5px'); + expect(chip.style.fontSize).toBe('10px'); + expect(chip.style.fontWeight).toBe('700'); + expect(chip.style.letterSpacing).toBe('0.08em'); + expect(chip.style.padding).toBe('3px 8px'); + expect(chip.style.textTransform).toBe('uppercase'); + // Text only: the tag itself is the indicator, so it carries no dot. + expect(chip.querySelector('span')).toBeNull(); + // Free-vs-needed title, using the card's existing GB rounding. + expect( + screen.getByText('Only ~4.0 GB free. Qwen3.5 9B needs ~8.0 GB.'), + ).toBeInTheDocument(); + // The single severity note. + expect(screen.getByTestId('memory-freeze-note')).toHaveTextContent( + 'That is far too tight to load on its own. Thuki always asks at this level, because loading can slow your Mac badly or freeze it.', + ); + // The old fit/estimate/consequence copy is gone in this band. + expect( + screen.queryByText('Qwen3.5 9B may not fit in memory right now.'), + ).toBeNull(); + expect( + screen.queryByText( + 'Estimated need: ~8.0 GB. Currently available: ~4.0 GB.', + ), + ).toBeNull(); + expect(screen.queryByText(INSUFFICIENT_MEMORY_CONSEQUENCE)).toBeNull(); + // Exactly Load anyway + Switch model; never "Always allow this model". + expect( + screen.queryByRole('button', { name: 'Always allow this model' }), + ).toBeNull(); + expect(screen.queryByRole('button', { name: 'Load once' })).toBeNull(); + expect(screen.getAllByRole('button')).toHaveLength(2); + fireEvent.click(screen.getByRole('button', { name: 'Load anyway' })); + expect(onLoadAnyway).toHaveBeenCalledWith(false); + fireEvent.click(screen.getByRole('button', { name: 'Switch model' })); + expect(onSwitchModel).toHaveBeenCalledTimes(1); + }); + + it('mild band renders no chip and no freeze note', () => { + render( + , + ); + expect(screen.queryByTestId('memory-freeze-note')).toBeNull(); + expect(screen.queryByTestId('memory-critical-chip')).toBeNull(); + }); + + it('renders only Switch model when onLoadAnyway is absent (mild band)', () => { + const onSwitchModel = vi.fn(); render( , + ); + expect( + screen.getByRole('button', { name: 'Switch model' }), + ).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Load once' })).toBeNull(); + expect( + screen.queryByRole('button', { name: 'Always allow this model' }), + ).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'Switch model' })); + expect(onSwitchModel).toHaveBeenCalledTimes(1); + }); + + it('renders only the force button when onSwitchModel is absent (freeze band)', () => { + const onLoadAnyway = vi.fn(); + render( + , ); @@ -346,10 +479,10 @@ describe('ErrorCard', () => { ).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Switch model' })).toBeNull(); fireEvent.click(screen.getByRole('button', { name: 'Load anyway' })); - expect(onLoadAnyway).toHaveBeenCalledTimes(1); + expect(onLoadAnyway).toHaveBeenCalledWith(false); }); - it('omits both buttons when neither handler is provided', () => { + it('omits all action buttons when neither handler is provided', () => { render( { result.current.updateErroredMessageModel(assistantId, 'qwen2.5:7b', { requiredBytes: 8 * 1024 ** 3, availableBytes: 4 * 1024 ** 3, + canRemember: true, }); }); @@ -2612,6 +2613,7 @@ describe('useModel', () => { expect(result.current.messages[1].memoryFit).toEqual({ requiredBytes: 8 * 1024 ** 3, availableBytes: 4 * 1024 ** 3, + canRemember: true, }); expect(result.current.messages[1].errorKind).toBe('InsufficientMemory'); expect(result.current.messages[1].retrySnapshot).toBe(originalSnapshot); @@ -2639,6 +2641,7 @@ describe('useModel', () => { result.current.updateErroredMessageModel('no-such-id', 'qwen2.5:7b', { requiredBytes: 8 * 1024 ** 3, availableBytes: 4 * 1024 ** 3, + canRemember: true, }); }); }).not.toThrow(); diff --git a/src/hooks/useModel.ts b/src/hooks/useModel.ts index 2b3a95c3..84f498fd 100644 --- a/src/hooks/useModel.ts +++ b/src/hooks/useModel.ts @@ -76,7 +76,11 @@ export interface Message { * rendering them forever if that refetch rejects). Absent on the initial * failure, where `ChatBubble` fetches the figures itself. */ - memoryFit?: { requiredBytes: number; availableBytes: number }; + memoryFit?: { + requiredBytes: number; + availableBytes: number; + canRemember: boolean; + }; } /** Raw streaming chunk payload emitted from the Rust chat backend. */ @@ -1035,7 +1039,11 @@ export function useModel( ( assistantMessageId: string, modelName: string, - memoryFit: { requiredBytes: number; availableBytes: number }, + memoryFit: { + requiredBytes: number; + availableBytes: number; + canRemember: boolean; + }, ) => { setMessages((prev) => prev.map((message) => diff --git a/src/settings/SettingsWindow.test.tsx b/src/settings/SettingsWindow.test.tsx index b57dc71d..dce5bc43 100644 --- a/src/settings/SettingsWindow.test.tsx +++ b/src/settings/SettingsWindow.test.tsx @@ -63,6 +63,7 @@ const SAMPLE: RawAppConfig = { auto_save_conversations: true, history_retention_days: -1, auto_save_notice_acknowledged: false, + dismissed_memory_fit_models: [], }, debug: { trace_enabled: false, diff --git a/src/settings/components/SaveField.test.tsx b/src/settings/components/SaveField.test.tsx index 970c3c7b..4493b002 100644 --- a/src/settings/components/SaveField.test.tsx +++ b/src/settings/components/SaveField.test.tsx @@ -55,6 +55,7 @@ const SAMPLE: RawAppConfig = { auto_save_conversations: true, history_retention_days: -1, auto_save_notice_acknowledged: false, + dismissed_memory_fit_models: [], }, debug: { trace_enabled: false, diff --git a/src/settings/hooks/useConfigSync.test.ts b/src/settings/hooks/useConfigSync.test.ts index 22a31d71..73c1c0c1 100644 --- a/src/settings/hooks/useConfigSync.test.ts +++ b/src/settings/hooks/useConfigSync.test.ts @@ -64,6 +64,7 @@ const CONFIG_A: RawAppConfig = { auto_save_conversations: true, history_retention_days: -1, auto_save_notice_acknowledged: false, + dismissed_memory_fit_models: [], }, debug: { trace_enabled: false, diff --git a/src/settings/hooks/useDebouncedSave.test.ts b/src/settings/hooks/useDebouncedSave.test.ts index d56f2837..11d52cb3 100644 --- a/src/settings/hooks/useDebouncedSave.test.ts +++ b/src/settings/hooks/useDebouncedSave.test.ts @@ -56,6 +56,7 @@ const SAMPLE_CONFIG: RawAppConfig = { auto_save_conversations: true, history_retention_days: -1, auto_save_notice_acknowledged: false, + dismissed_memory_fit_models: [], }, debug: { trace_enabled: false, diff --git a/src/settings/tabs/BehaviorTab.tsx b/src/settings/tabs/BehaviorTab.tsx index f5317338..27c675e0 100644 --- a/src/settings/tabs/BehaviorTab.tsx +++ b/src/settings/tabs/BehaviorTab.tsx @@ -25,6 +25,7 @@ import { Toggle, } from '../components'; import { DrawCheckIcon } from '../../components/DrawCheckIcon'; +import { DismissedMemoryFitSection } from './DismissedMemoryFitSection'; import { SaveField } from '../components/SaveField'; import { useDebouncedSave } from '../hooks/useDebouncedSave'; import { configHelp } from '../configHelpers'; @@ -665,6 +666,8 @@ export function BehaviorTab({ + +
+ + ))} + +
+ + ); +} diff --git a/src/settings/tabs/ModelTab.test.tsx b/src/settings/tabs/ModelTab.test.tsx index d9a105f4..b4d127de 100644 --- a/src/settings/tabs/ModelTab.test.tsx +++ b/src/settings/tabs/ModelTab.test.tsx @@ -57,6 +57,7 @@ function buildConfig( auto_save_conversations: true, history_retention_days: -1, auto_save_notice_acknowledged: false, + dismissed_memory_fit_models: [], }, debug: { trace_enabled: false, trace_retention_days: 7 }, }; diff --git a/src/settings/tabs/ProviderCards.test.tsx b/src/settings/tabs/ProviderCards.test.tsx index d8aa031d..5a4984a8 100644 --- a/src/settings/tabs/ProviderCards.test.tsx +++ b/src/settings/tabs/ProviderCards.test.tsx @@ -76,6 +76,7 @@ const BASE_CONFIG: RawAppConfig = { auto_save_conversations: true, history_retention_days: -1, auto_save_notice_acknowledged: false, + dismissed_memory_fit_models: [], }, debug: { trace_enabled: false, trace_retention_days: 7 }, }; diff --git a/src/settings/tabs/__tests__/DismissedMemoryFitSection.test.tsx b/src/settings/tabs/__tests__/DismissedMemoryFitSection.test.tsx new file mode 100644 index 00000000..c2440938 --- /dev/null +++ b/src/settings/tabs/__tests__/DismissedMemoryFitSection.test.tsx @@ -0,0 +1,194 @@ +/** + * Tests for the Behavior-tab "Models allowed over the memory limit" section: + * name resolution from installed models, the short-sha orphan fallback, the + * Remove path (calls `forget_model_memory_fit` with the sha and lifts the + * returned config), and the empty-list hide. + */ + +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { invoke } from '@tauri-apps/api/core'; + +import { DismissedMemoryFitSection } from '../DismissedMemoryFitSection'; +import type { InstalledModel } from '../../../types/starter'; +import type { RawAppConfig } from '../../types'; + +const invokeMock = invoke as unknown as ReturnType; + +const SHA_A = 'a'.repeat(64); +const SHA_B = 'b'.repeat(64); + +const INSTALLED: InstalledModel[] = [ + { + id: 'org/gemma:gemma.gguf', + sha256: SHA_A, + display_name: 'Gemma 3 4B', + size_bytes: 1, + quant: 'Q4_K_M', + }, +]; + +/** Base config with a configurable dismissed list. */ +function configWith(dismissed: string[]): RawAppConfig { + return { + inference: { + active_provider: 'builtin', + keep_warm_inactivity_minutes: 0, + num_ctx: 16384, + providers: [], + }, + prompt: { system: '' }, + window: { + overlay_width: 600, + max_chat_height: 648, + max_images: 3, + text_base_px: 15, + text_line_height: 1.5, + text_letter_spacing_px: 0, + text_font_weight: 500, + }, + quote: { + max_display_lines: 4, + max_display_chars: 300, + max_context_length: 4096, + }, + behavior: { + auto_replace: false, + auto_close: false, + auto_search: true, + search_notice_acknowledged: false, + auto_save_conversations: true, + history_retention_days: -1, + auto_save_notice_acknowledged: false, + dismissed_memory_fit_models: dismissed, + }, + debug: { trace_enabled: false, trace_retention_days: 7 }, + }; +} + +beforeEach(() => { + invokeMock.mockReset(); + invokeMock.mockImplementation((cmd: string) => { + if (cmd === 'list_installed_models') return Promise.resolve(INSTALLED); + return Promise.resolve(configWith([])); + }); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('DismissedMemoryFitSection', () => { + it('renders nothing when the dismissed list is empty', () => { + const { container } = render( + , + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders one row per entry, resolving installed names and short-sha orphans', async () => { + render( + , + ); + // Installed sha resolves to its display name. + expect(await screen.findByText('Gemma 3 4B')).toBeInTheDocument(); + // Orphaned sha (not installed) falls back to a short-sha label. + expect(screen.getByText(`${SHA_B.slice(0, 8)}…`)).toBeInTheDocument(); + expect(screen.getAllByRole('button', { name: 'Remove' })).toHaveLength(2); + }); + + it('Remove calls forget_model_memory_fit with the sha and lifts the config', async () => { + const onSaved = vi.fn(); + const next = configWith([SHA_B]); + invokeMock.mockImplementation((cmd: string) => { + if (cmd === 'list_installed_models') return Promise.resolve(INSTALLED); + if (cmd === 'forget_model_memory_fit') return Promise.resolve(next); + return Promise.resolve(configWith([])); + }); + + render( + , + ); + await screen.findByText('Gemma 3 4B'); + fireEvent.click(screen.getAllByRole('button', { name: 'Remove' })[0]); + + await waitFor(() => { + expect(invokeMock).toHaveBeenCalledWith('forget_model_memory_fit', { + modelSha: SHA_A, + }); + }); + await waitFor(() => expect(onSaved).toHaveBeenCalledWith(next)); + }); + + it('keeps the row when the remove command rejects', async () => { + const onSaved = vi.fn(); + invokeMock.mockImplementation((cmd: string) => { + if (cmd === 'list_installed_models') return Promise.resolve(INSTALLED); + if (cmd === 'forget_model_memory_fit') { + return Promise.reject(new Error('disk full')); + } + return Promise.resolve(configWith([])); + }); + + render( + , + ); + await screen.findByText('Gemma 3 4B'); + fireEvent.click(screen.getByRole('button', { name: 'Remove' })); + + await waitFor(() => { + expect(invokeMock).toHaveBeenCalledWith('forget_model_memory_fit', { + modelSha: SHA_A, + }); + }); + // Best-effort: onSaved never fires and the row remains. + expect(onSaved).not.toHaveBeenCalled(); + expect(screen.getByText('Gemma 3 4B')).toBeInTheDocument(); + }); + + it('falls back to short-sha labels when the installed list is not an array', async () => { + invokeMock.mockImplementation((cmd: string) => { + if (cmd === 'list_installed_models') return Promise.resolve(null); + return Promise.resolve(configWith([])); + }); + + render( + , + ); + expect( + await screen.findByText(`${SHA_A.slice(0, 8)}…`), + ).toBeInTheDocument(); + }); + + it('falls back to short-sha labels when the installed fetch fails', async () => { + invokeMock.mockImplementation((cmd: string) => { + if (cmd === 'list_installed_models') { + return Promise.reject(new Error('manifest unreadable')); + } + return Promise.resolve(configWith([])); + }); + + render( + , + ); + expect( + await screen.findByText(`${SHA_A.slice(0, 8)}…`), + ).toBeInTheDocument(); + }); +}); diff --git a/src/settings/tabs/models/LibraryPane.test.tsx b/src/settings/tabs/models/LibraryPane.test.tsx index 6e7ff4c2..177f2a8f 100644 --- a/src/settings/tabs/models/LibraryPane.test.tsx +++ b/src/settings/tabs/models/LibraryPane.test.tsx @@ -77,6 +77,7 @@ const BASE_CONFIG: RawAppConfig = { auto_save_conversations: true, history_retention_days: -1, auto_save_notice_acknowledged: false, + dismissed_memory_fit_models: [], }, debug: { trace_enabled: false, trace_retention_days: 7 }, }; @@ -104,6 +105,7 @@ function makeConfig(builtinModel: string): RawAppConfig { const GEMMA: InstalledModel = { id: 'org/gemma:gemma.gguf', + sha256: 'a'.repeat(64), size_bytes: 2_489_757_856, // A vision projector healed from the registry: folded into the shown total. mmproj_bytes: 500_000_000, @@ -118,6 +120,7 @@ const GEMMA: InstalledModel = { // that exercises the "RAM unknown" / weights-only / maker-fallback branches. const QWEN: InstalledModel = { id: 'org/qwen:qwen.gguf', + sha256: 'b'.repeat(64), display_name: 'qwen', size_bytes: 9_000_000_000, quant: '', diff --git a/src/settings/tabs/models/ProvidersPane.test.tsx b/src/settings/tabs/models/ProvidersPane.test.tsx index b4e7118a..70f2df26 100644 --- a/src/settings/tabs/models/ProvidersPane.test.tsx +++ b/src/settings/tabs/models/ProvidersPane.test.tsx @@ -93,6 +93,7 @@ function makeConfig( auto_save_conversations: true, history_retention_days: -1, auto_save_notice_acknowledged: false, + dismissed_memory_fit_models: [], }, debug: { trace_enabled: false, trace_retention_days: 7 }, }; diff --git a/src/settings/tabs/tabs.test.tsx b/src/settings/tabs/tabs.test.tsx index 6ce8480e..c830463d 100644 --- a/src/settings/tabs/tabs.test.tsx +++ b/src/settings/tabs/tabs.test.tsx @@ -83,6 +83,7 @@ const CONFIG: RawAppConfig = { auto_save_conversations: true, history_retention_days: -1, auto_save_notice_acknowledged: false, + dismissed_memory_fit_models: [], }, debug: { trace_enabled: false, @@ -573,6 +574,7 @@ describe('BehaviorTab', () => { auto_save_conversations: true, history_retention_days: -1, auto_save_notice_acknowledged: false, + dismissed_memory_fit_models: [], }, }} resyncToken={0} @@ -656,6 +658,7 @@ describe('BehaviorTab', () => { auto_save_conversations: true, history_retention_days: -1, auto_save_notice_acknowledged: false, + dismissed_memory_fit_models: [], }, }} resyncToken={0} @@ -692,6 +695,7 @@ describe('BehaviorTab', () => { auto_save_conversations: true, history_retention_days: -1, auto_save_notice_acknowledged: false, + dismissed_memory_fit_models: [], }, }} resyncToken={0} diff --git a/src/settings/types.ts b/src/settings/types.ts index f641c637..941bf418 100644 --- a/src/settings/types.ts +++ b/src/settings/types.ts @@ -63,6 +63,12 @@ export interface RawAppConfig { history_retention_days: number; /** When true, one-shot auto-save chat notice has been dismissed forever. */ auto_save_notice_acknowledged: boolean; + /** + * Weights SHA-256 of models the user chose to load over the mild memory + * limit without being re-warned. Managed via the warning card opt-in and + * removable per row here in Settings. Freeze-band loads still warn. + */ + dismissed_memory_fit_models: string[]; }; debug: { trace_enabled: boolean; diff --git a/src/types/starter.ts b/src/types/starter.ts index 9954547f..34b097ae 100644 --- a/src/types/starter.ts +++ b/src/types/starter.ts @@ -110,6 +110,9 @@ export interface InstalledModel { id: string; /** Human-readable label (e.g. the GGUF file stem). */ display_name: string; + /** Weights content address (SHA-256). Keys the remembered memory-fit override + * list, so the Behavior tab can resolve a remembered sha back to this name. */ + sha256: string; /** Weights file size in bytes, for the installed-list size column. */ size_bytes: number; /** Quantisation label (e.g. "Q4_K_M"); empty when unknown. */ diff --git a/src/view/ConversationView.tsx b/src/view/ConversationView.tsx index 324f8044..b602afd0 100644 --- a/src/view/ConversationView.tsx +++ b/src/view/ConversationView.tsx @@ -111,10 +111,11 @@ interface ConversationViewProps { onSwitchModel?: (snapshot?: RetrySnapshot) => void; /** Replays a specific turn with the pre-load memory gate bypassed (issue * #296), given that turn's immutable `RetrySnapshot`. Wrapped per-message - * below and forwarded to each ChatBubble's ErrorCard as the "Load anyway" - * action, so a later turn superseding an earlier one can never cause the - * wrong turn to be replayed. */ - onLoadAnyway?: (snapshot: RetrySnapshot) => void; + * below and forwarded to each ChatBubble's ErrorCard, so a later turn + * superseding an earlier one can never cause the wrong turn to be replayed. + * `remember` carries the split "Load once" (`false`) vs "Always allow this + * model" (`true`) choice from the card's action buttons. */ + onLoadAnyway?: (snapshot: RetrySnapshot, remember: boolean) => void; /** * Called when the user clicks the minimize (yellow) dot. * Omit when there is no conversation to park (ask-bar mode). @@ -440,7 +441,7 @@ export function ConversationView({ } onLoadAnyway={ retrySnapshot && onLoadAnyway - ? () => onLoadAnyway(retrySnapshot) + ? (remember) => onLoadAnyway(retrySnapshot, remember) : undefined } thinkingContent={msg.thinkingContent} diff --git a/src/view/__tests__/ConversationView.test.tsx b/src/view/__tests__/ConversationView.test.tsx index 6543013f..abdff461 100644 --- a/src/view/__tests__/ConversationView.test.tsx +++ b/src/view/__tests__/ConversationView.test.tsx @@ -1569,12 +1569,14 @@ describe('ConversationView', () => { />, ); + // Freeze band (estimate has no can_remember): the single "Load anyway" + // force forwards remember=false with the card's own snapshot. fireEvent.click( await screen.findByRole('button', { name: 'Load anyway' }), ); expect(onLoadAnyway).toHaveBeenCalledTimes(1); - expect(onLoadAnyway).toHaveBeenCalledWith(retrySnapshot); + expect(onLoadAnyway).toHaveBeenCalledWith(retrySnapshot, false); }); it('omits the button instead of crashing when a message has no retrySnapshot', async () => { @@ -1599,7 +1601,9 @@ describe('ConversationView', () => { />, ); - await screen.findByText('This model may not fit in memory right now.'); + // Freeze band (estimate has no can_remember): the card still renders, but + // with no retrySnapshot there is no force action to wire. + await screen.findByText('Only ~0.0 GB free. This model needs ~0.0 GB.'); expect( screen.queryByRole('button', { name: 'Load anyway' }), ).not.toBeInTheDocument();