From 523c357b70d5119d58e1b34fbe48021ec3b53a7c Mon Sep 17 00:00:00 2001 From: Shahaf Antwarg Date: Tue, 1 Sep 2026 12:52:10 +0300 Subject: [PATCH 01/15] feat(desktop): Disconnect confirmation, readiness-gated power state, unified modal chrome --- CHANGELOG.md | 4 + apps/desktop/src/renderer/core/state.ts | 5 +- apps/desktop/src/renderer/global.scss | 57 +++++++++ .../modules/app/connect-badge.test.ts | 31 +++++ .../src/renderer/modules/app/connect-badge.ts | 30 +++++ .../modules/app/disconnect-confirm.test.ts | 42 +++++++ .../modules/app/disconnect-confirm.ts | 35 ++++++ .../desktop/src/renderer/modules/app/float.ts | 5 +- .../src/renderer/modules/app/runtime.ts | 44 +------ .../src/renderer/modules/chat/controller.ts | 28 +++++ .../src/renderer/modules/dashboard/render.ts | 9 +- .../src/renderer/ui/components/VprShell.tsx | 13 ++- .../ui/components/views/CursorAppCard.tsx | 1 - .../views/DisconnectConfirmDialog.module.scss | 110 ++++++++++++++++++ .../views/DisconnectConfirmDialog.tsx | 62 ++++++++++ .../ui/components/views/TelegramBotCard.tsx | 1 - .../ui/components/views/VprHomeView.tsx | 38 +++++- .../components/views/VprToolsView.module.scss | 49 -------- .../ui/components/views/VprToolsView.tsx | 3 - 19 files changed, 462 insertions(+), 105 deletions(-) create mode 100644 apps/desktop/src/renderer/modules/app/connect-badge.test.ts create mode 100644 apps/desktop/src/renderer/modules/app/connect-badge.ts create mode 100644 apps/desktop/src/renderer/modules/app/disconnect-confirm.test.ts create mode 100644 apps/desktop/src/renderer/modules/app/disconnect-confirm.ts create mode 100644 apps/desktop/src/renderer/ui/components/views/DisconnectConfirmDialog.module.scss create mode 100644 apps/desktop/src/renderer/ui/components/views/DisconnectConfirmDialog.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 0396a5203..f0b492241 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ This project uses selective package publishing. Each release entry lists the pub ### Added +- Desktop's Home power button now asks for confirmation before disconnecting, so a mis-click no longer stops routing (and drops connected apps and in-progress chats). The prompt reuses the standard confirm modal and has a "Don't show this again" checkbox that is remembered per install. - Desktop now includes privacy-conscious activation and reliability telemetry for first open, buyer-runtime/DHT/peer readiness, time to first visible model, fixed semantic user actions and each launch's first action, setup completion, coarse deposit milestones and failures, first chat, buyer-eligible model selections and free/paid classification, correlated per-request chat starts/outcomes, discovery failures, and clean/crash lifecycle signals. Events are main-process owned, consume structured runtime status rather than logs, use explicit allowlisted actions rather than frontend autocapture, are strictly allowlisted and bucketed, normalize failures to fixed local taxonomies without sending raw errors, exact prices, or peer IDs, emit model IDs only from a release-owned reviewed registry, briefly flush the final clean-shutdown event, disable PostHog GeoIP enrichment and person profiles, use the normalized public on-chain address as the PostHog identifier plus random session/request IDs, suppress successful periodic discovery captures, migrate existing identities without falsely emitting `app_first_opened`, collapse paired model persistence into one `model_selected` event, avoid overlapping model/peer `user_action` events, remain off in development/source builds without explicit configuration, and can be disabled from Preferences. - Desktop Connected Apps now includes **Claude**: connecting it switches Anthropic's Claude desktop app into its native third-party inference mode, pointed at a new loopback Claude gateway inside AntSeed Desktop (default port 8380, `ANTSEED_CLAUDE_GATEWAY_PORT` to override). The gateway offers Claude's model picker **AntSeed Auto** (the route selected in the desktop, live — changing it applies to running Claude chats) plus the top of the desktop's curated model list under their real names, and forwards chats to the buyer proxy — no API key, no MITM proxy, and the user's normal Claude login, chats, and MCP config stay untouched in the separate 1p profile. Claude's per-chat title-generation requests are recognized as housekeeping, so they no longer appear as phantom conversations in Recent Chats. Because Claude's picker only accepts Anthropic model ids, network models are advertised behind those ids with their real names as display names, and a short note in each chat's system prompt tells the serving model the conversation is delivered over the AntSeed network. Connecting opens Claude (restarting it first when it was already running so it picks up the new profile). On Windows, every known Claude install layout is probed — the classic `%APPDATA%\Claude` installer, MSIX/Store installs under `%LOCALAPPDATA%\Packages\Claude_*`, and the `%LOCALAPPDATA%` variants. Disconnecting (or an enterprise-provisioned third-party profile being present) restores Claude to its usual profile. @@ -16,6 +17,8 @@ This project uses selective package publishing. Each release entry lists the pub ### Fixed +- Desktop no longer shows routing as on before it actually is. The Home power button, hero status, footer status strip, and floating pill lit up as soon as the buyer process was spawned — on first launch and when turning routing back on — even though the local proxy was not yet accepting connections. They now stay in a "Starting..." state until the proxy port answers a reachability probe (re-checked every second during startup), and only then switch to on/Running. +- Desktop's footer status strip no longer reports the network as "Healthy" after routing is stopped — network stats kept their last DHT snapshot, so the strip read "Healthy | Stopped". It now shows "Offline" while the buyer runtime is stopped, and the "Stopped" state is shown in red like other error states. - Desktop telemetry launches now appear as sessions in PostHog. Events carried the launch id only as a custom `session_id` property and as a v4 UUID, so PostHog's Sessions explorer — which keys on `$session_id` and derives the session start from a UUIDv7 timestamp — showed nothing. The launch id is now a UUIDv7 and is sent as both `session_id` and `$session_id`, enabling per-launch analysis (session duration, events per launch, crash rate) without changing anything built on `session_id`. - Phones browsing the website in "Desktop site" mode no longer download desktop installers they can't run. Mobile detection for download CTAs previously relied on viewport width alone, so a phone requesting the desktop site (which widens the layout viewport and, in Samsung Internet, spoofs an `X11; Linux` user agent) was handed the Linux AppImage. The reroute to the `/get-started` flow now also checks touch-only hardware (`pointer: coarse` + `hover: none`) and the UA-CH mobile signal — neither of which desktop-site mode changes — and platform detection treats such devices as unknown, so an installer is never resolved for them. Analytics counts these taps as `get_started` funnel entries instead of download conversions, matching the behavior. Touchscreen laptops keep a fine, hover-capable primary pointer and still get the direct download. - Seller and transport failures returned through the buyer protocol now clearly explain that the selected peer failed, suggest choosing another peer or Auto routing, preserve the seller's original response and status for diagnostics, and identify pinned-peer failures so clients can surface them immediately without retrying the same peer. Buyer-side failures, payment-required responses, and actionable request errors remain unchanged. @@ -28,6 +31,7 @@ This project uses selective package publishing. Each release entry lists the pub ### Changed +- Desktop modals share one lighter header style: no divider under the title, a plain close glyph instead of a boxed button, tighter padding, and the app's card background — applied app-wide (chat confirmations, peer/reputation prompts, signer key dialogs, app settings) rather than only to the Connected Apps modals. The new disconnect confirmation adds an animated power-off badge. - The `download.antseed.com` proxy now reports download telemetry per download instead of per HTTP request. Download managers that fetch an installer as several concurrent byte ranges, and browsers resuming a paused download, previously produced one `download_started`/`download_completed` per range (about 3× inflation for those clients); now only the request covering the first byte starts a download and only the one delivering the last byte finishes it, with other segments logged locally. Every proxy event also carries an `attributed` (1/0) GA4 param saying whether the visitor's browser passed its GA ids — `attributed=0` downloads come from browsers that blocked GA and so never fired the website's `download_vpr` click, which explains why server-side download counts can exceed click counts. - Ox Alpha is no longer part of the desktop's curated free model lineup (first-run default and free-first model lists). diff --git a/apps/desktop/src/renderer/core/state.ts b/apps/desktop/src/renderer/core/state.ts index 175ec4225..80e4d229a 100644 --- a/apps/desktop/src/renderer/core/state.ts +++ b/apps/desktop/src/renderer/core/state.ts @@ -373,6 +373,8 @@ export type RendererUiState = { chatConversations: unknown[]; chatConversationsLoaded: boolean; chatProxyPort: number; + /** Buyer proxy port answered the last reachability probe. */ + chatProxyOnline: boolean; /** Chat opens thin (compact window, no conversation list). The header toggle expands the window to the standard preset and shows the panel. */ chatPanelExpanded: boolean; @@ -467,7 +469,7 @@ export function createInitialUiState(): RendererUiState { daemonState: null, // Runtime display - connectBadge: { tone: 'idle', label: 'Stopped' }, + connectBadge: { tone: 'bad', label: 'Stopped' }, runtimeActivity: { tone: 'idle', message: 'Idle' }, // Logs @@ -552,6 +554,7 @@ export function createInitialUiState(): RendererUiState { chatConversations: [], chatConversationsLoaded: false, chatProxyPort: 0, + chatProxyOnline: false, chatPanelExpanded: false, chatMessages: [], chatStreamingMessage: null, diff --git a/apps/desktop/src/renderer/global.scss b/apps/desktop/src/renderer/global.scss index 1f2258f99..01104b190 100644 --- a/apps/desktop/src/renderer/global.scss +++ b/apps/desktop/src/renderer/global.scss @@ -1269,3 +1269,60 @@ select.form-input { 0%, 80%, 100% { opacity: 0.2; transform: scale(0.8); } 40% { opacity: 1; transform: scale(1); } } + +/* =================================================================== + Shared @antseed/ui modal chrome (all desktop windows) + The Modal primitive ships the dashboard's `--as-*` look (bordered + header, boxed close button, generous padding). Restyle it once here + with the app's tokens so every desktop modal — confirms, settings, + pickers — matches the VPR views without per-dialog overrides. + Selectors are nested under `.as-modal` / `.as-modal-overlay` so they + outrank the primitive's own rules whatever the stylesheet order. + =================================================================== */ + +.as-modal-overlay .as-modal { + border: none; + border-radius: 16px; + background: var(--bg-card); + box-shadow: var(--shadow); +} + +.as-modal .as-modal__header { + align-items: center; + gap: 12px; + padding: 16px 16px 6px; + border-bottom: none; +} + +.as-modal .as-modal__title { + color: var(--text-primary); + font-size: 14px; + font-weight: 600; + letter-spacing: 0; + line-height: 20px; +} + +.as-modal .as-modal__subtitle { + color: var(--text-muted); + font-size: 11px; + line-height: 15px; +} + +.as-modal .as-modal__close { + width: 24px; + height: 24px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--text-muted); +} + +.as-modal .as-modal__close:hover:not(:disabled) { + border-color: transparent; + background: var(--bg-surface); + color: var(--text-primary); +} + +.as-modal .as-modal__body { + padding: 10px 16px 16px; +} diff --git a/apps/desktop/src/renderer/modules/app/connect-badge.test.ts b/apps/desktop/src/renderer/modules/app/connect-badge.test.ts new file mode 100644 index 000000000..4d136e12a --- /dev/null +++ b/apps/desktop/src/renderer/modules/app/connect-badge.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { deriveConnectBadge, isBuyerReady } from './connect-badge'; +import type { RuntimeProcessState } from '../../types/bridge'; + +const running: RuntimeProcessState = { mode: 'connect', running: true, pid: 1, startedAt: 1, lastExitCode: null, lastError: null }; +const stopped: RuntimeProcessState = { mode: 'connect', running: false, pid: null, startedAt: null, lastExitCode: 0, lastError: null }; +const errored: RuntimeProcessState = { ...stopped, lastExitCode: 1, lastError: 'boom' }; + +describe('deriveConnectBadge', () => { + it('is Starting while the process runs but the proxy port is closed', () => { + expect(deriveConnectBadge([running], false)).toEqual({ tone: 'warn', label: 'Starting...' }); + }); + it('is Running once the proxy is reachable', () => { + expect(deriveConnectBadge([running], true)).toEqual({ tone: 'active', label: 'Running' }); + }); + it('is Stopped (bad tone) with no process or a clean exit', () => { + expect(deriveConnectBadge([], false)).toEqual({ tone: 'bad', label: 'Stopped' }); + expect(deriveConnectBadge([stopped], true)).toEqual({ tone: 'bad', label: 'Stopped' }); + }); + it('is Error after a failed exit', () => { + expect(deriveConnectBadge([errored], false)).toEqual({ tone: 'warn', label: 'Error' }); + }); +}); + +describe('isBuyerReady', () => { + it('needs both a running process and a reachable proxy', () => { + expect(isBuyerReady([running], true)).toBe(true); + expect(isBuyerReady([running], false)).toBe(false); + expect(isBuyerReady([stopped], true)).toBe(false); + }); +}); diff --git a/apps/desktop/src/renderer/modules/app/connect-badge.ts b/apps/desktop/src/renderer/modules/app/connect-badge.ts new file mode 100644 index 000000000..aff73e5e1 --- /dev/null +++ b/apps/desktop/src/renderer/modules/app/connect-badge.ts @@ -0,0 +1,30 @@ +import type { BadgeState } from '../../core/state'; +import type { RuntimeProcessState } from '../../types/bridge'; + +/** + * Buyer runtime status badge. `processes[].running` flips true the moment + * the child process is spawned, well before the buyer proxy accepts + * connections — so a "running" process with the proxy port still closed is + * reported as starting, and the UI only lights up once the proxy is reachable. + */ +export function deriveConnectBadge( + processes: readonly RuntimeProcessState[], + proxyOnline: boolean, +): BadgeState { + const connect = processes.find((process) => process.mode === 'connect') ?? null; + if (connect?.running) { + return proxyOnline + ? { tone: 'active', label: 'Running' } + : { tone: 'warn', label: 'Starting...' }; + } + if (connect?.lastError) return { tone: 'warn', label: 'Error' }; + return { tone: 'bad', label: 'Stopped' }; +} + +/** The buyer is actually usable: process running and proxy port reachable. */ +export function isBuyerReady( + processes: readonly RuntimeProcessState[], + proxyOnline: boolean, +): boolean { + return proxyOnline && processes.some((process) => process.mode === 'connect' && process.running === true); +} diff --git a/apps/desktop/src/renderer/modules/app/disconnect-confirm.test.ts b/apps/desktop/src/renderer/modules/app/disconnect-confirm.test.ts new file mode 100644 index 000000000..2b78d8ed3 --- /dev/null +++ b/apps/desktop/src/renderer/modules/app/disconnect-confirm.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { + DISCONNECT_CONFIRM_DISMISSED_KEY, + isDisconnectConfirmDismissed, + persistDisconnectConfirmDismissed, +} from './disconnect-confirm'; + +function memoryStorage(initial: Record = {}) { + const map = new Map(Object.entries(initial)); + return { + getItem: (key: string) => map.get(key) ?? null, + setItem: (key: string, value: string) => { map.set(key, value); }, + map, + }; +} + +describe('disconnect-confirm', () => { + it('is not dismissed by default', () => { + expect(isDisconnectConfirmDismissed(memoryStorage())).toBe(false); + expect(isDisconnectConfirmDismissed(null)).toBe(false); + }); + + it('persists only when the user ticks "don\'t show again"', () => { + const storage = memoryStorage(); + persistDisconnectConfirmDismissed(false, storage); + expect(storage.map.has(DISCONNECT_CONFIRM_DISMISSED_KEY)).toBe(false); + expect(isDisconnectConfirmDismissed(storage)).toBe(false); + + persistDisconnectConfirmDismissed(true, storage); + expect(storage.getItem(DISCONNECT_CONFIRM_DISMISSED_KEY)).toBe('true'); + expect(isDisconnectConfirmDismissed(storage)).toBe(true); + }); + + it('swallows storage errors', () => { + const broken = { + getItem: () => { throw new Error('denied'); }, + setItem: () => { throw new Error('denied'); }, + }; + expect(isDisconnectConfirmDismissed(broken)).toBe(false); + expect(() => persistDisconnectConfirmDismissed(true, broken)).not.toThrow(); + }); +}); diff --git a/apps/desktop/src/renderer/modules/app/disconnect-confirm.ts b/apps/desktop/src/renderer/modules/app/disconnect-confirm.ts new file mode 100644 index 000000000..c5ea59d17 --- /dev/null +++ b/apps/desktop/src/renderer/modules/app/disconnect-confirm.ts @@ -0,0 +1,35 @@ +/** + * "Don't show again" flag for the Home power-button disconnect confirmation, + * persisted per install (localStorage) like the other one-off dialog dismissals. + */ +export const DISCONNECT_CONFIRM_DISMISSED_KEY = 'antseed:disconnectConfirmDismissed'; + +type StorageLike = Pick; + +function defaultStorage(): StorageLike | null { + try { + return typeof window === 'undefined' ? null : window.localStorage; + } catch { + return null; + } +} + +export function isDisconnectConfirmDismissed(storage: StorageLike | null = defaultStorage()): boolean { + try { + return storage?.getItem(DISCONNECT_CONFIRM_DISMISSED_KEY) === 'true'; + } catch { + return false; + } +} + +export function persistDisconnectConfirmDismissed( + dontShowAgain: boolean, + storage: StorageLike | null = defaultStorage(), +): void { + if (!dontShowAgain) return; + try { + storage?.setItem(DISCONNECT_CONFIRM_DISMISSED_KEY, 'true'); + } catch { + // Storage unavailable — the dialog simply shows again next time. + } +} diff --git a/apps/desktop/src/renderer/modules/app/float.ts b/apps/desktop/src/renderer/modules/app/float.ts index fe602dce8..462d96d3a 100644 --- a/apps/desktop/src/renderer/modules/app/float.ts +++ b/apps/desktop/src/renderer/modules/app/float.ts @@ -1,4 +1,5 @@ import type { RendererUiState } from '../../core/state'; +import { isBuyerReady } from './connect-badge'; import { selectHeadlineBalanceUsdc } from '../../core/balance'; import { formatCompactTokens, formatCredits, shortAddress } from '../../core/format'; import { notifyUiStateChanged } from '../../core/store'; @@ -309,9 +310,7 @@ export function initVprFloatModule({ const pinnedSellers = Object.fromEntries( pinnedSellerLabels(uiState.vprRoutableRows, uiState.vprModelPins, models), ); - const runtimeOn = uiState.processes.some( - (process) => process.mode === 'connect' && process.running === true, - ); + const runtimeOn = isBuyerReady(uiState.processes, uiState.chatProxyOnline); return { apps: connected.map((profile) => ({ diff --git a/apps/desktop/src/renderer/modules/app/runtime.ts b/apps/desktop/src/renderer/modules/app/runtime.ts index 67db76a84..6db3545bd 100644 --- a/apps/desktop/src/renderer/modules/app/runtime.ts +++ b/apps/desktop/src/renderer/modules/app/runtime.ts @@ -1,20 +1,13 @@ import type { LogEvent, RuntimeProcessState } from '../../types/bridge'; -import type { RendererUiState, BadgeTone } from '../../core/state'; +import type { RendererUiState } from '../../core/state'; import { appendLogEntry, replaceLogEntries } from '../../core/state'; import { notifyUiStateChanged } from '../../core/store'; -import { formatClock, formatDuration } from '../../core/format'; +import { deriveConnectBadge } from './connect-badge'; type RuntimeModuleOptions = { uiState: RendererUiState; }; -type ProcessBadgeState = 'running' | 'stopped' | 'error'; - -function processBadgeToDisplay(state: ProcessBadgeState): { tone: BadgeTone; label: string } { - if (state === 'running') return { tone: 'active', label: 'Running' }; - if (state === 'error') return { tone: 'warn', label: 'Error' }; - return { tone: 'idle', label: 'Stopped' }; -} export function initRuntimeModule({ uiState }: RuntimeModuleOptions) { function appendLog(entry: LogEvent): void { @@ -42,39 +35,12 @@ export function initRuntimeModule({ uiState }: RuntimeModuleOptions) { return Boolean(proc && proc.running); } - function computeProcessState( - mode: string, - processInfo: RuntimeProcessState | null, - ): { stateText: string; badge: ProcessBadgeState } { - if (!processInfo) { - return { stateText: 'Unknown', badge: 'stopped' }; - } - - if (processInfo.running) { - const uptimeMs = processInfo.startedAt ? Date.now() - processInfo.startedAt : 0; - return { - stateText: `Running (pid=${processInfo.pid ?? 'unknown'}, uptime=${formatDuration(uptimeMs)})`, - badge: 'running', - }; - } - - const segments = ['Stopped']; - if (processInfo.lastExitCode !== null) { - segments.push(`exit=${processInfo.lastExitCode}`); - } - if (processInfo.lastError) { - segments.push(`error=${processInfo.lastError}`); - } - - const badge: ProcessBadgeState = processInfo.lastError ? 'error' : 'stopped'; - return { stateText: segments.join(' | '), badge }; - } - function renderProcesses(processes: RuntimeProcessState[]): void { uiState.processes = Array.isArray(processes) ? processes : []; - const connect = computeProcessState('connect', processByMode('connect')); - uiState.connectBadge = processBadgeToDisplay(connect.badge); + // The proxy-reachability probe (chat controller) refines this: a spawned + // process whose proxy port is still closed shows as "Starting...". + uiState.connectBadge = deriveConnectBadge(uiState.processes, uiState.chatProxyOnline); notifyUiStateChanged(); } diff --git a/apps/desktop/src/renderer/modules/chat/controller.ts b/apps/desktop/src/renderer/modules/chat/controller.ts index 98d5356b3..f90daa700 100644 --- a/apps/desktop/src/renderer/modules/chat/controller.ts +++ b/apps/desktop/src/renderer/modules/chat/controller.ts @@ -1,6 +1,7 @@ import type { BadgeTone, DiscoverRow, RendererUiState, VprModelCatalogEntry, VprSelectedModel } from '../../core/state'; import { LOCALHOST_URL } from '../../constants'; import { notifyUiStateChanged, notifyUiStateChangedSync } from '../../core/store'; +import { deriveConnectBadge } from '../app/connect-badge'; import { normalizeDiscoverRow, projectRowsToChatServiceOptions } from '../catalog/discover-rows.js'; import { resolveVprChatOption } from './projection.js'; import { supportsImageEdits, supportsServiceParameter } from '../catalog/model-capabilities.js'; @@ -190,6 +191,7 @@ export function initChatModule({ const CHAT_SERVICE_SELECTION_SEPARATOR = '\u0001'; const CHAT_SERVICE_REFRESH_INTERVAL_MS = 60_000; + const PROXY_STARTUP_PROBE_INTERVAL_MS = 1_000; // Faster retry during first-run setup while no services have been found yet. const CHAT_SERVICE_SETUP_REFRESH_INTERVAL_MS = 2_000; // Last-resort backstop only: the main-process handler bounds itself to @@ -1668,11 +1670,34 @@ export function initChatModule({ // Proxy status // --------------------------------------------------------------------------- + // Re-probe timer used while the runtime process is up but the proxy port + // is not answering yet (startup) — the 5s poll is too slow for the power + // button / status strip to feel responsive. + let proxyStartupProbeTimer: ReturnType | null = null; + + function setProxyOnline(online: boolean): void { + uiState.chatProxyOnline = online; + uiState.connectBadge = deriveConnectBadge(uiState.processes, online); + const connectRunning = uiState.processes.some((process) => process.mode === 'connect' && process.running === true); + if (proxyStartupProbeTimer) { + clearTimeout(proxyStartupProbeTimer); + proxyStartupProbeTimer = null; + } + if (!online && connectRunning) { + proxyStartupProbeTimer = setTimeout(() => { + proxyStartupProbeTimer = null; + void refreshChatProxyStatus(); + }, PROXY_STARTUP_PROBE_INTERVAL_MS); + } + } + async function refreshChatProxyStatus(): Promise { const previousProxyState = proxyState; if (!bridge || !bridge.chatAiGetProxyStatus) { proxyState = 'unknown'; proxyPort = 0; + setProxyOnline(false); + notifyUiStateChanged(); updateStreamingIndicator(); return; } @@ -1685,6 +1710,7 @@ export function initChatModule({ proxyState = 'online'; proxyPort = Number(port) || 0; uiState.chatProxyPort = proxyPort; + setProxyOnline(true); notifyUiStateChanged(); // Proxy just became available — fetch metering stats for active conversation if (activeConversation) { @@ -1701,6 +1727,7 @@ export function initChatModule({ proxyState = 'offline'; proxyPort = 0; uiState.chatProxyPort = 0; + setProxyOnline(false); notifyUiStateChanged(); if (previousProxyState !== 'offline') { setRuntimeActivity('warn', 'Waiting for runtime.'); @@ -1711,6 +1738,7 @@ export function initChatModule({ proxyState = 'offline'; proxyPort = 0; uiState.chatProxyPort = 0; + setProxyOnline(false); notifyUiStateChanged(); if (previousProxyState !== 'offline') { setRuntimeActivity('warn', 'Buyer proxy unreachable; retrying.'); diff --git a/apps/desktop/src/renderer/modules/dashboard/render.ts b/apps/desktop/src/renderer/modules/dashboard/render.ts index 2461794d7..ce98bc50d 100644 --- a/apps/desktop/src/renderer/modules/dashboard/render.ts +++ b/apps/desktop/src/renderer/modules/dashboard/render.ts @@ -228,8 +228,6 @@ export function initDashboardRenderModule({ const peers = normalizedNetwork.peers; const stats = normalizedNetwork.stats; const serviceCount = normalizedNetwork.serviceCount; - const dht = networkHealth(stats, peers.length); - const statusPayload = results.status.ok ? (results.status.data as Record) : null; const dataSourcesPayload = results.dataSources.ok ? (results.dataSources.data as Record | null) : null; const configPayload = results.config.ok ? (results.config.data as Record | null) : null; @@ -240,6 +238,11 @@ export function initDashboardRenderModule({ const daemonDetailsCount = daemonChannelDetails.length; const buyerRuntimeState = isModeRunning('connect') ? 'connected' : 'offline'; + const dht = networkHealth(stats, peers.length); + // Network stats are owned by the main process and keep the last DHT + // snapshot after the buyer runtime stops, so the footer would keep saying + // "Healthy" while disconnected. Report the network as offline instead. + const ovDhtHealth = buyerRuntimeState === 'offline' ? 'Offline' : dht.label; const activeChannels = Math.max( safeNumber(statusPayload?.activeChannels, 0), daemonActiveChannels, @@ -257,7 +260,7 @@ export function initDashboardRenderModule({ // Overview stats uiState.ovNodeState = buyerRuntimeState; uiState.ovPeers = formatInt(peers.length); - uiState.ovDhtHealth = dht.label; + uiState.ovDhtHealth = ovDhtHealth; uiState.ovProxyPort = proxyPort > 0 ? String(proxyPort) : '-'; uiState.ovServiceCount = formatInt(serviceCount); uiState.ovLastScan = formatRelativeTime(stats.lastScanAt); diff --git a/apps/desktop/src/renderer/ui/components/VprShell.tsx b/apps/desktop/src/renderer/ui/components/VprShell.tsx index 0888bef2d..5cb4be2cb 100644 --- a/apps/desktop/src/renderer/ui/components/VprShell.tsx +++ b/apps/desktop/src/renderer/ui/components/VprShell.tsx @@ -46,6 +46,7 @@ export function VprShell({ activeView, onSelectView, onNavigateBack, children }: const snap = useUiSelector((state) => ({ headlineBalanceUsdc: selectHeadlineBalanceUsdc(state), connectBadgeLabel: state.connectBadge.label, + connectBadgeTone: state.connectBadge.tone, networkHealth: state.ovDhtHealth, proxyPort: state.ovProxyPort, peers: state.ovPeers, @@ -155,7 +156,17 @@ export function VprShell({ activeView, onSelectView, onNavigateBack, children }: {snap.networkHealth} - {snap.connectBadgeLabel} + + {snap.connectBadgeLabel} + Port: {snap.proxyPort} diff --git a/apps/desktop/src/renderer/ui/components/views/CursorAppCard.tsx b/apps/desktop/src/renderer/ui/components/views/CursorAppCard.tsx index 63920639c..db23be452 100644 --- a/apps/desktop/src/renderer/ui/components/views/CursorAppCard.tsx +++ b/apps/desktop/src/renderer/ui/components/views/CursorAppCard.tsx @@ -124,7 +124,6 @@ export function CursorAppCard() { )} - className={styles.vprModal} bodyClassName={styles.settingsBody} >
diff --git a/apps/desktop/src/renderer/ui/components/views/DisconnectConfirmDialog.module.scss b/apps/desktop/src/renderer/ui/components/views/DisconnectConfirmDialog.module.scss new file mode 100644 index 000000000..7dccdc970 --- /dev/null +++ b/apps/desktop/src/renderer/ui/components/views/DisconnectConfirmDialog.module.scss @@ -0,0 +1,110 @@ +/* Disconnect confirmation body. Header/panel chrome is the app-wide modal + restyle in global.scss. */ + +.body { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + text-align: center; +} + +/* Animated power-off badge: the icon dims like a light going out while two + rings ripple outward and fade — "signal leaving". */ +.badge { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 64px; + height: 64px; + margin: 32px; + border-radius: 50%; + background: rgba(var(--danger-rgb), 0.1); + color: var(--danger); +} + +.badgeIcon { + position: relative; + z-index: 1; + display: flex; + animation: disconnect-power-dim 2.4s ease-in-out infinite; +} + +.ring { + position: absolute; + inset: 0; + border-radius: 50%; + border: 1.5px solid rgba(var(--danger-rgb), 0.55); + animation: disconnect-ring 2.4s ease-out infinite; + pointer-events: none; +} + +.ringDelayed { + animation-delay: 0.8s; +} + +@keyframes disconnect-ring { + 0% { + transform: scale(0.85); + opacity: 0.7; + } + 100% { + transform: scale(1.55); + opacity: 0; + } +} + +@keyframes disconnect-power-dim { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 55% { + opacity: 0.45; + transform: scale(0.94); + } +} + +@media (prefers-reduced-motion: reduce) { + .badgeIcon, + .ring { + animation: none; + } + .ring { + opacity: 0.35; + transform: scale(1.15); + } +} + +.text { + margin: 0; + max-width: 300px; + font-size: 13px; + line-height: 1.5; + color: var(--text-secondary); +} + +.dontShowRow { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--text-muted); + cursor: pointer; + user-select: none; + + input[type="checkbox"] { + margin: 0; + cursor: pointer; + } +} + +.actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + width: 100%; + margin-top: 6px; +} diff --git a/apps/desktop/src/renderer/ui/components/views/DisconnectConfirmDialog.tsx b/apps/desktop/src/renderer/ui/components/views/DisconnectConfirmDialog.tsx new file mode 100644 index 000000000..cf298b459 --- /dev/null +++ b/apps/desktop/src/renderer/ui/components/views/DisconnectConfirmDialog.tsx @@ -0,0 +1,62 @@ +import { useEffect, useState } from 'react'; +import { HugeiconsIcon } from '@hugeicons/react'; +import { PowerOffIcon } from '@hugeicons/core-free-icons'; +import { Button, Modal } from '@antseed/ui'; +import styles from './DisconnectConfirmDialog.module.scss'; + +type DisconnectConfirmDialogProps = { + visible: boolean; + onConfirm: (dontShowAgain: boolean) => void; + onCancel: () => void; +}; + +/** + * Confirmation shown when the Home power button is used to disconnect. + * Built on the shared Modal + Button (desktop modal chrome comes from + * global.scss). + */ +export function DisconnectConfirmDialog({ visible, onConfirm, onCancel }: DisconnectConfirmDialogProps) { + const [dontShowAgain, setDontShowAgain] = useState(false); + + useEffect(() => { + if (visible) setDontShowAgain(false); + }, [visible]); + + return ( + + +

+ Routing will stop and connected apps lose their AntSeed connection until you turn it + back on. Chats in progress will be interrupted. +

+ +
+ + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/ui/components/views/TelegramBotCard.tsx b/apps/desktop/src/renderer/ui/components/views/TelegramBotCard.tsx index 8cca020e7..2ef6bca3c 100644 --- a/apps/desktop/src/renderer/ui/components/views/TelegramBotCard.tsx +++ b/apps/desktop/src/renderer/ui/components/views/TelegramBotCard.tsx @@ -147,7 +147,6 @@ export function TelegramBotCard() { )} - className={styles.vprModal} bodyClassName={styles.settingsBody} > {showTokenForm ? ( diff --git a/apps/desktop/src/renderer/ui/components/views/VprHomeView.tsx b/apps/desktop/src/renderer/ui/components/views/VprHomeView.tsx index 4221e2f4a..a1906151a 100644 --- a/apps/desktop/src/renderer/ui/components/views/VprHomeView.tsx +++ b/apps/desktop/src/renderer/ui/components/views/VprHomeView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { HugeiconsIcon } from '@hugeicons/react'; import { ArrowDown01Icon, @@ -39,6 +39,9 @@ import { VprModelRowList } from '../vpr/VprModelRows'; import { hasSeenChats, rememberSeenChats, VprRecentChatsCard } from '../vpr/VprRecentChats'; import { conversationRoutedPeerName } from '../../../modules/routing/conversations'; import { formatCompactTokens, VprStatRow, VprStatTile } from '../vpr/VprKit'; +import { DisconnectConfirmDialog } from './DisconnectConfirmDialog'; +import { isBuyerReady } from '../../../modules/app/connect-badge'; +import { isDisconnectConfirmDismissed, persistDisconnectConfirmDismissed } from '../../../modules/app/disconnect-confirm'; import styles from './VprHomeView.module.scss'; import { recordFirstModelShown, recordUserAction } from '../../../modules/telemetry/actions'; @@ -67,6 +70,7 @@ export function VprHomeView({ onSelectView }: Props) { discoverRows: state.vprRoutableRows, defaultProvisional: state.vprDefaultModelProvisional, processes: state.processes, + proxyOnline: state.chatProxyOnline, connectBadge: state.connectBadge, usage: state.creditsBuyerUsage, floatOpen: state.vprFloatOpen, @@ -310,8 +314,29 @@ export function VprHomeView({ onSelectView }: Props) { // Visual-only: with the network unreachable the runtime is still running, // but showing the hero lit would promise routing that can't happen. const networkDown = snap.networkAlert !== 'none'; - const connected = (snap.connectBadge.tone === 'active' || runtimeOn) && !networkDown; - const powerLit = runtimeOn && !networkDown; + // Lit only once the buyer proxy actually answers — `runtimeOn` is true the + // moment the process is spawned, which is too early to promise routing. + const buyerReady = isBuyerReady(snap.processes, snap.proxyOnline); + const connected = buyerReady && !networkDown; + const powerLit = buyerReady && !networkDown; + // Power button off: confirm first unless the user opted out of the prompt. + const [disconnectConfirmOpen, setDisconnectConfirmOpen] = useState(false); + const onPowerClick = useCallback(() => { + if (!runtimeOn) { + void actions.startAll(); + return; + } + if (isDisconnectConfirmDismissed()) { + void actions.stopAll(); + return; + } + setDisconnectConfirmOpen(true); + }, [actions, runtimeOn]); + const confirmDisconnect = useCallback((dontShowAgain: boolean) => { + persistDisconnectConfirmDismissed(dontShowAgain); + setDisconnectConfirmOpen(false); + void actions.stopAll(); + }, [actions]); // The Add Balance banner is a nudge for low balances only — with more than // $5 left it's just noise. Keyed off spendable, not the unreserved slice: an @@ -421,7 +446,7 @@ export function VprHomeView({ onSelectView }: Props) {
); } diff --git a/apps/desktop/src/renderer/ui/components/views/VprToolsView.module.scss b/apps/desktop/src/renderer/ui/components/views/VprToolsView.module.scss index 05ff0397a..f74ef94e6 100644 --- a/apps/desktop/src/renderer/ui/components/views/VprToolsView.module.scss +++ b/apps/desktop/src/renderer/ui/components/views/VprToolsView.module.scss @@ -268,55 +268,6 @@ background: var(--bg-surface); } -/* ---------- VPR-styled modal (app settings / add custom app) ---------- - The shared Modal ships the dashboard's `--as-*` look; these overrides - restyle it with the app's own tokens so it matches the VPR views. */ - -.vprModal { - border: none; - border-radius: 16px; - background: var(--bg-card); - box-shadow: var(--shadow-lg, 0 16px 48px rgba(0, 0, 0, 0.28)); - - :global(.as-modal__header) { - padding: 16px 16px 6px; - border-bottom: none; - } - - :global(.as-modal__title) { - color: var(--text-primary); - font-size: 14px; - font-weight: 600; - letter-spacing: 0; - line-height: 20px; - } - - :global(.as-modal__subtitle) { - color: var(--text-muted); - font-size: 11px; - line-height: 15px; - } - - :global(.as-modal__close) { - width: 24px; - height: 24px; - border: none; - border-radius: 8px; - background: transparent; - color: var(--text-muted); - cursor: pointer; - } - - :global(.as-modal__close):hover { - background: var(--bg-surface); - color: var(--text-primary); - } - - :global(.as-modal__body) { - padding: 10px 16px 16px; - } -} - /* ---------- Per-app settings modal ---------- */ .settingsBody { diff --git a/apps/desktop/src/renderer/ui/components/views/VprToolsView.tsx b/apps/desktop/src/renderer/ui/components/views/VprToolsView.tsx index f4a94e5fa..8df11b929 100644 --- a/apps/desktop/src/renderer/ui/components/views/VprToolsView.tsx +++ b/apps/desktop/src/renderer/ui/components/views/VprToolsView.tsx @@ -752,7 +752,6 @@ export function VprToolsView() { size="sm" title="Connected apps" subtitle="How VPR works with your tools" - className={styles.vprModal} bodyClassName={styles.settingsBody} >
@@ -839,7 +838,6 @@ export function VprToolsView() { ) : null} ) : undefined} - className={styles.vprModal} bodyClassName={styles.settingsBody} > {settingsProfile ? (() => { @@ -1011,7 +1009,6 @@ export function VprToolsView() { : addStep === 1 ? 'Route another app through VPR' : addStep === 2 ? 'Trust the HTTPS certificate' : 'Connect the app'} - className={styles.vprModal} bodyClassName={styles.settingsBody} > {addPane.pane !== 'apps' ? ( From 7f3da1fd2e45b9bd3227b37bf8c266ffcd925b86 Mon Sep 17 00:00:00 2001 From: Shahaf Antwarg Date: Tue, 1 Sep 2026 15:03:04 +0300 Subject: [PATCH 02/15] feat(desktop): Chat-first home composer with model pill, Claude/Cursor in app preview --- CHANGELOG.md | 1 + .../components/views/VprHomeView.module.scss | 132 +++++++++-- .../ui/components/views/VprHomeView.tsx | 220 +++++++++++------- .../components/vpr/VprModelRows.module.scss | 8 + .../ui/components/vpr/VprModelRows.tsx | 9 +- 5 files changed, 276 insertions(+), 94 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0b492241..8a2eb04f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ This project uses selective package publishing. Each release entry lists the pub ### Changed +- Desktop Home now leads with the chat. For users without connected apps, the pinned hero shows a single composer card under the power button — prompt on top, the model picker as a compact pill and the send button in a footer row, the same anatomy as the chat view's input — replacing the separate model card and pill-shaped ask box. The prompt opens pre-filled with "What is AntSeed?" until the first chat exists (placeholder otherwise: "How can I help you today?"), and the send button pulses while that suggestion is untouched. The "Routing to your existing apps or start chatting here" heading is gone; the app list below keeps its own label. Users with connected apps keep the standalone model card. The first-run app list on Home now also shows Claude (connect) and Cursor (set up on the Apps page) after Telegram, ahead of the first catalog apps. - Desktop modals share one lighter header style: no divider under the title, a plain close glyph instead of a boxed button, tighter padding, and the app's card background — applied app-wide (chat confirmations, peer/reputation prompts, signer key dialogs, app settings) rather than only to the Connected Apps modals. The new disconnect confirmation adds an animated power-off badge. - The `download.antseed.com` proxy now reports download telemetry per download instead of per HTTP request. Download managers that fetch an installer as several concurrent byte ranges, and browsers resuming a paused download, previously produced one `download_started`/`download_completed` per range (about 3× inflation for those clients); now only the request covering the first byte starts a download and only the one delivering the last byte finishes it, with other segments logged locally. Every proxy event also carries an `attributed` (1/0) GA4 param saying whether the visitor's browser passed its GA ids — `attributed=0` downloads come from browsers that blocked GA and so never fired the website's `download_vpr` click, which explains why server-side download counts can exceed click counts. - Ox Alpha is no longer part of the desktop's curated free model lineup (first-run default and free-first model lists). diff --git a/apps/desktop/src/renderer/ui/components/views/VprHomeView.module.scss b/apps/desktop/src/renderer/ui/components/views/VprHomeView.module.scss index 423223aef..f247db152 100644 --- a/apps/desktop/src/renderer/ui/components/views/VprHomeView.module.scss +++ b/apps/desktop/src/renderer/ui/components/views/VprHomeView.module.scss @@ -216,7 +216,7 @@ align-items: center; justify-content: space-between; gap: 8px; - padding: 12px 16px; + padding: 9px 16px; border: none; border-top: 1px solid var(--border); border-radius: 0; @@ -642,31 +642,99 @@ gap: 16px; } -.connectHeading { - text-align: center; - font-size: 14px; - font-weight: 400; - line-height: 20px; - color: var(--text-primary); +/* Chat-first hero block: intro line + ask input, above the model card. */ +.heroAsk { + position: relative; + z-index: 1; + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + margin-top: 14px; } -.askForm { + +/* Composer card (chat-first hero): prompt on top, model pill + send in a + footer row — the same anatomy as the chat view's input. */ +.composer { + position: relative; width: 100%; display: flex; - align-items: center; - gap: 8px; - padding: 9px 9px 9px 21px; - border-radius: 28px; + flex-direction: column; + gap: 10px; + padding: 14px 12px 12px 16px; + border-radius: 20px; border: 1px solid var(--border); background: var(--bg-card); + box-shadow: var(--shadow-sm); + transition: border-color 0.12s ease, box-shadow 0.12s ease; } -.askForm:focus-within { +.composer:focus-within { border-color: rgba(var(--brand-rgb), 0.5); } -.askInput { +.composerFooter { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +/* Static wrapper so the menu anchors to the composer card, not the pill. */ +.modelDropdownInline { + position: static; + min-width: 0; flex: 1 1 auto; + display: flex; + justify-content: flex-end; +} + +.modelPill { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; + max-width: 100%; + height: 40px; + padding: 0 4px 0 6px; + border: none; + border-radius: 10px; + background: transparent; + color: var(--text-primary); + font: inherit; + font-size: 14px; + font-weight: 600; + line-height: 20px; + text-align: left; + cursor: pointer; + transition: background 0.12s ease; +} + +.modelPill:hover { + background: var(--bg-hover); +} + +.modelPill:focus-visible { + outline: none; + box-shadow: 0 0 0 2px rgba(var(--brand-rgb), 0.4); +} + +.modelPillName { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.modelPillChevron { + flex: 0 0 auto; + color: var(--text-secondary); +} + +.askInput { + width: 100%; min-width: 0; border: none; background: transparent; @@ -689,7 +757,7 @@ padding: 0; place-items: center; border: none; - border-radius: 50%; + border-radius: 10px; background: rgba(var(--brand-rgb), 0.12); color: var(--brand); cursor: pointer; @@ -705,6 +773,40 @@ opacity: 0.6; } +/* "Ping": while the suggested first prompt is still in the box, the send + button breathes and throws a soft ring so it reads as the thing to press. */ +.askSendPing:not(:disabled) { + background: var(--brand); + color: #fff; + animation: ask-send-ping 1.8s ease-out infinite; +} + +.askSendPing:not(:disabled):hover { + background: var(--brand); +} + +@keyframes ask-send-ping { + 0% { + box-shadow: 0 0 0 0 rgba(var(--brand-rgb), 0.55); + transform: scale(1); + } + 60% { + box-shadow: 0 0 0 12px rgba(var(--brand-rgb), 0); + transform: scale(1.04); + } + 100% { + box-shadow: 0 0 0 0 rgba(var(--brand-rgb), 0); + transform: scale(1); + } +} + +@media (prefers-reduced-motion: reduce) { + .askSendPing:not(:disabled) { + animation: none; + box-shadow: 0 0 0 4px rgba(var(--brand-rgb), 0.25); + } +} + .appsGroup { width: 100%; display: flex; diff --git a/apps/desktop/src/renderer/ui/components/views/VprHomeView.tsx b/apps/desktop/src/renderer/ui/components/views/VprHomeView.tsx index a1906151a..f8b8b4f28 100644 --- a/apps/desktop/src/renderer/ui/components/views/VprHomeView.tsx +++ b/apps/desktop/src/renderer/ui/components/views/VprHomeView.tsx @@ -12,6 +12,7 @@ import { Tick02Icon, } from '@hugeicons/core-free-icons'; import type { VprModelCatalogEntry } from '../../../core/state'; +import type { SystemProxyProfileSummary } from '../../../types/bridge'; import { getUiStateRef } from '../../../core/store'; import { activeProfilesFromRuntimeState } from '../../../modules/routing/tools'; import { pinnedSellerLabel, pinnedSellerLabels } from '../../../modules/catalog/view-models'; @@ -61,6 +62,10 @@ function isFreeEntry(entry: VprModelCatalogEntry | undefined): boolean { return i !== null && o !== null && i <= 0 && o <= 0; } +/* Nudge for first-timers: the chat opens pre-filled with this so the very + first thing to do on Home is press send. */ +const SUGGESTED_FIRST_PROMPT = 'What is AntSeed?'; + export function VprHomeView({ onSelectView }: Props) { const actions = useActions(); const snap = useUiSelector((state) => ({ @@ -89,7 +94,7 @@ export function VprHomeView({ onSelectView }: Props) { const proxyState = proxyResource.data?.state ?? null; const conversations = conversationsResource.data; const [expectChats] = useState(hasSeenChats); - const [draft, setDraft] = useState(''); + const [draft, setDraft] = useState(() => (hasSeenChats() ? '' : SUGGESTED_FIRST_PROMPT)); const [addBalanceDismissed, setAddBalanceDismissed] = useState(() => { try { return localStorage.getItem(ADD_BALANCE_DISMISSED_KEY) === '1'; @@ -145,9 +150,18 @@ export function VprHomeView({ onSelectView }: Props) { () => profiles.filter((profile) => activeProfiles?.has(profile.name) ?? false), [activeProfiles, profiles], ); - // Match the Apps tab ordering in the first-run home preview: Telegram is - // the special built-in app row, followed by the first catalog profiles. - const homePreviewProfiles = useMemo(() => profiles.slice(0, 2), [profiles]); + // First-run home preview, mirroring the Apps tab: Telegram (built-in), + // Claude + Cursor (the two headline desktop integrations), then the top of + // the catalog. Claude is a proxy profile; Cursor is set up from the Apps + // page (public endpoint), so its row navigates there. + const claudeProfile = useMemo( + () => profiles.find((profile) => profile.name === 'claude-desktop') ?? null, + [profiles], + ); + const homePreviewProfiles = useMemo( + () => profiles.filter((profile) => profile.name !== 'claude-desktop').slice(0, 2), + [profiles], + ); const restartProfiles = useMemo( () => connectedProfiles.filter((profile) => profile.needsRestart), [connectedProfiles], @@ -421,6 +435,59 @@ export function VprHomeView({ onSelectView }: Props) {
) : null; + /* Model picker panel — shared by the composer pill (first-timers) and the + standalone model card (connected-apps variant). */ + const modelMenu = modelMenuOpen ? ( +
+ { + selectModelForNewChats(provider, serviceId); + }} + emptyLabel="No models discovered yet" + frameless + /> + +
+ ) : null; + + const renderProfileRow = (profile: SystemProxyProfileSummary) => ( + + ); + return (
{/* The hero is pinned outside the scroller — only the content below it @@ -460,6 +527,60 @@ export function VprHomeView({ onSelectView }: Props) { : snap.connectBadge.label} + {!hasConnectedApps ? ( + /* Chat comes first: one composer card — prompt on top, model pill + and send below — so the first thing to do is press send. Hidden + once apps are connected; that variant leads with the chats. */ +
+
{ event.preventDefault(); submitDraft(); }} + > + setDraft(event.target.value)} + // The suggestion is meant to be replaced as easily as sent. + onFocus={(event) => { if (event.target.value === SUGGESTED_FIRST_PROMPT) event.target.select(); }} + /> +
+
+ + {modelMenu} +
+ +
+
+
+ ) : (
- {modelMenuOpen && ( -
- { - selectModelForNewChats(provider, serviceId); - }} - emptyLabel="No models discovered yet" - frameless - /> - -
- )} + {modelMenu}
+ )} @@ -629,33 +724,8 @@ export function VprHomeView({ onSelectView }: Props) { ) : ( - /* Ask + routed apps */ + /* Routed apps + recent chats (the ask input lives in the hero) */
-

Routing to your existing apps or start chatting here

- -
{ event.preventDefault(); submitDraft(); }} - > - setDraft(event.target.value)} - /> - -
- {/* The connect pitch is for first-timers — once chats exist the user knows the flow, so only the "More apps" link (below the chats) remains. */} @@ -678,26 +748,20 @@ export function VprHomeView({ onSelectView }: Props) { Set up - {homePreviewProfiles.map((profile) => ( - - ))} + {claudeProfile ? renderProfileRow(claudeProfile) : null} + + {homePreviewProfiles.map(renderProfileRow)}
)} diff --git a/apps/desktop/src/renderer/ui/components/vpr/VprModelRows.module.scss b/apps/desktop/src/renderer/ui/components/vpr/VprModelRows.module.scss index 380315c26..a6b6b4794 100644 --- a/apps/desktop/src/renderer/ui/components/vpr/VprModelRows.module.scss +++ b/apps/desktop/src/renderer/ui/components/vpr/VprModelRows.module.scss @@ -258,6 +258,14 @@ padding: 6px 10px; } +/* Dense: same content as a full row, tighter box — Home model menu. */ +.rowDense { + min-height: 44px; + contain-intrinsic-size: auto 44px; + gap: 6px; + padding: 5px 12px; +} + .rowCompact .discount { font-size: 13px; } diff --git a/apps/desktop/src/renderer/ui/components/vpr/VprModelRows.tsx b/apps/desktop/src/renderer/ui/components/vpr/VprModelRows.tsx index 79b2d8fe0..823078c64 100644 --- a/apps/desktop/src/renderer/ui/components/vpr/VprModelRows.tsx +++ b/apps/desktop/src/renderer/ui/components/vpr/VprModelRows.tsx @@ -35,6 +35,9 @@ export type VprModelRowListProps = { * under the name and the trailing chevron is dropped, so the model name * keeps the full row width instead of truncating after a few characters. */ compact?: boolean; + /** Tighter rows with the full content kept — menus that must fit on screen + * (the Home composer's model menu). */ + dense?: boolean; /** Rows only pick a model instead of drilling into its page (the Home * dropdown, the chat model pickers) — the trailing chevron is dropped so * the row doesn't promise a navigation that never happens. */ @@ -71,13 +74,14 @@ function formatPrice(price: number | null): string { return price === null ? '—' : formatUsdShort(price); } -function ModelRow({ entry, checked, favorite, badge, compact, chevron = true, pinnedPeerLabel, onClick, onConfigure }: { +function ModelRow({ entry, checked, favorite, badge, compact, dense, chevron = true, pinnedPeerLabel, onClick, onConfigure }: { entry: VprModelCatalogEntry; /** Leading checkmark for the currently selected model (Figma "model list" checked state). */ checked?: boolean; favorite?: boolean; badge?: JSX.Element | null; compact?: boolean; + dense?: boolean; /** Trailing right chevron — only for rows that open the model page. */ chevron?: boolean; /** Seller this model is pinned to; replaces the peer count on the meta line. */ @@ -130,6 +134,7 @@ function ModelRow({ entry, checked, favorite, badge, compact, chevron = true, pi className={[ styles.row, compact ? styles.rowCompact : '', + dense ? styles.rowDense : '', ].filter(Boolean).join(' ')} aria-pressed={checked} onClick={onClick} @@ -242,6 +247,7 @@ export function VprModelRowList({ recommendedCount, recommendedLabel, frameless, + dense, compact, selectOnly, pinnedPeerLabels, @@ -287,6 +293,7 @@ export function VprModelRowList({ entry={entry} checked={selected} compact={compact} + dense={dense} chevron={!selectOnly} pinnedPeerLabel={pinned} favorite={favoriteKeys?.has(favoriteModelKey(entry.provider, entry.serviceId))} From beeac14bb5e9bdcab20b853b70bc34b794ad8d64 Mon Sep 17 00:00:00 2001 From: Shahaf Antwarg Date: Tue, 1 Sep 2026 15:09:45 +0300 Subject: [PATCH 03/15] feat(desktop): Home app pills, placeholder-only composer, copy tweaks --- CHANGELOG.md | 2 +- .../components/views/VprHomeView.module.scss | 66 ++++++++----------- .../ui/components/views/VprHomeView.tsx | 61 ++++++++--------- 3 files changed, 54 insertions(+), 75 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a2eb04f7..5f6fbbebc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,7 @@ This project uses selective package publishing. Each release entry lists the pub ### Changed -- Desktop Home now leads with the chat. For users without connected apps, the pinned hero shows a single composer card under the power button — prompt on top, the model picker as a compact pill and the send button in a footer row, the same anatomy as the chat view's input — replacing the separate model card and pill-shaped ask box. The prompt opens pre-filled with "What is AntSeed?" until the first chat exists (placeholder otherwise: "How can I help you today?"), and the send button pulses while that suggestion is untouched. The "Routing to your existing apps or start chatting here" heading is gone; the app list below keeps its own label. Users with connected apps keep the standalone model card. The first-run app list on Home now also shows Claude (connect) and Cursor (set up on the Apps page) after Telegram, ahead of the first catalog apps. +- Desktop Home now leads with the chat. For users without connected apps, the pinned hero shows a single composer card under the power button — prompt on top, the model picker as a compact pill and the send button in a footer row, the same anatomy as the chat view's input — replacing the separate model card and pill-shaped ask box. The prompt reads "How can I help you today?", and until the first chat exists the send button pulses as soon as something is typed. The "Routing to your existing apps or start chatting here" heading is gone; the app list below keeps its own label. Users with connected apps keep the standalone model card. The first-run app list on Home is now a wrapping row of pills — Telegram, Claude (connect), Cursor (set up on the Apps page), then most of the catalog — instead of one line per app, with the rest behind "More apps". - Desktop modals share one lighter header style: no divider under the title, a plain close glyph instead of a boxed button, tighter padding, and the app's card background — applied app-wide (chat confirmations, peer/reputation prompts, signer key dialogs, app settings) rather than only to the Connected Apps modals. The new disconnect confirmation adds an animated power-off badge. - The `download.antseed.com` proxy now reports download telemetry per download instead of per HTTP request. Download managers that fetch an installer as several concurrent byte ranges, and browsers resuming a paused download, previously produced one `download_started`/`download_completed` per range (about 3× inflation for those clients); now only the request covering the first byte starts a download and only the one delivering the last byte finishes it, with other segments logged locally. Every proxy event also carries an `attributed` (1/0) GA4 param saying whether the visitor's browser passed its GA ids — `attributed=0` downloads come from browsers that blocked GA and so never fired the website's `download_vpr` click, which explains why server-side download counts can exceed click counts. - Ox Alpha is no longer part of the desktop's curated free model lineup (first-run default and free-first model lists). diff --git a/apps/desktop/src/renderer/ui/components/views/VprHomeView.module.scss b/apps/desktop/src/renderer/ui/components/views/VprHomeView.module.scss index f247db152..9230f5547 100644 --- a/apps/desktop/src/renderer/ui/components/views/VprHomeView.module.scss +++ b/apps/desktop/src/renderer/ui/components/views/VprHomeView.module.scss @@ -705,7 +705,7 @@ color: var(--text-primary); font: inherit; font-size: 14px; - font-weight: 600; + font-weight: 400; line-height: 20px; text-align: left; cursor: pointer; @@ -773,8 +773,8 @@ opacity: 0.6; } -/* "Ping": while the suggested first prompt is still in the box, the send - button breathes and throws a soft ring so it reads as the thing to press. */ +/* "Ping": once a first-timer has typed something, the send button breathes + and throws a soft ring so it reads as the thing to press. */ .askSendPing:not(:disabled) { background: var(--brand); color: #fff; @@ -827,52 +827,48 @@ .toolList { width: 100%; display: flex; - flex-direction: column; - gap: 4px; + flex-wrap: wrap; + justify-content: center; + gap: 8px; } -.toolRow { - width: 100%; - display: flex; +/* App pill: icon + name; click connects (catalog apps) or opens the Apps page. */ +.toolPill { + display: inline-flex; align-items: center; - justify-content: space-between; - gap: 8px; - padding: 8px 16px 8px 12px; - border: none; - border-radius: 16px; - background: transparent; + gap: 6px; + max-width: 100%; + min-height: 32px; + padding: 5px 12px 5px 9px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--bg-card); font: inherit; cursor: pointer; - transition: background 0.12s ease; + transition: background 0.12s ease, border-color 0.12s ease; } -.toolRow:hover:not(:disabled) { +.toolPill:hover:not(:disabled) { background: var(--bg-hover); + border-color: rgba(var(--brand-rgb), 0.5); } -.toolRow:focus-visible { +.toolPill:focus-visible { outline: none; box-shadow: 0 0 0 2px rgba(var(--brand-rgb), 0.4); } -.toolRow:disabled { +.toolPill:disabled { opacity: 0.6; cursor: default; } -.toolIdentity { - min-width: 0; - display: inline-flex; - align-items: center; - gap: 4px; -} - -/* Real application icon (extracted from the installed app) on app rows. */ +/* Real application icon (extracted from the installed app) on app pills. */ .appIcon { flex: 0 0 auto; - width: 20px; - height: 20px; - border-radius: 5px; + width: 16px; + height: 16px; + border-radius: 4px; object-fit: contain; } @@ -881,17 +877,9 @@ text-overflow: ellipsis; white-space: nowrap; color: var(--text-primary); - font-size: 14px; - font-weight: 500; - line-height: 20px; -} - -.toolConnect { - flex: 0 0 auto; - color: var(--accent-green); - font-size: 14px; + font-size: 13px; font-weight: 500; - line-height: 20px; + line-height: 18px; } .moreApps { diff --git a/apps/desktop/src/renderer/ui/components/views/VprHomeView.tsx b/apps/desktop/src/renderer/ui/components/views/VprHomeView.tsx index f8b8b4f28..a1ae98883 100644 --- a/apps/desktop/src/renderer/ui/components/views/VprHomeView.tsx +++ b/apps/desktop/src/renderer/ui/components/views/VprHomeView.tsx @@ -62,9 +62,8 @@ function isFreeEntry(entry: VprModelCatalogEntry | undefined): boolean { return i !== null && o !== null && i <= 0 && o <= 0; } -/* Nudge for first-timers: the chat opens pre-filled with this so the very - first thing to do on Home is press send. */ -const SUGGESTED_FIRST_PROMPT = 'What is AntSeed?'; +/* Catalog apps shown as pills on Home (plus Telegram, Claude, Cursor). */ +const HOME_APP_PILL_LIMIT = 11; export function VprHomeView({ onSelectView }: Props) { const actions = useActions(); @@ -94,7 +93,7 @@ export function VprHomeView({ onSelectView }: Props) { const proxyState = proxyResource.data?.state ?? null; const conversations = conversationsResource.data; const [expectChats] = useState(hasSeenChats); - const [draft, setDraft] = useState(() => (hasSeenChats() ? '' : SUGGESTED_FIRST_PROMPT)); + const [draft, setDraft] = useState(''); const [addBalanceDismissed, setAddBalanceDismissed] = useState(() => { try { return localStorage.getItem(ADD_BALANCE_DISMISSED_KEY) === '1'; @@ -158,8 +157,10 @@ export function VprHomeView({ onSelectView }: Props) { () => profiles.find((profile) => profile.name === 'claude-desktop') ?? null, [profiles], ); + // Pills wrap, so the preview can afford most of the catalog; the tail and + // custom apps stay behind "More apps". const homePreviewProfiles = useMemo( - () => profiles.filter((profile) => profile.name !== 'claude-desktop').slice(0, 2), + () => profiles.filter((profile) => profile.name !== 'claude-desktop').slice(0, HOME_APP_PILL_LIMIT), [profiles], ); const restartProfiles = useMemo( @@ -467,23 +468,20 @@ export function VprHomeView({ onSelectView }: Props) { ) : null; - const renderProfileRow = (profile: SystemProxyProfileSummary) => ( + const renderProfilePill = (profile: SystemProxyProfileSummary) => ( ); @@ -543,8 +541,6 @@ export function VprHomeView({ onSelectView }: Props) { placeholder="How can I help you today?" aria-label="How can I help you today?" onChange={(event) => setDraft(event.target.value)} - // The suggestion is meant to be replaced as easily as sent. - onFocus={(event) => { if (event.target.value === SUGGESTED_FIRST_PROMPT) event.target.select(); }} />
@@ -570,7 +566,7 @@ export function VprHomeView({ onSelectView }: Props) {
- {claudeProfile ? renderProfileRow(claudeProfile) : null} + {claudeProfile ? renderProfilePill(claudeProfile) : null} - {homePreviewProfiles.map(renderProfileRow)} + {homePreviewProfiles.map(renderProfilePill)}
)} From 9d72b6cb6286c7b1acb9b7b4466b1b0abca96497 Mon Sep 17 00:00:00 2001 From: Shahaf Antwarg Date: Tue, 1 Sep 2026 15:12:05 +0300 Subject: [PATCH 04/15] fix(desktop): Move internet-accessible endpoint row to end of Help VPR card --- .../renderer/ui/components/views/VprHelpView.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/renderer/ui/components/views/VprHelpView.tsx b/apps/desktop/src/renderer/ui/components/views/VprHelpView.tsx index 1ae4aef37..10b7aee5b 100644 --- a/apps/desktop/src/renderer/ui/components/views/VprHelpView.tsx +++ b/apps/desktop/src/renderer/ui/components/views/VprHelpView.tsx @@ -816,13 +816,6 @@ export function VprHelpView({ onSelectView }: Props) {

VPR

- {HELP_TOPICS.map((entry) => ( +
From c200a7fb7e48eb0f8361d5fc26022bf5f5ce100c Mon Sep 17 00:00:00 2001 From: Shahaf Antwarg Date: Tue, 1 Sep 2026 19:10:28 +0300 Subject: [PATCH 05/15] feat(desktop): Open chat wide by default --- CHANGELOG.md | 1 + apps/desktop/src/renderer/core/state.ts | 4 +++- apps/desktop/src/renderer/ui/AppShell.tsx | 4 ++-- apps/desktop/src/shared/view-windows.ts | 6 +++--- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f6fbbebc..0163e4574 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ This project uses selective package publishing. Each release entry lists the pub ### Changed +- Desktop Chat now opens wide by default — the window switches to the standard (wide) preset with the conversation list showing as soon as the Chat view is entered; the in-view toggle still collapses it to the thin panel. - Desktop Home now leads with the chat. For users without connected apps, the pinned hero shows a single composer card under the power button — prompt on top, the model picker as a compact pill and the send button in a footer row, the same anatomy as the chat view's input — replacing the separate model card and pill-shaped ask box. The prompt reads "How can I help you today?", and until the first chat exists the send button pulses as soon as something is typed. The "Routing to your existing apps or start chatting here" heading is gone; the app list below keeps its own label. Users with connected apps keep the standalone model card. The first-run app list on Home is now a wrapping row of pills — Telegram, Claude (connect), Cursor (set up on the Apps page), then most of the catalog — instead of one line per app, with the rest behind "More apps". - Desktop modals share one lighter header style: no divider under the title, a plain close glyph instead of a boxed button, tighter padding, and the app's card background — applied app-wide (chat confirmations, peer/reputation prompts, signer key dialogs, app settings) rather than only to the Connected Apps modals. The new disconnect confirmation adds an animated power-off badge. - The `download.antseed.com` proxy now reports download telemetry per download instead of per HTTP request. Download managers that fetch an installer as several concurrent byte ranges, and browsers resuming a paused download, previously produced one `download_started`/`download_completed` per range (about 3× inflation for those clients); now only the request covering the first byte starts a download and only the one delivering the last byte finishes it, with other segments logged locally. Every proxy event also carries an `attributed` (1/0) GA4 param saying whether the visitor's browser passed its GA ids — `attributed=0` downloads come from browsers that blocked GA and so never fired the website's `download_vpr` click, which explains why server-side download counts can exceed click counts. diff --git a/apps/desktop/src/renderer/core/state.ts b/apps/desktop/src/renderer/core/state.ts index 80e4d229a..3baeac945 100644 --- a/apps/desktop/src/renderer/core/state.ts +++ b/apps/desktop/src/renderer/core/state.ts @@ -555,7 +555,9 @@ export function createInitialUiState(): RendererUiState { chatConversationsLoaded: false, chatProxyPort: 0, chatProxyOnline: false, - chatPanelExpanded: false, + // Chat opens wide (standard preset) with the conversation list showing; + // the in-view toggle collapses it to the thin panel. + chatPanelExpanded: true, chatMessages: [], chatStreamingMessage: null, chatSending: false, diff --git a/apps/desktop/src/renderer/ui/AppShell.tsx b/apps/desktop/src/renderer/ui/AppShell.tsx index 14fe69cb7..9329f2acd 100644 --- a/apps/desktop/src/renderer/ui/AppShell.tsx +++ b/apps/desktop/src/renderer/ui/AppShell.tsx @@ -151,8 +151,8 @@ export function AppShell() { useEffect(() => { if (showSetup) return; - // Chat supports both window sizes: thin by default, expanded to the - // standard preset when the user opens the conversation-list panel. + // Chat supports both window sizes: wide (standard preset, conversation + // list showing) by default, thin when the user collapses the panel. if (activeView === 'chat' && snap.chatPanelExpanded) { void window.antseedDesktop?.applyWindowPreset?.('standard'); return; diff --git a/apps/desktop/src/shared/view-windows.ts b/apps/desktop/src/shared/view-windows.ts index ed6b99649..400964337 100644 --- a/apps/desktop/src/shared/view-windows.ts +++ b/apps/desktop/src/shared/view-windows.ts @@ -19,9 +19,9 @@ export const VIEW_WINDOW_PRESETS = { activity: 'compact', rewards: 'compact', help: 'compact', - // Chat opens thin like the other VPR panels; an in-view expand toggle - // switches the window to the standard (wide) preset, which also reveals - // the conversation-list panel (see AppShell / VprShell chatPanelExpanded). + // Chat's base preset is the thin panel, but the view opens wide by default: + // chatPanelExpanded starts true, and AppShell applies the standard preset + // whenever it is set — the in-view toggle collapses back to this. chat: 'compact', // Diagnostic screens stay in the same compact panel as the VPR views // (they're reachable from Help & Support) — their tables and panels scroll From be315c2bc1fabd3e2c0986861d403e4a2bfd5550 Mon Sep 17 00:00:00 2001 From: Shahaf Antwarg Date: Tue, 1 Sep 2026 19:16:10 +0300 Subject: [PATCH 06/15] feat(desktop): Show usage tiles on home by default, drop more-apps links --- CHANGELOG.md | 3 +- .../components/views/VprHomeView.module.scss | 20 ----- .../ui/components/views/VprHomeView.tsx | 80 +++++++++---------- 3 files changed, 40 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0163e4574..319797afb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,8 +31,9 @@ This project uses selective package publishing. Each release entry lists the pub ### Changed +- Desktop Home shows the Usage tiles (requests, tokens, saving) from the first launch — including for users without connected apps, at zero — instead of only once an app is connected. Saving reads "$0" rather than "-" until there is priced usage. - Desktop Chat now opens wide by default — the window switches to the standard (wide) preset with the conversation list showing as soon as the Chat view is entered; the in-view toggle still collapses it to the thin panel. -- Desktop Home now leads with the chat. For users without connected apps, the pinned hero shows a single composer card under the power button — prompt on top, the model picker as a compact pill and the send button in a footer row, the same anatomy as the chat view's input — replacing the separate model card and pill-shaped ask box. The prompt reads "How can I help you today?", and until the first chat exists the send button pulses as soon as something is typed. The "Routing to your existing apps or start chatting here" heading is gone; the app list below keeps its own label. Users with connected apps keep the standalone model card. The first-run app list on Home is now a wrapping row of pills — Telegram, Claude (connect), Cursor (set up on the Apps page), then most of the catalog — instead of one line per app, with the rest behind "More apps". +- Desktop Home now leads with the chat. For users without connected apps, the pinned hero shows a single composer card under the power button — prompt on top, the model picker as a compact pill and the send button in a footer row, the same anatomy as the chat view's input — replacing the separate model card and pill-shaped ask box. The prompt reads "How can I help you today?", and until the first chat exists the send button pulses as soon as something is typed. The "Routing to your existing apps or start chatting here" heading is gone; the app list below keeps its own label. Users with connected apps keep the standalone model card. The first-run app list on Home is now a wrapping row of pills — Telegram, Claude (connect), Cursor (set up on the Apps page), then most of the catalog — instead of one line per app; the "More apps" / "Connect more apps" links are gone (the full catalog is on the Apps page). - Desktop modals share one lighter header style: no divider under the title, a plain close glyph instead of a boxed button, tighter padding, and the app's card background — applied app-wide (chat confirmations, peer/reputation prompts, signer key dialogs, app settings) rather than only to the Connected Apps modals. The new disconnect confirmation adds an animated power-off badge. - The `download.antseed.com` proxy now reports download telemetry per download instead of per HTTP request. Download managers that fetch an installer as several concurrent byte ranges, and browsers resuming a paused download, previously produced one `download_started`/`download_completed` per range (about 3× inflation for those clients); now only the request covering the first byte starts a download and only the one delivering the last byte finishes it, with other segments logged locally. Every proxy event also carries an `attributed` (1/0) GA4 param saying whether the visitor's browser passed its GA ids — `attributed=0` downloads come from browsers that blocked GA and so never fired the website's `download_vpr` click, which explains why server-side download counts can exceed click counts. - Ox Alpha is no longer part of the desktop's curated free model lineup (first-run default and free-first model lists). diff --git a/apps/desktop/src/renderer/ui/components/views/VprHomeView.module.scss b/apps/desktop/src/renderer/ui/components/views/VprHomeView.module.scss index 9230f5547..f3bc3bb4e 100644 --- a/apps/desktop/src/renderer/ui/components/views/VprHomeView.module.scss +++ b/apps/desktop/src/renderer/ui/components/views/VprHomeView.module.scss @@ -882,24 +882,4 @@ line-height: 18px; } -.moreApps { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 0; - border: none; - background: transparent; - color: var(--accent-green); - font-size: 14px; - font-weight: 500; - line-height: 20px; - text-decoration: underline; - text-underline-offset: 2px; - cursor: pointer; -} - -.moreApps:hover { - color: var(--brand); -} - /* Recent chats card moved to the shared VprRecentChats component. */ diff --git a/apps/desktop/src/renderer/ui/components/views/VprHomeView.tsx b/apps/desktop/src/renderer/ui/components/views/VprHomeView.tsx index a1ae98883..8369efac7 100644 --- a/apps/desktop/src/renderer/ui/components/views/VprHomeView.tsx +++ b/apps/desktop/src/renderer/ui/components/views/VprHomeView.tsx @@ -158,7 +158,7 @@ export function VprHomeView({ onSelectView }: Props) { [profiles], ); // Pills wrap, so the preview can afford most of the catalog; the tail and - // custom apps stay behind "More apps". + // custom apps live on the Apps page. const homePreviewProfiles = useMemo( () => profiles.filter((profile) => profile.name !== 'claude-desktop').slice(0, HOME_APP_PILL_LIMIT), [profiles], @@ -486,6 +486,38 @@ export function VprHomeView({ onSelectView }: Props) { ); + /* Usage tiles — shown on both Home variants, zeros included, so the + module is there from the first launch. */ + const usageTiles = ( +
+

Usage

+ + + + 0 + ? ( + + {measuredSavings + ? `${formatSavedUsd(measuredSavings.baselineUsd - measuredSavings.actualUsd)}` + : `${expectedSavingsPct}%`} + + ) + : formatSavedUsd(0)} + /> + +
+ ); + return (
{/* The hero is pinned outside the scroller — only the content below it @@ -663,11 +695,6 @@ export function VprHomeView({ onSelectView }: Props) { {recentChats} - - {showAddBalance && !snap.reminderOffer ? (
) : ( /* Routed apps + recent chats (the ask input lives in the hero) */
{/* The connect pitch is for first-timers — once chats exist the - user knows the flow, so only the "More apps" link (below the - chats) remains. */} + user knows the flow (the full catalog lives on the Apps page). */} {(conversations === null ? !expectChats : conversations.length === 0) && (

Use AntSeed on your favorite app

{/* Same lead items as the Apps tab (Telegram, Claude, Cursor), then - the catalog — the full list lives on the Apps page behind - "More apps". */} + the catalog — the full list lives on the Apps page. */}
)}
From 8ba983c5bce5b43d5df47cebce3271f8e9bebd4c Mon Sep 17 00:00:00 2001 From: Shahaf Antwarg Date: Tue, 1 Sep 2026 22:16:52 +0300 Subject: [PATCH 07/15] feat(desktop): Setup screen walkthrough with animated mini-app preview --- CHANGELOG.md | 1 + apps/desktop/src/main/app-context.ts | 10 +- apps/desktop/src/renderer/ui/AppShell.tsx | 7 +- .../ui/components/SetupAppPreview.module.scss | 467 ++++++++++++++++++ .../ui/components/SetupAppPreview.tsx | 155 ++++++ .../ui/components/SetupScreen.module.scss | 171 +++++-- .../renderer/ui/components/SetupScreen.tsx | 90 ++-- 7 files changed, 811 insertions(+), 90 deletions(-) create mode 100644 apps/desktop/src/renderer/ui/components/SetupAppPreview.module.scss create mode 100644 apps/desktop/src/renderer/ui/components/SetupAppPreview.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 319797afb..6468db34e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ This project uses selective package publishing. Each release entry lists the pub ### Changed +- Desktop first-run setup screen now leads with what the user gets rather than raw network tallies: a drifting marquee of the model families on the network, and a miniature VPR window at the app's real proportions that plays as a walkthrough-loader — cycling Home (composer, app pills, usage tiles with the saving highlighted), Chat ("What is AntSeed?" asked and answered), Models (free and paid rows with price chips), and Connected apps (toggles flipping on for Claude, ChatGPT, Cursor, OpenCode, Telegram), with the left nav highlight following the scenes and a caption line narrating each one. The walkthrough content is fixed, so first-run users see it in full before discovery finds anything; the node/seller/model counts in the footer remain live. The headline text is gone; the bottom note now reads "Setting up — only on first run, takes 2 minutes max." The node/seller/model/free counts and the current-activity line remain, smaller, at the bottom of the screen. - Desktop Home shows the Usage tiles (requests, tokens, saving) from the first launch — including for users without connected apps, at zero — instead of only once an app is connected. Saving reads "$0" rather than "-" until there is priced usage. - Desktop Chat now opens wide by default — the window switches to the standard (wide) preset with the conversation list showing as soon as the Chat view is entered; the in-view toggle still collapses it to the thin panel. - Desktop Home now leads with the chat. For users without connected apps, the pinned hero shows a single composer card under the power button — prompt on top, the model picker as a compact pill and the send button in a footer row, the same anatomy as the chat view's input — replacing the separate model card and pill-shaped ask box. The prompt reads "How can I help you today?", and until the first chat exists the send button pulses as soon as something is typed. The "Routing to your existing apps or start chatting here" heading is gone; the app list below keeps its own label. Users with connected apps keep the standalone model card. The first-run app list on Home is now a wrapping row of pills — Telegram, Claude (connect), Cursor (set up on the Apps page), then most of the catalog — instead of one line per app; the "More apps" / "Connect more apps" links are gone (the full catalog is on the Apps page). diff --git a/apps/desktop/src/main/app-context.ts b/apps/desktop/src/main/app-context.ts index aaf558980..ee6beea7e 100644 --- a/apps/desktop/src/main/app-context.ts +++ b/apps/desktop/src/main/app-context.ts @@ -13,7 +13,15 @@ import { desktopInstanceName, desktopUserDataDir, isMultiInstanceDevelopment } f import { ASSETS_DIR, RENDERER_INDEX_PATH } from './paths.js'; export const isDev = Boolean(process.env['VITE_DEV_SERVER_URL']); -export const rendererUrl = process.env['VITE_DEV_SERVER_URL'] ?? `file://${RENDERER_INDEX_PATH}`; +/* Dev-only: ANTSEED_DESKTOP_FORCE_SETUP=1 keeps the first-run setup screen + on screen regardless of setup state, for styling it (AppShell reads the + query flag only in dev builds). */ +const forceSetupQuery = process.env['VITE_DEV_SERVER_URL'] && process.env['ANTSEED_DESKTOP_FORCE_SETUP'] === '1' + ? '?forceSetup=1' + : ''; +export const rendererUrl = process.env['VITE_DEV_SERVER_URL'] + ? `${process.env['VITE_DEV_SERVER_URL']}${forceSetupQuery}` + : `file://${RENDERER_INDEX_PATH}`; export const INTERNAL_APP_NAME = 'AntStation Desktop'; const devInstanceName = isMultiInstanceDevelopment() ? desktopInstanceName() : ''; diff --git a/apps/desktop/src/renderer/ui/AppShell.tsx b/apps/desktop/src/renderer/ui/AppShell.tsx index 9329f2acd..f9c9cee7b 100644 --- a/apps/desktop/src/renderer/ui/AppShell.tsx +++ b/apps/desktop/src/renderer/ui/AppShell.tsx @@ -130,7 +130,12 @@ export function AppShell() { } }, [snap.appSetupStatusKnown, snap.appSetupNeeded, snap.appSetupComplete, snap.freeDefaultReady, hasServices, setupDismissed]); - const showSetup = setupVisible; + // Dev-only preview hook: `ANTSEED_DESKTOP_FORCE_SETUP=1 pnpm run dev` pins + // the setup screen so it can be styled without a fresh profile. + const forceSetup = import.meta.env.DEV + && typeof window !== 'undefined' + && new URLSearchParams(window.location.search).get('forceSetup') === '1'; + const showSetup = setupVisible || forceSetup; useEffect(() => { return window.antseedDesktop?.onNavigateView?.((viewName) => { diff --git a/apps/desktop/src/renderer/ui/components/SetupAppPreview.module.scss b/apps/desktop/src/renderer/ui/components/SetupAppPreview.module.scss new file mode 100644 index 000000000..d80f8ee5f --- /dev/null +++ b/apps/desktop/src/renderer/ui/components/SetupAppPreview.module.scss @@ -0,0 +1,467 @@ +/* Miniature VPR window used as the setup walkthrough-loader. One 16s period, + 4s per scene (Home, Models, Apps, Chat); elements stagger within their + scene via inline animation-delay. Sized to the app's real portrait + proportions so it reads as the actual window, not a squashed card. */ + +$period: 16s; + +.walkthrough { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + width: 100%; +} + +.frame { + position: relative; + display: flex; + width: 264px; + height: 336px; + border-radius: 16px; + border: 1px solid var(--border); + background: var(--bg-primary); + box-shadow: var(--shadow); + overflow: hidden; +} + +/* ---------- nav rail ---------- */ + +.rail { + flex: 0 0 40px; + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + padding-top: 14px; + background: var(--bg-secondary); + border-right: 1px solid var(--border); +} + +.railItem { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: 8px; + color: var(--text-muted); +} + +/* Highlight riding the scene clock — lit while its scene is on stage. */ +.railItemCycle { + animation: rail-active $period ease-in-out infinite; +} + +@keyframes rail-active { + 0% { background: transparent; color: var(--text-muted); } + 4%, 23% { background: var(--bg-sidebar-active); color: var(--text-primary); } + 27%, 100% { background: transparent; color: var(--text-muted); } +} + +/* ---------- scenes ---------- */ + +.pane { + position: relative; + flex: 1 1 auto; + min-width: 0; +} + +.scene { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + padding: 12px; + opacity: 0; + animation: scene-cycle $period ease-in-out infinite; +} + +.sceneHome { animation-delay: 0s; } +.sceneChat { animation-delay: 4s; align-items: stretch; gap: 7px; } +.sceneModels { animation-delay: 8s; align-items: stretch; gap: 7px; } +.sceneApps { animation-delay: 12s; align-items: stretch; gap: 7px; } + +/* Each scene owns 25% of the period: enter over ~4%, hold, leave by 26%. */ +@keyframes scene-cycle { + 0% { opacity: 0; transform: translateY(6px); } + 4%, 23% { opacity: 1; transform: none; } + 26%, 100% { opacity: 0; transform: translateY(-6px); } +} + +.sceneTitle { + flex: 0 0 auto; + font-size: 10px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +/* Element entrance on the shared clock: visible from its delay until the + scene leaves. */ +@keyframes element-in { + 0% { opacity: 0; transform: translateY(4px); } + 2.5%, 23% { opacity: 1; transform: none; } + 26%, 100% { opacity: 0; } +} + +/* ---------- Home scene ---------- */ + +.banner { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 88px; + background: url('../../assets/banner-texture.jpg') center / cover no-repeat, var(--brand); +} + +.power { + position: relative; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + width: 52px; + height: 52px; + margin-top: 48px; + border-radius: 50%; + background: var(--bg-card); + color: var(--accent-green); + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.14); + animation: power-glow 1.8s ease-in-out infinite; +} + +@keyframes power-glow { + 0%, 100% { box-shadow: 0 4px 14px rgba(0, 0, 0, 0.14), 0 0 0 0 rgba(var(--accent-green-rgb), 0.35); } + 60% { box-shadow: 0 4px 14px rgba(0, 0, 0, 0.14), 0 0 0 9px rgba(var(--accent-green-rgb), 0); } +} + +.statusLine { + margin-top: 8px; + font-size: 11px; + font-weight: 600; + color: var(--accent-green); +} + +.composer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + width: 100%; + margin-top: 16px; + padding: 8px 7px 8px 11px; + border-radius: 12px; + border: 1px solid var(--border); + background: var(--bg-card); + animation: element-in $period ease-in-out infinite; + animation-delay: 0.6s; +} + +.composerText { + font-size: 10.5px; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.composerSend { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 6px; + background: rgba(var(--brand-rgb), 0.14); + color: var(--brand); +} + +/* App pill strip — the tools that connect through the VPR. */ +.pillRow { + display: flex; + justify-content: center; + gap: 6px; + margin-top: 10px; + animation: element-in $period ease-in-out infinite; + animation-delay: 0.9s; +} + +.pill { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 20px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--bg-card); + color: var(--text-secondary); +} + +/* Usage tiles — Saving in green is the takeaway. */ +.homeUsage { + width: 100%; + display: flex; + gap: 5px; + margin-top: 6px; + animation: element-in $period ease-in-out infinite; + animation-delay: 1.3s; +} + +.usageTile { + flex: 1 1 0; + min-width: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 1px; + padding: 5px 4px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--bg-card); +} + +.usageLabel { + font-size: 8px; + color: var(--text-muted); +} + +.usageValue { + font-size: 10.5px; + font-weight: 600; + color: var(--text-primary); + font-variant-numeric: tabular-nums; +} + +.usageSaving { + color: var(--accent-green); +} + +/* ---------- Models scene ---------- */ + +.modelRow { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 9px; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--bg-card); + opacity: 0; + animation: element-in $period ease-in-out infinite; +} + +.modelName { + flex: 1 1 auto; + min-width: 0; + font-size: 11px; + font-weight: 500; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.chip { + flex: 0 0 auto; + min-width: 28px; + padding: 1px 6px; + border-radius: 6px; + font-size: 9px; + font-weight: 600; + line-height: 13px; + text-align: center; + color: transparent; + background: var(--bg-surface); +} + +.chipFree { + color: var(--success-subtle-text); + background: var(--success-subtle-bg); +} + +.chipPrice { + color: var(--text-secondary); +} + +/* ---------- Apps scene ---------- */ + +.appRow { + display: flex; + align-items: center; + gap: 8px; + padding: 9px 10px; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--bg-card); + opacity: 0; + animation: element-in $period ease-in-out infinite; +} + +.appName { + flex: 1 1 auto; + min-width: 0; + font-size: 11px; + font-weight: 500; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Mini switch that flips on shortly after its row lands. */ +.toggle { + flex: 0 0 auto; + position: relative; + width: 26px; + height: 15px; + border-radius: 999px; + background: var(--bg-surface); + animation: toggle-on $period ease-in-out infinite; +} + +@keyframes toggle-on { + 0%, 1% { background: var(--bg-surface); } + 3%, 23% { background: var(--accent-green); } + 26%, 100% { background: var(--bg-surface); } +} + +.toggleKnob { + position: absolute; + top: 2px; + left: 2px; + width: 11px; + height: 11px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); + animation: knob-on $period ease-in-out infinite; +} + +@keyframes knob-on { + 0%, 1% { transform: translateX(0); } + 3%, 23% { transform: translateX(11px); } + 26%, 100% { transform: translateX(0); } +} + +.appsHint { + margin-top: 2px; + font-size: 9.5px; + color: var(--text-muted); + text-align: center; + opacity: 0; + animation: element-in $period ease-in-out infinite; + animation-delay: 14.2s; +} + +/* ---------- Chat scene ---------- */ + +.bubble { + max-width: 82%; + padding: 7px 10px; + border-radius: 11px; + font-size: 11px; + line-height: 15px; + opacity: 0; + animation: element-in $period ease-in-out infinite; +} + +.bubbleUser { + align-self: flex-end; + background: var(--brand); + color: #fff; + animation-delay: 4.4s; +} + +.bubbleTyping { + align-self: flex-start; + display: inline-flex; + gap: 3px; + background: var(--bg-surface); + animation: typing-slot $period ease-in-out infinite; + animation-delay: 4.4s; + + span { + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--text-muted); + animation: typing-dot 1s ease-in-out infinite; + &:nth-child(2) { animation-delay: 0.15s; } + &:nth-child(3) { animation-delay: 0.3s; } + } +} + +/* The typing bubble shows briefly, then the answer replaces it. */ +@keyframes typing-slot { + 0% { opacity: 0; } + 3%, 9% { opacity: 1; } + 11%, 100% { opacity: 0; } +} + +@keyframes typing-dot { + 0%, 80%, 100% { opacity: 0.3; transform: scale(0.85); } + 40% { opacity: 1; transform: scale(1); } +} + +.bubbleAnswer { + align-self: flex-start; + width: 88%; + margin-top: -24px; + background: var(--bg-surface); + color: var(--text-secondary); + font-size: 10px; + line-height: 14px; + animation-delay: 6s; +} + +/* ---------- captions under the frame ---------- */ + +.captions { + position: relative; + width: 100%; + height: 18px; +} + +.caption { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); + opacity: 0; + animation: scene-cycle $period ease-in-out infinite; +} + +/* ---------- reduced motion: hold the Home scene ---------- */ + +@media (prefers-reduced-motion: reduce) { + .scene, + .composer, + .pillRow, + .homeUsage, + .modelRow, + .appRow, + .railItemCycle, + .appsHint, + .toggle, + .toggleKnob, + .bubble, + .power, + .caption { + animation: none; + } + .sceneHome, + .composer, + .pillRow, + .homeUsage { opacity: 1; transform: none; } + .caption:first-child { opacity: 1; } +} diff --git a/apps/desktop/src/renderer/ui/components/SetupAppPreview.tsx b/apps/desktop/src/renderer/ui/components/SetupAppPreview.tsx new file mode 100644 index 000000000..aff959bae --- /dev/null +++ b/apps/desktop/src/renderer/ui/components/SetupAppPreview.tsx @@ -0,0 +1,155 @@ +import { HugeiconsIcon } from '@hugeicons/react'; +import { + ArrowUp02Icon, + BubbleChatIcon, + ConnectIcon, + DiscoverCircleIcon, + PowerIcon, + PreferenceHorizontalIcon, + SquarePowerIcon, +} from '@hugeicons/core-free-icons'; +import { BrandIcon, type BrandKey } from './brand/BrandIcon'; +import styles from './SetupAppPreview.module.scss'; + +/* Everything in the walkthrough is hard-coded: it plays during first-run + setup, before discovery has produced anything, so live data would leave a + new user staring at empty scenes. The names shown are model families and + apps actually on the network. */ + +/** The walkthrough's Apps scene: the headline integrations. */ +const PREVIEW_APPS: Array<{ brand: BrandKey; label: string }> = [ + { brand: 'anthropic', label: 'Claude' }, + { brand: 'openai', label: 'ChatGPT' }, + { brand: 'cursor', label: 'Cursor' }, + { brand: 'opencode', label: 'OpenCode' }, + { brand: 'telegram', label: 'Telegram Bot' }, +]; + +/** The Models scene rows: a mix of free and paid. */ +const PREVIEW_MODELS: Array<{ brand: BrandKey; label: string; chip: string; free: boolean }> = [ + { brand: 'deepseek', label: 'DeepSeek V4 Flash', chip: 'Free', free: true }, + { brand: 'qwen', label: 'Qwen 3.5 Coder', chip: 'Free', free: true }, + { brand: 'openai', label: 'GPT 5.6 Sol', chip: '$0.55', free: false }, + { brand: 'anthropic', label: 'Claude Opus 5', chip: '$1.20', free: false }, + { brand: 'glm', label: 'GLM 5.2', chip: '$0.30', free: false }, +]; + +/** One line per scene, shown under the frame in sync with it. */ +const SCENE_CAPTIONS = [ + 'Turn it on — your VPR joins the network', + 'Chat with any model, pay per use', + 'The latest models, free ones included', + 'Connect the apps you already use', +]; + +/** + * Miniature VPR window that plays as a walkthrough-loader: the nav rail on + * the left, the main pane cycling Home → Models → Apps → Chat at the app's + * real portrait proportions, and a caption line underneath narrating each + * scene. Pure CSS timing — one 16s period, 4s per scene, elements staggered + * within their scene by animation-delay. + */ +export function SetupAppPreview() { + return ( + ); } diff --git a/apps/desktop/src/renderer/ui/components/SetupScreen.module.scss b/apps/desktop/src/renderer/ui/components/SetupScreen.module.scss index 36ff83e3f..e23dab10b 100644 --- a/apps/desktop/src/renderer/ui/components/SetupScreen.module.scss +++ b/apps/desktop/src/renderer/ui/components/SetupScreen.module.scss @@ -50,7 +50,7 @@ .marquee { width: 100%; overflow: hidden; - margin-bottom: 18px; + margin-top: 18px; /* Soft edges so logos fade in/out instead of being clipped. */ mask-image: linear-gradient(to right, transparent, #000 15%, #000 85%, transparent); -webkit-mask-image: linear-gradient(to right, transparent, #000 15%, #000 85%, transparent); diff --git a/apps/desktop/src/renderer/ui/components/SetupScreen.tsx b/apps/desktop/src/renderer/ui/components/SetupScreen.tsx index 69913f418..fcb8f979e 100644 --- a/apps/desktop/src/renderer/ui/components/SetupScreen.tsx +++ b/apps/desktop/src/renderer/ui/components/SetupScreen.tsx @@ -149,6 +149,8 @@ export function SetupScreen() {
+ + {/* Logo marquee: the families of models on the network, drifting by while discovery runs — the wait has something alive to look at. */} + {showOnboarding && setShowOnboarding(false)} />} + {/* Connected-apps explainer. */} Date: Tue, 1 Sep 2026 22:53:11 +0300 Subject: [PATCH 11/15] feat(desktop): Show floating window on traffic by default --- CHANGELOG.md | 1 + apps/desktop/src/renderer/core/state.ts | 2 +- apps/desktop/src/renderer/modules/app/float-settings.ts | 7 ++++--- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e824616fc..5f0258413 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ This project uses selective package publishing. Each release entry lists the pub ### Added +- Desktop's floating window now shows on traffic by default: when a connected app starts sending requests, the pill pops up on its own unless the "Show on traffic" preference is explicitly turned off (previously it defaulted to off). - Desktop now nudges new users toward connecting their apps: a notification dot sits on the Apps nav item until the screen is first opened, and that first visit plays an animated coach-mark walkthrough — tooltips bobbing over the live view with a pulsing beacon, pointing at the app list and the "Add custom app" button. Shown once per install. - Desktop's Home power button now asks for confirmation before disconnecting, so a mis-click no longer stops routing (and drops connected apps and in-progress chats). The prompt reuses the standard confirm modal and has a "Don't show this again" checkbox that is remembered per install. - Desktop now includes privacy-conscious activation and reliability telemetry for first open, buyer-runtime/DHT/peer readiness, time to first visible model, fixed semantic user actions and each launch's first action, setup completion, coarse deposit milestones and failures, first chat, buyer-eligible model selections and free/paid classification, correlated per-request chat starts/outcomes, discovery failures, and clean/crash lifecycle signals. Events are main-process owned, consume structured runtime status rather than logs, use explicit allowlisted actions rather than frontend autocapture, are strictly allowlisted and bucketed, normalize failures to fixed local taxonomies without sending raw errors, exact prices, or peer IDs, emit model IDs only from a release-owned reviewed registry, briefly flush the final clean-shutdown event, disable PostHog GeoIP enrichment and person profiles, use the normalized public on-chain address as the PostHog identifier plus random session/request IDs, suppress successful periodic discovery captures, migrate existing identities without falsely emitting `app_first_opened`, collapse paired model persistence into one `model_selected` event, avoid overlapping model/peer `user_action` events, remain off in development/source builds without explicit configuration, and can be disabled from Preferences. diff --git a/apps/desktop/src/renderer/core/state.ts b/apps/desktop/src/renderer/core/state.ts index 3baeac945..233fd4ed5 100644 --- a/apps/desktop/src/renderer/core/state.ts +++ b/apps/desktop/src/renderer/core/state.ts @@ -593,7 +593,7 @@ export function createInitialUiState(): RendererUiState { blockedPeerIds: [], }, vprFloatOpen: false, - vprFloatAutoOpen: false, + vprFloatAutoOpen: true, vprFloatShowRoutedPeer: false, chatDiscoverRowsLoaded: false, chatSelectedServiceValue: '', diff --git a/apps/desktop/src/renderer/modules/app/float-settings.ts b/apps/desktop/src/renderer/modules/app/float-settings.ts index eabeeff61..78d4e1b72 100644 --- a/apps/desktop/src/renderer/modules/app/float-settings.ts +++ b/apps/desktop/src/renderer/modules/app/float-settings.ts @@ -2,12 +2,13 @@ export const VPR_FLOAT_AUTO_OPEN_STORAGE_KEY = 'antseed.desktop.vpr.floatAutoOpen'; -/** Whether the pill should pop up on its own when routed traffic starts. */ +/** Whether the pill should pop up on its own when routed traffic starts. + On by default — only an explicit opt-out disables it. */ export function loadFloatAutoOpen(): boolean { try { - return localStorage.getItem(VPR_FLOAT_AUTO_OPEN_STORAGE_KEY) === 'true'; + return localStorage.getItem(VPR_FLOAT_AUTO_OPEN_STORAGE_KEY) !== 'false'; } catch { - return false; + return true; } } From e5fed16598cd038bcff9089f02b43c30d75fe6e7 Mon Sep 17 00:00:00 2001 From: Shahaf Antwarg Date: Tue, 1 Sep 2026 22:57:17 +0300 Subject: [PATCH 12/15] feat(desktop): Keep Home app pills until first chat, mark connected apps --- CHANGELOG.md | 1 + .../components/views/VprHomeView.module.scss | 12 ++ .../ui/components/views/VprHomeView.tsx | 112 ++++++++++-------- 3 files changed, 76 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f0258413..7578eb49e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ This project uses selective package publishing. Each release entry lists the pub ### Added +- Desktop's Home screen keeps the "Use AntSeed on your favorite app" pills visible after connecting a tool — previously connecting anything hid the whole list. The pitch now disappears only once the user has chats, and an already-connected app's pill shows as connected (green dot, green-tinted border) and opens the Apps page instead of reconnecting. - Desktop's floating window now shows on traffic by default: when a connected app starts sending requests, the pill pops up on its own unless the "Show on traffic" preference is explicitly turned off (previously it defaulted to off). - Desktop now nudges new users toward connecting their apps: a notification dot sits on the Apps nav item until the screen is first opened, and that first visit plays an animated coach-mark walkthrough — tooltips bobbing over the live view with a pulsing beacon, pointing at the app list and the "Add custom app" button. Shown once per install. - Desktop's Home power button now asks for confirmation before disconnecting, so a mis-click no longer stops routing (and drops connected apps and in-progress chats). The prompt reuses the standard confirm modal and has a "Don't show this again" checkbox that is remembered per install. diff --git a/apps/desktop/src/renderer/ui/components/views/VprHomeView.module.scss b/apps/desktop/src/renderer/ui/components/views/VprHomeView.module.scss index f3bc3bb4e..c055b6aa4 100644 --- a/apps/desktop/src/renderer/ui/components/views/VprHomeView.module.scss +++ b/apps/desktop/src/renderer/ui/components/views/VprHomeView.module.scss @@ -863,6 +863,18 @@ cursor: default; } +.toolPillConnected { + border-color: rgba(var(--accent-green-rgb), 0.45); +} + +.toolConnectedDot { + flex: 0 0 auto; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent-green); +} + /* Real application icon (extracted from the installed app) on app pills. */ .appIcon { flex: 0 0 auto; diff --git a/apps/desktop/src/renderer/ui/components/views/VprHomeView.tsx b/apps/desktop/src/renderer/ui/components/views/VprHomeView.tsx index 8369efac7..e7734656a 100644 --- a/apps/desktop/src/renderer/ui/components/views/VprHomeView.tsx +++ b/apps/desktop/src/renderer/ui/components/views/VprHomeView.tsx @@ -468,23 +468,66 @@ export function VprHomeView({ onSelectView }: Props) {
) : null; - const renderProfilePill = (profile: SystemProxyProfileSummary) => ( - - ); + const renderProfilePill = (profile: SystemProxyProfileSummary) => { + // A connected app keeps its pill, shown as connected; clicking it leads + // to the Apps page where the connection is managed. + const connected = activeProfiles?.has(profile.name) ?? false; + return ( + + ); + }; + + /* Connect pitch — shown on both Home variants until the user has chats: + connecting one tool shouldn't hide the rest of the catalog. Once chats + exist the user knows the flow (the full list lives on the Apps page). */ + const appsPitch = (conversations === null ? !expectChats : conversations.length === 0) ? ( +
+

Use AntSeed on your favorite app

+ +
+ {/* Same lead items as the Apps tab (Telegram, Claude, Cursor), then + the catalog — the full list lives on the Apps page. */} + + {claudeProfile ? renderProfilePill(claudeProfile) : null} + + {homePreviewProfiles.map(renderProfilePill)} +
+
+ ) : null; /* Usage tiles — shown on both Home variants, zeros included, so the module is there from the first launch. */ @@ -718,43 +761,14 @@ export function VprHomeView({ onSelectView }: Props) { ) : null} + {appsPitch} + {usageTiles} ) : ( /* Routed apps + recent chats (the ask input lives in the hero) */
- {/* The connect pitch is for first-timers — once chats exist the - user knows the flow (the full catalog lives on the Apps page). */} - {(conversations === null ? !expectChats : conversations.length === 0) && ( -
-

Use AntSeed on your favorite app

- -
- {/* Same lead items as the Apps tab (Telegram, Claude, Cursor), then - the catalog — the full list lives on the Apps page. */} - - {claudeProfile ? renderProfilePill(claudeProfile) : null} - - {homePreviewProfiles.map(renderProfilePill)} -
-
- )} + {appsPitch} {usageTiles} From 367d789aa8526cb441bce6870a43e43a63aa1cf0 Mon Sep 17 00:00:00 2001 From: Shahaf Antwarg Date: Tue, 1 Sep 2026 23:05:42 +0300 Subject: [PATCH 13/15] fix(desktop): Resolve any installed T3 Code channel, use its app icon --- CHANGELOG.md | 1 + .../main/connected-apps/profile-targets.ts | 20 +++++++++----- apps/desktop/src/main/ipc/desktop.ts | 21 ++++++++------- .../ui/components/brand/BrandIcon.tsx | 9 +++++++ apps/desktop/src/shared/tool-resume.ts | 27 ++++++++++++------- 5 files changed, 53 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7578eb49e..28006d1b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ This project uses selective package publishing. Each release entry lists the pub ### Added +- Desktop now finds T3 Code installed under any release channel — the launch-target lookup previously only checked for "T3 Code (Alpha)", so stable/Beta/Nightly installs got no app icon, no default "Open with" application, and no restart action. All channel variants are now probed (stable first), and the T3 Code rows fall back to the official t3.codes icon instead of the generic mark when the app isn't installed. - Desktop's Home screen keeps the "Use AntSeed on your favorite app" pills visible after connecting a tool — previously connecting anything hid the whole list. The pitch now disappears only once the user has chats, and an already-connected app's pill shows as connected (green dot, green-tinted border) and opens the Apps page instead of reconnecting. - Desktop's floating window now shows on traffic by default: when a connected app starts sending requests, the pill pops up on its own unless the "Show on traffic" preference is explicitly turned off (previously it defaulted to off). - Desktop now nudges new users toward connecting their apps: a notification dot sits on the Apps nav item until the screen is first opened, and that first visit plays an animated coach-mark walkthrough — tooltips bobbing over the live view with a pulsing beacon, pointing at the app list and the "Add custom app" button. Shown once per install. diff --git a/apps/desktop/src/main/connected-apps/profile-targets.ts b/apps/desktop/src/main/connected-apps/profile-targets.ts index f37c180dd..3804d6ff1 100644 --- a/apps/desktop/src/main/connected-apps/profile-targets.ts +++ b/apps/desktop/src/main/connected-apps/profile-targets.ts @@ -9,7 +9,7 @@ import { existsSync } from 'node:fs'; import { homedir } from 'node:os'; import path from 'node:path'; -import { toolDesktopAppName } from '../../shared/tool-resume.js'; +import { toolDesktopAppNames } from '../../shared/tool-resume.js'; import { resolveConnectDataDir } from '../runtime/process-manager.js'; import { loadAppIdentitySettings, @@ -28,19 +28,25 @@ import { fetchCustomAppSiteMetadata, loadCustomApps, saveCustomApps } from './cu export const defaultLaunchTargetCache = new Map(); export function defaultLaunchTargetForProfile(profileName: string): AppLaunchTarget | null { - const appName = toolDesktopAppName(profileName); - if (!appName) return null; + const appNames = toolDesktopAppNames(profileName); + if (appNames.length === 0) return null; if (process.platform === 'darwin') { - for (const dir of ['/Applications', path.join(homedir(), 'Applications')]) { - const bundle = path.join(dir, `${appName}.app`); - if (existsSync(bundle)) return { name: appName, path: bundle }; + for (const appName of appNames) { + for (const dir of ['/Applications', path.join(homedir(), 'Applications')]) { + const bundle = path.join(dir, `${appName}.app`); + if (existsSync(bundle)) return { name: appName, path: bundle }; + } } return null; } if (process.platform === 'win32') { const listed = listInstalledApplications(); if (!listed.ok) return null; - return listed.apps.find((entry) => entry.name.toLowerCase() === appName.toLowerCase()) ?? null; + for (const appName of appNames) { + const found = listed.apps.find((entry) => entry.name.toLowerCase() === appName.toLowerCase()); + if (found) return found; + } + return null; } return null; } diff --git a/apps/desktop/src/main/ipc/desktop.ts b/apps/desktop/src/main/ipc/desktop.ts index a70a0f068..6da3fc9a5 100644 --- a/apps/desktop/src/main/ipc/desktop.ts +++ b/apps/desktop/src/main/ipc/desktop.ts @@ -4,7 +4,7 @@ */ import { ipcMain } from 'electron'; import { - toolDesktopAppName, + toolDesktopAppNames, toolResumeCommand, } from '../../shared/tool-resume.js'; import { @@ -140,19 +140,22 @@ export function registerDesktopIpc(): void { ipcMain.handle('desktop:open-tool-session', (_event, tool: unknown, sessionKey: unknown, target: unknown) => { const toolSlug = typeof tool === 'string' ? tool : ''; if (target === 'app') { - const appName = toolDesktopAppName(toolSlug); - if (!appName) { + const appNames = toolDesktopAppNames(toolSlug); + if (appNames.length === 0) { return { ok: false, error: 'This tool has no known desktop app.' }; } if (process.platform !== 'darwin') { - return { ok: false, error: `Open the ${appName} app manually on this platform.` }; + return { ok: false, error: `Open the ${appNames[0]} app manually on this platform.` }; } - try { - execFileSync('open', ['-a', appName], { stdio: 'pipe' }); - return { ok: true }; - } catch { - return { ok: false, error: `The ${appName} app doesn't seem to be installed.` }; + for (const appName of appNames) { + try { + execFileSync('open', ['-a', appName], { stdio: 'pipe' }); + return { ok: true }; + } catch { + // Try the next release-channel variant. + } } + return { ok: false, error: `The ${appNames[0]} app doesn't seem to be installed.` }; } const command = toolResumeCommand( toolSlug, diff --git a/apps/desktop/src/renderer/ui/components/brand/BrandIcon.tsx b/apps/desktop/src/renderer/ui/components/brand/BrandIcon.tsx index e3e2aae3d..420b4cb3c 100644 --- a/apps/desktop/src/renderer/ui/components/brand/BrandIcon.tsx +++ b/apps/desktop/src/renderer/ui/components/brand/BrandIcon.tsx @@ -17,6 +17,7 @@ export type BrandKey = | 'openai' | 'codex' | 'opencode' + | 't3code' | 'pi' | 'crush' | 'goose' @@ -51,6 +52,7 @@ const META_BLUE = '#0668E1'; const TELEGRAM_BLUE = '#26A5E4'; const CLOUDFLARE_ICON = new URL('../../../assets/cloudflare.svg', import.meta.url).href; const NGROK_ICON = new URL('../../../assets/ngrok.svg', import.meta.url).href; +const T3CODE_ICON = 'data:image/png;base64,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'; const MINIMAX_ICON = 'data:image/png;base64,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'; const glyphs: Record = { @@ -91,6 +93,12 @@ const glyphs: Record = { ), + // t3code — the desktop app icon (repo assets/nightly, macOS artwork). + t3code: ({ size }) => ( + + ), // pi — the tool's blocky geometric "P" monogram. pi: ({ size }) => (
{appsPitch} - {usageTiles} - {reminderCard} {recentChats} + + {/* Usage sits last on both variants. */} + {usageTiles}
)}
From 22da02325d315e3b3d5b38a287ce91dc3cb8b9dd Mon Sep 17 00:00:00 2001 From: Shahaf Antwarg Date: Tue, 1 Sep 2026 23:15:35 +0300 Subject: [PATCH 15/15] refactor(desktop): simplify UX state and markup --- .../renderer/modules/app/apps-onboarding.ts | 29 +++++---------- .../modules/app/disconnect-confirm.ts | 29 +++++---------- .../modules/app/local-storage-flag.ts | 25 +++++++++++++ .../ui/components/SetupAppPreview.tsx | 37 ++++++++++++------- .../src/renderer/ui/components/VprShell.tsx | 33 ++++++++--------- .../views/DisconnectConfirmDialog.tsx | 2 +- 6 files changed, 83 insertions(+), 72 deletions(-) create mode 100644 apps/desktop/src/renderer/modules/app/local-storage-flag.ts diff --git a/apps/desktop/src/renderer/modules/app/apps-onboarding.ts b/apps/desktop/src/renderer/modules/app/apps-onboarding.ts index 0d2750f9f..1e9c7c7c2 100644 --- a/apps/desktop/src/renderer/modules/app/apps-onboarding.ts +++ b/apps/desktop/src/renderer/modules/app/apps-onboarding.ts @@ -1,3 +1,10 @@ +import { + defaultStorage, + readStorageFlag, + type StorageLike, + writeStorageFlag, +} from './local-storage-flag'; + /** * One-shot Apps onboarding: after the user's first chat, a dot appears on the * Apps nav item; opening Apps then plays a "connect your tools" intro screen. @@ -6,30 +13,12 @@ */ export const APPS_ONBOARDING_SEEN_KEY = 'antseed:appsOnboardingSeen'; -type StorageLike = Pick; - -function defaultStorage(): StorageLike | null { - try { - return typeof window === 'undefined' ? null : window.localStorage; - } catch { - return null; - } -} - export function isAppsOnboardingSeen(storage: StorageLike | null = defaultStorage()): boolean { - try { - return storage?.getItem(APPS_ONBOARDING_SEEN_KEY) === 'true'; - } catch { - return false; - } + return readStorageFlag(APPS_ONBOARDING_SEEN_KEY, storage); } export function persistAppsOnboardingSeen(storage: StorageLike | null = defaultStorage()): void { - try { - storage?.setItem(APPS_ONBOARDING_SEEN_KEY, 'true'); - } catch { - // Storage unavailable — the intro simply shows again next time. - } + writeStorageFlag(APPS_ONBOARDING_SEEN_KEY, storage); } /** Nav-rail dot condition: the user hasn't opened the Apps screen yet. */ diff --git a/apps/desktop/src/renderer/modules/app/disconnect-confirm.ts b/apps/desktop/src/renderer/modules/app/disconnect-confirm.ts index c5ea59d17..d6d6310df 100644 --- a/apps/desktop/src/renderer/modules/app/disconnect-confirm.ts +++ b/apps/desktop/src/renderer/modules/app/disconnect-confirm.ts @@ -1,25 +1,18 @@ +import { + defaultStorage, + readStorageFlag, + type StorageLike, + writeStorageFlag, +} from './local-storage-flag'; + /** * "Don't show again" flag for the Home power-button disconnect confirmation, * persisted per install (localStorage) like the other one-off dialog dismissals. */ export const DISCONNECT_CONFIRM_DISMISSED_KEY = 'antseed:disconnectConfirmDismissed'; -type StorageLike = Pick; - -function defaultStorage(): StorageLike | null { - try { - return typeof window === 'undefined' ? null : window.localStorage; - } catch { - return null; - } -} - export function isDisconnectConfirmDismissed(storage: StorageLike | null = defaultStorage()): boolean { - try { - return storage?.getItem(DISCONNECT_CONFIRM_DISMISSED_KEY) === 'true'; - } catch { - return false; - } + return readStorageFlag(DISCONNECT_CONFIRM_DISMISSED_KEY, storage); } export function persistDisconnectConfirmDismissed( @@ -27,9 +20,5 @@ export function persistDisconnectConfirmDismissed( storage: StorageLike | null = defaultStorage(), ): void { if (!dontShowAgain) return; - try { - storage?.setItem(DISCONNECT_CONFIRM_DISMISSED_KEY, 'true'); - } catch { - // Storage unavailable — the dialog simply shows again next time. - } + writeStorageFlag(DISCONNECT_CONFIRM_DISMISSED_KEY, storage); } diff --git a/apps/desktop/src/renderer/modules/app/local-storage-flag.ts b/apps/desktop/src/renderer/modules/app/local-storage-flag.ts new file mode 100644 index 000000000..a9bf32c71 --- /dev/null +++ b/apps/desktop/src/renderer/modules/app/local-storage-flag.ts @@ -0,0 +1,25 @@ +export type StorageLike = Pick; + +export function defaultStorage(): StorageLike | null { + try { + return typeof window === 'undefined' ? null : window.localStorage; + } catch { + return null; + } +} + +export function readStorageFlag(key: string, storage: StorageLike | null): boolean { + try { + return storage?.getItem(key) === 'true'; + } catch { + return false; + } +} + +export function writeStorageFlag(key: string, storage: StorageLike | null): void { + try { + storage?.setItem(key, 'true'); + } catch { + // Storage unavailable; the one-shot UI will appear again next time. + } +} diff --git a/apps/desktop/src/renderer/ui/components/SetupAppPreview.tsx b/apps/desktop/src/renderer/ui/components/SetupAppPreview.tsx index 6a51f7884..1a73fecc3 100644 --- a/apps/desktop/src/renderer/ui/components/SetupAppPreview.tsx +++ b/apps/desktop/src/renderer/ui/components/SetupAppPreview.tsx @@ -34,14 +34,18 @@ const PREVIEW_MODELS: Array<{ brand: BrandKey; label: string; chip: string; free { brand: 'glm', label: 'GLM 5.2', chip: '$0.30', free: false }, ]; -/** One line per scene, shown under the frame in sync with it. */ -const SCENE_CAPTIONS = [ - 'Turn it on — your VPR joins the network', - 'Chat with any model, pay per use', - 'The latest models, free ones included', - 'Connect the apps you already use', +const SCENE_DURATION_SECONDS = 4; +const PREVIEW_SCENES = [ + { caption: 'Turn it on — your VPR joins the network', icon: SquarePowerIcon }, + { caption: 'Chat with any model, pay per use', icon: BubbleChatIcon }, + { caption: 'The latest models, free ones included', icon: DiscoverCircleIcon }, + { caption: 'Connect the apps you already use', icon: ConnectIcon }, ]; +function sceneDelay(index: number): string { + return `${index * SCENE_DURATION_SECONDS}s`; +} + /** * Miniature VPR window that plays as a walkthrough-loader: the nav rail on * the left, the main pane cycling Home → Models → Apps → Chat at the app's @@ -54,8 +58,8 @@ export function SetupAppPreview() {