Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/settings-provider-card-cleanup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@open-codesign/desktop': patch
---

feat(desktop): simplify provider cards in Settings → API 服务

Each provider card now collapses to a single header row (label, masked key,
optional base URL) with the active badge or a compact "set active" button on
the right. The model selector is hidden on non-active providers (they don't
drive any generation) and shown as a click-to-edit chip on the active card.
Per-row actions move into a `···` overflow menu (Test connection on the
active card, Re-enter key, Delete with inline confirm), and the duplicated
dashed "Add provider" button at the bottom of the list is removed.
267 changes: 193 additions & 74 deletions apps/desktop/src/renderer/src/components/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,15 @@ import {
Cpu,
FolderOpen,
Globe,
KeyRound,
Loader2,
MoreHorizontal,
Palette,
Plus,
RotateCcw,
Sliders,
Trash2,
X,
Zap,
} from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import type { AppPaths, Preferences, ProviderRow } from '../../../preload/index';
Expand Down Expand Up @@ -507,6 +508,127 @@ function AddProviderModal({
);
}

function ProviderOverflowMenu({
isActive,
hasError,
onTestConnection,
onReEnterKey,
onDelete,
label,
}: {
isActive: boolean;
hasError: boolean;
onTestConnection: () => void;
onReEnterKey: () => void;
onDelete: () => void;
label: string;
}) {
const t = useT();
const [open, setOpen] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const wrapRef = useRef<HTMLDivElement | null>(null);

useEffect(() => {
if (!open) return;
function onDocClick(e: MouseEvent) {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
setOpen(false);
setConfirmDelete(false);
}
}
document.addEventListener('mousedown', onDocClick);
return () => document.removeEventListener('mousedown', onDocClick);
}, [open]);

function close() {
setOpen(false);
setConfirmDelete(false);
}

const itemClass =
'w-full flex items-center gap-2 px-2.5 py-1.5 text-left text-[var(--text-xs)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] transition-colors';

return (
<div className="relative" ref={wrapRef}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="p-1.5 rounded-[var(--radius-sm)] text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)] transition-colors"
aria-label={t('settings.providers.moreActions')}
aria-haspopup="menu"
aria-expanded={open}
>
<MoreHorizontal className="w-4 h-4" />
</button>
{open && (
<div
role="menu"
className="absolute right-0 top-full mt-1 z-10 min-w-[10rem] rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-[var(--shadow-elevated)] py-1"
>
{isActive && !hasError && (
<button
type="button"
role="menuitem"
onClick={() => {
close();
onTestConnection();
}}
className={itemClass}
>
<CheckCircle className="w-3.5 h-3.5" />
{t('settings.providers.testConnection')}
</button>
)}
<button
type="button"
role="menuitem"
onClick={() => {
close();
onReEnterKey();
}}
className={itemClass}
>
<KeyRound className="w-3.5 h-3.5" />
{t('settings.providers.reEnterKey')}
</button>
{confirmDelete ? (
<div className="px-2.5 py-1.5 flex items-center gap-1.5">
<button
type="button"
onClick={() => {
close();
onDelete();
}}
className="h-6 px-2 rounded-[var(--radius-sm)] text-[var(--text-xs)] text-[var(--color-on-accent)] bg-[var(--color-error)] hover:opacity-90 transition-opacity"
>
{t('settings.providers.confirm')}
</button>
<button
type="button"
onClick={() => setConfirmDelete(false)}
className="h-6 px-2 rounded-[var(--radius-sm)] text-[var(--text-xs)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] transition-colors"
>
{t('common.cancel')}
</button>
</div>
) : (
<button
type="button"
role="menuitem"
onClick={() => setConfirmDelete(true)}
className={`${itemClass} text-[var(--color-error)] hover:text-[var(--color-error)]`}
aria-label={t('settings.providers.deleteAria', { label })}
>
<Trash2 className="w-3.5 h-3.5" />
{t('settings.providers.delete')}
</button>
)}
</div>
)}
</div>
);
}

function ProviderCard({
row,
config,
Expand All @@ -521,8 +643,8 @@ function ProviderCard({
onReEnterKey: (p: SupportedOnboardingProvider) => void;
}) {
const t = useT();
const pushToast = useCodesignStore((s) => s.pushToast);
const label = SHORTLIST[row.provider]?.label ?? row.provider;
const [confirmDelete, setConfirmDelete] = useState(false);
const hasError = row.error !== undefined;

const stateClass = hasError
Expand All @@ -531,27 +653,43 @@ function ProviderCard({
? 'border-[var(--color-border)] border-l-[var(--size-accent-stripe)] border-l-[var(--color-accent)] bg-[var(--color-accent-tint)]'
: 'border-[var(--color-border)] bg-[var(--color-surface)]';

async function handleTestConnection() {
if (!window.codesign) return;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if (!window.codesign) return; silently drops the action. Project constraints require surfaced errors/no silent fallback. Please toast or throw with context here.

try {
const res = await window.codesign.connection.testActive();
if (res.ok) {
pushToast({ variant: 'success', title: t('settings.providers.toast.connectionOk') });
} else {
pushToast({
variant: 'error',
title: t('settings.providers.toast.connectionFailed'),
description: res.hint || res.message,
});
}
} catch (err) {
pushToast({
variant: 'error',
title: t('settings.providers.toast.connectionFailed'),
description: err instanceof Error ? err.message : t('settings.common.unknownError'),
});
}
}

return (
<div
className={`rounded-[var(--radius-lg)] border px-[var(--space-3)] py-[var(--space-2_5)] transition-colors ${stateClass}`}
>
<div className="flex items-center justify-between gap-[var(--space-3)]">
<div className="min-w-0 flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-[var(--space-3)]">
<div className="min-w-0 flex-1 flex items-center gap-2 flex-wrap">
<span className="text-[var(--text-sm)] font-medium text-[var(--color-text-primary)]">
{label}
</span>
{row.isActive && !hasError && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full border border-[var(--color-accent)] text-[var(--color-accent)] bg-transparent text-[var(--font-size-badge)] font-medium leading-none">
{t('settings.providers.active')}
</span>
)}
{hasError && (
{hasError ? (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-[var(--color-error)] text-[var(--color-on-accent)] text-[var(--font-size-badge)] font-medium leading-none">
<AlertTriangle className="w-2.5 h-2.5" />
{t('settings.providers.decryptionFailed')}
</span>
)}
{!hasError && (
) : (
<code className="text-[var(--text-xs)] text-[var(--color-text-muted)] font-mono">
{row.maskedKey}
</code>
Expand All @@ -564,7 +702,12 @@ function ProviderCard({
)}
</div>

<div className="flex items-center gap-1 shrink-0">
<div className="flex items-center gap-1.5 shrink-0">
{row.isActive && !hasError && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full border border-[var(--color-accent)] text-[var(--color-accent)] bg-transparent text-[var(--font-size-badge)] font-medium leading-none">
{t('settings.providers.active')}
</span>
)}
{!row.isActive && !hasError && (
<button
type="button"
Expand All @@ -583,36 +726,14 @@ function ProviderCard({
{t('settings.providers.reEnterKey')}
</button>
)}
{confirmDelete ? (
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => {
setConfirmDelete(false);
onDelete(row.provider);
}}
className="h-7 px-2 rounded-[var(--radius-sm)] text-[var(--text-xs)] text-[var(--color-on-accent)] bg-[var(--color-error)] hover:opacity-90 transition-opacity"
>
{t('settings.providers.confirm')}
</button>
<button
type="button"
onClick={() => setConfirmDelete(false)}
className="h-7 px-2 rounded-[var(--radius-sm)] text-[var(--text-xs)] text-[var(--color-text-secondary)] border border-[var(--color-border)] hover:bg-[var(--color-surface-hover)] transition-colors"
>
{t('common.cancel')}
</button>
</div>
) : (
<button
type="button"
onClick={() => setConfirmDelete(true)}
className="p-1.5 rounded-[var(--radius-sm)] text-[var(--color-text-muted)] hover:text-[var(--color-error)] hover:bg-[var(--color-surface-hover)] transition-colors"
aria-label={t('settings.providers.deleteAria', { label })}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
<ProviderOverflowMenu
isActive={row.isActive}
hasError={hasError}
onTestConnection={handleTestConnection}
onReEnterKey={() => onReEnterKey(row.provider)}
onDelete={() => onDelete(row.provider)}
label={label}
/>
</div>
</div>

Expand All @@ -637,50 +758,56 @@ function ActiveModelSelector({
const pushToast = useCodesignStore((s) => s.pushToast);

const [primary, setPrimary] = useState(config.modelPrimary ?? sl.defaultPrimary);
const saveTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const [editing, setEditing] = useState(false);

useEffect(() => {
setPrimary(config.modelPrimary ?? sl.defaultPrimary);
}, [config.modelPrimary, sl.defaultPrimary]);

useEffect(() => {
return () => {
if (saveTimeout.current !== null) {
clearTimeout(saveTimeout.current);
saveTimeout.current = null;
}
};
}, []);

async function save(p: string) {
if (!window.codesign) return;
async function save(next: string): Promise<boolean> {
if (!window.codesign) return false;
try {
const next = await window.codesign.settings.setActiveProvider({
const updated = await window.codesign.settings.setActiveProvider({
provider,
modelPrimary: p,
modelPrimary: next,
});
setConfig(next);
setConfig(updated);
return true;
} catch (err) {
pushToast({
variant: 'error',
title: t('settings.providers.toast.modelSaveFailed'),
description: err instanceof Error ? err.message : t('settings.common.unknownError'),
});
return false;
}
}

function handlePrimaryChange(v: string) {
function handleChange(v: string) {
const prev = primary;
setPrimary(v);
if (saveTimeout.current !== null) clearTimeout(saveTimeout.current);
saveTimeout.current = setTimeout(() => void save(v), 400);
setEditing(false);
void save(v).then((ok) => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rollback is race-prone across overlapping saves: a late failure from an earlier change can reset a newer successful selection. Please gate rollback with a request sequence/id (or cancel stale requests).

if (!ok) setPrimary(prev);
});
}

return (
<div className="mt-[var(--space-2_5)] pt-[var(--space-2_5)] border-t border-[var(--color-border-subtle)]">
<p className="flex items-center gap-1 text-[var(--text-xs)] text-[var(--color-text-muted)] mb-1.5">
<Cpu className="w-3 h-3" /> {t('settings.providers.primary')}
</p>
<NativeSelect value={primary} onChange={handlePrimaryChange} options={primaryOptions} />
<div className="mt-[var(--space-2)] flex items-center gap-[var(--space-2)] text-[var(--text-xs)] text-[var(--color-text-muted)]">
<Cpu className="w-3 h-3 shrink-0" />
{editing ? (
<NativeSelect value={primary} onChange={handleChange} options={primaryOptions} />
) : (
<button
type="button"
onClick={() => setEditing(true)}
aria-label={t('settings.providers.editModel')}
className="inline-flex items-center gap-1 h-6 px-2 rounded-[var(--radius-sm)] bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--text-xs)] font-mono text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)] transition-colors"
>
{primary}
<ChevronDown className="w-3 h-3 text-[var(--color-text-muted)]" />
</button>
)}
</div>
);
}
Expand Down Expand Up @@ -820,14 +947,6 @@ function ModelsTab() {
onReEnterKey={setReEnterProvider}
/>
))}
<button
type="button"
onClick={() => setShowAdd(true)}
className="w-full flex items-center justify-center gap-[var(--space-1_5)] h-[var(--size-control-md)] rounded-[var(--radius-lg)] border border-dashed border-[var(--color-border)] bg-transparent text-[var(--text-xs)] text-[var(--color-text-muted)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-secondary)] transition-colors"
>
<Plus className="w-3.5 h-3.5" />
{t('settings.providers.addProvider')}
</button>
</div>
)}
</div>
Expand Down
8 changes: 7 additions & 1 deletion packages/i18n/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,10 @@
"setActive": "Set active",
"reEnterKey": "Re-enter key",
"confirm": "Confirm",
"delete": "Delete",
"testConnection": "Test connection",
"moreActions": "More actions",
"editModel": "Change model",
"deleteAria": "Delete {{label}} provider",
"primary": "Primary",
"fast": "Fast",
Expand All @@ -140,7 +144,9 @@
"switchedTo": "Switched to {{label}}",
"switchFailed": "Switch failed",
"saved": "Provider saved",
"modelSaveFailed": "Failed to save model selection"
"modelSaveFailed": "Failed to save model selection",
"connectionOk": "Connection OK",
"connectionFailed": "Connection failed"
}
},
"appearance": {
Expand Down
Loading
Loading