feat: support DeepSeek Harness Desktop and refine management UI
- Detect and launch DeepSeek Harness Desktop on Windows - Improve agent client selection and responsive layouts - Unify dashboard runtime and connection card styling - Streamline plugin navigation, cards, and installation dialogs - Update translations and regression tests
This commit is contained in:
1 parent
3b11361f1b
commit
2649df000c
24 files changed
+587
-180
No files matched your search
@@ -4,6 +4,7 @@ mod backups;
|
||||
mod commands;
|
||||
mod configuration;
|
||||
mod deepseek_harness;
|
||||
mod deepseek_desktop;
|
||||
mod discovery;
|
||||
mod launch;
|
||||
mod native_oauth;
|
||||
@@ -18,6 +19,7 @@ pub(crate) use backups::*;
|
||||
pub(crate) use commands::*;
|
||||
pub(crate) use configuration::*;
|
||||
pub(crate) use deepseek_harness::*;
|
||||
pub(crate) use deepseek_desktop::*;
|
||||
pub(crate) use discovery::*;
|
||||
pub(crate) use launch::*;
|
||||
pub(crate) use native_oauth::*;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
use super::*;
|
||||
|
||||
pub(crate) fn find_deepseek_harness_desktop_application(home: &Path) -> Option<PathBuf> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let local = agent_configuration_environment("LOCALAPPDATA")
|
||||
.unwrap_or_else(|| home.join("AppData/Local"));
|
||||
let roots = ["ProgramFiles", "ProgramFiles(x86)"]
|
||||
.into_iter()
|
||||
.filter_map(agent_configuration_environment)
|
||||
.collect::<Vec<_>>();
|
||||
// Keep fixture homes isolated from applications installed on the test host.
|
||||
#[cfg(test)]
|
||||
let registrations = Vec::new();
|
||||
#[cfg(not(test))]
|
||||
let registrations = collect_windows_desktop_registrations(
|
||||
"DeepSeek Harness.exe",
|
||||
windows_display_name_matches_deepseek_harness,
|
||||
);
|
||||
find_windows_deepseek_harness_desktop(&local, &roots, ®istrations)
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = home;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn windows_display_name_matches_deepseek_harness(name: &str) -> bool {
|
||||
let name = name.trim().to_ascii_lowercase();
|
||||
name.strip_prefix("deepseek harness")
|
||||
.is_some_and(|rest| rest.is_empty() || rest.starts_with([' ', '(']))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn find_windows_deepseek_harness_desktop(
|
||||
local: &Path,
|
||||
program_files: &[PathBuf],
|
||||
registrations: &[WindowsZcodeRegistration],
|
||||
) -> Option<PathBuf> {
|
||||
let registered = registrations.iter().filter_map(|entry| {
|
||||
parse_windows_desktop_registration(entry.kind, &entry.value, "DeepSeek Harness.exe")
|
||||
});
|
||||
let defaults = [
|
||||
local.join("Programs/DeepSeek Harness/DeepSeek Harness.exe"),
|
||||
local.join("DeepSeek Harness/DeepSeek Harness.exe"),
|
||||
];
|
||||
registered
|
||||
.chain(defaults)
|
||||
.chain(
|
||||
program_files
|
||||
.iter()
|
||||
.map(|root| root.join("DeepSeek Harness/DeepSeek Harness.exe")),
|
||||
)
|
||||
.find(|path| path.is_file())
|
||||
}
|
||||
|
||||
pub(crate) fn read_deepseek_harness_desktop_version(application: &Path) -> Option<String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// Electron's PE ProductVersion drops prerelease suffixes; package.json retains them.
|
||||
read_codex_asar_version(&application.parent()?.join("resources/app.asar"))
|
||||
.or_else(|| read_windows_executable_version(application))
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = application;
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -1015,6 +1015,9 @@ pub(crate) fn inspect_agent_config(
|
||||
let opencode_desktop_application = (client == AgentClient::OpenCode)
|
||||
.then(|| find_opencode_desktop_application(home))
|
||||
.flatten();
|
||||
let harness_desktop_application = (client == AgentClient::DeepSeekHarness)
|
||||
.then(|| find_deepseek_harness_desktop_application(home))
|
||||
.flatten();
|
||||
let app_version = match client {
|
||||
AgentClient::ClaudeDesktop => read_claude_desktop_version(home),
|
||||
AgentClient::Codex => codex_app_installation
|
||||
@@ -1023,13 +1026,21 @@ pub(crate) fn inspect_agent_config(
|
||||
AgentClient::OpenCode => opencode_desktop_application
|
||||
.as_deref()
|
||||
.and_then(read_opencode_desktop_version),
|
||||
AgentClient::DeepSeekHarness => read_deepseek_harness_profile_version(home),
|
||||
AgentClient::DeepSeekHarness => harness_desktop_application
|
||||
.as_deref()
|
||||
.and_then(read_deepseek_harness_desktop_version),
|
||||
AgentClient::ZCode => executable.as_deref().and_then(read_zcode_app_version),
|
||||
AgentClient::WorkBuddy => executable.as_deref().and_then(read_workbuddy_app_version),
|
||||
_ => None,
|
||||
};
|
||||
let version = cli_version.clone().or_else(|| app_version.clone());
|
||||
let app_installed = codex_app_installation.is_some() || opencode_desktop_application.is_some();
|
||||
let version = cli_version.clone().or_else(|| app_version.clone()).or_else(|| {
|
||||
(client == AgentClient::DeepSeekHarness)
|
||||
.then(|| read_deepseek_harness_profile_version(home))
|
||||
.flatten()
|
||||
});
|
||||
let app_installed = codex_app_installation.is_some()
|
||||
|| opencode_desktop_application.is_some()
|
||||
|| harness_desktop_application.is_some();
|
||||
let installed = agent_installation_detected(
|
||||
client,
|
||||
version.as_deref(),
|
||||
@@ -1190,6 +1201,13 @@ pub(crate) fn agent_launch_targets(
|
||||
detail: format!("{} web", path_to_string(executable)),
|
||||
});
|
||||
}
|
||||
if app_installed {
|
||||
targets.push(AgentLaunchTarget {
|
||||
id: "app".to_string(),
|
||||
label: "DeepSeek Harness Desktop".to_string(),
|
||||
detail: "DeepSeek Harness Desktop".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(executable) = executable {
|
||||
|
||||
@@ -202,6 +202,11 @@ pub(crate) fn launch_agent(
|
||||
}
|
||||
(AgentClient::Codex, "app") => launch_codex_desktop(&home),
|
||||
(AgentClient::OpenCode, "app") => launch_opencode_desktop(&home),
|
||||
(AgentClient::DeepSeekHarness, "app") => {
|
||||
let executable = find_deepseek_harness_desktop_application(&home)
|
||||
.ok_or_else(|| "DeepSeek Harness Desktop application not found".to_string())?;
|
||||
launch_desktop_agent(&executable, "DeepSeek Harness Desktop")
|
||||
}
|
||||
(AgentClient::ClaudeDesktop | AgentClient::ZCode | AgentClient::WorkBuddy, "cli") => {
|
||||
Err(format!("{} does not support CLI launch mode", client.name()))
|
||||
}
|
||||
|
||||
@@ -238,7 +238,7 @@ pub(crate) fn collect_windows_zcode_registrations() -> Vec<WindowsZcodeRegistrat
|
||||
collect_windows_desktop_registrations("ZCode.exe", windows_display_name_matches_zcode)
|
||||
}
|
||||
|
||||
fn collect_windows_desktop_registrations(executable: &str, matches_name: fn(&str) -> bool) -> Vec<WindowsZcodeRegistration> {
|
||||
pub(crate) fn collect_windows_desktop_registrations(executable: &str, matches_name: fn(&str) -> bool) -> Vec<WindowsZcodeRegistration> {
|
||||
let mut registrations = Vec::new();
|
||||
for root in current_version_roots() {
|
||||
if let Some(path) = RegistryKey::open(
|
||||
|
||||
@@ -2,6 +2,7 @@ use super::*;
|
||||
|
||||
mod agent_configuration;
|
||||
mod deepseek_harness_catalog;
|
||||
mod deepseek_desktop;
|
||||
mod agent_paths;
|
||||
mod agent_state;
|
||||
mod agent_transactions;
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
#[cfg(target_os = "windows")]
|
||||
use super::support::agent_test_home;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deepseek_desktop_launch_targets_are_independent_of_cli_and_version() {
|
||||
let client = AgentClient::DeepSeekHarness;
|
||||
assert!(agent_installation_detected(client, None, false, true));
|
||||
let desktop = agent_launch_targets(client, None, None, true);
|
||||
assert_eq!(desktop.len(), 1);
|
||||
assert_eq!(desktop[0].id, "app");
|
||||
let both = agent_launch_targets(client, Some(Path::new("dsh")), None, true);
|
||||
assert_eq!(
|
||||
both.iter()
|
||||
.map(|target| target.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["cli", "app"]
|
||||
);
|
||||
// An installed profile alone must not create a Desktop launch target.
|
||||
assert!(agent_launch_targets(client, None, Some("0.2.0"), false).is_empty());
|
||||
assert!(!agent_installation_detected(client, None, false, false));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn deepseek_desktop_windows_finds_custom_and_default_installations() {
|
||||
let home = agent_test_home("deepseek-desktop-discovery");
|
||||
let local = home.join("local");
|
||||
let custom = home.join("custom/DeepSeek Harness.exe");
|
||||
let default = local.join("Programs/DeepSeek Harness/DeepSeek Harness.exe");
|
||||
fs::create_dir_all(custom.parent().unwrap()).unwrap();
|
||||
fs::create_dir_all(default.parent().unwrap()).unwrap();
|
||||
fs::write(&custom, []).unwrap();
|
||||
for (kind, value) in [
|
||||
("directory", path_to_string(custom.parent().unwrap())),
|
||||
("icon", format!("\"{}\",0", path_to_string(&custom))),
|
||||
(
|
||||
"uninstaller",
|
||||
format!(
|
||||
"\"{}\" /S",
|
||||
path_to_string(
|
||||
&custom
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join("Uninstall DeepSeek Harness.exe")
|
||||
)
|
||||
),
|
||||
),
|
||||
] {
|
||||
let entries = [WindowsZcodeRegistration { kind, value }];
|
||||
assert_eq!(
|
||||
find_windows_deepseek_harness_desktop(&local, &[], &entries),
|
||||
Some(custom.clone())
|
||||
);
|
||||
}
|
||||
fs::remove_file(&custom).unwrap();
|
||||
let stale = [WindowsZcodeRegistration {
|
||||
kind: "executable",
|
||||
value: path_to_string(&custom),
|
||||
}];
|
||||
assert_eq!(
|
||||
find_windows_deepseek_harness_desktop(&local, &[], &stale),
|
||||
None
|
||||
);
|
||||
fs::write(&default, []).unwrap();
|
||||
assert_eq!(
|
||||
find_windows_deepseek_harness_desktop(&local, &[], &stale),
|
||||
Some(default)
|
||||
);
|
||||
fs::remove_dir_all(home).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn deepseek_desktop_windows_registration_name_is_specific() {
|
||||
for name in [
|
||||
"DeepSeek Harness",
|
||||
"DeepSeek Harness 0.2.0-rc.2",
|
||||
"deepseek harness (user)",
|
||||
] {
|
||||
assert!(windows_display_name_matches_deepseek_harness(name));
|
||||
}
|
||||
for name in [
|
||||
"DeepSeek",
|
||||
"DeepSeek HarnessHelper",
|
||||
"Other DeepSeek Harness",
|
||||
] {
|
||||
assert!(!windows_display_name_matches_deepseek_harness(name));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
#[ignore = "Requires DeepSeek Harness Desktop installed on the host"]
|
||||
fn deepseek_desktop_installed_host_probe() {
|
||||
let registrations = collect_windows_desktop_registrations(
|
||||
"DeepSeek Harness.exe",
|
||||
windows_display_name_matches_deepseek_harness,
|
||||
);
|
||||
let local = PathBuf::from(env::var_os("LOCALAPPDATA").unwrap());
|
||||
let app = find_windows_deepseek_harness_desktop(&local, &[], ®istrations)
|
||||
.expect("installed Desktop should be detected");
|
||||
let version = read_deepseek_harness_desktop_version(&app).expect("Desktop version");
|
||||
println!("DeepSeek Harness Desktop: {} ({version})", app.display());
|
||||
}
|
||||
+4
-2
@@ -1083,13 +1083,15 @@ export const jaOverrides = {
|
||||
'agents.description.kimiCode': 'CPA の OpenAI Chat Completions API と動的モデルカタログを使用',
|
||||
'agents.description.grokBuild': 'CPA の OpenAI Chat Completions API とカスタムモデルカタログを使用',
|
||||
'agents.description.pi': 'CLIProxyAPI プロバイダーを使用して Pi CLI のモデルを動的に読み込みます',
|
||||
'agents.clients.switch': 'クライアントを切り替え',
|
||||
'agents.clients.switchHint': 'クライアントを検索して選択します。選択中のクライアントはショートカット一覧に表示されます。',
|
||||
'agents.clients.manage': 'クライアントを管理',
|
||||
'agents.clients.description': '一覧に表示するクライアントを選択します。表示のみを変更し、クライアントの設定は変更しません。',
|
||||
'agents.clients.description': 'ショートカットに優先表示するクライアントを選択します。選択中のクライアントは常に表示され、その他は「クライアントを切り替え」から選べます。',
|
||||
'agents.clients.search': 'クライアントを検索',
|
||||
'agents.clients.shown': '{count} / {total} 件を表示',
|
||||
'agents.clients.selected': '{count} 件のクライアントを選択中',
|
||||
'agents.clients.automatic': '自動表示に戻す',
|
||||
'agents.clients.automaticHint': '既定では、インストール済みまたは設定済みのクライアントを表示します。',
|
||||
'agents.clients.automaticHint': '既定では、インストール済みまたは設定済みのクライアントを優先表示します。',
|
||||
'agents.clients.minimum': '少なくとも 1 件のクライアントを表示してください。',
|
||||
'agents.clients.noResults': '一致するクライアントが見つかりません',
|
||||
'agents.list.detecting': '検出中',
|
||||
|
||||
@@ -1083,13 +1083,15 @@ export const en: Record<MessageKey, string> = {
|
||||
'agents.description.kimiCode': 'Uses the CPA OpenAI Chat Completions API and dynamic model catalog',
|
||||
'agents.description.grokBuild': 'Uses the CPA OpenAI Chat Completions API and custom model catalog',
|
||||
'agents.description.pi': 'Loads models dynamically in Pi through the CLIProxyAPI provider',
|
||||
'agents.clients.switch': 'Switch Client',
|
||||
'agents.clients.switchHint': 'Search and select a client. Your current selection stays in the shortcut list.',
|
||||
'agents.clients.manage': 'Manage Clients',
|
||||
'agents.clients.description': 'Choose which clients appear in the list. This only changes visibility, not client settings.',
|
||||
'agents.clients.description': 'Choose which clients take priority in the shortcuts. Your current selection stays visible; use Switch Client to access any other client.',
|
||||
'agents.clients.search': 'Search clients',
|
||||
'agents.clients.shown': 'Showing {count} / {total}',
|
||||
'agents.clients.selected': '{count} clients selected',
|
||||
'agents.clients.automatic': 'Restore automatic display',
|
||||
'agents.clients.automaticHint': 'Installed or configured clients are shown by default.',
|
||||
'agents.clients.automaticHint': 'Installed or configured clients take priority by default.',
|
||||
'agents.clients.minimum': 'Keep at least one client visible.',
|
||||
'agents.clients.noResults': 'No matching clients found',
|
||||
'agents.list.detecting': 'Detecting',
|
||||
|
||||
@@ -1082,13 +1082,15 @@ export const zhCN = {
|
||||
'agents.description.kimiCode': '使用 CPA 的 OpenAI Chat Completions 接口和动态模型目录',
|
||||
'agents.description.grokBuild': '使用 CPA 的 OpenAI Chat Completions 接口和自定义模型目录',
|
||||
'agents.description.pi': '使用 Pi CLI 和 CLIProxyAPI 提供方动态加载模型',
|
||||
'agents.clients.switch': '切换客户端',
|
||||
'agents.clients.switchHint': '搜索并选择客户端,当前选择会显示在快捷列表中。',
|
||||
'agents.clients.manage': '管理客户端',
|
||||
'agents.clients.description': '选择要显示在列表中的客户端,仅调整显示,不修改客户端配置。',
|
||||
'agents.clients.description': '选择快捷列表优先展示的客户端。当前选择始终保留,其他客户端可通过“切换客户端”访问。',
|
||||
'agents.clients.search': '搜索客户端',
|
||||
'agents.clients.shown': '显示 {count} / {total}',
|
||||
'agents.clients.selected': '已选择 {count} 个客户端',
|
||||
'agents.clients.automatic': '恢复自动显示',
|
||||
'agents.clients.automaticHint': '默认显示已安装或已有配置的客户端。',
|
||||
'agents.clients.automaticHint': '默认优先展示已安装或已有配置的客户端。',
|
||||
'agents.clients.minimum': '至少保留一个客户端。',
|
||||
'agents.clients.noResults': '未找到匹配的客户端',
|
||||
'agents.list.detecting': '正在检测',
|
||||
|
||||
@@ -3,12 +3,23 @@
|
||||
contain: size;
|
||||
}
|
||||
|
||||
.agents-page .agent-list-items {
|
||||
.agents-page .agent-client-list .agent-list-items {
|
||||
gap: 4px;
|
||||
flex: 1 1 auto;
|
||||
grid-auto-flow: row;
|
||||
grid-auto-columns: auto;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: none;
|
||||
grid-auto-rows: 56px;
|
||||
min-height: 56px;
|
||||
overflow: hidden;
|
||||
scrollbar-gutter: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.agents-page .agent-list-items button {
|
||||
.agents-page .agent-client-list .agent-list-items button {
|
||||
min-height: 56px;
|
||||
height: 56px;
|
||||
padding: 8px;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -29,16 +40,46 @@
|
||||
}
|
||||
|
||||
.agent-client-list-footer {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
border-top: 1px solid var(--border-primary);
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.agent-client-switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.agent-client-list-footer > .compact-button {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.agent-client-manager button.agent-client-option {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.agent-client-manager button.agent-client-option:hover,
|
||||
.agent-client-manager button.agent-client-option[aria-pressed="true"] {
|
||||
border-color: var(--ui-accent);
|
||||
background: var(--ui-accent-soft);
|
||||
}
|
||||
|
||||
.agent-client-manager button.agent-client-option > svg {
|
||||
flex: none;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.agent-client-list-footer small,
|
||||
.agent-client-manager small,
|
||||
.agent-client-manager p,
|
||||
@@ -221,7 +262,25 @@
|
||||
}
|
||||
|
||||
@container agent-workspace (max-width: 760px) {
|
||||
.agents-page:not(.agents-page-embedded) .agent-client-list {
|
||||
.agents-page:not(.agents-page-embedded) .agent-client-list,
|
||||
.app-shell:has(.agents-page:not(.agents-page-embedded)) .agent-client-list {
|
||||
contain: none;
|
||||
height: 270px;
|
||||
max-height: 270px;
|
||||
}
|
||||
}
|
||||
|
||||
/* These three compact controls fit side by side at ordinary desktop widths. */
|
||||
@container agent-panel (min-width: 620px) and (max-width: 760px) {
|
||||
.agent-claude-desktop-mapping-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.agent-claude-code-runtime-settings {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.2fr);
|
||||
}
|
||||
|
||||
.agent-claude-code-disable-compact {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useId, useRef, useState, type ReactNode } from 'react';
|
||||
import { RefreshCw, Search, SlidersHorizontal, X } from 'lucide-react';
|
||||
import { useEffect, useLayoutEffect, useId, useRef, useState, type ReactNode } from 'react';
|
||||
import { Check, RefreshCw, Search, SlidersHorizontal, X } from 'lucide-react';
|
||||
import { MessageNotice } from '../appNotice';
|
||||
import { useDialogFocusTrap } from '../components/useDialogFocusTrap';
|
||||
import { useI18n } from '../i18n';
|
||||
@@ -41,22 +41,39 @@ export function AgentClientList<Id extends string>({
|
||||
const { t } = useI18n();
|
||||
const [visibleIds, setVisibleIds] = useState(() => readVisibleClients(clients));
|
||||
const [managing, setManaging] = useState(false);
|
||||
const [picking, setPicking] = useState(false);
|
||||
const [capacity, setCapacity] = useState(1);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const close = () => { setManaging(false); setPicking(false); };
|
||||
const [draftIds, setDraftIds] = useState<Id[] | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
// Keep the current client reachable even when detection fails or it was uninstalled.
|
||||
const automaticIds = clients.filter((client) => client.detected || client.id === selected)
|
||||
.map((client) => client.id);
|
||||
const visibleClients = clients.filter((client) => (visibleIds ?? automaticIds).includes(client.id));
|
||||
const shortcuts = visibleClients.slice(0, capacity);
|
||||
const current = visibleClients.find((client) => client.id === selected);
|
||||
if (current && !shortcuts.includes(current)) shortcuts[shortcuts.length - 1] = current;
|
||||
const draftSelection = draftIds ?? automaticIds;
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const titleId = useId();
|
||||
const descriptionId = useId();
|
||||
const dialogRef = useDialogFocusTrap<HTMLDialogElement>({
|
||||
active: managing,
|
||||
onEscape: () => setManaging(false),
|
||||
active: managing || picking,
|
||||
onEscape: close,
|
||||
initialFocusRef: searchRef,
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const list = listRef.current;
|
||||
if (!list) return;
|
||||
const resize = () => setCapacity(Math.max(1, Math.min(6, Math.floor((list.clientHeight + 4) / 60))));
|
||||
resize();
|
||||
const observer = new ResizeObserver(resize);
|
||||
observer.observe(list);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!busy && visibleIds && !visibleIds.includes(selected) && visibleIds.length) {
|
||||
onSelect(visibleIds[0]);
|
||||
@@ -64,8 +81,19 @@ export function AgentClientList<Id extends string>({
|
||||
}, [busy, onSelect, selected, visibleIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (managing) dialogRef.current?.showModal();
|
||||
}, [dialogRef, managing]);
|
||||
if (managing || picking) dialogRef.current?.showModal();
|
||||
}, [dialogRef, managing, picking]);
|
||||
|
||||
const pick = (id: Id) => {
|
||||
// A manually hidden client can be brought back directly from the switcher.
|
||||
if (visibleIds && !visibleIds.includes(id)) {
|
||||
const next = [...visibleIds, id];
|
||||
setVisibleIds(next);
|
||||
try { window.localStorage.setItem(VISIBLE_CLIENTS_KEY, JSON.stringify(next)); } catch { /* Session only. */ }
|
||||
}
|
||||
onSelect(id);
|
||||
close();
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
if (!draftSelection.length) return;
|
||||
@@ -95,8 +123,8 @@ export function AgentClientList<Id extends string>({
|
||||
</button>
|
||||
</div>
|
||||
{error ? <MessageNotice message={error} onDismiss={onDismissError} /> : null}
|
||||
<div className="agent-list-items">
|
||||
{visibleClients.map((client) => <button type="button" key={client.id}
|
||||
<div className="agent-list-items" ref={listRef}>
|
||||
{shortcuts.map((client) => <button type="button" key={client.id}
|
||||
className={selected === client.id ? 'active' : ''} aria-pressed={selected === client.id}
|
||||
onClick={() => onSelect(client.id)} disabled={busy}>
|
||||
<span className="agent-client-icon">{client.icon}</span>
|
||||
@@ -105,29 +133,38 @@ export function AgentClientList<Id extends string>({
|
||||
</button>)}
|
||||
</div>
|
||||
<div className="agent-client-list-footer">
|
||||
<small>{t('agents.clients.shown', { count: visibleClients.length, total: clients.length })}</small>
|
||||
<button type="button" className="secondary-button agent-client-switch" disabled={busy || loading}
|
||||
onClick={() => { setQuery(''); setPicking(true); }}>
|
||||
<Search size={15} aria-hidden="true" />{t('agents.clients.switch')}
|
||||
</button>
|
||||
<small>{t('agents.clients.shown', { count: shortcuts.length, total: clients.length })}</small>
|
||||
<button type="button" className="secondary-button compact-button" disabled={busy || loading}
|
||||
onClick={() => { setDraftIds(visibleIds); setQuery(''); setManaging(true); }}>
|
||||
<SlidersHorizontal size={15} aria-hidden="true" />{t('agents.clients.manage')}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
{managing ? <dialog className="agent-client-manager" ref={dialogRef} aria-labelledby={titleId}
|
||||
aria-describedby={descriptionId} onCancel={(event) => { event.preventDefault(); setManaging(false); }}>
|
||||
{managing || picking ? <dialog className="agent-client-manager" ref={dialogRef} aria-labelledby={titleId}
|
||||
aria-describedby={descriptionId} onCancel={(event) => { event.preventDefault(); close(); }}>
|
||||
<header>
|
||||
<h2 id={titleId}>{t('agents.clients.manage')}</h2>
|
||||
<button type="button" className="icon-button quiet" aria-label={t('common.close')} onClick={() => setManaging(false)}>
|
||||
<h2 id={titleId}>{t(picking ? 'agents.clients.switch' : 'agents.clients.manage')}</h2>
|
||||
<button type="button" className="icon-button quiet" aria-label={t('common.close')} onClick={close}>
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
<p id={descriptionId}>{t('agents.clients.description')}</p>
|
||||
<p id={descriptionId}>{t(picking ? 'agents.clients.switchHint' : 'agents.clients.description')}</p>
|
||||
<div className="agent-client-search">
|
||||
<Search size={16} aria-hidden="true" />
|
||||
<input ref={searchRef} type="search" value={query} onChange={(event) => setQuery(event.currentTarget.value)}
|
||||
placeholder={t('agents.clients.search')} aria-label={t('agents.clients.search')} />
|
||||
</div>
|
||||
<div className="agent-client-catalog">
|
||||
{matches.map((client) => <label key={client.id} className="agent-client-option">
|
||||
{matches.map((client) => picking ? <button type="button" key={client.id} className="agent-client-option"
|
||||
aria-label={client.name} aria-pressed={selected === client.id} onClick={() => pick(client.id)}>
|
||||
<span className="agent-client-icon">{client.icon}</span>
|
||||
<span className="agent-client-option-copy"><strong>{client.name}</strong><small title={client.summary}>{client.summary}</small></span>
|
||||
{selected === client.id ? <Check size={16} aria-hidden="true" /> : null}
|
||||
</button> : <label key={client.id} className="agent-client-option">
|
||||
<input type="checkbox" aria-label={client.name} checked={draftSelection.includes(client.id)}
|
||||
onChange={(event) => setDraftIds(event.currentTarget.checked
|
||||
? [...draftSelection, client.id] : draftSelection.filter((id) => id !== client.id))} />
|
||||
@@ -136,7 +173,7 @@ export function AgentClientList<Id extends string>({
|
||||
</label>)}
|
||||
{!matches.length ? <p role="status">{t('agents.clients.noResults')}</p> : null}
|
||||
</div>
|
||||
<div className="agent-client-manager-defaults">
|
||||
{managing ? <><div className="agent-client-manager-defaults">
|
||||
<button type="button" className="secondary-button compact-button" onClick={() => setDraftIds(null)}>
|
||||
{t('agents.clients.automatic')}
|
||||
</button>
|
||||
@@ -148,7 +185,7 @@ export function AgentClientList<Id extends string>({
|
||||
<button type="button" className="secondary-button" onClick={() => setManaging(false)}>{t('common.cancel')}</button>
|
||||
<button type="button" className="primary-button" onClick={save} disabled={!draftSelection.length}>{t('common.save')}</button>
|
||||
</div>
|
||||
</footer>
|
||||
</footer></> : null}
|
||||
</dialog> : null}
|
||||
</>;
|
||||
}
|
||||
@@ -41,12 +41,12 @@ export function AgentRunControls({
|
||||
return <section className="agent-run-controls" aria-label={t('agents.run.title')}>
|
||||
<div className="agent-section-heading"><div><strong>{t('agents.run.title')}</strong></div></div>
|
||||
<div className="agent-launch-actions">
|
||||
{dualTargets ? <>{launchButton(cli, 'cli')}{launchButton(app, 'app')}</>
|
||||
: harness?.running ? <button type="button" className="danger-button agent-launch-button"
|
||||
{harness?.running ? <button type="button" className="danger-button agent-launch-button"
|
||||
disabled={busy} onClick={onStop} title={t('agents.deepseekLaunch.runningDetail', { pid: harness.pid ?? '—', mode: harness.mode ?? '—' })}>
|
||||
{busyAction === 'stop-deepseek' ? <LoaderCircle size={16} className="spin" /> : <Square size={16} />}
|
||||
{t(busyAction === 'stop-deepseek' ? 'agents.deepseekLaunch.stopping' : 'agents.deepseekLaunch.stop')}
|
||||
</button> : launchButton(targets[0] ?? null)}
|
||||
</button> : dualTargets ? launchButton(cli, 'cli') : launchButton(targets[0] ?? null)}
|
||||
{dualTargets ? launchButton(app, 'app') : null}
|
||||
{desktop ? <button type="button" className="secondary-button agent-launch-button"
|
||||
onClick={onRestart} disabled={busy || !enabled || !app} title={app?.detail ?? t('agents.launch.unavailable')}>
|
||||
{busyAction === 'restart-app' ? <LoaderCircle size={16} className="spin" /> : <RefreshCw size={16} />}
|
||||
|
||||
@@ -1079,7 +1079,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
|
||||
const selectedModel = selectedModelOption?.name ?? '';
|
||||
const isPiClient = selected === 'pi';
|
||||
const isDeepSeekHarnessClient = selected === 'deepseek-harness';
|
||||
const hasIndependentCliAndApp = selected === 'codex' || selected === 'opencode';
|
||||
const hasIndependentCliAndApp = selected === 'codex' || selected === 'opencode' || isDeepSeekHarnessClient;
|
||||
const isClaudeModelMappingClient = selected === 'claude-code' || selected === 'claude-desktop';
|
||||
const claudeModelMappingsDraft = isClaudeModelMappingClient
|
||||
? claudeModelMappingsDraftByClient[selected]
|
||||
@@ -2461,7 +2461,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
|
||||
<MessageNotice tone="success" message={!configurationErrorMessage ? configurationNotice || clearNotice : null}
|
||||
onDismiss={() => { setConfigurationNotice(''); setClearNotice(''); }} />
|
||||
{activeSubpage === 'core' ? <AgentRunControls name={activeDefinition.name} dualTargets={hasIndependentCliAndApp}
|
||||
desktop={hasIndependentCliAndApp || selected === 'claude-desktop' || selected === 'zcode' || selected === 'workbuddy'}
|
||||
desktop={(hasIndependentCliAndApp && !isDeepSeekHarnessClient) || selected === 'claude-desktop' || selected === 'zcode' || selected === 'workbuddy'}
|
||||
targets={activeLaunchTargets} enabled={launchEnabled} busyAction={busyAction}
|
||||
harness={isDeepSeekHarnessClient ? deepSeekHarnessProcessStatus : null}
|
||||
onLaunch={(target) => void launchAgent(target)} onRestart={() => void restartDesktopApp()}
|
||||
|
||||
+43
-41
@@ -19,7 +19,7 @@
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 16px 20px;
|
||||
padding: 20px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-card);
|
||||
@@ -28,8 +28,8 @@
|
||||
|
||||
.home-dashboard .home-top-grid .panel-heading {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
min-height: 0;
|
||||
align-items: flex-start;
|
||||
min-height: 48px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -43,14 +43,14 @@
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.home-dashboard .home-top-grid h2 { font-size: 18px; }
|
||||
.home-dashboard .home-top-grid h2 { font-size: 17px; }
|
||||
|
||||
.home-dashboard .control-panel .panel-detail-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: none;
|
||||
align-content: start;
|
||||
gap: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
flex: 1;
|
||||
align-content: stretch;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
@@ -58,29 +58,28 @@
|
||||
|
||||
.home-dashboard .control-panel .panel-detail-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
min-height: 32px;
|
||||
padding: 4px 0;
|
||||
padding: 6px 12px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--border-secondary);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.home-dashboard .control-panel .panel-detail-row:last-child { border-bottom: 0; }
|
||||
|
||||
.home-dashboard .control-panel .panel-detail-row dt {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 16px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.home-dashboard .control-panel .panel-detail-row dd {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
white-space: normal;
|
||||
@@ -113,7 +112,7 @@
|
||||
min-width: 0;
|
||||
min-height: 38px;
|
||||
padding: 6px 10px;
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
@@ -140,21 +139,26 @@
|
||||
}
|
||||
|
||||
.home-panel-heading > div { min-width: 0; }
|
||||
.home-panel-heading { flex-wrap: wrap; }
|
||||
.home-panel-heading p {
|
||||
.home-panel-heading { flex-wrap: wrap; align-items: flex-start; min-height: 48px; }
|
||||
.home-dashboard .home-panel-heading p {
|
||||
margin: 5px 0 0;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 14px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.home-dashboard .home-access-panel { justify-content: space-between; }
|
||||
|
||||
.home-connection-state {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 14px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.home-connection-state > span { width: 8px; height: 8px; flex: none; border-radius: 50%; background: currentColor; }
|
||||
@@ -162,9 +166,10 @@
|
||||
|
||||
.home-protocol-tabs {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border-radius: 9px;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
.home-protocol-tabs button {
|
||||
display: inline-flex;
|
||||
@@ -174,19 +179,18 @@
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 6px 4px 8px;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
border-radius: 0;
|
||||
padding: 6px 4px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.home-protocol-tabs button:hover { color: var(--text-primary); background: transparent; }
|
||||
.home-protocol-tabs button.active { color: var(--ui-accent); border-bottom-color: var(--ui-accent); font-weight: 600; }
|
||||
.home-protocol-tabs img { width: 20px; height: 20px; flex: none; object-fit: contain; }
|
||||
.home-protocol-tabs button.active { color: var(--ui-accent); border-color: var(--border-color); background: var(--bg-card); font-weight: 600; box-shadow: 0 1px 2px rgb(0 0 0 / 4%); }
|
||||
.home-protocol-tabs img { width: 18px; height: 18px; flex: none; object-fit: contain; }
|
||||
:root[data-theme='dark'] #home-protocol-openai img { filter: invert(1); }
|
||||
|
||||
.home-dashboard .home-access-panel .home-connection-details.client-api-card,
|
||||
@@ -211,10 +215,10 @@
|
||||
gap: 4px 12px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 16px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.home-field-label > span + span { color: var(--text-tertiary); font-size: 14px; }
|
||||
.home-field-label > span + span { color: var(--text-tertiary); font-size: 12px; }
|
||||
.home-copy-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -224,7 +228,7 @@
|
||||
padding: 4px 6px 4px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-primary);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
.home-copy-field code {
|
||||
flex: 1;
|
||||
@@ -232,7 +236,7 @@
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.home-dashboard .home-copy-field > button { flex: none; width: 32px; height: 32px; min-width: 32px; border-color: transparent; background: transparent; }
|
||||
@@ -248,7 +252,7 @@
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--ui-accent);
|
||||
font-size: 14px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -294,8 +298,6 @@
|
||||
.content > .home-page.home-dashboard { flex: 1; }
|
||||
.home-dashboard .home-top-grid { flex: 0 0 auto; }
|
||||
.home-dashboard .control-panel .panel-detail-grid { flex: 1; }
|
||||
.home-dashboard .control-panel .panel-detail-row { flex: 1; }
|
||||
.home-dashboard .home-access-panel { justify-content: space-between; }
|
||||
.home-dashboard .home-overview { display: flex; flex-direction: column; flex: 0 0 auto; }
|
||||
.home-dashboard .home-overview-heading { flex: none; }
|
||||
.home-dashboard .home-stat-grid { flex: 1; }
|
||||
|
||||
@@ -213,9 +213,10 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
|
||||
<h1 className="sr-only">{t('app.nav.home')}</h1>
|
||||
<div className="home-top-grid">
|
||||
<div className="panel control-panel">
|
||||
<div className="panel-heading">
|
||||
<div className="panel-heading home-panel-heading">
|
||||
<div>
|
||||
<h2>{t('kernel.control.title')}</h2>
|
||||
<p>{t(coreReady ? 'home.runtime.ready' : coreInstalled ? 'home.runtime.stopped' : 'home.runtime.notInstalled')}</p>
|
||||
</div>
|
||||
<button type="button" className="icon-button quiet home-runtime-refresh" disabled={coreProcessBusy} onClick={() => void refreshStatus()} title={t('kernel.control.refresh')} aria-label={t('kernel.control.refresh')}><RefreshCw size={15} aria-hidden="true" /></button>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { AlertTriangle, ExternalLink, X } from 'lucide-react';
|
||||
import { ExternalLink, X } from 'lucide-react';
|
||||
import { useDialogFocusTrap } from '../components/useDialogFocusTrap';
|
||||
import { useI18n } from '../i18n';
|
||||
import { pluginText } from '../i18n/plugins';
|
||||
import { pluginStoreApi, type PluginStoreEntry, type PluginStoreInstallResult } from '../services/plugins';
|
||||
import { buildRepositoryURL, getPluginConfirmToken, isOfficialPlugin } from '../services/pluginResources';
|
||||
import { buildRepositoryURL } from '../services/pluginResources';
|
||||
import { isValidManualReleaseTag, supportsPluginVersionSelection } from '../services/pluginReleaseVersions';
|
||||
|
||||
export function PluginInstallDialog({ entry, onClose, onInstalled }: {
|
||||
@@ -17,8 +17,6 @@ export function PluginInstallDialog({ entry, onClose, onInstalled }: {
|
||||
const { t, locale } = useI18n();
|
||||
const pt = (key: Parameters<typeof pluginText>[0]) => pluginText(key, locale);
|
||||
const [version, setVersion] = useState('');
|
||||
const [step, setStep] = useState(0);
|
||||
const [typed, setTyped] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const pending = useRef(false);
|
||||
@@ -26,14 +24,12 @@ export function PluginInstallDialog({ entry, onClose, onInstalled }: {
|
||||
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
|
||||
// The dialog remains open throughout installation, including on errors.
|
||||
const ref = useDialogFocusTrap<HTMLElement>({ onEscape: busy ? undefined : onClose, preventEscape: busy });
|
||||
const thirdParty = !isOfficialPlugin(entry);
|
||||
const token = getPluginConfirmToken(entry);
|
||||
const canSelectVersion = supportsPluginVersionSelection(entry.installType);
|
||||
const validVersion = !version.trim() || isValidManualReleaseTag(version);
|
||||
const repository = buildRepositoryURL(entry.repository);
|
||||
const action = entry.installed ? pt('update') : pt('install');
|
||||
const install = async () => {
|
||||
if (pending.current || !validVersion || (thirdParty && (step < 2 || typed.trim() !== token))) return;
|
||||
if (pending.current || !validVersion) return;
|
||||
pending.current = true; setBusy(true); setError('');
|
||||
try {
|
||||
const result = await pluginStoreApi.install(entry.id, { sourceId: entry.sourceId, version: canSelectVersion ? version.trim() : undefined });
|
||||
@@ -46,13 +42,11 @@ export function PluginInstallDialog({ entry, onClose, onInstalled }: {
|
||||
<section className="config-dialog plugin-install-dialog" role="dialog" aria-modal="true" aria-labelledby="plugin-install-title" ref={ref} tabIndex={-1}>
|
||||
<div className="config-dialog-heading"><h2 id="plugin-install-title">{action} · {entry.name || entry.id}</h2><button className="icon-button quiet" aria-label={t('common.close')} disabled={busy} onClick={onClose}><X size={18} /></button></div>
|
||||
<div className="plugin-install-body">
|
||||
<dl className="plugin-install-origin"><dt>{pt('source')}</dt><dd>{entry.sourceName || entry.sourceId}<small>{entry.sourceUrl}</small></dd><dt>{pt('repository')}</dt><dd>{entry.repository || '—'}{repository && <button className="icon-button quiet" aria-label={pt('repository')} onClick={() => void invoke('open_external_url', { url: repository }).catch(e => setError(String(e)))}><ExternalLink size={15} /></button>}</dd></dl>
|
||||
{step === 0 && <><p>{pt('installHint')}</p>{canSelectVersion && <label className="plugin-field">{pt('version')}<input value={version} onChange={e => setVersion(e.target.value)} disabled={busy} placeholder={entry.version || 'v1.0.0'} aria-invalid={!validVersion} />{!validVersion && <small role="alert">{pt('invalidVersion')}</small>}</label>}</>}
|
||||
{thirdParty && step >= 1 && <p className="plugin-warning"><AlertTriangle size={18} aria-hidden="true" />{pt('trustWarning')}</p>}
|
||||
{thirdParty && step === 2 && <label className="plugin-field">{pt('trustType')}<code>{token}</code><input value={typed} onChange={e => setTyped(e.target.value)} disabled={busy} autoComplete="off" spellCheck={false} /></label>}
|
||||
<dl className="plugin-install-origin"><dt>{pt('source')}</dt><dd>{entry.sourceName || entry.sourceId}<small>{entry.sourceUrl}</small></dd><dt>{pt('repository')}</dt><dd className="plugin-install-repository"><span>{entry.repository || '—'}</span>{repository && <button className="icon-button quiet" aria-label={pt('repository')} onClick={() => void invoke('open_external_url', { url: repository }).catch(e => setError(String(e)))}><ExternalLink size={15} /></button>}</dd></dl>
|
||||
{canSelectVersion && <label className="plugin-field">{pt('version')}<input value={version} onChange={e => setVersion(e.target.value)} disabled={busy} placeholder={entry.version || 'v1.0.0'} aria-invalid={!validVersion} />{!validVersion && <small role="alert">{pt('invalidVersion')}</small>}</label>}
|
||||
{error && <p className="plugin-error" role="alert">{error}</p>}
|
||||
</div>
|
||||
<div className="plugin-dialog-actions"><button className="secondary-button" disabled={busy} onClick={onClose}>{t('common.cancel')}</button>{thirdParty && step < 2 ? <button className="primary-button" disabled={!validVersion} onClick={() => setStep(step + 1)}>{step === 0 ? action : pt('trustNext')}</button> : <button className={thirdParty ? 'danger-button' : 'primary-button'} disabled={busy || !validVersion || (thirdParty && typed.trim() !== token)} onClick={() => void install()}>{busy ? t('common.loading') : action}</button>}</div>
|
||||
<div className="plugin-dialog-actions"><button className="secondary-button" disabled={busy} onClick={onClose}>{t('common.cancel')}</button><button className="primary-button" disabled={busy || !validVersion} onClick={() => void install()}>{busy ? t('common.loading') : action}</button></div>
|
||||
</section>
|
||||
</div>, document.body);
|
||||
}
|
||||
+31
-17
@@ -1,17 +1,19 @@
|
||||
.plugins-page { display: flex; flex-direction: column; gap: 20px; min-width: 0; max-width: 1500px; margin: 0 auto; padding: 8px 0 32px; }
|
||||
.plugins-heading { display: flex; justify-content: space-between; align-items: center; gap: 20px; }
|
||||
.plugins-heading h1 { margin: 8px 0; font-size: 28px; letter-spacing: -.6px; }
|
||||
.plugins-page .plugins-heading > button { width: auto; flex: none; }
|
||||
.plugins-heading h1 { margin: 0 0 6px; font-size: 24px; letter-spacing: -.4px; }
|
||||
.plugins-heading p { margin: 0; color: var(--text-tertiary); line-height: 1.7; }
|
||||
.plugins-eyebrow { display: flex; gap: 8px; align-items: center; color: var(--primary-color); font-size: 12px; font-weight: 650; }
|
||||
.plugin-summary { display: flex; flex-wrap: wrap; gap: 16px 24px; align-items: center; background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 10px; padding: 18px 20px; }
|
||||
.plugin-summary { display: flex; flex-wrap: wrap; gap: 12px 24px; align-items: center; background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 12px; padding: 14px 18px; }
|
||||
.plugin-summary > span { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-tertiary); overflow-wrap: anywhere; min-width: 0; }
|
||||
.plugin-summary > span:last-child { margin-left: auto; }
|
||||
.plugin-global-toggle { display: flex; align-items: center; gap: 10px; font-size: 14px; font-weight: 600; }
|
||||
.plugin-global-toggle input { width: 18px; height: 18px; accent-color: var(--primary-color); }
|
||||
.plugin-banner { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 18px; margin: 0; border-radius: 8px; background: var(--bg-tertiary); color: var(--text-secondary); font-size: 13px; line-height: 1.65; overflow-wrap: anywhere; }
|
||||
.plugin-tabs { display: flex; gap: 24px; border-bottom: 1px solid var(--border-color); }
|
||||
.plugin-tabs button { border: 0; border-bottom: 2px solid transparent; padding: 12px 2px; border-radius: 0; background: transparent; color: var(--text-tertiary); font-size: 14px; }
|
||||
.plugin-tabs button.active { border-color: var(--primary-color); color: var(--primary-color); font-weight: 650; }
|
||||
.plugin-navigation { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px 20px; }
|
||||
.plugin-navigation > .plugin-toolbar { flex: 1 1 260px; justify-content: flex-end; }
|
||||
.plugin-tabs { display: flex; flex-wrap: wrap; gap: 4px; padding: 4px; border: 1px solid var(--border-color); border-radius: 10px; background: var(--bg-tertiary); }
|
||||
.plugin-tabs button { border: 1px solid transparent; padding: 7px 14px; border-radius: 7px; background: transparent; color: var(--text-tertiary); font-size: 13px; }
|
||||
.plugin-tabs button.active { border-color: var(--border-color); background: var(--bg-card); color: var(--primary-color); font-weight: 650; box-shadow: 0 1px 2px rgb(0 0 0 / 4%); }
|
||||
.plugin-toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.plugin-search { display: flex; align-items: center; gap: 9px; max-width: 440px; min-width: 180px; flex: 1; padding: 0 12px; border: 1px solid var(--border-color); border-radius: 8px; background: var(--bg-card); color: var(--text-tertiary); }
|
||||
.plugin-search input { border: 0; background: transparent; color: var(--text-primary); width: 100%; min-width: 0; padding: 11px 0; font: inherit; font-size: 13px; outline: none; }
|
||||
@@ -19,19 +21,24 @@
|
||||
.plugin-toolbar select { border: 1px solid var(--border-color); border-radius: 8px; color: var(--text-primary); background: var(--bg-card); padding: 10px 12px; }
|
||||
.plugin-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 330px), 1fr)); gap: 16px; }
|
||||
.plugin-card { min-width: 0; display: flex; flex-direction: column; background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 12px; padding: 20px; gap: 12px; }
|
||||
.plugin-card-title { display: flex; align-items: center; gap: 12px; min-width: 0; flex-wrap: wrap; }
|
||||
.plugin-card-title > div { flex: 1; min-width: 100px; }
|
||||
.plugin-card-title { display: grid; grid-template-columns: 40px minmax(0, 1fr) fit-content(38%); align-items: center; gap: 8px 12px; min-width: 0; }
|
||||
.plugin-card-title > div { min-width: 0; }
|
||||
.plugin-card-title > .plugin-badge { justify-self: end; white-space: normal; overflow-wrap: anywhere; line-height: 1.5; }
|
||||
.plugin-card-title h2 { font-size: 15px; margin: 0 0 5px; overflow-wrap: anywhere; }
|
||||
.plugin-card-title code { color: var(--text-tertiary); font-size: 11px; overflow-wrap: anywhere; }
|
||||
.plugin-icon { display: grid; place-items: center; width: 44px; height: 44px; flex-shrink: 0; border-radius: 10px; color: var(--primary-color); background: var(--ui-accent-soft); }
|
||||
.plugin-icon { display: grid; place-items: center; width: 40px; height: 40px; flex-shrink: 0; border-radius: 10px; color: var(--primary-color); background: var(--ui-accent-soft); }
|
||||
.plugin-badge { display: inline-flex; padding: 4px 8px; border-radius: 5px; background: var(--bg-tertiary); color: var(--text-tertiary); font-size: 11px; white-space: nowrap; }
|
||||
.plugin-badge.active { color: var(--success-badge-text); background: var(--success-badge-bg); }
|
||||
.plugin-badge.warning { color: var(--amber-text); background: var(--theme-fff7e9); }
|
||||
.plugin-meta, .plugin-path { color: var(--text-tertiary); font-size: 12px; margin: 0; line-height: 1.7; overflow-wrap: anywhere; }
|
||||
.plugin-path { font-family: ui-monospace, monospace; font-size: 11px; }
|
||||
.plugin-local-card .plugin-path { padding: 8px 10px; border-radius: 6px; background: var(--bg-secondary); }
|
||||
.plugin-local-card .plugin-card-description { padding: 10px 12px; border-radius: 8px; background: var(--bg-secondary); color: var(--text-tertiary); font-size: 12px; }
|
||||
.plugin-local-card .plugin-card-title h2:last-child { margin-bottom: 0; }
|
||||
.plugin-card-description { font-size: 13px; line-height: 1.7; color: var(--text-secondary); margin: 0; overflow-wrap: anywhere; }
|
||||
.plugin-card-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding-top: 12px; margin-top: auto; border-top: 1px solid var(--border-color); }
|
||||
.plugin-card-actions button { font-size: 12px; min-height: 34px; }
|
||||
.plugins-page .plugin-card-actions > button { width: auto; }
|
||||
.plugin-card-actions .primary-button { margin-left: auto; }
|
||||
.plugin-delete { margin-left: auto; color: var(--danger-color); }
|
||||
.plugin-menus { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
@@ -49,18 +56,25 @@
|
||||
.plugin-field textarea { resize: vertical; font-family: ui-monospace, monospace; font-weight: normal; line-height: 1.7; }
|
||||
.plugin-field small { font-size: 12px; color: var(--text-tertiary); font-weight: normal; line-height: 1.7; }
|
||||
.plugin-field code { overflow-wrap: anywhere; padding: 10px; border-radius: 5px; background: var(--bg-tertiary); }
|
||||
.config-dialog.plugin-install-dialog { width: min(620px, 100%); padding: 0; overflow: auto; max-height: calc(100dvh - 40px); color: var(--text-primary); }
|
||||
.plugin-install-dialog .config-dialog-heading { padding: 20px; border-bottom: 1px solid var(--border-color); }
|
||||
.plugin-install-dialog h2 { font-size: 17px; overflow-wrap: anywhere; }
|
||||
.plugin-install-body { display: grid; gap: 20px; padding: 20px; font-size: 13px; line-height: 1.7; }
|
||||
.config-dialog.plugin-install-dialog { display: flex; flex-direction: column; gap: 0; width: min(620px, 100%); height: auto; min-width: 0; padding: 0; overflow: hidden; max-height: calc(100dvh - 40px); color: var(--text-primary); }
|
||||
.plugin-install-dialog .config-dialog-heading { flex-shrink: 0; padding: 20px; border-bottom: 1px solid var(--border-color); }
|
||||
.plugin-install-dialog .config-dialog-heading h2 { min-width: 0; font-size: 17px; white-space: normal; overflow-wrap: anywhere; }
|
||||
.plugin-install-dialog .config-dialog-heading button { flex-shrink: 0; }
|
||||
.plugin-install-body { display: grid; gap: 20px; min-height: 0; overflow-y: auto; padding: 20px; font-size: 13px; line-height: 1.7; }
|
||||
.plugin-install-body p { margin: 0; }
|
||||
.plugin-install-origin { display: grid; grid-template-columns: auto 1fr; gap: 10px 16px; margin: 0; font-size: 12px; }
|
||||
.plugin-install-origin { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 12px 16px; margin: 0; font-size: 12px; }
|
||||
.plugin-install-origin dt { color: var(--text-tertiary); }
|
||||
.plugin-install-origin dd { margin: 0; overflow-wrap: anywhere; }
|
||||
.plugin-install-origin dd { min-width: 0; margin: 0; overflow-wrap: anywhere; }
|
||||
.plugin-install-repository { display: flex; align-items: flex-start; gap: 8px; }
|
||||
.plugin-install-repository span { min-width: 0; }
|
||||
.plugin-install-repository button { flex-shrink: 0; }
|
||||
.plugin-install-origin small { display: block; color: var(--text-tertiary); }
|
||||
.plugin-warning { display: flex; gap: 10px; padding: 16px; background: var(--bg-error-light); border-radius: 8px; color: var(--error-color); }
|
||||
.plugin-warning svg { flex-shrink: 0; }
|
||||
.plugin-dialog-actions { display: flex; justify-content: flex-end; gap: 10px; padding: 18px 20px; border-top: 1px solid var(--border-color); }
|
||||
.plugin-dialog-actions { display: flex; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; gap: 10px; padding: 18px 20px; border-top: 1px solid var(--border-color); }
|
||||
.plugins-page .primary-button, .plugins-page .secondary-button, .plugin-install-dialog .primary-button, .plugin-install-dialog .secondary-button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; }
|
||||
.plugins-page button svg { flex-shrink: 0; }
|
||||
.plugin-resource { display: flex; flex-direction: column; gap: 16px; }
|
||||
.plugin-resource iframe { width: 100%; min-height: 65vh; border: 1px solid var(--border-color); border-radius: 8px; background: var(--bg-card); }
|
||||
@media (max-width: 700px) { .plugins-heading { align-items: flex-start; gap: 12px; } .plugins-heading h1 { font-size: 24px; } .plugin-summary { padding: 14px; gap: 14px; } .plugin-summary > span { width: 100%; } .plugin-summary > span:last-child { margin-left: 0; } .plugin-card, .plugin-settings { padding: 16px; } .plugin-banner { flex-wrap: wrap; } .plugin-tabs { gap: 20px; } }
|
||||
@media (max-width: 700px) { .plugins-heading { align-items: flex-start; gap: 12px; } .plugins-heading h1 { font-size: 24px; } .plugin-summary { padding: 14px; gap: 14px; } .plugin-summary > span { width: 100%; } .plugin-summary > span:last-child { margin-left: 0; } .plugin-card, .plugin-settings { padding: 16px; } .plugin-banner { flex-wrap: wrap; } .plugin-navigation > .plugin-toolbar { flex-basis: 100%; } .plugin-search { max-width: none; } }
|
||||
@media (max-width: 420px) { .plugin-card-title { grid-template-columns: 40px minmax(0, 1fr); } .plugin-card-title > .plugin-badge { grid-column: 2; justify-self: start; } .plugin-tabs button { padding-inline: 10px; } }
|
||||
@@ -128,30 +128,33 @@ export function PluginsPage() {
|
||||
const locked = busy || loading || Boolean(configPlugin || installEntry || oauthPlugin);
|
||||
|
||||
return <div className="plugins-page">
|
||||
<header className="plugins-heading"><div><div className="plugins-eyebrow"><Puzzle size={18} aria-hidden="true" />CLI Proxy API</div><h1>{pt('title')}</h1><p>{pt('description')}</p></div><button className="secondary-button" disabled={locked} onClick={() => void load()}><RefreshCw size={16} aria-hidden="true" />{t('common.refresh')}</button></header>
|
||||
<header className="plugins-heading"><div><h1>{pt('title')}</h1><p>{pt('description')}</p></div><button className="secondary-button" disabled={locked} onClick={() => void load()}><RefreshCw size={16} aria-hidden="true" />{t('common.refresh')}</button></header>
|
||||
{error && <div className="plugin-error plugin-banner" role="alert"><span>{error}</span><button className="secondary-button" disabled={busy || loading} onClick={() => void load()}>{pt('retry')}</button></div>}
|
||||
{supported === false ? <section className="plugin-empty"><Puzzle size={36} /><p>{pt('unsupported')}</p></section> : supported === null ? <section className="plugin-empty" role="status">{loading ? t('common.loading') : pt('retry')}</section> : <>
|
||||
<div className="plugin-summary"><label className="plugin-global-toggle"><input type="checkbox" checked={data?.pluginsEnabled ?? false} disabled={locked || !data} onChange={e => { const enabled = e.target.checked; void mutate(() => pluginsApi.updateSettings({ enabled }), pt('saved')); }} />{pt('global')}</label><span><Folder size={15} aria-hidden="true" />{data?.pluginsDir || 'plugins'}</span><span>{pt('installed')} <strong>{installedCount}</strong>{configOnlyCount > 0 && <> · {pt('configOnly')} <strong>{configOnlyCount}</strong></>}</span></div>
|
||||
{data && !data.pluginsEnabled && <p className="plugin-banner">{pt('disabledHint')}</p>}
|
||||
{restartRequired && <div className="plugin-banner"><span>{pt('restart')}</span><button className="secondary-button" disabled={locked} onClick={async () => { if (await askConfirmation({ title: pt('restartButton'), message: pt('restartConfirm') })) void mutate(async () => { await invoke('restart_core_process'); if (mounted.current) setRestartRequired(false); }, pt('saved')); }}>{pt('restartButton')}</button></div>}
|
||||
{resource ? <section className="plugin-resource"><div className="plugin-toolbar"><button className="secondary-button" onClick={() => setResource(null)}><ArrowLeft size={16} />{pt('back')}</button><strong>{resource.entry.label}</strong><button className="secondary-button" onClick={() => void invoke('open_external_url', { url: resource.url }).catch(e => setError(message(e)))}><ExternalLink size={16} />{pt('openExternal')}</button></div><iframe key={resource.url} src={resource.url} title={resource.entry.label} referrerPolicy="no-referrer" sandbox="allow-scripts allow-same-origin allow-forms allow-popups" allow="clipboard-read; clipboard-write" /></section> : <>
|
||||
<div className="plugin-navigation">
|
||||
<nav className="plugin-tabs" aria-label={pt('title')}>{(['installed', 'store', 'settings'] as const).map(value => <button key={value} className={tab === value ? 'active' : ''} aria-current={tab === value ? 'page' : undefined} disabled={busy} onClick={() => { setTab(value); setSearch(''); setFilter('all'); }}>{pt(value === 'installed' ? 'local' : value)}</button>)}</nav>
|
||||
{tab !== 'settings' &&
|
||||
<div className="plugin-toolbar"><label className="plugin-search"><Search size={17} aria-hidden="true" /><input type="search" value={search} onChange={e => setSearch(e.target.value)} placeholder={pt('search')} aria-label={pt('search')} /></label>{tab === 'store' && <select aria-label={pt('store')} value={filter} onChange={e => setFilter(e.target.value)}>{['all', 'installed', 'updates'].map(value => <option key={value} value={value}>{pt(value as 'all' | 'installed' | 'updates')}</option>)}</select>}</div>}
|
||||
</div>
|
||||
{tab === 'settings' ? <PluginSettingsPanel onSaved={() => { notice.showNotice(pt('saved')); void load(); }} /> : <>
|
||||
<div className="plugin-toolbar"><label className="plugin-search"><Search size={17} aria-hidden="true" /><input type="search" value={search} onChange={e => setSearch(e.target.value)} placeholder={pt('search')} aria-label={pt('search')} /></label>{tab === 'store' && <select aria-label={pt('store')} value={filter} onChange={e => setFilter(e.target.value)}>{['all', 'installed', 'updates'].map(value => <option key={value} value={value}>{pt(value as 'all' | 'installed' | 'updates')}</option>)}</select>}</div>
|
||||
{loading && <p className="plugin-loading" role="status">{t('common.loading')}…</p>}
|
||||
{tab === 'installed' ? <div className="plugin-grid">{plugins.map(plugin => {
|
||||
const status = getPluginStatus(plugin, data?.pluginsEnabled ?? false);
|
||||
const installed = isPluginInstalled(plugin);
|
||||
const meta = [plugin.metadata?.version, plugin.metadata?.author].filter(Boolean).join(' · ');
|
||||
const removeLabel = `${pt(installed ? 'remove' : 'removeConfig')} ${getPluginTitle(plugin)}`;
|
||||
return <article className="plugin-card" key={plugin.id}>
|
||||
<div className="plugin-card-title"><span className="plugin-icon"><Puzzle size={23} /></span><div><h2>{getPluginTitle(plugin)}</h2><code>{plugin.id}</code></div><span className={`plugin-badge ${status === 'active' ? 'active' : status === 'missingFile' || status === 'notLoaded' ? 'warning' : ''}`}>{pt(status)}</span></div>
|
||||
return <article className="plugin-card plugin-local-card" key={plugin.id}>
|
||||
<div className="plugin-card-title"><span className="plugin-icon"><Puzzle size={23} /></span><div><h2>{getPluginTitle(plugin)}</h2>{getPluginTitle(plugin) !== plugin.id && <code>{plugin.id}</code>}</div><span className={`plugin-badge ${status === 'active' ? 'active' : status === 'missingFile' || status === 'notLoaded' ? 'warning' : ''}`}>{pt(status)}</span></div>
|
||||
{meta && <p className="plugin-meta">{meta}</p>}{plugin.path && <p className="plugin-path" title={plugin.path}>{plugin.path}</p>}
|
||||
{status === 'missingFile' && <p className="plugin-card-description">{pt('missingFileHint')}</p>}
|
||||
{status === 'notLoaded' && <p className="plugin-card-description">{pt('notLoadedHint')}</p>}
|
||||
{collectPluginResourceEntries([plugin]).length > 0 && <div className="plugin-menus">{collectPluginResourceEntries([plugin]).map(entry => <button key={entry.menuIndex} title={entry.description} className="secondary-button" disabled={locked} onClick={() => void openResource(entry)}><ExternalLink size={14} />{entry.label}</button>)}</div>}
|
||||
{plugin.effectiveEnabled && plugin.supportsOAuth && plugin.oauthProvider && <button className="secondary-button" disabled={locked} onClick={() => setOAuthPlugin(plugin)}><LogIn size={15} />{pt('oauth')}</button>}
|
||||
<div className="plugin-card-actions">
|
||||
{plugin.effectiveEnabled && plugin.supportsOAuth && plugin.oauthProvider && <button className="secondary-button" disabled={locked} onClick={() => setOAuthPlugin(plugin)}><LogIn size={15} />{pt('oauth')}</button>}
|
||||
{installed ? <button className="secondary-button" disabled={locked} onClick={() => void mutate(() => pluginsApi.updateEnabled(plugin.id, !plugin.enabled), pt('saved'))}>{pt(plugin.enabled ? 'disabled' : 'enabled')}</button> : <button className="secondary-button" disabled={locked} onClick={() => { setSearch(plugin.id); setFilter('all'); setTab('store'); }}><Download size={15} />{pt('findInStore')}</button>}
|
||||
<button className="secondary-button" disabled={locked} onClick={() => setConfigPlugin(plugin)}><Settings2 size={15} />{pt('configure')}</button>
|
||||
{status === 'notLoaded' && <button className="secondary-button" disabled={locked} onClick={() => void invoke('open_core_logs_directory').catch(reason => { if (mounted.current) setError(message(reason)); })}><Folder size={15} />{pt('openLogs')}</button>}
|
||||
|
||||
@@ -21,15 +21,13 @@ const screenshots = path.join(os.tmpdir(), 'easycliproxy-agent-clients');
|
||||
await page.route('**/*', route => route.request().url().startsWith(base + '/') ? route.continue() : route.abort());
|
||||
fs.mkdirSync(screenshots, { recursive: true });
|
||||
|
||||
const clients = () => page.locator('.agent-list-items button strong');
|
||||
const visibleClients = () => clients().allTextContents();
|
||||
const activeClient = () => page.locator('.agent-list-items button.active strong').textContent();
|
||||
const stored = () => page.evaluate(key => localStorage.getItem(key), storageKey);
|
||||
const manage = () => page.getByRole('button', { name: '管理客户端', exact: true });
|
||||
const dialog = () => page.getByRole('dialog', { name: '管理客户端', exact: true });
|
||||
const manage = () => page.getByRole('button', { name: /^(管理客户端|Manage Clients)$/, exact: true });
|
||||
const dialog = () => page.getByRole('dialog', { name: /^(管理客户端|Manage Clients)$/, exact: true });
|
||||
const checkbox = name => dialog().getByRole('checkbox', { name, exact: true });
|
||||
const save = () => dialog().getByRole('button', { name: '保存', exact: true });
|
||||
const cancel = () => dialog().getByRole('button', { name: '取消', exact: true });
|
||||
const cancel = () => dialog().getByRole('button', { name: /^(取消|Cancel)$/, exact: true });
|
||||
const search = () => dialog().getByRole('searchbox', { name: '搜索客户端', exact: true });
|
||||
const automatic = () => dialog().getByRole('button', { name: '恢复自动显示', exact: true });
|
||||
const ready = () => page.waitForFunction(() => window.fixtureCalls?.some(call => call.cmd === 'get_agent_config_statuses')
|
||||
@@ -37,10 +35,15 @@ const screenshots = path.join(os.tmpdir(), 'easycliproxy-agent-clients');
|
||||
&& !document.querySelector('.agent-client-list-heading button').disabled);
|
||||
const waitClients = async names => {
|
||||
await page.waitForFunction(expected => {
|
||||
const actual = Array.from(document.querySelectorAll('.agent-list-items button strong'), node => node.textContent);
|
||||
return JSON.stringify(actual) === JSON.stringify(expected);
|
||||
const list = document.querySelector('.agent-list-items');
|
||||
const actual = Array.from(list.querySelectorAll('button strong'), node => node.textContent);
|
||||
const selected = list.querySelector('button.active strong')?.textContent;
|
||||
return actual.length > 0 && actual.length <= 6 && actual.every(name => expected.includes(name))
|
||||
&& expected.includes(selected) && list.scrollHeight <= list.clientHeight + 1;
|
||||
}, names);
|
||||
assert.deepEqual(await visibleClients(), names);
|
||||
await manage().click();
|
||||
assert.deepEqual(await dialog().locator('label:has(input:checked) strong').allTextContents(), names);
|
||||
await cancel().click();
|
||||
};
|
||||
const open = async (extra = 'mixed-clients', saved = null) => {
|
||||
if (page.url() !== 'about:blank') {
|
||||
@@ -172,6 +175,48 @@ const screenshots = path.join(os.tmpdir(), 'easycliproxy-agent-clients');
|
||||
await page.screenshot({ path: path.join(screenshots, 'manager-desktop.png'), fullPage: true });
|
||||
await closeAndFocus(() => cancel().click());
|
||||
|
||||
// Reproduce the reported desktop size with twelve previously saved clients.
|
||||
await page.setViewportSize({ width: 1368, height: 912 });
|
||||
const twelve = ['claude-code', 'claude-desktop', 'codex', 'deepseek-harness', 'opencode', 'pi',
|
||||
'grok-build', 'antigravity-cli', 'workbuddy', 'zcode', 'kimi-code', 'openclaw'];
|
||||
await page.evaluate(({ key, ids }) => localStorage.setItem(key, JSON.stringify(ids)), { key: storageKey, ids: twelve });
|
||||
await page.goto(`${base}/tests/fixtures/agent-backups.html?shell&client=claude-code`, { waitUntil: 'domcontentloaded' });
|
||||
await ready();
|
||||
await page.waitForFunction(() => document.querySelectorAll('.agent-list-items button').length === 6);
|
||||
const assertFits = async () => {
|
||||
const metrics = await page.locator('.agent-list-items').evaluate(list => {
|
||||
const rect = list.getBoundingClientRect();
|
||||
return { height: list.clientHeight, scrollHeight: list.scrollHeight, scrollWidth: list.scrollWidth,
|
||||
width: list.clientWidth, clipped: Array.from(list.children).some(child => {
|
||||
const r = child.getBoundingClientRect();
|
||||
return r.top < rect.top - 1 || r.bottom > rect.bottom + 1;
|
||||
}) };
|
||||
});
|
||||
assert.ok(metrics.scrollHeight <= metrics.height + 1 && metrics.scrollWidth <= metrics.width + 1);
|
||||
assert.equal(metrics.clipped, false, 'all shortcuts are fully visible without hiding overflowing rows');
|
||||
};
|
||||
await assertFits();
|
||||
assert.deepEqual(JSON.parse(await stored()), twelve, 'limiting shortcuts preserves saved preferences');
|
||||
const right = await page.locator('.agent-config-scroll-region').evaluate(node => [node.clientHeight, node.scrollHeight]);
|
||||
assert.ok(right[1] <= right[0] + 1, 'Claude Code settings fit at the reported desktop size');
|
||||
const switchClient = () => page.getByRole('button', { name: '切换客户端', exact: true });
|
||||
await switchClient().click();
|
||||
const picker = page.getByRole('dialog', { name: '切换客户端', exact: true });
|
||||
await picker.getByRole('searchbox').fill('herMES');
|
||||
await picker.getByRole('button', { name: 'Hermes Agent', exact: true }).click();
|
||||
assert.equal(await activeClient(), 'Hermes Agent', 'a client outside the first six remains in the shortcuts after selection');
|
||||
assert.ok(JSON.parse(await stored()).includes('hermes'), 'switching to a hidden client brings it back');
|
||||
assert.equal(await switchClient().evaluate(node => node === document.activeElement), true);
|
||||
await assertFits();
|
||||
await page.setViewportSize({ width: 360, height: 600 });
|
||||
await page.waitForFunction(() => document.querySelectorAll('.agent-list-items button').length < 6);
|
||||
await assertFits();
|
||||
assert.equal(await activeClient(), 'Hermes Agent', 'resizing always retains the active client');
|
||||
await page.setViewportSize({ width: 1368, height: 912 });
|
||||
await switchClient().click();
|
||||
await picker.getByRole('button', { name: 'Claude Code', exact: true }).click();
|
||||
await page.locator('.agent-workbench').screenshot({ path: path.join(screenshots, 'shortcuts-desktop.png') });
|
||||
|
||||
// Both entry points remain usable with narrow windows and English text in dark mode.
|
||||
for (const embedded of [false, true]) {
|
||||
for (const english of [false, true]) {
|
||||
|
||||
@@ -130,48 +130,23 @@ const desktopViewports = [
|
||||
return metrics;
|
||||
};
|
||||
|
||||
const assertClientListScroll = async (label, { requireOverflow = false } = {}) => {
|
||||
const assertClientShortcuts = async label => {
|
||||
const metrics = await page.locator('.agent-list-items').evaluate(list => {
|
||||
const style = getComputedStyle(list);
|
||||
const overflowX = list.scrollWidth > list.clientWidth + 1;
|
||||
const overflowY = list.scrollHeight > list.clientHeight + 1;
|
||||
const original = {
|
||||
left: list.scrollLeft,
|
||||
top: list.scrollTop,
|
||||
snap: list.style.scrollSnapType,
|
||||
behavior: list.style.scrollBehavior,
|
||||
};
|
||||
list.style.scrollSnapType = 'none';
|
||||
list.style.scrollBehavior = 'auto';
|
||||
const listRect = list.getBoundingClientRect();
|
||||
const unreachable = Array.from(list.querySelectorAll('button')).filter(button => {
|
||||
const before = button.getBoundingClientRect();
|
||||
list.scrollLeft += before.left - listRect.left;
|
||||
list.scrollTop += before.top - listRect.top;
|
||||
const after = button.getBoundingClientRect();
|
||||
return after.left < listRect.left - 1 || after.right > listRect.right + 1
|
||||
|| after.top < listRect.top - 1 || after.bottom > listRect.bottom + 1;
|
||||
}).map(button => button.textContent.trim());
|
||||
const scrollable = (!overflowX || ['auto', 'scroll'].includes(style.overflowX))
|
||||
&& (!overflowY || ['auto', 'scroll'].includes(style.overflowY));
|
||||
list.scrollLeft = original.left;
|
||||
list.scrollTop = original.top;
|
||||
list.style.scrollSnapType = original.snap;
|
||||
list.style.scrollBehavior = original.behavior;
|
||||
return {
|
||||
overflows: overflowX || overflowY,
|
||||
scrollable,
|
||||
unreachable,
|
||||
};
|
||||
const rect = list.getBoundingClientRect();
|
||||
const buttons = Array.from(list.querySelectorAll('button'));
|
||||
return { count: buttons.length,
|
||||
overflow: list.scrollHeight > list.clientHeight + 1 || list.scrollWidth > list.clientWidth + 1,
|
||||
clipped: buttons.filter(button => {
|
||||
const r = button.getBoundingClientRect();
|
||||
return r.top < rect.top - 1 || r.bottom > rect.bottom + 1 || r.left < rect.left - 1 || r.right > rect.right + 1;
|
||||
}).map(button => button.textContent) };
|
||||
});
|
||||
assert.equal(metrics.scrollable, true, `${label}: overflowing client options must remain scrollable`);
|
||||
if (requireOverflow) {
|
||||
assert.equal(metrics.overflows, true, `${label}: the fixture must exercise scrolling through the client list`);
|
||||
}
|
||||
assert.deepEqual(metrics.unreachable, [], `${label}: scrolling must reveal every client option`);
|
||||
assert.ok(metrics.count >= 1 && metrics.count <= 6, `${label}: show at most six shortcuts`);
|
||||
assert.equal(metrics.overflow, false, `${label}: shortcuts must fit without scrolling`);
|
||||
assert.deepEqual(metrics.clipped, [], `${label}: no shortcut may be clipped`);
|
||||
};
|
||||
|
||||
const assertDesktopLayout = async (label, { requireClientOverflow = false } = {}) => {
|
||||
const assertDesktopLayout = async label => {
|
||||
const [clientList, configuration] = await page.locator('.agent-client-list, .agent-config-panel')
|
||||
.evaluateAll(nodes => nodes.map(node => {
|
||||
const rect = node.getBoundingClientRect();
|
||||
@@ -184,7 +159,7 @@ const desktopViewports = [
|
||||
assert.ok(Math.abs(clientList.y - configuration.y) <= 1,
|
||||
`${label}: the desktop panels must share a top edge`);
|
||||
await assertNaturalPanel(label);
|
||||
await assertClientListScroll(label, { requireOverflow: requireClientOverflow });
|
||||
await assertClientShortcuts(label);
|
||||
return { clientList, configuration };
|
||||
};
|
||||
|
||||
@@ -200,7 +175,7 @@ const desktopViewports = [
|
||||
assert.ok(clientList.y + clientList.height <= configuration.y + 1,
|
||||
`${label}: the client list must stay above the configuration panel`);
|
||||
await assertNaturalPanel(label, { checkControls: false });
|
||||
await assertClientListScroll(label);
|
||||
await assertClientShortcuts(label);
|
||||
const pageWidth = await page.evaluate(() => ({
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
@@ -335,7 +310,7 @@ const desktopViewports = [
|
||||
};
|
||||
});
|
||||
|
||||
const assertFixedShellDesktop = async (label, { requireRightOverflow = false, requireClientOverflow = false } = {}) => {
|
||||
const assertFixedShellDesktop = async (label, { requireRightOverflow = false } = {}) => {
|
||||
const metrics = await shellLayoutMetrics();
|
||||
assert.ok(Math.abs(metrics.client.height - metrics.configuration.height) <= 1,
|
||||
`${label}: fixed-height desktop panels must remain equal in height`);
|
||||
@@ -349,7 +324,7 @@ const desktopViewports = [
|
||||
`${label}: fixed-height desktop layout must not create a far-right page scrollbar`);
|
||||
assert.ok(metrics.documentScrollWidth <= metrics.documentClientWidth + 1,
|
||||
`${label}: fixed-height desktop layout must not create horizontal page scrolling`);
|
||||
await assertClientListScroll(label, { requireOverflow: requireClientOverflow });
|
||||
await assertClientShortcuts(label);
|
||||
await assertShellScrollRegion(label, { requireOverflow: requireRightOverflow });
|
||||
return metrics;
|
||||
};
|
||||
@@ -369,10 +344,10 @@ const desktopViewports = [
|
||||
const label = `${viewport.width}x${viewport.height}/${embedded ? 'embedded' : 'full'}/${client}/core`;
|
||||
await assertDesktopLayout(label);
|
||||
if (client === 'zcode' && viewport.height === 941) {
|
||||
const status = page.locator('.agent-list-items button').filter({ hasText: 'Antigravity CLI' }).locator('small');
|
||||
const status = page.locator('.agent-list-items button').filter({ hasText: 'Claude Code' }).locator('small');
|
||||
const longModel = 'gemini-3.8-flash-high-with-a-long-client-status-for-layout-verification';
|
||||
await page.evaluate(model => {
|
||||
window.fixtureClientStatusesOverride = { 'antigravity-cli': { appliedModel: model } };
|
||||
window.fixtureClientStatusesOverride = { 'claude-code': { appliedModel: model } };
|
||||
}, longModel);
|
||||
await page.locator('.agent-client-list-heading button').click();
|
||||
await page.waitForFunction(model => Array.from(document.querySelectorAll('.agent-list-items small'))
|
||||
@@ -429,7 +404,6 @@ const desktopViewports = [
|
||||
const label = `${viewport.width}x${viewport.height}/shell/claude-desktop/core`;
|
||||
const metrics = await assertFixedShellDesktop(label, {
|
||||
requireRightOverflow: height === 700,
|
||||
requireClientOverflow: height === 700,
|
||||
});
|
||||
fixedShellByHeight.set(height, metrics);
|
||||
}
|
||||
@@ -466,7 +440,7 @@ const desktopViewports = [
|
||||
`${shellNarrowLabel}: the stacked workbench must preserve the fixed window-edge gap`);
|
||||
assert.ok(shellNarrow.documentScrollHeight <= shellNarrow.documentClientHeight + 1,
|
||||
`${shellNarrowLabel}: the bounded one-column shell must not leak scrolling to the page`);
|
||||
await assertClientListScroll(shellNarrowLabel);
|
||||
await assertClientShortcuts(shellNarrowLabel);
|
||||
await assertShellScrollRegion(shellNarrowLabel);
|
||||
|
||||
// At phone width the shell follows Usage records and returns to natural document scrolling.
|
||||
@@ -482,7 +456,7 @@ const desktopViewports = [
|
||||
`${shellPhoneLabel}: phone-width client list must remain above configuration content`);
|
||||
assert.ok(shellPhone.documentScrollWidth <= shellPhone.documentClientWidth + 1,
|
||||
`${shellPhoneLabel}: natural document flow must not create horizontal scrolling`);
|
||||
await assertClientListScroll(shellPhoneLabel);
|
||||
await assertClientShortcuts(shellPhoneLabel);
|
||||
await assertShellScrollRegion(shellPhoneLabel, { natural: true });
|
||||
|
||||
for (const viewport of [{ width: 640, height: 700 }, { width: 360, height: 941 }]) {
|
||||
@@ -511,7 +485,7 @@ const desktopViewports = [
|
||||
await tab('会话管理').click();
|
||||
await page.locator('.codex-session-row').first().waitFor();
|
||||
const label = `${viewport.width}x${viewport.height}/full/codex/sessions`;
|
||||
await assertDesktopLayout(label, { requireClientOverflow: false });
|
||||
await assertDesktopLayout(label);
|
||||
const sessions = await page.locator('.codex-session-list').evaluate(list => {
|
||||
const style = getComputedStyle(list);
|
||||
const last = list.querySelector('.codex-session-row:last-child');
|
||||
|
||||
@@ -12,7 +12,14 @@ const assert = require('node:assert/strict');
|
||||
page.on('pageerror', error => errors.push(String(error)));
|
||||
const tab = name => page.getByRole('tab', { name, exact: true });
|
||||
const button = name => page.getByRole('button', { name, exact: true });
|
||||
const client = name => page.locator('.agent-list-items button').filter({ has: page.getByText(name, { exact: true }) });
|
||||
const client = name => ({ click: async () => {
|
||||
const shortcut = page.locator('.agent-list-items button').filter({ has: page.getByText(name, { exact: true }) });
|
||||
if (await shortcut.count()) await shortcut.click();
|
||||
else {
|
||||
await button('切换客户端').click();
|
||||
await page.getByRole('dialog', { name: '切换客户端', exact: true }).getByRole('button', { name, exact: true }).click();
|
||||
}
|
||||
} });
|
||||
const active = async name => {
|
||||
await page.waitForFunction(name => Array.from(document.querySelectorAll('[role=tab]'))
|
||||
.some(el => el.textContent === name && el.getAttribute('aria-selected') === 'true'), name);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// Run Vite on port 1421 before this test.
|
||||
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true });
|
||||
try {
|
||||
for (const scenario of ['desktop', 'both', 'running', 'cli']) {
|
||||
const page = await browser.newPage();
|
||||
await page.addInitScript(scenario => {
|
||||
window.fixtureClientStatusesOverride = {
|
||||
'deepseek-harness': {
|
||||
cliVersion: scenario === 'desktop' ? null : '0.1.9',
|
||||
appVersion: scenario === 'cli' ? null : '0.2.0-rc.2',
|
||||
launchTargets: [
|
||||
...(scenario === 'desktop' ? [] : [{ id: 'cli', label: 'DeepSeek Harness Web', detail: 'dsh web' }]),
|
||||
...(scenario === 'cli' ? [] : [{ id: 'app', label: 'DeepSeek Harness Desktop', detail: 'DeepSeek Harness.exe' }]),
|
||||
],
|
||||
},
|
||||
};
|
||||
}, scenario);
|
||||
await page.goto(`http://localhost:1421/tests/fixtures/agent-backups.html?client=deepseek-harness&reset-selections${scenario === 'running' ? '&running' : ''}`);
|
||||
const controls = page.locator('.agent-run-controls');
|
||||
const app = controls.getByRole('button', { name: '启动 App', exact: true });
|
||||
await app.waitFor();
|
||||
if (scenario === 'cli') {
|
||||
assert.ok(await app.isDisabled());
|
||||
} else {
|
||||
await page.locator('.agent-status-grid').getByText('0.2.0-rc.2', { exact: true }).waitFor();
|
||||
await app.click();
|
||||
await page.waitForFunction(() => window.fixtureCalls.some(call => call.cmd === 'launch_agent'));
|
||||
const calls = await page.evaluate(() => window.fixtureCalls.filter(call => call.cmd === 'launch_agent'));
|
||||
assert.equal(calls[0].args.target, 'app');
|
||||
assert.equal(calls[0].args.deepseekHarnessOptions, null);
|
||||
}
|
||||
if (scenario === 'running') {
|
||||
await controls.getByRole('button', { name: '重启 Web', exact: true }).waitFor();
|
||||
await controls.getByRole('button', { name: '关闭 DeepSeek Harness', exact: true }).click();
|
||||
await controls.getByRole('button', { name: '启动 CLI', exact: true }).waitFor();
|
||||
} else {
|
||||
assert.equal(await controls.getByRole('button', { name: '启动 CLI', exact: true }).isDisabled(), scenario === 'desktop');
|
||||
}
|
||||
assert.equal(await controls.getByRole('button', { name: '重启 App', exact: true }).count(), 0);
|
||||
await page.close();
|
||||
}
|
||||
console.log('DeepSeek Desktop UI: desktop-only, CLI-only, both, and running Web passed');
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
+23
-10
@@ -107,14 +107,6 @@ const fs = require('node:fs');
|
||||
await dialog().getByLabel('Version tag (blank for latest)').fill('../unsafe');
|
||||
assert.equal(await dialog().getByRole('button', { name: 'Install', exact: true }).isDisabled(), true);
|
||||
await dialog().getByLabel('Version tag (blank for latest)').fill('v1.2.3');
|
||||
await dialog().getByRole('button', { name: 'Install', exact: true }).click();
|
||||
assert.equal((await installs()).length, 0, 'First third-party confirmation cannot install');
|
||||
await dialog().getByText(/has not been reviewed by the official project/).waitFor();
|
||||
await dialog().getByRole('button', { name: 'Understood, review source' }).click();
|
||||
const token = dialog().getByLabel(/Type the following/);
|
||||
await token.fill('wrong/source');
|
||||
assert.equal(await dialog().getByRole('button', { name: 'Install', exact: true }).isDisabled(), true);
|
||||
await token.fill('router-for-me/analytics');
|
||||
await page.evaluate(() => { window.pluginsFixture.holdInstall = true; });
|
||||
await dialog().getByRole('button', { name: 'Install', exact: true }).click();
|
||||
await page.waitForFunction(() => window.pluginsFixture.installs.length === 1);
|
||||
@@ -166,7 +158,7 @@ const fs = require('node:fs');
|
||||
await page.getByRole('alert').filter({ hasText: 'Fixture list temporarily unavailable' }).waitFor({ state: 'detached' });
|
||||
|
||||
for (const locale of ['en', 'zh-CN']) {
|
||||
for (const width of [640, 1024]) {
|
||||
for (const width of [375, 640, 1070, 1368]) {
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
await open(`locale=${locale}&theme=${locale === 'en' ? 'light' : 'dark'}`);
|
||||
await page.locator('.plugin-card').first().waitFor();
|
||||
@@ -186,6 +178,27 @@ const fs = require('node:fs');
|
||||
fs.mkdirSync(process.env.PLUGIN_SCREENSHOT_DIR, { recursive: true });
|
||||
await page.screenshot({ path: path.join(process.env.PLUGIN_SCREENSHOT_DIR, `plugins-${locale}-${width}.png`), fullPage: true });
|
||||
}
|
||||
await card('Community analytics').getByRole('button', { name: locale === 'en' ? 'Install' : '安装', exact: true }).click();
|
||||
await dialog().waitFor();
|
||||
// Exercise wrapping and scrolling with metadata longer than a typical registry entry.
|
||||
await dialog().locator('h2').evaluate(el => { el.textContent += ' · model-fallback-router-compatibility'; });
|
||||
await dialog().locator('.plugin-install-origin small').evaluate(el => { el.textContent = 'https://raw.githubusercontent.com/example/plugins-store/main/' + 'long-registry-path-'.repeat(10) + 'registry.json'; });
|
||||
await page.setViewportSize({ width, height: 480 });
|
||||
const layout = await dialog().evaluate(el => {
|
||||
const heading = el.querySelector('.config-dialog-heading').getBoundingClientRect();
|
||||
const body = el.querySelector('.plugin-install-body').getBoundingClientRect();
|
||||
const footer = el.querySelector('.plugin-dialog-actions').getBoundingClientRect();
|
||||
const bounds = el.getBoundingClientRect();
|
||||
return { ordered: heading.bottom <= body.top + 1 && body.bottom <= footer.top + 1,
|
||||
fits: bounds.top >= 0 && bounds.bottom <= innerHeight && el.scrollWidth <= el.clientWidth + 1 };
|
||||
});
|
||||
assert.deepEqual(layout, { ordered: true, fits: true }, 'Dialog sections must not overlap or overflow');
|
||||
await noOverflow();
|
||||
if (process.env.PLUGIN_SCREENSHOT_DIR) {
|
||||
await page.screenshot({ path: path.join(process.env.PLUGIN_SCREENSHOT_DIR, `plugins-install-${locale}-${width}.png`) });
|
||||
}
|
||||
await page.keyboard.press('Escape');
|
||||
await dialog().waitFor({ state: 'detached' });
|
||||
}
|
||||
}
|
||||
await open('unsupported');
|
||||
@@ -193,7 +206,7 @@ const fs = require('node:fs');
|
||||
assert.equal(await page.locator('.plugin-tabs').count(), 0);
|
||||
assert.equal(await page.evaluate(() => window.pluginsFixture.requests.length), 0, 'Unsupported cores do not receive plugin mutations or list requests');
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('Plugin UI passed: configured-only and unloaded states, installed counts, store lookup, configuration removal, resources, global/instance toggle, store filters, source trust gate, versions, retry, settings, uninstall, unsupported cores, responsive English/Chinese.');
|
||||
console.log('Plugin UI passed: configured-only and unloaded states, installed counts, store lookup, configuration removal, resources, global/instance toggle, store filters, single-step installation, versions, retry, settings, uninstall, unsupported cores, responsive English/Chinese.');
|
||||
} finally {
|
||||
await browser?.close();
|
||||
await server.close();
|
||||
|
||||
Reference in new issue
Block a user