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
5 changes: 5 additions & 0 deletions apps/maple-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ agent runtime; its desktop Agent page points users to Maple Work.
attachments (picker, paste, or drag and drop), a per-message Copy button,
and a context-window indicator. Every tool call runs without asking for
approval.
- A task can switch models between messages: its model menu offers every
model, a pick applies from the next message, and the transcript notes the
switch. When the new model's context window is smaller, the model that
wrote the history summarizes it before the next message if it no longer
fits, as `crates/maple-agent/src/agent/switch_compaction.rs` describes.
- Shell tool calls start in the task's saved folder on the execution computer.
A `cd` inside a command does not affect later calls. Repository searches
should name the intended checkout/worktree with explicit absolute paths.
Expand Down
88 changes: 6 additions & 82 deletions apps/maple-agent/app/src/ui/chat/composer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,44 +22,6 @@ use crate::ui::theme;
use crate::ui::titlebar;
use crate::ui::widgets;

/// Research's compact label precedence, projected once when the catalog loads.
/// IDs remain the keys used for selection and persisted tasks.
pub(super) fn catalog_model_labels(
catalog: &maple_sdk::ModelCatalogResponse,
) -> std::collections::HashMap<String, String> {
let label = |short: Option<&str>, full: Option<&str>, id: &str| {
short
.filter(|value| !value.trim().is_empty())
.or_else(|| full.filter(|value| !value.trim().is_empty()))
.unwrap_or(id)
.to_string()
};
catalog
.data
.iter()
.map(|model| {
(
model.id.clone(),
label(
model.short_name.as_deref(),
model.display_name.as_deref(),
&model.id,
),
)
})
.chain(catalog.aliases.iter().map(|alias| {
(
alias.id.clone(),
label(
alias.short_name.as_deref(),
alias.label.as_deref(),
&alias.id,
),
)
}))
.collect()
}

/// The composer's button row, left to right. The row renders from this
/// list and application Vim walks it, so a new control is reachable from
/// the keyboard once it has an entry here, a rendering, and an action.
Expand Down Expand Up @@ -370,7 +332,7 @@ impl ChatScreen {
ChatPopup::Model,
chip(
"model-picker",
self.model_locked.then_some("lock"),
None,
label,
true,
self.popup.is_open(&ChatPopup::Model),
Expand Down Expand Up @@ -562,27 +524,21 @@ impl ChatScreen {

fn model_menu(&self) -> Menu<Self> {
let selected = self.selected_model.as_deref();
let locked = self.model_locked;
// A task offers every model; a pick applies from its next message.
Menu::new("model-menu", px(320.))
.label("Model")
.max_height(px(320.))
.application_vim(self.application_vim_enabled)
.items(self.model_menu_models().into_iter().map(|model| {
.items(self.models.iter().map(|model| {
let pick = model.clone();
let item = MenuItem::new(
MenuItem::new(
SharedString::from(format!("model-{model}")),
self.model_label(&model).to_string(),
self.model_label(model).to_string(),
move |this: &mut Self, _: &mut Window, cx: &mut Context<Self>| {
this.pick_model(pick.clone(), cx);
},
)
.current(selected == Some(model.as_str()));
// A task keeps the model of its first message.
if locked {
item.note("Locked to this task. Start a new task to switch.")
} else {
item
}
.current(selected == Some(model.as_str()))
}))
}

Expand Down Expand Up @@ -1559,35 +1515,3 @@ fn chip(
.child(div().whitespace_nowrap().child(label))
.when(chevron, |el| el.child(icon("chevron-down", px(14.), color)))
}

#[cfg(test)]
mod tests {
use super::catalog_model_labels;

#[test]
fn model_labels_follow_research_catalog_precedence() {
let catalog = serde_json::from_value(serde_json::json!({
"object": "list",
"data": [
{"id": "model-a", "short_name": "Compact", "display_name": "Full name"},
{"id": "model-b", "display_name": "Server supplied name"},
{"id": "model-c", "short_name": " ", "display_name": "Fallback name"},
{"id": "model-d"}
],
"aliases": [
{"id": "alias-a", "short_name": "Alias", "label": "Full alias", "target_model": "model-a"},
{"id": "alias-b", "label": "Automatic", "target_model": "model-b"},
{"id": "alias-c", "short_name": "", "label": " ", "target_model": "model-c"}
]
}))
.unwrap();
let labels = catalog_model_labels(&catalog);
assert_eq!(labels["model-a"], "Compact");
assert_eq!(labels["model-b"], "Server supplied name");
assert_eq!(labels["model-c"], "Fallback name");
assert_eq!(labels["model-d"], "model-d");
assert_eq!(labels["alias-a"], "Alias");
assert_eq!(labels["alias-b"], "Automatic");
assert_eq!(labels["alias-c"], "alias-c");
}
}
66 changes: 22 additions & 44 deletions apps/maple-agent/app/src/ui/chat/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -549,8 +549,6 @@ pub struct ChatScreen {
thinking_loading: bool,
thinking_generation: u64,
thinking_saving: bool,
/// The task on screen has a persisted model after its first message.
model_locked: bool,
/// Desktop notifications enabled (settings).
notify_enabled: bool,
/// Local-store policy, independent of the selected execution computer.
Expand Down Expand Up @@ -1410,7 +1408,6 @@ impl ChatScreen {
thinking_loading: false,
thinking_generation: 0,
thinking_saving: false,
model_locked: false,
notify_enabled: settings.desktop_notifications,
notify_details: settings.notification_details_enabled(),
window_active: true,
Expand Down Expand Up @@ -2232,7 +2229,7 @@ impl ChatScreen {
this.models_loading = false;
if let Ok((mut models, saved, catalog)) = result {
if let Some(catalog) = &catalog {
this.model_labels = composer::catalog_model_labels(catalog);
this.model_labels = maple_agent::agent::catalog_model_labels(catalog);
// An older execution computer lists every API model,
// or every catalog model; offer only what the catalog
// describes and does not keep out of Maple Work.
Expand All @@ -2254,7 +2251,7 @@ impl ChatScreen {
.or(saved.filter(offered))
.or_else(|| models.first().cloned());
}
if !this.model_locked && this.selected_model.is_none() {
if this.selected_model.is_none() {
this.selected_model = this.default_model.clone();
}
this.models = models;
Expand Down Expand Up @@ -3303,7 +3300,7 @@ impl ChatScreen {
self.draft = false;
self.draft_mcp_defaults = None;
self.draft_mcp_changes.clear();
// The lock belongs to the task; the empty screen uses the default.
// The empty screen uses the default, not the last task's model.
self.restore_default_model(cx);
self.sync_sidebar_selection(cx);
self.set_queue(Vec::new());
Expand Down Expand Up @@ -5510,22 +5507,9 @@ impl ChatScreen {
);
}

/// Pick the model of the next message, in a task or a new one. It also
/// becomes the account default for new tasks.
fn pick_model(&mut self, model: String, cx: &mut Context<Self>) {
if self.model_locked {
self.popup.close(cx);
if self.selected_model.as_deref() != Some(model.as_str())
&& let Some(locked) = self.selected_model.as_deref()
{
self.notice = Some(
format!(
"This task is locked to model {locked}. Start a new task to use {model}."
)
.into(),
);
}
cx.notify();
return;
}
self.selected_model = Some(model.clone());
self.default_model = Some(model.clone());
self.refresh_thinking(cx);
Expand Down Expand Up @@ -5553,37 +5537,28 @@ impl ChatScreen {
self.refresh_context_usage(cx);
}

/// New Task and unlocked sessions use the account default, not the
/// last opened task's locked model.
/// New Task and drafts use the account default, not the last opened
/// task's model.
fn restore_default_model(&mut self, cx: &mut Context<Self>) {
self.model_locked = false;
self.set_composer_model(self.default_model.clone(), cx);
self.refresh_thinking(cx);
}

/// A started task opens on the model it last used.
fn apply_session_model(&mut self, session: &AgentSessionSummary, cx: &mut Context<Self>) {
let locked = session.message_count > 0 && session.model.is_some();
if locked {
self.model_locked = true;
self.set_composer_model(session.model.clone(), cx);
self.refresh_thinking(cx);
} else {
self.restore_default_model(cx);
match session.model.clone().filter(|_| session.message_count > 0) {
Some(model) => {
self.set_composer_model(Some(model), cx);
self.refresh_thinking(cx);
}
None => self.restore_default_model(cx),
}
}

fn model_label<'a>(&'a self, model: &'a str) -> &'a str {
self.model_labels.get(model).map_or(model, String::as_str)
}

fn model_menu_models(&self) -> Vec<String> {
if self.model_locked {
self.selected_model.iter().cloned().collect()
} else {
self.models.clone()
}
}

/// Re-measure one row after it changed in place. The list keeps the
/// row's last height as a hint and the scroll position as it was, so
/// neither the scroll range nor the view moves before the next paint.
Expand Down Expand Up @@ -5750,15 +5725,18 @@ impl ChatScreen {
if session_summary_eq(existing, &session) {
return false;
}
let mut selected = None;
let mut switched = None;
if self.selected_session.as_deref() == Some(session.id.as_str()) {
self.web_enabled = session.web_enabled;
selected = Some(session.clone());
// Follow a switch the task made, here or on another surface;
// a pick waiting for the next message stays.
if existing.model != session.model {
switched = Some(session.clone());
}
}
*existing = session;
// The first message locks the task on screen to its model.
if let Some(selected) = selected {
self.apply_session_model(&selected, cx);
if let Some(switched) = switched {
self.apply_session_model(&switched, cx);
}
} else {
self.sessions.insert(0, session);
Expand Down
Loading
Loading