Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 39 additions & 2 deletions crates/rustyclaw-desktop/src/app/dialogs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -780,7 +780,18 @@ pub(super) fn Dialogs(sig: AppSignals) -> Element {
EnginesDialog {
visible: state.read().show_engines_dialog,
data: state.read().engines_data.clone(),
on_close: move |_| state.write().show_engines_dialog = false,
on_close: move |_| {
let mut s = state.write();
s.show_engines_dialog = false;
// The inline action result belongs to the dialog; don't
// let it linger into the next open.
s.engine_action_result = None;
},
Comment thread
rexlunae marked this conversation as resolved.
action_pending: state.read().engine_model_action_pending.clone(),
action_result: state.read().engine_action_result.clone(),
on_clear_action_result: move |_| {
state.write().engine_action_result = None;
},
on_engine_action: move |(engine, action): (String, EngineActionKind)| {
let gw = gateway.read().clone();
if let Some(client) = gw {
Expand All @@ -795,6 +806,18 @@ pub(super) fn Dialogs(sig: AppSignals) -> Element {
}
},
on_model_action: move |(engine, model, action): (String, String, ModelActionKind)| {
state
.write()
.engine_model_action_pending = Some((engine.clone(), model.clone()));
Comment on lines +809 to +811

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Model buttons can stay stuck on "Loading…" when the request never leaves the app

The clicked model is flagged as busy (engine_model_action_pending = Some(...) at crates/rustyclaw-desktop/src/app/dialogs.rs:809-811) before it is known whether the request can actually be sent, so when it cannot the row's buttons stay greyed out and stuck on "Loading…".
Impact: Load/Unload/Remove for that model become unusable until the connection drops or the app is restarted, with no error shown.

Pending flag set unconditionally; only a gateway answer or a Disconnected event clears it

crates/rustyclaw-desktop/src/app/dialogs.rs:809-811 sets engine_model_action_pending before gateway.read() is checked. If gw is None, nothing is ever sent. Likewise, if client.send(...) returns Err (queue closed/full path), the spawned task only logs (tracing::error!("Failed to send model action: {}", e)). The flag is cleared only in GatewayEvent::EngineActionResult (crates/rustyclaw-desktop/src/app_support.rs:1007-1013) or on GatewayEvent::Disconnected (crates/rustyclaw-desktop/src/app_support.rs:191). Meanwhile the dialog disables all three action buttons for that row via pending_here (crates/rustyclaw-desktop/src/components/engines.rs:704-708, 729-731, 742-745, 758).

Setting the flag only after a successful enqueue (or clearing it in the error branch, and surfacing the failure through engine_action_result) keeps the buttons honest.

Prompt for agents
In crates/rustyclaw-desktop/src/app/dialogs.rs, `on_model_action` sets `state.write().engine_model_action_pending = Some((engine, model))` before checking that a gateway client exists and before knowing whether `client.send(GatewayCommand::EngineModelAction { .. })` succeeded. If there is no client, or the send errors (the spawned task only logs), the flag is never cleared and the engines dialog keeps the row's Load/Unload/Remove buttons disabled with a "Loading…" label (see the `pending_here` usage in crates/rustyclaw-desktop/src/components/engines.rs). Only `EngineActionResult` or a `Disconnected` event clears it (crates/rustyclaw-desktop/src/app_support.rs). Fix by only marking the action pending once the command has actually been enqueued, and clearing the pending marker (optionally recording an inline failure via `engine_action_result`) in the send-error branch.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

// A Load should honour the context window saved in the
// engine's parameters (the gateway maps it per engine:
// --n-ctx / --ctx-size / --num-ctx).
let saved_ctx = state
.read()
.engines_data
.as_ref()
.and_then(|d| d.engine(&engine))
.and_then(|e| e.config.context_length);
let gw = gateway.read().clone();
if let Some(client) = gw {
spawn(async move {
Expand All @@ -803,7 +826,7 @@ pub(super) fn Dialogs(sig: AppSignals) -> Element {
engine,
model,
action,
context_length: None,
context_length: saved_ctx,
extra_args: Vec::new(),
})
.await
Expand Down Expand Up @@ -869,6 +892,20 @@ pub(super) fn Dialogs(sig: AppSignals) -> Element {
format!("Switched to {} / {}", engine, model_for_state),
);
},
on_config_save: move |(engine, config): (String, rustyclaw_core::engines::EngineConfig)| {
let gw = gateway.read().clone();
if let Some(client) = gw {
spawn(async move {
if let Err(e) = client
.send(GatewayCommand::EngineConfigSet { engine, config })
.await
{
tracing::error!("Failed to save engine config: {}", e);
}
});
}
},
configs_received: state.read().engine_configs_received,
on_refresh: move |_| {
let gw = gateway.read().clone();
let selected = state
Expand Down
29 changes: 29 additions & 0 deletions crates/rustyclaw-desktop/src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,34 @@ pub fn App() -> Element {
}
});

// The engines dialog auto-loads the selected engine's models: whenever
// the dialog is open and the engine list (re)arrived, fetch the models
// for the active tab without requiring a click.
use_effect(move || {
if !state.read().engines_models_pending {
return;
}
state.write().engines_models_pending = false;
if !state.read().show_engines_dialog {
return;
}
let selected = state
.read()
.engines_data
.as_ref()
.and_then(|d| d.selected_engine.clone());
let gw = gateway.read().clone();
if let (Some(client), Some(engine)) = (gw, selected) {
spawn_reporting("load engine models", async move {
client
.send(GatewayCommand::EngineModelList { engine })
.await
.context("sending EngineModelList")?;
Ok(())
});
}
});

// Re-fetch panel lists after a mutation result marks them stale, so the
// cron/memory/MCP/channels/tool dialogs reflect the change.
use_effect(move || {
Expand Down Expand Up @@ -1736,6 +1764,7 @@ pub fn App() -> Element {
agent_name: state.read().agent_name.clone(),
pending_prompt: state.read().visible_user_prompt(),
provider_models: state.read().provider_models.clone(),
provider_loaded: state.read().provider_loaded_models.clone(),
on_submit: on_submit,
on_cancel: on_cancel,
on_delete_message: on_delete_message,
Expand Down
60 changes: 56 additions & 4 deletions crates/rustyclaw-desktop/src/app_support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,14 @@ pub(crate) fn handle_gateway_event(
s.pending_tool_approvals.clear();
s.pending_credential_requests.clear();
s.pending_device_flows.clear();
// An engine model action in flight died with the connection —
// its EngineActionResult can never arrive, so the dialog's
// "Loading…" buttons would stay stuck forever.
s.engine_model_action_pending = None;
s.engine_action_result = None;
// The next connection is a fresh exchange: the EngineConfigList
// snapshot for the engines panel has not arrived yet.
s.engine_configs_received = false;
}
GatewayEvent::AuthRequired => {
state.write().connection = ConnectionStatus::Authenticating;
Expand Down Expand Up @@ -875,6 +883,10 @@ pub(crate) fn handle_gateway_event(
// ── Engines ──────────────────────────────────────────────────────
GatewayEvent::EngineListResult { engines } => {
let mut s = state.write();
// A fresh exchange: the EngineConfigList snapshot that patches
// the panel's configs follows this frame, and has not arrived
// yet — until it does, saving parameters must stay disabled.
s.engine_configs_received = false;
let (host_ram, host_vram, host_gpu) = host_resources(&s);
let panel = s
.engines_data
Expand All @@ -888,9 +900,15 @@ pub(crate) fn handle_gateway_event(
if panel.selected_engine.is_none() {
panel.selected_engine = panel.engines.first().map(|e| e.id.clone());
}
// The dialog is open: fetch the selected engine's models without
// requiring a tab click.
if s.show_engines_dialog {
s.engines_models_pending = true;
}
}
GatewayEvent::EngineModelListResult { engine, models } => {
let mut s = state.write();
s.engines_models_pending = false;
let panel = s
.engines_data
.get_or_insert_with(rustyclaw_view::EnginesPanelData::default);
Expand Down Expand Up @@ -920,10 +938,30 @@ pub(crate) fn handle_gateway_event(
state.write().provider_models.insert(provider, models);
}
}
// The loaded/running markers and full engine configs arrive in their
// own frames (new capabilities, new frames); the desktop surfaces
// them once the enriched-payload handling lands with the UI work.
GatewayEvent::ProviderModelLoadedList { .. } | GatewayEvent::EngineConfigList { .. } => {}
// Loaded/running markers arrive in their own frame (new capability,
// new frame); the picker marks these models as running.
GatewayEvent::ProviderModelLoadedList { provider, loaded } => {
state
.write()
.provider_loaded_models
.insert(provider, loaded);
}
// Full engine configs arrive in their own frame right after the
// engine list; patch them onto the panel's engine entries so the
// parameters editor can round-trip them.
GatewayEvent::EngineConfigList { configs } => {
let mut s = state.write();
// The real config snapshot is here: the panel entries are no
// longer placeholders, so saving parameters is safe again.
s.engine_configs_received = true;
if let Some(panel) = s.engines_data.as_mut() {
for engine in &mut panel.engines {
if let Some(cfg) = configs.get(&engine.id) {
engine.config = cfg.clone();
}
}
}
}
GatewayEvent::EnginePullProgress {
engine,
model,
Expand Down Expand Up @@ -964,6 +1002,17 @@ pub(crate) fn handle_gateway_event(
message,
} => {
let mut s = state.write();
// A model action finished: clear the in-flight marker and keep
// the outcome for the dialog's inline feedback.
if model.is_some() {
if s.engine_model_action_pending
.as_ref()
.is_some_and(|(e, _)| e == &engine)
{
s.engine_model_action_pending = None;
}
s.engine_action_result = Some((engine.clone(), ok, message.clone()));
}
if let Some(ref mut panel) = s.engines_data {
// A pull just finished (successfully or not) — clear the bar.
if model.is_some() {
Expand Down Expand Up @@ -1154,6 +1203,9 @@ fn dto_to_engine_data(
can_load: dto.capabilities.can_load,
can_unload: dto.capabilities.can_unload,
},
// The engine config arrives in the EngineConfigList frame right
// after the list; it is patched onto the panel entry there.
config: Default::default(),
}
}

Expand Down
4 changes: 4 additions & 0 deletions crates/rustyclaw-desktop/src/components/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ pub struct ChatProps {
/// Live model lists fetched from provider APIs, keyed by provider id.
/// The model picker prefers these over the static catalogue.
pub provider_models: std::collections::HashMap<String, Vec<String>>,
/// Live "loaded/running" model ids per provider (a subset of
/// `provider_models`); the picker marks those models as running.
pub provider_loaded: std::collections::HashMap<String, Vec<String>>,
pub on_submit: EventHandler<String>,
pub on_cancel: EventHandler<()>,
pub on_prompt_respond: EventHandler<(String, PromptResponseValue)>,
Expand Down Expand Up @@ -127,6 +130,7 @@ pub fn Chat(props: ChatProps) -> Element {
current_provider: props.bottom_bar.composer.current_provider.clone(),
current_model: props.bottom_bar.composer.current_model.clone(),
provider_models: props.provider_models.clone(),
provider_loaded: props.provider_loaded.clone(),
directory_selector: props.bottom_bar.directory_selector.clone(),
on_model_change: props.on_model_change,
on_add_provider: props.on_add_provider,
Expand Down
15 changes: 14 additions & 1 deletion crates/rustyclaw-desktop/src/components/composer_accessory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ pub struct ComposerAccessoryProps {
pub current_model: Option<String>,
/// Live model lists fetched from provider APIs, keyed by provider id.
pub provider_models: HashMap<String, Vec<String>>,
/// Live "loaded/running" model ids per provider (a subset of
/// `provider_models`); the picker marks those models as running.
pub provider_loaded: HashMap<String, Vec<String>>,
pub directory_selector: rustyclaw_view::DirectorySelectorState,
pub on_model_change: EventHandler<ModelSelection>,
pub on_add_provider: EventHandler<()>,
Expand All @@ -35,6 +38,7 @@ pub fn ComposerAccessory(props: ComposerAccessoryProps) -> Element {
current_provider: props.current_provider.clone(),
current_model: props.current_model.clone(),
provider_models: props.provider_models.clone(),
provider_loaded: props.provider_loaded.clone(),
on_model_change: props.on_model_change,
on_add_provider: props.on_add_provider,
}
Expand Down Expand Up @@ -97,6 +101,8 @@ struct ModelBarProps {
current_model: Option<String>,
/// Live model lists fetched from provider APIs, keyed by provider id.
provider_models: HashMap<String, Vec<String>>,
/// Live "loaded/running" model ids per provider.
provider_loaded: HashMap<String, Vec<String>>,
on_model_change: EventHandler<ModelSelection>,
on_add_provider: EventHandler<()>,
}
Expand Down Expand Up @@ -148,6 +154,13 @@ fn ModelBar(props: ModelBarProps) -> Element {
if !current_model.is_empty() && !model_options.iter().any(|m| m == &current_model) {
model_options.insert(0, current_model.clone());
}
// Which of the listed models are loaded/running on the local engine, so
// the picker can say so instead of showing a flat list of names.
let loaded_set: std::collections::HashSet<&String> = props
.provider_loaded
.get(&provider_for_models)
.map(|v| v.iter().collect())
.unwrap_or_default();

rsx! {
div { class: "model-bar",
Expand Down Expand Up @@ -227,7 +240,7 @@ fn ModelBar(props: ModelBarProps) -> Element {
option {
value: "{mid}",
selected: *mid == current_model,
"{mid}"
if loaded_set.contains(mid) { "{mid} ● running" } else { "{mid}" }
}
}
}
Expand Down
Loading
Loading