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
1 change: 1 addition & 0 deletions apps/maple-agent/app/assets/icons/triangle-alert.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions apps/maple-agent/app/locales/de/app.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -1529,6 +1529,8 @@ chat-image-unavailable = Bild nicht verfügbar
chat-background = im Hintergrund
chat-stop = Stoppen
chat-billing-open = Abrechnung öffnen
chat-usage-limit-title = Nutzung aufgebraucht
chat-usage-limit-body = Unter „Abrechnung“ findest du Optionen, um weiterzuarbeiten.
chat-question-label = Frage von Maple
chat-permission-label = Diese Aktion erlauben?
chat-permission-allow = Einmal erlauben
Expand Down
2 changes: 2 additions & 0 deletions apps/maple-agent/app/locales/en/app.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -1529,6 +1529,8 @@ chat-image-unavailable = Image unavailable
chat-background = background
chat-stop = Stop
chat-billing-open = Open Billing
chat-usage-limit-title = Out of usage
chat-usage-limit-body = See your options in Billing so you can keep working.
chat-question-label = Question from Maple
chat-permission-label = Allow this action?
chat-permission-allow = Allow once
Expand Down
2 changes: 2 additions & 0 deletions apps/maple-agent/app/locales/es/app.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -1418,6 +1418,8 @@ chat-image-unavailable = Imagen no disponible
chat-background = en segundo plano
chat-stop = Detener
chat-billing-open = Abrir facturación
chat-usage-limit-title = Uso agotado
chat-usage-limit-body = Consulta tus opciones en Facturación para seguir trabajando.
chat-question-label = Pregunta de Maple
chat-permission-label = ¿Permitir esta acción?
chat-permission-allow = Permitir una vez
Expand Down
1 change: 1 addition & 0 deletions apps/maple-agent/app/src/assets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ assets!(
"icons/square.svg",
"icons/terminal.svg",
"icons/trash-2.svg",
"icons/triangle-alert.svg",
"icons/undo-2.svg",
"icons/users.svg",
"icons/volume-2.svg",
Expand Down
85 changes: 85 additions & 0 deletions apps/maple-agent/app/src/ui/chat/composer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1231,6 +1231,87 @@ impl ChatScreen {
(row, false)
}

/// The selected task's latest turn ended out of usage and nothing has
/// gone out since. A window that does not hold the tail (paged back
/// into history, or with live rows past its budget) cannot tell.
fn usage_limit_reached(&self) -> bool {
!self.pages.after
&& !self.pages.live_has_more
&& !self.is_run_active()
&& !self.has_send_feedback()
&& self
.timeline
.last()
.is_some_and(super::transcript::is_usage_limit)
}

/// The out-of-usage notice, docked on top of the composer where the
/// next message would go. It offers Billing until a message is sent;
/// the transcript keeps a one-line marker of the turn.
fn render_usage_tray(&self, cx: &mut Context<Self>) -> Div {
div()
.debug_selector(|| "composer-usage-limit".to_string())
.flex()
.items_center()
.gap_3()
.mx_2()
.mt_2()
.pl_2p5()
.pr_2()
.py_2()
.rounded(theme::RADIUS_LG)
.bg(gpui::rgb(theme::bg_composer_tray()))
.border_1()
.border_color(gpui::rgb(theme::border_subtle()))
.child(
div()
.flex_none()
.size_8()
.flex()
.items_center()
.justify_center()
.rounded(theme::RADIUS_SM)
.bg(gpui::rgb(theme::accent_container()))
.child(icon("gauge", px(16.), theme::accent_ink())),
)
.child(
div()
.flex_1()
.min_w_0()
.flex()
.flex_col()
.child(
div()
.text_sm()
.font_weight(gpui::FontWeight::SEMIBOLD)
.text_color(gpui::rgb(theme::text_heading()))
.child(gpui::text!(tr("chat-usage-limit-title"))),
)
.child(
div()
.text_xs()
.text_color(gpui::rgb(theme::text_muted()))
.child(gpui::text!(tr("chat-usage-limit-body"))),
),
)
.child(
widgets::default_button("composer-open-billing")
.flex_none()
.pr_4()
.debug_selector(|| "composer-open-billing".to_string())
.child(gpui::text!(tr("chat-billing-open")))
.child(icon(
"arrow-right",
px(14.),
theme::text_on_default_button(),
))
.on_click(cx.listener(|_this, _event, _window, cx| {
cx.stop_propagation();
cx.emit(OpenSettingsSection(Section::Billing));
})),
)
}

pub(super) fn render_composer(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Div {
let running = self.is_run_active();
// The first send's create keeps the composer's text on show and
Expand All @@ -1256,6 +1337,9 @@ impl ChatScreen {
// turns it back into Send, which queues behind the run.
let drafting = has_text || has_attachments;
let show_stop = running && !self.booting && !pending_create && !drafting;
let usage_tray = self
.usage_limit_reached()
.then(|| self.render_usage_tray(cx));
let queue_chips = self.render_queue(cx);
let expanded = self.composer_expanded;
let composer = self.composer.clone();
Expand Down Expand Up @@ -1342,6 +1426,7 @@ impl ChatScreen {
this.add_attachment_paths(paths.paths().to_vec(), cx);
}),
)
.children(usage_tray)
.children(queue_chips)
.when_some(upload, |container, (id, label)| {
container.child(
Expand Down
2 changes: 1 addition & 1 deletion apps/maple-agent/app/src/ui/chat/feedback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ impl ChatScreen {
.child(tr("chat-feedback-privacy")),
)
.when_some(form.error.clone(), |card, error| {
card.child(widgets::banner(theme::status_error()).child(error))
card.child(widgets::banner(widgets::Tone::Error, error))
})
.child(
div()
Expand Down
47 changes: 29 additions & 18 deletions apps/maple-agent/app/src/ui/chat/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7749,23 +7749,34 @@ impl ChatScreen {
.w_full()
.pt_2()
.child(
widgets::banner(theme::status_error())
.flex()
.flex_col()
.items_start()
.gap_2()
.child(crate::ui::host_errors::persistence_text(failure).resolve())
.when(remote, |panel| panel.child(tr("chat-retry-save-remote")))
.when(!remote, |panel| {
panel.child(
widgets::secondary_button("retry-local-store-persistence")
.debug_selector(|| "retry-local-store-persistence".to_string())
.child(gpui::text!(tr("chat-retry-save")))
.on_click(cx.listener(|_this, _event, _window, cx| {
cx.emit(crate::ui::store_gate::RetryStorePersistence);
})),
)
}),
widgets::banner(
widgets::Tone::Error,
div()
.flex()
.flex_col()
.gap_0p5()
.child(gpui::text!(
crate::ui::host_errors::persistence_text(failure).resolve()
))
.when(remote, |message| {
message.child(
div()
.text_color(gpui::rgb(theme::text_muted()))
.child(gpui::text!(tr("chat-retry-save-remote"))),
)
}),
)
.when(!remote, |panel| {
panel.child(
widgets::secondary_button("retry-local-store-persistence")
.flex_none()
.debug_selector(|| "retry-local-store-persistence".to_string())
.child(gpui::text!(tr("chat-retry-save")))
.on_click(cx.listener(|_this, _event, _window, cx| {
cx.emit(crate::ui::store_gate::RetryStorePersistence);
})),
)
}),
),
)
}
Expand Down Expand Up @@ -7931,7 +7942,7 @@ impl ChatScreen {
)
})
.when_some(self.runtime_error.clone(), |column, error| {
column.child(widgets::banner(theme::status_error()).child(error))
column.child(widgets::banner(widgets::Tone::Error, error))
})
.children(self.render_persistence_failure(cx))
.children(self.render_notice(cx)),
Expand Down
103 changes: 90 additions & 13 deletions apps/maple-agent/app/src/ui/chat/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12664,22 +12664,21 @@ mod state_tests {
width before truncating (title {thinking_title:?}, card {card:?})"
);
}

#[gpui::test]
fn out_of_usage_banner_opens_billing(cx: &mut TestAppContext) {
fn out_of_usage_offers_billing_from_the_composer_until_a_later_turn(cx: &mut TestAppContext) {
use std::cell::Cell;
use std::rc::Rc;

use maple_agent::agent::USAGE_LIMIT_MESSAGE;

let (chat, cx) = chat_window(cx, |chat, _cx| {
chat.replace_timeline(vec![
item("usage-1", "error", Some(USAGE_LIMIT_MESSAGE)),
item(
"other-1",
"error",
Some("Maple rejected the inference request (status 403)"),
),
]);
let out_of_usage = vec![
user_item("user-1", "test"),
item("usage-1", "error", Some(USAGE_LIMIT_MESSAGE)),
];
let (chat, cx) = chat_window(cx, {
let timeline = out_of_usage.clone();
move |chat, _cx| chat.replace_timeline(timeline)
});
let opened = Rc::new(Cell::new(0));
let observed = opened.clone();
Expand All @@ -12692,11 +12691,89 @@ mod state_tests {
},
)
});
assert!(cx.debug_bounds("open-billing-usage-1").is_some());
assert!(cx.debug_bounds("open-billing-other-1").is_none());
press(cx, "open-billing-usage-1");
press(cx, "composer-open-billing");
cx.run_until_parked();
assert_eq!(opened.get(), 1);
// The transcript keeps the turn as its one-line marker row.
assert!(cx.debug_bounds("timeline-row-usage-1").is_some());

let offered = |cx: &mut gpui::VisualTestContext| {
cx.update(|window, _| window.refresh());
cx.run_until_parked();
cx.debug_bounds("composer-open-billing").is_some()
};

// A message sent since, or a next turn that failed for another
// reason, takes the offer away; the transcript keeps the history.
let retried = [user_item("user-2", "try again")];
let failed_otherwise = [
user_item("user-2", "try again"),
item(
"other-1",
"error",
Some("Maple rejected the inference request (status 403)"),
),
];
for later in [&retried[..], &failed_otherwise[..]] {
let mut timeline = out_of_usage.clone();
timeline.extend_from_slice(later);
chat.update(cx, |chat, cx| {
chat.replace_timeline(timeline);
cx.notify();
});
assert!(!offered(cx));
}

// The refusal usually arrives live as the run ends, labelled by the
// runtime rather than matched by its text.
let labelled: AgentTimelineItem = serde_json::from_value(serde_json::json!({
"id": "usage-2", "itemType": "error", "createdMs": 1, "merge": "replace",
"text": "opaque retained diagnostic", "label": {"kind": "error-usage"}
}))
.unwrap();
chat.update(cx, |chat, cx| {
chat.replace_timeline(vec![user_item("user-1", "test")]);
chat.apply_incoming_item("s1", labelled, cx);
cx.notify();
});
assert!(offered(cx));

// Nor while a run is active, or while the next message is on its way.
chat.update(cx, |chat, cx| {
chat.selected_session = Some("s1".into());
chat.active_runs.insert("s1".into(), "r1".into());
cx.notify();
});
assert!(!offered(cx));
chat.update(cx, |chat, cx| {
chat.active_runs.clear();
chat.sending_previews.insert(
"send-1".into(),
sending::SendPreview::new(Some("s1".into()), "try again", &[])
.capture_timeline(&chat.timeline),
);
cx.notify();
});
assert!(!offered(cx));
chat.update(cx, |chat, cx| {
chat.sending_previews.clear();
cx.notify();
});
assert!(offered(cx));

// A window that does not hold the tail cannot tell what came last:
// live rows past its budget, or a page back into history.
chat.update(cx, |chat, cx| {
chat.pages.live_has_more = true;
cx.notify();
});
assert!(!offered(cx));
chat.update(cx, |chat, cx| {
chat.pages.live_has_more = false;
chat.pages.after = true;
cx.notify();
});
assert!(!offered(cx));
}

#[gpui::test]
Expand Down
Loading
Loading