[apps] Improve app loading. (#10994)

There are two concepts of apps that we load in the harness:

- Directory apps, which is all the apps that the user can install.
- Accessible apps, which is what the user actually installed and can be
$ inserted and be used by the model. These are extracted from the tools
that are loaded through the gateway MCP.

Previously we wait for both sets of apps before returning the full apps
list. Which causes many issues because accessible apps won't be
available to the UI or the model if directory apps aren't loaded or
failed to load.

In this PR we are separating them so that accessible apps can be loaded
separately and are instantly available to be shown in the UI and to be
provided in model context. We also added an app-server event so that
clients can subscribe to also get accessible apps without being blocked
on the full app list.

- [x] Separate accessible apps and directory apps loading.
- [x] `app/list` request will also emit `app/list/updated` notifications
that app-server clients can subscribe. Which allows clients to get
accessible apps list to render in the $ menu without being blocked by
directory apps.
- [x] Cache both accessible and directory apps with 1 hour TTL to avoid
reloading them when creating new threads.
- [x] TUI improvements to redraw $ menu and /apps menu when app list is
updated.
This commit is contained in:
Matthew Zeng
2026-02-08 15:24:56 -08:00
committed by GitHub
parent 181b721ba5
commit 45b7763c3f
27 changed files with 1164 additions and 87 deletions
+2 -2
View File
@@ -1603,8 +1603,8 @@ impl App {
AppEvent::RateLimitSnapshotFetched(snapshot) => {
self.chat_widget.on_rate_limit_snapshot(Some(snapshot));
}
AppEvent::ConnectorsLoaded(result) => {
self.chat_widget.on_connectors_loaded(result);
AppEvent::ConnectorsLoaded { result, is_final } => {
self.chat_widget.on_connectors_loaded(result, is_final);
}
AppEvent::UpdateReasoningEffort(effort) => {
self.on_update_reasoning_effort(effort);
+4 -1
View File
@@ -97,7 +97,10 @@ pub(crate) enum AppEvent {
RateLimitSnapshotFetched(RateLimitSnapshot),
/// Result of prefetching connectors.
ConnectorsLoaded(Result<ConnectorsSnapshot, String>),
ConnectorsLoaded {
result: Result<ConnectorsSnapshot, String>,
is_final: bool,
},
/// Result of computing a `/diff` command.
DiffResult(String),
@@ -16,6 +16,11 @@ pub(crate) trait BottomPaneView: Renderable {
false
}
/// Stable identifier for views that need external refreshes while open.
fn view_id(&self) -> Option<&'static str> {
None
}
/// Handle Ctrl-C while this view is active.
fn on_ctrl_c(&mut self) -> CancellationEvent {
CancellationEvent::NotHandled
@@ -421,6 +421,7 @@ impl ChatComposer {
pub fn set_connector_mentions(&mut self, connectors_snapshot: Option<ConnectorsSnapshot>) {
self.connectors_snapshot = connectors_snapshot;
self.sync_popups();
}
pub(crate) fn take_mention_bindings(&mut self) -> Vec<MentionBinding> {
@@ -4269,6 +4270,43 @@ mod tests {
assert_ne!(composer.footer_mode, FooterMode::ShortcutOverlay);
}
#[test]
fn set_connector_mentions_refreshes_open_mention_popup() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_connectors_enabled(true);
composer.set_text_content("$".to_string(), Vec::new(), Vec::new());
assert!(matches!(composer.active_popup, ActivePopup::None));
let connectors = vec![AppInfo {
id: "connector_1".to_string(),
name: "Notion".to_string(),
description: Some("Workspace docs".to_string()),
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
install_url: Some("https://example.test/notion".to_string()),
is_accessible: true,
}];
composer.set_connector_mentions(Some(ConnectorsSnapshot { connectors }));
let ActivePopup::Skill(popup) = &composer.active_popup else {
panic!("expected mention popup to open after connectors update");
};
let mention = popup
.selected_mention()
.expect("expected connector mention to be selected");
assert_eq!(mention.insert_text, "$notion".to_string());
assert_eq!(mention.path, Some("app://connector_1".to_string()));
}
#[test]
fn shortcut_overlay_persists_while_task_running() {
use crossterm::event::KeyCode;
@@ -68,6 +68,7 @@ pub(crate) struct SelectionItem {
/// `AutoAllRows` measures all rows to ensure stable column widths as the user scrolls
/// `Fixed` used a fixed 30/70 split between columns
pub(crate) struct SelectionViewParams {
pub view_id: Option<&'static str>,
pub title: Option<String>,
pub subtitle: Option<String>,
pub footer_note: Option<Line<'static>>,
@@ -83,6 +84,7 @@ pub(crate) struct SelectionViewParams {
impl Default for SelectionViewParams {
fn default() -> Self {
Self {
view_id: None,
title: None,
subtitle: None,
footer_note: None,
@@ -103,6 +105,7 @@ impl Default for SelectionViewParams {
/// visible rows and source items and for preserving selection while filters
/// change.
pub(crate) struct ListSelectionView {
view_id: Option<&'static str>,
footer_note: Option<Line<'static>>,
footer_hint: Option<Line<'static>>,
items: Vec<SelectionItem>,
@@ -139,6 +142,7 @@ impl ListSelectionView {
]));
}
let mut s = Self {
view_id: params.view_id,
footer_note: params.footer_note,
footer_hint: params.footer_hint,
items: params.items,
@@ -460,6 +464,10 @@ impl BottomPaneView for ListSelectionView {
self.complete
}
fn view_id(&self) -> Option<&'static str> {
self.view_id
}
fn on_ctrl_c(&mut self) -> CancellationEvent {
self.complete = true;
CancellationEvent::Handled
+20
View File
@@ -658,6 +658,26 @@ impl BottomPane {
self.push_view(Box::new(view));
}
/// Replace the active selection view when it matches `view_id`.
pub(crate) fn replace_selection_view_if_active(
&mut self,
view_id: &'static str,
params: list_selection_view::SelectionViewParams,
) -> bool {
let is_match = self
.view_stack
.last()
.is_some_and(|view| view.view_id() == Some(view_id));
if !is_match {
return false;
}
self.view_stack.pop();
let view = list_selection_view::ListSelectionView::new(params, self.app_event_tx.clone());
self.push_view(Box::new(view));
true
}
/// Update the queued messages preview shown above the composer.
pub(crate) fn set_queued_user_messages(&mut self, queued: Vec<String>) {
self.queued_user_messages.messages = queued;
+83 -23
View File
@@ -145,12 +145,14 @@ use ratatui::widgets::Wrap;
use tokio::sync::mpsc::UnboundedSender;
use tokio::task::JoinHandle;
use tracing::debug;
use tracing::warn;
const DEFAULT_MODEL_DISPLAY_NAME: &str = "loading";
const PLAN_IMPLEMENTATION_TITLE: &str = "Implement this plan?";
const PLAN_IMPLEMENTATION_YES: &str = "Yes, implement this plan";
const PLAN_IMPLEMENTATION_NO: &str = "No, stay in Plan mode";
const PLAN_IMPLEMENTATION_CODING_MESSAGE: &str = "Implement the plan.";
const CONNECTORS_SELECTION_VIEW_ID: &str = "connectors-selection";
use crate::app_event::AppEvent;
use crate::app_event::ConnectorsSnapshot;
@@ -539,6 +541,7 @@ pub(crate) struct ChatWidget {
/// currently executing.
mcp_startup_status: Option<HashMap<String, McpStartupStatus>>,
connectors_cache: ConnectorsCacheState,
connectors_prefetch_in_flight: bool,
// Queue of interruptive UI events deferred during an active write cycle
interrupts: InterruptManager,
// Accumulates the current reasoning block text to extract a header
@@ -1013,7 +1016,6 @@ impl ChatWidget {
self.bottom_pane
.set_history_metadata(event.history_log_id, event.history_entry_count);
self.set_skills(None);
self.bottom_pane.set_connectors_snapshot(None);
self.thread_id = Some(event.session_id);
self.thread_name = event.thread_name.clone();
self.forked_from = event.forked_from_id;
@@ -2619,6 +2621,7 @@ impl ChatWidget {
agent_turn_running: false,
mcp_startup_status: None,
connectors_cache: ConnectorsCacheState::default(),
connectors_prefetch_in_flight: false,
interrupts: InterruptManager::new(),
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),
@@ -2782,6 +2785,7 @@ impl ChatWidget {
agent_turn_running: false,
mcp_startup_status: None,
connectors_cache: ConnectorsCacheState::default(),
connectors_prefetch_in_flight: false,
interrupts: InterruptManager::new(),
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),
@@ -2934,6 +2938,7 @@ impl ChatWidget {
agent_turn_running: false,
mcp_startup_status: None,
connectors_cache: ConnectorsCacheState::default(),
connectors_prefetch_in_flight: false,
interrupts: InterruptManager::new(),
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),
@@ -4470,24 +4475,52 @@ impl ChatWidget {
}
fn prefetch_connectors(&mut self) {
if !self.connectors_enabled() {
return;
}
if matches!(self.connectors_cache, ConnectorsCacheState::Loading) {
if !self.connectors_enabled() || self.connectors_prefetch_in_flight {
return;
}
self.connectors_cache = ConnectorsCacheState::Loading;
self.connectors_prefetch_in_flight = true;
if !matches!(self.connectors_cache, ConnectorsCacheState::Ready(_)) {
self.connectors_cache = ConnectorsCacheState::Loading;
}
let config = self.config.clone();
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
let result: Result<ConnectorsSnapshot, anyhow::Error> = async {
let connectors = connectors::list_connectors(&config).await?;
let accessible_connectors =
match connectors::list_accessible_connectors_from_mcp_tools(&config).await {
Ok(connectors) => connectors,
Err(err) => {
app_event_tx.send(AppEvent::ConnectorsLoaded {
result: Err(format!("Failed to load apps: {err}")),
is_final: true,
});
return;
}
};
app_event_tx.send(AppEvent::ConnectorsLoaded {
result: Ok(ConnectorsSnapshot {
connectors: accessible_connectors.clone(),
}),
is_final: false,
});
let result: Result<ConnectorsSnapshot, String> = async {
let all_connectors = connectors::list_all_connectors(&config).await?;
let connectors = connectors::merge_connectors_with_accessible(
all_connectors,
accessible_connectors,
);
Ok(ConnectorsSnapshot { connectors })
}
.await;
let result = result.map_err(|err| format!("Failed to load apps: {err}"));
app_event_tx.send(AppEvent::ConnectorsLoaded(result));
.await
.map_err(|err: anyhow::Error| format!("Failed to load apps: {err}"));
app_event_tx.send(AppEvent::ConnectorsLoaded {
result,
is_final: true,
});
});
}
@@ -6345,6 +6378,11 @@ impl ChatWidget {
}
fn open_connectors_popup(&mut self, connectors: &[connectors::AppInfo]) {
self.bottom_pane
.show_selection_view(self.connectors_popup_params(connectors));
}
fn connectors_popup_params(&self, connectors: &[connectors::AppInfo]) -> SelectionViewParams {
let total = connectors.len();
let installed = connectors
.iter()
@@ -6412,7 +6450,8 @@ impl ChatWidget {
items.push(item);
}
self.bottom_pane.show_selection_view(SelectionViewParams {
SelectionViewParams {
view_id: Some(CONNECTORS_SELECTION_VIEW_ID),
header: Box::new(header),
footer_hint: Some(Self::connectors_popup_hint_line()),
items,
@@ -6420,7 +6459,14 @@ impl ChatWidget {
search_placeholder: Some("Type to search apps".to_string()),
col_width_mode: ColumnWidthMode::AutoAllRows,
..Default::default()
});
}
}
fn refresh_connectors_popup_if_open(&mut self, connectors: &[connectors::AppInfo]) {
let _ = self.bottom_pane.replace_selection_view_if_active(
CONNECTORS_SELECTION_VIEW_ID,
self.connectors_popup_params(connectors),
);
}
fn connectors_popup_hint_line() -> Line<'static> {
@@ -6659,16 +6705,30 @@ impl ChatWidget {
self.set_skills_from_response(&ev);
}
pub(crate) fn on_connectors_loaded(&mut self, result: Result<ConnectorsSnapshot, String>) {
self.connectors_cache = match result {
Ok(connectors) => ConnectorsCacheState::Ready(connectors),
Err(err) => ConnectorsCacheState::Failed(err),
};
if let ConnectorsCacheState::Ready(snapshot) = &self.connectors_cache {
self.bottom_pane
.set_connectors_snapshot(Some(snapshot.clone()));
} else {
self.bottom_pane.set_connectors_snapshot(None);
pub(crate) fn on_connectors_loaded(
&mut self,
result: Result<ConnectorsSnapshot, String>,
is_final: bool,
) {
if is_final {
self.connectors_prefetch_in_flight = false;
}
match result {
Ok(snapshot) => {
self.refresh_connectors_popup_if_open(&snapshot.connectors);
self.connectors_cache = ConnectorsCacheState::Ready(snapshot.clone());
self.bottom_pane.set_connectors_snapshot(Some(snapshot));
}
Err(err) => {
if matches!(self.connectors_cache, ConnectorsCacheState::Ready(_)) {
warn!("failed to refresh apps list; retaining current apps snapshot: {err}");
return;
}
self.connectors_cache = ConnectorsCacheState::Failed(err);
self.bottom_pane.set_connectors_snapshot(None);
}
}
}
+69
View File
@@ -1074,6 +1074,7 @@ async fn make_chatwidget_manual(
agent_turn_running: false,
mcp_startup_status: None,
connectors_cache: ConnectorsCacheState::default(),
connectors_prefetch_in_flight: false,
interrupts: InterruptManager::new(),
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),
@@ -3667,6 +3668,74 @@ fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String {
lines.join("\n")
}
#[tokio::test]
async fn apps_popup_refreshes_when_connectors_snapshot_updates() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.config.features.enable(Feature::Apps);
chat.bottom_pane.set_connectors_enabled(true);
chat.on_connectors_loaded(
Ok(ConnectorsSnapshot {
connectors: vec![codex_chatgpt::connectors::AppInfo {
id: "connector_1".to_string(),
name: "Notion".to_string(),
description: Some("Workspace docs".to_string()),
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
install_url: Some("https://example.test/notion".to_string()),
is_accessible: true,
}],
}),
false,
);
chat.add_connectors_output();
let before = render_bottom_popup(&chat, 80);
assert!(
before.contains("Installed 1 of 1 available apps."),
"expected initial apps popup snapshot, got:\n{before}"
);
chat.on_connectors_loaded(
Ok(ConnectorsSnapshot {
connectors: vec![
codex_chatgpt::connectors::AppInfo {
id: "connector_1".to_string(),
name: "Notion".to_string(),
description: Some("Workspace docs".to_string()),
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
install_url: Some("https://example.test/notion".to_string()),
is_accessible: true,
},
codex_chatgpt::connectors::AppInfo {
id: "connector_2".to_string(),
name: "Linear".to_string(),
description: Some("Project tracking".to_string()),
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
install_url: Some("https://example.test/linear".to_string()),
is_accessible: true,
},
],
}),
true,
);
let after = render_bottom_popup(&chat, 80);
assert!(
after.contains("Installed 2 of 2 available apps."),
"expected refreshed apps popup snapshot, got:\n{after}"
);
assert!(
after.contains("Linear"),
"expected refreshed popup to include new connector, got:\n{after}"
);
}
#[tokio::test]
async fn experimental_features_popup_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;