feat(desktop): multi-transport hosts with relay fallback, card-style services dropdown

- A saved host now keeps every transport its pairing link carried: direct URL
  plus the relay descriptor, with one token for both (the mobile connection
  model). Switching tries the direct leg and falls back to the E2EE tunnel;
  list probes report Connected · Relay when only the tunnel reaches the host;
  relaunch restore picks direct first
- Host switching trusts the dropdown's fresh probe instead of re-probing on
  click (no doubled latency, no transient Unreachable flashes); statuses are
  written once with the final outcome, survive the dropdown closing via a
  last-known cache, and an unprobed host reads Checking — never Unknown
- Open-in-new-window works for relay hosts: a new IPC command boots the local
  UI with the host id injected and the renderer picks the transport; the app
  render holds on the relay restore so the splash shows instead of a transient
  auth screen (10s safety valve)
- Relay host control socket gained protocol-level keepalive: a missed pong
  window terminates and reconnects, so the relay can no longer hold a ghost
  registration that leaves every client tunnel hanging; the desktop relay
  probe also hard-times-out at 8s instead of hanging status flows
- Services dropdown restyled with mobile-style cards: per-provider usage
  cards, per-host instance cards with a selected highlight and a toned
  status line, MCP servers grouped in a card
This commit is contained in:
Bohdan Triapitsyn
2026-07-10 12:24:50 +03:00
parent ba32518b88
commit 51e6ae7e3f
11 changed files with 400 additions and 175 deletions
+10 -9
View File
@@ -480,22 +480,23 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
</div>
) : null}
<div className="py-2">
{rateLimitGroups.map((group, index) => {
{/* One elevated card per provider (same card language as the mobile
usage popover) instead of a flat run of divider-separated rows. */}
<div className="space-y-2 px-3 py-2.5">
{rateLimitGroups.map((group) => {
const providerExpandedFamilies = expandedFamilies[group.providerId] ?? [];
return (
<React.Fragment key={group.providerId}>
{index > 0 ? <div className="mx-4 my-2 border-t border-[var(--interactive-border)]" /> : null}
<div className="flex items-center gap-2 px-4 py-2">
<div key={group.providerId} className="min-w-0 rounded-xl bg-[var(--surface-muted)] p-3">
<div className="flex items-center gap-2 pb-2">
<ProviderLogo providerId={group.providerId} className="h-4 w-4" />
<span className="typography-ui-label font-medium text-foreground">{group.providerName}</span>
</div>
{group.entries.length === 0 && (!group.modelFamilies || group.modelFamilies.length === 0) ? (
<div className="px-4 pb-2">
<span className="typography-ui-label text-muted-foreground">{group.error ?? t('header.services.noRateLimitsReported')}</span>
<div>
<span className="typography-ui-label text-muted-foreground">{group.error ?? t('header.services.noRateLimitsReported')}</span>
</div>
) : (
<div className="space-y-3 px-4 pb-2">
<div className="space-y-3">
{group.entries.map(([label, window]) => {
const displayPercent = quotaDisplayMode === 'remaining' ? window.remainingPercent : window.usedPercent;
const paceInfo = calculatePace(window.usedPercent, window.resetAt, window.windowSeconds, label);
@@ -584,7 +585,7 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
) : null}
</div>
)}
</React.Fragment>
</div>
);
})}
</div>