From d2f543ed0c869540353db0c6e7cefcca4c871c9e Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Wed, 15 Jul 2026 06:12:52 +0200 Subject: [PATCH 01/50] feat(gui): tighten providers workspace layout and model data --- design-qa.md | 67 + gui/src/App.tsx | 2 +- gui/src/components/ProviderWorkspace.tsx | 1019 +++++++++++++++ gui/src/pages/Providers.tsx | 31 + gui/src/provider-icons.ts | 3 +- gui/src/provider-workspace-data.ts | 264 ++++ gui/src/styles-provider-workspace.css | 1476 ++++++++++++++++++++++ gui/src/styles.css | 17 + tests/provider-workspace-data.test.ts | 436 +++++++ 9 files changed, 3313 insertions(+), 2 deletions(-) create mode 100644 design-qa.md create mode 100644 gui/src/components/ProviderWorkspace.tsx create mode 100644 gui/src/provider-workspace-data.ts create mode 100644 gui/src/styles-provider-workspace.css create mode 100644 tests/provider-workspace-data.test.ts diff --git a/design-qa.md b/design-qa.md new file mode 100644 index 00000000..277acbae --- /dev/null +++ b/design-qa.md @@ -0,0 +1,67 @@ +# Provider Workspace Design QA + +## Evidence + +- Source visual truth: + - Selected: `C:\Users\JK\AppData\Local\Temp\codex-clipboard-41a0f690-3177-440e-9dd3-a7d7e4b5b2a0.png` + - Overview: `C:\Users\JK\AppData\Local\Temp\codex-clipboard-2e296411-25e3-4f0f-a8ae-ee1bb3e2476c.png` + - Empty: `C:\Users\JK\AppData\Local\Temp\codex-clipboard-44f618c0-b1a9-46a1-bca5-91056a10f68d.png` +- Browser-rendered implementation: + - Selected: `C:\Users\JK\AppData\Local\Temp\opencodex-provider-qa\selected-final.png` + - Overview: `C:\Users\JK\AppData\Local\Temp\opencodex-provider-qa\overview.png` + - Empty: `C:\Users\JK\AppData\Local\Temp\opencodex-provider-qa\empty.png` +- Direct comparison images: + - Selected: `C:\Users\JK\AppData\Local\Temp\opencodex-provider-qa\selected-final-comparison.png` + - Overview: `C:\Users\JK\AppData\Local\Temp\opencodex-provider-qa\overview-comparison.png` + - Empty: `C:\Users\JK\AppData\Local\Temp\opencodex-provider-qa\empty-comparison.png` +- Viewport: 1329 x 856, dark system theme. +- Route: `http://127.0.0.1:5174/#providers`; isolated empty-state preview used `http://127.0.0.1:5175/#providers` with an empty mock config so the real config was not modified. + +## States and Interactions + +- Overview with providers and no selection. +- Selected provider overview, tabs, settings action, overflow menu, enable toggle, and classic-view fallback. +- No-provider empty state with all three entry actions. +- Console errors checked in selected and empty states: none. + +## Fidelity Review + +- Typography: existing product font stack, weights, hierarchy, wrapping, and muted text tokens are retained. The implementation is slightly larger than the reference where required by the existing app shell. +- Spacing and layout: three-column selected layout, grouped provider rail, overview summary, and two-part empty state match the reference composition. The existing global sidebar remains wider than the mock by explicit product constraint. +- Colors and tokens: existing dark theme tokens are used instead of introducing a separate palette. Ready, setup, disabled, borders, and selected states preserve the reference semantics. +- Assets: existing provider icons and icon library assets are used. No emoji, handcrafted SVG, CSS drawing, or placeholder asset replaces a source asset. +- Copy and content: structure follows the reference, while counts, provider names, model totals, usage totals, auth state, and quota timestamps come from real endpoints. Unsupported daily quota and connection-health claims are shown as unavailable rather than fabricated. + +## Full-View Comparison + +- Selected: detail header, tabs, connection card, quick actions, and stats rail are all visible at the target viewport. The wider existing global sidebar reduces detail width compared with the mock, but the selected view remains a two-column desktop layout. +- Overview: provider rail, status counters, quick actions, and real 30-day usage summary align with the reference hierarchy. +- Empty: left setup card and right onboarding choices align with the reference composition without touching the user's real provider config. + +## Focused Comparison + +- Selected-state connection and stats regions were compared separately because the initial implementation compressed the connection card and pushed quick actions below the fold. +- Overview and empty states needed no additional crop after their text, controls, and layout were legible in the full-view comparisons. + +## Comparison History + +1. Initial selected-state comparison found a P1 density mismatch: excessive inner padding and sidebar width collapsed the connection card and pushed quick actions too low. +2. The selected detail header, tab strip, overview grid, connection card, quick actions, and stats rail were tightened without changing data or behavior. +3. Post-fix evidence in `selected-final-comparison.png` shows the connection card and quick actions substantially above the fold while preserving the required global sidebar and provider rail. + +## Findings + +- No actionable P0, P1, or P2 findings remain. +- P3: the selected detail region is narrower than the mock because the existing application sidebar is intentionally preserved. +- P3: displayed values differ from the mock because the implementation uses live provider and usage data and avoids invented health or daily-limit values. + +## Implementation Checklist + +- [x] Match selected, overview, and empty compositions. +- [x] Preserve the existing global sidebar. +- [x] Keep the classic providers view available. +- [x] Wire real provider, model, auth, quota, and usage data. +- [x] Verify core actions, tabs, fallback, empty state, and console. +- [x] Run focused tests, build, lint, and React Doctor. + +final result: passed diff --git a/gui/src/App.tsx b/gui/src/App.tsx index ccb0b7a7..251ef7e1 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -247,7 +247,7 @@ export default function App() {
-
+
{page === "dashboard" && } {page === "providers" && } {page === "models" && } diff --git a/gui/src/components/ProviderWorkspace.tsx b/gui/src/components/ProviderWorkspace.tsx new file mode 100644 index 00000000..01646fd0 --- /dev/null +++ b/gui/src/components/ProviderWorkspace.tsx @@ -0,0 +1,1019 @@ +import { useState, useMemo, useEffect, useCallback, useRef } from "react"; +import { + type WorkspaceProvider, + type WorkspaceItem, + type WorkspaceSections, + buildProviderWorkspace, + binProviderStatus, + buildAttentionItems, + countAvailableModels, + buildMostUsedProviders, + formatRelativeTime, + formatRequestCount, + formatTokenCount, + type AttentionItem, + type ProviderModelCounts, + type ProviderAvailableModels, + type ProviderSelectedModels, + type ProviderUsageTotals, + parseAvailableModels, + parseSelectedModels, +} from "../provider-workspace-data"; +import { providerIconSrc } from "../provider-icons"; +import { + IconSearch, + IconPlus, + IconX, + IconPower, + IconTrash, + IconCheck, + IconAlert, + IconServer, + IconBoxes, + IconRefresh, + IconKey, + IconGlobe, + IconInfo, + IconList, + IconChevron, + IconExternal, + IconActivity, +} from "../icons"; +import { Switch } from "../ui"; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export interface ProviderWorkspaceProps { + /** Provider map as returned from the proxy config API. */ + providers: Record; + /** Base URL for API calls, e.g. http://localhost:11434 */ + apiBase: string; + onAddProvider: () => void; + onUseLegacyView: () => void; + onEditConfig: () => void; + onSetDisabled: (name: string, disabled: boolean) => void; + onRemoveProvider: (name: string) => void; + quotaReports?: Record; + oauthStatus?: Record; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +type Tab = "overview" | "models" | "auth" | "usage" | "settings"; + +const TABS: { id: Tab; label: string }[] = [ + { id: "overview", label: "Overview" }, + { id: "models", label: "Models" }, + { id: "auth", label: "Auth" }, + { id: "usage", label: "Usage & limits" }, + { id: "settings", label: "Settings" }, +]; + +function statusDotCls(p: WorkspaceProvider): string { + const s = binProviderStatus(p); + if (s === "disabled") return "pwi-dot pwi-dot--inactive"; + if (s === "ready") return "pwi-dot pwi-dot--active"; + return "pwi-dot pwi-dot--warning"; +} + +function statusLabel(p: WorkspaceProvider): string { + const s = binProviderStatus(p); + if (s === "disabled") return "Disabled"; + if (s === "ready") return "Ready"; + return "Needs setup"; +} + +function authModeLabel(item: WorkspaceItem): string { + switch (item.authMode) { + case "oauth": return "OAuth"; + case "forward": return "Passthrough"; + case "local": return "Local"; + case "key": return "API key"; + default: return item.authMode ?? (item.keyOptional ? "No key needed" : "API key"); + } +} + +// --------------------------------------------------------------------------- +// Provider icon +// --------------------------------------------------------------------------- + +function ProviderIcon({ name, adapter, baseUrl, cls }: { + name: string; + adapter?: string; + baseUrl?: string; + cls: string; +}) { + const src = providerIconSrc(name, { adapter, baseUrl }); + return ( + + {src + ? + : + ); +} + +// --------------------------------------------------------------------------- +// Rail row +// --------------------------------------------------------------------------- + +function RailRow({ item, selected, modelCount, onClick }: { + item: WorkspaceItem; + selected: boolean; + modelCount?: number; + onClick: () => void; +}) { + return ( + + ); +} + +// --------------------------------------------------------------------------- +// Connection card +// --------------------------------------------------------------------------- + +function ConnectionCard({ item, onEdit, lastCheckedAt }: { + item: WorkspaceItem; + onEdit: () => void; + lastCheckedAt?: number; +}) { + const baseUrl = item.baseUrl?.trim() ? item.baseUrl : "—"; + const status = binProviderStatus(item); + const statusText = status === "ready" ? "Ready" : status === "needs-setup" ? "Needs setup" : "Disabled"; + const configurationText = status === "ready" + ? "Configuration is ready" + : status === "needs-setup" ? "Credentials required" : "Provider disabled"; + const statusCls = status === "ready" + ? "pwi-connection-status pwi-connection-status--ok" + : "pwi-connection-status pwi-connection-status--warn"; + return ( +
+
+ Connection +
+
+
+
+ Status + {statusText} +
+
+ Base URL + {baseUrl} +
+
+ Last checked + {formatRelativeTime(lastCheckedAt)} +
+
+ Authentication + {authModeLabel(item)} +
+
+ Default model + + {item.defaultModel + ? <>{item.defaultModel}{" "}Default + : } + +
+
+
+ {status === "ready" + ?
+
+ + + +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Quick actions (selected provider overview) +// --------------------------------------------------------------------------- + +function QuickActionsCard({ onSelectTab }: { onSelectTab: (tab: Tab) => void }) { + return ( +
+
+ Quick actions +
+ +
+ ); +} + +// --------------------------------------------------------------------------- +// Stats sidebar +// --------------------------------------------------------------------------- + +function StatsSidebar({ item, usageTotals, quotaReport, onViewUsage }: { + item: WorkspaceItem; + usageTotals?: ProviderUsageTotals; + quotaReport?: { updatedAt: number; source?: string }; + onViewUsage: () => void; +}) { + const requests = usageTotals?.requests; + const tokens = usageTotals?.totalTokens; + return ( +
+ +
+
Notes
+ {item.note ? ( +
{item.note}
+ ) : ( +
Add a note about this provider…
+ )} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Tab panels +// --------------------------------------------------------------------------- + +function TabOverview({ item, usageTotals, quotaReport, onSelectTab }: { + item: WorkspaceItem; + usageTotals?: ProviderUsageTotals; + quotaReport?: { updatedAt: number; source?: string }; + onSelectTab: (tab: Tab) => void; +}) { + return ( +
+ {item.note &&

{item.note}

} +
+
+ onSelectTab("settings")} lastCheckedAt={quotaReport?.updatedAt} /> + +
+ onSelectTab("usage")} /> +
+
+ ); +} + +function TabModels({ + item, + modelCount, + availableModels, + selectedModels, +}: { + item: WorkspaceItem; + modelCount: number; + availableModels: string[]; + selectedModels: string[]; +}) { + const selectedSet = new Set(selectedModels); + const models = availableModels.length > 0 + ? availableModels + : item.defaultModel + ? [item.defaultModel] + : []; + + return ( +
+
+ Models + {modelCount > 0 ? ( + {modelCount} available + ) : null} +
+
+ {models.length > 0 ? ( +
+ {models.map(modelId => { + const isDefault = modelId === item.defaultModel; + const isSelected = selectedSet.has(modelId); + return ( +
+ {modelId} + + {isDefault ? Default : null} + {isSelected ? Selected : null} + +
+ ); + })} +
+ ) : ( +
+ + No default model configured. + + Models are resolved at runtime from this provider's endpoint. + + +
+ )} +
+
+ ); +} + +function TabAuth({ item, oauth }: { + item: WorkspaceItem; + oauth?: { loggedIn: boolean; email?: string; error?: string }; +}) { + const hasKey = item.hasApiKey === true; + return ( +
+
+ Authentication +
+
+
+ + Auth mode + How credentials are supplied to this provider. + + {authModeLabel(item)} +
+ {(item.authMode === "key" || (!item.authMode && !item.keyOptional)) && ( +
+ + API key + + {hasKey ? "A key is configured for this provider." : "No key configured. Add one via the classic view."} + + + + {hasKey + ? + : } + +
+ )} + {item.authMode === "oauth" && ( +
+ + OAuth + + {oauth?.error + ? oauth.error + : oauth?.loggedIn ? `Signed in${oauth.email ? ` as ${oauth.email}` : ""}.` : "Not signed in."} + + + {oauth?.loggedIn ? "Ready" : "Needs setup"} +
+ )} + {item.keyOptional && ( +
+ + Key optional + This provider does not require an API key (free tier). + +
+ )} +
+
+ ); +} + +function TabUsage({ item, usageTotals, quotaReport }: { + item: WorkspaceItem; + usageTotals?: ProviderUsageTotals; + quotaReport?: { updatedAt: number; source?: string }; +}) { + return ( +
+
+ Usage & limits +
+
+
+ + Usage totals (30d) + + {usageTotals?.requests === undefined + ? `No usage recorded for ${item.name}.` + : `${formatRequestCount(usageTotals.requests)} requests and ${formatTokenCount(usageTotals.totalTokens)} tokens.`} + + +
+ {quotaReport && ( +
+ + Quota context + + Updated {formatRelativeTime(quotaReport.updatedAt)}{quotaReport.source ? ` from ${quotaReport.source}` : ""}. + + +
+ )} +
+
+ ); +} + +function TabSettings({ item, onSetDisabled, onRemoveProvider }: { + item: WorkspaceItem; + onSetDisabled: (name: string, disabled: boolean) => void; + onRemoveProvider: (name: string) => void; +}) { + return ( + <> +
+
+ Provider state +
+
+
+ + {item.disabled ? "Provider disabled" : "Provider enabled"} + Disabled providers are excluded from model routing. + + + + +
+
+
+
+
+ Danger zone +
+
+
+ + Remove provider + Permanently removes this provider from the proxy config. + + + + +
+
+
+ + ); +} + +// --------------------------------------------------------------------------- +// Detail panel +// --------------------------------------------------------------------------- + +function DetailPanel({ + item, onSetDisabled, onRemoveProvider, onDeselect, onUseLegacyView, usageTotals, + quotaReport, oauth, modelCount, availableModels, selectedModels, +}: { + item: WorkspaceItem; + onSetDisabled: (name: string, disabled: boolean) => void; + onRemoveProvider: (name: string) => void; + onDeselect: () => void; + onUseLegacyView: () => void; + usageTotals?: ProviderUsageTotals; + quotaReport?: { updatedAt: number; source?: string }; + oauth?: { loggedIn: boolean; email?: string; error?: string }; + modelCount: number; + availableModels: string[]; + selectedModels: string[]; +}) { + const [tab, setTab] = useState("overview"); + const [menuOpen, setMenuOpen] = useState(false); + const menuRef = useRef(null); + const isEnabled = !item.disabled; + + useEffect(() => { + if (!menuOpen) return; + const handler = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false); + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [menuOpen]); + + const renderTabPanel = (): React.ReactNode => { + switch (tab) { + case "overview": return ; + case "models": return ( + + ); + case "auth": return ; + case "usage": return ; + case "settings": return ; + default: return null; + } + }; + + return ( +
+
+ +
+
{item.name}
+
+
+
+
+ +
+ + {menuOpen && ( +
+ + + +
+ +
+ )} +
+
+ Enabled + onSetDisabled(item.name, isEnabled)} + label={isEnabled ? `Disable ${item.name}` : `Enable ${item.name}`} + /> +
+
+
+ +
+ {TABS.map(t => ( + + ))} +
+ +
+ {renderTabPanel()} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Empty state +// --------------------------------------------------------------------------- + +function EmptyState({ onAddProvider }: { onAddProvider: () => void }) { + return ( +
+
+
+ +

No providers yet

+

Get started by connecting your first provider.

+ +
+
+
+ +

Connect your first provider

+

+ Use cloud APIs, local models, or a compatible custom endpoint to get started. +

+
+ + + +
+

+ Not sure where to start?{" "} + + View documentation + +

+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Overview panel +// --------------------------------------------------------------------------- + +function OverviewPanel({ + sections, onSelect, onAddProvider, onEditConfig, attentionItems, usageTotals +}: { + sections: WorkspaceSections; + onSelect: (name: string) => void; + onAddProvider: () => void; + onEditConfig: () => void; + attentionItems: AttentionItem[]; + usageTotals: Record; +}) { + const providersByName = new Map( + [...sections.ready, ...sections.needsSetup, ...sections.disabled].map(item => [item.name, item]), + ); + const mostUsed = buildMostUsedProviders(usageTotals) + .filter(entry => providersByName.has(entry.name)) + .slice(0, 3); + return ( +
+
+

Providers overview

+

Manage all your model providers in one place.

+
+
+
+ Ready + {sections.ready.length} +
+
+ Need setup + {sections.needsSetup.length} +
+
+ Disabled + {sections.disabled.length} +
+
+
+
Quick actions
+
+ + + +
+
+
+ {attentionItems.length > 0 && ( +
+
Attention required
+ {attentionItems.map(ai => ( + + ))} +
+ )} +
+
Most used (30d)
+ {mostUsed.length === 0 ? ( +
No usage recorded.
+ ) : mostUsed.map(entry => { + const item = providersByName.get(entry.name)!; + return ( + + );})} +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Root +// --------------------------------------------------------------------------- + +export default function ProviderWorkspace({ + providers, apiBase, onAddProvider, onUseLegacyView, onEditConfig, + onSetDisabled, onRemoveProvider, quotaReports = {}, oauthStatus = {}, +}: ProviderWorkspaceProps) { + const [search, setSearch] = useState(""); + const [selectedName, setSelectedName] = useState(null); + const [modelCounts, setModelCounts] = useState({}); + const [availableModels, setAvailableModels] = useState({}); + const [selectedModels, setSelectedModels] = useState({}); + const [usageTotals, setUsageTotals] = useState>({}); + + const sections = useMemo(() => buildProviderWorkspace(providers), [providers]); + + const filteredSections = useMemo((): WorkspaceSections => { + const q = search.trim().toLowerCase(); + if (!q) return sections; + const filter = (items: WorkspaceItem[]) => + items.filter(p => p.name.toLowerCase().includes(q) || p.adapter.toLowerCase().includes(q)); + return { ready: filter(sections.ready), needsSetup: filter(sections.needsSetup), disabled: filter(sections.disabled) }; + }, [sections, search]); + + const selectedItem = useMemo( + () => selectedName + ? [...sections.ready, ...sections.needsSetup, ...sections.disabled].find(p => p.name === selectedName) ?? null + : null, + [selectedName, sections], + ); + + const attentionItems = useMemo(() => buildAttentionItems(sections, {}), [sections]); + + const fetchModelCounts = useCallback(async () => { + try { + const res = await fetch(`${apiBase}/api/selected-models`); + if (!res.ok) return; + const data = await res.json(); + setModelCounts(countAvailableModels(data)); + setAvailableModels(parseAvailableModels(data)); + setSelectedModels(parseSelectedModels(data)); + } catch { /* network unavailable */ } + }, [apiBase]); + + const fetchUsageTotals = useCallback(async () => { + try { + const res = await fetch(`${apiBase}/api/usage?range=30d`); + if (!res.ok) return; + const data = await res.json() as { + providers?: Array<{ provider: string; requests: number; totalTokens?: number }>; + }; + const byProvider: Record = {}; + for (const p of data.providers ?? []) { + byProvider[p.provider] = { requests: p.requests, totalTokens: p.totalTokens }; + } + setUsageTotals(byProvider); + } catch { /* leave empty */ } + }, [apiBase]); + + useEffect(() => { + const t = window.setTimeout(() => { + void fetchModelCounts(); + void fetchUsageTotals(); + }, 0); + return () => window.clearTimeout(t); + }, [fetchModelCounts, fetchUsageTotals]); + + const handleRemoveProvider = (name: string) => { + onRemoveProvider(name); + if (selectedName === name) setSelectedName(null); + }; + + const total = Object.keys(providers).length; + + if (total === 0) return ; + + return ( +
+ +
+ {selectedItem ? ( + setSelectedName(null)} + onUseLegacyView={onUseLegacyView} + usageTotals={usageTotals[selectedItem.name]} + quotaReport={quotaReports[selectedItem.name]} + oauth={oauthStatus[selectedItem.name]} + modelCount={modelCounts[selectedItem.name] ?? 0} + availableModels={availableModels[selectedItem.name] ?? []} + selectedModels={selectedModels[selectedItem.name] ?? []} + /> + ) : ( + + )} +
+
+ ); +} diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index ecac9818..ffc8564a 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -1,11 +1,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import AddProviderModal from "../components/AddProviderModal"; +import ProviderWorkspace from "../components/ProviderWorkspace"; import { Notice } from "../ui"; import { IconPlus, IconTrash, IconLock, IconExternal, IconPower, IconChevron } from "../icons"; import { useT } from "../i18n"; import type { AccountQuota } from "../codex-quota-utils"; import QuotaBars from "../components/QuotaBars"; import { providerIconSrc } from "../provider-icons"; +import "../styles-provider-workspace.css"; interface Config { port: number; @@ -44,6 +46,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { const [keyPools, setKeyPools] = useState>({}); const [addingKeyFor, setAddingKeyFor] = useState(null); const [newKeyValue, setNewKeyValue] = useState(""); + const [layout, setLayout] = useState<"workspace" | "classic">("workspace"); const aliveRef = useRef(true); const notify = (msg: string, ok: boolean) => { setStatus(msg); setStatusOk(ok); }; @@ -317,6 +320,33 @@ export default function Providers({ apiBase }: { apiBase: string }) { if (!config) return
{t("prov.loadingConfig")}
; + if (layout === "workspace") { + return ( + <> + {status && {status}} + setAdding(true)} + onUseLegacyView={() => setLayout("classic")} + onEditConfig={() => { setLayout("classic"); setEditing(true); }} + onSetDisabled={setProviderDisabled} + onRemoveProvider={removeProvider} + quotaReports={quotaReports} + oauthStatus={oauthStatus} + /> + {adding && ( + setAdding(false)} + onAdded={(name) => { setAdding(false); notify(t("prov.added", { name, cmd: "ocx sync" }), true); fetchConfig(); fetchOauth(); fetchProviderQuotas(true); }} + /> + )} + + ); + } + // API-key providers shown alongside OAuth logins in the account panel. const keyProviders = Object.entries(config.providers) .filter(([name, prov]) => prov.hasApiKey && prov.authMode !== "oauth" && prov.authMode !== "forward" && !oauthProviders.includes(name)) @@ -334,6 +364,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { ) : ( <> + diff --git a/gui/src/provider-icons.ts b/gui/src/provider-icons.ts index 00f9303f..2b4fb341 100644 --- a/gui/src/provider-icons.ts +++ b/gui/src/provider-icons.ts @@ -49,7 +49,8 @@ function providerIconAlias(provider: string): string | undefined { return PROVIDER_ICON_ALIASES[provider.toLowerCase()]; } -export function providerIconSrc(provider: string, hints?: ProviderIconHints): string | undefined { +export function providerIconSrc(provider: string, _hints?: ProviderIconHints): string | undefined { + void _hints; const icon = providerIconAlias(provider); return icon ? `/provider-icons/${icon}` : undefined; } diff --git a/gui/src/provider-workspace-data.ts b/gui/src/provider-workspace-data.ts new file mode 100644 index 00000000..008ae68d --- /dev/null +++ b/gui/src/provider-workspace-data.ts @@ -0,0 +1,264 @@ +/** + * provider-workspace-data.ts + * + * Pure data/view-model helpers for the Providers workspace view. + * No network calls, no model-count inference -- transforms the proxy config + * `providers` map into three stable UI sections: ready, needsSetup, disabled. + * + * Binning rules (applied in priority order): + * 1. disabled === true -> disabled + * 2. keyOptional === true -> ready (free tier; key not required) + * 3. authMode === "oauth" -> ready (credentials managed externally) + * 4. authMode === "forward" -> ready (passes caller credentials through) + * 5. authMode === "local" -> ready (local runtime, no key required) + * 6. loopback base URL -> ready (local runtime, auth mode may be stripped) + * 7. hasApiKey === true -> ready (key-auth with credential present) + * 8. everything else -> needsSetup + */ + +/** + * Shape of a single provider value as it appears in the proxy config map. + * The provider name is the Record key, not a field here. + */ +export interface WorkspaceProvider { + adapter: string; + baseUrl: string; + hasApiKey?: boolean; + hasHeaders?: boolean; + defaultModel?: string; + authMode?: "key" | "forward" | "oauth" | "local" | string; + keyOptional?: boolean; + disabled?: boolean; + note?: string; +} + +/** + * A provider item as surfaced to the workspace view. + * Extends WorkspaceProvider with the name resolved from the Record key. + */ +export interface WorkspaceItem extends WorkspaceProvider { + name: string; +} + +/** The three sections rendered in the Providers workspace. */ +export interface WorkspaceSections { + /** Providers that are enabled and have all credentials needed to route requests. */ + ready: WorkspaceItem[]; + /** Enabled providers that are missing required credentials (e.g. an API key). */ + needsSetup: WorkspaceItem[]; + /** Providers explicitly disabled by the user. */ + disabled: WorkspaceItem[]; +} + +function hasLoopbackBaseUrl(baseUrl: string): boolean { + try { + const hostname = new URL(baseUrl).hostname.replace(/^\[|\]$/g, "").toLowerCase(); + return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; + } catch { + return false; + } +} + +function isConfigurationReady(p: WorkspaceProvider): boolean { + return p.keyOptional === true || + p.authMode === "oauth" || + p.authMode === "forward" || + p.authMode === "local" || + hasLoopbackBaseUrl(p.baseUrl) || + p.hasApiKey === true; +} + +/** + * Transforms the proxy config `providers` map into the three workspace sections. + * Iteration order follows `Object.entries` (insertion order). + */ +export function buildProviderWorkspace( + providers: Record, +): WorkspaceSections { + const ready: WorkspaceItem[] = []; + const needsSetup: WorkspaceItem[] = []; + const disabled: WorkspaceItem[] = []; + + for (const [name, p] of Object.entries(providers)) { + const item: WorkspaceItem = { name, ...p }; + if (p.disabled) { + disabled.push(item); + continue; + } + if (isConfigurationReady(p)) { + ready.push(item); + } else { + needsSetup.push(item); + } + } + + return { ready, needsSetup, disabled }; +} + +// --------------------------------------------------------------------------- +// Legacy aliases -- kept so any existing imports of the v1 names still compile. +// --------------------------------------------------------------------------- + +/** @deprecated Use WorkspaceProvider instead. */ +export interface ProviderRecord extends WorkspaceProvider { + name: string; + hasApiKey: boolean; + hasHeaders: boolean; + keyOptional: boolean; + disabled: boolean; +} + +/** @deprecated Use WorkspaceSections instead. */ +export type ProviderWorkspaceSections = WorkspaceSections; + +/** @deprecated Use buildProviderWorkspace instead. */ +export function buildProviderWorkspaceSections( + providers: ProviderRecord[], +): WorkspaceSections { + const map: Record = {}; + for (const p of providers) { + const { name, ...rest } = p; + map[name] = rest; + } + return buildProviderWorkspace(map); +} + +// --------------------------------------------------------------------------- +// New v2 pure-data helpers for the alternate workspace view +// --------------------------------------------------------------------------- + +/** Canonical status string for a single provider — no network, pure config. */ +export type ProviderStatus = "ready" | "needs-setup" | "disabled"; + +/** + * Returns the canonical status for a single WorkspaceProvider (or WorkspaceItem). + * Applies the same priority rules as buildProviderWorkspace. + */ +export function binProviderStatus(p: WorkspaceProvider): ProviderStatus { + if (p.disabled) return "disabled"; + if (isConfigurationReady(p)) return "ready"; + return "needs-setup"; +} + +/** + * Per-provider model count as returned by /api/selected-models. + * The endpoint shape is { available: Record }. + */ +export type ProviderModelCounts = Record; +export type ProviderAvailableModels = Record; +export type ProviderSelectedModels = Record; + +/** Parse `/api/selected-models` available map into provider -> model id list. */ +export function parseAvailableModels(data: unknown): ProviderAvailableModels { + if (!data || typeof data !== "object") return {}; + const available = (data as { available?: unknown }).available; + if (!available || typeof available !== "object" || Array.isArray(available)) return {}; + + const models: ProviderAvailableModels = {}; + for (const [provider, ids] of Object.entries(available)) { + if (!Array.isArray(ids)) continue; + models[provider] = ids.filter((id): id is string => typeof id === "string"); + } + return models; +} + +/** Parse `/api/selected-models` selected allowlist map into provider -> model id list. */ +export function parseSelectedModels(data: unknown): ProviderSelectedModels { + if (!data || typeof data !== "object") return {}; + const selected = (data as { selected?: unknown }).selected; + if (!selected || typeof selected !== "object" || Array.isArray(selected)) return {}; + + const models: ProviderSelectedModels = {}; + for (const [provider, ids] of Object.entries(selected)) { + if (!Array.isArray(ids)) continue; + models[provider] = ids.filter((id): id is string => typeof id === "string"); + } + return models; +} + +export function countAvailableModels(data: unknown): ProviderModelCounts { + const counts: ProviderModelCounts = {}; + for (const [provider, models] of Object.entries(parseAvailableModels(data))) { + counts[provider] = models.length; + } + return counts; +} + +/** + * Per-provider usage totals derived from /api/usage?range=30d. + * The endpoint shape is { providers: Array<{ provider: string; requests: number; totalTokens: number }> }. + */ +export interface ProviderUsageTotals { + requests?: number; + totalTokens?: number; +} + +export interface MostUsedProvider extends ProviderUsageTotals { + name: string; + requests: number; +} + +export function buildMostUsedProviders( + usageTotals: Record, +): MostUsedProvider[] { + return Object.entries(usageTotals) + .filter((entry): entry is [string, ProviderUsageTotals & { requests: number }] => + typeof entry[1].requests === "number" && entry[1].requests > 0) + .map(([name, totals]) => ({ name, ...totals, requests: totals.requests })) + .sort((a, b) => b.requests - a.requests || a.name.localeCompare(b.name)); +} + +export function formatRelativeTime(updatedAt: number | undefined, now = Date.now()): string { + if (updatedAt === undefined || !Number.isFinite(updatedAt)) return "Not checked"; + const elapsedMs = Math.max(0, now - updatedAt); + const minutes = Math.floor(elapsedMs / 60_000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; +} + +/** An entry in the "Attention required" list shown in the overview panel. */ +export interface AttentionItem { + name: string; + reason: string; +} + +/** + * Derives the list of providers that require user attention: + * - needsSetup providers → "Missing credentials" + * - disabled providers that have an explicit override reason in `overrideReasons` + * + * Ready providers are never included. + */ +export function buildAttentionItems( + sections: WorkspaceSections, + overrideReasons: Record, +): AttentionItem[] { + const items: AttentionItem[] = []; + for (const p of sections.needsSetup) { + items.push({ name: p.name, reason: overrideReasons[p.name] ?? "Missing credentials" }); + } + for (const p of sections.disabled) { + const reason = overrideReasons[p.name]; + if (reason) items.push({ name: p.name, reason }); + } + return items; +} + +/** + * Format a raw request/token count for display. + * Returns "—" when the value is undefined (data unavailable). + */ +export function formatRequestCount(n: number | undefined): string { + if (n === undefined) return "\u2014"; + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; + return String(n); +} + +/** Same as formatRequestCount but aliased for token quantities (same rules). */ +export function formatTokenCount(n: number | undefined): string { + return formatRequestCount(n); +} diff --git a/gui/src/styles-provider-workspace.css b/gui/src/styles-provider-workspace.css new file mode 100644 index 00000000..4acc0fa2 --- /dev/null +++ b/gui/src/styles-provider-workspace.css @@ -0,0 +1,1476 @@ +/* ============================================================================ + providers-workspace — isolated stylesheet + Master/detail desktop layout for the alternative Providers workspace. + Namespace: providers-workspace- + Uses only design tokens from styles.css (:root custom properties). + No gradients. No custom SVG/CSS artwork. Does not touch .sidebar or global. + ============================================================================ */ + +/* ---- root layout: rail | detail panel ---- */ + +.providers-workspace-root { + display: grid; + grid-template-columns: 300px 1fr; + min-height: 100%; + height: 100%; + width: 100%; + overflow: hidden; + align-items: stretch; +} + +/* ---- provider rail (left column) ---- */ + +.providers-workspace-rail { + position: sticky; + top: 0; + height: 100%; + min-height: 0; + display: flex; + flex-direction: column; + overflow-y: auto; + border-right: 1px solid var(--border); + background: var(--rail); +} + +.providers-workspace-rail-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 14px 16px 12px; + border-bottom: 1px solid var(--border-soft); + flex-shrink: 0; +} + +.providers-workspace-rail-title { + font-size: 18px; + font-weight: 600; + color: var(--text); + text-transform: none; + letter-spacing: 0; +} + +.providers-workspace-rail-count { + font-family: var(--mono); + font-size: 11px; + color: var(--faint); +} + +.providers-workspace-rail-list { + flex: 1 1 auto; + display: flex; + flex-direction: column; + gap: 0; + padding: 0; + overflow-y: auto; +} + +.providers-workspace-rail-group + .providers-workspace-rail-group { + margin-top: 10px; +} + +.providers-workspace-rail-group-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 3px 8px 5px; + color: var(--muted); + font-size: 11px; + font-weight: 600; +} + +/* compact provider row in the rail */ +.providers-workspace-rail-row { + display: flex; + align-items: center; + gap: 9px; + padding: 11px 16px; + min-height: 34px; + border-radius: 0; + border: none; + border-bottom: 1px solid var(--border-soft); + background: none; + cursor: pointer; + font: inherit; + font-size: 13px; + color: var(--text); + text-align: left; + transition: background 0.12s, border-color 0.12s, color 0.12s; + min-width: 0; + width: 100%; +} + +.providers-workspace-rail-row:hover { + background: var(--hover); +} + +.providers-workspace-rail-row.providers-workspace-rail-row--selected { + background: var(--accent-soft); + box-shadow: inset 3px 0 0 var(--accent); + color: var(--text); +} + +.providers-workspace-rail-row:focus-visible { + outline: 1px solid var(--accent); + outline-offset: -1px; +} + +.providers-workspace-rail-icon { + width: 26px; + height: 26px; + border-radius: var(--radius-xs); + border: 1px solid var(--border-soft); + background: var(--raised); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--text); +} + +.providers-workspace-rail-icon img { + width: 16px; + height: 16px; + object-fit: contain; + display: block; +} + +.providers-workspace-rail-name { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; + font-size: 13px; +} + +.providers-workspace-rail-status { + flex-shrink: 0; + width: 7px; + height: 7px; + border-radius: 50%; +} + +.providers-workspace-rail-status--active { + background: var(--green); + box-shadow: 0 0 0 3px var(--green-soft); +} + +.providers-workspace-rail-status--inactive { + background: var(--muted); +} + +.providers-workspace-rail-status--warning { + background: var(--amber); + box-shadow: 0 0 0 3px var(--amber-soft); +} + +/* ---- detail panel (right column) ---- */ + +.providers-workspace-detail { + min-width: 0; + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; + overflow: hidden; +} + +/* ---- overview: no provider selected ---- */ + +.providers-workspace-overview { + display: flex; + flex-direction: column; + gap: 20px; + padding: 20px 24px 32px; + max-width: 900px; +} + +.providers-workspace-overview-head { + display: flex; + flex-direction: column; + gap: 4px; +} + +.providers-workspace-overview-title { + font-size: 19px; + font-weight: 600; + color: var(--text); +} + +.providers-workspace-overview-sub { + font-size: 13.5px; + color: var(--muted); + margin: 0; + max-width: 60ch; +} + +/* summary counter row */ +.providers-workspace-summary-row { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; +} + +.providers-workspace-summary-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 6px; + transition: border-color 0.12s; +} + +.providers-workspace-summary-card:hover { + border-color: var(--faint); +} + +.providers-workspace-summary-label { + font-size: 12px; + font-weight: 500; + color: var(--muted); + display: flex; + align-items: center; + gap: 6px; +} + +.providers-workspace-summary-label svg { + width: 14px; + height: 14px; + flex-shrink: 0; +} + +.providers-workspace-summary-value { + font-size: 22px; + font-weight: 600; + color: var(--text); + line-height: 1.1; +} + +/* 3-column provider grid in overview */ +.providers-workspace-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 10px; +} + +.providers-workspace-grid-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + cursor: pointer; + transition: border-color 0.12s, background 0.12s; +} + +.providers-workspace-grid-card:hover { + border-color: var(--accent-ring); + background: var(--hover); +} + +.providers-workspace-grid-card-body { + display: flex; + align-items: center; + gap: 11px; + padding: 14px 14px 10px; +} + +.providers-workspace-grid-icon { + width: 32px; + height: 32px; + border-radius: var(--radius-xs); + border: 1px solid var(--border-soft); + background: var(--raised); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--text); +} + +.providers-workspace-grid-icon img { + width: 20px; + height: 20px; + object-fit: contain; + display: block; +} + +.providers-workspace-grid-info { + min-width: 0; + flex: 1 1 auto; +} + +.providers-workspace-grid-name { + font-size: 13.5px; + font-weight: 600; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.providers-workspace-grid-meta { + font-size: 11.5px; + color: var(--muted); + margin-top: 2px; + font-family: var(--mono); +} + +.providers-workspace-grid-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 14px 12px; + gap: 8px; +} + +/* ---- true empty state: no providers configured ---- */ + +.providers-workspace-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 14px; + padding: 72px 24px; + text-align: center; + flex: 1 1 auto; +} + +.providers-workspace-empty-icon { + width: 48px; + height: 48px; + border-radius: 50%; + background: var(--raised); + border: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: center; + color: var(--faint); +} + +.providers-workspace-empty-icon svg { + width: 22px; + height: 22px; +} + +.providers-workspace-empty-title { + font-size: 15px; + font-weight: 600; + color: var(--text); + margin: 0; +} + +.providers-workspace-empty-sub { + font-size: 13px; + color: var(--muted); + max-width: 36ch; + margin: 0; + line-height: 1.5; +} + +.providers-workspace-empty-actions { + display: flex; + align-items: center; + gap: 8px; + margin-top: 4px; +} + +/* ---- selected provider detail view ---- */ + +.providers-workspace-selected { + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; +} + +.providers-workspace-detail-head { + display: flex; + align-items: center; + gap: 14px; + padding: 16px 22px 12px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; + flex-wrap: wrap; + min-width: 0; +} + +.providers-workspace-detail-icon { + width: 48px; + height: 48px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-soft); + background: var(--raised); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--text); +} + +.providers-workspace-detail-icon img, +.providers-workspace-detail-icon > svg { + width: 30px; + height: 30px; + object-fit: contain; + display: block; +} + +.providers-workspace-detail-title-group { + flex: 1 1 auto; + min-width: 0; + flex-basis: 180px; +} + +.providers-workspace-detail-title { + font-size: 17px; + font-weight: 650; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.providers-workspace-detail-subtitle { + font-size: 12.5px; + color: var(--muted); + margin-top: 2px; +} + +.providers-workspace-detail-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + flex-wrap: wrap; + justify-content: flex-end; + margin-left: auto; +} + +/* tabs strip */ +.providers-workspace-tabs { + display: flex; + align-items: flex-end; + gap: 0; + padding: 0 22px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; + overflow-x: auto; +} + +.providers-workspace-tab { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 9px 12px; + background: none; + border: none; + border-bottom: 2px solid transparent; + font: inherit; + font-size: 13px; + font-weight: 500; + color: var(--muted); + cursor: pointer; + white-space: nowrap; + transition: color 0.12s, border-color 0.12s; + margin-bottom: -1px; +} + +.providers-workspace-tab:hover { + color: var(--text); +} + +.providers-workspace-tab.providers-workspace-tab--active { + color: var(--text); + border-bottom-color: var(--accent); + font-weight: 600; +} + +.providers-workspace-tab svg { + width: 14px; + height: 14px; + flex-shrink: 0; +} + +/* tab content region */ +.providers-workspace-tab-content { + flex: 1 1 auto; + padding: 14px 22px 28px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 16px; + min-height: 0; +} + +/* ---- config section card inside a tab ---- */ + +.providers-workspace-section { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + overflow: hidden; +} + +.providers-workspace-section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 14px; + border-bottom: 1px solid var(--border-soft); +} + +.providers-workspace-section-title { + font-size: 13.5px; + font-weight: 600; + color: var(--text); +} + +.providers-workspace-section-meta { + font-size: 12px; + color: var(--muted); + white-space: nowrap; +} + +.providers-workspace-section-body { + padding: 0; +} + +/* compact table rows inside a section */ +.providers-workspace-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 14px; + border-bottom: 1px solid var(--border-soft); + min-width: 0; +} + +.providers-workspace-row:last-child { + border-bottom: none; +} + +.providers-workspace-row-label { + font-size: 13px; + font-weight: 500; + color: var(--text); + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.providers-workspace-row-label-desc { + font-size: 11.5px; + font-weight: 400; + color: var(--muted); + line-height: 1.4; +} + +.providers-workspace-row-value { + font-size: 13px; + color: var(--muted); + font-family: var(--mono); + text-align: right; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 28ch; +} + +.providers-workspace-row-controls { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +/* ---- model list inside selected provider tab ---- */ + +.providers-workspace-model-list { + display: flex; + flex-direction: column; + gap: 0; +} + +.providers-workspace-model-row { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 16px; + border-bottom: 1px solid var(--border-soft); + transition: background 0.12s; +} + +.providers-workspace-model-row:last-child { + border-bottom: none; +} + +.providers-workspace-model-row:hover { + background: var(--hover); +} + +.providers-workspace-model-id { + font-family: var(--mono); + font-size: 12.5px; + font-weight: 600; + color: var(--text); + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.providers-workspace-model-meta { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +/* ---- responsive: single-column collapse at narrow viewport ---- */ + +@media (max-width: 760px) { + .providers-workspace-root { + grid-template-columns: 1fr; + grid-template-rows: auto 1fr; + height: 100%; + min-height: 0; + } + + .providers-workspace-rail { + position: static; + height: auto; + max-height: 38vh; + border-right: none; + border-bottom: 1px solid var(--border); + } + + .providers-workspace-rail-list { + flex-direction: column; + flex-wrap: nowrap; + overflow-x: hidden; + overflow-y: auto; + gap: 0; + padding: 0; + } + + .providers-workspace-rail-row { + flex-direction: row; + align-items: center; + justify-content: flex-start; + gap: 9px; + padding: 10px 12px; + flex-shrink: 0; + width: 100%; + min-width: 0; + min-height: 44px; + text-align: left; + position: static; + border-radius: 0; + } + + .providers-workspace-rail-name { + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + width: 100%; + text-align: left; + } + + .providers-workspace-rail-status { + position: static; + } + + .providers-workspace-rail-model-count, + .providers-workspace-rail-chevron { + display: none; + } + + .providers-workspace-detail { + min-height: 0; + height: 100%; + overflow: hidden; + } + + .providers-workspace-overview { + padding: 20px 18px 48px; + gap: 20px; + } + + .providers-workspace-summary-row { + grid-template-columns: repeat(2, 1fr); + } + + .providers-workspace-grid { + grid-template-columns: 1fr; + } + + .providers-workspace-detail-head { + padding: 16px 18px 12px; + flex-wrap: wrap; + } + + .providers-workspace-detail-actions { + flex-wrap: wrap; + justify-content: flex-start; + } + + .providers-workspace-tabs { + padding: 0 16px; + } + + .providers-workspace-tab-content { + padding: 20px 18px 48px; + } + + .providers-workspace-section-head { + flex-wrap: wrap; + } + + .providers-workspace-row { + flex-wrap: wrap; + gap: 8px; + } + + .providers-workspace-row-value { + max-width: 100%; + text-align: left; + } + + .providers-workspace-model-id { + font-size: 11.5px; + } +} + +/* ============================================================================ + pwi-* extension classes for the v2 alternate workspace design + Namespace: pwi- + All new classes added below; existing providers-workspace-* classes above + are kept for backward compatibility and modified in-place where needed. + ============================================================================ */ + +/* main panel */ +.providers-workspace-main { + min-width: 0; + display: flex; + flex-direction: column; +} + +/* ---- Status dots (replaces .providers-workspace-rail-status) ---- */ +.pwi-dot { + flex-shrink: 0; + width: 7px; + height: 7px; + border-radius: 50%; + display: inline-block; +} +.pwi-dot--active { + background: var(--green); + box-shadow: 0 0 0 3px var(--green-soft, color-mix(in srgb, var(--green) 20%, transparent)); +} +.pwi-dot--inactive { + background: var(--muted); +} +.pwi-dot--warning { + background: var(--amber); + box-shadow: 0 0 0 3px var(--amber-soft, color-mix(in srgb, var(--amber) 20%, transparent)); +} + +/* ---- Rail group heading with status dot ---- */ +.pwi-group-head { + display: flex; + align-items: center; + gap: 7px; +} + +/* ---- Rail model count chip ---- */ +.providers-workspace-rail-model-count { + font-size: 11px; + color: var(--muted); + white-space: nowrap; + flex-shrink: 0; +} + +/* ---- Rail chevron ---- */ +.providers-workspace-rail-chevron { + width: 14px; + height: 14px; + color: var(--faint); + flex-shrink: 0; + margin-left: auto; +} + +/* ---- Rail search + filter row ---- */ +.pwi-rail-search-row { + display: flex; + align-items: center; + gap: 4px; + padding: 8px 12px 6px; + flex-shrink: 0; +} +.pwi-rail-search-wrap { + position: relative; + flex: 1 1 auto; + min-width: 0; +} +.pwi-rail-search-icon { + width: 12px; + height: 12px; + color: var(--muted); + position: absolute; + left: 10px; + top: 50%; + transform: translateY(-50%); + pointer-events: none; +} +.pwi-rail-search-input { + padding-left: 28px; + font-size: 13px; + height: 32px; + width: 100%; +} +.pwi-rail-filter-btn { + flex-shrink: 0; + padding: 4px 6px; +} +.pwi-rail-add-btn { + border: 1px solid var(--border); + font-size: 12px; +} + +/* ---- Detail header: status badge row ---- */ +.providers-workspace-detail-status-row { + display: flex; + align-items: center; + gap: 7px; + margin-top: 3px; +} +.providers-workspace-detail-status-label { + font-size: 13px; + color: var(--muted); + font-weight: 500; +} + +/* ---- Kebab menu ---- */ +.pwi-kebab-wrap { + position: relative; +} +.pwi-kebab-btn { + font-size: 16px; + letter-spacing: 1px; + padding: 2px 8px; + line-height: 1; +} +.pwi-kebab-menu { + position: absolute; + top: calc(100% + 6px); + right: 0; + min-width: 170px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: 0 4px 16px rgba(0,0,0,0.18); + z-index: 200; + overflow: hidden; + display: flex; + flex-direction: column; +} +.pwi-kebab-item { + display: flex; + align-items: center; + gap: 9px; + padding: 9px 14px; + background: none; + border: none; + font: inherit; + font-size: 13px; + color: var(--text); + cursor: pointer; + text-align: left; + transition: background 0.1s; + width: 100%; +} +.pwi-kebab-item:hover { + background: var(--hover); +} +.pwi-kebab-item--danger { + color: var(--red); +} +.pwi-kebab-divider { + height: 1px; + background: var(--border-soft); + margin: 3px 0; +} + +/* ---- Enabled toggle beside kebab ---- */ +.pwi-enabled-toggle { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + margin-left: 4px; +} +.pwi-enabled-label { + font-size: 13px; + color: var(--muted); + font-weight: 500; + white-space: nowrap; +} + +/* ---- Overview layout: two-column main+sidebar ---- */ +.pwi-overview-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(200px, 224px); + gap: 16px; + padding: 0; + min-height: 100%; + align-items: start; +} +.pwi-overview-tab { + display: flex; + flex-direction: column; + gap: 14px; +} +.pwi-provider-summary { + margin: 0; + color: var(--muted); + font-size: 13px; + line-height: 1.5; + max-width: 76ch; +} +.pwi-overview-main { + display: flex; + flex-direction: column; + gap: 14px; + min-width: 0; +} + +/* ---- Connection card ---- */ +.pwi-connection-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0; +} +.pwi-connection-cell { + display: flex; + flex-direction: column; + gap: 3px; + padding: 10px 14px; + min-height: 56px; + border-bottom: 1px solid var(--border-soft); + border-right: 1px solid var(--border-soft); +} +.pwi-connection-cell:nth-child(3n) { + border-right: none; +} +.pwi-connection-cell:nth-last-child(-n+2) { + border-bottom: none; +} +.pwi-connection-cell:last-child { + border-right: none; +} +.pwi-cell-label { + font-size: 11.5px; + color: var(--muted); + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.pwi-cell-value { + font-size: 13.5px; + color: var(--text); + font-weight: 500; +} +.pwi-cell-value-mono { + font-size: 12.5px; + color: var(--text); + font-family: var(--mono); + word-break: break-all; +} +.pwi-connection-status { + font-size: 13.5px; + font-weight: 600; +} +.pwi-connection-status--ok { + color: var(--green); +} +.pwi-connection-status--warn { + color: var(--amber); +} +.pwi-connection-operational { + display: flex; + align-items: center; + gap: 7px; + padding: 9px 14px; + color: var(--green); + font-size: 13px; + font-weight: 500; + border-top: 1px solid var(--border-soft); +} +.pwi-connection-operational--warn { + color: var(--amber); +} +.pwi-connection-actions { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + border-top: 1px solid var(--border-soft); + flex-wrap: wrap; +} + +/* ---- Quick actions (selected provider) ---- */ +.pwi-qa-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; + padding: 12px; +} +.pwi-qa-tile { + display: flex; + flex-direction: column; + gap: 4px; + padding: 12px 13px; + background: var(--raised); + border: 1px solid var(--border-soft); + border-radius: 6px; + font: inherit; + color: var(--text); + text-decoration: none; + cursor: pointer; + text-align: left; + transition: background 0.12s; + align-items: flex-start; +} +.pwi-qa-tile:hover { + background: var(--hover); + border-color: var(--border); +} +.pwi-qa-label { + font-size: 13px; + font-weight: 600; + color: var(--text); +} +.pwi-qa-desc { + font-size: 11.5px; + color: var(--muted); + line-height: 1.4; +} + +/* ---- Stats sidebar ---- */ +.pwi-stats-column { + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; + align-self: start; + position: sticky; + top: 14px; +} +.pwi-stats-sidebar, +.pwi-stats-notes { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + padding: 14px; + min-width: 0; +} +.pwi-stats-sidebar { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 13px; +} +.pwi-stats-notes { + display: flex; + flex-direction: column; + gap: 10px; +} +.pwi-stats-head { + font-size: 13.5px; + font-weight: 600; + color: var(--text); + margin-bottom: 4px; +} +.pwi-stats-row-label { + font-size: 11.5px; + color: var(--muted); + font-weight: 500; +} +.pwi-stats-value { + font-size: 20px; + font-weight: 700; + color: var(--text); + line-height: 1.1; +} +.pwi-stats-unavailable { + font-size: 12px; +} +.pwi-stats-divider { + height: 1px; + background: var(--border-soft); + margin: 2px 0; +} +.pwi-stats-line { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.pwi-stats-line-label { + font-size: 12.5px; + color: var(--muted); +} +.pwi-stats-line-value { + font-size: 12.5px; + font-weight: 600; + color: var(--text); + font-family: var(--mono); +} +.pwi-stats-usage-link { + justify-content: space-between; + padding: 4px 2px; + width: 100%; + font-size: 12.5px; + color: var(--accent); + gap: 6px; +} +.pwi-stats-notes-head { + font-size: 12px; + font-weight: 600; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} +.pwi-stats-notes-body { + font-size: 12.5px; + color: var(--text); + line-height: 1.5; +} +.pwi-stats-notes-placeholder { + font-size: 12.5px; + font-style: italic; +} + +/* ---- Overview panel ---- */ +.pwi-overview-section { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + overflow: hidden; +} +.pwi-overview-section-head { + font-size: 13px; + font-weight: 600; + color: var(--text); + padding: 12px 14px; + border-bottom: 1px solid var(--border-soft); +} + +/* ---- Overview counter cards ---- */ +.pwi-summary-ready .providers-workspace-summary-value { color: var(--green); } +.pwi-summary-setup .providers-workspace-summary-value { color: var(--amber); } + +/* ---- Overview quick actions ---- */ +.pwi-oa-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; +} +.pwi-oa-tile { + display: flex; + flex-direction: column; + gap: 5px; + padding: 14px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + font: inherit; + cursor: pointer; + text-align: left; + transition: background 0.12s; + align-items: flex-start; + color: var(--text); +} +.pwi-oa-tile:hover { background: var(--hover); border-color: var(--accent-ring); } +.pwi-overview-quick-actions { + border: none; + background: transparent; + overflow: visible; +} +.pwi-overview-quick-actions .pwi-overview-section-head { + padding: 0 0 10px; + border-bottom: none; +} +.pwi-oa-label { + font-size: 13px; + font-weight: 600; +} +.pwi-oa-desc { + font-size: 12px; + color: var(--muted); + line-height: 1.4; +} + +/* ---- Overview two-column (attention + recently used) ---- */ +.pwi-overview-two-col { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +/* ---- Attention required ---- */ +.pwi-attention-row { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + background: none; + border: none; + border-bottom: 1px solid var(--border-soft); + font: inherit; + font-size: 13px; + color: var(--text); + cursor: pointer; + text-align: left; + transition: background 0.12s; + width: 100%; +} +.pwi-attention-row:last-child { border-bottom: none; } +.pwi-attention-row:hover { background: var(--hover); } +.pwi-attention-name { + font-weight: 600; + flex-shrink: 0; +} +.pwi-attention-reason { + flex: 1 1 auto; + font-size: 12.5px; +} + +/* ---- Recently used ---- */ +.pwi-recent-row { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 14px; + background: none; + border: none; + border-bottom: 1px solid var(--border-soft); + font: inherit; + font-size: 13px; + color: var(--text); + cursor: pointer; + transition: background 0.12s; + width: 100%; + text-align: left; +} +.pwi-recent-row:hover { background: var(--hover); } +.pwi-recent-name { + flex: 1 1 auto; + font-weight: 500; + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.pwi-recent-empty { + padding: 12px 14px; + font-size: 12.5px; +} +.pwi-recent-all { + width: 100%; + justify-content: space-between; + padding: 9px 14px; + font-size: 12.5px; + color: var(--muted); +} + +/* ---- Empty state (no providers) ---- */ +.providers-workspace-empty-root { + display: grid; + grid-template-columns: 320px 1fr; + min-height: 100%; + height: 100%; + align-items: stretch; +} +.pwi-empty-left { + display: flex; + align-items: center; + justify-content: center; + padding: 0 24px; + height: 100%; + border-right: 1px solid var(--border); +} +.pwi-empty-card { + border: 1px dashed var(--border); + border-radius: 8px; + padding: 28px 20px; + display: flex; + flex-direction: column; + align-items: center; + gap: 14px; + text-align: center; + max-width: 180px; + width: 100%; +} +.pwi-empty-card-icon { + width: 44px; + height: 44px; + border-radius: 50%; + background: var(--raised); + border: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: center; + color: var(--muted); +} +.pwi-empty-card-title { + font-size: 15px; + font-weight: 600; + margin: 0; +} +.pwi-empty-card-sub { + font-size: 13px; + color: var(--muted); + margin: 0; + max-width: 26ch; +} +.pwi-empty-right { + display: flex; + flex-direction: column; + gap: 20px; + padding: 48px; + align-items: center; + justify-content: center; + text-align: center; +} +.pwi-empty-right-icon { + color: var(--faint); +} +.pwi-empty-right-title { + font-size: 20px; + font-weight: 650; + color: var(--text); + margin: 0; +} +.pwi-empty-right-sub { + font-size: 13.5px; + color: var(--muted); + margin: 0; + max-width: 44ch; + line-height: 1.5; +} +.pwi-empty-tiles { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; + width: min(100%, 620px); +} +.pwi-empty-tile { + display: flex; + flex-direction: column; + gap: 7px; + padding: 16px 14px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + font: inherit; + cursor: pointer; + text-align: left; + transition: border-color 0.12s, background 0.12s; + align-items: center; + text-align: center; + color: var(--text); +} +.pwi-empty-tile:hover { + border-color: var(--accent-ring); + background: var(--hover); +} +.pwi-empty-tile-label { + font-size: 13px; + font-weight: 600; +} +.pwi-empty-tile-desc { + font-size: 11.5px; + color: var(--muted); + line-height: 1.4; +} +.pwi-empty-doc-link { + font-size: 12.5px; + margin: 0; +} + +/* ---- Responsive updates ---- */ +@media (max-width: 960px) { + .providers-workspace-root { + grid-template-columns: 240px 1fr; + } + .pwi-overview-layout { + grid-template-columns: 1fr; + gap: 14px; + } + .pwi-stats-sidebar { + position: static; + } + .pwi-stats-column { + position: static; + } + .pwi-overview-two-col { + grid-template-columns: 1fr; + } + .providers-workspace-empty-root { + grid-template-columns: 1fr; + grid-template-rows: auto 1fr; + min-height: auto; + } + .pwi-empty-left { + border-right: none; + border-bottom: 1px solid var(--border); + padding: 32px 24px; + } + .pwi-empty-right { + padding: 32px 24px; + } + .pwi-empty-tiles { + grid-template-columns: 1fr; + } + .pwi-connection-grid { + grid-template-columns: 1fr; + } + .pwi-connection-cell { + border-right: none; + } + .pwi-connection-cell:nth-last-child(-n+2) { + border-bottom: 1px solid var(--border-soft); + } + .pwi-connection-cell:last-child { + border-bottom: none; + } + .pwi-qa-grid { + grid-template-columns: 1fr; + } + .pwi-qa-tile { + border-right: none; + border-bottom: 1px solid var(--border-soft); + } + .pwi-qa-tile:last-child { + border-bottom: none; + } + .pwi-oa-grid { + grid-template-columns: 1fr; + } + .pwi-oa-tile { + border-right: none; + border-bottom: 1px solid var(--border-soft); + } + .pwi-oa-tile:last-child { border-bottom: none; } +} diff --git a/gui/src/styles.css b/gui/src/styles.css index 53c523df..143ed707 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -202,6 +202,22 @@ input[type="checkbox"], input[type="radio"] { accent-color: var(--accent); } .main { min-width: 0; } .main-inner { max-width: 980px; margin: 0 auto; padding: 32px 36px 64px; } +.main-inner.main-inner--providers { + max-width: none; + margin: 0; + padding: 0; + min-height: 100dvh; + height: 100dvh; + overflow: hidden; + display: flex; + flex-direction: column; +} +.main-inner.main-inner--providers > .providers-workspace-root, +.main-inner.main-inner--providers > .providers-workspace-empty-root { + flex: 1 1 auto; + min-height: 0; + height: 100%; +} /* ---- page header ---- */ .page-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 6px; } @@ -687,6 +703,7 @@ select.input { appearance: none; } } .drawer-scrim { display: block; } .main-inner { padding: 22px 18px 48px; } + .main-inner.main-inner--providers { padding: 0; min-height: 100dvh; height: 100dvh; overflow: hidden; } /* settings rows: copy takes the full width, controls drop underneath */ .setting-row { flex-wrap: wrap; } .setting-row .setting-copy { flex: 1 1 100% !important; } diff --git a/tests/provider-workspace-data.test.ts b/tests/provider-workspace-data.test.ts new file mode 100644 index 00000000..c5cce581 --- /dev/null +++ b/tests/provider-workspace-data.test.ts @@ -0,0 +1,436 @@ +import { describe, expect, test } from "bun:test"; +import * as workspaceData from "../gui/src/provider-workspace-data"; +import { + buildProviderWorkspace, + type WorkspaceProvider, + type WorkspaceSections, +} from "../gui/src/provider-workspace-data"; +import { + binProviderStatus, + formatRequestCount, + formatTokenCount, + buildAttentionItems, + type ProviderModelCounts, + type ProviderUsageTotals, + type AttentionItem, +} from "../gui/src/provider-workspace-data"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Base defaults matching a minimal, unconfigured provider value. */ +function prov(overrides: Partial = {}): WorkspaceProvider { + return { + adapter: "openai-chat", + baseUrl: "https://api.example.com/v1", + hasApiKey: false, + hasHeaders: false, + defaultModel: undefined, + authMode: undefined, + keyOptional: false, + disabled: false, + note: undefined, + ...overrides, + }; +} + +/** Thin wrapper to build a single-entry Record. */ +function single(name: string, overrides: Partial = {}): Record { + return { [name]: prov(overrides) }; +} + +// --------------------------------------------------------------------------- +// Section membership +// --------------------------------------------------------------------------- + +describe("buildProviderWorkspace", () => { + test("disabled provider always goes into the disabled section", () => { + const sections = buildProviderWorkspace({ + "disabled-keyed": prov({ authMode: "key", hasApiKey: true, disabled: true }), + "disabled-oauth": prov({ authMode: "oauth", disabled: true }), + "disabled-forward": prov({ authMode: "forward", disabled: true }), + }); + expect(sections.disabled.map(p => p.name)).toEqual([ + "disabled-keyed", + "disabled-oauth", + "disabled-forward", + ]); + expect(sections.ready).toHaveLength(0); + expect(sections.needsSetup).toHaveLength(0); + }); + + test("keyOptional provider without an API key is ready (free tier)", () => { + const sections = buildProviderWorkspace(single("opencode-go", { keyOptional: true, hasApiKey: false })); + expect(sections.ready.map(p => p.name)).toContain("opencode-go"); + expect(sections.needsSetup).toHaveLength(0); + }); + + test("keyOptional provider with an API key is also ready", () => { + const sections = buildProviderWorkspace(single("opencode-pro", { keyOptional: true, hasApiKey: true })); + expect(sections.ready.map(p => p.name)).toContain("opencode-pro"); + }); + + test("OAuth provider with no key required is ready", () => { + const sections = buildProviderWorkspace(single("xai", { authMode: "oauth" })); + expect(sections.ready.map(p => p.name)).toContain("xai"); + expect(sections.needsSetup).toHaveLength(0); + }); + + test("forward/passthrough provider is ready without credentials", () => { + const sections = buildProviderWorkspace(single("cursor-proxy", { authMode: "forward" })); + expect(sections.ready.map(p => p.name)).toContain("cursor-proxy"); + }); + + test("key-auth provider WITH a key is ready", () => { + const sections = buildProviderWorkspace(single("openai", { authMode: "key", hasApiKey: true })); + expect(sections.ready.map(p => p.name)).toContain("openai"); + expect(sections.needsSetup).toHaveLength(0); + }); + + test("key-auth provider WITHOUT a key goes to needsSetup", () => { + const sections = buildProviderWorkspace(single("anthropic", { authMode: "key", hasApiKey: false })); + expect(sections.needsSetup.map(p => p.name)).toContain("anthropic"); + expect(sections.ready).toHaveLength(0); + }); + + test("plain enabled provider (no authMode) without a key goes to needsSetup", () => { + const sections = buildProviderWorkspace(single("custom-no-auth", { hasApiKey: false })); + expect(sections.needsSetup.map(p => p.name)).toContain("custom-no-auth"); + }); + + test("plain enabled provider (no authMode) with a key is ready", () => { + const sections = buildProviderWorkspace(single("custom-keyed", { hasApiKey: true })); + expect(sections.ready.map(p => p.name)).toContain("custom-keyed"); + }); + + test("local auth provider is ready without credentials", () => { + const sections = buildProviderWorkspace(single("ollama", { + authMode: "local", + baseUrl: "http://ollama:11434/v1", + })); + expect(sections.ready.map(p => p.name)).toEqual(["ollama"]); + expect(sections.needsSetup).toHaveLength(0); + }); + + test("loopback providers remain ready when authMode is absent", () => { + const sections = buildProviderWorkspace({ + localhost: prov({ baseUrl: "http://localhost:11434/v1" }), + ipv4: prov({ baseUrl: "http://127.0.0.1:1234/v1" }), + ipv6: prov({ baseUrl: "http://[::1]:8080/v1" }), + }); + expect(sections.ready.map(p => p.name)).toEqual(["localhost", "ipv4", "ipv6"]); + expect(sections.needsSetup).toHaveLength(0); + }); + + // --------------------------------------------------------------------------- + // Name injection + // --------------------------------------------------------------------------- + + test("injects name from the Record key into each output item", () => { + const sections = buildProviderWorkspace({ + "my-provider": prov({ authMode: "key", hasApiKey: true }), + }); + expect(sections.ready[0]!.name).toBe("my-provider"); + }); + + // --------------------------------------------------------------------------- + // Metadata preservation + // --------------------------------------------------------------------------- + + test("preserves adapter, baseUrl, defaultModel, authMode on output items", () => { + const sections = buildProviderWorkspace({ + "my-provider": prov({ + adapter: "openai-responses", + baseUrl: "https://api.openai.com/v1", + defaultModel: "gpt-4o", + authMode: "key", + hasApiKey: true, + }), + }); + const item = sections.ready[0]!; + expect(item.adapter).toBe("openai-responses"); + expect(item.baseUrl).toBe("https://api.openai.com/v1"); + expect(item.defaultModel).toBe("gpt-4o"); + expect(item.authMode).toBe("key"); + }); + + test("preserves keyOptional and note on output items", () => { + const sections = buildProviderWorkspace( + single("free-prov", { keyOptional: true, note: "Free tier, no key needed." }), + ); + const item = sections.ready[0]!; + expect(item.keyOptional).toBe(true); + expect(item.note).toBe("Free tier, no key needed."); + }); + + // --------------------------------------------------------------------------- + // Mixed scenario + // --------------------------------------------------------------------------- + + test("correctly bins a mixed set of providers", () => { + const sections = buildProviderWorkspace({ + "openai": prov({ authMode: "key", hasApiKey: true }), + "anthropic": prov({ authMode: "key", hasApiKey: false }), + "xai-oauth": prov({ authMode: "oauth" }), + "cursor-fwd": prov({ authMode: "forward" }), + "free-svc": prov({ keyOptional: true }), + "legacy-off": prov({ hasApiKey: true, disabled: true }), + }); + expect(sections.ready.map(p => p.name).sort()).toEqual( + ["openai", "xai-oauth", "cursor-fwd", "free-svc"].sort(), + ); + expect(sections.needsSetup.map(p => p.name)).toEqual(["anthropic"]); + expect(sections.disabled.map(p => p.name)).toEqual(["legacy-off"]); + }); + + // --------------------------------------------------------------------------- + // Edge cases + // --------------------------------------------------------------------------- + + test("empty input produces empty sections", () => { + const sections = buildProviderWorkspace({}); + expect(sections.ready).toHaveLength(0); + expect(sections.needsSetup).toHaveLength(0); + expect(sections.disabled).toHaveLength(0); + }); + + test("returns a stable WorkspaceSections shape", () => { + const sections: WorkspaceSections = buildProviderWorkspace({}); + expect(sections).toHaveProperty("ready"); + expect(sections).toHaveProperty("needsSetup"); + expect(sections).toHaveProperty("disabled"); + }); +}); + +// --------------------------------------------------------------------------- +// binProviderStatus — pure status derivation (no network) +// --------------------------------------------------------------------------- + +describe("binProviderStatus", () => { + test("disabled provider returns 'disabled'", () => { + expect(binProviderStatus(prov({ disabled: true, hasApiKey: true }))).toBe("disabled"); + }); + + test("keyOptional provider returns 'ready'", () => { + expect(binProviderStatus(prov({ keyOptional: true }))).toBe("ready"); + }); + + test("oauth provider returns 'ready'", () => { + expect(binProviderStatus(prov({ authMode: "oauth" }))).toBe("ready"); + }); + + test("forward provider returns 'ready'", () => { + expect(binProviderStatus(prov({ authMode: "forward" }))).toBe("ready"); + }); + + test("key-auth with key returns 'ready'", () => { + expect(binProviderStatus(prov({ authMode: "key", hasApiKey: true }))).toBe("ready"); + }); + + test("key-auth without key returns 'needs-setup'", () => { + expect(binProviderStatus(prov({ authMode: "key", hasApiKey: false }))).toBe("needs-setup"); + }); + + test("no authMode, no key returns 'needs-setup'", () => { + expect(binProviderStatus(prov({ hasApiKey: false }))).toBe("needs-setup"); + }); + + test("local auth mode returns 'ready'", () => { + expect(binProviderStatus(prov({ authMode: "local" }))).toBe("ready"); + }); + + test("loopback base URLs return 'ready' without authMode", () => { + expect(binProviderStatus(prov({ baseUrl: "http://localhost:11434/v1" }))).toBe("ready"); + expect(binProviderStatus(prov({ baseUrl: "http://127.0.0.1:1234/v1" }))).toBe("ready"); + expect(binProviderStatus(prov({ baseUrl: "http://[::1]:8080/v1" }))).toBe("ready"); + }); +}); + +describe("countAvailableModels", () => { + test("counts each provider array from the selected-models available map", () => { + expect(workspaceData.countAvailableModels({ + available: { + openai: ["gpt-4o", "gpt-4.1"], + ollama: [{ id: "llama3" }], + empty: [], + }, + selected: { openai: ["gpt-4o"] }, + })).toEqual({ openai: 2, ollama: 0, empty: 0 }); + }); + + test("returns no counts for unsupported endpoint shapes", () => { + expect(workspaceData.countAvailableModels({ models: [{ provider: "openai" }] })).toEqual({}); + expect(workspaceData.countAvailableModels(null)).toEqual({}); + }); +}); + +describe("parseAvailableModels", () => { + test("returns string model ids per provider", () => { + expect(workspaceData.parseAvailableModels({ + available: { + openai: ["gpt-4o", "gpt-4.1"], + ollama: [{ id: "llama3" }], + mixed: ["valid", 42, null], + }, + })).toEqual({ + openai: ["gpt-4o", "gpt-4.1"], + ollama: [], + mixed: ["valid"], + }); + }); + + test("returns empty map for invalid payloads", () => { + expect(workspaceData.parseAvailableModels(null)).toEqual({}); + expect(workspaceData.parseAvailableModels({ selected: {} })).toEqual({}); + }); +}); + +describe("parseSelectedModels", () => { + test("returns string model ids from selected allowlist", () => { + expect(workspaceData.parseSelectedModels({ + selected: { + openai: ["gpt-4o"], + anthropic: ["claude-3", 99], + }, + })).toEqual({ + openai: ["gpt-4o"], + anthropic: ["claude-3"], + }); + }); +}); + +describe("buildMostUsedProviders", () => { + test("sorts real 30-day usage totals by requests descending", () => { + expect(workspaceData.buildMostUsedProviders({ + anthropic: { requests: 12, totalTokens: 900 }, + openai: { requests: 40, totalTokens: 4_000 }, + ollama: { requests: 20, totalTokens: 2_000 }, + }).map(item => item.name)).toEqual(["openai", "ollama", "anthropic"]); + }); + + test("omits providers without recorded requests", () => { + expect(workspaceData.buildMostUsedProviders({ + unavailable: {}, + zero: { requests: 0, totalTokens: 0 }, + })).toEqual([]); + }); +}); + +describe("formatRelativeTime", () => { + test("shows Not checked when no real update time exists", () => { + expect(workspaceData.formatRelativeTime(undefined, 10_000)).toBe("Not checked"); + }); + + test("formats a real update time relative to now", () => { + expect(workspaceData.formatRelativeTime(880_000, 1_000_000)).toBe("2m ago"); + }); +}); + +// --------------------------------------------------------------------------- +// formatRequestCount — display formatting (pure) +// --------------------------------------------------------------------------- + +describe("formatRequestCount", () => { + test("undefined returns unavailable marker", () => { + expect(formatRequestCount(undefined)).toBe("—"); + }); + + test("small numbers render as-is", () => { + expect(formatRequestCount(0)).toBe("0"); + expect(formatRequestCount(999)).toBe("999"); + }); + + test("thousands render with k suffix", () => { + expect(formatRequestCount(1000)).toBe("1.0k"); + expect(formatRequestCount(12548)).toBe("12.5k"); + }); + + test("millions render with M suffix", () => { + expect(formatRequestCount(1_000_000)).toBe("1.0M"); + }); +}); + +// --------------------------------------------------------------------------- +// formatTokenCount — token display formatting (pure) +// --------------------------------------------------------------------------- + +describe("formatTokenCount", () => { + test("undefined returns unavailable marker", () => { + expect(formatTokenCount(undefined)).toBe("—"); + }); + + test("sub-thousand renders as integer", () => { + expect(formatTokenCount(500)).toBe("500"); + }); + + test("284600 renders as 284.6k", () => { + expect(formatTokenCount(284_600)).toBe("284.6k"); + }); + + test("millions render with M suffix (one decimal)", () => { + expect(formatTokenCount(1_500_000)).toBe("1.5M"); + }); +}); + +// --------------------------------------------------------------------------- +// buildAttentionItems — derive attention-required list from sections +// --------------------------------------------------------------------------- + +describe("buildAttentionItems", () => { + function makeItem(name: string, overrides: Partial = {}): import("../gui/src/provider-workspace-data").WorkspaceItem { + return { name, adapter: "openai-chat", baseUrl: "https://x", ...overrides }; + } + + test("empty sections produce no attention items", () => { + const items: AttentionItem[] = buildAttentionItems( + { ready: [], needsSetup: [], disabled: [] }, + {}, + ); + expect(items).toHaveLength(0); + }); + + test("needsSetup provider without a reason produces a 'Missing credentials' item", () => { + const sections = { + ready: [], + needsSetup: [makeItem("aws-bedrock", { authMode: "key", hasApiKey: false })], + disabled: [], + }; + const items = buildAttentionItems(sections, {}); + expect(items).toHaveLength(1); + expect(items[0]!.name).toBe("aws-bedrock"); + expect(items[0]!.reason).toContain("credential"); + }); + + test("disabled provider with no key produces a 'Connection test failed' item when overrideReason given", () => { + const sections = { + ready: [], + needsSetup: [], + disabled: [makeItem("replicate", { disabled: true })], + }; + const items = buildAttentionItems(sections, { replicate: "Connection test failed" }); + expect(items).toHaveLength(1); + expect(items[0]!.name).toBe("replicate"); + expect(items[0]!.reason).toBe("Connection test failed"); + }); + + test("ready providers do not appear in attention items", () => { + const sections = { + ready: [makeItem("openai", { hasApiKey: true })], + needsSetup: [], + disabled: [], + }; + const items = buildAttentionItems(sections, {}); + expect(items).toHaveLength(0); + }); + + test("disabled providers without override reason are excluded from attention items", () => { + const sections = { + ready: [], + needsSetup: [], + disabled: [makeItem("replicate", { disabled: true })], + }; + const items = buildAttentionItems(sections, {}); + expect(items).toHaveLength(0); + }); +}); From 18024a7f7e7a418057c16e89592b49573fc37888 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Wed, 15 Jul 2026 06:33:04 +0200 Subject: [PATCH 02/50] feat(gui): enforce no hardcoded UI strings via ESLint i18n rules --- gui/.eslint/i18n-allowlist.ts | 48 +++++++++ gui/.eslint/i18n-file-groups.ts | 14 +++ gui/.eslint/local-i18n-plugin.ts | 180 +++++++++++++++++++++++++++++++ gui/AGENTS.md | 20 ++++ gui/eslint.config.js | 32 +++++- gui/package.json | 1 + 6 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 gui/.eslint/i18n-allowlist.ts create mode 100644 gui/.eslint/i18n-file-groups.ts create mode 100644 gui/.eslint/local-i18n-plugin.ts create mode 100644 gui/AGENTS.md diff --git a/gui/.eslint/i18n-allowlist.ts b/gui/.eslint/i18n-allowlist.ts new file mode 100644 index 00000000..c7c1f4f1 --- /dev/null +++ b/gui/.eslint/i18n-allowlist.ts @@ -0,0 +1,48 @@ +/** + * Literals that may stay hardcoded in UI/data code. + * - Brand/product names (proper nouns) + * - Model identifiers (technical ids from providers/APIs) + */ + +const BRAND_LITERALS = new Set([ + "OpenAI", + "Anthropic", + "GitHub", + "Codex", + "OpenRouter", + "Ollama", + "xAI", + "Grok", + "Google", + "Azure", + "DeepSeek", + "Kimi", + "Moonshot", + "Cursor", + "OpenCode", + "Xiaomi", + "Mimo", + "Claude", + "ChatGPT", + "OpenCodex", + "OAuth", + "API", +]); + +/** Model/catalog ids: gpt-4o, claude-3-5-sonnet, deepseek-v4-flash-free, provider/model */ +export function isModelIdentifier(value: string): boolean { + const trimmed = value.trim(); + if (!trimmed || /\s/.test(trimmed)) return false; + if (!/^[a-z0-9][a-z0-9._\-/+:]*$/i.test(trimmed)) return false; + return /[a-z]/i.test(trimmed) && (/\d/.test(trimmed) || /[-_/]/.test(trimmed) || /^gpt/i.test(trimmed) || /^claude/i.test(trimmed)); +} + +export function isBrandOrModelLiteral(value: unknown): boolean { + if (typeof value !== "string") return false; + const trimmed = value.trim(); + if (!trimmed) return false; + if (BRAND_LITERALS.has(trimmed)) return true; + if (isModelIdentifier(trimmed)) return true; + return false; +} + diff --git a/gui/.eslint/i18n-file-groups.ts b/gui/.eslint/i18n-file-groups.ts new file mode 100644 index 00000000..4f084599 --- /dev/null +++ b/gui/.eslint/i18n-file-groups.ts @@ -0,0 +1,14 @@ +/** GUI surfaces where visible copy must come from src/i18n (useT / t()). */ +export const I18N_UI_FILES = [ + "src/App.tsx", + "src/main.tsx", + "src/ui.tsx", + "src/pages/**/*.{ts,tsx}", + "src/components/**/*.{ts,tsx}", +]; + +/** Data/helpers that expose user-facing labels via object fields. */ +export const I18N_DATA_FILES = [ + "src/provider-workspace-data.ts", +]; + diff --git a/gui/.eslint/local-i18n-plugin.ts b/gui/.eslint/local-i18n-plugin.ts new file mode 100644 index 00000000..5f098159 --- /dev/null +++ b/gui/.eslint/local-i18n-plugin.ts @@ -0,0 +1,180 @@ +import type { Rule } from "eslint"; +import type { JSXAttribute, JSXElement, JSXText, Node, Property, TemplateElement } from "estree"; +import { isBrandOrModelLiteral } from "./i18n-allowlist.ts"; + +const LITERAL_PATTERN = + /[A-Za-zÀ-ÖØ-öø-ÿ\u0100-\u024F\u1E00-\u1EFF\u0400-\u04FF\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF\uAC00-\uD7AF]/u; + +const UI_ATTRS = new Set([ + "title", + "placeholder", + "aria-label", + "aria-description", + "aria-roledescription", + "alt", +]); + +const DATA_COPY_KEYS = new Set([ + "label", + "title", + "description", + "placeholder", + "message", + "reason", + "text", + "summary", + "subtitle", + "helper", + "empty", + "hint", +]); + +function isAllowedLiteral(value: string): boolean { + const trimmed = value.trim(); + if (!trimmed) return true; + if (!LITERAL_PATTERN.test(trimmed)) return true; + if (isBrandOrModelLiteral(trimmed)) return true; + return false; +} + +function reportLiteral( + context: Rule.RuleContext, + node: Node, + value: string, + messageId: "uiString" | "dataCopy", +) { + if (isAllowedLiteral(value)) return; + context.report({ node, messageId }); +} + +function isInsideTrans(node: Node): boolean { + let current: Node | undefined = node; + while (current) { + if (current.type === "JSXElement") { + const opening = (current as JSXElement).openingElement; + const name = opening.name; + if (name.type === "JSXIdentifier" && name.name === "Trans") return true; + } + current = (current as { parent?: Node }).parent; + } + return false; +} + +function isTransProp(node: Node): boolean { + const parent = (node as { parent?: Node }).parent; + if (!parent || parent.type !== "JSXAttribute") return false; + const attr = parent as JSXAttribute; + if (attr.name.type !== "JSXIdentifier") return false; + return attr.name.name === "k" || attr.name.name === "cmd"; +} + +function isInsideTCall(node: Node): boolean { + let current: Node | undefined = node; + while (current) { + if (current.type === "CallExpression") { + const callee = (current as { callee: Node }).callee; + if (callee.type === "Identifier" && callee.name === "t") return true; + } + current = (current as { parent?: Node }).parent; + } + return false; +} + +function propertyKeyName(key: Property["key"]): string | null { + if (key.type === "Identifier") return key.name; + if (key.type === "Literal" && typeof key.value === "string") return key.value; + return null; +} + +const noHardcodedUiStrings: Rule.RuleModule = { + meta: { + type: "problem", + docs: { + description: "Disallow hardcoded user-facing UI strings; use src/i18n keys via useT/t/Trans.", + }, + schema: [], + messages: { + uiString: + "Hardcoded UI text is not allowed. Add a key to src/i18n/en.ts (+ de/ko/zh) and render with t() or . Company names and model ids are the only allowed literals.", + }, + }, + create(context) { + return { + JSXText(node: JSXText) { + const value = node.value.replace(/\s+/g, " ").trim(); + if (!value) return; + reportLiteral(context, node, value, "uiString"); + }, + JSXAttribute(node: JSXAttribute) { + if (node.name.type !== "JSXIdentifier") return; + if (!UI_ATTRS.has(node.name.name)) return; + const valueNode = node.value; + if (!valueNode) return; + if (valueNode.type === "Literal" && typeof valueNode.value === "string") { + reportLiteral(context, valueNode, valueNode.value, "uiString"); + return; + } + if (valueNode.type === "JSXExpressionContainer") { + const expr = valueNode.expression; + if (expr.type === "Literal" && typeof expr.value === "string") { + reportLiteral(context, expr, expr.value, "uiString"); + } + } + }, + Literal(node) { + if (typeof node.value !== "string") return; + const parent = (node as { parent?: Node }).parent; + if (!parent) return; + if (parent.type === "ImportDeclaration") return; + if (isInsideTrans(node) || isTransProp(node) || isInsideTCall(node)) return; + if (parent.type === "JSXAttribute") return; + if (parent.type === "JSXExpressionContainer") return; + if (parent.type === "Property") { + const key = propertyKeyName((parent as Property).key); + if (key && DATA_COPY_KEYS.has(key)) { + reportLiteral(context, node, node.value, "uiString"); + } + } + }, + TemplateElement(node: TemplateElement) { + if ((node.value.expressions?.length ?? 0) > 0) return; + const raw = node.value.raw.replace(/\s+/g, " ").trim(); + if (!raw) return; + reportLiteral(context, node, raw, "uiString"); + }, + }; + }, +}; + +const noHardcodedDataCopy: Rule.RuleModule = { + meta: { + type: "problem", + docs: { + description: "Disallow hardcoded label/title/description fields in data helpers.", + }, + schema: [], + messages: { + dataCopy: + "Hardcoded user-facing copy in data objects is not allowed. Use i18n keys (e.g. labelKey) and resolve with t() at render time.", + }, + }, + create(context) { + return { + Property(node: Property) { + const key = propertyKeyName(node.key); + if (!key || !DATA_COPY_KEYS.has(key)) return; + const value = node.value; + if (value.type === "Literal" && typeof value.value === "string") { + reportLiteral(context, value, value.value, "dataCopy"); + } + }, + }; + }, +}; + +export default { + rules: { + "no-hardcoded-ui-strings": noHardcodedUiStrings, + "no-hardcoded-data-copy": noHardcodedDataCopy, + }, +}; diff --git a/gui/AGENTS.md b/gui/AGENTS.md new file mode 100644 index 00000000..7ed5f107 --- /dev/null +++ b/gui/AGENTS.md @@ -0,0 +1,20 @@ +# OpenCodex GUI — agent rules + +## Text and i18n + +- **No hardcoded visible UI text** in `src/pages`, `src/components`, `src/App.tsx`, or `src/ui.tsx`. +- Every new user-facing string goes into **all** locale files: + - `src/i18n/en.ts` (source of truth / `TKey`) + - `src/i18n/de.ts` + - `src/i18n/ko.ts` + - `src/i18n/zh.ts` +- Render copy with `useT()` / `t("key")` or `` for `{cmd}` chips. +- **Allowed literals without i18n keys:** + - **Company / product names** (e.g. OpenAI, Anthropic, GitHub, Codex) — prefer identical entries in locale files when the string is shown as UI copy. + - **Model identifiers** from APIs/catalogs (e.g. `gpt-4o`, `deepseek-v4-flash-free`) when displaying provider data, not when writing labels like "Default model". +- Run `bun run lint:i18n` after UI copy changes; fix violations before committing. + +## Failure mode + +Hardcoding English (or German) in JSX to “fix” a bad translation is **not** allowed. Add or fix the key in all locale files instead. + diff --git a/gui/eslint.config.js b/gui/eslint.config.js index ef614d25..4d405488 100644 --- a/gui/eslint.config.js +++ b/gui/eslint.config.js @@ -4,9 +4,21 @@ import reactHooks from 'eslint-plugin-react-hooks' import reactRefresh from 'eslint-plugin-react-refresh' import tseslint from 'typescript-eslint' import { defineConfig, globalIgnores } from 'eslint/config' +import { I18N_DATA_FILES, I18N_UI_FILES } from './.eslint/i18n-file-groups.ts' + +const localI18nPlugin = (await import(new URL('./.eslint/local-i18n-plugin.ts', import.meta.url).href)).default export default defineConfig([ - globalIgnores(['dist']), + globalIgnores([ + 'dist', + 'src/i18n/**', + '**/*.test.ts', + '**/*.test.tsx', + 'src/api.ts', + 'src/format*.ts', + 'src/icons.tsx', + 'src/provider-icons.ts', + ]), { files: ['**/*.{ts,tsx}'], extends: [ @@ -19,4 +31,22 @@ export default defineConfig([ globals: globals.browser, }, }, + { + files: I18N_UI_FILES, + plugins: { + 'local-i18n': localI18nPlugin, + }, + rules: { + 'local-i18n/no-hardcoded-ui-strings': 'error', + }, + }, + { + files: I18N_DATA_FILES, + plugins: { + 'local-i18n': localI18nPlugin, + }, + rules: { + 'local-i18n/no-hardcoded-data-copy': 'error', + }, + }, ]) diff --git a/gui/package.json b/gui/package.json index d9a85043..5870ec87 100644 --- a/gui/package.json +++ b/gui/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", + "lint:i18n": "eslint src/pages src/components src/App.tsx src/ui.tsx src/provider-workspace-data.ts", "preview": "vite preview" }, "dependencies": { From 2b5f0dbab7385a01e62695e7c1da5086f9de4076 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Wed, 15 Jul 2026 06:38:14 +0200 Subject: [PATCH 03/50] gui: dynamic i18n locale hint and hardcoded snippets in lint --- gui/.eslint/i18n-allowlist.ts | 17 +++++++++-- gui/.eslint/i18n-locales.ts | 49 ++++++++++++++++++++++++++++++++ gui/.eslint/local-i18n-plugin.ts | 17 ++++++++--- gui/AGENTS.md | 9 ++---- 4 files changed, 80 insertions(+), 12 deletions(-) create mode 100644 gui/.eslint/i18n-locales.ts diff --git a/gui/.eslint/i18n-allowlist.ts b/gui/.eslint/i18n-allowlist.ts index c7c1f4f1..b95fa472 100644 --- a/gui/.eslint/i18n-allowlist.ts +++ b/gui/.eslint/i18n-allowlist.ts @@ -29,6 +29,20 @@ const BRAND_LITERALS = new Set([ "API", ]); +const BRAND_LITERALS_LOWER = new Set( + [...BRAND_LITERALS].map((name) => name.toLowerCase()), +); + +/** Non-UI technical strings (API paths, CSS fragments, dotted key fragments). */ +export function isTechnicalLiteral(value: string): boolean { + const trimmed = value.trim(); + if (!trimmed) return true; + if (trimmed.startsWith("/") && /^\/[\w./?=&%-]+$/.test(trimmed)) return true; + if (/^(var\(--|calc\(|repeat\(|hsl\(|url\(|linear-gradient\()/i.test(trimmed)) return true; + if (/^[\w-]+(\.[\w-]+)+$/i.test(trimmed)) return true; + return false; +} + /** Model/catalog ids: gpt-4o, claude-3-5-sonnet, deepseek-v4-flash-free, provider/model */ export function isModelIdentifier(value: string): boolean { const trimmed = value.trim(); @@ -41,8 +55,7 @@ export function isBrandOrModelLiteral(value: unknown): boolean { if (typeof value !== "string") return false; const trimmed = value.trim(); if (!trimmed) return false; - if (BRAND_LITERALS.has(trimmed)) return true; + if (BRAND_LITERALS.has(trimmed) || BRAND_LITERALS_LOWER.has(trimmed.toLowerCase())) return true; if (isModelIdentifier(trimmed)) return true; return false; } - diff --git a/gui/.eslint/i18n-locales.ts b/gui/.eslint/i18n-locales.ts new file mode 100644 index 00000000..10ec0946 --- /dev/null +++ b/gui/.eslint/i18n-locales.ts @@ -0,0 +1,49 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const I18N_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "src", "i18n"); + +/** Source locale / `TKey` definition (`src/i18n/en.ts`). */ +export const I18N_SOURCE_LOCALE = "en"; + +/** Non-dictionary modules under `src/i18n/`. */ +const I18N_MODULE_SKIP = new Set(["index", "shared", "provider"]); + +/** Locale module ids matching `src/i18n/{id}.ts` (discovered at lint time). */ +export function listI18nLocaleModules(): string[] { + if (!fs.existsSync(I18N_DIR)) return [I18N_SOURCE_LOCALE]; + return fs + .readdirSync(I18N_DIR) + .filter((name) => name.endsWith(".ts")) + .map((name) => name.replace(/\.ts$/, "")) + .filter((id) => !I18N_MODULE_SKIP.has(id)) + .sort((a, b) => { + if (a === I18N_SOURCE_LOCALE) return -1; + if (b === I18N_SOURCE_LOCALE) return 1; + return a.localeCompare(b); + }); +} + +/** Locales that need a translation entry when adding a new key (all except source). */ +export function listTranslationLocales(): string[] { + return listI18nLocaleModules().filter((code) => code !== I18N_SOURCE_LOCALE); +} + +/** Hint for ESLint/docs — picks up new files like `src/i18n/fr.ts` automatically. */ +export function i18nLocaleFileHint(): string { + const modules = listI18nLocaleModules(); + if (modules.length <= 1) { + return `src/i18n/${I18N_SOURCE_LOCALE}.ts`; + } + const files = modules.map((code) => `src/i18n/${code}.ts`); + return files.join(", "); +} + +export function formatHardcodedSnippet(value: string, maxLen = 56): string { + const oneLine = value.replace(/\s+/g, " ").trim(); + if (!oneLine) return "(empty)"; + if (oneLine.length <= maxLen) return oneLine; + return `${oneLine.slice(0, maxLen - 1)}…`; +} + diff --git a/gui/.eslint/local-i18n-plugin.ts b/gui/.eslint/local-i18n-plugin.ts index 5f098159..2eb96061 100644 --- a/gui/.eslint/local-i18n-plugin.ts +++ b/gui/.eslint/local-i18n-plugin.ts @@ -1,6 +1,7 @@ import type { Rule } from "eslint"; import type { JSXAttribute, JSXElement, JSXText, Node, Property, TemplateElement } from "estree"; -import { isBrandOrModelLiteral } from "./i18n-allowlist.ts"; +import { isBrandOrModelLiteral, isTechnicalLiteral } from "./i18n-allowlist.ts"; +import { formatHardcodedSnippet, i18nLocaleFileHint } from "./i18n-locales.ts"; const LITERAL_PATTERN = /[A-Za-zÀ-ÖØ-öø-ÿ\u0100-\u024F\u1E00-\u1EFF\u0400-\u04FF\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF\uAC00-\uD7AF]/u; @@ -33,6 +34,7 @@ function isAllowedLiteral(value: string): boolean { const trimmed = value.trim(); if (!trimmed) return true; if (!LITERAL_PATTERN.test(trimmed)) return true; + if (isTechnicalLiteral(trimmed)) return true; if (isBrandOrModelLiteral(trimmed)) return true; return false; } @@ -44,7 +46,14 @@ function reportLiteral( messageId: "uiString" | "dataCopy", ) { if (isAllowedLiteral(value)) return; - context.report({ node, messageId }); + context.report({ + node, + messageId, + data: { + snippet: formatHardcodedSnippet(value), + locales: i18nLocaleFileHint(), + }, + }); } function isInsideTrans(node: Node): boolean { @@ -95,7 +104,7 @@ const noHardcodedUiStrings: Rule.RuleModule = { schema: [], messages: { uiString: - "Hardcoded UI text is not allowed. Add a key to src/i18n/en.ts (+ de/ko/zh) and render with t() or . Company names and model ids are the only allowed literals.", + 'Hardcoded UI text: "{{snippet}}". Add a key to {{locales}} and render with t() or . Company names and model ids are the only allowed literals.', }, }, create(context) { @@ -155,7 +164,7 @@ const noHardcodedDataCopy: Rule.RuleModule = { schema: [], messages: { dataCopy: - "Hardcoded user-facing copy in data objects is not allowed. Use i18n keys (e.g. labelKey) and resolve with t() at render time.", + 'Hardcoded user-facing copy: "{{snippet}}". Use i18n keys (e.g. labelKey) in {{locales}} and resolve with t() at render time.', }, }, create(context) { diff --git a/gui/AGENTS.md b/gui/AGENTS.md index 7ed5f107..117c16c4 100644 --- a/gui/AGENTS.md +++ b/gui/AGENTS.md @@ -4,17 +4,14 @@ - **No hardcoded visible UI text** in `src/pages`, `src/components`, `src/App.tsx`, or `src/ui.tsx`. - Every new user-facing string goes into **all** locale files: - - `src/i18n/en.ts` (source of truth / `TKey`) - - `src/i18n/de.ts` - - `src/i18n/ko.ts` - - `src/i18n/zh.ts` + - `src/i18n/en.ts` — source of truth / `TKey` + - plus every other `src/i18n/{locale}.ts` module (discovered automatically by `bun run lint:i18n`; when adding a language, add `{locale}.ts` and wire it in `src/i18n/shared.ts`) - Render copy with `useT()` / `t("key")` or `` for `{cmd}` chips. - **Allowed literals without i18n keys:** - **Company / product names** (e.g. OpenAI, Anthropic, GitHub, Codex) — prefer identical entries in locale files when the string is shown as UI copy. - **Model identifiers** from APIs/catalogs (e.g. `gpt-4o`, `deepseek-v4-flash-free`) when displaying provider data, not when writing labels like "Default model". -- Run `bun run lint:i18n` after UI copy changes; fix violations before committing. +- Run `bun run lint:i18n` after UI copy changes; fix violations before committing. Each error includes a **snippet** of the hardcoded text. ## Failure mode Hardcoding English (or German) in JSX to “fix” a bad translation is **not** allowed. Add or fix the key in all locale files instead. - From a0fbed2494cd1e6acef5d3a6b79b5499c47b8728 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Wed, 15 Jul 2026 08:05:27 +0200 Subject: [PATCH 04/50] Improvements --- gui/src/components/ProviderWorkspace.tsx | 19 +++- gui/src/pages/Providers.tsx | 57 ++++++++---- gui/src/styles-provider-workspace.css | 113 ++++++++++++++++++++--- gui/src/styles.css | 28 ++++++ 4 files changed, 182 insertions(+), 35 deletions(-) diff --git a/gui/src/components/ProviderWorkspace.tsx b/gui/src/components/ProviderWorkspace.tsx index 01646fd0..9f9f051e 100644 --- a/gui/src/components/ProviderWorkspace.tsx +++ b/gui/src/components/ProviderWorkspace.tsx @@ -121,6 +121,13 @@ function ProviderIcon({ name, adapter, baseUrl, cls }: { // Rail row // --------------------------------------------------------------------------- +function railStatusCls(item: WorkspaceItem): string { + const s = binProviderStatus(item); + if (s === "disabled") return "providers-workspace-rail-status providers-workspace-rail-status--inactive"; + if (s === "ready") return "providers-workspace-rail-status providers-workspace-rail-status--active"; + return "providers-workspace-rail-status providers-workspace-rail-status--warning"; +} + function RailRow({ item, selected, modelCount, onClick }: { item: WorkspaceItem; selected: boolean; @@ -134,7 +141,7 @@ function RailRow({ item, selected, modelCount, onClick }: { onClick={onClick} role="option" aria-selected={selected} - aria-label={`Select provider ${item.name}`} + aria-label={`Select provider ${item.name}, ${statusLabel(item)}`} > {item.name} - {modelCount !== undefined && ( +
)}
-
Most used (30d)
+
Recently used
{mostUsed.length === 0 ? (
No usage recorded.
) : mostUsed.map(entry => { diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index ffc8564a..f022841d 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -318,32 +318,57 @@ export default function Providers({ apiBase }: { apiBase: string }) { notify(data.error || (disabled ? t("prov.disableFail", { name }) : t("prov.enableFail", { name })), false); }; - if (!config) return
{t("prov.loadingConfig")}
; + if (!config) { + return ( +
+ {status && ( +
+ {status} +
+ )} +
+ {status ? null : t("prov.loadingConfig")} +
+
+ ); + } if (layout === "workspace") { return ( - <> - {status && {status}} - setAdding(true)} - onUseLegacyView={() => setLayout("classic")} - onEditConfig={() => { setLayout("classic"); setEditing(true); }} - onSetDisabled={setProviderDisabled} - onRemoveProvider={removeProvider} - quotaReports={quotaReports} - oauthStatus={oauthStatus} - /> +
+ {status && ( +
+ {status} +
+ )} +
+ setAdding(true)} + onUseLegacyView={() => setLayout("classic")} + onEditConfig={() => { setLayout("classic"); setEditing(true); }} + onSetDisabled={setProviderDisabled} + onRemoveProvider={removeProvider} + quotaReports={quotaReports} + oauthStatus={oauthStatus} + /> +
{adding && ( setAdding(false)} - onAdded={(name) => { setAdding(false); notify(t("prov.added", { name, cmd: "ocx sync" }), true); fetchConfig(); fetchOauth(); fetchProviderQuotas(true); }} + onAdded={(name) => { + setAdding(false); + notify(t("prov.added", { name, cmd: "ocx sync" }), true); + fetchConfig(); + fetchOauth(); + fetchProviderQuotas(true); + }} /> )} - +
); } diff --git a/gui/src/styles-provider-workspace.css b/gui/src/styles-provider-workspace.css index 4acc0fa2..c985580c 100644 --- a/gui/src/styles-provider-workspace.css +++ b/gui/src/styles-provider-workspace.css @@ -166,6 +166,34 @@ box-shadow: 0 0 0 3px var(--amber-soft); } +.providers-workspace-rail-model-count { + flex: 0 0 auto; + white-space: nowrap; + font-size: 11.5px; + color: var(--muted); + font-variant-numeric: tabular-nums; + letter-spacing: 0; +} + +.providers-workspace-rail-chevron { + flex: 0 0 auto; + width: 14px; + height: 14px; + color: var(--faint); + transform: rotate(0deg); +} + +/* Keep row pieces on one line — no letter stacking / vertical glyphs */ +.providers-workspace-rail-row > * { + min-width: 0; +} +.providers-workspace-rail-row .providers-workspace-rail-icon, +.providers-workspace-rail-row .providers-workspace-rail-status, +.providers-workspace-rail-row .providers-workspace-rail-model-count, +.providers-workspace-rail-row .providers-workspace-rail-chevron { + min-width: unset; +} + /* ---- detail panel (right column) ---- */ .providers-workspace-detail { @@ -391,12 +419,13 @@ .providers-workspace-detail-head { display: flex; align-items: center; - gap: 14px; - padding: 16px 22px 12px; + gap: 12px 14px; + padding: 14px 20px 12px; border-bottom: 1px solid var(--border); flex-shrink: 0; flex-wrap: wrap; min-width: 0; + row-gap: 10px; } .providers-workspace-detail-icon { @@ -445,9 +474,27 @@ display: flex; align-items: center; gap: 8px; - flex-shrink: 0; + flex: 1 1 auto; flex-wrap: wrap; justify-content: flex-end; + min-width: 0; +} +.providers-workspace-detail-actions .btn { + white-space: nowrap; + flex: 0 0 auto; +} +.pwi-enabled-toggle { + display: inline-flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; + white-space: nowrap; +} +.pwi-enabled-label { + font-size: 12.5px; + color: var(--muted); + white-space: nowrap; +} margin-left: auto; } @@ -1410,9 +1457,20 @@ } /* ---- Responsive updates ---- */ +@media (max-width: 1100px) { + .providers-workspace-root { + grid-template-columns: minmax(210px, 250px) minmax(0, 1fr); + } + .providers-workspace-rail-model-count { + max-width: 7.5rem; + overflow: hidden; + text-overflow: ellipsis; + } +} + @media (max-width: 960px) { .providers-workspace-root { - grid-template-columns: 240px 1fr; + grid-template-columns: minmax(200px, 230px) minmax(0, 1fr); } .pwi-overview-layout { grid-template-columns: 1fr; @@ -1444,6 +1502,35 @@ grid-template-columns: 1fr; } .pwi-connection-grid { + grid-template-columns: 1fr 1fr; + } + .pwi-connection-cell { + border-right: 1px solid var(--border-soft); + } + .pwi-connection-cell:nth-child(2n) { + border-right: none; + } + .pwi-qa-grid { + grid-template-columns: 1fr 1fr; + } + .pwi-oa-grid { + grid-template-columns: 1fr 1fr; + } + .providers-workspace-tabs { + overflow-x: auto; + flex-wrap: nowrap; + -webkit-overflow-scrolling: touch; + } + .providers-workspace-tab { + flex: 0 0 auto; + white-space: nowrap; + } +} + +@media (max-width: 760px) { + .pwi-connection-grid, + .pwi-qa-grid, + .pwi-oa-grid { grid-template-columns: 1fr; } .pwi-connection-cell { @@ -1455,22 +1542,20 @@ .pwi-connection-cell:last-child { border-bottom: none; } - .pwi-qa-grid { - grid-template-columns: 1fr; - } - .pwi-qa-tile { + .pwi-qa-tile, + .pwi-oa-tile { border-right: none; border-bottom: 1px solid var(--border-soft); } - .pwi-qa-tile:last-child { + .pwi-qa-tile:last-child, + .pwi-oa-tile:last-child { border-bottom: none; } - .pwi-oa-grid { + .providers-workspace-summary-row { grid-template-columns: 1fr; } - .pwi-oa-tile { - border-right: none; - border-bottom: 1px solid var(--border-soft); + .providers-workspace-detail-actions { + width: 100%; + justify-content: flex-start; } - .pwi-oa-tile:last-child { border-bottom: none; } } diff --git a/gui/src/styles.css b/gui/src/styles.css index 143ed707..8641e4d3 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -202,6 +202,7 @@ input[type="checkbox"], input[type="radio"] { accent-color: var(--accent); } .main { min-width: 0; } .main-inner { max-width: 980px; margin: 0 auto; padding: 32px 36px 64px; } +/* Providers workspace is a full-height master-detail shell (do not constrain width). */ .main-inner.main-inner--providers { max-width: none; margin: 0; @@ -212,6 +213,33 @@ input[type="checkbox"], input[type="radio"] { accent-color: var(--accent); } display: flex; flex-direction: column; } +.main-inner.main-inner--providers > .providers-workspace-shell { + flex: 1 1 auto; + min-height: 0; + height: 100%; + display: flex; + flex-direction: column; +} +.providers-workspace-shell-banner { + flex: 0 0 auto; + padding: 10px 16px 0; +} +.providers-workspace-shell-banner .notice { + margin: 0; +} +.providers-workspace-shell-body { + flex: 1 1 auto; + min-height: 0; + height: 100%; + display: flex; + flex-direction: column; +} +.providers-workspace-shell-body > .providers-workspace-root, +.providers-workspace-shell-body > .providers-workspace-empty-root { + flex: 1 1 auto; + min-height: 0; + height: 100%; +} .main-inner.main-inner--providers > .providers-workspace-root, .main-inner.main-inner--providers > .providers-workspace-empty-root { flex: 1 1 auto; From 35e0482b95c4b3d3d3cc6c7e6a407ae1de122727 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Wed, 15 Jul 2026 09:18:54 +0200 Subject: [PATCH 05/50] Improvements --- gui/src/components/ProviderWorkspace.tsx | 670 +++++++++++++++++------ gui/src/icons.tsx | 18 + gui/src/pages/Providers.tsx | 31 ++ gui/src/provider-workspace-data.ts | 43 ++ gui/src/styles-provider-workspace.css | 644 ++++++++++++++++++---- src/server/management-api.ts | 131 ++++- tests/provider-workspace-data.test.ts | 32 ++ 7 files changed, 1291 insertions(+), 278 deletions(-) diff --git a/gui/src/components/ProviderWorkspace.tsx b/gui/src/components/ProviderWorkspace.tsx index 9f9f051e..44c6a98f 100644 --- a/gui/src/components/ProviderWorkspace.tsx +++ b/gui/src/components/ProviderWorkspace.tsx @@ -11,6 +11,9 @@ import { formatRelativeTime, formatRequestCount, formatTokenCount, + isFreeProvider, + sortWorkspaceItems, + type ProviderSortMode, type AttentionItem, type ProviderModelCounts, type ProviderAvailableModels, @@ -22,6 +25,7 @@ import { import { providerIconSrc } from "../provider-icons"; import { IconSearch, + IconFilter, IconPlus, IconX, IconPower, @@ -55,24 +59,42 @@ export interface ProviderWorkspaceProps { onEditConfig: () => void; onSetDisabled: (name: string, disabled: boolean) => void; onRemoveProvider: (name: string) => void; + /** Partial update of a provider (settings form). */ + onUpdateProvider?: (name: string, patch: ProviderUpdatePatch) => Promise<{ ok: boolean; error?: string }>; quotaReports?: Record; oauthStatus?: Record; } +export type ProviderUpdatePatch = { + adapter?: string; + baseUrl?: string; + defaultModel?: string; + apiKey?: string; + authMode?: string; + note?: string; + disabled?: boolean; +}; + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -type Tab = "overview" | "models" | "auth" | "usage" | "settings"; +type Tab = "overview" | "models" | "usage" | "settings"; const TABS: { id: Tab; label: string }[] = [ { id: "overview", label: "Overview" }, { id: "models", label: "Models" }, - { id: "auth", label: "Auth" }, { id: "usage", label: "Usage & limits" }, { id: "settings", label: "Settings" }, ]; +const SORT_OPTIONS: { id: ProviderSortMode; label: string }[] = [ + { id: "az", label: "A → Z" }, + { id: "za", label: "Z → A" }, + { id: "free-paid", label: "Free → Paid" }, + { id: "paid-free", label: "Paid → Free" }, +]; + function statusDotCls(p: WorkspaceProvider): string { const s = binProviderStatus(p); if (s === "disabled") return "pwi-dot pwi-dot--inactive"; @@ -128,12 +150,24 @@ function railStatusCls(item: WorkspaceItem): string { return "providers-workspace-rail-status providers-workspace-rail-status--warning"; } +function isLocalProvider(item: WorkspaceProvider): boolean { + if (item.authMode === "local") return true; + try { + const host = new URL(item.baseUrl).hostname.replace(/^\[|\]$/g, "").toLowerCase(); + return host === "localhost" || host === "127.0.0.1" || host === "::1"; + } catch { + return false; + } +} + function RailRow({ item, selected, modelCount, onClick }: { item: WorkspaceItem; selected: boolean; modelCount?: number; onClick: () => void; }) { + const free = isFreeProvider(item); + const local = isLocalProvider(item); return ( + {msg && {msg.text}} +
+
+
+
Provider state @@ -530,30 +660,6 @@ function TabSettings({ item, onSetDisabled, onRemoveProvider }: {
-
-
- Danger zone -
-
-
- - Remove provider - Permanently removes this provider from the proxy config. - - - - -
-
-
); } @@ -563,39 +669,81 @@ function TabSettings({ item, onSetDisabled, onRemoveProvider }: { // --------------------------------------------------------------------------- function DetailPanel({ - item, onSetDisabled, onRemoveProvider, onDeselect, onUseLegacyView, usageTotals, - quotaReport, oauth, modelCount, availableModels, selectedModels, + item, apiBase, onSetDisabled, onRemoveProvider, onDeselect, onUpdateProvider, + usageTotals, quotaReport, oauth, modelCount, availableModels, selectedModels, + onTestDone, }: { item: WorkspaceItem; + apiBase: string; onSetDisabled: (name: string, disabled: boolean) => void; onRemoveProvider: (name: string) => void; onDeselect: () => void; - onUseLegacyView: () => void; + onUpdateProvider?: (name: string, patch: ProviderUpdatePatch) => Promise<{ ok: boolean; error?: string }>; usageTotals?: ProviderUsageTotals; quotaReport?: { updatedAt: number; source?: string }; oauth?: { loggedIn: boolean; email?: string; error?: string }; modelCount: number; availableModels: string[]; selectedModels: string[]; + onTestDone?: () => void; }) { const [tab, setTab] = useState("overview"); - const [menuOpen, setMenuOpen] = useState(false); - const menuRef = useRef(null); + const [testing, setTesting] = useState(false); + const [testMsg, setTestMsg] = useState<{ ok: boolean; text: string } | null>(null); + const [lastCheckedAt, setLastCheckedAt] = useState(quotaReport?.updatedAt); const isEnabled = !item.disabled; useEffect(() => { - if (!menuOpen) return; - const handler = (e: MouseEvent) => { - if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false); - }; - document.addEventListener("mousedown", handler); - return () => document.removeEventListener("mousedown", handler); - }, [menuOpen]); + setTab("overview"); + setTestMsg(null); + setLastCheckedAt(quotaReport?.updatedAt); + }, [item.name]); // eslint-disable-line react-hooks/exhaustive-deps -- reset UI when switching provider + + const testConnection = async () => { + setTesting(true); + setTestMsg(null); + try { + const res = await fetch(`${apiBase}/api/providers/test?name=${encodeURIComponent(item.name)}`, { method: "POST" }); + const data = await res.json().catch(() => ({})) as { + ok?: boolean; latencyMs?: number; models?: number; message?: string; error?: string; + }; + const ok = data.ok === true; + const latency = typeof data.latencyMs === "number" ? ` · ${data.latencyMs}ms` : ""; + setTestMsg({ + ok, + text: ok + ? `${data.message ?? "Connected"}${latency}` + : `${data.error ?? "Connection failed"}${latency}`, + }); + if (ok) { + setLastCheckedAt(Date.now()); + onTestDone?.(); + } + } catch { + setTestMsg({ ok: false, text: "Network error — is the proxy running?" }); + } finally { + setTesting(false); + } + }; + + const confirmRemove = () => { + if (window.confirm(`Remove provider "${item.name}"? This cannot be undone from the UI.`)) { + onRemoveProvider(item.name); + } + }; const renderTabPanel = (): React.ReactNode => { switch (tab) { - case "overview": return ; - case "models": return ( + case "overview": return ( + + ); + case "models": return ( ); - case "auth": return ; - case "usage": return ; - case "settings": return ; - default: return null; + case "usage": return ; + case "settings": return ( + + ); + default: return null; } }; return (
+
-
{item.name}
-
-
- -
+
+ + {testing ? "Checking…" : (testMsg?.text ?? "")} + - {menuOpen && ( -
- - - -
- -
- )}
+
Enabled
- Ready {sections.ready.length} + Ready
- Need setup {sections.needsSetup.length} + Need setup
- Disabled {sections.disabled.length} + Disabled
@@ -874,25 +1021,79 @@ function OverviewPanel({ // --------------------------------------------------------------------------- export default function ProviderWorkspace({ - providers, apiBase, onAddProvider, onUseLegacyView, onEditConfig, - onSetDisabled, onRemoveProvider, quotaReports = {}, oauthStatus = {}, + providers, apiBase, onAddProvider, onUseLegacyView: _onUseLegacyView, onEditConfig, + onSetDisabled, onRemoveProvider, onUpdateProvider, quotaReports = {}, oauthStatus = {}, }: ProviderWorkspaceProps) { + void _onUseLegacyView; const [search, setSearch] = useState(""); const [selectedName, setSelectedName] = useState(null); const [modelCounts, setModelCounts] = useState({}); const [availableModels, setAvailableModels] = useState({}); const [selectedModels, setSelectedModels] = useState({}); const [usageTotals, setUsageTotals] = useState>({}); + /** Status + pricing facets shown in the rail (all on by default). */ + const [statusFilter, setStatusFilter] = useState({ ready: true, needsSetup: true, disabled: true }); + const [pricingFilter, setPricingFilter] = useState({ free: true, paid: true }); + const [sortMode, setSortMode] = useState("az"); + const [filterOpen, setFilterOpen] = useState(false); + const filterWrapRef = useRef(null); const sections = useMemo(() => buildProviderWorkspace(providers), [providers]); + const allItems = useMemo( + () => [...sections.ready, ...sections.needsSetup, ...sections.disabled], + [sections], + ); + const freeCount = useMemo(() => allItems.filter(isFreeProvider).length, [allItems]); + const paidCount = allItems.length - freeCount; + const filteredSections = useMemo((): WorkspaceSections => { const q = search.trim().toLowerCase(); - if (!q) return sections; - const filter = (items: WorkspaceItem[]) => - items.filter(p => p.name.toLowerCase().includes(q) || p.adapter.toLowerCase().includes(q)); - return { ready: filter(sections.ready), needsSetup: filter(sections.needsSetup), disabled: filter(sections.disabled) }; - }, [sections, search]); + const byQueryAndPricing = (items: WorkspaceItem[]) => { + const filtered = items.filter(p => { + if (q && !p.name.toLowerCase().includes(q) && !p.adapter.toLowerCase().includes(q)) return false; + const free = isFreeProvider(p); + if (free && !pricingFilter.free) return false; + if (!free && !pricingFilter.paid) return false; + return true; + }); + return sortWorkspaceItems(filtered, sortMode); + }; + return { + ready: statusFilter.ready ? byQueryAndPricing(sections.ready) : [], + needsSetup: statusFilter.needsSetup ? byQueryAndPricing(sections.needsSetup) : [], + disabled: statusFilter.disabled ? byQueryAndPricing(sections.disabled) : [], + }; + }, [sections, search, statusFilter, pricingFilter, sortMode]); + + const filterActive = + !statusFilter.ready || !statusFilter.needsSetup || !statusFilter.disabled + || !pricingFilter.free || !pricingFilter.paid + || sortMode !== "az"; + + const resetFilters = () => { + setStatusFilter({ ready: true, needsSetup: true, disabled: true }); + setPricingFilter({ free: true, paid: true }); + setSortMode("az"); + }; + + useEffect(() => { + if (!filterOpen) return; + const onDoc = (e: MouseEvent) => { + if (filterWrapRef.current && !filterWrapRef.current.contains(e.target as Node)) { + setFilterOpen(false); + } + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setFilterOpen(false); + }; + document.addEventListener("mousedown", onDoc); + window.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDoc); + window.removeEventListener("keydown", onKey); + }; + }, [filterOpen]); const selectedItem = useMemo( () => selectedName @@ -959,26 +1160,133 @@ export default function ProviderWorkspace({
-
+
+ + {filterOpen && ( +
+
Filters
+ +
+
Status
+ {([ + ["ready", "Ready", "pwi-dot--active", sections.ready.length] as const, + ["needsSetup", "Needs setup", "pwi-dot--warning", sections.needsSetup.length] as const, + ["disabled", "Disabled", "pwi-dot--inactive", sections.disabled.length] as const, + ]).map(([key, label, dotCls, count]) => ( + + ))} +
+ +
+
Pricing
+ + +
+ +
+
Sort
+
+ {SORT_OPTIONS.map(opt => ( + + ))} +
+
+ +
+ +
+
+ )}
-
{Object.values(filteredSections).every(items => items.length === 0) && ( - {search ? `No results for \u201c${search}\u201d` : "No providers configured."} + {search + ? `No results for \u201c${search}\u201d` + : filterActive + ? "No providers match the current filters." + : "No providers configured."} )} {([ - ["Ready", filteredSections.ready, "pwi-dot--active"], - ["Needs setup", filteredSections.needsSetup, "pwi-dot--warning"], - ["Disabled", filteredSections.disabled, "pwi-dot--inactive"], - ] as const).map(([title, items, dotCls]) => { + ["Ready", filteredSections.ready, "pwi-dot--active"] as const, + ["Needs setup", filteredSections.needsSetup, "pwi-dot--warning"] as const, + ["Disabled", filteredSections.disabled, "pwi-dot--inactive"] as const, + ]).map(([title, items, dotCls]) => { if (items.length === 0) return null; return (
@@ -1001,16 +1309,18 @@ export default function ProviderWorkspace({ {selectedItem ? ( setSelectedName(null)} - onUseLegacyView={onUseLegacyView} + onUpdateProvider={onUpdateProvider} usageTotals={usageTotals[selectedItem.name]} quotaReport={quotaReports[selectedItem.name]} oauth={oauthStatus[selectedItem.name]} modelCount={modelCounts[selectedItem.name] ?? 0} availableModels={availableModels[selectedItem.name] ?? []} selectedModels={selectedModels[selectedItem.name] ?? []} + onTestDone={() => { void fetchModelCounts(); }} /> ) : ( (); export const IconInfo = (p: P) => (); export const IconSearch = (p: P) => (); +/** + * Funnel filter control — solid silhouette (wide top → stem). + * Stroke-only funnels read as inverted/ambiguous at 14–16px. + */ +export const IconFilter = (p: P) => ( + + + +); export const IconArrowUp = (p: P) => (); export const IconArrowDown = (p: P) => (); export const IconChevron = (p: P) => (); diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index f022841d..0b87ce20 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -318,6 +318,36 @@ export default function Providers({ apiBase }: { apiBase: string }) { notify(data.error || (disabled ? t("prov.disableFail", { name }) : t("prov.enableFail", { name })), false); }; + const updateProvider = async ( + name: string, + patch: { + adapter?: string; + baseUrl?: string; + defaultModel?: string; + apiKey?: string; + authMode?: string; + note?: string; + disabled?: boolean; + }, + ): Promise<{ ok: boolean; error?: string }> => { + const res = await fetch(`${apiBase}/api/providers?name=${encodeURIComponent(name)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), + }); + const data = await res.json().catch(() => ({})) as { error?: string }; + if (res.ok) { + notify(`Saved ${name}`, true); + fetchConfig(); + fetchOauth(); + fetchProviderQuotas(true); + return { ok: true }; + } + const err = data.error || `Failed to update ${name}`; + notify(err, false); + return { ok: false, error: err }; + }; + if (!config) { return (
@@ -350,6 +380,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { onEditConfig={() => { setLayout("classic"); setEditing(true); }} onSetDisabled={setProviderDisabled} onRemoveProvider={removeProvider} + onUpdateProvider={updateProvider} quotaReports={quotaReports} oauthStatus={oauthStatus} /> diff --git a/gui/src/provider-workspace-data.ts b/gui/src/provider-workspace-data.ts index 008ae68d..7c60db9a 100644 --- a/gui/src/provider-workspace-data.ts +++ b/gui/src/provider-workspace-data.ts @@ -68,6 +68,49 @@ function isConfigurationReady(p: WorkspaceProvider): boolean { p.hasApiKey === true; } +/** + * Free-tier / no-paid-key providers: explicit free flag, local runtimes, or loopback. + * Everything else is treated as paid (API-key / cloud subscription providers). + */ +export function isFreeProvider(p: WorkspaceProvider): boolean { + return p.keyOptional === true + || p.authMode === "local" + || hasLoopbackBaseUrl(p.baseUrl); +} + +export function isPaidProvider(p: WorkspaceProvider): boolean { + return !isFreeProvider(p); +} + +/** Rail / list sort modes for the providers workspace. */ +export type ProviderSortMode = "az" | "za" | "free-paid" | "paid-free"; + +export function sortWorkspaceItems(items: WorkspaceItem[], mode: ProviderSortMode): WorkspaceItem[] { + const copy = [...items]; + const byName = (a: WorkspaceItem, b: WorkspaceItem) => + a.name.localeCompare(b.name, undefined, { sensitivity: "base" }); + switch (mode) { + case "az": + return copy.sort(byName); + case "za": + return copy.sort((a, b) => byName(b, a)); + case "free-paid": + return copy.sort((a, b) => { + const af = isFreeProvider(a) ? 0 : 1; + const bf = isFreeProvider(b) ? 0 : 1; + return af - bf || byName(a, b); + }); + case "paid-free": + return copy.sort((a, b) => { + const af = isFreeProvider(a) ? 1 : 0; + const bf = isFreeProvider(b) ? 1 : 0; + return af - bf || byName(a, b); + }); + default: + return copy; + } +} + /** * Transforms the proxy config `providers` map into the three workspace sections. * Iteration order follows `Object.entries` (insertion order). diff --git a/gui/src/styles-provider-workspace.css b/gui/src/styles-provider-workspace.css index c985580c..e7b9bff4 100644 --- a/gui/src/styles-provider-workspace.css +++ b/gui/src/styles-provider-workspace.css @@ -72,11 +72,13 @@ .providers-workspace-rail-group-head { display: flex; align-items: center; - justify-content: space-between; - padding: 3px 8px 5px; + justify-content: flex-start; + gap: 7px; + padding: 8px 16px 6px; color: var(--muted); - font-size: 11px; + font-size: 11.5px; font-weight: 600; + letter-spacing: 0.01em; } /* compact provider row in the rail */ @@ -145,6 +147,19 @@ font-size: 13px; } +/* Exception labels only (Local / Free) — quiet type, no pill chrome. Paid = unmarked default. */ +.pwi-rail-meta { + flex: 0 0 auto; + font-size: 11.5px; + font-weight: 500; + color: var(--muted); + line-height: 1.2; + white-space: nowrap; +} +.pwi-rail-meta--free { + color: var(--green); +} + .providers-workspace-rail-status { flex-shrink: 0; width: 7px; @@ -198,10 +213,11 @@ .providers-workspace-detail { min-width: 0; - display: flex; - flex-direction: column; min-height: 0; height: 100%; + max-height: 100%; + display: flex; + flex-direction: column; overflow: hidden; } @@ -222,9 +238,10 @@ } .providers-workspace-overview-title { - font-size: 19px; - font-weight: 600; + font-size: 22px; + font-weight: 650; color: var(--text); + letter-spacing: -0.015em; } .providers-workspace-overview-sub { @@ -234,7 +251,7 @@ max-width: 60ch; } -/* summary counter row */ +/* summary counter row — mock: big number on top, label under */ .providers-workspace-summary-row { display: grid; grid-template-columns: repeat(3, 1fr); @@ -244,11 +261,11 @@ .providers-workspace-summary-card { background: var(--surface); border: 1px solid var(--border); - border-radius: 6px; - padding: 12px 14px; + border-radius: 10px; + padding: 16px 18px 14px; display: flex; flex-direction: column; - gap: 6px; + gap: 4px; transition: border-color 0.12s; } @@ -257,7 +274,7 @@ } .providers-workspace-summary-label { - font-size: 12px; + font-size: 12.5px; font-weight: 500; color: var(--muted); display: flex; @@ -272,10 +289,12 @@ } .providers-workspace-summary-value { - font-size: 22px; - font-weight: 600; + font-size: 30px; + font-weight: 650; color: var(--text); - line-height: 1.1; + line-height: 1.05; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; } /* 3-column provider grid in overview */ @@ -420,18 +439,18 @@ display: flex; align-items: center; gap: 12px 14px; - padding: 14px 20px 12px; + padding: 16px 22px 14px; border-bottom: 1px solid var(--border); flex-shrink: 0; - flex-wrap: wrap; + flex-wrap: nowrap; min-width: 0; row-gap: 10px; } .providers-workspace-detail-icon { - width: 48px; - height: 48px; - border-radius: var(--radius-sm); + width: 52px; + height: 52px; + border-radius: 12px; border: 1px solid var(--border-soft); background: var(--raised); display: flex; @@ -443,8 +462,8 @@ .providers-workspace-detail-icon img, .providers-workspace-detail-icon > svg { - width: 30px; - height: 30px; + width: 32px; + height: 32px; object-fit: contain; display: block; } @@ -452,16 +471,24 @@ .providers-workspace-detail-title-group { flex: 1 1 auto; min-width: 0; - flex-basis: 180px; +} + +.providers-workspace-detail-title-row { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; } .providers-workspace-detail-title { - font-size: 17px; + font-size: 20px; font-weight: 650; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + letter-spacing: -0.01em; + min-width: 0; } .providers-workspace-detail-subtitle { @@ -470,13 +497,33 @@ margin-top: 2px; } +.providers-workspace-detail-status-row { + display: inline-flex; + align-items: center; + gap: 6px; + margin-top: 4px; + padding: 2px 8px 2px 6px; + border-radius: 999px; + background: var(--raised); + border: 1px solid var(--border-soft); + width: fit-content; + max-width: 100%; +} + +.providers-workspace-detail-status-label { + font-size: 12px; + font-weight: 550; + color: var(--muted); +} + .providers-workspace-detail-actions { display: flex; align-items: center; gap: 8px; - flex: 1 1 auto; - flex-wrap: wrap; + flex: 0 0 auto; + flex-wrap: nowrap; justify-content: flex-end; + margin-left: auto; min-width: 0; } .providers-workspace-detail-actions .btn { @@ -489,24 +536,26 @@ gap: 8px; flex: 0 0 auto; white-space: nowrap; + margin-left: 4px; + padding-left: 10px; + border-left: 1px solid var(--border-soft); } .pwi-enabled-label { font-size: 12.5px; color: var(--muted); white-space: nowrap; } - margin-left: auto; -} -/* tabs strip */ +/* tabs strip — never show a scrollbar here; only the panel below scrolls */ .providers-workspace-tabs { display: flex; align-items: flex-end; + flex-wrap: nowrap; gap: 0; padding: 0 22px; border-bottom: 1px solid var(--border); flex-shrink: 0; - overflow-x: auto; + overflow: hidden; /* no scrollbars on the tab labels */ } .providers-workspace-tab { @@ -543,15 +592,22 @@ flex-shrink: 0; } -/* tab content region */ +/* tab content region — the only vertical scroll surface in the detail pane. + Use block layout (not flex column): flex children default to flex-shrink:1 and + get squashed to fit, so scrollHeight never exceeds clientHeight and scrolling + is impossible. Block flow lets content grow and overflow-y:auto works. */ .providers-workspace-tab-content { - flex: 1 1 auto; + flex: 1 1 0; padding: 14px 22px 28px; + overflow-x: hidden; overflow-y: auto; - display: flex; - flex-direction: column; - gap: 16px; + display: block; min-height: 0; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; +} +.providers-workspace-tab-content > * + * { + margin-top: 14px; } /* ---- config section card inside a tab ---- */ @@ -687,26 +743,31 @@ @media (max-width: 760px) { .providers-workspace-root { grid-template-columns: 1fr; - grid-template-rows: auto 1fr; + grid-template-rows: auto minmax(0, 1fr); height: 100%; min-height: 0; + overflow: hidden; } .providers-workspace-rail { position: static; height: auto; - max-height: 38vh; + max-height: min(42vh, 320px); + min-height: 160px; border-right: none; border-bottom: 1px solid var(--border); + overflow: hidden; } .providers-workspace-rail-list { + flex: 1 1 auto; flex-direction: column; flex-wrap: nowrap; overflow-x: hidden; overflow-y: auto; gap: 0; padding: 0; + min-height: 0; } .providers-workspace-rail-row { @@ -805,11 +866,15 @@ are kept for backward compatibility and modified in-place where needed. ============================================================================ */ -/* main panel */ +/* main panel — must clip height so .providers-workspace-tab-content can scroll */ .providers-workspace-main { min-width: 0; + min-height: 0; + height: 100%; + max-height: 100%; display: flex; flex-direction: column; + overflow: hidden; } /* ---- Status dots (replaces .providers-workspace-rail-status) ---- */ @@ -860,7 +925,7 @@ .pwi-rail-search-row { display: flex; align-items: center; - gap: 4px; + gap: 6px; padding: 8px 12px 6px; flex-shrink: 0; } @@ -868,44 +933,345 @@ position: relative; flex: 1 1 auto; min-width: 0; + display: flex; + align-items: center; } .pwi-rail-search-icon { - width: 12px; - height: 12px; + width: 14px !important; + height: 14px !important; + min-width: 14px; + min-height: 14px; color: var(--muted); position: absolute; - left: 10px; + left: 11px; top: 50%; transform: translateY(-50%); pointer-events: none; + z-index: 1; + display: block; + flex-shrink: 0; } .pwi-rail-search-input { - padding-left: 28px; + /* Room for the loupe (left 11 + 14 + gap) — avoid overlapping placeholder/text */ + padding: 7px 10px 7px 34px !important; font-size: 13px; - height: 32px; + height: 34px; width: 100%; + line-height: 1.2; +} +/* Hide native search clear chrome that steals padding on WebKit/Chromium */ +.pwi-rail-search-input::-webkit-search-decoration, +.pwi-rail-search-input::-webkit-search-cancel-button, +.pwi-rail-search-input::-webkit-search-results-button, +.pwi-rail-search-input::-webkit-search-results-decoration { + -webkit-appearance: none; + appearance: none; + display: none; +} +.pwi-rail-filter-wrap { + position: relative; + flex-shrink: 0; } .pwi-rail-filter-btn { + position: relative; flex-shrink: 0; - padding: 4px 6px; + width: 38px; + height: 38px; + min-width: 38px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--border); + border-radius: 10px; + color: var(--muted); + background: var(--surface); + box-shadow: 0 1px 0 color-mix(in srgb, var(--text) 4%, transparent); } -.pwi-rail-add-btn { +.pwi-rail-filter-btn:hover { + color: var(--text); + background: var(--hover); + border-color: var(--faint); +} +.pwi-rail-filter-btn--active { + color: var(--text); + border-color: var(--accent-ring); + background: var(--accent-soft); +} +.pwi-rail-filter-btn svg { + display: block; + width: 29px !important; + height: 29px !important; +} +.pwi-rail-filter-dot { + position: absolute; + top: 6px; + right: 6px; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 0 2px var(--surface); +} +.pwi-rail-filter-menu { + position: absolute; + top: calc(100% + 8px); + right: 0; + z-index: 40; + width: 248px; + padding: 10px; + background: var(--surface); border: 1px solid var(--border); - font-size: 12px; + border-radius: 14px; + box-shadow: + 0 1px 2px color-mix(in srgb, var(--text) 6%, transparent), + 0 12px 32px color-mix(in srgb, var(--text) 12%, transparent); } - -/* ---- Detail header: status badge row ---- */ -.providers-workspace-detail-status-row { +.pwi-rail-filter-menu-title { + padding: 2px 6px 10px; + font-size: 13.5px; + font-weight: 650; + color: var(--text); + letter-spacing: -0.01em; +} +.pwi-rail-filter-section { + background: var(--raised); + border: 1px solid var(--border-soft); + border-radius: 12px; + padding: 6px; + margin-bottom: 8px; +} +.pwi-rail-filter-section:last-of-type { + margin-bottom: 0; +} +.pwi-rail-filter-menu-head { + padding: 4px 8px 6px; + font-size: 10.5px; + font-weight: 700; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.06em; +} +.pwi-rail-filter-option { + position: relative; display: flex; align-items: center; + gap: 9px; + padding: 8px 8px; + font-size: 13px; + color: var(--text); + cursor: pointer; + user-select: none; + border-radius: 9px; + transition: background 0.12s; +} +.pwi-rail-filter-option:hover { + background: color-mix(in srgb, var(--hover) 80%, transparent); +} +.pwi-rail-filter-option--on { + background: color-mix(in srgb, var(--accent-soft) 55%, transparent); +} +.pwi-rail-filter-option--on:hover { + background: var(--accent-soft); +} +.pwi-rail-filter-native { + position: absolute; + opacity: 0; + inset: 0; + width: 100%; + height: 100%; + margin: 0; + cursor: pointer; +} +.pwi-rail-filter-check { + width: 18px; + height: 18px; + border-radius: 6px; + border: 1.5px solid var(--border); + background: var(--surface); + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--surface); + transition: background 0.12s, border-color 0.12s, color 0.12s; +} +.pwi-rail-filter-check--on { + background: var(--text); + border-color: var(--text); + color: var(--surface); +} +.pwi-rail-filter-option-label { + flex: 1 1 auto; + min-width: 0; + font-weight: 550; + display: inline-flex; + align-items: center; gap: 7px; - margin-top: 3px; + flex-wrap: wrap; } -.providers-workspace-detail-status-label { - font-size: 13px; +.pwi-rail-filter-option-count { + font-size: 11.5px; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--muted); + background: var(--surface); + border: 1px solid var(--border-soft); + border-radius: 999px; + min-width: 22px; + padding: 1px 7px; + text-align: center; +} + +.pwi-rail-filter-footer { + display: flex; + justify-content: flex-end; + padding: 8px 2px 2px; +} +.pwi-rail-filter-reset { + font-size: 12px; color: var(--muted); +} +.pwi-rail-filter-reset:disabled { + opacity: 0.4; + cursor: default; +} +.pwi-rail-filter-reset:not(:disabled):hover { + color: var(--text); +} +.pwi-rail-sort-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; + padding: 2px 4px 6px; +} +.pwi-rail-sort-btn { + font: inherit; + font-size: 12px; + font-weight: 550; + padding: 7px 8px; + border-radius: 8px; + border: 1px solid var(--border-soft); + background: var(--surface); + color: var(--muted); + cursor: pointer; + transition: background 0.12s, border-color 0.12s, color 0.12s; +} +.pwi-rail-sort-btn:hover { + color: var(--text); + border-color: var(--border); +} +.pwi-rail-sort-btn--active { + color: var(--text); + background: var(--accent-soft); + border-color: var(--accent-ring); + font-weight: 650; +} + +.pwi-back-overview { + flex: 0 0 auto; + gap: 4px; + margin-right: 2px; + white-space: nowrap; +} +.pwi-remove-btn { + color: var(--red, #c44) !important; + border: 1px solid color-mix(in srgb, var(--red, #c44) 45%, transparent) !important; + border-radius: 999px; + background: color-mix(in srgb, var(--red, #c44) 8%, transparent); +} +.pwi-remove-btn svg { + color: var(--red, #c44); + stroke: currentColor; +} +.pwi-remove-btn:hover { + color: var(--red, #c44) !important; + background: var(--red-soft, color-mix(in srgb, var(--red, #c44) 16%, transparent)); + border-color: var(--red, #c44) !important; +} +/* Status text LEFT of the button — full string, no ellipsis */ +.pwi-test-cluster { + display: inline-flex; + align-items: center; + flex-direction: row; + gap: 10px; + min-width: 0; + max-width: min(560px, 52vw); +} +.pwi-test-msg { + order: 0; + font-size: 12.5px; font-weight: 500; + line-height: 1.35; + min-height: 1.35em; + text-align: right; + white-space: normal; + overflow: visible; + text-overflow: clip; + max-width: none; +} +.pwi-test-cluster .btn { + order: 1; + flex-shrink: 0; +} +.pwi-test-msg--idle { + visibility: hidden; + /* keep a short invisible placeholder so the cluster width doesn’t jump wildly */ + min-width: 0; +} +.pwi-test-msg--ok { color: var(--green); visibility: visible; } +.pwi-test-msg--err { color: var(--red, #c44); visibility: visible; } +@keyframes pwi-spin { to { transform: rotate(360deg); } } +.pwi-spin { + animation: pwi-spin 0.8s linear infinite; +} + +/* Detail pane: head + tabs fixed; only .providers-workspace-tab-content scrolls */ +.providers-workspace-detail { + min-height: 0; + overflow: hidden; /* never scroll the whole detail (would drag tabs with it) */ + display: flex; + flex-direction: column; +} +.pwi-settings-form { + display: flex; + flex-direction: column; + gap: 12px; + padding: 14px 16px 16px; +} +.pwi-settings-field { + display: flex; + flex-direction: column; + gap: 5px; +} +.pwi-settings-label { + font-size: 12px; + font-weight: 600; + color: var(--muted); +} +.pwi-settings-hint { + font-size: 12px; + line-height: 1.4; +} +.pwi-settings-actions { + display: flex; + align-items: center; + gap: 12px; + padding-top: 4px; } +.pwi-settings-msg { font-size: 12.5px; font-weight: 500; } +.pwi-settings-msg--ok { color: var(--green); } +.pwi-settings-msg--err { color: var(--red, #c44); } +.pwi-rail-add-btn { + border: 1px solid var(--border); + border-radius: 999px; + font-size: 12px; + font-weight: 550; + padding: 5px 10px; + gap: 5px; +} + +/* Status badge: defined with detail-head (pill). Do not redeclare flat styles here. */ /* ---- Kebab menu ---- */ .pwi-kebab-wrap { @@ -980,7 +1346,30 @@ gap: 16px; padding: 0; min-height: 100%; - align-items: start; + align-items: stretch; +} +/* Keep connection + stats columns top-aligned and equal stretch */ +.pwi-overview-main { + display: flex; + flex-direction: column; + gap: 14px; + min-width: 0; + min-height: 0; +} +.pwi-overview-main > .providers-workspace-section:first-child { + /* Connection card: grow so it matches stats column height when short */ + flex: 0 0 auto; +} +.pwi-stats-column { + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; + align-self: stretch; + height: 100%; +} +.pwi-stats-sidebar { + flex: 1 1 auto; } .pwi-overview-tab { display: flex; @@ -1010,19 +1399,22 @@ .pwi-connection-cell { display: flex; flex-direction: column; - gap: 3px; - padding: 10px 14px; - min-height: 56px; + gap: 4px; + padding: 12px 14px; + min-height: 60px; border-bottom: 1px solid var(--border-soft); border-right: 1px solid var(--border-soft); } +/* 3-col: right edge cells */ .pwi-connection-cell:nth-child(3n) { border-right: none; } -.pwi-connection-cell:nth-last-child(-n+2) { +/* 5 cells: row2 has Auth + Default model — clear bottom on both + no right on last of row */ +.pwi-connection-cell:nth-child(4), +.pwi-connection-cell:nth-child(5) { border-bottom: none; } -.pwi-connection-cell:last-child { +.pwi-connection-cell:nth-child(5) { border-right: none; } .pwi-cell-label { @@ -1085,22 +1477,33 @@ .pwi-qa-tile { display: flex; flex-direction: column; - gap: 4px; - padding: 12px 13px; - background: var(--raised); - border: 1px solid var(--border-soft); - border-radius: 6px; + gap: 5px; + padding: 14px 13px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; font: inherit; color: var(--text); text-decoration: none; cursor: pointer; text-align: left; - transition: background 0.12s; + transition: background 0.12s, border-color 0.12s; align-items: flex-start; } .pwi-qa-tile:hover { background: var(--hover); - border-color: var(--border); + border-color: var(--accent-ring); +} +.pwi-qa-tile > svg { + width: 18px; + height: 18px; + padding: 7px; + box-sizing: content-box; + border-radius: 9px; + background: var(--raised); + border: 1px solid var(--border-soft); + margin-bottom: 2px; + color: var(--text); } .pwi-qa-label { font-size: 13px; @@ -1192,11 +1595,11 @@ gap: 6px; } .pwi-stats-notes-head { - font-size: 12px; - font-weight: 600; + font-size: 11.5px; + font-weight: 650; color: var(--muted); text-transform: uppercase; - letter-spacing: 0.04em; + letter-spacing: 0.05em; } .pwi-stats-notes-body { font-size: 12.5px; @@ -1212,7 +1615,7 @@ .pwi-overview-section { background: var(--surface); border: 1px solid var(--border); - border-radius: 6px; + border-radius: 10px; overflow: hidden; } .pwi-overview-section-head { @@ -1226,29 +1629,41 @@ /* ---- Overview counter cards ---- */ .pwi-summary-ready .providers-workspace-summary-value { color: var(--green); } .pwi-summary-setup .providers-workspace-summary-value { color: var(--amber); } +.pwi-summary-disabled .providers-workspace-summary-value { color: var(--muted); } /* ---- Overview quick actions ---- */ .pwi-oa-grid { display: grid; grid-template-columns: repeat(3, 1fr); - gap: 10px; + gap: 12px; } .pwi-oa-tile { display: flex; flex-direction: column; - gap: 5px; - padding: 14px; + gap: 6px; + padding: 16px; background: var(--surface); border: 1px solid var(--border); - border-radius: 6px; + border-radius: 10px; font: inherit; cursor: pointer; text-align: left; - transition: background 0.12s; + transition: background 0.12s, border-color 0.12s; align-items: flex-start; color: var(--text); } .pwi-oa-tile:hover { background: var(--hover); border-color: var(--accent-ring); } +.pwi-oa-tile > svg { + width: 20px; + height: 20px; + padding: 8px; + box-sizing: content-box; + border-radius: 10px; + background: var(--raised); + border: 1px solid var(--border-soft); + color: var(--text); + margin-bottom: 2px; +} .pwi-overview-quick-actions { border: none; background: transparent; @@ -1257,9 +1672,12 @@ .pwi-overview-quick-actions .pwi-overview-section-head { padding: 0 0 10px; border-bottom: none; + font-size: 13px; + font-weight: 600; + color: var(--text); } .pwi-oa-label { - font-size: 13px; + font-size: 13.5px; font-weight: 600; } .pwi-oa-desc { @@ -1273,6 +1691,11 @@ display: grid; grid-template-columns: 1fr 1fr; gap: 12px; + align-items: start; +} +/* Single child (e.g. only Recently used) spans full width */ +.pwi-overview-two-col > :only-child { + grid-column: 1 / -1; } /* ---- Attention required ---- */ @@ -1457,7 +1880,8 @@ } /* ---- Responsive updates ---- */ -@media (max-width: 1100px) { +/* Use min+max ranges so narrower breakpoints are not overridden by later max-width rules. */ +@media (min-width: 761px) and (max-width: 1100px) { .providers-workspace-root { grid-template-columns: minmax(210px, 250px) minmax(0, 1fr); } @@ -1468,9 +1892,12 @@ } } -@media (max-width: 960px) { +@media (min-width: 761px) and (max-width: 960px) { .providers-workspace-root { - grid-template-columns: minmax(200px, 230px) minmax(0, 1fr); + grid-template-columns: minmax(190px, 220px) minmax(0, 1fr); + } + .providers-workspace-rail-model-count { + display: none; } .pwi-overview-layout { grid-template-columns: 1fr; @@ -1510,16 +1937,39 @@ .pwi-connection-cell:nth-child(2n) { border-right: none; } + .pwi-connection-cell:nth-child(3n) { + border-right: 1px solid var(--border-soft); + } + .pwi-connection-cell:nth-child(4), + .pwi-connection-cell:nth-child(5) { + border-bottom: 1px solid var(--border-soft); + } + .pwi-connection-cell:last-child { + border-bottom: none; + } .pwi-qa-grid { grid-template-columns: 1fr 1fr; } .pwi-oa-grid { grid-template-columns: 1fr 1fr; } + .providers-workspace-detail-head { + flex-wrap: wrap; + } + .providers-workspace-detail-actions { + width: 100%; + margin-left: 0; + justify-content: flex-start; + padding-left: 66px; /* align under title, past icon */ + } + .pwi-enabled-toggle { + margin-left: auto; + border-left: none; + padding-left: 0; + } .providers-workspace-tabs { - overflow-x: auto; + overflow: hidden; flex-wrap: nowrap; - -webkit-overflow-scrolling: touch; } .providers-workspace-tab { flex: 0 0 auto; @@ -1534,28 +1984,38 @@ grid-template-columns: 1fr; } .pwi-connection-cell { - border-right: none; + border-right: none !important; } - .pwi-connection-cell:nth-last-child(-n+2) { + .pwi-connection-cell:nth-child(4), + .pwi-connection-cell:nth-child(5) { border-bottom: 1px solid var(--border-soft); } .pwi-connection-cell:last-child { border-bottom: none; } - .pwi-qa-tile, - .pwi-oa-tile { - border-right: none; - border-bottom: 1px solid var(--border-soft); + .providers-workspace-summary-row { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; } - .pwi-qa-tile:last-child, - .pwi-oa-tile:last-child { - border-bottom: none; + .providers-workspace-summary-card { + padding: 12px 10px; } - .providers-workspace-summary-row { - grid-template-columns: 1fr; + .providers-workspace-summary-value { + font-size: 24px; } .providers-workspace-detail-actions { + padding-left: 0; width: 100%; justify-content: flex-start; } + .pwi-enabled-toggle { + margin-left: 0; + } + .providers-workspace-tabs { + flex-wrap: nowrap; + overflow: hidden; /* still no scrollbar on the tab strip */ + } + .providers-workspace-tab { + flex: 0 0 auto; + } } diff --git a/src/server/management-api.ts b/src/server/management-api.ts index 55b78b94..a53293c6 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -447,17 +447,136 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon if (url.pathname === "/api/providers" && req.method === "PATCH") { const name = url.searchParams.get("name")?.trim(); if (!name || !isValidProviderName(name) || !hasOwnProvider(config.providers, name)) return jsonResponse({ error: "unknown provider" }, 404); - let body: { disabled?: unknown }; + let body: { + disabled?: unknown; + adapter?: unknown; + baseUrl?: unknown; + defaultModel?: unknown; + apiKey?: unknown; + authMode?: unknown; + note?: unknown; + }; try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); } - if (typeof body.disabled !== "boolean") return jsonResponse({ error: "disabled boolean is required" }, 400); - if (body.disabled && name === config.defaultProvider) { - return jsonResponse({ error: "cannot disable the default provider; set another default first" }, 400); + + const next: OcxProviderConfig = { ...config.providers[name]! }; + let touched = false; + + if ("disabled" in body) { + if (typeof body.disabled !== "boolean") return jsonResponse({ error: "disabled must be a boolean" }, 400); + if (body.disabled && name === config.defaultProvider) { + return jsonResponse({ error: "cannot disable the default provider; set another default first" }, 400); + } + next.disabled = body.disabled; + touched = true; + } + if (typeof body.adapter === "string" && body.adapter.trim()) { + next.adapter = body.adapter.trim(); + touched = true; + } + if (typeof body.baseUrl === "string" && body.baseUrl.trim()) { + next.baseUrl = body.baseUrl.trim(); + touched = true; + } + if (typeof body.defaultModel === "string") { + const dm = body.defaultModel.trim(); + if (dm) next.defaultModel = dm; + else delete next.defaultModel; + touched = true; + } + if (typeof body.apiKey === "string") { + // Non-empty replaces the key; empty leaves existing key unchanged (never round-trip secrets). + if (body.apiKey.trim()) { + next.apiKey = body.apiKey.trim(); + touched = true; + } + } + if (typeof body.authMode === "string") { + const mode = body.authMode.trim(); + if (mode === "key" || mode === "forward" || mode === "oauth" || mode === "local") { + next.authMode = mode; + touched = true; + } else if (mode === "") { + delete next.authMode; + touched = true; + } else { + return jsonResponse({ error: "authMode must be key, forward, oauth, or local" }, 400); + } + } + if (typeof body.note === "string") { + const note = body.note.trim(); + if (note) next.note = note; + else delete next.note; + touched = true; } + + if (!touched) return jsonResponse({ error: "no recognized fields to update" }, 400); + + const providerError = providerManagementConfigError(name, next); + if (providerError) return jsonResponse({ error: providerError }, 400); + const resolvedError = await providerDestinationResolvedError(name, next); + if (resolvedError) return jsonResponse({ error: resolvedError }, 400); + const { saveConfig: save } = await import("../config"); - config.providers[name] = { ...config.providers[name], disabled: body.disabled }; + config.providers[name] = stripRegistryOnlyStaticHeaders(name, next); save(config); + const { clearModelCache } = await import("../codex/model-cache"); + clearModelCache(name); await refreshCodexCatalogBestEffort(); - return jsonResponse({ success: true, name, disabled: body.disabled }); + return jsonResponse({ + success: true, + name, + disabled: config.providers[name]!.disabled === true, + hasApiKey: !!config.providers[name]!.apiKey, + }); + } + + // Lightweight connectivity probe: clear the model cache for this provider and re-resolve + // models. Inspired by gateway "test connection" flows (probe, measure latency, report) — + // uses our existing catalog path rather than inventing a separate health wire format. + if (url.pathname === "/api/providers/test" && req.method === "POST") { + const name = url.searchParams.get("name")?.trim(); + if (!name || !isValidProviderName(name) || !hasOwnProvider(config.providers, name)) { + return jsonResponse({ error: "unknown provider" }, 404); + } + const prov = config.providers[name]!; + if (prov.disabled) { + return jsonResponse({ ok: false, error: "Provider is disabled", latencyMs: 0 }, 200); + } + const { clearModelCache } = await import("../codex/model-cache"); + clearModelCache(name); + const started = Date.now(); + try { + const models = (await fetchAllModels(config)).filter(m => m.provider === name); + const latencyMs = Date.now() - started; + if (prov.authMode === "forward") { + return jsonResponse({ + ok: true, + latencyMs, + models: models.length, + message: "Passthrough provider is configured (forwards your Codex login; no upstream /models).", + }); + } + if (models.length === 0) { + return jsonResponse({ + ok: false, + latencyMs, + models: 0, + error: "No models returned. Check base URL, adapter, and credentials.", + }); + } + return jsonResponse({ + ok: true, + latencyMs, + models: models.length, + message: `Connected — ${models.length} model${models.length === 1 ? "" : "s"} available.`, + }); + } catch (err) { + return jsonResponse({ + ok: false, + latencyMs: Date.now() - started, + error: err instanceof Error ? err.message : "Connection test failed", + }); + } } if (url.pathname === "/api/providers" && req.method === "DELETE") { diff --git a/tests/provider-workspace-data.test.ts b/tests/provider-workspace-data.test.ts index c5cce581..ce6e49b3 100644 --- a/tests/provider-workspace-data.test.ts +++ b/tests/provider-workspace-data.test.ts @@ -10,9 +10,12 @@ import { formatRequestCount, formatTokenCount, buildAttentionItems, + sortWorkspaceItems, + isFreeProvider, type ProviderModelCounts, type ProviderUsageTotals, type AttentionItem, + type WorkspaceItem, } from "../gui/src/provider-workspace-data"; // --------------------------------------------------------------------------- @@ -207,6 +210,35 @@ describe("buildProviderWorkspace", () => { // binProviderStatus — pure status derivation (no network) // --------------------------------------------------------------------------- +describe("sortWorkspaceItems + isFreeProvider", () => { + function item(name: string, overrides: Partial = {}): WorkspaceItem { + return { name, adapter: "openai-chat", baseUrl: "https://x", ...overrides }; + } + + test("A-Z / Z-A by name", () => { + const items = [item("zeta"), item("alpha"), item("mid")]; + expect(sortWorkspaceItems(items, "az").map(i => i.name)).toEqual(["alpha", "mid", "zeta"]); + expect(sortWorkspaceItems(items, "za").map(i => i.name)).toEqual(["zeta", "mid", "alpha"]); + }); + + test("free-paid groups free first", () => { + const items = [ + item("paid-a", { hasApiKey: true }), + item("free-b", { keyOptional: true }), + item("paid-c", { hasApiKey: true }), + ]; + expect(sortWorkspaceItems(items, "free-paid").map(i => i.name)).toEqual(["free-b", "paid-a", "paid-c"]); + expect(sortWorkspaceItems(items, "paid-free").map(i => i.name)).toEqual(["paid-a", "paid-c", "free-b"]); + }); + + test("isFreeProvider detects local and keyOptional", () => { + expect(isFreeProvider(prov({ keyOptional: true }))).toBe(true); + expect(isFreeProvider(prov({ authMode: "local" }))).toBe(true); + expect(isFreeProvider(prov({ baseUrl: "http://127.0.0.1:11434/v1" }))).toBe(true); + expect(isFreeProvider(prov({ hasApiKey: true }))).toBe(false); + }); +}); + describe("binProviderStatus", () => { test("disabled provider returns 'disabled'", () => { expect(binProviderStatus(prov({ disabled: true, hasApiKey: true }))).toBe("disabled"); From c8fe9217366c5697c6f6e9d1d1895e68912b6e9d Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:43:32 +0200 Subject: [PATCH 06/50] Improvements --- gui/src/components/AddProviderModal.tsx | 14 +- gui/src/components/ProviderWorkspace.tsx | 430 ++++++++++++++++++++--- gui/src/pages/Providers.tsx | 95 ++++- gui/src/styles-provider-workspace.css | 204 +++++++++++ output/playwright/check-auth-ui.cjs | 23 ++ output/playwright/check-err.cjs | 22 ++ output/playwright/check-titles.cjs | 26 ++ output/playwright/cursor-detail2.png | Bin 0 -> 199580 bytes output/playwright/detail-auth-check.png | Bin 0 -> 211788 bytes 9 files changed, 759 insertions(+), 55 deletions(-) create mode 100644 output/playwright/check-auth-ui.cjs create mode 100644 output/playwright/check-err.cjs create mode 100644 output/playwright/check-titles.cjs create mode 100644 output/playwright/cursor-detail2.png create mode 100644 output/playwright/detail-auth-check.png diff --git a/gui/src/components/AddProviderModal.tsx b/gui/src/components/AddProviderModal.tsx index 504a524c..37158093 100644 --- a/gui/src/components/AddProviderModal.tsx +++ b/gui/src/components/AddProviderModal.tsx @@ -35,16 +35,22 @@ interface FormState { } export default function AddProviderModal({ - apiBase, existingNames, onClose, onAdded, + apiBase, existingNames, onClose, onAdded, initialCustom = false, }: { apiBase: string; existingNames: string[]; onClose: () => void; onAdded: (name: string) => void; + /** Skip catalog picker and open the custom-provider form immediately. */ + initialCustom?: boolean; }) { const [query, setQuery] = useState(""); - const [preset, setPreset] = useState(null); - const [form, setForm] = useState(null); + const [preset, setPreset] = useState(initialCustom ? FALLBACK_PRESETS[0]! : null); + const [form, setForm] = useState( + initialCustom + ? { name: "", adapter: "openai-chat", baseUrl: "", authMode: "key", apiKey: "", defaultModel: "" } + : null, + ); const [saving, setSaving] = useState(false); const [error, setError] = useState(""); const [oauthSupported, setOauthSupported] = useState([]); @@ -54,7 +60,7 @@ export default function AddProviderModal({ const searchRef = useRef(null); const aliveRef = useRef(true); - useEffect(() => { searchRef.current?.focus(); }, []); + useEffect(() => { if (!initialCustom) searchRef.current?.focus(); }, [initialCustom]); useEffect(() => () => { aliveRef.current = false; }, []); // stop the OAuth poll if the modal unmounts useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; diff --git a/gui/src/components/ProviderWorkspace.tsx b/gui/src/components/ProviderWorkspace.tsx index 44c6a98f..4fa0b120 100644 --- a/gui/src/components/ProviderWorkspace.tsx +++ b/gui/src/components/ProviderWorkspace.tsx @@ -42,6 +42,7 @@ import { IconChevron, IconExternal, IconActivity, + IconLock, } from "../icons"; import { Switch } from "../ui"; @@ -49,13 +50,48 @@ import { Switch } from "../ui"; // Public API // --------------------------------------------------------------------------- +export type OAuthAccountRow = { + id: string; + email?: string; + active: boolean; + needsReauth?: boolean; +}; + +export type ApiKeyRow = { + id: string; + label?: string; + masked: string; + active: boolean; +}; + +export type LoginHint = { + provider: string; + url?: string; + instructions?: string; +}; + +export interface ProviderAuthHandlers { + onLogin: (provider: string, addAccount?: boolean) => void; + onLogout: (provider: string) => void; + onSwitchAccount: (provider: string, account: OAuthAccountRow) => void; + onRemoveAccount: (provider: string, account: OAuthAccountRow) => void; + onAddApiKey: (provider: string, key: string) => Promise; + onSwitchApiKey: (provider: string, entry: ApiKeyRow) => void; + onRemoveApiKey: (provider: string, entry: ApiKeyRow) => void; +} + export interface ProviderWorkspaceProps { /** Provider map as returned from the proxy config API. */ providers: Record; /** Base URL for API calls, e.g. http://localhost:11434 */ apiBase: string; + /** Name of the default routing provider (shows a Default label in the rail). */ + defaultProvider?: string; onAddProvider: () => void; + /** Open the add-provider flow focused on a custom endpoint (not a catalog preset). */ + onAddCustomProvider?: () => void; onUseLegacyView: () => void; + /** Open raw config JSON editor (workspace modal — do not leave workspace). */ onEditConfig: () => void; onSetDisabled: (name: string, disabled: boolean) => void; onRemoveProvider: (name: string) => void; @@ -63,6 +99,15 @@ export interface ProviderWorkspaceProps { onUpdateProvider?: (name: string, patch: ProviderUpdatePatch) => Promise<{ ok: boolean; error?: string }>; quotaReports?: Record; oauthStatus?: Record; + /** OAuth multi-account sets keyed by provider name. */ + accountSets?: Record; + /** API-key pools keyed by provider name. */ + keyPools?: Record; + /** Provider currently running an OAuth browser flow. */ + busyProvider?: string | null; + /** Live login hint (URL / instructions) for the busy OAuth provider. */ + loginHint?: LoginHint | null; + authHandlers?: ProviderAuthHandlers; } export type ProviderUpdatePatch = { @@ -160,10 +205,11 @@ function isLocalProvider(item: WorkspaceProvider): boolean { } } -function RailRow({ item, selected, modelCount, onClick }: { +function RailRow({ item, selected, modelCount, isDefault, onClick }: { item: WorkspaceItem; selected: boolean; modelCount?: number; + isDefault?: boolean; onClick: () => void; }) { const free = isFreeProvider(item); @@ -175,7 +221,7 @@ function RailRow({ item, selected, modelCount, onClick }: { onClick={onClick} role="option" aria-selected={selected} - aria-label={`Select provider ${item.name}, ${statusLabel(item)}${local ? ", local" : free ? ", free" : ", paid"}`} + aria-label={`Select provider ${item.name}, ${statusLabel(item)}${isDefault ? ", default" : ""}${local ? ", local" : free ? ", free" : ""}`} > {item.name} - {/* Only label exceptions (Local / Free). Paid is the default — no badge. */} + {isDefault && ( + Default + )} + {/* Only label exceptions (Local / Free). Paid is the unmarked default. */} {local ? ( Local ) : free ? ( @@ -259,15 +308,7 @@ function ConnectionCard({ item, onEdit, lastCheckedAt }: {
- -
@@ -286,7 +327,7 @@ function QuickActionsCard({ onSelectTab }: { onSelectTab: (tab: Tab) => void }) Quick actions
-
+
onSelectTab("models")} aria-label="Manage models">
@@ -379,6 +429,15 @@ function TabOverview({ item, usageTotals, quotaReport, onSelectTab, lastCheckedA
onSelectTab("settings")} lastCheckedAt={lastCheckedAt ?? quotaReport?.updatedAt} /> +
onSelectTab("usage")} /> @@ -387,6 +446,233 @@ function TabOverview({ item, usageTotals, quotaReport, onSelectTab, lastCheckedA ); } +// --------------------------------------------------------------------------- +// Accounts / API keys (login-logout + multi-key) — new workspace style +// --------------------------------------------------------------------------- + +function AuthAccountsCard({ + item, oauth, accounts = [], keys = [], busy = false, loginHint, authHandlers, +}: { + item: WorkspaceItem; + oauth?: { loggedIn: boolean; email?: string; error?: string }; + accounts?: OAuthAccountRow[]; + keys?: ApiKeyRow[]; + busy?: boolean; + loginHint?: LoginHint | null; + authHandlers?: ProviderAuthHandlers; +}) { + const [addingKey, setAddingKey] = useState(false); + const [newKey, setNewKey] = useState(""); + const [keyBusy, setKeyBusy] = useState(false); + + const mode = (item.authMode ?? "").toLowerCase(); + const isOauth = mode === "oauth"; + const isForward = mode === "forward"; + const isLocal = mode === "local" || isLocalProvider(item); + // Key-auth: explicit key mode, or unspecified mode that is not oauth/forward/local. + const isKeyAuth = mode === "key" || (!isOauth && !isForward && !isLocal) || item.hasApiKey === true; + + // Always show when handlers exist: oauth login, key pool, or at least a configured key/local note. + if (!authHandlers) return null; + if (isForward) return null; // ChatGPT passthrough — no multi-key / multi-account here + if (!isOauth && !isKeyAuth && !isLocal) return null; + + const hintForThis = loginHint?.provider === item.name ? loginHint : null; + + const submitKey = async () => { + const key = newKey.trim(); + if (!key) return; + setKeyBusy(true); + const ok = await authHandlers.onAddApiKey(item.name, key); + setKeyBusy(false); + if (ok) { + setNewKey(""); + setAddingKey(false); + } + }; + + return ( +
+
+ + {isOauth ? "Account login" : "API keys"} + +
+
+ {isOauth && ( + <> +
+
+ + {busy && hintForThis && (hintForThis.url || hintForThis.instructions) && ( +
+ )} + + {accounts.length > 0 && ( +
+ {accounts.map(account => ( +
+ + +
+ ))} + +
+ )} + + )} + + {(isKeyAuth || (!isOauth && item.hasApiKey)) && ( + <> +
+ 0 ? "pwi-auth-dot--ok" : "pwi-auth-dot--off"}`} aria-hidden="true" /> + + {item.hasApiKey || keys.length > 0 + ? (keys.find(k => k.active)?.masked ?? "API key configured") + : "No API key configured"} + +
+ {keys.length > 0 && ( +
+ {keys.map(entry => ( +
+ + +
+ ))} +
+ )} + {addingKey ? ( +
+ setNewKey(e.target.value)} + onKeyDown={e => { + if (e.key === "Enter") void submitKey(); + if (e.key === "Escape") { setAddingKey(false); setNewKey(""); } + }} + /> + + +
+ ) : ( + + )} + + )} +
+
+ ); +} + function TabModels({ item, modelCount, @@ -669,12 +955,13 @@ function TabSettings({ // --------------------------------------------------------------------------- function DetailPanel({ - item, apiBase, onSetDisabled, onRemoveProvider, onDeselect, onUpdateProvider, + item, apiBase, defaultProvider, onSetDisabled, onRemoveProvider, onDeselect, onUpdateProvider, usageTotals, quotaReport, oauth, modelCount, availableModels, selectedModels, - onTestDone, + onTestDone, accounts, keys, busy, loginHint, authHandlers, }: { item: WorkspaceItem; apiBase: string; + defaultProvider?: string; onSetDisabled: (name: string, disabled: boolean) => void; onRemoveProvider: (name: string) => void; onDeselect: () => void; @@ -686,6 +973,11 @@ function DetailPanel({ availableModels: string[]; selectedModels: string[]; onTestDone?: () => void; + accounts?: OAuthAccountRow[]; + keys?: ApiKeyRow[]; + busy?: boolean; + loginHint?: LoginHint | null; + authHandlers?: ProviderAuthHandlers; }) { const [tab, setTab] = useState("overview"); const [testing, setTesting] = useState(false); @@ -741,6 +1033,12 @@ function DetailPanel({ quotaReport={quotaReport} onSelectTab={setTab} lastCheckedAt={lastCheckedAt} + oauth={oauth} + accounts={accounts} + keys={keys} + busy={busy} + loginHint={loginHint} + authHandlers={authHandlers} /> ); case "models": return ( @@ -786,6 +1084,9 @@ function DetailPanel({
{item.name}
+ {defaultProvider === item.name && ( + Default + )} {isLocalProvider(item) ? ( Local ) : isFreeProvider(item) ? ( @@ -867,7 +1168,13 @@ function DetailPanel({ // Empty state // --------------------------------------------------------------------------- -function EmptyState({ onAddProvider }: { onAddProvider: () => void }) { +function EmptyState({ + onAddProvider, + onAddCustomProvider, +}: { + onAddProvider: () => void; + onAddCustomProvider?: () => void; +}) { return (
@@ -877,9 +1184,16 @@ function EmptyState({ onAddProvider }: { onAddProvider: () => void }) {

No providers yet

Get started by connecting your first provider.

- +
+ + {onAddCustomProvider && ( + + )} +
@@ -888,7 +1202,7 @@ function EmptyState({ onAddProvider }: { onAddProvider: () => void }) {

Connect your first provider

- Use cloud APIs, local models, or a compatible custom endpoint to get started. + Pick a catalog provider or wire any OpenAI-compatible endpoint.

- -

@@ -923,11 +1237,12 @@ function EmptyState({ onAddProvider }: { onAddProvider: () => void }) { // --------------------------------------------------------------------------- function OverviewPanel({ - sections, onSelect, onAddProvider, onEditConfig, attentionItems, usageTotals + sections, onSelect, onAddProvider, onAddCustomProvider, onEditConfig, attentionItems, usageTotals }: { sections: WorkspaceSections; onSelect: (name: string) => void; onAddProvider: () => void; + onAddCustomProvider?: () => void; onEditConfig: () => void; attentionItems: AttentionItem[]; usageTotals: Record; @@ -964,17 +1279,22 @@ function OverviewPanel({ - -

@@ -1021,8 +1341,10 @@ function OverviewPanel({ // --------------------------------------------------------------------------- export default function ProviderWorkspace({ - providers, apiBase, onAddProvider, onUseLegacyView: _onUseLegacyView, onEditConfig, + providers, apiBase, defaultProvider, onAddProvider, onAddCustomProvider, + onUseLegacyView: _onUseLegacyView, onEditConfig, onSetDisabled, onRemoveProvider, onUpdateProvider, quotaReports = {}, oauthStatus = {}, + accountSets = {}, keyPools = {}, busyProvider = null, loginHint = null, authHandlers, }: ProviderWorkspaceProps) { void _onUseLegacyView; const [search, setSearch] = useState(""); @@ -1145,18 +1467,28 @@ export default function ProviderWorkspace({ const total = Object.keys(providers).length; - if (total === 0) return ; + if (total === 0) { + return ; + } return (