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:
lzt404 committed 2026-10-04 17:31:52 +08:00
1 parent 3b11361f1b
commit 2649df000c
24 files changed
+587 -180

No files matched your search

+2
View File
@@ -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::*;
+71
View File
@@ -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, &registrations)
}
#[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
}
}
+21 -3
View File
@@ -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 {
+5
View File
@@ -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()))
}
+1 -1
View File
@@ -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(
+1
View File
@@ -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;
+105
View File
@@ -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, &[], &registrations)
.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
View File
@@ -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': '検出中',
+4 -2
View File
@@ -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',
+4 -2
View File
@@ -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': '正在检测',
+64 -5
View File
@@ -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;
}
}
+54 -17
View File
@@ -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}
</>;
}
+3 -3
View File
@@ -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} />}
+2 -2
View File
@@ -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
View File
@@ -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; }
+2 -1
View File
@@ -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>
+6 -12
View File
@@ -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
View File
@@ -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; } }
+8 -5
View File
@@ -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>}
+53 -8
View File
@@ -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]) {
+22 -48
View File
@@ -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');
+8 -1
View File
@@ -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);
+50
View File
@@ -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
View File
@@ -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();