fix: detect low balance and prompt for top up (#7166)

Signed-off-by: raj-subhankar <subhankar.rj@gmail.com>
Co-authored-by: Douwe Osinga <douwe@squareup.com>
Co-authored-by: raj-subhankar <subhankar.rj@gmail.com>
This commit is contained in:
Michael Neale 2026-02-19 13:20:16 +11:00 committed by GitHub
parent 3600c84e4b
commit 629108d0fc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 461 additions and 103 deletions

View file

@ -45,7 +45,8 @@ use goose::conversation::message::{ActionRequiredData, Message, MessageContent};
use rustyline::EditMode;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::io::IsTerminal;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Instant;
@ -967,6 +968,7 @@ impl CliSession {
let mut progress_bars = output::McpSpinners::new();
let cancel_token_clone = cancel_token.clone();
let mut markdown_buffer = streaming_buffer::MarkdownBuffer::new();
let mut prompted_credits_urls: HashSet<String> = HashSet::new();
let mut thinking_header_shown = false;
use futures::StreamExt;
@ -1041,6 +1043,11 @@ impl CliSession {
emit_stream_event(&StreamEvent::Message { message: message.clone() });
} else if !is_json_mode {
output::render_message_streaming(&message, &mut markdown_buffer, &mut thinking_header_shown, self.debug);
maybe_open_credits_top_up_url(
&message,
interactive,
&mut prompted_credits_urls,
);
}
}
}
@ -1452,6 +1459,37 @@ impl CliSession {
}
}
fn maybe_open_credits_top_up_url(
message: &Message,
interactive: bool,
prompted_credits_urls: &mut HashSet<String>,
) {
if !interactive || !std::io::stdout().is_terminal() {
return;
}
let Some(url) = output::get_credits_top_up_url(message) else {
return;
};
if !prompted_credits_urls.insert(url.clone()) {
return;
}
let should_open = cliclack::confirm("Open the top-up URL in your browser?")
.initial_value(false)
.interact()
.unwrap_or(false);
if should_open && webbrowser::open(&url).is_err() {
output::render_text(
"Could not open browser automatically. Visit the URL above.",
Some(Color::Yellow),
true,
);
}
}
fn emit_stream_event(event: &StreamEvent) {
if let Ok(json) = serde_json::to_string(event) {
println!("{}", json);

View file

@ -3,7 +3,8 @@ use bat::WrappingMode;
use console::{measure_text_width, style, Color, Term};
use goose::config::Config;
use goose::conversation::message::{
ActionRequiredData, Message, MessageContent, ToolRequest, ToolResponse,
ActionRequiredData, Message, MessageContent, SystemNotificationContent, SystemNotificationType,
ToolRequest, ToolResponse,
};
use goose::providers::canonical::maybe_get_canonical_model;
#[cfg(target_os = "windows")]
@ -245,8 +246,6 @@ pub fn render_message(message: &Message, debug: bool) {
print_markdown("Thinking was redacted", theme);
}
MessageContent::SystemNotification(notification) => {
use goose::conversation::message::SystemNotificationType;
match notification.notification_type {
SystemNotificationType::ThinkingMessage => {
show_thinking();
@ -256,6 +255,9 @@ pub fn render_message(message: &Message, debug: bool) {
hide_thinking();
println!("\n{}", style(&notification.msg).yellow());
}
SystemNotificationType::CreditsExhausted => {
render_credits_exhausted_notification(notification);
}
}
}
_ => {
@ -329,8 +331,6 @@ pub fn render_message_streaming(
print_markdown("Thinking was redacted", theme);
}
MessageContent::SystemNotification(notification) => {
use goose::conversation::message::SystemNotificationType;
match notification.notification_type {
SystemNotificationType::ThinkingMessage => {
show_thinking();
@ -341,6 +341,10 @@ pub fn render_message_streaming(
hide_thinking();
println!("\n{}", style(&notification.msg).yellow());
}
SystemNotificationType::CreditsExhausted => {
flush_markdown_buffer(buffer, theme);
render_credits_exhausted_notification(notification);
}
}
}
_ => {
@ -353,6 +357,40 @@ pub fn render_message_streaming(
let _ = std::io::stdout().flush();
}
fn render_credits_exhausted_notification(notification: &SystemNotificationContent) {
hide_thinking();
println!("\n{}", style(&notification.msg).yellow());
if let Some(url) = notification
.data
.as_ref()
.and_then(|d| d.get("top_up_url"))
.and_then(|v| v.as_str())
{
println!(
"{}",
style(format!("Visit this URL to top up credits: {url}")).yellow()
);
}
}
pub fn get_credits_top_up_url(message: &Message) -> Option<String> {
message.content.iter().find_map(|content| {
let MessageContent::SystemNotification(notification) = content else {
return None;
};
if notification.notification_type != SystemNotificationType::CreditsExhausted {
return None;
}
notification
.data
.as_ref()
.and_then(|d| d.get("top_up_url"))
.and_then(|v| v.as_str())
.map(str::to_string)
})
}
pub fn flush_markdown_buffer(buffer: &mut MarkdownBuffer, theme: Theme) {
let remaining = buffer.flush();
if !remaining.is_empty() {
@ -1434,6 +1472,7 @@ impl McpSpinners {
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::env;
#[test]
@ -1501,4 +1540,27 @@ mod tests {
"/v/l/p/w/m/components/file.txt"
);
}
#[test]
fn test_get_credits_top_up_url_from_credits_notification() {
let message = Message::assistant().with_system_notification_with_data(
SystemNotificationType::CreditsExhausted,
"Insufficient credits",
json!({"top_up_url": "https://router.tetrate.ai/billing"}),
);
assert_eq!(
get_credits_top_up_url(&message).as_deref(),
Some("https://router.tetrate.ai/billing")
);
}
#[test]
fn test_get_credits_top_up_url_ignores_non_credits_notification() {
let message = Message::assistant().with_system_notification_with_data(
SystemNotificationType::InlineMessage,
"hello",
json!({"top_up_url": "https://router.tetrate.ai/billing"}),
);
assert_eq!(get_credits_top_up_url(&message), None);
}
}

View file

@ -1500,6 +1500,29 @@ impl Agent {
}
}
}
Err(ref provider_err @ ProviderError::CreditsExhausted { details: _, ref top_up_url }) => {
crate::posthog::emit_error(provider_err.telemetry_type(), &provider_err.to_string());
error!("Error: {}", provider_err);
let user_msg = if top_up_url.is_some() {
"Please add credits to your account, then resend your message to continue.".to_string()
} else {
"Please check your account with your provider to add more credits, then resend your message to continue.".to_string()
};
let notification_data = serde_json::json!({
"top_up_url": top_up_url,
});
yield AgentEvent::Message(
Message::assistant().with_system_notification_with_data(
SystemNotificationType::CreditsExhausted,
user_msg,
notification_data,
)
);
break;
}
Err(ref provider_err) => {
crate::posthog::emit_error(provider_err.telemetry_type(), &provider_err.to_string());
error!("Error: {}", provider_err);

View file

@ -164,6 +164,7 @@ pub struct FrontendToolRequest {
pub enum SystemNotificationType {
ThinkingMessage,
InlineMessage,
CreditsExhausted,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]

View file

@ -30,6 +30,12 @@ pub enum ProviderError {
#[error("Unsupported operation: {0}")]
NotImplemented(String),
#[error("Credits exhausted: {details}")]
CreditsExhausted {
details: String,
top_up_url: Option<String>,
},
}
impl ProviderError {
@ -43,6 +49,7 @@ impl ProviderError {
ProviderError::ExecutionError(_) => "execution",
ProviderError::UsageError(_) => "usage",
ProviderError::NotImplemented(_) => "not_implemented",
ProviderError::CreditsExhausted { .. } => "credits_exhausted",
}
}
}

View file

@ -176,6 +176,10 @@ pub fn map_http_error_to_provider_error(
StatusCode::NOT_FOUND => {
ProviderError::RequestFailed(format!("Resource not found (404): {}", extract_message()))
}
StatusCode::PAYMENT_REQUIRED => ProviderError::CreditsExhausted {
details: extract_message(),
top_up_url: None,
},
StatusCode::PAYLOAD_TOO_LARGE => ProviderError::ContextLengthExceeded(extract_message()),
StatusCode::BAD_REQUEST => {
let payload_str = extract_message();
@ -251,3 +255,67 @@ pub fn stream_openai_compat(
}
}))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use test_case::test_case;
#[test_case(
StatusCode::PAYMENT_REQUIRED,
Some(json!({"error": {"message": "Insufficient credits to complete this request"}})),
"CreditsExhausted"
; "402 with payload"
)]
#[test_case(
StatusCode::PAYMENT_REQUIRED,
None,
"CreditsExhausted"
; "402 without payload"
)]
#[test_case(
StatusCode::TOO_MANY_REQUESTS,
Some(json!({"error": {"message": "Rate limit exceeded"}})),
"RateLimitExceeded"
; "429 rate limit"
)]
#[test_case(
StatusCode::UNAUTHORIZED,
None,
"Authentication"
; "401 unauthorized"
)]
#[test_case(
StatusCode::BAD_REQUEST,
Some(json!({"error": {"message": "This request exceeds the maximum context length"}})),
"ContextLengthExceeded"
; "400 context length"
)]
#[test_case(
StatusCode::INTERNAL_SERVER_ERROR,
None,
"ServerError"
; "500 server error"
)]
fn http_status_maps_to_expected_error(
status: StatusCode,
payload: Option<Value>,
expected_variant: &str,
) {
let err = map_http_error_to_provider_error(status, payload);
let actual = err.telemetry_type();
let expected_telemetry = match expected_variant {
"CreditsExhausted" => "credits_exhausted",
"RateLimitExceeded" => "rate_limit",
"Authentication" => "auth",
"ContextLengthExceeded" => "context_length",
"ServerError" => "server",
other => panic!("Unknown variant: {other}"),
};
assert_eq!(
actual, expected_telemetry,
"Expected {expected_variant}, got error: {err:?}"
);
}
}

View file

@ -1,7 +1,10 @@
use super::api_client::{ApiClient, AuthMethod};
use super::base::{ConfigKey, MessageStream, Provider, ProviderDef, ProviderMetadata};
use super::errors::ProviderError;
use super::openai_compatible::{handle_status_openai_compat, stream_openai_compat};
use super::openai_compatible::{
handle_response_openai_compat, handle_status_openai_compat, map_http_error_to_provider_error,
stream_openai_compat,
};
use super::retry::ProviderRetry;
use super::utils::RequestLog;
use crate::config::signup_tetrate::TETRATE_DEFAULT_MODEL;
@ -13,9 +16,12 @@ use futures::future::BoxFuture;
use crate::model::ModelConfig;
use crate::providers::formats::openai::create_request;
use rmcp::model::Tool;
use serde_json::Value;
const TETRATE_PROVIDER_NAME: &str = "tetrate";
// Tetrate Agent Router Service can run many models, we suggest the default
pub const TETRATE_DOC_URL: &str = "https://router.tetrate.ai";
pub const TETRATE_BILLING_URL: &str = "https://router.tetrate.ai/billing";
pub const TETRATE_KNOWN_MODELS: &[&str] = &[
"claude-opus-4-1",
"claude-3-7-sonnet-latest",
@ -28,7 +34,6 @@ pub const TETRATE_KNOWN_MODELS: &[&str] = &[
"gpt-5-nano",
"gpt-4.1",
];
pub const TETRATE_DOC_URL: &str = "https://router.tetrate.ai";
#[derive(serde::Serialize)]
pub struct TetrateProvider {
@ -44,7 +49,6 @@ impl TetrateProvider {
pub async fn from_env(model: ModelConfig) -> Result<Self> {
let config = crate::config::Config::global();
let api_key: String = config.get_secret("TETRATE_API_KEY")?;
// API host for LLM endpoints (/v1/chat/completions, /v1/models)
let host: String = config
.get_param("TETRATE_HOST")
.unwrap_or_else(|_| "https://api.router.tetrate.ai".to_string());
@ -61,6 +65,27 @@ impl TetrateProvider {
name: TETRATE_PROVIDER_NAME.to_string(),
})
}
fn enrich_credits_error(err: ProviderError) -> ProviderError {
match err {
ProviderError::CreditsExhausted { details, .. } => ProviderError::CreditsExhausted {
details,
top_up_url: Some(TETRATE_BILLING_URL.to_string()),
},
other => other,
}
}
fn error_from_tetrate_error_payload(payload: Value) -> ProviderError {
let code = payload
.get("error")
.and_then(|e| e.get("code"))
.and_then(|c| c.as_u64())
.unwrap_or(500) as u16;
let status = reqwest::StatusCode::from_u16(code)
.unwrap_or(reqwest::StatusCode::INTERNAL_SERVER_ERROR);
Self::enrich_credits_error(map_http_error_to_provider_error(status, Some(payload)))
}
}
impl ProviderDef for TetrateProvider {
@ -130,7 +155,34 @@ impl Provider for TetrateProvider {
.api_client
.response_post(Some(session_id), "v1/chat/completions", &payload)
.await?;
handle_status_openai_compat(resp).await
let resp = handle_status_openai_compat(resp)
.await
.map_err(Self::enrich_credits_error)?;
let is_json = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(|v| v.to_ascii_lowercase())
.is_some_and(|v| v.contains("json"));
if is_json {
// Streaming responses should be SSE; when we get JSON instead, parse it to map
// explicit error payloads and otherwise fail as a protocol mismatch.
let body = handle_response_openai_compat(resp)
.await
.map_err(Self::enrich_credits_error)?;
if body.get("error").is_some() {
return Err(Self::error_from_tetrate_error_payload(body));
}
return Err(ProviderError::ExecutionError(
"Expected streaming response but received non-streaming payload"
.to_string(),
));
}
Ok(resp)
})
.await
.inspect_err(|e| {
@ -140,69 +192,96 @@ impl Provider for TetrateProvider {
stream_openai_compat(response, log)
}
/// Fetch supported models from Tetrate Agent Router Service API (only models with tool support)
/// Fetch supported models from Tetrate Agent Router Service API
async fn fetch_supported_models(&self) -> Result<Vec<String>, ProviderError> {
// Use the existing api_client which already has authentication configured
let response = match self
let response = self
.api_client
.request(None, "v1/models")
.response_get()
.response_get(None, "v1/models")
.await
{
Ok(response) => response,
Err(e) => {
return Err(ProviderError::ExecutionError(format!(
"Failed to fetch models from Tetrate API: {}. Please check your API key and account at {}",
e, TETRATE_DOC_URL
)));
}
};
.map_err(|e| ProviderError::RequestFailed(e.to_string()))?;
let json = handle_response_openai_compat(response).await?;
let json: serde_json::Value = response.json().await.map_err(|e| {
ProviderError::ExecutionError(format!(
"Failed to parse Tetrate API response: {}. Please check your API key and account at {}",
e, TETRATE_DOC_URL
))
})?;
// Check for error in response
if let Some(err_obj) = json.get("error") {
let msg = err_obj
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("unknown error");
return Err(ProviderError::ExecutionError(format!(
"Tetrate API error: {}. Please check your API key and account at {}",
msg, TETRATE_DOC_URL
)));
// Tetrate can return errors in 200 OK responses, so check explicitly
if json.get("error").is_some() {
return Err(Self::error_from_tetrate_error_payload(json));
}
// The response format from /v1/models is expected to be OpenAI-compatible
// It should have a "data" field with an array of model objects
let data = json.get("data").and_then(|v| v.as_array()).ok_or_else(|| {
ProviderError::ExecutionError(format!(
"Tetrate API response missing 'data' field. Please check your API key and account at {}",
TETRATE_DOC_URL
))
let arr = json.get("data").and_then(|v| v.as_array()).ok_or_else(|| {
ProviderError::RequestFailed("Missing 'data' array in models response".to_string())
})?;
let mut models: Vec<String> = data
let mut models: Vec<String> = arr
.iter()
.filter_map(|model| {
let id = model.get("id").and_then(|v| v.as_str())?;
let supports_computer_use = model
.get("supports_computer_use")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if supports_computer_use {
Some(id.to_string())
} else {
None
}
})
.filter_map(|m| m.get("id").and_then(|v| v.as_str()).map(str::to_string))
.collect();
models.sort();
Ok(models)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn enrich_adds_dashboard_url() {
let err = ProviderError::CreditsExhausted {
details: "out of credits".to_string(),
top_up_url: None,
};
match TetrateProvider::enrich_credits_error(err) {
ProviderError::CreditsExhausted { top_up_url, .. } => {
assert_eq!(
top_up_url.as_deref(),
Some("https://router.tetrate.ai/billing")
);
}
_ => panic!("Expected CreditsExhausted variant"),
}
}
#[test]
fn enrich_passes_through_other_errors() {
let err = ProviderError::ServerError("boom".to_string());
assert!(matches!(
TetrateProvider::enrich_credits_error(err),
ProviderError::ServerError(_)
));
}
#[test]
fn error_payload_maps_credits_and_adds_billing_url() {
let payload = json!({
"error": {
"code": 402,
"message": "Insufficient credits"
}
});
match TetrateProvider::error_from_tetrate_error_payload(payload) {
ProviderError::CreditsExhausted {
details,
top_up_url,
} => {
assert!(details.contains("Insufficient credits"));
assert_eq!(top_up_url.as_deref(), Some(TETRATE_BILLING_URL));
}
other => panic!("Expected CreditsExhausted, got {other:?}"),
}
}
#[test]
fn error_payload_maps_authentication() {
let payload = json!({
"error": {
"code": 401,
"message": "Invalid API key"
}
});
match TetrateProvider::error_from_tetrate_error_payload(payload) {
ProviderError::Authentication(msg) => {
assert!(msg.contains("Invalid API key"));
}
other => panic!("Expected Authentication, got {other:?}"),
}
}
}

View file

@ -48,18 +48,13 @@ runners:
# stdio:
# - node mcp-harness/dist/index.js
- name: goose
- name: goose-full
type: goose
bin: goose
extensions: [developer, todo, skills, code_execution, extensionmanager]
stdio:
- node mcp-harness/dist/index.js
- name: goose-diet
type: goose
bin: ~/Downloads/goose-diet
extensions: [developer]
- name: opencode
type: opencode
bin: opencode
@ -74,12 +69,6 @@ runners:
stdio:
- node mcp-harness/dist/index.js
- name: pi-lean
type: pi
bin: pi
# Pi takes provider/model from the test matrix, not config
# MCP support via pi-mcp-adapter: `pi install npm:pi-mcp-adapter`
# =============================================================================
# Test Matrix
# =============================================================================
@ -91,9 +80,6 @@ matrix:
- scenario: everyday-app-automation
- scenario: file-editing
# Feature removal: all runners
- scenario: remove-feature
# Multi-turn: goose and pi only (opencode doesn't support session continuation)
- scenario: multi-turn-edit
runners: [pi, goose]
runners: [goose-full]

View file

@ -6815,7 +6815,8 @@
"type": "string",
"enum": [
"thinkingMessage",
"inlineMessage"
"inlineMessage",
"creditsExhausted"
]
},
"TaskSupport": {

View file

@ -1167,7 +1167,7 @@ export type SystemNotificationContent = {
notificationType: SystemNotificationType;
};
export type SystemNotificationType = 'thinkingMessage' | 'inlineMessage';
export type SystemNotificationType = 'thinkingMessage' | 'inlineMessage' | 'creditsExhausted';
export type TaskSupport = string;

View file

@ -15,10 +15,17 @@
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Message } from '../api';
import { Message, SystemNotificationContent } from '../api';
import GooseMessage from './GooseMessage';
import UserMessage from './UserMessage';
import { SystemNotificationInline } from './context_management/SystemNotificationInline';
import {
SystemNotificationInline,
getInlineSystemNotification,
} from './context_management/SystemNotificationInline';
import {
CreditsExhaustedNotification,
getCreditsExhaustedNotification,
} from './context_management/CreditsExhaustedNotification';
import { NotificationEvent } from '../types/message';
import LoadingGoose from './LoadingGoose';
import { ChatType } from '../types/chat';
@ -71,11 +78,19 @@ export default function ProgressiveMessageList({
const hasOnlyToolResponses = (message: Message) =>
message.content.every((c) => c.type === 'toolResponse');
const hasInlineSystemNotification = (message: Message): boolean => {
return message.content.some(
(content) =>
content.type === 'systemNotification' && content.notificationType === 'inlineMessage'
);
const getSystemNotification = (message: Message): SystemNotificationContent | undefined => {
return getCreditsExhaustedNotification(message) ?? getInlineSystemNotification(message);
};
const renderSystemNotification = (notification: SystemNotificationContent) => {
switch (notification.notificationType) {
case 'creditsExhausted':
return <CreditsExhaustedNotification notification={notification} />;
case 'inlineMessage':
return <SystemNotificationInline notification={notification} />;
default:
return null;
}
};
// Simple progressive loading - start immediately when component mounts if needed
@ -188,15 +203,15 @@ export default function ProgressiveMessageList({
return null;
}
// System notifications are never user messages, handle them first
if (hasInlineSystemNotification(message)) {
const notification = getSystemNotification(message);
if (notification) {
return (
<div
key={message.id ?? `msg-${index}-${message.created}`}
key={`notification-${message.id ?? `msg-${index}-${message.created}`}`}
className={`relative ${index === 0 ? 'mt-0' : 'mt-4'} assistant`}
data-testid="message-container"
>
<SystemNotificationInline message={message} />
{renderSystemNotification(notification)}
</div>
);
}

View file

@ -0,0 +1,76 @@
import React from 'react';
import { AlertTriangle, ExternalLink } from 'lucide-react';
import { Message, SystemNotificationContent } from '../../api';
import { WEB_PROTOCOLS } from '../../utils/urlSecurity';
interface CreditsExhaustedNotificationProps {
notification: SystemNotificationContent;
}
function getValidatedTopUpUrl(data: unknown): string | null {
if (!data || typeof data !== 'object') {
return null;
}
const rawUrl = (data as Record<string, unknown>).top_up_url;
if (typeof rawUrl !== 'string') {
return null;
}
const url = rawUrl.trim();
if (!url) {
return null;
}
try {
const parsedUrl = new URL(url);
if (!WEB_PROTOCOLS.includes(parsedUrl.protocol)) {
return null;
}
return parsedUrl.toString();
} catch {
return null;
}
}
export const CreditsExhaustedNotification: React.FC<CreditsExhaustedNotificationProps> = ({
notification,
}) => {
const topUpUrl = getValidatedTopUpUrl(notification.data);
const handleTopUp = () => {
if (topUpUrl) {
window.electron.openExternal(topUpUrl);
}
};
return (
<div className="rounded-lg border border-yellow-600/30 dark:border-yellow-500/30 bg-yellow-500/10 dark:bg-yellow-500/10 p-4 my-2">
<div className="flex items-start gap-3">
<AlertTriangle className="h-4 w-4 text-yellow-600 dark:text-yellow-400 mt-0.5 shrink-0" />
<div className="flex-1">
<div className="text-sm font-semibold text-yellow-800 dark:text-yellow-200">Insufficient Credits</div>
<div className="text-sm text-yellow-800/80 dark:text-yellow-200/80 mt-1">{notification.msg}</div>
{topUpUrl && (
<button
onClick={handleTopUp}
className="mt-3 inline-flex items-center gap-2 rounded-md bg-yellow-600 hover:bg-yellow-500 dark:bg-yellow-700 dark:hover:bg-yellow-600 text-white text-sm font-medium px-4 py-2 transition-colors"
>
Add credits
<ExternalLink className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
</div>
);
};
export function getCreditsExhaustedNotification(
message: Message
): SystemNotificationContent | undefined {
return message.content.find(
(content): content is SystemNotificationContent & { type: 'systemNotification' } =>
content.type === 'systemNotification' && content.notificationType === 'creditsExhausted'
);
}

View file

@ -2,18 +2,20 @@ import React from 'react';
import { Message, SystemNotificationContent } from '../../api';
interface SystemNotificationInlineProps {
message: Message;
notification: SystemNotificationContent;
}
export const SystemNotificationInline: React.FC<SystemNotificationInlineProps> = ({ message }) => {
const systemNotification = message.content.find(
export const SystemNotificationInline: React.FC<SystemNotificationInlineProps> = ({
notification,
}) => {
return <div className="text-xs text-gray-400 py-2 text-left">{notification.msg}</div>;
};
export function getInlineSystemNotification(
message: Message
): SystemNotificationContent | undefined {
return message.content.find(
(content): content is SystemNotificationContent & { type: 'systemNotification' } =>
content.type === 'systemNotification' && content.notificationType === 'inlineMessage'
);
if (!systemNotification?.msg) {
return null;
}
return <div className="text-xs text-gray-400 py-2 text-left">{systemNotification.msg}</div>;
};
}