fix(ui): unify bottom-right notifications and improve dialog interactions
- Move operation feedback into a shared floating stack to prevent layout shifts - Allow identical errors to reappear after dismissal - Support keyboard navigation, scrolling, and focus restoration in dialogs - Add notification interaction regression tests - Remove code comments throughout the project
This commit is contained in:
1 parent
aaa6806e26
commit
18a9bfbaa9
60 files changed
+614
-402
No files matched your search
@@ -1,4 +1,3 @@
|
||||
//! Agent client discovery, configuration generation, and reversible state management.
|
||||
|
||||
use super::*;
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
//! Explicit, immutable backups. Legacy history and dated backups are never read or migrated.
|
||||
use super::*;
|
||||
use serde_json::Value;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
@@ -78,7 +77,6 @@ pub(crate) struct BackupPreview {
|
||||
pub(crate) fn agent_data_directory(paths: &[PathBuf]) -> Result<PathBuf, String> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
// Tests cannot reach the running application's data or real client configuration.
|
||||
let temp = std::env::temp_dir();
|
||||
let relative = paths
|
||||
.first()
|
||||
@@ -107,7 +105,6 @@ fn path_identity(paths: &[PathBuf]) -> String {
|
||||
}
|
||||
|
||||
fn backup_directory(client: &str, paths: &[PathBuf]) -> Result<PathBuf, String> {
|
||||
// Only client IDs returned by config_paths reach here; validate again for internal callers.
|
||||
if client != PI_AGENT_ID {
|
||||
AgentClient::parse(client)?;
|
||||
}
|
||||
@@ -293,7 +290,6 @@ pub(crate) fn create_backup(client: &str, home: &Path) -> Result<BackupSummary,
|
||||
if config_images(&paths)? != images || mapping_revision(client, &paths)? != state_revision {
|
||||
return Err("备份期间配置发生变化,请重试".into());
|
||||
}
|
||||
// Raw bytes are deliberately saved even if parsing fails.
|
||||
write_version(&paths, &version)?;
|
||||
backup_summary(client, &paths, &version.id)
|
||||
}
|
||||
@@ -332,7 +328,6 @@ pub(crate) fn list_backups(client: &str, home: &Path) -> Result<BackupList, Stri
|
||||
pub(crate) fn delete_backup(client: &str, home: &Path, id: &str) -> Result<(), String> {
|
||||
let paths = config_paths(client, home)?;
|
||||
let path = version_path(client, &paths, id)?;
|
||||
// Check parents, then unlink only this entry. A damaged or symlinked package can be deleted safely.
|
||||
validate_config_path(&path.parent().ok_or("备份目录无效")?.join(".path-check"))?;
|
||||
fs::remove_file(&path).map_err(|_| "删除手动备份失败,请刷新列表后重试".to_string())
|
||||
}
|
||||
@@ -347,7 +342,6 @@ fn preview(
|
||||
validate_restorable(&version)?;
|
||||
let current = config_images(paths)?;
|
||||
let after = backup_images(&version);
|
||||
// Entire package hash binds preview to bytes, existence, paths and Desktop mappings.
|
||||
if read_agent_bytes(&version_path(client, paths, id)?)?.as_deref() != Some(&package) {
|
||||
return Err("备份在预览期间发生变化,请重新预览".into());
|
||||
}
|
||||
|
||||
@@ -146,7 +146,6 @@ fn codex_catalog_reset_removes_managed_fields_and_preserves_extensions_on_disk()
|
||||
.unwrap();
|
||||
let defaults = codex_catalog::prepare_catalog(&runtime).unwrap();
|
||||
let mut expected: Value = serde_json::from_str(&defaults.json).unwrap();
|
||||
// The editor restores 95% by dropping its override on this official model.
|
||||
assert!(expected["models"][0]
|
||||
.get("effective_context_window_percent")
|
||||
.is_none());
|
||||
@@ -787,7 +786,6 @@ fn nested_model_options_and_third_party_providers_survive_all_updates() {
|
||||
}
|
||||
save(path, render(path, &root).unwrap());
|
||||
}
|
||||
// Change credentials without reordering the selected model inventory.
|
||||
let catalog = catalog("gpt-one");
|
||||
let mappings = ClaudeDesktopModelMappings::all("gpt-one");
|
||||
apply_agent_configuration_with_oauth(
|
||||
|
||||
@@ -439,7 +439,7 @@ fn validate_deepseek_harness_versioned_credentials(
|
||||
let Some(key) = key.as_str() else {
|
||||
return Err(format!("{label} 顶层字段名必须是字符串"));
|
||||
};
|
||||
let _ = key; // Preserve extension fields from the client and other integrations.
|
||||
let _ = key;
|
||||
}
|
||||
for section in ["refs", "records"] {
|
||||
if yaml_mapping_value(root, section).is_some_and(|value| !value.is_mapping()) {
|
||||
@@ -460,8 +460,6 @@ fn deepseek_harness_credentials_refs_mut<'a>(
|
||||
);
|
||||
} else {
|
||||
validate_deepseek_harness_versioned_credentials(root, label)?;
|
||||
// Repair files written by older EasyCLIProxyAPI builds, which placed
|
||||
// this managed reference beside version/refs/records.
|
||||
root.remove(yaml_key(DEEPSEEK_HARNESS_CREDENTIAL));
|
||||
}
|
||||
|
||||
@@ -2982,8 +2980,6 @@ fn build_zcode_config(
|
||||
})
|
||||
.collect::<serde_json::Map<_, _>>();
|
||||
let managed_provider = ensure_json_object_entry(providers, MANAGED_AGENT_PROVIDER_ID);
|
||||
// `npm` is an OpenCode provider field. ZCode treats it as an incompatible
|
||||
// provider definition, so also remove values written by earlier versions.
|
||||
managed_provider.remove("npm");
|
||||
managed_provider.insert("enabled".to_string(), serde_json::json!(true));
|
||||
managed_provider.insert("name".to_string(), serde_json::json!("EasyCLIProxyAPI"));
|
||||
|
||||
@@ -826,10 +826,6 @@ pub(crate) fn claude_desktop_config_paths_from_directories(
|
||||
pub(crate) fn claude_desktop_config_paths_from_local_app_data(
|
||||
local_app_data: &Path,
|
||||
) -> Vec<PathBuf> {
|
||||
// Claude Desktop may add a channel or version suffix to these directories.
|
||||
// Match cc-switch's resolver so status detection and configuration writes use
|
||||
// the same files as the installed Desktop client rather than only the legacy
|
||||
// fixed `Claude` / `Claude-3p` locations.
|
||||
let normal = find_windows_claude_data_directory(local_app_data, false)
|
||||
.unwrap_or_else(|| local_app_data.join("Claude"));
|
||||
let threep = find_windows_claude_data_directory(local_app_data, true)
|
||||
@@ -908,9 +904,6 @@ pub(crate) fn inspect_agent_config(
|
||||
Err(_) => (false, None, false, false, Some("配置读取或解析失败,请检查文件权限,或使用手动备份恢复、基础配置模板修复".to_string())),
|
||||
};
|
||||
let executable = find_agent_executable(client, home);
|
||||
// Desktop application executables are not CLIs. Invoking them with
|
||||
// --version can start their GUI and block discovery, so never probe them
|
||||
// directly. ZCode may still expose a separate command-line entry point.
|
||||
let cli_version = if client == AgentClient::ZCode {
|
||||
find_named_agent_executable(home, &["zcode"])
|
||||
.filter(|path| executable.as_ref() != Some(path))
|
||||
@@ -966,7 +959,6 @@ pub(crate) fn inspect_agent_config(
|
||||
}
|
||||
|
||||
let configuration_synchronized = agent_configuration_is_synchronized(client, home, configured);
|
||||
// Legacy backup state never participates in current configuration discovery.
|
||||
|
||||
AgentConfigStatus {
|
||||
id: client.id().to_string(),
|
||||
@@ -1213,8 +1205,6 @@ pub(crate) fn inspect_agent_managed_config(
|
||||
}
|
||||
}
|
||||
|
||||
// Configuration health and evidence of CPA integration are separate facts.
|
||||
// A saved history version alone does not mean the current files still use CPA.
|
||||
pub(crate) fn agent_connection_state(configured: bool, valid: bool, managed: Result<bool, String>) -> &'static str {
|
||||
if !valid || managed.is_err() { "invalid" }
|
||||
else if configured { "configured" }
|
||||
@@ -1225,7 +1215,6 @@ pub(crate) fn agent_connection_state(configured: bool, valid: bool, managed: Res
|
||||
pub(crate) fn agent_has_connection_evidence(client: AgentClient, paths: &[PathBuf]) -> Result<bool, String> {
|
||||
let active_marker = agent_has_managed_marker(client, paths)?;
|
||||
if active_marker { return Ok(true); }
|
||||
// Recognize partial configurations too, even if the selected provider/model was removed.
|
||||
for path in paths {
|
||||
let Some(bytes) = read_agent_bytes(path)? else { continue; };
|
||||
let content = std::str::from_utf8(&bytes).map_err(|e| e.to_string())?;
|
||||
@@ -2020,7 +2009,6 @@ pub(crate) fn read_codex_app_installation_version(
|
||||
pub(crate) fn read_zcode_app_version(executable: &Path) -> Option<String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// Missing version metadata must not cause the desktop app to be launched.
|
||||
read_windows_executable_version(executable)
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -2093,8 +2081,6 @@ pub(crate) fn find_zcode_desktop_executable(home: &Path) -> Option<PathBuf> {
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn find_windows_registered_zcode_executable() -> Option<PathBuf> {
|
||||
// Electron/NSIS permits arbitrary installation directories. Its uninstall
|
||||
// record may omit InstallLocation and point DisplayIcon at an .ico file.
|
||||
const DISCOVERY_SCRIPT: &str = r#"
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
@@ -2172,7 +2158,6 @@ pub(crate) fn parse_windows_zcode_discovery_output(output: &str) -> Option<PathB
|
||||
let value = if let Some(quoted) = value.strip_prefix('"') {
|
||||
quoted.split_once('"')?.0
|
||||
} else if kind == "uninstaller" {
|
||||
// Never execute UninstallString; only use its executable's directory.
|
||||
let end = value.to_ascii_lowercase().match_indices(".exe").find_map(
|
||||
|(index, extension)| {
|
||||
let end = index + extension.len();
|
||||
@@ -2381,8 +2366,6 @@ pub(crate) fn read_windows_codex_desktop_version(executable: &Path) -> Option<St
|
||||
.and_then(|content| parse_codex_owl_app_version(&content))
|
||||
.or_else(|| read_codex_asar_version(&asar))
|
||||
.or_else(|| {
|
||||
// Owl's EXE reports the Chromium runtime version, not the Codex
|
||||
// version. Never launch a desktop executable with --version either.
|
||||
if owl_ini.exists() || asar.exists() || resources.join("owl-electron-app.json").exists()
|
||||
{
|
||||
None
|
||||
@@ -2417,9 +2400,6 @@ pub(crate) fn parse_codex_owl_app_version(content: &str) -> Option<String> {
|
||||
pub(crate) fn read_codex_asar_version(path: &Path) -> Option<String> {
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
|
||||
// ASAR starts with two Chromium pickles: a header-size pickle followed by
|
||||
// a JSON header pickle. Read only the header and the root package.json,
|
||||
// not the hundreds of MB of bundled application code.
|
||||
let mut file = fs::File::open(path).ok()?;
|
||||
let mut prefix = [0_u8; 16];
|
||||
file.read_exact(&mut prefix).ok()?;
|
||||
|
||||
@@ -421,7 +421,6 @@ fn restart_managed_deepseek_harness_with(
|
||||
stop: impl FnOnce(&mut Child) -> Result<(), String>,
|
||||
spawn: impl FnOnce(&Path, &Path, &[String]) -> Result<Child, String>,
|
||||
) -> Result<DeepSeekHarnessProcessStatus, String> {
|
||||
// Keep the lock across stop and spawn so launch/stop/status cannot interleave.
|
||||
let mut process = process_state
|
||||
.process
|
||||
.lock()
|
||||
@@ -442,13 +441,11 @@ fn restart_managed_deepseek_harness_with(
|
||||
return Err("只有 Web 模式支持重启".to_string());
|
||||
}
|
||||
let launch = managed.launch.clone();
|
||||
// Validate paths before shutting down a working service.
|
||||
if !launch.executable.is_file() || !launch.working_directory.is_dir() {
|
||||
return Err("DeepSeek Harness 启动程序或工作目录已不存在".to_string());
|
||||
}
|
||||
stop(&mut managed.child)?;
|
||||
*process = None;
|
||||
// The old process must release its endpoint before the replacement starts.
|
||||
ensure_deepseek_harness_web_endpoint_available(launch.options.as_ref())?;
|
||||
let child = spawn(
|
||||
&launch.executable,
|
||||
@@ -1146,8 +1143,6 @@ fn linux_process_matches_application(process_id: i32, application: &Path) -> boo
|
||||
.map(|argument| Path::new(OsStr::from_bytes(argument)))
|
||||
.enumerate()
|
||||
.any(|(index, argument)| {
|
||||
// An unrelated CLI may mention the desktop executable as an input file.
|
||||
// Only argv[0] identifies a launcher; retain Nix wrapper discovery below.
|
||||
(index == 0 && linux_process_path_matches(argument, application))
|
||||
|| installation_root.is_some_and(|root| {
|
||||
fs::canonicalize(argument)
|
||||
@@ -1666,7 +1661,6 @@ mod tests {
|
||||
processes.children[1].try_wait().unwrap().is_none(),
|
||||
"unrelated installation must keep running"
|
||||
);
|
||||
// Stopping an installation with no running processes succeeds as well.
|
||||
stop_other_desktop(&installation, "Test Desktop").unwrap();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
//! Generate and validate the entire template before presenting any destructive confirmation.
|
||||
use super::*;
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -86,8 +85,6 @@ pub(crate) async fn prepare_desktop_core_update(
|
||||
Ok((before, after))
|
||||
}
|
||||
|
||||
// Management API/parser errors may quote configuration contents. Keep actionable transaction
|
||||
// outcomes without forwarding response bodies, YAML source lines or credentials to the UI.
|
||||
pub(crate) fn agent_core_error(error: String) -> String {
|
||||
if error.contains("自动恢复失败") || error.contains("回滚失败") {
|
||||
"内核别名或智能体配置写入失败且回滚失败,请检查当前配置".into()
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
//! Configuration transactions use only in-memory rollback images. No backup I/O.
|
||||
use super::*;
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -24,7 +23,6 @@ pub(crate) fn config_paths(client: &str, home: &Path) -> Result<Vec<PathBuf>, St
|
||||
))
|
||||
}
|
||||
|
||||
// Reject links (including Windows junctions) in every existing path component.
|
||||
pub(crate) fn validate_config_path(path: &Path) -> Result<(), String> {
|
||||
if !path.is_absolute()
|
||||
|| path
|
||||
@@ -223,8 +221,6 @@ fn commit_config_transaction(
|
||||
if config_images(&all_paths)? != previous {
|
||||
return Err("配置已被其他程序修改,请刷新后重试".into());
|
||||
}
|
||||
// Check each file again immediately before writing. Track only files this operation touched
|
||||
// so a conflict on a later file cannot roll back someone else's edit to that file.
|
||||
let mut attempted = Vec::new();
|
||||
let result = (if checked {
|
||||
(|| {
|
||||
@@ -252,12 +248,10 @@ fn commit_config_transaction(
|
||||
}
|
||||
});
|
||||
if result.is_err() {
|
||||
// Do not expose arbitrary writer errors: parsers and external tools may include credentials.
|
||||
let rollback = (if checked {
|
||||
let mut failed = false;
|
||||
for (index, observed) in attempted.into_iter().rev() {
|
||||
let (path, _) = &previous[index];
|
||||
// A file changed after our write: leave that external edit intact.
|
||||
if observed.is_err() || read_agent_bytes(path) != observed {
|
||||
failed = true;
|
||||
continue;
|
||||
@@ -373,8 +367,6 @@ pub(crate) fn prepare_config_updates(
|
||||
Ok(after)
|
||||
}
|
||||
|
||||
// Model inventories are refreshed from CPA, but matching model entries may have client-specific
|
||||
// nested options. Keep those options while allowing unavailable managed models to disappear.
|
||||
fn preserve_model_extensions(client: &str, path: &Path, before: &Value, after: &mut Value) {
|
||||
fn fill_missing(before: &Value, after: &mut Value) {
|
||||
if let (Some(old), Some(new)) = (before.as_object(), after.as_object_mut()) {
|
||||
@@ -590,7 +582,6 @@ fn validate_unmanaged_preserved(
|
||||
None,
|
||||
);
|
||||
set(&mut value, &[DEEPSEEK_HARNESS_CREDENTIAL.into()], None);
|
||||
// The credentials format requires version 1 when initially created.
|
||||
if value.get("version") == Some(&serde_json::json!(1)) {
|
||||
set(&mut value, &["version".into()], None);
|
||||
}
|
||||
|
||||
@@ -894,9 +894,6 @@ pub(crate) async fn download_and_stage_portable_app_update(
|
||||
work_dir: work_dir.clone(),
|
||||
target_version: pending.version.clone(),
|
||||
};
|
||||
// Keep the updater inside its signed application bundle. Copying the Mach-O
|
||||
// executable into a temporary directory strips the bundle context Gatekeeper
|
||||
// uses to validate it and can cause macOS to terminate it before it starts.
|
||||
let helper_path = macos_update_helper_path(¤t_exe);
|
||||
launch_portable_update_helper(app, &helper_path, &work_dir, &descriptor).await
|
||||
}
|
||||
|
||||
@@ -77,8 +77,6 @@ pub(crate) fn validate_embedded_catalog() -> Result<(), String> {
|
||||
catalog_state().map(|_| ())
|
||||
}
|
||||
|
||||
// An omitted managed field means "use the Codex default", not "keep the disk value".
|
||||
// Derive ownership from the bundled schemas and editor so optional fields stay in sync.
|
||||
pub(crate) fn is_managed_model_field(field: &str) -> bool {
|
||||
static FIELDS: OnceLock<HashSet<String>> = OnceLock::new();
|
||||
FIELDS
|
||||
@@ -608,8 +606,6 @@ fn normalize_fallback_model(model: &mut Map<String, Value>) {
|
||||
}
|
||||
}
|
||||
|
||||
// Both official and fallback templates take context defaults from the CPA API.
|
||||
// Template values are used only when the API provides no valid context metadata.
|
||||
fn apply_runtime_context_windows(model: &mut Map<String, Value>, runtime: &CodexRuntimeModel) {
|
||||
if let Some(context_window) = runtime.context_window.or(runtime.max_context_window) {
|
||||
let max_context_window = runtime
|
||||
|
||||
@@ -66,8 +66,6 @@ fn editable_configuration(model: &Map<String, Value>) -> Map<String, Value> {
|
||||
(
|
||||
field.to_string(),
|
||||
model.get(*field).cloned().unwrap_or_else(|| {
|
||||
// Codex defaults this optional field to 95 when it is omitted.
|
||||
// The editor needs the same value to validate and save context changes.
|
||||
if *field == "effective_context_window_percent" {
|
||||
Value::from(95)
|
||||
} else {
|
||||
@@ -517,7 +515,6 @@ mod tests {
|
||||
256_000
|
||||
);
|
||||
|
||||
// Restoring defaults drops the saved override and follows the current API response.
|
||||
state.customizations = customizations_from_request(
|
||||
&updated,
|
||||
CatalogEditorRequest {
|
||||
|
||||
@@ -332,8 +332,6 @@ pub(crate) fn ensure_claude_desktop_model_aliases_with_oauth_definitions_in_yaml
|
||||
.as_mapping_mut()
|
||||
.ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?;
|
||||
|
||||
// All roles resolve against the same input. Replacing one old role must not erase
|
||||
// the only source record needed to configure another role.
|
||||
let sources = root.clone();
|
||||
for (alias, source_model) in [
|
||||
(CLAUDE_DESKTOP_OPUS_MODEL_ID, mappings.opus.as_str()),
|
||||
@@ -654,8 +652,6 @@ fn resolve_claude_desktop_alias_source(
|
||||
root: &serde_norway::Mapping,
|
||||
source_model: &str,
|
||||
) -> Result<Option<ClaudeDesktopAliasSource>, String> {
|
||||
// Prefer an exact client-visible model. Legacy configurations may only retain
|
||||
// an aliased entry, whose upstream name still identifies its configured source.
|
||||
for upstream in [false, true] {
|
||||
let matching_model = |model: &serde_norway::Value| {
|
||||
let (name, client_model, _) = configured_model_identity(model)?;
|
||||
|
||||
@@ -666,9 +666,6 @@ pub(crate) fn write_bytes_atomically(path: &Path, content: &[u8]) -> Result<(),
|
||||
let mut file = File::create(&temporary_path)?;
|
||||
file.write_all(content)?;
|
||||
file.sync_all()?;
|
||||
// ReplaceFileW requires the replacement file handle to be closed.
|
||||
// Unix rename permits replacing an open file, so this otherwise only
|
||||
// surfaces on Windows as ERROR_SHARING_VIOLATION (os error 32).
|
||||
drop(file);
|
||||
replace_file_atomically(&temporary_path, path)
|
||||
})();
|
||||
|
||||
@@ -12,7 +12,6 @@ pub(crate) fn lock_core_operation(
|
||||
Ok(guard)
|
||||
}
|
||||
|
||||
// Own the guard from spawn, including while the management port is starting.
|
||||
pub(crate) struct CoreChild {
|
||||
child: Child,
|
||||
#[cfg(windows)]
|
||||
@@ -323,7 +322,6 @@ pub(crate) fn pause_core_process_for_install(
|
||||
process_state: &CoreProcessState,
|
||||
) -> Result<bool, String> {
|
||||
process_state.ensure_active()?;
|
||||
// A process can hold files even when its HTTP port is not responding.
|
||||
let was_running = current_core_status(Some(process_state), None)?.running;
|
||||
if was_running {
|
||||
stop_core_process_inner(process_state)?;
|
||||
@@ -1036,7 +1034,6 @@ pub(crate) fn core_release_asset_name(version: &str, platform: &CorePlatform) ->
|
||||
)
|
||||
}
|
||||
|
||||
// Download progress and cancellation require the complete transfer context here.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn download_asset(
|
||||
client: &reqwest::Client,
|
||||
@@ -1318,9 +1315,6 @@ pub(crate) fn core_start_stdio(log_path: &Path) -> io::Result<(Stdio, Stdio)> {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
// Keep only the current process run so console output cannot grow without
|
||||
// bound across restarts. Both child handles use append mode to avoid their
|
||||
// independent file cursors overwriting each other's output.
|
||||
let mut header_file = File::options()
|
||||
.write(true)
|
||||
.create(true)
|
||||
@@ -2304,9 +2298,6 @@ pub(crate) fn terminate_process_id(process_id: u32) -> Result<(), String> {
|
||||
System::Threading::{OpenProcess, WaitForSingleObject, PROCESS_SYNCHRONIZE},
|
||||
};
|
||||
|
||||
// Keep a handle to this process until it is signalled. taskkill exiting
|
||||
// successfully only means termination was requested, not that file
|
||||
// handles have been released.
|
||||
let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE, 0, process_id) };
|
||||
if handle.is_null() {
|
||||
let error = io::Error::last_os_error();
|
||||
@@ -2474,11 +2465,6 @@ fn overlay_directory(source_dir: &Path, target_dir: &Path) -> Result<(), String>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copies through a sibling temporary file and atomically replaces the target.
|
||||
///
|
||||
/// In particular, do not change this back to copying over an existing file:
|
||||
/// macOS caches code-signature validation by vnode, so in-place updates can
|
||||
/// leave an otherwise valid executable permanently rejected with SIGKILL.
|
||||
pub(crate) fn copy_core_file_replace(source_path: &Path, target_path: &Path) -> Result<(), String> {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
@@ -2522,8 +2508,6 @@ pub(crate) fn copy_core_file_replace(source_path: &Path, target_path: &Path) ->
|
||||
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
pub(crate) fn rematerialize_core_binary(binary_path: &Path) -> Result<(), String> {
|
||||
// Replacing the path with an identical copy gives it a fresh vnode and
|
||||
// clears the macOS signature-cache state left by older in-place updates.
|
||||
copy_core_file_replace(binary_path, binary_path)
|
||||
}
|
||||
|
||||
|
||||
@@ -87,8 +87,6 @@ const RELEASE_DOWNLOAD_PREFIX: &str =
|
||||
const APP_UPDATE_MANIFEST_URL: &str = "https://github.com/router-for-me/EasyCLIProxyAPI/releases/latest/download/portable-update-windows.json";
|
||||
#[cfg(target_os = "linux")]
|
||||
const APP_UPDATE_MANIFEST_URL: &str = "https://github.com/router-for-me/EasyCLIProxyAPI/releases/latest/download/portable-update-linux.json";
|
||||
// Legacy macOS clients contain an updater that cannot launch outside its signed app bundle.
|
||||
// A separate channel makes those clients fall back to a one-time manual installation.
|
||||
#[cfg(target_os = "macos")]
|
||||
const APP_UPDATE_MANIFEST_URL: &str = "https://github.com/router-for-me/EasyCLIProxyAPI/releases/latest/download/portable-update-darwin-v2.json";
|
||||
const APP_RELEASE_DOWNLOAD_PREFIX: &str =
|
||||
@@ -643,8 +641,6 @@ struct GuiConfigFile {
|
||||
download_source: VersionDownloadSource,
|
||||
custom_download_mirrors: Vec<String>,
|
||||
active_custom_download_mirror: String,
|
||||
// Kept for migration compatibility with configurations written before
|
||||
// multi-source downloads were introduced.
|
||||
prefer_gitcode_downloads: bool,
|
||||
routing_session_affinity: bool,
|
||||
routing_session_affinity_ttl: String,
|
||||
@@ -920,8 +916,6 @@ impl Default for GuiConfigFile {
|
||||
auth_dir: DEFAULT_AUTH_DIR.to_string(),
|
||||
api_keys: vec![default_api_key_entry()],
|
||||
api_access_remarks: Vec::new(),
|
||||
// Populated with an OS-generated secret while loading the GUI
|
||||
// configuration. Core hashes the value written into config.yaml.
|
||||
management_secret_key: String::new(),
|
||||
debug: false,
|
||||
commercial_mode: false,
|
||||
@@ -1505,7 +1499,6 @@ struct CoreConfigSettings {
|
||||
max_retry_credentials: u32,
|
||||
max_retry_interval: u32,
|
||||
streaming_bootstrap_retries: u32,
|
||||
// Kept for internal config migration/tests; never exposed to the WebView.
|
||||
#[allow(dead_code)]
|
||||
#[serde(skip_serializing)]
|
||||
management_secret_key: Option<String>,
|
||||
@@ -2638,8 +2631,6 @@ fn main() {
|
||||
}
|
||||
app_handle.state::<CoreDownloadState>().cancel();
|
||||
let app_handle = app_handle.clone();
|
||||
// Keep the event loop alive while an in-flight operation finishes:
|
||||
// tray/status updates from that operation may need the UI thread.
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let _guard = CORE_OPERATION_LOCK
|
||||
.lock()
|
||||
|
||||
@@ -298,14 +298,9 @@ pub(crate) async fn submit_oauth_callback(
|
||||
pub(crate) fn management_http_client() -> Result<reqwest::Client, String> {
|
||||
static CLIENT: LazyLock<Result<reqwest::Client, String>> = LazyLock::new(|| {
|
||||
reqwest::Client::builder()
|
||||
// GUI-to-Core management traffic must always connect directly. Upstream
|
||||
// traffic still uses Core's proxy-url, and other GUI HTTP clients keep
|
||||
// their independently configured proxy behavior.
|
||||
.no_proxy()
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.timeout(Duration::from_secs(30))
|
||||
// Management requests target the configured local listener. This keeps
|
||||
// self-signed/private-CA certificates usable without weakening upstream clients.
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.map_err(|err| format_management_request_error("创建管理 API 客户端失败", &err))
|
||||
|
||||
@@ -731,8 +731,6 @@ fn yaml_edit_runtime_patch_removes_empty_keys_and_skips_unsupported_sections() {
|
||||
assert!(rendered.contains("host: 127.0.0.1"));
|
||||
assert!(rendered.contains("port: 8317"));
|
||||
|
||||
// Nested plugin/routing sections are still optional for comment-preserving
|
||||
// runtime patches; missing maps remain unsupported and stay untouched.
|
||||
let unsupported = "host: 127.0.0.1\nport: 8317\n";
|
||||
let file = unsupported.parse::<yaml_edit::YamlFile>().unwrap();
|
||||
let document = file.document().unwrap();
|
||||
|
||||
@@ -172,7 +172,6 @@ fn updating_stops_an_adopted_core_without_a_port_before_replacing_its_config() {
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null());
|
||||
configure_background_command(&mut command);
|
||||
// Spawn without the GUI's job guard to simulate a previous orphan.
|
||||
let mut child = command.spawn().unwrap();
|
||||
let output = io::BufReader::new(child.stdout.take().unwrap());
|
||||
let ready = output
|
||||
|
||||
@@ -123,7 +123,6 @@ fn legacy_desktop_alias_only_provider_retains_its_source_during_reconfiguration(
|
||||
"codex-api-key:\n - api-key: preserved-key\n base-url: https://example.test\n models:\n - name: model-a\n alias: {opus}\n context-length: 123456\n",
|
||||
opus = CLAUDE_DESKTOP_OPUS_MODEL_ID,
|
||||
);
|
||||
// Older configurations can retain only the aliased entry for the upstream model.
|
||||
let result = ensure_claude_desktop_model_aliases_in_yaml(
|
||||
&input,
|
||||
&ClaudeDesktopModelMappings::all("model-a"),
|
||||
@@ -169,7 +168,6 @@ fn legacy_desktop_alias_source_is_resolved_before_removing_its_route() {
|
||||
"codex-api-key:\n - api-key: preserved-key\n models: [{{name: model-a, alias: {opus}}}]\n",
|
||||
opus = CLAUDE_DESKTOP_OPUS_MODEL_ID,
|
||||
);
|
||||
// Legacy callers and restore previews may have model IDs without is_alias metadata.
|
||||
let selected = CLAUDE_DESKTOP_OPUS_MODEL_ID;
|
||||
let updated = ensure_claude_desktop_model_aliases_in_yaml(
|
||||
&input,
|
||||
|
||||
+2
-3
@@ -1,3 +1,4 @@
|
||||
import { MessageNotice } from './appNotice';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
@@ -486,9 +487,7 @@ function AppContent() {
|
||||
{t('app.close.description')}
|
||||
</p>
|
||||
{windowsClosePrompt.error ? (
|
||||
<div className="close-dialog-error" role="alert">
|
||||
{windowsClosePrompt.error}
|
||||
</div>
|
||||
<MessageNotice message={windowsClosePrompt.error} onDismiss={() => setWindowsClosePrompt(current => current ? { ...current, error: null } : current)} />
|
||||
) : null}
|
||||
<label className="close-dialog-remember">
|
||||
<input
|
||||
|
||||
+68
-2
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useId, useReducer } from 'react';
|
||||
import { useCallback, useEffect, useId, useReducer, useRef, useState } from 'react';
|
||||
import { NoticePortal } from './components/NoticePortal';
|
||||
import { AlertCircle, CheckCircle2, Info, X } from 'lucide-react';
|
||||
import { useI18n } from './i18n';
|
||||
import type { MessageKey } from './i18n/resources';
|
||||
@@ -94,4 +95,69 @@ export function InlineNotice({
|
||||
);
|
||||
}
|
||||
|
||||
export const ActionFeedback = InlineNotice;
|
||||
export function FloatingNotice(props: InlineNoticeProps) {
|
||||
const { t } = useI18n();
|
||||
const { notice } = props;
|
||||
if (!notice) return null;
|
||||
const message = typeof notice.message === 'string' ? notice.message : t(notice.message.key, notice.message.variables);
|
||||
if (!message.trim()) return null;
|
||||
return <FloatingNoticeInstance key={notice.tone + ':' + message} {...props} notice={notice} />;
|
||||
}
|
||||
|
||||
function FloatingNoticeInstance({ notice, onDismiss, className }: InlineNoticeProps & { notice: AppNotice }) {
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const entryRef = useRef<HTMLDivElement>(null);
|
||||
const returnFocusRef = useRef<HTMLElement | null>(null);
|
||||
const dismissRef = useRef(onDismiss);
|
||||
useEffect(() => { dismissRef.current = onDismiss; }, [onDismiss]);
|
||||
const dismiss = useCallback(() => {
|
||||
const entry = entryRef.current;
|
||||
if (entry?.contains(document.activeElement)) {
|
||||
const dialog = entry.closest('dialog, [role="dialog"], [role="alertdialog"]');
|
||||
const previous = returnFocusRef.current;
|
||||
const target = previous?.isConnected && !previous.matches(':disabled')
|
||||
&& !previous.closest('.app-notice-stack') && (!dialog || dialog.contains(previous))
|
||||
? previous
|
||||
: Array.from(dialog?.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), [tabindex="0"]') ?? [])
|
||||
.find(control => !control.closest('.app-notice-stack') && control.getClientRects().length > 0);
|
||||
target?.focus();
|
||||
}
|
||||
setDismissed(true);
|
||||
dismissRef.current?.();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (dismissed || hovered || focused || notice.tone !== 'success') return;
|
||||
const timer = window.setTimeout(dismiss, 6_000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [dismissed, hovered, focused, notice.tone, dismiss]);
|
||||
if (dismissed) return null;
|
||||
return <NoticePortal>
|
||||
<div ref={entryRef} className="app-notice-entry"
|
||||
onPointerEnter={() => setHovered(true)} onPointerLeave={() => setHovered(false)}
|
||||
onFocusCapture={(event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget)) {
|
||||
returnFocusRef.current = event.relatedTarget instanceof HTMLElement ? event.relatedTarget : null;
|
||||
}
|
||||
setFocused(true);
|
||||
}}
|
||||
onBlurCapture={(event) => { if (!event.currentTarget.contains(event.relatedTarget)) setFocused(false); }}
|
||||
onKeyDown={(event) => { if (event.key !== 'Tab') event.stopPropagation(); }}
|
||||
onClick={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()}>
|
||||
<InlineNotice notice={notice} onDismiss={dismiss} className={className} />
|
||||
</div>
|
||||
</NoticePortal>;
|
||||
}
|
||||
|
||||
export function MessageNotice({ message, tone = 'error', onDismiss, source }: {
|
||||
message?: NoticeMessage | null;
|
||||
tone?: NoticeTone;
|
||||
onDismiss?: () => void;
|
||||
source?: MessageKey;
|
||||
}) {
|
||||
const owner = useId();
|
||||
return <FloatingNotice notice={message ? { owner, message, tone, source } : null} onDismiss={onDismiss} />;
|
||||
}
|
||||
|
||||
export const ActionFeedback = FloatingNotice;
|
||||
+4
-4
@@ -1,3 +1,4 @@
|
||||
import { MessageNotice } from './appNotice';
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
@@ -10,7 +11,7 @@ import {
|
||||
} from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { AlertCircle, Download, RefreshCw } from 'lucide-react';
|
||||
import { Download, RefreshCw } from 'lucide-react';
|
||||
import { getCurrentLocale, translate, useI18n } from './i18n';
|
||||
|
||||
export type AppUpdateInfo = {
|
||||
@@ -151,6 +152,7 @@ export function AppUpdateProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
const cancel = useCallback(async () => {
|
||||
setError('');
|
||||
try {
|
||||
await invoke('cancel_app_update');
|
||||
} catch (nextError) {
|
||||
@@ -250,9 +252,7 @@ export function AppUpdateDialog() {
|
||||
<span>{task.message || phaseLabel}</span>
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="install-dialog-message error" role="alert">
|
||||
<AlertCircle size={15} aria-hidden="true" /> {error}
|
||||
</div>
|
||||
<MessageNotice message={error} />
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MessageNotice } from '../appNotice';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Check, Copy, LoaderCircle, Search, X } from 'lucide-react';
|
||||
import { useI18n } from '../i18n';
|
||||
@@ -62,7 +63,7 @@ export function AuthFileModelsDialog({ name, onClose }: AuthFileModelsDialogProp
|
||||
<div className="model-discovery-message"><LoaderCircle size={20} className="spin" />{t('authFiles.models.loading')}</div>
|
||||
) : (
|
||||
<div className="model-discovery-results">
|
||||
<div>{error ? <div className="model-discovery-inline-error" role="alert">{error}</div> : null}</div>
|
||||
<MessageNotice message={error} onDismiss={() => setError('')} />
|
||||
{visibleModels.length === 0 ? (
|
||||
<div className="model-discovery-message">{t(models.length ? 'authFiles.models.noMatch' : 'authFiles.models.viewEmpty')}</div>
|
||||
) : (
|
||||
|
||||
@@ -31,9 +31,9 @@ export function ConfirmationDialog({ title, message, confirmText, warning, detai
|
||||
event.stopPropagation();
|
||||
onDecision(false);
|
||||
} else if (event.key === 'Tab') {
|
||||
const buttons = Array.from(dialogRef.current?.querySelectorAll<HTMLButtonElement>('button:not(:disabled)') ?? []);
|
||||
const first = buttons[0];
|
||||
const last = buttons[buttons.length - 1];
|
||||
const controls = Array.from(dialogRef.current?.querySelectorAll<HTMLElement>('button:not(:disabled), [tabindex="0"]') ?? []);
|
||||
const first = controls[0];
|
||||
const last = controls[controls.length - 1];
|
||||
if (event.shiftKey && (document.activeElement === first || !dialogRef.current?.contains(document.activeElement))) {
|
||||
event.preventDefault();
|
||||
last?.focus();
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useLayoutEffect, useState, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
type NoticeHost = { element: HTMLDivElement; users: number; dispose: () => void };
|
||||
const hosts = new WeakMap<Document, NoticeHost>();
|
||||
|
||||
function acquireHost(doc: Document) {
|
||||
let host = hosts.get(doc);
|
||||
if (!host) {
|
||||
const element = doc.createElement('div');
|
||||
element.className = 'app-notice-stack';
|
||||
let observedDialog: Element | undefined;
|
||||
const resizeObserver = new ResizeObserver(() => placeHost());
|
||||
const placeHost = () => {
|
||||
const nativeDialogs = doc.querySelectorAll('dialog:modal');
|
||||
const nativeDialog = nativeDialogs[nativeDialogs.length - 1];
|
||||
const dialogs = Array.from((nativeDialog ?? doc).querySelectorAll('dialog[open], [role="dialog"], [role="alertdialog"]'))
|
||||
.filter(dialog => dialog.getBoundingClientRect().height > 0);
|
||||
const dialog = dialogs[dialogs.length - 1] ?? nativeDialog;
|
||||
const parent = dialog ?? doc.body;
|
||||
if (element.parentElement !== parent) parent.appendChild(element);
|
||||
if (dialog !== observedDialog) {
|
||||
if (observedDialog) resizeObserver.unobserve(observedDialog);
|
||||
if (dialog) resizeObserver.observe(dialog);
|
||||
observedDialog = dialog;
|
||||
}
|
||||
let bottom = 24;
|
||||
const viewportHeight = doc.documentElement.clientHeight;
|
||||
const bounds = element.getBoundingClientRect();
|
||||
const idealTop = viewportHeight - 24 - Math.min(element.scrollHeight, viewportHeight - 48);
|
||||
const buttons = dialog ? Array.from(dialog.querySelectorAll('button'))
|
||||
.filter(button => !element.contains(button))
|
||||
.map(button => button.getBoundingClientRect()).filter(rect => rect.width > 0 && rect.height > 0) : [];
|
||||
const lastBottom = Math.max(0, ...buttons.map(rect => rect.bottom));
|
||||
const footer = buttons.filter(rect => rect.bottom >= lastBottom - 8);
|
||||
if (footer.some(rect => rect.right > bounds.left && rect.left < bounds.right
|
||||
&& rect.bottom > idealTop && rect.top < viewportHeight - 24)) {
|
||||
bottom = Math.max(24, viewportHeight - Math.min(...footer.map(rect => rect.top)) + 12);
|
||||
}
|
||||
const value = bottom + 'px';
|
||||
if (element.style.getPropertyValue('--notice-bottom') !== value) {
|
||||
element.style.setProperty('--notice-bottom', value);
|
||||
}
|
||||
};
|
||||
const observer = new MutationObserver(placeHost);
|
||||
placeHost();
|
||||
observer.observe(doc.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['open', 'class', 'hidden'] });
|
||||
resizeObserver.observe(element);
|
||||
doc.defaultView?.addEventListener('resize', placeHost);
|
||||
host = { element, users: 0, dispose() {
|
||||
observer.disconnect();
|
||||
resizeObserver.disconnect();
|
||||
doc.defaultView?.removeEventListener('resize', placeHost);
|
||||
element.remove();
|
||||
} };
|
||||
hosts.set(doc, host);
|
||||
}
|
||||
host.users += 1;
|
||||
const acquired = host;
|
||||
return {
|
||||
element: acquired.element,
|
||||
release() {
|
||||
acquired.users -= 1;
|
||||
if (acquired.users === 0) {
|
||||
acquired.dispose();
|
||||
hosts.delete(doc);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function NoticePortal({ children }: { children: ReactNode }) {
|
||||
const [host, setHost] = useState<HTMLDivElement | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
const acquired = acquireHost(document);
|
||||
setHost(acquired.element);
|
||||
return acquired.release;
|
||||
}, []);
|
||||
useLayoutEffect(() => {
|
||||
if (host) host.scrollTop = host.scrollHeight;
|
||||
}, [host]);
|
||||
return host ? createPortal(children, host) : null;
|
||||
}
|
||||
@@ -1,18 +1,13 @@
|
||||
import { AlertCircle, CheckCircle2 } from 'lucide-react';
|
||||
import { MessageNotice } from '../appNotice';
|
||||
import { useI18n } from '../i18n';
|
||||
import type { QuotaState } from '../services/quotaService';
|
||||
|
||||
export function QuotaActionFeedback({ quota }: { quota: QuotaState }) {
|
||||
export function QuotaActionFeedback({ quota, name }: { quota: QuotaState; name?: string }) {
|
||||
const { t } = useI18n();
|
||||
const result = quota.actionResult;
|
||||
if (!result) return null;
|
||||
const successful = result.status === 'success';
|
||||
const message = t(successful ? 'quota.resetResult.submitted' : result.status === 'refresh-error'
|
||||
? 'quota.resetResult.refreshFailed' : 'quota.resetResult.failed', { error: result.error ?? '' });
|
||||
return (
|
||||
<div className={successful ? 'quota-action-feedback success' : 'quota-action-feedback error'} role={successful ? 'status' : 'alert'}>
|
||||
{successful ? <CheckCircle2 size={16} aria-hidden="true" /> : <AlertCircle size={16} aria-hidden="true" />}
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
);
|
||||
return <MessageNotice tone={successful ? 'success' : 'error'} message={name ? name + ': ' + message : message} />;
|
||||
}
|
||||
@@ -43,7 +43,6 @@ function detectInitialLocale(): AppLocale {
|
||||
const saved = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (saved) return normalizeLocale(saved);
|
||||
} catch {
|
||||
// Storage may be unavailable in a restricted WebView; use the OS language.
|
||||
}
|
||||
return normalizeLocale(window.navigator.languages?.[0] ?? window.navigator.language);
|
||||
}
|
||||
@@ -83,7 +82,6 @@ export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, locale);
|
||||
} catch {
|
||||
// The in-memory locale still works when persistent storage is unavailable.
|
||||
}
|
||||
void invoke('set_app_locale', { locale }).catch((error) => {
|
||||
console.warn('Failed to synchronize the app locale with the native shell', error);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MessageNotice } from '../appNotice';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { ArchiveRestore, LoaderCircle, X } from 'lucide-react';
|
||||
@@ -84,7 +85,7 @@ export function AgentConfigBackupDialog({ client, onClose, onRestored }: {
|
||||
<strong>{t(confirmation === 'restore' ? 'agents.backup.restoreConfirm' : 'agents.backup.deleteConfirm', { version: date(selected) })}</strong>
|
||||
<p>{t(confirmation === 'restore' ? 'agents.backup.restoreWarning' : 'agents.backup.deleteWarning')}</p>
|
||||
</div>}
|
||||
{error && <p className="agent-inline-message error" role="alert">{error}</p>}
|
||||
{error && <MessageNotice message={error} onDismiss={() => setError('')} />}
|
||||
<footer>
|
||||
<button type="button" className="secondary-button" disabled={busy} onClick={() => confirmation ? setConfirmation(null) : onClose()}>{t('common.cancel')}</button>
|
||||
{confirmation ? <button type="button" className="danger-button" disabled={busy} onClick={() => void confirm()}>{t(confirmation === 'restore' ? 'agents.backup.confirmRestore' : 'agents.backup.confirmDelete')}</button> : <>
|
||||
|
||||
+14
-11
@@ -1,10 +1,11 @@
|
||||
import { MessageNotice } from '../appNotice';
|
||||
import { AppWindow, LoaderCircle, Play, RefreshCw, Square, Terminal, Trash2 } from 'lucide-react';
|
||||
import { useI18n } from '../i18n';
|
||||
|
||||
type LaunchTarget = { id: 'app' | 'cli'; label: string; detail: string };
|
||||
|
||||
export function AgentRunControls({
|
||||
name, dualTargets, desktop, targets, enabled, busyAction, harness, onLaunch, onRestart, onStop, onRestartWeb, error,
|
||||
name, dualTargets, desktop, targets, enabled, busyAction, harness, onLaunch, onRestart, onStop, onRestartWeb, error, onErrorDismiss,
|
||||
}: {
|
||||
name: string;
|
||||
dualTargets: boolean;
|
||||
@@ -18,6 +19,7 @@ export function AgentRunControls({
|
||||
onStop: () => void;
|
||||
onRestartWeb: () => void;
|
||||
error: string;
|
||||
onErrorDismiss?: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const busy = busyAction !== null;
|
||||
@@ -56,21 +58,22 @@ export function AgentRunControls({
|
||||
{t(busyAction === 'restart-deepseek' ? 'agents.deepseekLaunch.restarting' : 'agents.deepseekLaunch.restart')}
|
||||
</button> : null}
|
||||
</div>
|
||||
{error ? <span className="agent-inline-message error" role="alert">{error}</span> : null}
|
||||
{error ? <MessageNotice message={error} onDismiss={onErrorDismiss} /> : null}
|
||||
</section>;
|
||||
}
|
||||
|
||||
export function AgentConfigurationFeedback({ pending, notice, error, description }: {
|
||||
pending: boolean; notice: string; error: string; description: string;
|
||||
export function AgentConfigurationFeedback({ pending, notice, error, description, status = '', onNoticeDismiss, onErrorDismiss }: {
|
||||
pending: boolean; notice: string; error: string; description: string; status?: string; onNoticeDismiss?: () => void; onErrorDismiss?: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
if (!pending && !notice && !error && !description) return null;
|
||||
return <div className="agent-save-feedback agent-shared-feedback" aria-live="polite">
|
||||
{pending ? <span className="agent-write-state">{t('agents.modify.pending')}</span> : null}
|
||||
{error ? <span className="agent-inline-message error" role="alert">{error}</span> : null}
|
||||
{notice ? <span className="agent-inline-message">{notice}</span> : null}
|
||||
{description ? <small>{description}</small> : null}
|
||||
</div>;
|
||||
return <>
|
||||
<MessageNotice message={error} onDismiss={onErrorDismiss} />
|
||||
<MessageNotice tone="success" message={!error ? notice : null} onDismiss={onNoticeDismiss} />
|
||||
<div className="agent-save-feedback agent-shared-feedback" aria-live="polite">
|
||||
<span className="agent-write-state">{pending ? t('agents.modify.pending') : status || ' '}</span>
|
||||
<small tabIndex={description ? 0 : undefined}>{description || ' '}</small>
|
||||
</div>
|
||||
</>;
|
||||
}
|
||||
|
||||
export function AgentConfigManagementPanel({
|
||||
|
||||
+16
-54
@@ -1,3 +1,4 @@
|
||||
import { MessageNotice } from '../appNotice';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -339,8 +340,6 @@ const DEFAULT_AGENT_VIEW_STATE: AgentViewState = {
|
||||
launchError: '',
|
||||
};
|
||||
|
||||
// Keep navigation and feedback for this app session, including when the page unmounts.
|
||||
// The compact view has no session manager, so it keeps its own navigation history.
|
||||
let agentViewStateCache: Record<'full' | 'embedded', Partial<Record<AgentClientId, AgentViewState>>> = {
|
||||
full: {},
|
||||
embedded: {},
|
||||
@@ -368,7 +367,6 @@ const writeSelectedAgentClient = (client: AgentClientId) => {
|
||||
try {
|
||||
window.localStorage.setItem(AGENT_SELECTED_CLIENT_KEY, client);
|
||||
} catch {
|
||||
// Keep the current in-memory selection when persistent storage is unavailable.
|
||||
}
|
||||
};
|
||||
|
||||
@@ -395,7 +393,6 @@ const writeAgentModelSelections = (
|
||||
try {
|
||||
window.localStorage.setItem(AGENT_MODEL_SELECTIONS_KEY, JSON.stringify(selections));
|
||||
} catch {
|
||||
// Local storage can be unavailable in hardened webviews; the in-memory selection still works.
|
||||
}
|
||||
};
|
||||
|
||||
@@ -416,7 +413,6 @@ const writeAgentLaunchDirectoryHistory = (history: AgentLaunchDirectoryHistory)
|
||||
try {
|
||||
window.localStorage.setItem(AGENT_LAUNCH_DIRECTORY_HISTORY_KEY, JSON.stringify(history));
|
||||
} catch {
|
||||
// Keep the current in-memory history when persistent storage is unavailable.
|
||||
}
|
||||
};
|
||||
|
||||
@@ -924,8 +920,6 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
|
||||
setLaunchDirectoryTarget(null);
|
||||
setLaunchDirectoryError('');
|
||||
setOauthLoginRequiredAction(null);
|
||||
// Preserve unsaved client-specific configuration while navigating between clients.
|
||||
// Each configuration action decides whether its draft should be retained or cleared.
|
||||
}, [selected]);
|
||||
|
||||
const activeDefinition = agentDefinitions.find((agent) => agent.id === selected)
|
||||
@@ -1044,7 +1038,6 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
|
||||
};
|
||||
const hasPendingChanges = Boolean(formEditBaselineByClient[selected]);
|
||||
|
||||
// Only user edits establish a baseline. Detection and default selections are not edits.
|
||||
const trackFormEdit = (nextValues: AgentFormValues) => {
|
||||
setConfigurationNotice('');
|
||||
setClearNotice('');
|
||||
@@ -1106,9 +1099,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
|
||||
'sdk-minimal': t('agents.deepseekLaunch.mode.sdkMinimalDescription'),
|
||||
custom: t('agents.deepseekLaunch.mode.customDescription'),
|
||||
}[deepSeekHarnessLaunchDraft.mode];
|
||||
const modelHint = modelSelectionError
|
||||
|| modelError
|
||||
|| (modelLoading
|
||||
const modelHint = (modelLoading
|
||||
? t('agents.model.readingAvailable')
|
||||
: models.length === 0
|
||||
? ''
|
||||
@@ -1713,9 +1704,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
|
||||
</div>
|
||||
<div className="agent-header-actions">
|
||||
{detectionError ? (
|
||||
<span className="agent-inline-message error" role="alert" aria-live="polite">
|
||||
{detectionError}
|
||||
</span>
|
||||
<MessageNotice message={detectionError} onDismiss={() => setDetectionError('')} />
|
||||
) : null}
|
||||
<button type="button" className="secondary-button compact-button" onClick={() => void refresh()} disabled={loading || busy}>
|
||||
<RefreshCw size={16} className={loading ? 'spin' : ''} />
|
||||
@@ -1794,15 +1783,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{activeStatus?.error || activeStatus?.warnings.length ? (
|
||||
<div className="agent-minimal-message" aria-live="polite">
|
||||
{activeStatus.error ? (
|
||||
<span className="agent-inline-message error" role="alert">{activeStatus.error}</span>
|
||||
) : (
|
||||
<span className="agent-inline-message warning">{activeStatus.warnings.join(';')}</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<MessageNotice message={activeStatus?.error || activeStatus?.warnings.join(';')} tone={activeStatus?.error ? 'error' : 'info'} />
|
||||
|
||||
{selected === 'claude-desktop' && !activeStatus?.claudeDesktopModelMappings ? <p className="agent-inline-message warning">{t('agents.backup.mappingRequired')}</p> : null}
|
||||
<div className="agent-minimal-field">
|
||||
@@ -1893,15 +1874,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activeStatus?.error || activeStatus?.warnings.length ? (
|
||||
<div className="agent-status-messages" aria-live="polite">
|
||||
{activeStatus.error ? (
|
||||
<span className="agent-inline-message error" role="alert">{activeStatus.error}</span>
|
||||
) : (
|
||||
<span className="agent-inline-message warning">{activeStatus.warnings.join(';')}</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<MessageNotice message={activeStatus?.error || activeStatus?.warnings.join(';')} tone={activeStatus?.error ? 'error' : 'info'} />
|
||||
|
||||
{!isClaudeModelMappingClient ? (
|
||||
<section className="agent-core-setting-section agent-model-section">
|
||||
@@ -1917,15 +1890,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
|
||||
onChange={selectModel}
|
||||
onRefresh={refreshModels}
|
||||
/>
|
||||
{modelHint ? (
|
||||
<span
|
||||
className={`agent-model-hint ${modelSelectionError || modelError ? 'error' : ''}`}
|
||||
role={modelSelectionError || modelError ? 'alert' : undefined}
|
||||
aria-live="polite"
|
||||
>
|
||||
{modelHint}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="agent-model-hint agent-model-status" title={modelHint} aria-live="polite">{modelHint || ' '}</span>
|
||||
{isDeepSeekHarnessClient ? <p className="agent-model-hint">{t('agents.harness.defaultHint')}</p> : null}
|
||||
{harnessCatalogButton}
|
||||
{selected === 'codex' ? (
|
||||
@@ -2131,15 +2096,18 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
|
||||
</div>
|
||||
) : null}
|
||||
<AgentConfigurationFeedback pending={hasPendingChanges}
|
||||
notice={configurationNotice || clearNotice || (modelLoading || !activeStatus ? t('agents.modify.checking')
|
||||
: activeStatus.modificationState === 'invalid' ? t('agents.modify.invalidState') : '')}
|
||||
error={configurationError || modelSelectionError} description={modificationDescription} />
|
||||
notice={configurationNotice || clearNotice}
|
||||
onNoticeDismiss={() => { setConfigurationNotice(''); setClearNotice(''); }}
|
||||
status={modelLoading || !activeStatus ? t('agents.modify.checking')
|
||||
: activeStatus.modificationState === 'invalid' ? t('agents.modify.invalidState') : ''}
|
||||
onErrorDismiss={() => { setConfigurationError(''); setModelSelectionError(''); setModelError(''); }}
|
||||
error={configurationError || modelSelectionError || modelError} description={modificationDescription} />
|
||||
{activeSubpage === 'core' ? <AgentRunControls name={activeDefinition.name} dualTargets={hasIndependentCliAndApp}
|
||||
desktop={hasIndependentCliAndApp || selected === 'claude-desktop' || selected === 'zcode'}
|
||||
targets={activeLaunchTargets} enabled={launchEnabled} busyAction={busyAction}
|
||||
harness={isDeepSeekHarnessClient ? deepSeekHarnessProcessStatus : null}
|
||||
onLaunch={(target) => void launchAgent(target)} onRestart={() => void restartDesktopApp()}
|
||||
onStop={() => void stopDeepSeekHarness()} onRestartWeb={() => void restartDeepSeekHarness()} error={launchError} /> : null}
|
||||
onStop={() => void stopDeepSeekHarness()} onRestartWeb={() => void restartDeepSeekHarness()} error={launchError} onErrorDismiss={() => setLaunchError('')} /> : null}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -2365,9 +2333,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
|
||||
</div>
|
||||
) : null}
|
||||
{launchDirectoryError ? (
|
||||
<span className="agent-inline-message error" role="alert" aria-live="polite">
|
||||
{launchDirectoryError}
|
||||
</span>
|
||||
<MessageNotice message={launchDirectoryError} onDismiss={() => setLaunchDirectoryError('')} />
|
||||
) : null}
|
||||
<div className="config-dialog-actions two-actions">
|
||||
<button
|
||||
@@ -2410,9 +2376,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
|
||||
</p>
|
||||
{templatePreview ? <ul className="agent-template-files">{templatePreview.files.map((file) => <li key={file}><code>{file}</code></li>)}</ul> : null}
|
||||
{defaultError ? (
|
||||
<span className="agent-inline-message error" role="alert" aria-live="polite">
|
||||
{defaultError}
|
||||
</span>
|
||||
<MessageNotice message={defaultError} onDismiss={() => setDefaultError('')} />
|
||||
) : null}
|
||||
<div className="config-dialog-actions two-actions">
|
||||
<button type="button" className="secondary-button" onClick={closeDefaultConfirmation} disabled={busy}>{t('common.cancel')}</button>
|
||||
@@ -2433,9 +2397,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
|
||||
</div>
|
||||
<p>{t('agents.clear.description')}</p>
|
||||
{clearError ? (
|
||||
<span className="agent-inline-message error" role="alert" aria-live="polite">
|
||||
{clearError}
|
||||
</span>
|
||||
<MessageNotice message={clearError} onDismiss={() => setClearError('')} />
|
||||
) : null}
|
||||
<div className="config-dialog-actions two-actions">
|
||||
<button type="button" className="secondary-button" onClick={closeClearConfirmation} disabled={busy}>{t('common.cancel')}</button>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useConfirmation } from '../components/ConfirmationDialog';
|
||||
import { ModelSelectionPanel } from '../components/ModelSelectionPanel';
|
||||
import {
|
||||
@@ -74,7 +73,7 @@ import {
|
||||
import { modelMatchesRule } from '../services/oauthModels';
|
||||
import { getCurrentLocale, translate, useI18n } from '../i18n';
|
||||
import type { MessageKey } from '../i18n/resources';
|
||||
import { InlineNotice, useAppNotice } from '../appNotice';
|
||||
import { MessageNotice, FloatingNotice, useAppNotice } from '../appNotice';
|
||||
|
||||
export type ProviderSection =
|
||||
| 'gemini-api-key'
|
||||
@@ -1218,7 +1217,7 @@ export function ApiAccessPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error ? <div className="management-alert error">{error}</div> : null}
|
||||
{error ? <MessageNotice message={error} onDismiss={() => setError('')} /> : null}
|
||||
<div className="provider-workbench real-provider-workbench">
|
||||
<aside className="panel provider-category-panel">
|
||||
{providerDefinitions.map((definition) => (
|
||||
@@ -1353,17 +1352,7 @@ export function ApiAccessPage() {
|
||||
onClose={() => setHealthDialogRow(null)}
|
||||
/>
|
||||
) : null}
|
||||
{typeof document === 'undefined'
|
||||
? null
|
||||
: createPortal(
|
||||
<InlineNotice
|
||||
key={feedback.revision}
|
||||
notice={feedback.notice}
|
||||
onDismiss={feedback.clearNotice}
|
||||
className="api-access-page-notice"
|
||||
/>,
|
||||
document.body,
|
||||
)}
|
||||
<FloatingNotice key={feedback.revision} notice={feedback.notice} onDismiss={feedback.clearNotice} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1507,7 +1496,7 @@ function ProviderHealthDialog({ row, onClose }: ProviderHealthDialogProps) {
|
||||
failed: failedCount,
|
||||
})}</span>
|
||||
{modelError ? (
|
||||
<small className="error" title={modelError}>{t('apiAccess.health.modelLoadFailed', { error: modelError })}</small>
|
||||
<MessageNotice message={t('apiAccess.health.modelLoadFailed', { error: modelError })} onDismiss={() => setModelError('')} />
|
||||
) : modelLoading ? (
|
||||
<small>{t('apiAccess.health.loadingModels')}</small>
|
||||
) : null}
|
||||
@@ -1644,7 +1633,7 @@ export function ApiProviderDialog({
|
||||
event.stopPropagation();
|
||||
closeModelDiscovery();
|
||||
} else if (event.key === 'Tab') {
|
||||
const controls = Array.from(dialog?.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled)') ?? []);
|
||||
const controls = Array.from(dialog?.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), [tabindex="0"]') ?? []);
|
||||
const first = controls[0];
|
||||
const last = controls[controls.length - 1];
|
||||
if (event.shiftKey && (document.activeElement === first || !dialog?.contains(document.activeElement))) {
|
||||
@@ -1984,7 +1973,7 @@ export function ApiProviderDialog({
|
||||
<Plus size={14} />{t('apiAccess.models.add')}
|
||||
</button>
|
||||
</div>
|
||||
{modelError && !modelDiscoveryOpen ? <small className="model-picker-error">{modelError}</small> : null}
|
||||
{modelError && !modelDiscoveryOpen ? <MessageNotice message={modelError} onDismiss={() => setModelError('')} /> : null}
|
||||
</div>
|
||||
<label><span>{t('apiAccess.field.priority')}</span><input inputMode="numeric" value={draft.priority} onChange={(event) => updateTextField('priority', event.currentTarget.value.replace(/\D/g, ''))} placeholder={t('common.optional')} /></label>
|
||||
<details className="provider-advanced-settings">
|
||||
@@ -2042,9 +2031,7 @@ export function ApiProviderDialog({
|
||||
</div>
|
||||
</details>
|
||||
{formError ? (
|
||||
<div className="management-alert error api-provider-dialog-error" role="alert">
|
||||
{formError}
|
||||
</div>
|
||||
<MessageNotice message={formError} onDismiss={() => setFormError('')} />
|
||||
) : null}
|
||||
<div className="config-dialog-actions two-actions">
|
||||
<button type="button" className="secondary-button" onClick={onClose} disabled={busy}>{t('common.cancel')}</button>
|
||||
@@ -2069,10 +2056,7 @@ export function ApiProviderDialog({
|
||||
</div>
|
||||
|
||||
{modelError ? (
|
||||
<div className="model-discovery-inline-error" role="alert">
|
||||
<strong>{t('apiAccess.modelDialog.fetchFailed')}</strong>
|
||||
<span title={modelError}>{modelError}</span>
|
||||
</div>
|
||||
<MessageNotice source="apiAccess.modelDialog.fetchFailed" message={modelError} onDismiss={() => setModelError('')} />
|
||||
) : null}
|
||||
|
||||
<div className="model-transfer-panels">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useConfirmation } from '../components/ConfirmationDialog';
|
||||
import { QuotaActionFeedback } from '../components/QuotaActionFeedback';
|
||||
import { InlineNotice, useAppNotice } from '../appNotice';
|
||||
import { MessageNotice, FloatingNotice, useAppNotice } from '../appNotice';
|
||||
import { AuthFileModelsDialog } from '../components/AuthFileModelsDialog';
|
||||
import {
|
||||
Check,
|
||||
@@ -113,7 +113,7 @@ const statusText = (file: AuthFile) => {
|
||||
return readString(file, 'status') || translate(getCurrentLocale(), 'authFiles.status.ready');
|
||||
};
|
||||
|
||||
function AuthFileQuotaSummary({ quota }: { quota: QuotaState }) {
|
||||
function AuthFileQuotaSummary({ quota, name }: { quota: QuotaState; name: string }) {
|
||||
const { locale, t } = useI18n();
|
||||
const now = useQuotaClock() + (quota.serverTimeOffsetMs ?? 0);
|
||||
if (quota.status === 'loading') {
|
||||
@@ -128,7 +128,7 @@ function AuthFileQuotaSummary({ quota }: { quota: QuotaState }) {
|
||||
return (
|
||||
<div className="auth-file-quota error" title={quota.error}>
|
||||
<span>{t('authFiles.quota.failed')}</span>
|
||||
{quota.error ? <small>{quota.error}</small> : null}
|
||||
<MessageNotice message={quota.error ? name + ': ' + quota.error : null} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -154,7 +154,7 @@ function AuthFileQuotaSummary({ quota }: { quota: QuotaState }) {
|
||||
) : null}
|
||||
{quota.resetCreditsApplicable !== undefined ? <span className="auth-file-quota-credit">{t('quota.resetApplicable', { count: quota.resetCreditsApplicable })}</span> : null}
|
||||
{quota.subscriptionActiveUntil ? <span className="auth-file-quota-credit">{t('quota.subscriptionExpiry', { time: formatQuotaTimestamp(quota.subscriptionActiveUntil, locale) })}</span> : null}
|
||||
{quota.resetCreditsError ? <small>{t('quota.resetCreditsWarning', { error: quota.resetCreditsError })}</small> : null}
|
||||
<MessageNotice message={quota.resetCreditsError ? name + ': ' + t('quota.resetCreditsWarning', { error: quota.resetCreditsError }) : null} />
|
||||
{quota.resetCreditsEarliestExpiry ? (
|
||||
<span className="auth-file-quota-credit">{t('authFiles.quota.expiry', { time: formatQuotaTimestamp(quota.resetCreditsEarliestExpiry, locale) })}</span>
|
||||
) : null}
|
||||
@@ -499,8 +499,8 @@ export function AuthFileManagementPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error ? <div className="management-alert error">{error}</div> : null}
|
||||
<InlineNotice key={feedback.revision} notice={feedback.notice} onDismiss={feedback.clearNotice} />
|
||||
{error ? <MessageNotice message={error} onDismiss={() => setError('')} /> : null}
|
||||
<FloatingNotice key={feedback.revision} notice={feedback.notice} onDismiss={feedback.clearNotice} />
|
||||
|
||||
<section className="panel auth-files-panel real-auth-files-panel">
|
||||
<div className="management-toolbar auth-files-toolbar">
|
||||
@@ -550,8 +550,8 @@ export function AuthFileManagementPage() {
|
||||
<button type="button" className={`${disabled ? 'primary-button' : 'secondary-button'} compact-button`} onClick={() => void toggleStatus(file)} disabled={busy}>{disabled ? t('common.enable') : t('common.disable')}</button>
|
||||
<button type="button" className="icon-button danger" onClick={() => void deleteFile(file)} disabled={busy || isRuntimeOnly(file)} title={t('common.delete')}><Trash2 size={16} /></button>
|
||||
</div>
|
||||
{quotaProviderForFile(file) && quotas[quotaKey(file)]?.status !== 'idle' ? <AuthFileQuotaSummary quota={quotas[quotaKey(file)] ?? idleQuota()} /> : null}
|
||||
<QuotaActionFeedback quota={quotas[quotaKey(file)] ?? idleQuota()} />
|
||||
{quotaProviderForFile(file) && quotas[quotaKey(file)]?.status !== 'idle' ? <AuthFileQuotaSummary quota={quotas[quotaKey(file)] ?? idleQuota()} name={name} /> : null}
|
||||
<QuotaActionFeedback quota={quotas[quotaKey(file)] ?? idleQuota()} name={name} />
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
@@ -655,8 +655,8 @@ export function AuthFileManagementPage() {
|
||||
) : (
|
||||
<div className="model-discovery-results">
|
||||
<div>
|
||||
{oauthModelError ? <div className="model-discovery-inline-error" role="alert">{oauthModelError}</div> : null}
|
||||
{oauthModelSettings?.catalogError ? <div className="model-discovery-inline-error" role="status">{t('authFiles.models.catalogUnavailable')}</div> : null}
|
||||
{oauthModelError ? <MessageNotice message={oauthModelError} onDismiss={() => setOauthModelError('')} /> : null}
|
||||
{oauthModelSettings?.catalogError ? <MessageNotice tone="info" message={t('authFiles.models.catalogUnavailable')} /> : null}
|
||||
</div>
|
||||
{visibleOauthModels.length === 0 ? (
|
||||
<div className="model-discovery-message"><strong>{oauthModels.length ? t('authFiles.models.noMatch') : t('authFiles.models.empty')}</strong></div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MessageNotice } from '../appNotice';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { Check, LoaderCircle, RefreshCw, RotateCcw, Search, X } from 'lucide-react';
|
||||
@@ -170,9 +171,11 @@ export function CodexModelCatalogDialog({ onClose, onSaved }: CodexModelCatalogD
|
||||
setSnapshot(result.snapshot);
|
||||
setModels(cloneModels(result.snapshot));
|
||||
setDefaultsRestored(false);
|
||||
setNotice(result.synchronizationError
|
||||
? t('agents.catalog.syncFailed', { error: result.synchronizationError })
|
||||
: t('agents.catalog.saved'));
|
||||
if (result.synchronizationError) {
|
||||
setError(t('agents.catalog.syncFailed', { error: result.synchronizationError }));
|
||||
} else {
|
||||
setNotice(t('agents.catalog.saved'));
|
||||
}
|
||||
await onSaved();
|
||||
} catch (requestError) {
|
||||
const message = String(requestError);
|
||||
@@ -290,9 +293,9 @@ export function CodexModelCatalogDialog({ onClose, onSaved }: CodexModelCatalogD
|
||||
|
||||
<footer className="codex-catalog-footer">
|
||||
<div>
|
||||
{error ? <span className="agent-inline-message error" role="alert">{error}</span> : null}
|
||||
{!error && notice ? <span className="agent-inline-message" role="status">{notice}</span> : null}
|
||||
{!error && !notice ? <span>{dirty ? t('agents.catalog.unsaved') : t('agents.catalog.saveHint')}</span> : null}
|
||||
{error ? <MessageNotice message={error} onDismiss={() => setError('')} /> : null}
|
||||
<MessageNotice tone="success" message={!error ? notice : null} onDismiss={() => setNotice('')} />
|
||||
<span>{dirty ? t('agents.catalog.unsaved') : t('agents.catalog.saveHint')}</span>
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" className="secondary-button" onClick={() => void restoreAll()} disabled={loading || saving}>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MessageNotice } from '../appNotice';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
@@ -6,7 +7,6 @@ import {
|
||||
ArrowRight,
|
||||
Archive,
|
||||
Database,
|
||||
Info,
|
||||
LoaderCircle,
|
||||
RefreshCw,
|
||||
ScanSearch,
|
||||
@@ -41,7 +41,6 @@ type DeleteConfirmation = {
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
// Retain the user's place across agent/tab navigation; always reload the actual sessions.
|
||||
let sessionViewCache = {
|
||||
offset: 0,
|
||||
selectionMode: false,
|
||||
@@ -387,13 +386,7 @@ export function CodexSessionsPanel() {
|
||||
|
||||
</section>
|
||||
|
||||
{notice ? (
|
||||
<div className={`codex-session-notice ${notice.kind}`} role={notice.kind === 'error' ? 'alert' : 'status'}>
|
||||
{notice.kind === 'error' ? <TriangleAlert size={16} /> : notice.kind === 'warning' ? <Info size={16} /> : <ShieldCheck size={16} />}
|
||||
<span>{notice.message}</span>
|
||||
<button type="button" aria-label={t('common.close')} onClick={() => setNotice(null)}><X size={14} /></button>
|
||||
</div>
|
||||
) : null}
|
||||
<MessageNotice message={notice?.message} tone={notice?.kind === 'warning' ? 'info' : notice?.kind ?? 'info'} onDismiss={() => setNotice(null)} />
|
||||
|
||||
<section className="codex-session-list-section">
|
||||
<div className="codex-session-list-head">
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { useCoreRuntime, type CoreStatus } from '../coreRuntime';
|
||||
import { useI18n } from '../i18n';
|
||||
import { InlineNotice, useAppNotice } from '../appNotice';
|
||||
import { MessageNotice, FloatingNotice, useAppNotice } from '../appNotice';
|
||||
import { webUiManagementUrl } from '../services/clientAccess';
|
||||
import { ThinkingAliasesPage } from './ThinkingAliasesPage';
|
||||
|
||||
@@ -185,7 +185,7 @@ export function ConfigPanelPage() {
|
||||
const tlsFeedback = useAppNotice();
|
||||
const softwareFeedback = useAppNotice();
|
||||
const renderFeedback = (feedback: ReturnType<typeof useAppNotice>) => (
|
||||
<InlineNotice key={feedback.revision} notice={feedback.notice} onDismiss={feedback.clearNotice} />
|
||||
<FloatingNotice key={feedback.revision} notice={feedback.notice} onDismiss={feedback.clearNotice} />
|
||||
);
|
||||
const [activeSubpage, setActiveSubpage] = useState<ConfigSubpage>('general');
|
||||
const [portDraft, setPortDraft] = useState('8317');
|
||||
@@ -1903,9 +1903,8 @@ export function ConfigPanelPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={`config-tls-message ${tlsError ? 'error' : ''}`} role={tlsError ? 'alert' : undefined}>
|
||||
{tlsError || t('config.tls.restartHint')}
|
||||
</div>
|
||||
<MessageNotice message={tlsError} onDismiss={() => setTlsError('')} />
|
||||
<div className="config-tls-message">{t('config.tls.restartHint')}</div>
|
||||
{renderFeedback(tlsFeedback)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MessageNotice } from '../appNotice';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { LoaderCircle, RefreshCw, RotateCcw, Search, Settings2, X } from 'lucide-react';
|
||||
@@ -127,7 +128,7 @@ export function DeepSeekHarnessCatalogDialog({ onClose, onSaved }: { onClose: ()
|
||||
if (event.key === 'Escape') { event.stopPropagation(); if (confirmation) setConfirmation(null); else close(); }
|
||||
if (event.key === 'Tab') {
|
||||
const scope = confirmation ? dialogRef.current?.querySelector('[role="alertdialog"]') : dialogRef.current;
|
||||
const targets = [...(scope?.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), summary') ?? [])].filter(el => el.getClientRects().length);
|
||||
const targets = [...(scope?.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), summary, [tabindex="0"]') ?? [])].filter(el => el.getClientRects().length);
|
||||
const first = targets[0], last = targets[targets.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
|
||||
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
|
||||
@@ -182,7 +183,7 @@ export function DeepSeekHarnessCatalogDialog({ onClose, onSaved }: { onClose: ()
|
||||
</> : null}
|
||||
</fieldset></main>
|
||||
</div>
|
||||
<footer className="codex-catalog-footer"><div aria-live="polite">{error ? <span className="agent-inline-message error" role="alert">{error}</span> : notice ? <span role="status">{notice}</span> : <span>{t(dirty ? 'agents.catalog.unsaved' : 'agents.harness.saveHint')}</span>}</div><div>
|
||||
<footer className="codex-catalog-footer"><div><MessageNotice message={error} onDismiss={() => setError('')} /><MessageNotice tone="success" message={!error ? notice : null} onDismiss={() => setNotice('')} /><span>{t(dirty ? 'agents.catalog.unsaved' : 'agents.harness.saveHint')}</span></div><div>
|
||||
<button className="secondary-button" disabled={loading || saving || !snapshot} onClick={() => { setDrafts(Object.fromEntries(snapshot!.models.map(m => [m.id, {}]))); setProvider({}); changed(); }}>{t('agents.catalog.resetAll')}</button>
|
||||
<button className="secondary-button" disabled={saving} onClick={close}>{t('common.cancel')}</button><button className="primary-button" disabled={!dirty || loading || saving} onClick={() => void save()}>{saving ? <LoaderCircle size={16} className="spin" /> : null}{t(saving ? 'common.saving' : 'common.save')}</button>
|
||||
</div></footer>
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
import appLogo from "../assets/logo.jpg";
|
||||
import { useI18n, languageOptions, type AppLocale } from "../i18n";
|
||||
import type { MessageKey } from "../i18n/resources";
|
||||
import { InlineNotice, useAppNotice, type NoticeMessage } from "../appNotice";
|
||||
import { MessageNotice, FloatingNotice, useAppNotice, type NoticeMessage } from "../appNotice";
|
||||
import {
|
||||
managementApi,
|
||||
readString,
|
||||
@@ -155,7 +155,6 @@ export function EasyModePage({
|
||||
const [guideApiModelsFetched, setGuideApiModelsFetched] = useState(false);
|
||||
const [guideAgentConfigured, setGuideAgentConfigured] = useState(false);
|
||||
|
||||
// 新手聚焦指导状态(默认关闭,点击开启)
|
||||
const [guideActive, setGuideActive] = useState(false);
|
||||
const [guideStep, setGuideStep] = useState<number>(1);
|
||||
const [spotlightRect, setSpotlightRect] = useState<{
|
||||
@@ -454,7 +453,6 @@ export function EasyModePage({
|
||||
}
|
||||
};
|
||||
|
||||
// 获取当前聚焦点元素 ID (4 个稳定步骤)
|
||||
const currentTargetId = (() => {
|
||||
if (!guideActive) return null;
|
||||
if (activeStep === 1) {
|
||||
@@ -467,7 +465,6 @@ export function EasyModePage({
|
||||
return null;
|
||||
})();
|
||||
|
||||
// 计算聚光灯位置
|
||||
const updateSpotlightPosition = useCallback(() => {
|
||||
if (!guideActive || !currentTargetId) {
|
||||
setSpotlightRect(null);
|
||||
@@ -576,7 +573,6 @@ export function EasyModePage({
|
||||
? hasConnectedSource || guideOAuthCompleted || guideApiSaved
|
||||
: guideAgentConfigured;
|
||||
|
||||
// 指引步骤控制
|
||||
const handleNextGuideStep = () => {
|
||||
if (!guideCanAdvance) return;
|
||||
if (guideStep === 1) {
|
||||
@@ -620,10 +616,8 @@ export function EasyModePage({
|
||||
|
||||
return (
|
||||
<section className="page simple-mode-page simple-mode-expanded">
|
||||
{/* 全灰色聚焦蒙层 (Dimmed Backdrop) */}
|
||||
{guideActive ? <div className="guide-dimmed-overlay" /> : null}
|
||||
|
||||
{/* 顶部全宽导航栏 */}
|
||||
<header className="simple-mode-topbar">
|
||||
<div className="simple-mode-topbar-left">
|
||||
<div className="simple-mode-brand">
|
||||
@@ -636,7 +630,6 @@ export function EasyModePage({
|
||||
<span className="simple-mode-brand-sub">{t("easyMode.brandSub")}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* 新手聚焦指导开关 */}
|
||||
<button
|
||||
type="button"
|
||||
className={`simple-mode-guide-toggle simple-mode-highlight-button${guideActive ? " active" : ""}`}
|
||||
@@ -650,7 +643,6 @@ export function EasyModePage({
|
||||
</div>
|
||||
|
||||
<div className="simple-mode-topbar-right">
|
||||
{/* 主题切换 */}
|
||||
{setTheme ? (
|
||||
<div className="simple-mode-theme-group" role="group" aria-label={t("app.theme.label")}>
|
||||
<button
|
||||
@@ -686,7 +678,6 @@ export function EasyModePage({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* 语言选择 */}
|
||||
<div className="simple-mode-lang-dropdown">
|
||||
<button
|
||||
type="button"
|
||||
@@ -719,7 +710,6 @@ export function EasyModePage({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 退出新手模式返回常规控制台 */}
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button simple-mode-exit-btn simple-mode-highlight-button"
|
||||
@@ -731,7 +721,6 @@ export function EasyModePage({
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 步骤条进度指示器 */}
|
||||
<nav className="simple-mode-step-status" aria-label={t("easyMode.steps.label")}>
|
||||
<div className="simple-mode-step-status-heading">
|
||||
<span>{t("easyMode.steps.label")}</span>
|
||||
@@ -775,7 +764,6 @@ export function EasyModePage({
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* 第一步:接入模型 */}
|
||||
{activeStep === 1 ? (
|
||||
<section className="panel simple-mode-task">
|
||||
<div className="simple-mode-task-heading">
|
||||
@@ -785,7 +773,6 @@ export function EasyModePage({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 连接方式选择卡片 */}
|
||||
<div
|
||||
id="easy-guide-choice-grid"
|
||||
className={`simple-mode-choice-grid${guideActive && guideStep === 1 ? " guide-focus-highlight" : ""}`}
|
||||
@@ -835,13 +822,12 @@ export function EasyModePage({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* OAuth 平台列表 */}
|
||||
{authMethod === "oauth" ? (
|
||||
<div
|
||||
id="easy-guide-oauth-box"
|
||||
className={`simple-mode-embedded-box${guideActive && guideStep === 2 ? " guide-focus-highlight" : ""}`}
|
||||
>
|
||||
<InlineNotice key={oauthFeedback.revision} notice={oauthFeedback.notice} onDismiss={clearOAuthNotice} />
|
||||
<FloatingNotice key={oauthFeedback.revision} notice={oauthFeedback.notice} onDismiss={clearOAuthNotice} />
|
||||
<div className="simple-mode-provider-grid">
|
||||
{oauthProviders.map((provider) => {
|
||||
const loggedIn = isOAuthLoggedIn(provider.id);
|
||||
@@ -897,19 +883,17 @@ export function EasyModePage({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* API 接入表单 */}
|
||||
{authMethod === "api" ? (
|
||||
<div
|
||||
id="easy-guide-api-box"
|
||||
className={`simple-mode-embedded-box${guideActive && guideStep === 2 ? " guide-focus-highlight" : ""}`}
|
||||
>
|
||||
<InlineNotice key={apiFeedback.revision} notice={apiFeedback.notice} onDismiss={clearApiNotice} />
|
||||
<FloatingNotice key={apiFeedback.revision} notice={apiFeedback.notice} onDismiss={clearApiNotice} />
|
||||
{apiTestError ? (
|
||||
<div className="management-alert error" role="alert">{apiTestError}</div>
|
||||
<MessageNotice message={apiTestError} onDismiss={() => setApiTestError("")} />
|
||||
) : null}
|
||||
|
||||
<div className="simple-mode-api-form">
|
||||
{/* 平台格式切换 */}
|
||||
<div className="simple-mode-api-platforms">
|
||||
{apiSectionOptions.map((opt) => (
|
||||
<button
|
||||
@@ -968,7 +952,6 @@ export function EasyModePage({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模型拉取与选择 */}
|
||||
<div className="simple-mode-api-model-card">
|
||||
{apiTestedModels.length === 0 ? (
|
||||
<div className="simple-mode-api-model-fetch">
|
||||
@@ -1058,7 +1041,6 @@ export function EasyModePage({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* 第一步底部操作栏 */}
|
||||
<div
|
||||
id="easy-guide-footer-action"
|
||||
className={`simple-mode-task-footer${guideActive && guideStep === 3 ? " guide-focus-highlight" : ""}`}
|
||||
@@ -1091,7 +1073,6 @@ export function EasyModePage({
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* 第二步:接入智能体 */}
|
||||
{activeStep === 2 ? (
|
||||
<section
|
||||
id="easy-guide-agents-panel"
|
||||
@@ -1116,7 +1097,6 @@ export function EasyModePage({
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* 悬浮指导卡片 */}
|
||||
{guideActive && spotlightRect ? (
|
||||
<aside
|
||||
ref={guideTooltipRef}
|
||||
|
||||
@@ -15,7 +15,7 @@ import geminiIcon from '../assets/icons/gemini.svg';
|
||||
import { clientApiProfiles } from '../services/clientAccess';
|
||||
import { useI18n } from '../i18n';
|
||||
import { useAppUpdate } from '../appUpdate';
|
||||
import { InlineNotice, useAppNotice } from '../appNotice';
|
||||
import { FloatingNotice, useAppNotice } from '../appNotice';
|
||||
import { VersionManagementPage, displayAppVersion } from './VersionManagementPage';
|
||||
|
||||
type CoreProcessCommand = 'start_core_process' | 'stop_core_process' | 'restart_core_process';
|
||||
@@ -283,7 +283,7 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
|
||||
{t('kernel.control.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
<InlineNotice key={processFeedback.revision} notice={processFeedback.notice} onDismiss={processFeedback.clearNotice} />
|
||||
<FloatingNotice key={processFeedback.revision} notice={processFeedback.notice} onDismiss={processFeedback.clearNotice} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -331,7 +331,7 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<InlineNotice key={copyFeedback.revision} notice={copyFeedback.notice} onDismiss={copyFeedback.clearNotice} />
|
||||
<FloatingNotice key={copyFeedback.revision} notice={copyFeedback.notice} onDismiss={copyFeedback.clearNotice} />
|
||||
<div className="client-api-grid">
|
||||
{apiProfiles.map((profile) => (
|
||||
<article key={profile.id} className={`client-api-card ${profile.id}`}>
|
||||
|
||||
@@ -14,7 +14,7 @@ import codexIcon from '../assets/icons/codex.svg';
|
||||
import grokIcon from '../assets/icons/grok.svg';
|
||||
import kimiIcon from '../assets/icons/kimi-light.svg';
|
||||
import { useI18n } from '../i18n';
|
||||
import { InlineNotice, useAppNotice } from '../appNotice';
|
||||
import { FloatingNotice, useAppNotice } from '../appNotice';
|
||||
import { oauthSubpages, type OAuthSubpage } from '../oauthNavigation';
|
||||
import {
|
||||
changedOAuthAuthFileNames,
|
||||
@@ -192,7 +192,6 @@ export function OAuthLoginPage() {
|
||||
try {
|
||||
window.localStorage.setItem(OAUTH_BROWSER_STORAGE_KEY, selectedBrowser);
|
||||
} catch {
|
||||
// Keep the in-memory selection when persistent storage is unavailable.
|
||||
}
|
||||
}, [selectedBrowser]);
|
||||
|
||||
@@ -486,7 +485,7 @@ export function OAuthLoginPage() {
|
||||
</label>
|
||||
</header>
|
||||
|
||||
<InlineNotice key={feedback.revision} notice={feedback.notice} onDismiss={feedback.clearNotice} />
|
||||
<FloatingNotice key={feedback.revision} notice={feedback.notice} onDismiss={feedback.clearNotice} />
|
||||
<div className="oauth-grid">
|
||||
{oauthProviders.map((provider) => {
|
||||
const state = states[provider.id] ?? { status: 'idle' as const };
|
||||
@@ -546,18 +545,8 @@ export function OAuthLoginPage() {
|
||||
{t('oauth.submitCallback')}
|
||||
</button>
|
||||
</div>
|
||||
{state.callbackStatus === 'success' && state.status === 'waiting' ? (
|
||||
<div className="oauth-inline-status success">{t('oauth.callbackSubmitted')}</div>
|
||||
) : null}
|
||||
{state.callbackStatus === 'error' ? (
|
||||
<div className="oauth-inline-status error">{t('oauth.callbackFailed', { detail: state.callbackError ? `: ${state.callbackError}` : '' })}</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.status === 'error' && state.error ? (
|
||||
<div className="oauth-inline-status error">{state.error}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="button-row management-card-actions">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MessageNotice } from '../appNotice';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { AlertCircle, LoaderCircle, RefreshCw } from 'lucide-react';
|
||||
import { useConfirmation } from '../components/ConfirmationDialog';
|
||||
@@ -167,7 +168,7 @@ export function QuotaPage() {
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
{error ? <div className="management-alert error">{error}</div> : null}
|
||||
{error ? <MessageNotice message={error} /> : null}
|
||||
{loading ? (
|
||||
<div className="management-loading"><LoaderCircle size={20} className="spin" />{t('quota.loadingFiles')}</div>
|
||||
) : grouped.length === 0 ? (
|
||||
@@ -201,16 +202,19 @@ export function QuotaCard({ file, quota, onRefresh, onReset }: { file: AuthFile;
|
||||
<button type="button" className="icon-button quiet" onClick={onRefresh} disabled={disabled || quota.status === 'loading'} title={disabled ? t('quota.fileDisabled') : t('quota.refresh')}><RefreshCw size={16} className={quota.status === 'loading' ? 'spin' : ''} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<QuotaActionFeedback quota={quota} />
|
||||
<QuotaActionFeedback quota={quota} name={name} />
|
||||
{quota.status === 'idle' ? <div className="quota-card-message"><span>{disabled ? t('quota.fileDisabled') : t('quota.notFetched')}</span><button type="button" className="secondary-button compact-button" onClick={onRefresh} disabled={disabled}>{disabled ? t('quota.disabled') : t('quota.fetch')}</button></div> : null}
|
||||
{quota.status === 'loading' ? <div className="quota-card-message"><LoaderCircle size={18} className="spin" />{t(quota.pendingAction === 'reset' ? 'quota.resetting' : 'quota.querying')}</div> : null}
|
||||
{quota.status === 'error' ? <div className="quota-card-error"><AlertCircle size={18} />{quota.error}</div> : null}
|
||||
{quota.status === 'error' ? <>
|
||||
<MessageNotice message={quota.error ? name + ': ' + quota.error : null} />
|
||||
<div className="quota-card-error"><AlertCircle size={18} />{t('authFiles.quota.failed')}</div>
|
||||
</> : null}
|
||||
{quota.status === 'success' && provider === 'codex' ? <div className="quota-reset-credit-summary">
|
||||
<span>{t('quota.resetCredits')} <strong>{quota.resetCredits ?? '—'}</strong></span>
|
||||
{quota.resetCreditsApplicable !== undefined ? <span>{t('quota.resetApplicable', { count: quota.resetCreditsApplicable })}</span> : null}
|
||||
<span>{t('quota.earliestExpiry')} <strong>{formatQuotaTimestamp(quota.resetCreditsEarliestExpiry, locale)}</strong></span>
|
||||
{quota.subscriptionActiveUntil ? <span>{t('quota.subscriptionExpiry', { time: formatQuotaTimestamp(quota.subscriptionActiveUntil, locale) })}</span> : null}
|
||||
{quota.resetCreditsError ? <small>{t('quota.resetCreditsWarning', { error: quota.resetCreditsError })}</small> : null}
|
||||
<MessageNotice message={quota.resetCreditsError ? name + ': ' + t('quota.resetCreditsWarning', { error: quota.resetCreditsError }) : null} />
|
||||
</div> : null}
|
||||
{quota.status === 'success' ? <div className="quota-row-list">{quota.rows.map((row, index) => {
|
||||
const reset = formatQuotaReset(row.resetAtMs, row.reset, locale, now);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MessageNotice } from '../appNotice';
|
||||
import { useConfirmation } from '../components/ConfirmationDialog';
|
||||
import {
|
||||
type KeyboardEvent,
|
||||
@@ -568,10 +569,8 @@ export function ThinkingAliasesPage() {
|
||||
return (
|
||||
<section className="page management-page thinking-alias-page">
|
||||
{confirmationDialog}
|
||||
<div className="thinking-alias-feedback" aria-live="polite">
|
||||
{error && !editorOpen ? <div className="management-alert error">{error}</div> : null}
|
||||
{!error && notice ? <div className="management-alert success">{notice}</div> : null}
|
||||
</div>
|
||||
<MessageNotice message={error} onDismiss={() => setError('')} />
|
||||
<MessageNotice tone="success" message={!error ? notice : null} onDismiss={() => setNotice('')} />
|
||||
|
||||
<header className="management-header">
|
||||
<div>
|
||||
@@ -614,7 +613,7 @@ export function ThinkingAliasesPage() {
|
||||
</div>
|
||||
|
||||
<div className="thinking-alias-dialog-body">
|
||||
{error ? <div className="management-alert error" role="alert">{error}</div> : null}
|
||||
|
||||
<div className="thinking-alias-field thinking-model-field">
|
||||
<label htmlFor="thinking-model-search">{t('aliases.originalModel')}</label>
|
||||
<div className="thinking-model-picker" ref={modelPickerRef}>
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { getCurrentLocale, useI18n } from '../i18n';
|
||||
import { MessageNotice, FloatingNotice, useAppNotice } from '../appNotice';
|
||||
import type { MessageKey } from '../i18n/resources';
|
||||
import { formatCacheReadRate, formatGenerationSpeed } from '../services/usageMetrics';
|
||||
import { formatUsageNumber } from '../services/usageNumber';
|
||||
@@ -301,7 +302,6 @@ export function UsageRecordsPage() {
|
||||
try {
|
||||
localStorage.setItem(TAB_KEY, activeTab);
|
||||
} catch {
|
||||
/* Keep in-memory */
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
@@ -309,7 +309,6 @@ export function UsageRecordsPage() {
|
||||
try {
|
||||
localStorage.setItem(RANGE_KEY, range);
|
||||
} catch {
|
||||
/* Keep in-memory */
|
||||
}
|
||||
}, [range]);
|
||||
|
||||
@@ -473,7 +472,7 @@ export function UsageRecordsPage() {
|
||||
|
||||
return (
|
||||
<section className="page management-page usage-records-page">
|
||||
{error ? <div className="management-alert error">{error}</div> : null}
|
||||
{error ? <MessageNotice message={error} onDismiss={() => setError('')} /> : null}
|
||||
|
||||
<div className="usage-topbar">
|
||||
<div className="usage-tabs" role="tablist" aria-label={t('usage.pageLabel')}>
|
||||
@@ -775,13 +774,10 @@ function UsageDataManagementView() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error ? <div className="management-alert error">{error}</div> : null}
|
||||
{error ? <MessageNotice message={error} onDismiss={() => setError('')} /> : null}
|
||||
{result ? (
|
||||
<>
|
||||
<div className="management-alert success">
|
||||
<ShieldCheck size={16} aria-hidden="true" />
|
||||
<span>{t('usage.dataManagement.success', { repaired: result.repaired, deleted: result.deleted })}</span>
|
||||
</div>
|
||||
<MessageNotice tone="success" message={t('usage.dataManagement.success', { repaired: result.repaired, deleted: result.deleted })} />
|
||||
<div className="usage-data-management-result">
|
||||
<div><span>{t('usage.dataManagement.scanned')}</span><strong>{result.scanned.toLocaleString()}</strong></div>
|
||||
<div><span>{t('usage.dataManagement.repaired')}</span><strong>{result.repaired.toLocaleString()}</strong></div>
|
||||
@@ -1189,7 +1185,6 @@ const getInitialVisibleColumns = (): EventColumnKey[] => {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// fallback to defaults
|
||||
}
|
||||
return [...DEFAULT_EVENT_VISIBLE_COLUMNS];
|
||||
};
|
||||
@@ -1216,7 +1211,6 @@ const getInitialColumnWidths = (): Record<EventColumnKey, number> => {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// fallback to defaults
|
||||
}
|
||||
return initial;
|
||||
};
|
||||
@@ -1858,8 +1852,9 @@ function PricingView({
|
||||
const [draft, setDraft] = useState<PriceDraft | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [localError, setLocalError] = useState('');
|
||||
const { notice, revision, showNotice, clearNotice } = useAppNotice();
|
||||
|
||||
|
||||
const visibleRows = pricing.rows.filter((row) => {
|
||||
const keyword = search.trim().toLowerCase();
|
||||
return !keyword || row.model.toLowerCase().includes(keyword);
|
||||
@@ -1867,11 +1862,11 @@ function PricingView({
|
||||
|
||||
const savePrice = async () => {
|
||||
if (!draft?.model.trim()) {
|
||||
setLocalError(t('usage.pricing.modelRequired'));
|
||||
showNotice({ key: 'usage.pricing.modelRequired' }, 'error');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setLocalError('');
|
||||
clearNotice();
|
||||
try {
|
||||
await invoke('save_usage_model_price', {
|
||||
price: {
|
||||
@@ -1891,10 +1886,10 @@ function PricingView({
|
||||
} satisfies ModelPrice,
|
||||
});
|
||||
setDraft(null);
|
||||
setMessage(t('usage.pricing.saved'));
|
||||
showNotice({ key: 'usage.pricing.saved' });
|
||||
await onChanged();
|
||||
} catch (saveError) {
|
||||
setLocalError(String(saveError));
|
||||
showNotice(String(saveError), 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -1902,30 +1897,32 @@ function PricingView({
|
||||
|
||||
const deletePrice = async (model: string) => {
|
||||
if (!await askConfirmation({ title: t('common.delete'), message: t('usage.pricing.deleteConfirm', { model }), confirmText: t('common.delete'), variant: 'danger' })) return;
|
||||
clearNotice();
|
||||
try {
|
||||
await invoke('delete_usage_model_price', { model });
|
||||
setMessage(t('usage.pricing.deleted'));
|
||||
showNotice({ key: 'usage.pricing.deleted' });
|
||||
await onChanged();
|
||||
} catch (deleteError) {
|
||||
setLocalError(String(deleteError));
|
||||
showNotice(String(deleteError), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const syncPrices = async () => {
|
||||
setSyncing(true);
|
||||
setLocalError('');
|
||||
clearNotice();
|
||||
try {
|
||||
const result = await invoke<ModelPriceSyncResult>('sync_usage_model_prices', { query });
|
||||
setMessage(
|
||||
t('usage.pricing.syncResult', {
|
||||
showNotice({
|
||||
key: 'usage.pricing.syncResult',
|
||||
variables: {
|
||||
imported: result.imported,
|
||||
skipped: result.skipped,
|
||||
unmatched: result.unmatched.length,
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
await onChanged();
|
||||
} catch (syncError) {
|
||||
setLocalError(String(syncError));
|
||||
showNotice(String(syncError), 'error');
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
@@ -1962,8 +1959,7 @@ function PricingView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{localError ? <div className="management-alert error">{localError}</div> : null}
|
||||
{message ? <div className="management-alert success">{message}</div> : null}
|
||||
<FloatingNotice key={revision} notice={notice} onDismiss={clearNotice} />
|
||||
|
||||
{draft ? (
|
||||
<div className="usage-price-editor">
|
||||
|
||||
@@ -3,7 +3,6 @@ import { getVersion } from '@tauri-apps/api/app';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import {
|
||||
AlertCircle,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Info,
|
||||
@@ -15,7 +14,7 @@ import { useCoreRuntime } from '../coreRuntime';
|
||||
import { useCoreUpdate } from '../coreUpdate';
|
||||
import { useI18n } from '../i18n';
|
||||
import { useAppUpdate } from '../appUpdate';
|
||||
import { InlineNotice, useAppNotice } from '../appNotice';
|
||||
import { MessageNotice, FloatingNotice, useAppNotice } from '../appNotice';
|
||||
import { createVersionManagementVisitTracker } from '../services/versionManagementVisits';
|
||||
|
||||
export type CoreInstallResult = {
|
||||
@@ -198,7 +197,6 @@ export function VersionManagementPage() {
|
||||
} catch (error) {
|
||||
await loadVersionSourceSettings();
|
||||
setVersionSourceError(t('kernel.versions.gitcodeSaveFailed', { error: String(error) }));
|
||||
showNotice({ key: 'kernel.versions.gitcodeSaveFailed', variables: { error: String(error) } }, 'error');
|
||||
} finally {
|
||||
setVersionSourceSaving(false);
|
||||
}
|
||||
@@ -219,7 +217,6 @@ export function VersionManagementPage() {
|
||||
} catch (error) {
|
||||
const message = t('kernel.versions.customMirrorAddFailed', { error: String(error) });
|
||||
setVersionSourceError(message);
|
||||
showNotice(message, 'error');
|
||||
} finally {
|
||||
setVersionSourceSaving(false);
|
||||
}
|
||||
@@ -241,7 +238,6 @@ export function VersionManagementPage() {
|
||||
} catch (error) {
|
||||
const message = t('kernel.versions.customMirrorRemoveFailed', { error: String(error) });
|
||||
setVersionSourceError(message);
|
||||
showNotice(message, 'error');
|
||||
} finally {
|
||||
setVersionSourceSaving(false);
|
||||
}
|
||||
@@ -438,7 +434,6 @@ export function VersionManagementPage() {
|
||||
};
|
||||
}, [customMirrorDialogOpen, versionSourceSaving]);
|
||||
|
||||
// Derived state calculations
|
||||
const latestVersion = latest?.version ?? '';
|
||||
const currentVersion = coreStatus?.currentVersion ?? '';
|
||||
const coreInstalled = Boolean(coreStatus?.installed);
|
||||
@@ -484,7 +479,6 @@ export function VersionManagementPage() {
|
||||
? t('kernel.status.notInstalled')
|
||||
: null;
|
||||
|
||||
// Install dialog calculations
|
||||
const computedPercent = progress?.percent ?? (progress?.total && progress.total > 0 ? (progress.downloaded / progress.total) * 100 : null);
|
||||
const progressKnown = computedPercent !== null;
|
||||
const progressPercent = clampPercent(computedPercent ?? 0);
|
||||
@@ -532,6 +526,7 @@ export function VersionManagementPage() {
|
||||
|
||||
return (
|
||||
<section className="page management-page version-management-page">
|
||||
<MessageNotice message={versionSourceError} onDismiss={() => setVersionSourceError('')} />
|
||||
<section className="panel version-list">
|
||||
<div className="version-source-row" aria-label={t('kernel.versions.downloadSource')}>
|
||||
<div className="version-source-copy">
|
||||
@@ -540,9 +535,6 @@ export function VersionManagementPage() {
|
||||
{versionSource?.gitcodeAvailable === false ? (
|
||||
<span>{t('kernel.versions.gitcodeUnavailable')}</span>
|
||||
) : null}
|
||||
{versionSourceError ? (
|
||||
<span className="version-source-error" role="alert">{versionSourceError}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="version-source-control">
|
||||
<label>
|
||||
@@ -585,7 +577,7 @@ export function VersionManagementPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InlineNotice key={feedback.revision} notice={feedback.notice} onDismiss={feedback.clearNotice} />
|
||||
<FloatingNotice key={feedback.revision} notice={feedback.notice} onDismiss={feedback.clearNotice} />
|
||||
<div className="version-card-grid">
|
||||
<article className="version-list-item app-module-card">
|
||||
<div className="version-item-content">
|
||||
@@ -611,12 +603,8 @@ export function VersionManagementPage() {
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{appUpdateError ? (
|
||||
<div className="version-alert-banner error" role="alert">
|
||||
<AlertCircle size={14} />
|
||||
<span>{appUpdateError}</span>
|
||||
</div>
|
||||
) : !appUpdate?.autoUpdateSupported ? (
|
||||
<MessageNotice message={appUpdateError} />
|
||||
{!appUpdate?.autoUpdateSupported ? (
|
||||
<div className="version-alert-banner neutral">
|
||||
<Info size={14} />
|
||||
<span>{t('appUpdate.manualFallback')}</span>
|
||||
@@ -685,10 +673,7 @@ export function VersionManagementPage() {
|
||||
</dl>
|
||||
|
||||
{latestError ? (
|
||||
<div className="version-alert-banner error" role="alert">
|
||||
<AlertCircle size={14} />
|
||||
<span>{latestError}</span>
|
||||
</div>
|
||||
<MessageNotice message={latestError} />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -726,7 +711,6 @@ export function VersionManagementPage() {
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -758,9 +742,7 @@ export function VersionManagementPage() {
|
||||
aria-label={t('kernel.versions.customMirrorPlaceholder')}
|
||||
onChange={(event) => setCustomMirrorDraft(event.currentTarget.value)}
|
||||
/>
|
||||
{versionSourceError ? (
|
||||
<span className="custom-mirror-dialog-error" role="alert">{versionSourceError}</span>
|
||||
) : null}
|
||||
|
||||
{versionSource?.customMirrors.length ? (
|
||||
<div className="custom-mirror-dialog-list">
|
||||
<span>{t('kernel.versions.customMirrorSaved')}</span>
|
||||
@@ -805,7 +787,6 @@ export function VersionManagementPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Core Update Confirmation Dialog */}
|
||||
{confirmUpdateOpen ? (
|
||||
<div className="install-dialog-backdrop app-update-dialog-backdrop">
|
||||
<section
|
||||
@@ -849,7 +830,6 @@ export function VersionManagementPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Installation Progress Modal Dialog */}
|
||||
{installDialogOpen && progress ? (
|
||||
<div className="install-dialog-backdrop">
|
||||
<div
|
||||
|
||||
@@ -164,7 +164,6 @@ const messageFromPayload = (value: unknown, depth = 0): string => {
|
||||
const nested = messageFromPayload(parsed, depth + 1);
|
||||
if (nested) return nested;
|
||||
} catch {
|
||||
// The response is plain text rather than JSON.
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
@@ -234,7 +234,6 @@ export async function fetchModels(
|
||||
if (models.length) return models;
|
||||
}
|
||||
} catch {
|
||||
// Keep the authenticated request error as the useful failure reason.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,14 +32,12 @@ export const authFileExcludedRulesFromPayload = (payload: unknown): string[] =>
|
||||
try {
|
||||
metadata = JSON.parse(metadata);
|
||||
} catch {
|
||||
// Parser errors can quote credential contents; show a fixed message instead.
|
||||
throw new Error(translate(getCurrentLocale(), 'authFiles.models.invalidMetadata'));
|
||||
}
|
||||
}
|
||||
if (!isRecord(metadata)) {
|
||||
throw new Error(translate(getCurrentLocale(), 'authFiles.models.invalidMetadata'));
|
||||
}
|
||||
// CPA gives the canonical key precedence, including an explicit empty array or null.
|
||||
const rules = Object.prototype.hasOwnProperty.call(metadata, 'excluded_models')
|
||||
? metadata.excluded_models
|
||||
: metadata['excluded-models'];
|
||||
@@ -84,7 +82,6 @@ export const saveOAuthModelSettings = async (
|
||||
if (excludedModels.length === settings.excludedRules.length
|
||||
&& excludedModels.every((rule) => settings.excludedRules.includes(rule))) return;
|
||||
if (settings.target.scope === 'credential') {
|
||||
// Patch only this field: never upload a stale copy of tokens or other metadata.
|
||||
await api.patch('/auth-files/fields', {
|
||||
name: settings.target.name,
|
||||
excluded_models: excludedModels,
|
||||
|
||||
@@ -6,7 +6,7 @@ export const decodeQuotaToken = (value: unknown): Record<string, unknown> | null
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (isRecord(parsed)) return parsed;
|
||||
} catch { /* Try the JWT payload next. */ }
|
||||
} catch { }
|
||||
const segment = value.trim().split('.')[1];
|
||||
if (!segment) return null;
|
||||
try {
|
||||
|
||||
@@ -328,7 +328,6 @@ export const codexResetCreditDetailsFor = (
|
||||
.sort((left, right) => left.expiresAtMs - right.expiresAtMs)[0]?.expiresAt;
|
||||
|
||||
return {
|
||||
// An empty detail list is not an explicit zero; keep the usage-summary fallback.
|
||||
availableCount: availableCount === null
|
||||
? validCredits.length || undefined
|
||||
: Math.max(0, Math.floor(availableCount)),
|
||||
|
||||
@@ -52,7 +52,6 @@ export const formatQuotaReset = (
|
||||
return `${absolute} · ${label}`;
|
||||
};
|
||||
|
||||
// One clock shared by quota cards on both pages; no polling of upstream APIs.
|
||||
let now = Date.now();
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
+53
-10
@@ -4477,16 +4477,6 @@ textarea:focus-visible {
|
||||
color: var(--theme-2f6b3f);
|
||||
}
|
||||
|
||||
.action-feedback.inline-notice.api-access-page-notice {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
z-index: 80;
|
||||
width: min(360px, calc(100vw - 32px));
|
||||
margin: 0;
|
||||
box-shadow: 0 14px 34px rgba(45, 42, 38, 0.18);
|
||||
}
|
||||
|
||||
.real-provider-workbench {
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
}
|
||||
@@ -7450,6 +7440,16 @@ textarea:focus-visible {
|
||||
place-items: center;
|
||||
padding: 20px;
|
||||
border-radius: inherit;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
/* Blur the backdrop without making it a containing block for fixed notices. */
|
||||
.codex-catalog-confirm::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
border-radius: inherit;
|
||||
background: rgba(45, 42, 38, 0.35);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
@@ -14039,3 +14039,46 @@ body.table-col-resizing * {
|
||||
.harness-context-window > label > span { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.harness-context-source { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; }
|
||||
.harness-context-source > button { padding: 4px 8px; font-size: 12px; }
|
||||
|
||||
/* Operation feedback is shared across pages and dialogs, outside their layout. */
|
||||
.app-notice-stack {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: var(--notice-bottom, 24px);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: min(480px, calc(100vw - 48px));
|
||||
max-height: calc(100vh - var(--notice-bottom, 24px) - 24px);
|
||||
max-height: calc(100dvh - var(--notice-bottom, 24px) - 24px);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
pointer-events: none;
|
||||
}
|
||||
.app-notice-entry {
|
||||
flex: none;
|
||||
min-width: 0;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.app-notice-entry .action-feedback.inline-notice {
|
||||
margin: 0;
|
||||
box-shadow: 0 4px 16px rgba(45, 42, 38, 0.18);
|
||||
}
|
||||
|
||||
/* Persistent agent state uses reserved rows; transient results float above it. */
|
||||
.agent-shared-feedback {
|
||||
grid-template-rows: 20px 36px;
|
||||
}
|
||||
.agent-shared-feedback > .agent-write-state,
|
||||
.agent-shared-feedback > small {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
.agent-model-status {
|
||||
display: block;
|
||||
height: 20px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -121,7 +121,6 @@ describe('agent configuration update action', () => {
|
||||
'claude-desktop': false,
|
||||
};
|
||||
|
||||
// Switch from Claude Code to Desktop: Desktop still loads its own default.
|
||||
expect(resolveAgentModelMappingsDraftSourceForClient(
|
||||
drafts,
|
||||
'claude-desktop',
|
||||
@@ -130,7 +129,6 @@ describe('agent configuration update action', () => {
|
||||
dirtyByClient['claude-desktop'],
|
||||
)).toEqual(desktopFallback);
|
||||
|
||||
// Switch back to Claude Code: its unapplied selection must not be overwritten.
|
||||
expect(resolveAgentModelMappingsDraftSourceForClient(
|
||||
drafts,
|
||||
'claude-code',
|
||||
|
||||
@@ -15,11 +15,8 @@ describe('application confirmation and quota feedback', () => {
|
||||
expect(html).toContain('消耗 1 次重置机会');
|
||||
});
|
||||
|
||||
it('distinguishes a submitted reset whose follow-up query failed', () => {
|
||||
it('keeps reset feedback out of the card layout (message checked in browser)', () => {
|
||||
const html = renderToStaticMarkup(<I18nProvider><QuotaActionFeedback quota={{ status: 'success', rows: [], actionResult: { action: 'reset', status: 'refresh-error', error: 'query failed' } }} /></I18nProvider>);
|
||||
expect(html).toContain('重置请求已提交');
|
||||
expect(html).toContain('额度刷新失败');
|
||||
expect(html).toContain('避免重复消耗');
|
||||
expect(html).toContain('query failed');
|
||||
expect(html).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ const path = require('node:path');
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true });
|
||||
const page = await browser.newPage();
|
||||
await page.route('**/*', route => route.request().url().startsWith('http://127.0.0.1:1421/') ? route.continue() : route.abort());
|
||||
const errors = [];
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
const open = async (query = '') => {
|
||||
@@ -17,7 +18,7 @@ const path = require('node:path');
|
||||
const dialog = () => page.getByRole('dialog', { name: 'DeepSeek Harness 模型配置' });
|
||||
const save = async () => {
|
||||
await dialog().getByRole('button', { name: '保存', exact: true }).click();
|
||||
await dialog().getByRole('status').filter({ hasText: /已保存/ }).waitFor();
|
||||
await page.locator('.app-notice-stack').getByRole('status').filter({ hasText: /已保存/ }).waitFor();
|
||||
};
|
||||
try {
|
||||
for (const mode of ['', 'embedded&']) {
|
||||
@@ -78,18 +79,45 @@ const path = require('node:path');
|
||||
assert.equal(await dialog().getByRole('combobox', { name: '输入类型', exact: true }).inputValue(), '');
|
||||
await dialog().getByRole('spinbutton', { name: '上下文窗口(tokens)' }).fill('0');
|
||||
await dialog().getByRole('button', { name: '保存', exact: true }).click();
|
||||
await dialog().getByRole('alert').filter({ hasText: /contextWindow/ }).waitFor();
|
||||
const validation = page.locator('.app-notice-stack').getByRole('alert').filter({ hasText: /contextWindow/ });
|
||||
await validation.waitFor();
|
||||
await dialog().getByRole('button', { name: '保存', exact: true }).focus();
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(await validation.locator('.action-feedback-text').evaluate(el => el === document.activeElement), true);
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(await validation.getByRole('button').evaluate(el => el === document.activeElement), true);
|
||||
await page.keyboard.press('Enter');
|
||||
await validation.waitFor({ state: 'detached' });
|
||||
assert.equal(await dialog().getByRole('button', { name: '保存', exact: true }).evaluate(el => el === document.activeElement), true);
|
||||
const invalidWrites = await page.evaluate(() => window.fixtureCalls.filter(c => c.cmd === 'save_deepseek_harness_model_catalog_editor').length);
|
||||
await page.keyboard.press('Enter');
|
||||
await validation.waitFor();
|
||||
assert.equal(await page.evaluate(() => window.fixtureCalls.filter(c => c.cmd === 'save_deepseek_harness_model_catalog_editor').length), invalidWrites);
|
||||
await dialog().getByRole('spinbutton', { name: '上下文窗口(tokens)' }).fill('64000');
|
||||
await page.evaluate(() => window.fixtureFailHarnessSave = true);
|
||||
await dialog().getByRole('button', { name: '保存', exact: true }).click();
|
||||
await dialog().getByRole('alert').filter({ hasText: /模拟模型配置保存失败/ }).waitFor();
|
||||
await page.locator('.app-notice-stack').getByRole('alert').filter({ hasText: /模拟模型配置保存失败/ }).waitFor();
|
||||
assert.equal(await dialog().getByRole('spinbutton', { name: '上下文窗口(tokens)' }).inputValue(), '64000');
|
||||
await page.evaluate(() => { window.fixtureFailHarnessSave = false; window.fixtureStaleHarnessSave = true; });
|
||||
await dialog().getByRole('button', { name: '保存', exact: true }).click();
|
||||
await dialog().getByRole('alert').filter({ hasText: /模型列表或配置已变化/ }).waitFor();
|
||||
await page.locator('.app-notice-stack').getByRole('alert').filter({ hasText: /模型列表或配置已变化/ }).waitFor();
|
||||
await page.evaluate(() => window.fixtureStaleHarnessSave = false);
|
||||
await page.keyboard.press('Escape');
|
||||
await page.getByRole('alertdialog').waitFor();
|
||||
const confirmation = page.getByRole('alertdialog');
|
||||
await confirmation.waitFor();
|
||||
const nestedNotice = confirmation.locator('.app-notice-stack');
|
||||
await nestedNotice.waitFor();
|
||||
const nestedBounds = await nestedNotice.boundingBox();
|
||||
const viewport = page.viewportSize();
|
||||
assert.ok(Math.abs(viewport.width - nestedBounds.x - nestedBounds.width - 24) < 1, 'Nested modal notices stay at the viewport right edge');
|
||||
assert.ok(nestedBounds.y >= 0 && nestedBounds.y + nestedBounds.height <= viewport.height, 'Nested notices fit the viewport');
|
||||
const discard = confirmation.getByRole('button', { name: '放弃修改', exact: true });
|
||||
await discard.focus();
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(await nestedNotice.locator('.action-feedback-text').evaluate(el => el === document.activeElement), true);
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(await confirmation.getByRole('button', { name: '继续编辑', exact: true }).evaluate(el => el === document.activeElement), true, 'Notification remains within the nested focus trap');
|
||||
await page.getByRole('button', { name: '继续编辑', exact: true }).click();
|
||||
await save();
|
||||
assert.equal(await context().inputValue(), '64000');
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
<!doctype html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head><body><div id="root"></div><script type="module" src="./operation-feedback.tsx"></script></body></html>
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
import { StrictMode, useRef, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { mockIPC } from '@tauri-apps/api/mocks';
|
||||
import { I18nProvider } from '../../src/i18n';
|
||||
import { FloatingNotice, MessageNotice, useAppNotice } from '../../src/appNotice';
|
||||
import { UsageRecordsPage } from '../../src/pages/UsageRecordsPage';
|
||||
import { AgentConfigurationFeedback } from '../../src/pages/AgentControls';
|
||||
import { useConfirmation } from '../../src/components/ConfirmationDialog';
|
||||
import { QuotaActionFeedback } from '../../src/components/QuotaActionFeedback';
|
||||
import '../../src/styles.css';
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
localStorage.setItem('easy-cli-proxy-api.locale', params.get('locale') ?? 'zh-CN');
|
||||
localStorage.setItem('cpa-gui.usage-records-tab.v1', 'pricing');
|
||||
document.documentElement.dataset.theme = params.get('theme') ?? 'light';
|
||||
const fixture = window as typeof window & { feedbackFixture: { failSync: boolean } };
|
||||
fixture.feedbackFixture = { failSync: false };
|
||||
mockIPC(async (cmd) => {
|
||||
if (cmd === 'set_app_locale') return null;
|
||||
if (cmd === 'get_usage_collector_status') return { state: 'collecting', totalRecords: 1 };
|
||||
if (cmd === 'get_usage_analysis') return { models: [], providers: [], sources: [], apiKeys: [] };
|
||||
if (cmd === 'get_usage_pricing') return {
|
||||
rows: [{ model: 'test-model', requests: 1, totalTokens: 100, estimatedCost: 0, price: null }],
|
||||
totalCost: 0, totalRequests: 1, pricedRequests: 0, savedPrices: 0,
|
||||
};
|
||||
if (cmd === 'sync_usage_model_prices') {
|
||||
if (fixture.feedbackFixture.failSync) throw new Error('price sync failed');
|
||||
return { imported: 77, skipped: 0, unmatched: ['test-model'], usedBuiltin: false };
|
||||
}
|
||||
if (cmd === 'save_usage_model_price' || cmd === 'delete_usage_model_price') return null;
|
||||
throw new Error('Unexpected fixture command: ' + cmd);
|
||||
}, { shouldMockEvents: true });
|
||||
|
||||
function Fixture() {
|
||||
const first = useAppNotice();
|
||||
const second = useAppNotice();
|
||||
const [agent, setAgent] = useState(false);
|
||||
const [quota, setQuota] = useState(false);
|
||||
const [nativeError, setNativeError] = useState('');
|
||||
const { askConfirmation, confirmationDialog } = useConfirmation();
|
||||
const [mounted, setMounted] = useState(true);
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
return <main style={{ padding: 24 }}>
|
||||
<div data-testid="toolbar" style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button onClick={() => first.showNotice('Saved', 'success')}>Success</button>
|
||||
<button onClick={() => first.showNotice('Failure '.repeat(120), 'error')}>Error</button>
|
||||
<button onClick={() => second.showNotice('Second result', 'info')}>Second</button>
|
||||
<button onClick={() => { first.clearNotice(); second.clearNotice(); setAgent(false); setQuota(false); }}>Clear</button>
|
||||
<button onClick={() => setAgent(true)}>Agent result</button>
|
||||
<button onClick={() => setQuota(true)}>Quota result</button>
|
||||
<button onClick={() => dialog.current?.showModal()}>Native dialog</button>
|
||||
<button onClick={() => void askConfirmation({ title: "Confirm test", message: "Confirm the operation", confirmText: "Confirm" })}>Confirmation dialog</button>
|
||||
<button onClick={() => setMounted(value => !value)}>Toggle source</button>
|
||||
</div>
|
||||
<section data-testid="panel" style={{ transform: 'translateZ(0)', overflow: 'hidden', height: 210, marginTop: 16, border: '1px solid gray' }}>
|
||||
{mounted && <><FloatingNotice key={first.revision} notice={first.notice} onDismiss={first.clearNotice} />
|
||||
<FloatingNotice key={second.revision} notice={second.notice} onDismiss={second.clearNotice} /></>}
|
||||
<div data-testid="before">Existing form</div>
|
||||
<AgentConfigurationFeedback pending={agent} notice={agent ? 'Agent saved' : ''} error="" description={agent ? 'Persistent description '.repeat(40) : ''} />
|
||||
{quota && <QuotaActionFeedback name="test.json" quota={{ status: 'success', rows: [], actionResult: { action: 'reset', status: 'refresh-error', error: 'query failed' } }} />}
|
||||
<input data-testid="after" defaultValue="Keep focus" />
|
||||
</section>
|
||||
{confirmationDialog}
|
||||
<dialog ref={dialog} className="agent-backup-modal" style={{ width: 400, height: 'calc(100vh - 48px)' }}>
|
||||
<h2>Native backup dialog</h2>
|
||||
<button onClick={() => setNativeError('Native modal error')}>Native error</button>
|
||||
<MessageNotice message={nativeError} onDismiss={() => setNativeError('')} />
|
||||
<button data-testid="native-footer" style={{ position: 'absolute', right: 20, bottom: 20 }} onClick={() => dialog.current?.close()}>Close dialog</button>
|
||||
</dialog>
|
||||
</main>;
|
||||
}
|
||||
createRoot(document.getElementById('root')!).render(<StrictMode><I18nProvider>
|
||||
{params.get('scenario') === 'pricing' ? <UsageRecordsPage /> : <Fixture />}
|
||||
</I18nProvider></StrictMode>);
|
||||
@@ -35,8 +35,8 @@ const { mkdirSync } = require('node:fs');
|
||||
await dialog.getByRole('button', { name: 'Low', exact: true }).click();
|
||||
await page.evaluate(() => { window.fixtureFailSave = true; });
|
||||
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await dialog.getByRole('alert').waitFor();
|
||||
assert.match(await dialog.getByRole('alert').innerText(), /configuration changed/);
|
||||
await page.locator('.app-notice-stack').getByRole('alert').waitFor();
|
||||
assert.match(await page.locator('.app-notice-stack').getByRole('alert').innerText(), /configuration changed/);
|
||||
assert.equal(await dialog.locator('#thinking-alias-name').inputValue(), 'renamed-alias');
|
||||
await page.evaluate(() => { window.fixtureFailSave = false; });
|
||||
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
@@ -64,7 +64,33 @@ const { mkdirSync } = require('node:fs');
|
||||
await page.getByRole('button', { name: 'Create Alias', exact: true }).click();
|
||||
await page.keyboard.press('Escape');
|
||||
await dialog.getByRole('button', { name: 'Create Alias', exact: true }).click();
|
||||
await dialog.getByRole('alert').waitFor();
|
||||
const validation = page.locator('.app-notice-stack').getByRole('alert');
|
||||
await validation.waitFor();
|
||||
assert.equal(await validation.evaluate(el => Boolean(el.closest('[role="dialog"]'))), true);
|
||||
const create = dialog.getByRole('button', { name: 'Create Alias', exact: true });
|
||||
const close = dialog.getByRole('button', { name: 'Close', exact: true });
|
||||
const createCount = (await writes()).length;
|
||||
await create.focus();
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(await validation.locator('.action-feedback-text').evaluate(el => el === document.activeElement), true, 'Tab reaches validation text');
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(await validation.getByRole('button').evaluate(el => el === document.activeElement), true, 'Tab reaches notification dismissal');
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(await close.evaluate(el => el === document.activeElement), true, 'Tab wraps inside the dialog from the notification');
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
assert.equal(await validation.getByRole('button').evaluate(el => el === document.activeElement), true, 'Reverse Tab reaches the notification');
|
||||
await page.keyboard.press('Enter');
|
||||
await validation.waitFor({ state: 'detached' });
|
||||
assert.equal(await close.evaluate(el => el === document.activeElement), true, 'Keyboard dismissal restores focus in the dialog');
|
||||
await create.click();
|
||||
await validation.waitFor();
|
||||
assert.match(await validation.innerText(), /Select an original model first/);
|
||||
assert.equal((await writes()).length, createCount, 'Repeated invalid submission stays local and shows the same error again');
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.press('Enter');
|
||||
await validation.waitFor({ state: 'detached' });
|
||||
assert.equal(await create.evaluate(el => el === document.activeElement), true, 'Dismissal returns to the submit button');
|
||||
await dialog.locator('#thinking-model-search').fill('public-gpt');
|
||||
await page.getByRole('option').click();
|
||||
await dialog.locator('#thinking-alias-name').fill('new-alias');
|
||||
@@ -84,7 +110,7 @@ const { mkdirSync } = require('node:fs');
|
||||
assert.equal((await writes()).at(-1).args.expectedRevision, undefined);
|
||||
assert.equal((await writes()).at(-1).args.alias, 'new-alias');
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('PASS: remapped source, cancel, focus trap, Escape, inline errors, retry, rename, source switch, create, narrow dialog');
|
||||
console.log('PASS: remapped source, cancel, focus trap, Escape, floating errors, retry, rename, source switch, create, narrow dialog');
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ const { mkdirSync } = require('node:fs');
|
||||
await page.evaluate(() => { window.fixtureFailFetch = true; });
|
||||
await dialog.getByRole('button', { name: 'Refresh', exact: true }).click();
|
||||
await ready();
|
||||
assert.match(await dialog.getByRole('alert').innerText(), /Fixture discovery failed/);
|
||||
assert.match(await page.locator('.app-notice-stack').getByRole('alert').innerText(), /Fixture discovery failed/);
|
||||
assert.equal(await rows(right).count(), 1, 'Failed refresh keeps selected models');
|
||||
await left.getByRole('button', { name: 'Clear search', exact: true }).click();
|
||||
await counts(401, 1);
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
// Run Vite on port 1421, then node tests/operation-feedback-ui.cjs.
|
||||
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
|
||||
const assert = require('node:assert/strict');
|
||||
const base = process.env.FEEDBACK_TEST_BASE_URL || 'http://127.0.0.1:1421';
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true, args: ['--no-proxy-server'] });
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
|
||||
page.setDefaultTimeout(10000);
|
||||
page.setDefaultNavigationTimeout(30000);
|
||||
await page.route('**/*', route => route.request().url().startsWith(base + '/') ? route.continue() : route.abort());
|
||||
const errors = [];
|
||||
page.on('pageerror', error => errors.push(String(error)));
|
||||
const open = query => page.goto(`${base}/tests/fixtures/operation-feedback.html?${query || ''}`, { waitUntil: 'domcontentloaded' });
|
||||
const stack = page.locator('.app-notice-stack');
|
||||
const notice = stack.locator('.inline-notice');
|
||||
const positions = () => page.locator('[data-testid="toolbar"], [data-testid="panel"], [data-testid="after"]').evaluateAll(nodes => nodes.map(node => {
|
||||
const r = node.getBoundingClientRect(); return [r.x, r.y, r.width, r.height];
|
||||
}));
|
||||
const button = name => page.getByRole('button', { name, exact: true });
|
||||
const waitNotices = count => page.waitForFunction(count => document.querySelectorAll('.app-notice-stack .inline-notice').length === count, count);
|
||||
await open();
|
||||
await page.clock.install();
|
||||
const initial = await positions();
|
||||
await button('Success').click(); await waitNotices(1);
|
||||
assert.deepEqual(await positions(), initial, 'Showing feedback does not move form or toolbar');
|
||||
assert.equal(await page.locator('[data-testid="panel"] .inline-notice').count(), 0, 'Escapes clipped/transformed ancestor');
|
||||
await button('Second').click(); await waitNotices(2);
|
||||
assert.equal(await stack.count(), 1, 'All owners share one stack');
|
||||
const rects = await notice.evaluateAll(nodes => nodes.map(node => node.getBoundingClientRect().toJSON()));
|
||||
assert.ok(rects[0].bottom <= rects[1].top, 'Simultaneous notices do not overlap');
|
||||
await notice.first().getByRole('button').click(); await waitNotices(1);
|
||||
assert.deepEqual(await positions(), initial, 'Closing feedback does not move page');
|
||||
await button('Clear').click(); await waitNotices(0);
|
||||
assert.equal(await stack.count(), 0, 'Unused host is cleaned up');
|
||||
|
||||
await button('Success').click(); await waitNotices(1);
|
||||
await page.clock.fastForward(3500);
|
||||
await button('Success').click();
|
||||
await page.clock.fastForward(3500); await waitNotices(1);
|
||||
await page.clock.fastForward(3000); await waitNotices(0);
|
||||
await button('Success').click(); await waitNotices(1);
|
||||
await notice.locator('.action-feedback-text').focus();
|
||||
await page.clock.fastForward(7000); await waitNotices(1);
|
||||
await page.getByTestId('after').focus();
|
||||
await page.clock.fastForward(6100); await waitNotices(0);
|
||||
assert.equal(await page.getByTestId('after').evaluate(node => node === document.activeElement), true, 'Feedback does not steal focus');
|
||||
await button('Success').click(); await waitNotices(1);
|
||||
await notice.hover(); await page.clock.fastForward(7000); await waitNotices(1);
|
||||
await page.mouse.move(0, 0); await page.clock.fastForward(6100); await waitNotices(0);
|
||||
|
||||
await button('Error').click(); await waitNotices(1);
|
||||
await page.clock.fastForward(30000); await waitNotices(1);
|
||||
assert.equal(await notice.getAttribute('role'), 'alert');
|
||||
assert.ok(await notice.locator('.action-feedback-text').evaluate(node => node.scrollHeight > node.clientHeight), 'Long errors retain scrollable full text');
|
||||
assert.deepEqual(await positions(), initial);
|
||||
await button('Toggle source').click(); await waitNotices(0);
|
||||
assert.equal(await stack.count(), 0, 'Unmounting owner removes its notices');
|
||||
await button('Toggle source').click(); await waitNotices(1);
|
||||
await button('Clear').click(); await waitNotices(0);
|
||||
await button('Agent result').click(); await waitNotices(1);
|
||||
assert.deepEqual(await positions(), initial, 'Agent pending and success feedback leave controls stable');
|
||||
await button('Quota result').click(); await waitNotices(2);
|
||||
const quotaText = await notice.last().innerText();
|
||||
for (const text of ['test.json', '重置请求已提交', '额度刷新失败', '避免重复消耗', 'query failed']) assert.ok(quotaText.includes(text));
|
||||
assert.deepEqual(await positions(), initial, 'Quota reset error does not resize its row');
|
||||
await button('Clear').click(); await waitNotices(0);
|
||||
|
||||
await button('Second').click(); await waitNotices(1);
|
||||
await button('Native dialog').click();
|
||||
const footer = page.getByTestId('native-footer');
|
||||
const nativeBefore = await footer.boundingBox();
|
||||
await button('Native error').click(); await waitNotices(2);
|
||||
assert.equal(await stack.evaluate(node => node.parentElement.matches('dialog:modal')), true, 'Notifications remain above native dialog and clickable');
|
||||
assert.deepEqual(await footer.boundingBox(), nativeBefore, 'Native error does not move dialog actions');
|
||||
const floated = await stack.boundingBox();
|
||||
assert.ok(floated.y + floated.height <= nativeBefore.y, 'Stack avoids dialog action buttons, allowing immediate retry');
|
||||
await notice.last().getByRole('button').click(); await waitNotices(1);
|
||||
await button('Close dialog').click();
|
||||
await page.waitForFunction(() => document.querySelector('.app-notice-stack')?.parentElement === document.body);
|
||||
await notice.getByRole('button').click(); await waitNotices(0);
|
||||
|
||||
await button('Error').click(); await waitNotices(1);
|
||||
await button('Confirmation dialog').click();
|
||||
const confirmation = page.getByRole('alertdialog');
|
||||
await confirmation.locator('.app-notice-stack').waitFor();
|
||||
const confirmBefore = await confirmation.boundingBox();
|
||||
await button('Confirm').focus();
|
||||
await page.keyboard.press('Tab');
|
||||
const longText = notice.locator('.action-feedback-text');
|
||||
assert.equal(await longText.evaluate(node => node === document.activeElement), true, 'Capture-phase focus trap includes the notification text');
|
||||
await page.keyboard.press('End');
|
||||
await page.clock.fastForward(500);
|
||||
await page.waitForFunction(() => document.querySelector('.app-notice-stack .action-feedback-text')?.scrollTop > 0);
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(await notice.getByRole('button').evaluate(node => node === document.activeElement), true);
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(await confirmation.locator('button').first().evaluate(node => node === document.activeElement), true);
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
await page.keyboard.press('Enter'); await waitNotices(0);
|
||||
assert.equal(await confirmation.count(), 1, 'Dismissing a notice does not activate confirmation');
|
||||
assert.deepEqual(await confirmation.boundingBox(), confirmBefore, 'Notifications do not resize ordinary modals');
|
||||
assert.equal(await confirmation.locator('button').first().evaluate(node => node === document.activeElement), true, 'Focus stays in the modal after notification dismissal');
|
||||
await button('Confirm').click();
|
||||
await confirmation.waitFor({ state: 'detached' });
|
||||
|
||||
for (const theme of ['light', 'dark']) for (const width of [360, 640, 1280]) {
|
||||
await page.setViewportSize({ width, height: 700 }); await open(`theme=${theme}`);
|
||||
await button('Error').click(); await waitNotices(1);
|
||||
const r = await stack.boundingBox();
|
||||
assert.ok(r.x >= 0 && r.y >= 0 && r.x + r.width <= width && r.y + r.height <= 700, `${theme} ${width}: stack fits viewport`);
|
||||
assert.ok(Math.abs(width - (r.x + r.width) - 24) < 1 && Math.abs(700 - (r.y + r.height) - 24) < 1, 'Anchored at bottom right');
|
||||
}
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await open('scenario=pricing');
|
||||
const table = page.locator('.usage-pricing-table'); await table.waitFor();
|
||||
const priceBefore = await table.boundingBox();
|
||||
const sync = page.locator('.usage-pricing-actions .primary-button');
|
||||
await sync.click(); await waitNotices(1);
|
||||
assert.ok((await notice.innerText()).includes('价格更新完成:导入 77,保留手动价格 0,未匹配 1'));
|
||||
assert.deepEqual(await table.boundingBox(), priceBefore, 'Real pricing success never shifts table');
|
||||
if (process.env.FEEDBACK_SCREENSHOT) await page.screenshot({ path: process.env.FEEDBACK_SCREENSHOT });
|
||||
await notice.getByRole('button').click(); await waitNotices(0);
|
||||
assert.deepEqual(await table.boundingBox(), priceBefore);
|
||||
await page.evaluate(() => { window.feedbackFixture.failSync = true; });
|
||||
await sync.click(); await waitNotices(1);
|
||||
assert.ok((await notice.innerText()).includes('price sync failed'));
|
||||
assert.deepEqual(await table.boundingBox(), priceBefore, 'Real pricing error never shifts table');
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('PASS: no layout shifts, shared stack, owner cleanup, success expiry/repeat/hover/focus, persistent errors, quota details, native modal, light/dark at 3 widths, real pricing success/error.');
|
||||
} finally { await browser.close(); }
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -37,20 +37,6 @@ describe('控件自身反馈', () => {
|
||||
expect(nonEmptyCalls[0].arguments[1].getText()).toBe("'error'");
|
||||
});
|
||||
|
||||
it('API 接入提示固定在右下角,不插入页面布局', () => {
|
||||
const source = readFileSync(new URL('../src/pages/ApiAccessPage.tsx', import.meta.url), 'utf8');
|
||||
expect(source).toContain("import { createPortal } from 'react-dom';");
|
||||
expect(source).toContain('className="api-access-page-notice"');
|
||||
expect(source).toContain('createPortal(');
|
||||
expect(source).toContain('document.body');
|
||||
expect(source).not.toContain('feedbackRow');
|
||||
expect(source.indexOf('createPortal(')).toBeGreaterThan(source.indexOf('provider-workbench real-provider-workbench'));
|
||||
expect(source.slice(source.indexOf('provider-workbench real-provider-workbench'), source.lastIndexOf('createPortal('))).not.toContain('<InlineNotice');
|
||||
const styles = readFileSync(new URL('../src/styles.css', import.meta.url), 'utf8');
|
||||
expect(styles).toMatch(/\.action-feedback\.inline-notice\.api-access-page-notice\s*\{[\s\S]*?position:\s*fixed;/);
|
||||
expect(styles).toMatch(/\.action-feedback\.inline-notice\.api-access-page-notice\s*\{[\s\S]*?right:\s*24px;/);
|
||||
expect(styles).toMatch(/\.action-feedback\.inline-notice\.api-access-page-notice\s*\{[\s\S]*?bottom:\s*24px;/);
|
||||
});
|
||||
|
||||
it('凭证文件的启用停用由按钮与状态标签反馈', () => {
|
||||
expect(actionCalls('AuthFileManagementPage.tsx', 'toggleStatus', 'showNotice').calls).toHaveLength(0);
|
||||
|
||||
@@ -19,7 +19,7 @@ describe('quota card rendering', () => {
|
||||
expect(html).not.toMatch(/<button[^>]*disabled=""[^>]*>重置额度<\/button>/);
|
||||
expect(html).not.toContain('当前没有适用的重置次数');
|
||||
expect(html).toContain('当前适用:0 次');
|
||||
expect(html).toContain('temporary failure');
|
||||
expect(html).not.toContain('temporary failure');
|
||||
expect(html).toContain('订阅到期');
|
||||
});
|
||||
|
||||
@@ -30,7 +30,7 @@ describe('quota card rendering', () => {
|
||||
});
|
||||
expect(html).toMatch(/<button[^>]*title="重置额度"[^>]*>重置额度<\/button>/);
|
||||
expect(html).not.toMatch(/<button[^>]*disabled=""[^>]*>重置额度<\/button>/);
|
||||
expect(html).toContain('temporary failure');
|
||||
expect(html).not.toContain('temporary failure');
|
||||
});
|
||||
|
||||
it('xAI 付费账号保留额度说明和刷新入口,不显示可用性测试', () => {
|
||||
|
||||
Reference in new issue
Block a user