diff --git a/CHANGELOG.md b/CHANGELOG.md index 0396a5203..28006d1b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ 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. +- 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 +21,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 +35,11 @@ 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). +- 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/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/core/state.ts b/apps/desktop/src/renderer/core/state.ts index 175ec4225..233fd4ed5 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,7 +554,10 @@ export function createInitialUiState(): RendererUiState { chatConversations: [], chatConversationsLoaded: false, chatProxyPort: 0, - chatPanelExpanded: false, + chatProxyOnline: 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, @@ -588,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/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/apps-onboarding.test.ts b/apps/desktop/src/renderer/modules/app/apps-onboarding.test.ts new file mode 100644 index 000000000..2194f0327 --- /dev/null +++ b/apps/desktop/src/renderer/modules/app/apps-onboarding.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { + APPS_ONBOARDING_SEEN_KEY, + isAppsOnboardingSeen, + persistAppsOnboardingSeen, + shouldNotifyAppsOnboarding, +} from './apps-onboarding'; + +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('apps-onboarding', () => { + it('is unseen by default', () => { + expect(isAppsOnboardingSeen(memoryStorage())).toBe(false); + expect(isAppsOnboardingSeen(null)).toBe(false); + }); + + it('notifies until the intro has been seen', () => { + const storage = memoryStorage(); + expect(shouldNotifyAppsOnboarding(storage)).toBe(true); + + persistAppsOnboardingSeen(storage); + expect(storage.getItem(APPS_ONBOARDING_SEEN_KEY)).toBe('true'); + expect(shouldNotifyAppsOnboarding(storage)).toBe(false); + }); + + it('swallows storage errors', () => { + const broken = { + getItem: () => { throw new Error('denied'); }, + setItem: () => { throw new Error('denied'); }, + }; + expect(isAppsOnboardingSeen(broken)).toBe(false); + expect(() => persistAppsOnboardingSeen(broken)).not.toThrow(); + expect(shouldNotifyAppsOnboarding(broken)).toBe(true); + }); +}); diff --git a/apps/desktop/src/renderer/modules/app/apps-onboarding.ts b/apps/desktop/src/renderer/modules/app/apps-onboarding.ts new file mode 100644 index 000000000..1e9c7c7c2 --- /dev/null +++ b/apps/desktop/src/renderer/modules/app/apps-onboarding.ts @@ -0,0 +1,27 @@ +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. + * Seen-state persists per install (localStorage) like the other one-off + * dialog dismissals. + */ +export const APPS_ONBOARDING_SEEN_KEY = 'antseed:appsOnboardingSeen'; + +export function isAppsOnboardingSeen(storage: StorageLike | null = defaultStorage()): boolean { + return readStorageFlag(APPS_ONBOARDING_SEEN_KEY, storage); +} + +export function persistAppsOnboardingSeen(storage: StorageLike | null = defaultStorage()): void { + writeStorageFlag(APPS_ONBOARDING_SEEN_KEY, storage); +} + +/** Nav-rail dot condition: the user hasn't opened the Apps screen yet. */ +export function shouldNotifyAppsOnboarding(storage: StorageLike | null = defaultStorage()): boolean { + return !isAppsOnboardingSeen(storage); +} 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..d6d6310df --- /dev/null +++ b/apps/desktop/src/renderer/modules/app/disconnect-confirm.ts @@ -0,0 +1,24 @@ +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'; + +export function isDisconnectConfirmDismissed(storage: StorageLike | null = defaultStorage()): boolean { + return readStorageFlag(DISCONNECT_CONFIRM_DISMISSED_KEY, storage); +} + +export function persistDisconnectConfirmDismissed( + dontShowAgain: boolean, + storage: StorageLike | null = defaultStorage(), +): void { + if (!dontShowAgain) return; + writeStorageFlag(DISCONNECT_CONFIRM_DISMISSED_KEY, storage); +} 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; } } 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/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/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/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/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..1a73fecc3 --- /dev/null +++ b/apps/desktop/src/renderer/ui/components/SetupAppPreview.tsx @@ -0,0 +1,166 @@ +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 }, +]; + +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 + * 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 ( + + {!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)} + /> +
+
+ + {modelMenu} +
+ +
+
+
+ ) : (
- {modelMenuOpen && ( -
- { - selectModelForNewChats(provider, serviceId); - }} - emptyLabel="No models discovered yet" - frameless - /> - -
- )} + {modelMenu}
+ )} @@ -547,11 +738,6 @@ export function VprHomeView({ onSelectView }: Props) { {recentChats} - - {showAddBalance && !snap.reminderOffer ? (
) : ( - /* 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. */} - {(conversations === null ? !expectChats : conversations.length === 0) && ( -
-

Use it on your favorite app

- -
- {/* Same lead item as the Apps tab, then the top of the catalog — - the full list lives on the Apps page behind "More apps". */} - - {homePreviewProfiles.map((profile) => ( - - ))} -
-
- )} + {appsPitch} {reminderCard} {recentChats} - + {/* Usage sits last on both variants. */} + {usageTiles}
)} @@ -700,6 +791,11 @@ export function VprHomeView({ onSelectView }: Props) { onDismiss={() => setModelChangeNotice(null)} /> ) : null} + setDisconnectConfirmOpen(false)} + /> ); } 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..0ebf32d19 100644 --- a/apps/desktop/src/renderer/ui/components/views/VprToolsView.module.scss +++ b/apps/desktop/src/renderer/ui/components/views/VprToolsView.module.scss @@ -1,4 +1,5 @@ .view { + position: relative; /* anchors the first-visit coach-mark layer */ height: 100%; min-height: 0; overflow: hidden; @@ -268,55 +269,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..55e110b10 100644 --- a/apps/desktop/src/renderer/ui/components/views/VprToolsView.tsx +++ b/apps/desktop/src/renderer/ui/components/views/VprToolsView.tsx @@ -18,6 +18,8 @@ import { BrandIcon, isThemeAwareAppBrand, resolveBrandKey } from '../brand/Brand import { VprBadge, VprPage, VprSearch } from '../vpr/VprKit'; import { TelegramBotCard } from './TelegramBotCard'; import { CursorAppCard } from './CursorAppCard'; +import { AppsOnboarding } from './AppsOnboarding'; +import { isAppsOnboardingSeen, persistAppsOnboardingSeen } from '../../../modules/app/apps-onboarding'; import styles from './VprToolsView.module.scss'; import { recordUserAction } from '../../../modules/telemetry/actions'; @@ -105,6 +107,13 @@ export function VprToolsView() { const proxyResource = useCachedResource(systemProxyResource); const profiles = proxyResource.data?.profiles ?? []; const proxyState = proxyResource.data?.state ?? null; + // First-ever visit to this screen (however the user got here): play the + // coach-mark walkthrough once, and mark it seen immediately so the nav dot + // never comes back. + const [showOnboarding, setShowOnboarding] = useState(() => !isAppsOnboardingSeen()); + useEffect(() => { + if (showOnboarding) persistAppsOnboardingSeen(); + }, [showOnboarding]); const [busy, setBusy] = useState(null); // The app being connected right now. Connecting also restarts an app that // was already running, so the row has to stay busy well past the click. @@ -745,6 +754,8 @@ export function VprToolsView() { + {showOnboarding && setShowOnboarding(false)} />} + {/* Connected-apps explainer. */}
@@ -839,7 +849,6 @@ export function VprToolsView() { ) : null} ) : undefined} - className={styles.vprModal} bodyClassName={styles.settingsBody} > {settingsProfile ? (() => { @@ -1011,7 +1020,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' ? ( 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))} diff --git a/apps/desktop/src/shared/tool-resume.ts b/apps/desktop/src/shared/tool-resume.ts index 54a45bf2e..7f37539c5 100644 --- a/apps/desktop/src/shared/tool-resume.ts +++ b/apps/desktop/src/shared/tool-resume.ts @@ -26,16 +26,25 @@ export function toolResumeCommand(tool: string, sessionKey: string): string | nu * them expose a per-session deep link. Null for terminal-only tools. */ export function toolDesktopAppName(tool: string): string | null { - if (tool === 'opencode') return 'OpenCode'; + return toolDesktopAppNames(tool)[0] ?? null; +} + +/** + * All installed-application names a tool may ship under, preferred first. + * T3 Code releases per-channel apps (stable/Alpha/Beta/Nightly) that share + * the same config, so any installed variant is a valid target. + */ +export function toolDesktopAppNames(tool: string): string[] { + if (tool === 'opencode') return ['OpenCode']; // Codex's desktop experience ships inside the ChatGPT app, which shares // the CLI's session storage. - if (tool === 'codex' || tool.startsWith('codex-')) return 'ChatGPT'; + if (tool === 'codex' || tool.startsWith('codex-')) return ['ChatGPT']; // Goose Desktop and the goose CLI share ~/.config/goose; Zed is app-only. - if (tool === 'goose') return 'Goose'; - if (tool === 'zed') return 'Zed'; - if (tool === 't3code') return 'T3 Code (Alpha)'; - if (tool === 'gooeypi') return 'GooeyPi'; - if (tool === 'hermes') return 'Hermes'; - if (tool === 'cursor') return 'Cursor'; - return null; + if (tool === 'goose') return ['Goose']; + if (tool === 'zed') return ['Zed']; + if (tool === 't3code') return ['T3 Code', 'T3 Code (Alpha)', 'T3 Code (Beta)', 'T3 Code (Nightly)']; + if (tool === 'gooeypi') return ['GooeyPi']; + if (tool === 'hermes') return ['Hermes']; + if (tool === 'cursor') return ['Cursor']; + 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