feat(web): improve WASM channel setup flow (#380)

* feat(web): improve WASM channel setup flow with stepper UI and auto-configure

Streamline the WASM channel setup experience in the web gateway:

- Auto-open configure modal after installing a WASM channel
- Add progress stepper (Installed → Configured → Active) on channel cards
- Replace generic Activate button with state-specific actions (Setup, Reconfigure, Restart)
- Show "Awaiting Pairing" status for Telegram until first user is paired
- Add SSE extension_status events for real-time status updates
- Add gateway restart endpoint (POST /api/gateway/restart) with idempotency guard
- Always mount webhook routes at startup so hot-added channels work without restart
- Add pairing request polling (10s interval) on extensions tab
- Track activation errors per channel with inline error display

Includes review fixes: activation_error priority over active status, stepper
failed state rendering, restart poll timeout, configure modal double-submit
guard, and SSE sender ordering constraint documentation.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* refactor: address PR review comments

- Move PairingStore construction outside .map() loop
- Extract createReconfigureButton() helper to reduce duplication

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
Henry Park
2026-02-25 22:27:13 -08:00
committed by GitHub
co-authored by Claude Opus 4.6
parent abda94d44f
commit 996c6a8cc9
13 changed files with 659 additions and 61 deletions
+70 -10
View File
@@ -52,6 +52,14 @@ struct ChannelRuntimeState {
telegram_owner_id: Option<i64>,
}
/// Result of saving setup secrets and attempting activation.
pub struct SetupResult {
/// Human-readable status message.
pub message: String,
/// Whether the channel was successfully activated after saving secrets.
pub activated: bool,
}
/// Central manager for extension lifecycle operations.
pub struct ExtensionManager {
registry: ExtensionRegistry,
@@ -82,6 +90,11 @@ pub struct ExtensionManager {
store: Option<Arc<dyn crate::db::Database>>,
/// Names of WASM channels that were successfully loaded at startup.
active_channel_names: RwLock<HashSet<String>>,
/// Last activation error for each WASM channel (ephemeral, cleared on success).
activation_errors: RwLock<HashMap<String, String>>,
/// SSE broadcast sender (set post-construction via `set_sse_sender()`).
sse_sender:
RwLock<Option<tokio::sync::broadcast::Sender<crate::channels::web::types::SseEvent>>>,
}
impl ExtensionManager {
@@ -121,6 +134,8 @@ impl ExtensionManager {
user_id,
store,
active_channel_names: RwLock::new(HashSet::new()),
activation_errors: RwLock::new(HashMap::new()),
sse_sender: RwLock::new(None),
}
}
@@ -153,6 +168,25 @@ impl ExtensionManager {
active.extend(names);
}
/// Set the SSE broadcast sender for pushing extension status events to the web UI.
pub async fn set_sse_sender(
&self,
sender: tokio::sync::broadcast::Sender<crate::channels::web::types::SseEvent>,
) {
*self.sse_sender.write().await = Some(sender);
}
/// Broadcast an extension status change to the web UI via SSE.
async fn broadcast_extension_status(&self, name: &str, status: &str, message: Option<&str>) {
if let Some(ref sender) = *self.sse_sender.read().await {
let _ = sender.send(crate::channels::web::types::SseEvent::ExtensionStatus {
extension_name: name.to_string(),
status: status.to_string(),
message: message.map(|m| m.to_string()),
});
}
}
/// Search for extensions. If `discover` is true, also searches online.
pub async fn search(
&self,
@@ -299,6 +333,7 @@ impl ExtensionManager {
tools,
needs_setup: false,
installed: true,
activation_error: None,
});
}
}
@@ -327,6 +362,7 @@ impl ExtensionManager {
tools: if active { vec![name] } else { Vec::new() },
needs_setup: false,
installed: true,
activation_error: None,
});
}
}
@@ -343,10 +379,12 @@ impl ExtensionManager {
match crate::channels::wasm::discover_channels(&self.wasm_channels_dir).await {
Ok(channels) => {
let active_names = self.active_channel_names.read().await;
let errors = self.activation_errors.read().await;
for (name, _discovered) in channels {
let active = active_names.contains(&name);
let (authenticated, needs_setup) =
self.check_channel_auth_status(&name).await;
let activation_error = errors.get(&name).cloned();
extensions.push(InstalledExtension {
name,
kind: ExtensionKind::WasmChannel,
@@ -357,6 +395,7 @@ impl ExtensionManager {
tools: Vec::new(),
needs_setup,
installed: true,
activation_error,
});
}
}
@@ -392,6 +431,7 @@ impl ExtensionManager {
tools: Vec::new(),
needs_setup: false,
installed: false,
activation_error: None,
});
}
}
@@ -2087,11 +2127,14 @@ impl ExtensionManager {
}
/// Save setup secrets for an extension, validating names against the capabilities schema.
///
/// After saving, attempts to hot-activate the channel. Returns a [`SetupResult`]
/// indicating whether activation succeeded (so the frontend can show appropriate UI).
pub async fn save_setup_secrets(
&self,
name: &str,
secrets: &std::collections::HashMap<String, String>,
) -> Result<String, ExtensionError> {
) -> Result<SetupResult, ExtensionError> {
let kind = self.determine_installed_kind(name).await?;
if kind != ExtensionKind::WasmChannel {
return Err(ExtensionError::Other(
@@ -2174,21 +2217,38 @@ impl ExtensionManager {
// Try to hot-activate the channel now that secrets are saved
match self.activate_wasm_channel(name).await {
Ok(result) => Ok(format!(
"Configuration saved and channel '{}' activated. {}",
name, result.message
)),
Ok(result) => {
self.activation_errors.write().await.remove(name);
self.broadcast_extension_status(name, "active", None).await;
Ok(SetupResult {
message: format!(
"Configuration saved and channel '{}' activated. {}",
name, result.message
),
activated: true,
})
}
Err(e) => {
let error_msg = e.to_string();
tracing::warn!(
channel = name,
error = %e,
"Saved configuration but hot-activation failed, restart may be needed"
);
Ok(format!(
"Configuration saved for '{}'. \
Automatic activation failed ({}), restart IronClaw to activate.",
name, e
))
self.activation_errors
.write()
.await
.insert(name.to_string(), error_msg.clone());
self.broadcast_extension_status(name, "failed", Some(&error_msg))
.await;
Ok(SetupResult {
message: format!(
"Configuration saved for '{}'. \
Automatic activation failed ({}), restart IronClaw to activate.",
name, e
),
activated: false,
})
}
}
}
+3
View File
@@ -203,6 +203,9 @@ pub struct InstalledExtension {
/// Whether this extension is installed locally (false = available in registry but not installed).
#[serde(default = "default_true")]
pub installed: bool,
/// Last activation error for WASM channels.
#[serde(skip_serializing_if = "Option::is_none")]
pub activation_error: Option<String>,
}
/// Error type for extension operations.