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:
lzt404 committed 2026-09-15 02:39:03 +08:00
1 parent aaa6806e26
commit 18a9bfbaa9
60 files changed
+614 -402

No files matched your search

-1
View File
@@ -1,4 +1,3 @@
//! Agent client discovery, configuration generation, and reversible state management.
use super::*;
-6
View File
@@ -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());
}
-2
View File
@@ -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(
+1 -5
View File
@@ -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"));
-20
View File
@@ -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()?;
-6
View File
@@ -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();
}
-3
View File
@@ -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()
-9
View File
@@ -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);
}
-3
View File
@@ -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(&current_exe);
launch_portable_update_helper(app, &helper_path, &work_dir, &descriptor).await
}
-4
View File
@@ -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 {
-4
View File
@@ -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)?;
-3
View File
@@ -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)
})();
-16
View File
@@ -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)
}
-9
View File
@@ -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()
-5
View File
@@ -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))
-2
View File
@@ -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();
-1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+2 -1
View File
@@ -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>
) : (
+3 -3
View File
@@ -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();
+83
View File
@@ -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;
}
+3 -8
View File
@@ -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} />;
}
-2
View File
@@ -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);
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+8 -24
View File
@@ -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">
+10 -10
View File
@@ -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>
+9 -6
View File
@@ -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}>
+2 -9
View File
@@ -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">
+4 -5
View File
@@ -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>
+3 -2
View File
@@ -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>
+4 -24
View File
@@ -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}
+3 -3
View File
@@ -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}`}>
+2 -13
View File
@@ -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">
+8 -4
View File
@@ -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);
+4 -5
View File
@@ -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}>
+22 -26
View File
@@ -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">
+7 -27
View File
@@ -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
-1
View File
@@ -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;
}
-1
View File
@@ -234,7 +234,6 @@ export async function fetchModels(
if (models.length) return models;
}
} catch {
// Keep the authenticated request error as the useful failure reason.
}
}
}
-3
View File
@@ -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,
+1 -1
View File
@@ -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 {
-1
View File
@@ -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)),
-1
View File
@@ -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
View File
@@ -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;
}
-2
View File
@@ -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',
+2 -5
View File
@@ -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('');
});
});
+33 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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>);
+30 -4
View File
@@ -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();
}
+1 -1
View File
@@ -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);
+133
View File
@@ -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; });
-14
View File
@@ -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);
+2 -2
View File
@@ -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 付费账号保留额度说明和刷新入口,不显示可用性测试', () => {