diff --git a/.papercuts/troubleshooting.md b/.papercuts/troubleshooting.md index a037eb5d..bcc1b53f 100644 --- a/.papercuts/troubleshooting.md +++ b/.papercuts/troubleshooting.md @@ -311,3 +311,6 @@ owns; reopen the terminal before judging the final live state. platform-independent test that exercises both certificate and keychain paths. - A changelog search conflated the stable and prerelease lines. Verify published package code before assuming a release contains the upstream patch. + +- UX review (2026-09-05): the active Xcode installation rejects tools until its license is accepted. Git and desktop C helpers can use the separately installed Command Line Tools via `DEVELOPER_DIR=/Library/Developer/CommandLineTools`; helper build scripts replace the child environment, so this run compiled their unchanged C sources with the same flags directly. iOS physical-device discovery/test remains blocked; do not claim it passed. +- Electron E2E failure diagnostics called `app.process()` outside their try/catch; a closed Electron target hid the original launch error. Keep that call within the best-effort diagnostic block. The isolated E2E profile also cannot establish native Bot Keychain authority; the editor test injects a test-owned IPC catalog and captures the submitted access, while storage/authority tests run separately. diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenPairingScreen.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenPairingScreen.kt index ab75319a..00f03f61 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenPairingScreen.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenPairingScreen.kt @@ -183,14 +183,21 @@ fun AidenPairingScreen( // Pair New Mac Section Text( - text = "Pair New Mac", + text = "Connect your Mac", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, color = palette.secondary ) Spacer(modifier = Modifier.height(8.dp)) - // M3 Expressive 3-Tab Pill Segmented Group + Text( + text = "On your Mac, open Settings → Aiden On The Go → Connect a device. Then scan its code here.", + style = MaterialTheme.typography.bodyMedium, + color = palette.secondary + ) + Spacer(modifier = Modifier.height(12.dp)) + + // QR first, with a camera-free setup code fallback. Surface( color = palette.raised, shape = RoundedCornerShape(20.dp), @@ -241,31 +248,16 @@ fun AidenPairingScreen( } } - // Tab 2: Paste JSON - Surface( - color = if (selectedTab == 2) palette.accent else Color.Transparent, - shape = RoundedCornerShape(16.dp), - modifier = Modifier - .weight(1f) - .tactilePress { selectedTab = 2 } - ) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.padding(vertical = 8.dp) - ) { - Text( - text = "Paste JSON", - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Bold, - color = if (selectedTab == 2) Color.White else palette.secondary - ) - } - } + } } Spacer(modifier = Modifier.height(16.dp)) + TextButton(onClick = { selectedTab = if (selectedTab == 2) 0 else 2 }) { + Text(if (selectedTab == 2) "Back to scanning" else "Advanced: paste connection details") + } + errorMessage?.let { msg -> Surface( color = palette.danger.copy(alpha = 0.12f), @@ -325,7 +317,7 @@ fun AidenPairingScreen( colors = sbtbiswas.AidenOnTheGo.ui.theme.aidenTextFieldColors(), value = endpointUrl, onValueChange = { endpointUrl = it }, - label = { Text("Mac Address (HTTPS Endpoint)") }, + label = { Text("Mac address") }, singleLine = true, shape = RoundedCornerShape(12.dp), modifier = Modifier.fillMaxWidth() diff --git a/docs/aiden-on-the-go-remote-access.md b/docs/aiden-on-the-go-remote-access.md index 958dd183..49bd89ca 100644 --- a/docs/aiden-on-the-go-remote-access.md +++ b/docs/aiden-on-the-go-remote-access.md @@ -1,14 +1,15 @@ # Aiden On The Go remote access -Aiden Agent can expose a small authenticated API to Aiden On The Go on iPhone and iPad. Remote Access is off by default. Aiden must remain running on the Mac, although its window may be closed. +Aiden Agent can expose a small authenticated API to Aiden On The Go on phones and tablets. Phone access is off by default. Aiden must remain running on the Mac, although its window may be closed. ## Local Network setup -1. Open **Settings → Remote Access** in Aiden Agent. -2. Choose **Local Network** or **Local Network + Tailscale**. -3. Turn on **Enable Remote Access**. -4. Add only the folders the phone or iPad may explore. Selecting the entire home directory requires a second confirmation on the Mac; the filesystem root is never allowed. -5. Choose **Pair over Local Network** and scan the one-time QR code in Aiden On The Go. +1. Open **Settings → Aiden On The Go** in Aiden Agent. +2. Choose **On the same Wi-Fi**, then **Connect a device**. +3. Review what Aiden will enable and choose **Enable and show code**. +4. Scan the code in Aiden On The Go. If the camera is unavailable, use the setup code instead. + +After choosing the method, setup takes two desktop actions. Scanning and any phone permissions are additional steps. Existing ready connections can add a device directly. Under **Workspace access**, approve any additional folders the phone may browse; existing workspace access is unchanged. Approving the whole home folder requires a separate confirmation. The Mac advertises `_aiden-agent._tcp` with Bonjour only while Local Network access is running. LAN traffic uses a per-install P-256 HTTPS identity. The QR contains the private CA trust anchor and the server public-key pin so the mobile client can validate the hostname, certificate chain, and pinned key. A certificate renewal keeps the server key; an identity-key change requires pairing again. @@ -16,19 +17,22 @@ The Mac advertises `_aiden-agent._tcp` with Bonjour only while Local Network acc Tailscale supplies reachability and network encryption, but Aiden still requires its own device credential on every request. -1. Install Tailscale on the Mac and sign in to the intended tailnet. -2. Ensure HTTPS certificates are available for the tailnet. Aiden reports this prerequisite rather than enabling it silently. -3. In **Settings → Remote Access**, select **Tailscale** or **Local Network + Tailscale** and enable Remote Access. -4. Review the exact command-equivalent route preview, then choose **Connect**. -5. Pair with **Pair over Tailscale** after the stable `https://…ts.net/api/aiden/v1` address appears. +1. Install Tailscale on the Mac and phone, sign in to the intended network, and make sure HTTPS is authorized for the Mac’s Tailscale name. +2. Open **Settings → Aiden On The Go** and choose **Away from home**. +3. Choose **Connect a device → Enable and show code**. Aiden turns on access, sets up its private connection, checks it, and shows the one-time code. +4. Scan the code on your phone. + +Aiden checks installation, sign-in, HTTPS availability, and route ownership before setup. Missing prerequisites remain user actions. Conflicts and uncertain changes direct you to the advanced **Connection** controls; setup never silently replaces another route. If setup fails, Aiden removes only access introduced by that attempt where the outcome is known. An uncertain external change remains available for explicit verification. + +**This Mac settings** contains the Mac name and enable switch; **Connection** contains the saved mode and technical controls. Closing the code window stops pairing; phone access remains enabled until switched off. Removing a device’s access is separate from turning off all phone access. Aiden owns only `/api/aiden/v1`, proxies it to the loopback-only HTTP listener's matching `/api/aiden/v1` base, and verifies the resulting route. The matching target base is required because Tailscale strips the public `--set-path` prefix before proxying. On macOS, Aiden invokes Tailscale's shared app executable in its documented explicit CLI mode, so Finder and Dock launches do not depend on terminal environment variables. First-time connection works from an empty Serve configuration only after the node's exact Tailscale certificate domain proves HTTPS was already authorized. Aiden never enables Tailscale Funnel, never runs `tailscale serve reset`, never completes Tailscale authorization for you, and never changes unrelated Serve handlers. **Disconnect** removes only the exact route and target recorded by Aiden. A conflict is reported instead of being overwritten. ## Devices, credentials, and revocation -Each phone or iPad receives a separate random credential. Aiden persists only a fast lookup digest, a salted scrypt digest, and redacted device metadata—not the credential or pairing secret. Pairing QR codes expire after five minutes and work once. +Each phone or tablet receives a separate random credential. Aiden persists only a fast lookup digest, a salted scrypt digest, and redacted device metadata—not the credential or pairing secret. Pairing QR codes expire after five minutes and work once. -Use **Revoke** beside a paired device to invalidate it immediately. Revocation does not rotate model-provider credentials or affect other paired devices. Pair the device again to restore access. +Use **Remove access** beside a paired device to invalidate it immediately. Revocation does not rotate model-provider credentials or affect other paired devices. Pair the device again to restore access. ## Offline behavior diff --git a/docs/plans/README.md b/docs/plans/README.md index 5f00da2d..7f0240e4 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -6,6 +6,7 @@ This directory is the source of truth for Aiden's implementation plans. The engi | Plan | Status | Current state | | -------------------------------------------------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [Nontechnical User Journey UX](nontechnical-user-journey-ux-plan.md) | Active | Approved ten-journey UX pass implemented for review: guided phone setup, four AI choices, two-step Create a bot, setup acknowledgements, recovery, and native pairing copy. Broader audit backlog and physical-device acceptance remain open. | | [Aiden Assistant](aiden-assistant-plan.md) | Partial | The dock, Markdown rendering, and confirmed provider-connection/model-pinned project-or-MCP automation creation/editing ship; settings tools and proactivity remain planned. | | [Aiden On The Go](aiden-on-the-go-plan.md) | Active | Version 0.1.0 build 22 is `VALID` and `IN_BETA_TESTING` for Internal Testers. Android matches iOS's app-icon switcher, Workspace hierarchy, warm scoped Bots/Usage/SSE lifecycle, Usage dashboard, image showcase/gallery, keyboard-safe elevated composer, and split Photo/File pickers. Both clients support native in-process dictation or bounded no-retention transcription by the paired Mac's local Parakeet model. iOS also ships progressive onboarding, bidirectional media, reliable mobile approvals, typed activity timelines, semantic haptics, and one-chat-per-Bot conversations with companion vision for text-only models. Physical iPad/manual permission-system-UI acceptance, privacy publication, final store assets, and external/public-release decisions remain open. | | [Unified Workspace Sidebar](unified-workspace-sidebar-plan.md) | Active | Phases 1 and 2 ship the unified workspace/chat outline plus a feature-negotiated, transcript-free paginated summary read on Electron, iOS/iPadOS, and Android; physical-device performance acceptance remains open. | diff --git a/docs/plans/nontechnical-user-journey-ux-plan.md b/docs/plans/nontechnical-user-journey-ux-plan.md new file mode 100644 index 00000000..fa123a0e --- /dev/null +++ b/docs/plans/nontechnical-user-journey-ux-plan.md @@ -0,0 +1,372 @@ +# Make Aiden easier to start, understand, and recover + +Date: 2026-09-04 +Status: **Active — approved UX implementation in review; broader journey backlog and physical-device usability validation remain open.** +Baseline: `d40d00f1d` +Deliverable: UX audit, journey chart, remote-setup proposal, and implementation handoff. + +Visual review: [Now vs proposed — interactive HTML](../ux/now-vs-proposed.html). Ten key journeys, with simulated setup/recovery actions. Open the HTML in a browser; it runs locally without dependencies or external requests. Current screens are simplified source-based reconstructions, not screenshots. + + +## Approved implementation in this PR + +The user approved the HTML with two copy requirements: show **ChatGPT, LM Studio, Ollama, Other Custom Provider**, followed by **Other ways**, and keep **Create a bot** throughout. + +| Journey | Implemented change | Practical boundary | +| --- | --- | --- | +| Connect a phone | Two setup cards; one acknowledgement; main-owned setup enables access, prepares the owned route, verifies pairing prerequisites, and issues a code. Rollback, stale review, owner cancellation, and concurrent setup are guarded. | Two desktop actions after choosing the connection method. Changing the selected method adds one choice. Installation, sign-in, HTTPS authorization, scanning, and OS permissions remain external steps. | +| Connect AI / first chat | Four primary provider choices, additional services under Other ways, custom provider model validation, actionable composer readiness link. | Existing profile/tour and first-chat surfaces remain; no new automatic account sign-in or benchmark fetch. | +| Create a bot | Two pages; optional appearance and detailed capability controls; explicit model/access review; fresh desktop drafts start Custom with no file, shell, connection, skill, or extra capability grants. Failed saves retain the draft. | Existing bots preserve their saved access. Native bot editors retain their existing defaults in this desktop editor change; no bot-first phase is advanced. | +| Telegram | Three groups, acknowledgement before enabling, a single enable/connect action, owner pairing status, persistent connect errors; disconnect turns the service off. | BotFather token creation and Telegram owner pairing are still required. Full unattended authority is disclosed before connecting. | +| Voice | Audio destination labels and persistent recorder/transcription recovery beside the draft, with a direct Voice settings action. | Local model download and cloud credentials remain explicit; recording never sends the draft as a chat. | +| Computer Use | Plain explanation and acknowledgement before enable; Mac permission action; provider screenshot/text disclosure up front. | Per-chat opt-in, macOS permission gates, and per-control approval remain. | +| Scheduled work | What / when / access groups followed by a final review; failed saves retain choices. | Existing Create with Aiden natural-language entry remains primary. Script, Full, and MCP authority restrictions remain. | +| Plugins | Connection details disclosed progressively; Connect verifies tool availability; saved credentials are not labelled as a verified connection. | External authorization remains explicit. An unavailable endpoint leaves a persistent error. | +| Recovery | Phone setup rollback, preserved pairing lifecycle, composer voice recovery, and bot/schedule draft retention. | Existing native cache/reconnection contracts remain; no automatic mutation replay or new offline-writing contract. | +| Find settings / mobile pairing | Aiden On The Go destination, natural-language search aliases, updated Mac instructions, QR-first mobile navigation with manual/advanced fallbacks. | Stable settings route IDs and mobile wire protocol are unchanged. | + +The 39-row audit below remains the backlog rather than a claim that every possible branch has been redesigned. Release gates and evidence are tracked in [the implementation review](../ux/implementation-review.md). + +## Design direction + +Make the user's intended outcome the entry point. Aiden should assemble the required settings, explain the consequences once, and carry the user through to a verified result. + +Start with **Aiden On The Go**: two setup cards, **Connect your phone** and **Scan to finish**, followed by a connected-device summary. When Tailscale is already ready, the target is **two desktop clicks from the setup page to a usable QR code**. Scanning and any phone/OS permissions are additional actions. A fresh Tailscale installation cannot honestly be a two-click end-to-end experience; it needs guided installation, sign-in, and possibly administrator authorization. + +Then apply the same pattern to first chat, provider connection, permissions, Bots, Telegram, voice, plugins, and scheduled work. Reduce technical decisions and context switches; do not remove meaningful control over data, access, spending, or destructive actions. + +## What this audit establishes + +This is a source-based review of desktop entry points, all 15 Settings destinations, onboarding, chat/workspace controls, and representative iOS and Android pairing, connection, and task surfaces. It includes first use, repeat use, failure, recovery, and removal. The journey inventory below covers the shipped capability families visible in this checkout; it is not an exhaustive traversal of every conditional screen or every OS/account configuration. + +Current labels, control dependencies, and state branches are code observations. Assessments of confusion and proposed improvements are UX hypotheses, not measured user behavior. At audit time, no live application walkthrough, external account connection, physical-device test, or user study was performed. The implementation review now records automated Electron walkthroughs; external-account, physical-device, and user-study gates remain open. Release availability, actual timings, and platform-specific system dialogs still need verification before publishing setup instructions. + +Existing strengths to preserve: + +- Desktop onboarding already has three stages and explicit provider deferral. +- Settings already has search; many technical remote controls are already in disclosures. +- Remote pairing already has expiry, one-use codes, authenticated completion, per-device removal, and safe route ownership checks. +- Bots already have a guided editor and a review step. +- Schedules already support ordinary repeat/time controls and natural-language creation through the Assistant. +- Chat drafts, mobile caches, retry states, accessibility options, and local diagnostics have substantial existing support. + +The main shortcoming is how these pieces join together. A disclosure can hide a prerequisite without helping the user complete it. A wizard can still demand five difficult decisions. A successful connection does not necessarily mean the user knows what to do next. + +## Journey chart + +Priority: **P0** = first useful outcome or accurate understanding of access/data; **P1** = common repeat work or recovery; **P2** = specialist convenience. These are UX priorities, not vulnerability ratings. “Gap” is the source-informed hypothesis to validate. Evidence IDs link to the source register below. + +### Start and find your way + +| ID | User goal and current path | Gap / likely hurdle | Proposed path and completion signal | Priority / evidence | +|---|---|---|---|---| +| J01 | Launch → profile → provider → feature tour → app | A name, detailed search disclosure, provider choice, and large feature inventory precede first value. | Keep three stages; make optional profile detail deferrable, explain the AI connection, then offer a first task. Retain the full tour as optional exploration. Success: a first useful reply. | P0 · [S1](#s1) | +| J02 | Connect AI during onboarding or Providers | API keys, browser sign-in, local servers, and custom Tailscale models require different expertise. | Show “Sign in,” “Use an API key,” and “Use a local or custom model”; progressively reveal relevant fields. Label the actual account/service and costs where known. Success: connection validated and one visible, usable model selected. | P0 · [S1](#s1), [S3](#s3) | +| J03 | Skip provider → finish setup → try to chat | Deferral is explicit, but reaching the app can be mistaken for chat readiness. | Preserve browsing; place “Connect AI to send your first message” at the composer with a return-to-draft setup action. No automatic paid test prompt. | P0 · [S1](#s1), [S4](#s4) | +| J04 | New Agent → workspace/context controls → message | “Agent,” “chat,” “workspace,” and “scratch folder” require a mental model too early. | Start with “New chat”; offer “Just chat” and “Work with a folder.” Explain where generated files are saved, including the existing scratch folder behavior. | P0 · [S4](#s4), [S5](#s5) | +| J05 | Sidebar → workspaces/chats, Bots, Scheduled, Assistant dock | Multiple conversation entry points can look interchangeable. | Explain in empty states: Chat = a task; Bot = a reusable helper with its own ongoing conversation; Scheduled = repeated work; Assistant = help with Aiden. Keep recent work easy to resume. | P1 · [S5](#s5), [S6](#s6), [S8](#s8) | +| J06 | Settings → search section titles/keywords → section | Search currently filters destinations, not individual fixes; “Android” and ordinary “connect my phone” wording are not explicit Remote Access keywords. | Add intent aliases and result links to exact actions, including phone, sign-in, microphone, update, and missing folder. Preserve existing routes. | P1 · [S2](#s2) | + +### Chat and local work + +| ID | User goal and current path | Gap / likely hurdle | Proposed path and completion signal | Priority / evidence | +|---|---|---|---|---| +| J07 | Choose provider/model; optional Pad and reasoning controls | A large technical inventory makes the first choice hard. | First show current and pinned models with supported capability labels; offer a clearly identified default from the connected inventory. Keep full search/Pad available. Success: user can explain which service receives the message. | P0 · [S3](#s3), [S4](#s4) | +| J08 | Pick No access / Ask first / Full access; handle approvals | Users must understand scope, and “Full” can sound like a quality setting. | Retain enforced scopes; describe concrete file/command consequences and name the folder. Explain each approval with action, affected resource, and allow-once/deny choices. Full access remains an explicit consequential choice. | P0 · [S4](#s4), [S10](#s10) | +| J09 | Attach photo/file → model compatibility → send | Ordinary composer can skip images for unsupported models with a toast. User may think the photo was included. | Keep a persistent attachment-level explanation; offer an explicit compatible-model choice without changing recipients silently. Preserve supported attachments and text. Bots keep their separate companion-vision contract. | P0 · [S4](#s4), [S6](#s6) | +| J10 | Send → streaming answer, tools, reasoning, subagents/todos | Several kinds of activity compete with the actual outcome. | One plain-language current status; expand details when needed. Approval waiting, stopped, failed, and completed must remain distinct. Preserve current cancellation and durable activity semantics. | P1 · [S10](#s10) | +| J11 | Provider error, interrupted generation, retry | A generic retry can conceal sign-in, quota, network, or uncertain side effects. | Map known failures to “Sign in again,” “Try again,” or an explicit model change. Preserve draft and originating context; do not automatically resend an action with an unknown result. | P0 · [S4](#s4), [S10](#s10) | +| J12 | Open Files / Review / Quick View / Environment | Container names and Git-only states can obscure the simple goal of finding a result. | Lead with “Files” and “Changes” actions beside relevant output. A non-Git folder should lead to Files with a useful explanation, not an apparent dead end. | P1 · [S11](#s11) | +| J13 | Open generated artifact → expand/export | The interactive result and the saved deliverable are different objects. | Make preview, export, destination, and export failure clear. Success means a verified usable file, not merely an open preview. | P1 · [S11](#s11) | +| J14 | Branch/worktree → review → commit → push | Specialist Git vocabulary; save and publish can be confused. | Keep optional developer tools. Add short explanations: commit saves a version locally; push sends commits to the named remote. Preserve separate confirmations, stale-state checks, and conflict handling. | P2 · [S11](#s11) | +| J15 | Find/rename/delete chats or remove a worktree | Removing a conversation, a saved location, and actual files have different consequences. | Use object-specific removal copy and show exactly what survives. Offer undo only where backend recovery is real; never imply deleted files can be restored without evidence. | P1 · [S5](#s5) | + +### Reuse, connect, and automate + +| ID | User goal and current path | Gap / likely hurdle | Proposed path and completion signal | Priority / evidence | +|---|---|---|---|---| +| J16 | Create Bot → Identity → Access → Model → Capabilities → Review | Five stages and independent model/capability choices before a conversation. | Two core cards: “What should your bot do?” and “Review model and access.” Start a new Bot with a supported minimal custom scope; advanced customization stays available. Model remains explicitly pinned. | P1 · [S6](#s6) | +| J17 | Edit Bot, customize avatar, enable vision, bind Telegram | Durable identity, optional decoration, and external access are different tasks. | Allow optional avatar editing after first chat. Explain that model changes affect this Bot's ongoing conversation; connect Telegram or vision only on explicit intent, with recipient/access review. | P1 · [S6](#s6), [S7](#s7) | +| J18 | Telegram profile → token → enable → connect/poll → owner pairing → workspace/model | Multiple toggles and technical descriptions; independent Bot binding can require a second trip to Settings. | Three cards: “Connect Telegram,” “Choose what it can use,” “Send a message to finish.” Resume after BotFather; combine Aiden-owned enable/connect steps after acknowledgement. Verify the authorized owner before claiming readiness. | P1 · [S7](#s7) | +| J19 | Plugins catalog → preset → credential/authorization → save/test | “Connect” and “Test” may represent different readiness; generic editor exposes commands/headers. | Known plugin → permission/recipient summary → sign in or paste key → supported non-mutating connection verification. Distinguish “Saved” from “Ready.” Keep custom server setup under Advanced. | P1 · [S9](#s9) | +| J20 | Create/enable skill → invoke with `$` or model use | Difference between skill instructions, executable tools, and Bots is implicit. | Explain “Reusable instructions”; offer a simple example/template and a visible composer picker. Say when instructions are applied; do not claim enabling a skill guarantees invocation. | P2 · [S9](#s9) | +| J21 | Scheduled → editor or Ask Aiden → timing, scope, model/tools → confirm | Existing ordinary time controls are helpful, but run context is extensive. | Default to “What” and “When,” then one concrete review showing model, folder, access, time zone, and next run. Preserve advanced scripts/cron. Success: saved task with confirmed next run. | P1 · [S8](#s8) | +| J22 | Run/pause/resume schedule; inspect failure | A schedule can be mistaken for a cloud service that runs while the Mac is unavailable. | Keep “Runs while Aiden is open on this Mac” beside next run. Explain the actual missed-run policy, attention state, and pause status; never imply catch-up behavior without checking scheduler rules. | P1 · [S8](#s8) | +| J23 | Web Search on/off → provider catalog → routing/setup | Advanced fallback and recipient policy dominates a basic search preference. | First show On/Off and current recipient(s), with concise data disclosure. Keep custom routing below “Search options.” Changing recipients or unattended use remains explicit. | P1 · [S12](#s12) | +| J24 | Model Pad → benchmark credential/fetch → arrange models; Providers → catalog update | Optional evaluation data may look necessary for chat or become confused with model availability. | Describe it as optional model comparison. Keep manual source-specific fetch actions, provenance, and incomplete-data labels. Never fetch benchmarks or models.dev during setup or ordinary browsing. | P2 · [S3](#s3), [S12](#s12) | + +### Use Aiden on another device + +| ID | User goal and current path | Gap / likely hurdle | Proposed path and completion signal | Priority / evidence | +|---|---|---|---|---| +| J25 | Remote Access → enable → Connection → method → Tailscale Connect → Add device | Primary action is gated by prerequisites the user must find and order. | Two-card setup described below; explicit acknowledgement enables the selected connection and opens pairing after verification. | P0 · [S13](#s13), [S14](#s14) | +| J26 | Mobile onboarding → prepare Mac → choose connection → camera/manual entry | iOS repeats network choices already encoded in QR; Android puts Paste JSON beside Scan QR. | “Scan the code on your Mac” is primary. Manual setup remains accessible as fallback; payload import becomes Advanced. No second transport decision for a valid QR. | P0 · [S15](#s15), [S16](#s16) | +| J27 | Pair successfully → choose Bot/workspace; approve browsing folders on Mac | “Connected” can lead to an empty workspace; folder browsing roots and existing workspaces have different scopes. | Show existing permitted content, then “Add a folder on your Mac” only when relevant. Explain precisely that browsing roots govern discovery/addition; do not suggest all existing workspaces are hidden by default. | P0 · [S13](#s13), [S14](#s14), [S17](#s17) | +| J28 | Leave Wi-Fi, sleep/quit Mac, lose connection → reconnect | Off, unreachable, Tailscale not ready, and revoked are different states. | “Can't reach your Mac” with known facts, preserved drafts/cache, and one relevant next action. Label cached content with freshness; show “Nearby only” for a LAN pairing. Do not assert the Mac is asleep without evidence. | P0 · [S14](#s14), [S17](#s17) | +| J29 | Pair another Mac/phone → switch installations | Similar Mac names and cached content can conceal which machine will run work. | Keep active Mac visible on action surfaces and approval cards. Verify every newly paired device independently; preserve installation/device-scoped caches and revocation. | P1 · [S13](#s13), [S17](#s17) | +| J30 | Revoke on Mac or remove saved Mac on phone | Stopping service, removing one credential, and deleting local cached data are different. | Use “Pause phone access,” “Remove device access,” and “Remove this Mac from this phone” with exact consequences. Local removal must not claim server-side revocation unless performed and verified. | P0 · [S13](#s13), [S16](#s16), [S17](#s17) | + +### Voice, permissions, maintenance, and help + +| ID | User goal and current path | Gap / likely hurdle | Proposed path and completion signal | Priority / evidence | +|---|---|---|---|---| +| J31 | Voice settings → provider/model/download → microphone or dictation shortcut | On-device engine setup and cloud credentials precede an apparently simple microphone action. | First microphone use opens relevant setup: show audio destination, download size if needed, and one setup action. Capture only after explicit record intent. Success: editable transcript, not automatic message sending. | P1 · [S18](#s18) | +| J32 | Mobile speech → native or paired Mac → optional Parakeet setup | Where speech is processed and why the Mac must be online can be unclear. | Label “On this device” / “On your Mac” according to actual supported processing; disclose native service behavior accurately. Show Mac model download progress and retain typed fallback. | P1 · [S14](#s14), [S18](#s18) | +| J33 | Enable Computer Use → OS Accessibility/Screen Recording → per-chat opt-in → Allow once | Global readiness, OS permissions, and chat authority are separate gates; copy names the driver. | “Let Aiden help in Mac apps” → plain privacy review → request missing OS permissions in order → return to originating chat. Preserve per-chat opt-in and approval before control actions. | P0 · [S19](#s19) | +| J34 | Memory settings → automatic compaction engine + global/workspace memory | Conversation shortening and durable remembered facts are presented together. | Explain “Keep long chats working” separately from “Remember useful information.” Put experimental engine selection under Advanced. Any future fact viewer/delete action needs actual storage support. | P1 · [S20](#s20) | +| J35 | Appearance / shortcuts → customization and conflict handling | Useful existing controls need to remain discoverable through a simpler information architecture. | Keep system defaults, text size, contrast, reduced motion, and shortcut conflict repair accessible; no prerequisite customization tour. Test keyboard-only and screen readers across setup. | P1 · [S21](#s21) | +| J36 | Profile → usage/date range → share snapshot | Tokens, estimates, and actual provider bills can be confused; profile sharing includes a name. | Explain request/usage totals, cost coverage and missing prices; do not present estimates as invoices. Keep preview and explicit sharing with the included personal data visible. | P1 · [S22](#s22) | +| J37 | About/sidebar → update → download/retry/restart | App restart can interrupt an ongoing task; failures need a durable next action. | Clear progress and “Restart to update” when safe; retain existing active-work guards and retry. Distinguish installed version from downloaded update. | P1 · [S5](#s5), [S23](#s23) | +| J38 | About → reopen onboarding vs reset onboarding; diagnostics → export/delete | “Reset onboarding” sounds like replaying a tutorial but its description clears profile setup/preferences. | Rename by actual consequence; separate “Show setup again,” scoped repairs, and destructive reset. Support export explains local contents; sensitive dumps remain a separate explicit choice. | P0 · [S23](#s23) | +| J39 | Ask Assistant for help setting up the app | Assistant settings explicitly say it cannot inspect live settings/projects or use connected tools. | Initially provide accurate guidance and links. A future “Help me set this up” capability must use a bounded reviewed setup operation and confirmation; do not advertise it as shipped. | P1 · [S8](#s8) | + +## The Aiden On The Go proposal + +### Two setup cards, then a useful connected state + +Use **Aiden On The Go** as the user-facing destination, with “Remote Access” retained as a searchable alias. Settings, onboarding's optional feature tile, and the existing connection popover should open the same setup state. + +| Card | What the user sees | Primary action | What Aiden handles | +|---|---|---|---| +| **1. Connect your phone** | “Use your Bots and workspaces from your phone or tablet while Aiden is running on this Mac.” Connection choice: **Away from home — uses Tailscale on both devices** or **On the same Wi-Fi — no Tailscale needed**. Show the available recommended route, never hide its requirement. | **Connect a device** opens the acknowledgement below. | Read current settings and local readiness. Prepare a summary of the exact proposed changes. No service or route mutation merely from opening the page. | +| **2. Scan to finish** | After confirmation: compact preparation progress, then QR; “Open Aiden On The Go on your phone and scan this code.” Mac name visible. Manual-code fallback available. | Phone: **Scan code**. Desktop: **Create new code** only when required. | Enable the chosen service/mode, configure the Aiden-owned private connection when permitted, verify it, then open the existing one-use pairing window. Track authenticated completion. | +| **Connected summary** | “[Device name] is connected to [Mac name].” Show “Nearby only” or “Uses Tailscale,” available content, and “Keep Aiden running on your Mac.” | Phone: **Open a Bot** or **Open a workspace**, according to available content. | Show current reachability separately from saved pairing. Keep device management and advanced connection diagnostics below. | + +There are only two setup cards. If a dependency is missing, replace the preparation area inside card 2 with a single repair instruction. Do not add an expanding wall of independent switches. Optional folder access is a follow-up in the connected summary; it does not block pairing or Bot use. + +### One acknowledgement modal + +For the Tailscale path: + +> **Connect your phone to this Mac?** +> +> Aiden will turn on phone access, set up its private connection through Tailscale, and show a one-time code for your phone. +> +> Paired devices can use the workspaces and capabilities this Mac allows. Your AI keys stay on this Mac; requests still go to the AI service you choose. Keep Aiden running to use it from your phone. +> +> You can remove a device's access here at any time. +> +> **Enable and show code** · **Cancel** + +For nearby access, replace the first paragraph with: “Aiden will turn on phone access over your local network and show a one-time code. Your phone and Mac need to be on the same network.” + +The scope sentence must be built from the actual current permissions and allowed workspaces. Put a human-readable access summary behind **Review access**, with no new grants selected automatically. Enabling the network connection is not permission to grant the home folder, Full access, unattended tools, or every Bot capability. + +The primary button is the acknowledgement. Do not add an “I understand” checkbox or a second generic confirmation. Use a separate review only if the proposed action materially changes, such as replacing a previous Aiden connection or changing an existing device's route. + +### Honest click budget + +Count desktop clicks starting on the setup page; network waits, QR scanning, text entry, OS permissions, and external sign-in are recorded separately. Current counts are inferred from controls and vary with saved settings; establish the actual baseline in the live test. + +| Starting state | Target Aiden interaction | Extra work that must remain visible | +|---|---|---| +| Fresh Aiden remote setup, Tailscale already installed/signed in/HTTPS authorized, default route suitable | **Connect a device → Enable and show code** | Phone scan and any camera permission; both devices need authorized Tailscale connectivity. | +| Same Wi-Fi chosen instead of the suggested away route | Select **On the same Wi-Fi**, then the two actions above | Phone camera/local-network permissions and scan. This is three desktop clicks when a route choice is changed. | +| Existing ready connection; add another device with unchanged scope | **Add device** opens code directly; existing access summary remains visible | Phone scan. No repeated acknowledgement of unchanged settings. | +| Tailscale missing or signed out | Same two Aiden setup actions, then a guided prerequisite | Installation, sign-in on both devices, and any required HTTPS/admin approval. Resume rather than restart. No two-click completion claim. | +| Conflict or unknown previous route result | Explain and offer the applicable review/verification | Owner review or external repair may be necessary; do not overwrite a connection to satisfy a click target. | + +### State and recovery contract + +```mermaid +flowchart TD + A[Connect a device] --> B[Review and confirm access setup] + B --> C[Check selected connection prerequisites] + C -->|Missing| D[Show one specific setup action] + D -->|Return and recheck| C + C -->|Ready| E[Prepare and verify connection] + E -->|Verified| F[Show one-time QR code] + E -->|Conflict or uncertain result| G[Explain and review or verify] + G -->|Resolved| C + F -->|Authenticated phone completion| H[Connected: choose a first task] + F -->|Expired| I[Create a new code] + I --> F +``` + +| State | Plain-language presentation | Required behavior | +|---|---|---| +| Tailscale missing | “To connect away from home, install Tailscale on your Mac and phone.” **Get Tailscale**; **Use same Wi-Fi instead**. | Use reviewed official destinations; do not install or authorize it silently. Changing transport requires the updated scope to be visible. | +| Tailscale signed out | “Open Tailscale and sign in on both devices to the same private network.” **Open Tailscale**. | Recheck on return; preserve setup progress. Mac readiness alone cannot prove phone membership. | +| HTTPS approval missing | “Your Tailscale network needs permission to create a secure connection. You may need its administrator.” **View setup instructions**. | Keep HTTPS authorization explicit; do not auto-change account/network policy. | +| Preparing | “Turning on phone access…” → “Preparing your private connection…” → “Checking the connection…” | Ordered, bounded operations; one active attempt. No QR until the selected transport is verified. | +| Different Aiden profile uses route | “Another Aiden profile is using this Mac's phone connection.” | Keep the current route. Active owner blocks; a stale owner gets a specific review. Never overwrite unrelated routes or enable Funnel. | +| Unknown route result | “We couldn't confirm whether setup finished.” **Check connection**. | Reconcile observed ownership/health before retrying; never claim nothing changed without evidence. | +| QR ready / consumed / expired | “Scan this code”; “Finishing connection”; “This code expired.” | Preserve existing five-minute one-use lifecycle and identity checks. Do not weaken/manual-shorten the setup secret. Do not interrupt a completing phone handshake to rotate the code. | +| Camera denied / unavailable | “Camera access is off. You can enter a setup code instead.” | Provide accessible manual pairing and OS-settings recovery. Keep address required where discovery cannot supply it. | +| Paired but no usable content | “Connected. Open a Bot, or choose a workspace on your Mac.” | Tailor to actual inventory and granted scope; never silently approve a folder or select a different AI recipient. | +| Unreachable later | “Can't reach [Mac name]. Keep Aiden running and check the connection.” | Preserve drafts/cache with explicit freshness. Retry only safe reads; revoked credentials go to re-pairing and cache cleanup. | + +### Orchestration requirements for implementation + +Reuse existing service, route, pairing, and revocation logic. Add a main-process-owned setup coordinator rather than a fragile sequence of renderer toggle clicks. The new operation must: + +1. Capture the exact profile, prior enabled/mode state, route ownership, and reviewed change scope. Recheck before each mutation; reject a stale review if consequences changed. +2. Perform prerequisite checks before avoidable mutations. Then enable the selected service, configure only the owned connection, verify health, and begin pairing in the order required by the existing service contract. +3. Serialize attempts and handle double clicks, navigation, app restart, cancellation, and late responses. Reuse the existing authenticated pairing-completion lifecycle. +4. Preserve pre-existing enabled access, devices, roots, endpoints, and unrelated Tailscale handlers. Do not silently select “both” to make discovery easier or change an established mobile endpoint. +5. On failure/cancel, close the exact unused pairing session. Roll back only changes proven to belong to this attempt when no completed pairing or concurrent change depends on them. If cleanup is uncertain, report what is known and offer verification; never blanket-reset Tailscale. +6. Distinguish preparation, code-ready, paired, reachable, and useful-content states. A local listener, created QR, or consumed code alone is not completion. +7. Keep secrets, codes, endpoints, raw Tailscale output, and identifiers out of diagnostics. Optional UX measurements must be coarse local counters, not new upload telemetry. + +This is more than rearranging controls. Safe orchestration and recovery are the substantial engineering work; the two-card surface is its presentation. + +### Mobile parity and accurate copy + +- iOS and Android should use the same user concepts and state meanings while retaining native interaction patterns. Both default to scanning, support accessible manual entry, and put payload import under Advanced. +- Update desktop device labels, empty states, and onboarding copy to include Android where the shipped build supports it. `SettingsDeviceRow` currently renders every non-iPad device as “iPhone”; this needs contract review before choosing a corrected type mapping. +- Verified during implementation: the manual setup code decrypts the bootstrap on the phone and is never sent to the Mac. Preserve that accurate disclosure and the existing cryptography. This does not mean prompts or all chat data stay on the phone. +- Remove transport terminology from the primary phone path because a valid QR already specifies its endpoint. Keep endpoint/pin information available for manual setup and identity problems. +- Never bypass an identity mismatch, credential revocation, system permission, or unavailable feature on an older client. Provide a named recovery action or compatible fallback. + +## A consistent pattern for every setup + +Use **Choose outcome → review meaningful consequences → prepare automatically → verify → first useful action**. Keep the interface to two or three cards where that actually simplifies decisions. Do not force ordinary repeat actions into a wizard. + +| Setup | First card | Second card | Optional third / completion | +|---|---|---|---| +| AI connection | Choose how to connect | Sign in or provide required key; verify | Selected model and **Start a chat** | +| Bot | Describe its job | Review explicit model and minimal supported access | **Create and chat**; appearance/custom scope optional | +| Telegram | Connect your Telegram bot | Review owner and allowed work | Send pairing message; confirm connection | +| Voice | Choose where audio is processed | Complete required download/permission | Return to editable composer and record on intent | +| Computer Use | Explain screenshots and actions | Complete missing OS permissions | Enable for originating chat with existing action approvals | +| Plugin | Choose service and review access | Sign in/key and verify | Show available tools and return to task | +| Schedule | Describe work and when | Review exact time, model, scope and cost implications | Show next run and how to pause | + +Use one acknowledgement when an action enables remote access, changes recipients, grants capabilities, schedules unattended work, or downloads a substantial optional model. Use direct actions with clear feedback for ordinary navigation, unchanged repeat pairing, and reversible preferences. Preserve separate destructive confirmation where warranted. + +For new users, offer defaults derived from actual supported inventory. Do not silently replace a user's model, inherit Full access into a new Bot, enable unattended web/plugin access, or change a saved privacy preference. A “recommended” label needs a transparent reason, such as “already connected,” not an invented quality ranking. + +## Language and settings organization + +| Current wording | Proposed primary wording | Keep in detail when useful | +|---|---|---| +| Remote Access | Aiden On The Go / Connect your phone | Remote Access as search alias | +| Tailscale / Local Network | Away from home / On the same Wi-Fi | Tailscale requirement and actual route | +| Tailscale Serve | Private phone connection | Exact route/command and diagnostics | +| Approved roots | Folders your phone can browse | Root restrictions and existing-workspace distinction | +| Revoke | Remove device access | Immediate credential invalidation | +| Per-device credential / Pinned HTTPS identity | Only paired devices can connect | Identity verification details | +| Pi-powered teammate | Bot | Runtime names in developer information | +| Polling / polling lease | Connected / Checking for messages | Troubleshooting diagnostics | +| MCP server | App connection, or named plugin | MCP in Advanced and search aliases | +| Compaction | Keep long chats working | Summarization engine and experimental controls | +| Reset onboarding | Reset profile setup and preferences | Exact affected/preserved data from backend | + +Keep sentences accurate before making them shorter. For example, “Stored on this Mac” does not imply information is never included in a model request. “Same Wi-Fi” needs a fallback explanation for wired Macs and networks that isolate devices. “Away from home” still requires the Mac to be reachable and the phone to have the intended private-network access. + +Proposed Settings grouping, retaining deep links and expert access: + +| Group | Destinations / actions | +|---|---| +| AI and chat | AI connections, optional Model Pad, memory | +| Apps and tools | Plugins, Skills, Web Search, Computer Use | +| Phone and automation | Aiden On The Go, Telegram, Scheduled tasks | +| Personal preferences | Appearance, Voice, Keyboard shortcuts, Assistant | +| Help and app | Updates/About, replay setup, diagnostics, carefully separated reset | + +Prototype and test this grouping before moving navigation. The first implementation should repair high-friction journeys and copy without requiring a whole-app navigation migration. + +Every disabled primary control needs a nearby reason and a relevant action. Every empty state needs a next step. Every failure should say what happened, what is preserved, and what the user can do; do not promise preservation if outcome is unknown. Keep raw diagnostic detail expandable and copyable without displaying credentials. + +## Visual and accessibility requirements + +Use Aiden's existing semantic tokens and UI primitives, informed by [the desktop reference](../chatgpt-desktop-ui-inspiration.md) and [the interactive specimen](../chatgpt-ui-element-specimen.html). This proposal adds no new application UI or assets. + +- Cards use existing backgrounds and spacing. No decorative borders/outlines around radio choice cards; selection uses the radio and background state. +- Status uses soft semantic fills, text, and icons, never color alone or decorative colored outlines. +- Non-text keyboard controls retain visible neutral focus rings. Text-entry borders stay unchanged on focus; use existing input-background/caret states. +- Dialogs announce their title, contain focus appropriately, support cancel, and return focus to the initiating control. Step changes have concise screen-reader announcements. +- Progress is readable without animation. Respect reduced motion, text scaling, light/dark/high-contrast settings, and narrow desktop/mobile layouts. +- QR pairing cannot be the sole accessible route. Do not announce a countdown every second; announce meaningful state changes and keep time remaining available. +- Onboarding must stay concise and data-driven. Reuse the existing Aiden On The Go illustration when updating that tile. Any new advertised durable feature requires its own optimized 1024 × 1024 transparent PNG and the existing asset-contract test. + +## Delivery order and acceptance gates + +| Phase | Concrete deliverable | Dependencies and acceptance | +|---|---|---| +| 0 — Establish baseline | Walk through J01–J39 and mark observed/pass/fail/not applicable on actual builds; prototype remote cards. | No user-account/permission mutations during inspection without the corresponding user action. Record actions, navigation changes, completion, and confusing words. | +| 1 — Remote setup | Main-owned setup operation, two-card desktop flow, acknowledgement, recovery, native pairing copy/navigation, optional onboarding entry, updated remote guide. | Cover already-ready LAN/Tailscale, missing prerequisites, conflicts, cancellation, expiry, multiple profiles/devices, stale clients, and revocation. Two desktop clicks on the defined ready/default path. | +| 2 — First useful chat | Provider return-to-draft setup, deliberate model default, workspace wording, attachment compatibility recovery, actionable blocked composer. | Reuse onboarding validation; preserve provider choice and privacy defaults. Verify key-invalid, cancelled sign-in, no models, all-hidden models, offline provider, and failed send. | +| 3 — Other setup journeys | Bot, Telegram, voice, Computer Use, plugins, schedules using the shared UX pattern. | Ship in independently testable slices; keep backend access distinctions and explicit external authorization. No new unsupported “safe” Bot mode label. | +| 4 — Recovery and navigation | Settings intent search, memory/help/reset clarity, persistent repair actions, optional grouping changes. | Preserve deep links, shortcuts, accessibility, expert controls, and safe migration of existing preferences. | + +**Phase 1 definition of done:** a user can start at the setup page, understand what will be enabled, get a working code on a ready connection in two desktop clicks, finish pairing on iOS or Android, identify the connected Mac, open permitted content, recover from a blocked setup, and remove access. All existing identity/ownership safeguards must still pass. + +**Engineering checks for future implementation:** + +- Extend relevant existing desktop tests; register any added test file in `package.json`. Phase 1 includes `npm run test:aiden-remote`, `npm run test:onboarding`, and focused settings/command/connection-popover coverage where changed, plus type checking and build. +- For shared remote contracts or transcript/activity changes, inspect and update **both** native consumers and focused tests. Run applicable iOS tests under its documented Xcode workflow and Android Gradle suites; do not claim mobile validation from desktop tests alone. +- Other slices use the existing provider, Bots, scheduled, voice, web-search, Computer Use, memory, and command suites as relevant. Check `package.json` for the exact current scripts when implementing. +- Test outcome and state behavior, not only copy snapshots: one attempt per gesture, no premature success, no stale-result publication, preserved drafts, accurate cleanup, no unauthorized scope change, and safe re-entry. +- Add live keyboard/screen-reader, mobile camera/manual-entry, background/foreground, and Mac unavailable checks. Keep physical-device gates open until performed. + +**Validation for this document:** source references and local links checked, journey IDs checked for uniqueness, and `git diff --check`. Application tests are not required for this documentation-only proposal; none of the proposed behaviors have been implemented or runtime-validated here. + +## Usability study and success targets + +Use five to eight participants unfamiliar with developer tools. Include both mobile platforms, a keyboard-only/screen-reader session, someone without an AI connection, and someone without Tailscale. Use consented test accounts/devices; count external setup separately. + +| Task | What to observe | Proposed acceptance target, not a measured result | +|---|---|---| +| Connect AI and ask a first question | Abandonment, terms needing explanation, lost drafts | At least 80% complete without moderator intervention after account prerequisites are met. | +| Pair phone on prepared Tailscale | Desktop actions, transport confusion, accurate readiness | Two desktop clicks to QR on the defined default path; at least 80% finish pairing unassisted. | +| Pair phone with no Tailscale | External handoff, return/resume, understanding nearby alternative | Participants can identify the next required action and resume without repeating completed setup. | +| Recover from denied camera or unreachable Mac | Recovery discoverability, draft preservation | At least 80% find manual entry or the relevant recovery action without help. | +| Explain and remove access | Understanding of running Mac, provider requests, allowed work, removal | Every participant can locate removal; any misunderstanding of access/data consequences triggers copy redesign. | +| Create Bot and schedule a task | Required decisions and scope comprehension | At least 80% complete unassisted and can explain the selected model, access, next run, and Mac availability requirement. | + +Measure completion time, decision count, navigation changes, backtracking, assistance requests, and one post-task ease rating. Establish actual baseline values before setting time-reduction claims. Store research observations with consent; any product counters remain local and categorical unless separately approved. A small study identifies friction; it does not prove accessibility or population-wide success. + +## Implementation handoff prompt + +> Implement Phase 1 of `docs/plans/nontechnical-user-journey-ux-plan.md`. Read project instructions, current memory, remote hardening/manual-pairing plans, and both UI design references first. Replace dependency hunting in Aiden On The Go setup with the two-card, explicitly acknowledged flow and a main-process-owned coordinator. Preserve route ownership, endpoint stability, one-use codes, identity validation, permissions, per-device revocation, and all existing-device state. Keep Tailscale installation/sign-in/HTTPS authorization as guided external prerequisites. Include iOS and Android pairing/copy parity, relevant onboarding updates, recovery states, focused tests, remote setup documentation, and plan/memory updates. Treat the two-click budget as applying only to the specified prepared default route. Validate the work and report any remaining physical-device gates. Leave later phases proposed until separately scoped. + +## Source register + +Paths below are the authoritative audit evidence. Component code takes precedence where older narrative documentation uses superseded labels. Sources support the current-state observations; proposed copy and flows are recommendations. + +**S1 — First run:** [onboarding flow](../../renderer/components/onboarding-flow.tsx), [onboarding tests](../../renderer/components/onboarding-flow.test.tsx), [auth/validation plan](onboarding-auth-and-provider-validation-plan.md). + +**S2 — Settings:** [destinations and search keywords](../../renderer/shared/settings-section.ts), [settings view](../../renderer/main/settings-view.tsx). + +**S3 — Providers/models:** [providers](../../renderer/components/settings/providers-settings.tsx), [custom provider editor](../../renderer/components/settings/provider-editor.tsx), [model picker](../../renderer/components/model-picker.tsx), [model visibility](../../renderer/components/settings/provider-model-visibility.tsx). + +**S4 — Compose:** [composer](../../renderer/components/composer.tsx), [workspace picker](../../renderer/components/workspace-picker.tsx), [chat pane](../../renderer/main/chat-pane.tsx). + +**S5 — Navigation and history:** [sidebar](../../renderer/components/chat-sidebar.tsx), [chat layout](../../renderer/main/chat-layout.tsx). + +**S6 — Bots:** [desktop Bots/editor](../../renderer/main/bots-view.tsx), [iOS editor](../../ios/AidenOnTheGo/Features/Bots/AidenBotEditorView.swift), [Android editor](../../android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotEditorScreen.kt), [Bot-first plan](bot-first-aiden-on-the-go-plan.md). + +**S7 — Telegram:** [settings](../../renderer/components/settings/telegram-settings.tsx), [parity plan](telegram-first-class-agent-parity-plan.md). + +**S8 — Schedules/Assistant:** [task editor](../../renderer/components/scheduled-task-editor.tsx), [tasks view](../../renderer/components/scheduled-tasks-view.tsx), [Assistant capability disclosure](../../renderer/components/settings/assistant-settings.tsx), [Assistant automation approval](../../renderer/components/assistant/assistant-automation-approval.tsx). + +**S9 — Plugins/skills:** [plugin settings](../../renderer/components/settings/mcp-settings.tsx), [preset setup](../../renderer/components/settings/mcp-preset-setup.tsx), [skills](../../renderer/components/settings/skills-settings.tsx). + +**S10 — Progress/approval/recovery:** [activity feed](../../renderer/components/activity-feed.tsx), [subagent shell approval](../../renderer/components/subagent-shell-approval.tsx), [provider failure mapping](../../main/services/provider-failure.ts), [composer](../../renderer/components/composer.tsx). + +**S11 — Work surfaces/results:** [Files](../../renderer/components/files-panel.tsx), [Review](../../renderer/components/review-panel.tsx), [Environment](../../renderer/components/environment-panel.tsx), [artifact preview/export](../../renderer/components/html-artifact-frame.tsx), [commit](../../renderer/components/git-commit-dialog.tsx), [push](../../renderer/components/git-push-dialog.tsx). + +**S12 — Search and optional model information:** [Web Search](../../renderer/components/settings/web-search-settings.tsx), [Model Pad](../../renderer/components/settings/model-pad-settings.tsx), [model data](../../renderer/components/settings/model-data-settings.tsx), [manual catalog policy](../../AGENTS.md). + +**S13 — Desktop remote UX:** [settings and pairing dialog](../../renderer/components/settings/remote-access-settings.tsx), [remote settings tests](../../renderer/components/settings/remote-access-settings.test.tsx), [connection popover](../../renderer/components/remote-connection-popover.tsx), [pairing lifecycle](../../renderer/lib/remote-pairing-lifecycle.ts). + +**S14 — Remote boundaries:** [remote guide](../aiden-on-the-go-remote-access.md), [manual pairing plan](aiden-manual-pairing-plan.md), [multi-instance hardening](completed/aiden-remote-multi-instance-hardening-plan.md), [remote API](../aiden-remote-api-v1.md). + +**S15 — iOS pairing:** [onboarding and pairing view](../../ios/AidenOnTheGo/Features/Remote/AidenPairingView.swift), [remote client](../../ios/AidenOnTheGo/Networking/AidenRemoteClient.swift). + +**S16 — Android pairing:** [pairing and removal screen](../../android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenPairingScreen.kt), [remote client](../../android/app/src/main/java/sbtbiswas/AidenOnTheGo/networking/AidenRemoteClient.kt). + +**S17 — Native continuation:** [iOS coordinator](../../ios/AidenOnTheGo/Features/Remote/AidenRemoteCoordinator.swift), [iOS workspace shell](../../ios/AidenOnTheGo/Features/Remote/AidenWorkspaceShellView.swift), [Android product shell](../../android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenProductShellScreen.kt), [Android workspace shell](../../android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/workspaces/AidenWorkspaceShellScreen.kt). + +**S18 — Voice:** [voice settings](../../renderer/components/settings/voice-settings.tsx), [local voice setup](../../renderer/components/settings/local-voice-settings.tsx), [paired-Mac speech](../aiden-on-the-go-remote-access.md#paired-mac-voice-input). + +**S19 — Computer Use:** [settings and disclosures](../../renderer/components/settings/computer-use-settings.tsx), [hardening plan](update-microphone-computer-use-hardening-plan.md). + +**S20 — Memory:** [memory settings](../../renderer/components/settings/memory-settings.tsx), [compaction plan](compaction-plan.md). + +**S21 — Personal preferences:** [appearance](../../renderer/components/settings/appearance-settings.tsx), [shortcuts](../../renderer/components/settings/shortcut-settings.tsx), [semantic appearance definitions](../../renderer/shared/appearance.ts), [style tokens](../../renderer/styles.css). + +**S22 — Usage/sharing:** [profile](../../renderer/main/profile-view.tsx), [share card](../../renderer/components/usage/profile-share-card.tsx). + +**S23 — Maintenance:** [About/update/reset](../../renderer/components/settings/about-settings.tsx), [diagnostics](../../renderer/components/settings/diagnostics-settings.tsx), [test scripts](../../package.json). diff --git a/docs/ux/implementation-review.md b/docs/ux/implementation-review.md new file mode 100644 index 00000000..660f9df9 --- /dev/null +++ b/docs/ux/implementation-review.md @@ -0,0 +1,39 @@ +# Guided setup UX — implementation review + +Approved from [Now vs proposed](now-vs-proposed.html). The [journey chart](../plans/nontechnical-user-journey-ux-plan.md) records the broader backlog and the exact scope implemented here. + +## Quick review + +| Try this | Look for | +| --- | --- | +| First-run AI setup | ChatGPT, LM Studio, Ollama, Other Custom Provider; Other ways below. Custom setup cannot complete without an available default model. | +| Settings → Aiden On The Go | Connect your phone / Scan to finish. Choose a method, acknowledge once, scan the code. | +| Cancel the phone acknowledgement | Access stays off. No route changes. | +| Interrupt or fail phone preparation | New access is rolled back when the result is known. Existing access and unrelated routes are preserved. Uncertain changes require explicit verification. | +| Create a bot | Name and instructions → model and access. Optional appearance and detailed capabilities. Fresh desktop drafts start with no custom tool grants. | +| Telegram | Token → model/access → connect and pair. Enable and connect happen together after an unattended-access acknowledgement. | +| Voice | Choose where audio goes. Errors stay beside the draft with Open voice settings. | +| Computer Use | Read the screenshot/provider explanation before enable; then handle Mac permissions. | +| Scheduled task | Review the task and its access before creation. Failed saves keep the draft. | +| Connect a plugin | Connect checks the endpoint’s tool availability. Errors stay in the dialog. | +| Search Settings | Try “connect my phone”, “use my voice”, “connect my ai”, or “see my screen”. | +| Native pairing | Updated Mac instructions, scanning first, manual entry available, raw payload import under Advanced. | + +Two desktop actions means **Connect a device → Enable and show code after choosing the method**. External Tailscale installation/sign-in/HTTPS authorization, scanning, and OS permission prompts are additional steps. + +## Evidence + +- Desktop TypeScript and E2E TypeScript checks pass. +- Focused remote, onboarding, bot, Telegram, voice, scheduling, composer, plugin, and permission checks pass. Remote tests cover successful LAN/Tailscale setup, stale reviews, owner cancellation, concurrent attempts, rollback, preservation of enabled access, saved-route protection, and pending-outcome reconciliation. +- Electron walkthroughs cover the four provider choices, custom-provider validation, LM Studio discovery and relaunch, computer-control acknowledgement cancellation, guided LAN pairing cancellation/success, listener survival after closing the window, and all Settings destinations. +- The Bot editor Electron test uses a test-owned IPC catalog and captures its submitted Custom access. It deliberately fails saving to verify draft retention. It does not prove native Bot Keychain storage; the isolated profile cannot establish that authority. The separate Bot storage/permission suites pass. +- Android `:app:testDebugUnitTest` passes, including compiling the updated pairing UI. It uses the installed Android Studio JBR and local Android SDK. +- React Doctor reports no errors; its warnings concern existing large component/state patterns and draft resets when opening dialogs. ESLint passes for changed TypeScript files. +- Vite and Electron bundles build. The unchanged desktop C helpers compile with the installed Command Line Tools and the existing build flags. The normal `npm run build` wrapper is blocked because its sanitized child environment selects an Xcode installation with an unaccepted license. + +## Before release + +- Resolve the Xcode license and run the focused iOS native integration/pairing tests on the allowed physical device. No simulator was used. +- Complete a physical phone scan, actual Tailscale route setup/recovery, and device revocation walkthrough. Tests use local fixtures, not external accounts or a live tailnet. +- Verify native Bot Keychain storage in a suitable signed/test environment. No authority fallback was added to production. +- Conduct the nontechnical-user usability checks from the plan. The action reductions are implemented interaction counts, not measured user outcomes. Broader first-task suggestions and exhaustive 39-journey redesign remain tracked in the audit. diff --git a/docs/ux/now-vs-proposed.html b/docs/ux/now-vs-proposed.html new file mode 100644 index 00000000..52a9b94b --- /dev/null +++ b/docs/ux/now-vs-proposed.html @@ -0,0 +1,223 @@ + + + + + +Aiden · Now & proposed + + + + +
Aiden / UX review
Full audit ↗
+
+

Less setup. More doing.

The same capabilities, with an easier way in.

Visual concepts, based on the source audit. No real settings change.

+
+

+
NowSimplified reconstruction
+
ProposedTry the buttons ↗
+

+
+ +
+

+
+ + + diff --git a/ios/AidenOnTheGo/Features/Remote/AidenPairingView.swift b/ios/AidenOnTheGo/Features/Remote/AidenPairingView.swift index 2ac53bc2..80feba4f 100644 --- a/ios/AidenOnTheGo/Features/Remote/AidenPairingView.swift +++ b/ios/AidenOnTheGo/Features/Remote/AidenPairingView.swift @@ -9,7 +9,7 @@ struct AidenDiscoveredAgent: Identifiable, Equatable { enum AidenPairingAlertCopy { static let title = String(localized: "Aiden On The Go") - static let fallbackMessage = String(localized: "Try again from Aiden Agent Remote Access settings.") + static let fallbackMessage = String(localized: "Try again from Aiden Agent → Settings → Aiden On The Go.") } enum AidenDiscoveryIdentity { @@ -574,18 +574,18 @@ struct AidenPairingView: View { VStack(alignment: .leading, spacing: 28) { VStack(alignment: .leading, spacing: 8) { Text("Prepare your Mac").font(.largeTitle.bold()) - Text("Aiden Agent remains the server and keeps provider credentials on your Mac.") + Text("Your Mac does the work. Your AI account keys stay on your Mac.") .foregroundStyle(palette.secondary) } - pairingStep(number: 1, title: "Open Aiden Agent", detail: "On your Mac, go to Settings → Remote Access.") - pairingStep(number: 2, title: "Turn on Remote Access", detail: "Choose Local Network, Tailscale, or both. Tailscale is best when you are away from home.") - pairingStep(number: 3, title: "Create a pairing code", detail: "Keep the QR or setup code visible. Both expire after five minutes and can be used once.") + pairingStep(number: 1, title: "Open Aiden Agent", detail: "On your Mac, go to Settings → Aiden On The Go.") + pairingStep(number: 2, title: "Connect your phone", detail: "Choose where you’ll use Aiden, then select Connect a device. Review what Aiden will enable.") + pairingStep(number: 3, title: "Scan to finish", detail: "Keep the QR or setup code visible. Both expire after five minutes and can be used once.") VStack(alignment: .leading, spacing: 10) { - Label("Per-device credential", systemImage: "key.fill") - Label("Pinned HTTPS identity", systemImage: "lock.shield.fill") - Label("Revocable from your Mac", systemImage: "checkmark.shield") + Label("Only devices you connect can access Aiden", systemImage: "key.fill") + Label("Encrypted connection to your Mac", systemImage: "lock.shield.fill") + Label("Remove access from your Mac at any time", systemImage: "checkmark.shield") } .font(.subheadline) .foregroundStyle(palette.secondary) @@ -599,7 +599,7 @@ struct AidenPairingView: View { onIntroductionComplete?() step = 2 }) { - Text("Choose How to Connect") + Text("Scan the Code") } .padding(.bottom, AidenMobileOnboardingLayout.actionBottomPadding) } @@ -624,11 +624,12 @@ struct AidenPairingView: View { private var pairingPage: some View { VStack(spacing: 0) { VStack(alignment: .leading, spacing: 12) { - Text("Choose the connection shown in Aiden Agent’s Add Device window.") + Text("Scan the code in Settings → Aiden On The Go on your Mac.") .font(.subheadline) .foregroundStyle(palette.secondary) .fixedSize(horizontal: false, vertical: true) + DisclosureGroup("Other ways to connect") { Picker("Connection method", selection: $selectedPairingMethod) { ForEach(AidenPairingMethod.primary) { method in Text(method.tabTitle).tag(method) @@ -636,6 +637,7 @@ struct AidenPairingView: View { } .pickerStyle(.segmented) .accessibilityHint("Swipe the content below or choose a tab.") + } } .padding(.horizontal, 18) .padding(.top, 12) diff --git a/ios/AidenOnTheGoTests/AidenNativeIntegrationTests.swift b/ios/AidenOnTheGoTests/AidenNativeIntegrationTests.swift index 9051f11e..059d327a 100644 --- a/ios/AidenOnTheGoTests/AidenNativeIntegrationTests.swift +++ b/ios/AidenOnTheGoTests/AidenNativeIntegrationTests.swift @@ -527,7 +527,7 @@ final class AidenNativeIntegrationTests: XCTestCase { XCTAssertEqual(AidenPairingAlertCopy.title, "Aiden On The Go") XCTAssertEqual( AidenPairingAlertCopy.fallbackMessage, - "Try again from Aiden Agent Remote Access settings." + "Try again from Aiden Agent → Settings → Aiden On The Go." ) } diff --git a/main/handlers/aiden-remote.test.ts b/main/handlers/aiden-remote.test.ts index dea69e4a..9460963b 100644 --- a/main/handlers/aiden-remote.test.ts +++ b/main/handlers/aiden-remote.test.ts @@ -52,3 +52,15 @@ test("saved endpoint repair is an explicit IPC action", async () => { assert.match(source, /ipcMain\.handle\("remote:moveToAvailablePort"/u); assert.match(source, /service\.moveToAvailablePort\(\)/u); }); + + +test("guided setup IPC binds the acknowledgement to its live document and settings", async () => { + const source = await readFile(new URL("./aiden-remote.ts", import.meta.url), "utf8"); + const handler = source.slice(source.indexOf('ipcMain.handle("remote:setupPairing"'), source.indexOf('ipcMain.handle("remote:beginPairing"')); + assert.match(handler, /rendererDocumentOwner/u); + assert.match(handler, /parseAidenRemoteTransport\(transport\)/u); + assert.match(handler, /typeof review.enabled !== "boolean"/u); + assert.match(handler, /parseAidenRemoteConnectionMode\(review.connectionMode\)/u); + assert.match(handler, /service.setupPairing/u); + assert.match(handler, /!owner.isDestroyed\(\)/u); +}); diff --git a/main/handlers/aiden-remote.ts b/main/handlers/aiden-remote.ts index 938a46f3..4f2fe66f 100644 --- a/main/handlers/aiden-remote.ts +++ b/main/handlers/aiden-remote.ts @@ -129,6 +129,23 @@ export function registerAidenRemoteHandlers(): void { return settingsSnapshot(); }); + ipcMain.handle("remote:setupPairing", async (event, transport: unknown, expected: unknown) => { + const owner = rendererDocumentOwner(event, () => new Error("Phone setup requires the active application document.")); + const selectedTransport = parseAidenRemoteTransport(transport); + if (!expected || typeof expected !== "object" || Array.isArray(expected)) throw new Error("Invalid phone setup review."); + const review = expected as Record; + if (typeof review.instanceId !== "string" || review.instanceId.length > 128 + || typeof review.enabled !== "boolean") throw new Error("Invalid phone setup review."); + const connectionMode = parseAidenRemoteConnectionMode(review.connectionMode); + const service = (await getAidenRemoteRuntime()).service; + const pairing = await service.setupPairing(selectedTransport, { + instanceId: review.instanceId, enabled: review.enabled, connectionMode, + }, () => !owner.isDestroyed()); + return { ...pairing.bootstrap, pairingSessionId: pairing.sessionId, + qrPayload: pairing.qrPayload ?? service.pairingQrPayload(pairing.bootstrap, selectedTransport), + manualCode: pairing.manualCode }; + }); + ipcMain.handle("remote:beginPairing", async (_event, transport: unknown) => { const selectedTransport = parseAidenRemoteTransport(transport); const service = (await getAidenRemoteRuntime()).service; diff --git a/main/services/aiden-remote-service.test.ts b/main/services/aiden-remote-service.test.ts index 35db37fe..862e8dc5 100644 --- a/main/services/aiden-remote-service.test.ts +++ b/main/services/aiden-remote-service.test.ts @@ -196,6 +196,7 @@ async function fixture( } return { installed: true, + httpsAvailable: true, dnsName: "aiden.tailnet.ts.net", ...(options.tailscaleServeStatus ? { serveStatus: options.tailscaleServeStatus } @@ -1606,3 +1607,152 @@ test("two paired devices authenticate independently and revoking one leaves the await app.cleanup(); } }); + + +test("guided LAN setup enables access and issues one expiring pairing in one operation", async () => { + const f = await fixture(); + try { + const before = await f.state.snapshot(); + const pairing = await f.service.setupPairing("lan", before); + assert.ok(pairing.qrPayload); + assert.ok(pairing.manualCode); + assert.equal((await f.state.snapshot()).enabled, true); + assert.equal((await f.service.status()).running, true); + assert.equal(f.tailscale.connects, 0); + assert.equal(f.service.pairingStatus()?.state, "awaiting_scan"); + await assert.rejects(f.service.setupPairing("lan", await f.state.snapshot()), /already open/); + assert.equal(f.service.pairingStatus()?.sessionId, pairing.sessionId); + } finally { await f.cleanup(); } +}); + +test("guided setup rejects a stale review before enabling listeners", async () => { + const f = await fixture(); + try { + const before = await f.state.snapshot(); + await f.service.setConnectionMode("both"); + await assert.rejects(f.service.setupPairing("lan", before), /changed/); + assert.equal((await f.state.snapshot()).enabled, false); + assert.equal(f.bonjour.starts, 0); + } finally { await f.cleanup(); } +}); + +test("guided setup checks Tailscale prerequisites without changing access", async () => { + const f = await fixture({ tailscaleInspection: { + connectionStatus: { installed: false }, assessment: { state: "unavailable" }, + } }); + try { + const before = await f.state.snapshot(); + await assert.rejects(f.service.setupPairing("tailscale", before), /tailscale_not_installed/); + assert.deepEqual(await f.state.snapshot(), before); + assert.equal(f.tailscale.connects, 0); + assert.equal(f.bonjour.starts, 0); + } finally { await f.cleanup(); } +}); + +test("guided Tailscale setup enables the owned route and returns a sealed code", async () => { + const f = await fixture({ tailscaleAssessment: { state: "owned" } }); + try { + const pairing = await f.service.setupPairing("tailscale", await f.state.snapshot()); + assert.ok(pairing.qrPayload); + assert.equal(f.tailscale.connects, 1); + assert.equal((await f.state.snapshot()).connectionMode, "tailscale"); + assert.ok((await f.state.snapshot()).tailscaleOwnership); + } finally { await f.cleanup(); } +}); + +test("guided setup rolls back newly enabled listeners if its owning window closes", async () => { + let current = true; + const f = await fixture({ afterListenerBound: async () => { current = false; } }); + try { + await assert.rejects(f.service.setupPairing("lan", await f.state.snapshot(), () => current), /cancelled/); + assert.equal((await f.state.snapshot()).enabled, false); + assert.equal((await f.service.status()).running, false); + assert.equal(f.service.pairingStatus(), undefined); + } finally { await f.cleanup(); } +}); + +test("a failed fresh Tailscale pairing restores the original connection mode", async () => { + const f = await fixture({ tailscaleAssessment: { state: "unrelated_conflict" } }); + try { + const before = await f.state.snapshot(); + await assert.rejects(f.service.setupPairing("tailscale", before)); + const after = await f.state.snapshot(); + assert.equal(after.enabled, false); + assert.equal(after.connectionMode, before.connectionMode); + assert.equal(after.tailscaleOwnership, undefined); + assert.equal((await f.service.status()).running, false); + } finally { await f.cleanup(); } +}); + +test("simultaneous guided setup cannot issue competing pairing sessions", async () => { + let release!: () => void; + let bound!: () => void; + const reached = new Promise((resolve) => { bound = resolve; }); + const gate = new Promise((resolve) => { release = resolve; }); + const f = await fixture({ afterListenerBound: async () => { bound(); await gate; } }); + try { + const before = await f.state.snapshot(); + const first = f.service.setupPairing("lan", before); + await within(reached); + await assert.rejects(f.service.setupPairing("lan", before), /already in progress/); + release(); + assert.ok((await first).qrPayload); + } finally { release(); await f.cleanup(); } +}); + + +test("failed guided setup preserves an already enabled local connection", async () => { + const f = await fixture({ tailscaleAssessment: { state: "unrelated_conflict" } }); + try { + await f.service.setEnabled(true); + const before = await f.state.snapshot(); + await assert.rejects(f.service.setupPairing("tailscale", before)); + assert.equal((await f.state.snapshot()).enabled, true); + assert.equal((await f.state.snapshot()).connectionMode, "lan"); + assert.equal((await f.service.status()).running, true); + assert.equal((await f.state.snapshot()).tailscaleOwnership, undefined); + } finally { await f.cleanup(); } +}); + + +test("guided setup preserves an uncertain external route for explicit reconciliation", async () => { + const f = await fixture({ initial: (state) => { + state.tailscalePendingOutcome = { + operation: "connect", target: `http://127.0.0.1:${state.lanPort + 1}/api/aiden/v1`, + beforeFingerprint: "a".repeat(64), preservedFingerprint: "b".repeat(64), + normalizeListenerScaffolding: false, createdAt: 1_000, + }; + } }); + try { + const before = await f.state.snapshot(); + await assert.rejects(f.service.setupPairing("lan", before), /reconciliation_required/); + assert.deepEqual(await f.state.snapshot(), before); + assert.equal(f.tailscale.disconnects, 0); + assert.equal(f.tailscale.reconciles, 0); + } finally { await f.cleanup(); } +}); + + +test("failed pairing removes only the new route when existing access stays enabled", async () => { + const f = await fixture({ mode: "both", tailscaleAssessment: { state: "unrelated_conflict" } }); + try { + await f.service.setEnabled(true); + await assert.rejects(f.service.setupPairing("tailscale", await f.state.snapshot())); + assert.equal((await f.state.snapshot()).enabled, true); + assert.equal((await f.state.snapshot()).connectionMode, "both"); + assert.equal((await f.state.snapshot()).tailscaleOwnership, undefined); + assert.equal(f.tailscale.disconnects, 1); + } finally { await f.cleanup(); } +}); + +test("guided setup never changes the mode of a saved private connection", async () => { + const f = await fixture({ mode: "tailscale", tailscaleAssessment: { state: "owned" } }); + try { + await f.service.setEnabled(true); + await f.service.connectTailscale(); + const before = await f.state.snapshot(); + await assert.rejects(f.service.setupPairing("lan", before), /saved connection/); + assert.deepEqual(await f.state.snapshot(), before); + assert.equal(f.tailscale.disconnects, 0); + } finally { await f.cleanup(); } +}); diff --git a/main/services/aiden-remote-service.ts b/main/services/aiden-remote-service.ts index 671126c5..55233e13 100644 --- a/main/services/aiden-remote-service.ts +++ b/main/services/aiden-remote-service.ts @@ -386,6 +386,7 @@ export class AidenRemoteService { private activeState: AidenRemoteStateDocument | null = null; private lastError: string | undefined; private lastErrorCode: "remote_port_in_use" | undefined; + private setupInFlight = false; private operationTail: Promise = Promise.resolve(); private settleRemoteApi: (() => Promise) | undefined; private readonly now: () => number; @@ -692,34 +693,36 @@ export class AidenRemoteService { } async setEnabled(enabled: boolean): Promise { - await this.serialized(async () => { - const current = await this.options.state.snapshot(); - if (enabled) { - if (!current.enabled || !this.activeState) { - await this.startConfigured({ ...current, enabled: true }); - try { - await this.options.state.setEnabled(true); - } catch (error) { - await this.stopListeners(); - throw error; - } - } - return; - } - let disconnectError: unknown; - if (current.tailscaleOwnership) { + return this.serialized(() => this.setEnabledInternal(enabled)); + } + + private async setEnabledInternal(enabled: boolean): Promise { + const current = await this.options.state.snapshot(); + if (enabled) { + if (!current.enabled || !this.activeState) { + await this.startConfigured({ ...current, enabled: true }); try { - await this.disconnectTailscaleInternal(current); + await this.options.state.setEnabled(true); } catch (error) { - disconnectError = error; + await this.stopListeners(); + throw error; } } - await this.stopListeners(); - await this.options.state.setEnabled(false); - this.lastError = undefined; - this.lastErrorCode = undefined; - if (disconnectError) throw disconnectError; - }); + return; + } + let disconnectError: unknown; + if (current.tailscaleOwnership) { + try { + await this.disconnectTailscaleInternal(current); + } catch (error) { + disconnectError = error; + } + } + await this.stopListeners(); + await this.options.state.setEnabled(false); + this.lastError = undefined; + this.lastErrorCode = undefined; + if (disconnectError) throw disconnectError; } /** @@ -755,39 +758,41 @@ export class AidenRemoteService { } async setConnectionMode(connectionMode: AidenRemoteConnectionMode): Promise { - await this.serialized(async () => { - const current = await this.options.state.snapshot(); - if (current.tailscaleOwnership && connectionMode === "lan") { - await this.disconnectTailscaleInternal(current); + return this.serialized(() => this.setConnectionModeInternal(connectionMode)); + } + + private async setConnectionModeInternal(connectionMode: AidenRemoteConnectionMode): Promise { + const current = await this.options.state.snapshot(); + if (current.tailscaleOwnership && connectionMode === "lan") { + await this.disconnectTailscaleInternal(current); + } + await this.options.state.setConnectionMode(connectionMode); + if (current.enabled) { + if (!this.activeState || !this.lanServer || !this.tailscaleServer) { + await this.startConfigured({ ...current, connectionMode }); + return; } - await this.options.state.setConnectionMode(connectionMode); - if (current.enabled) { - if (!this.activeState || !this.lanServer || !this.tailscaleServer) { - await this.startConfigured({ ...current, connectionMode }); - return; - } - const previouslyAdvertised = current.connectionMode === "lan" - || current.connectionMode === "both"; - const shouldAdvertise = connectionMode === "lan" || connectionMode === "both"; - this.activeState.connectionMode = connectionMode; - if (connectionMode === "tailscale") this.destroyConnections(this.lanConnections); - if (connectionMode === "lan") this.destroyConnections(this.tailscaleConnections); - if (previouslyAdvertised && !shouldAdvertise) { - this.options.bonjour.stop(); - } else if (!previouslyAdvertised && shouldAdvertise) { - try { - await this.publishBonjour({ - instanceId: this.activeState.instanceId, - displayName: this.activeState.displayName, - port: this.activeState.lanPort, - }); - } catch (error) { - await this.stopListeners(); - throw error; - } + const previouslyAdvertised = current.connectionMode === "lan" + || current.connectionMode === "both"; + const shouldAdvertise = connectionMode === "lan" || connectionMode === "both"; + this.activeState.connectionMode = connectionMode; + if (connectionMode === "tailscale") this.destroyConnections(this.lanConnections); + if (connectionMode === "lan") this.destroyConnections(this.tailscaleConnections); + if (previouslyAdvertised && !shouldAdvertise) { + this.options.bonjour.stop(); + } else if (!previouslyAdvertised && shouldAdvertise) { + try { + await this.publishBonjour({ + instanceId: this.activeState.instanceId, + displayName: this.activeState.displayName, + port: this.activeState.lanPort, + }); + } catch (error) { + await this.stopListeners(); + throw error; } } - }); + } } private destroyConnections(connections: Set): void { @@ -823,32 +828,34 @@ export class AidenRemoteService { } async connectTailscale(): Promise { - await this.serialized(async () => { - const state = await this.options.state.snapshot(); - if (state.tailscalePendingOutcome) throw new Error("tailscale_reconciliation_required"); - if (!state.enabled || (state.connectionMode !== "tailscale" && state.connectionMode !== "both")) { - throw new Error("Enable Aiden Remote with Tailscale access before connecting Serve."); - } - if (!this.tailscaleServer) throw new Error("Aiden Remote loopback service is not running."); - const target = this.loopbackTarget(state); - let ownership = state.tailscaleOwnership; - if (ownership && ownership.target !== target) { - // Pre-acceptance builds persisted an origin-only target that cannot - // route the canonical API after Tailscale strips --set-path. Remove - // only that exact owned route before creating the corrected one. - await this.options.tailscale.disconnect( - ownership.target, - ownership, - () => this.options.state.commitTailscaleOutcome(undefined), - ); - ownership = undefined; - } - await this.options.tailscale.connect( - target, + return this.serialized(() => this.connectTailscaleInternal()); + } + + private async connectTailscaleInternal(): Promise { + const state = await this.options.state.snapshot(); + if (state.tailscalePendingOutcome) throw new Error("tailscale_reconciliation_required"); + if (!state.enabled || (state.connectionMode !== "tailscale" && state.connectionMode !== "both")) { + throw new Error("Enable Aiden Remote with Tailscale access before connecting Serve."); + } + if (!this.tailscaleServer) throw new Error("Aiden Remote loopback service is not running."); + const target = this.loopbackTarget(state); + let ownership = state.tailscaleOwnership; + if (ownership && ownership.target !== target) { + // Pre-acceptance builds persisted an origin-only target that cannot + // route the canonical API after Tailscale strips --set-path. Remove + // only that exact owned route before creating the corrected one. + await this.options.tailscale.disconnect( + ownership.target, ownership, - (nextOwnership) => this.options.state.commitTailscaleOutcome(nextOwnership), + () => this.options.state.commitTailscaleOutcome(undefined), ); - }); + ownership = undefined; + } + await this.options.tailscale.connect( + target, + ownership, + (nextOwnership) => this.options.state.commitTailscaleOutcome(nextOwnership), + ); } async reviewTailscaleTakeover(): Promise { @@ -916,79 +923,164 @@ export class AidenRemoteService { } async beginPairing(transport: "lan" | "tailscale"): Promise { - return this.serialized(async () => { - const state = await this.options.state.snapshot(); - if (!state.enabled || !this.pairing || !this.tlsIdentity) { - throw new Error("Enable Aiden Remote before pairing a device."); + return this.serialized(() => this.beginPairingInternal(transport)); + } + + private async beginPairingInternal(transport: "lan" | "tailscale"): Promise { + const state = await this.options.state.snapshot(); + if (!state.enabled || !this.pairing || !this.tlsIdentity) { + throw new Error("Enable Aiden Remote before pairing a device."); + } + let endpoint: string; + let serverSpkiSha256: string; + if (transport === "lan") { + if ( + !this.lanServer + || (state.connectionMode !== "lan" && state.connectionMode !== "both") + ) throw new Error("Local-network access is not enabled."); + endpoint = `https://${localDnsName(this.hostname)}:${state.lanPort}${AIDEN_REMOTE_BASE_PATH}`; + serverSpkiSha256 = this.tlsIdentity.serverSpkiSha256; + } else { + if (state.tailscalePendingOutcome) { + throw new Error("Verify the previous Tailscale route update before pairing."); } - let endpoint: string; - let serverSpkiSha256: string; - if (transport === "lan") { - if ( - !this.lanServer - || (state.connectionMode !== "lan" && state.connectionMode !== "both") - ) throw new Error("Local-network access is not enabled."); - endpoint = `https://${localDnsName(this.hostname)}:${state.lanPort}${AIDEN_REMOTE_BASE_PATH}`; - serverSpkiSha256 = this.tlsIdentity.serverSpkiSha256; + if ( + !state.tailscaleOwnership + || !this.tailscaleServer + || (state.connectionMode !== "tailscale" && state.connectionMode !== "both") + ) { + throw new Error("Connect the Aiden Tailscale Serve route before pairing."); + } + const inspection = this.options.tailscale.inspectRoute + ? await this.options.tailscale.inspectRoute( + this.loopbackTarget(state), + state.tailscaleOwnership, + ) + : undefined; + const status = inspection?.connectionStatus ?? await this.options.tailscale.status(); + if (inspection || this.options.tailscale.assessRoute) { + const assessment = inspection?.assessment ?? await this.options.tailscale.assessRoute!( + this.loopbackTarget(state), + state.tailscaleOwnership, + ); + if (assessment.state !== "owned" || assessment.errorCode) { + throw new Error("The Tailscale route is not privately connected to this Aiden profile."); + } } else { - if (state.tailscalePendingOutcome) { - throw new Error("Verify the previous Tailscale route update before pairing."); + let connected = false; + try { + connected = status.serveStatus !== undefined + && planAidenTailscaleConnect( + status.serveStatus, + this.loopbackTarget(state), + state.tailscaleOwnership, + status.httpsAvailable, + ).action === "noop"; + } catch { + connected = false; } - if ( - !state.tailscaleOwnership - || !this.tailscaleServer - || (state.connectionMode !== "tailscale" && state.connectionMode !== "both") - ) { - throw new Error("Connect the Aiden Tailscale Serve route before pairing."); + if (!connected) { + throw new Error("The Tailscale route is not privately connected to this Aiden profile."); } - const inspection = this.options.tailscale.inspectRoute - ? await this.options.tailscale.inspectRoute( - this.loopbackTarget(state), - state.tailscaleOwnership, - ) - : undefined; - const status = inspection?.connectionStatus ?? await this.options.tailscale.status(); - if (inspection || this.options.tailscale.assessRoute) { - const assessment = inspection?.assessment ?? await this.options.tailscale.assessRoute!( - this.loopbackTarget(state), - state.tailscaleOwnership, + } + if (!status.dnsName) throw new Error("Tailscale does not report a stable DNS name."); + endpoint = `https://${status.dnsName}${AIDEN_REMOTE_BASE_PATH}`; + serverSpkiSha256 = await ( + this.options.resolveTlsEndpointPin ?? fetchTlsServerSpkiSha256 + )(status.dnsName, 443); + } + const pairing = this.pairing.begin(endpoint, serverSpkiSha256); + try { + const qrPayload = this.pairingQrPayload(pairing.bootstrap, transport); + this.pairing.sealManualPayload(pairing.sessionId, qrPayload); + return { ...pairing, qrPayload }; + } catch (error) { + this.pairing.close(pairing.sessionId); + throw error; + } + } + + /** One acknowledged desktop action; shares the service mutation lane with advanced controls. */ + async setupPairing( + transport: "lan" | "tailscale", + expected: { instanceId: string; enabled: boolean; connectionMode: AidenRemoteConnectionMode }, + isCurrent: () => boolean = () => true, + ): Promise { + if (this.setupInFlight) throw new Error("Phone setup is already in progress."); + this.setupInFlight = true; + try { + return await this.serialized(async () => { + const current = await this.options.state.snapshot(); + const checkOwner = () => { + if (!isCurrent()) throw new Error("Phone setup was cancelled. Return to Settings to try again."); + }; + checkOwner(); + if (current.instanceId !== expected.instanceId || current.enabled !== expected.enabled + || current.connectionMode !== expected.connectionMode) { + throw new Error("Phone access changed. Review the setup again before continuing."); + } + if (current.tailscalePendingOutcome) throw new Error("tailscale_reconciliation_required"); + const mode = current.connectionMode === "both" ? "both" : transport; + // Changing a saved transport can strand existing devices. Keep that an + // explicit advanced operation, rather than silently choosing both. + if (mode !== current.connectionMode && (current.devices.length || current.tailscaleOwnership)) { + throw new Error("This Mac already has a saved connection. Use its current method, or review Connection settings before changing it."); + } + if (["finishing", "awaiting_scan"].includes(this.pairingStatus()?.state ?? "")) { + throw new Error("A phone connection is already open. Finish or close it before adding another device."); + } + if (transport === "tailscale") { + const inspection = await this.options.tailscale.inspectRoute?.( + this.loopbackTarget(current), current.tailscaleOwnership, ); - if (assessment.state !== "owned" || assessment.errorCode) { - throw new Error("The Tailscale route is not privately connected to this Aiden profile."); + const connection = inspection?.connectionStatus ?? await this.options.tailscale.status(); + checkOwner(); + if (!connection.installed) throw new Error("tailscale_not_installed"); + if (connection.errorCode) throw new Error(`tailscale_${connection.errorCode}`); + if (!connection.dnsName) throw new Error("tailscale_not_connected"); + if (connection.httpsAvailable !== true) throw new Error("tailscale_https_unavailable"); + if (inspection && !["available", "owned"].includes(inspection.assessment.state)) { + throw new Error("This phone connection is already in use or needs review. Open Connection settings to resolve it; nothing was replaced."); } - } else { - let connected = false; - try { - connected = status.serveStatus !== undefined - && planAidenTailscaleConnect( - status.serveStatus, - this.loopbackTarget(state), - state.tailscaleOwnership, - status.httpsAvailable, - ).action === "noop"; - } catch { - connected = false; - } - if (!connected) { - throw new Error("The Tailscale route is not privately connected to this Aiden profile."); + } + let pairing: AidenRemoteDesktopPairing | undefined; + try { + if (mode !== current.connectionMode) await this.setConnectionModeInternal(mode); + checkOwner(); + await this.setEnabledInternal(true); + checkOwner(); + if (transport === "tailscale") await this.connectTailscaleInternal(); + checkOwner(); + pairing = await this.beginPairingInternal(transport); + checkOwner(); + return pairing; + } catch (error) { + if (pairing) this.pairing?.close(pairing.sessionId); + const after = await this.options.state.snapshot(); + // Keep uncertain external results available for explicit reconciliation. + // Roll back only the access introduced by this acknowledged attempt. + if (!after.tailscalePendingOutcome) { + try { + if (!current.tailscaleOwnership && after.tailscaleOwnership) { + await this.disconnectTailscaleInternal(after); + } + if (!current.enabled) { + await this.stopListeners(); + await this.options.state.setEnabled(false); + if (mode !== current.connectionMode) await this.options.state.setConnectionMode(current.connectionMode); + } else if (mode !== current.connectionMode) { + await this.setConnectionModeInternal(current.connectionMode); + } + } catch { + throw new Error("Phone setup did not finish and cleanup could not be confirmed. Check Connection settings before trying again."); + } } + throw error; } - if (!status.dnsName) throw new Error("Tailscale does not report a stable DNS name."); - endpoint = `https://${status.dnsName}${AIDEN_REMOTE_BASE_PATH}`; - serverSpkiSha256 = await ( - this.options.resolveTlsEndpointPin ?? fetchTlsServerSpkiSha256 - )(status.dnsName, 443); - } - const pairing = this.pairing.begin(endpoint, serverSpkiSha256); - try { - const qrPayload = this.pairingQrPayload(pairing.bootstrap, transport); - this.pairing.sealManualPayload(pairing.sessionId, qrPayload); - return { ...pairing, qrPayload }; - } catch (error) { - this.pairing.close(pairing.sessionId); - throw error; - } - }); + }); + } finally { + this.setupInFlight = false; + } } async closePairing(sessionId: string): Promise { diff --git a/renderer/components/composer.test.tsx b/renderer/components/composer.test.tsx index 70f6439c..57028529 100644 --- a/renderer/components/composer.test.tsx +++ b/renderer/components/composer.test.tsx @@ -253,3 +253,15 @@ test("workspace access keyboard navigation moves focus without changing permissi assert.match(composer, /radios\?\.\[nextIndex\]\?\.focus\(\)/u); assert.doesNotMatch(composer, /requestPermission\(nextPermission\)/u); }); + + +test("voice recovery preserves the draft and offers a direct settings action", () => { + const composer = source("./composer.tsx"); + const recorder = source("../lib/use-voice-recorder.ts"); + assert.match(composer, /voice.lastError/u); + assert.match(composer, /Open voice settings/u); + assert.match(composer, /Your draft is still here/u); + assert.match(composer, /voice.dismissError/u); + assert.match(recorder, /setLastError\(message\)/u); + assert.match(composer, /onOpenSettings && readinessSettingsSection/u); +}); diff --git a/renderer/components/composer.tsx b/renderer/components/composer.tsx index d55f1229..9fc30d77 100644 --- a/renderer/components/composer.tsx +++ b/renderer/components/composer.tsx @@ -112,6 +112,7 @@ interface ComposerProps { ready: boolean; /** Actionable explanation for a disabled send state. */ readinessMessage?: string; + readinessSettingsSection?: SettingsSection; /** True once this chat has a persisted message. */ hasMessages: boolean; /** Stable identifier used to select an empty-chat prompt. */ @@ -261,6 +262,7 @@ function composerDraftReducer( export function Composer({ ready, readinessMessage, + readinessSettingsSection, hasMessages, chatId, onSend, @@ -1721,9 +1723,17 @@ export function Composer({ ) : null} ) : null} - {!ready && readinessMessage && text.trim().length > 0 ? ( + {voice.lastError ? ( +
+ {voice.lastError} Your draft is still here. + {onOpenSettings ? : null} + +
+ ) : null} + {!ready && readinessMessage ? ( {readinessMessage} + {onOpenSettings && readinessSettingsSection ? : null} ) : null}
diff --git a/renderer/components/onboarding-flow.test.tsx b/renderer/components/onboarding-flow.test.tsx index 624db126..acf9882a 100644 --- a/renderer/components/onboarding-flow.test.tsx +++ b/renderer/components/onboarding-flow.test.tsx @@ -190,7 +190,7 @@ test("onboarding keeps navigation fixed while its content scrolls", () => { }); test("provider setup progressively reveals configurable Pi providers and uses the dedicated Codex surface", () => { - assert.match(source, />\s*Choose from more\s*\s*Other ways\s* assert.match(agentsInstructions, /feature-tour bento gallery/u); assert.match(agentsInstructions, /1024 × 1024 transparent PNG/u); }); + + +test("primary AI choices include custom setup without opening advanced providers", () => { + assert.match(source, /\["openai-signin", "lmstudio", "ollama", "custom"\]/u); + for (const title of ["ChatGPT", "LM Studio", "Ollama", "Other Custom Provider"]) { + assert.ok(source.includes(`title: "${title}"`)); + } + assert.match(source, /(null); const [showMoreProviders, setShowMoreProviders] = React.useState(false); const [settingUpProvider, setSettingUpProvider] = React.useState(null); + const [customProvider, setCustomProvider] = React.useState(null); const [apiKeyDialogChoice, setApiKeyDialogChoice] = React.useState< "openai-key" | "anthropic" | null >(null); @@ -653,6 +660,11 @@ export function OnboardingFlow() { setIndex(2); }; + const openCustomProvider = () => setCustomProvider((current) => current ?? ({ + id: `custom:${crypto.randomUUID()}`, kind: "openai", label: "Custom Provider", baseUrl: "", + models: [], needsKey: true, hasKey: false, deployment: "hosted", + })); + const next = async () => { if (!canContinue || savingRef.current) return; if (step === "profile") { @@ -698,6 +710,7 @@ export function OnboardingFlow() { await completeProviderStep("openai-codex"); return; } + if (choice === "custom") { openCustomProvider(); return; } if (choice === "tailscale" && !baseUrl.trim()) { toast.error("Enter the Tailscale model server URL before continuing."); return; @@ -1028,7 +1041,7 @@ export function OnboardingFlow() { variant="heading1" className="block text-heading2 outline-none" > - Add a model provider + Connect your AI Choose one connection to get started. @@ -1036,17 +1049,18 @@ export function OnboardingFlow() {
- {providerChoices.map((item) => ( + {providerChoices.filter((item) => ["openai-signin", "lmstudio", "ollama", "custom"].includes(item.id)).map((item) => ( + ))} +
{providers.isLoading && moreProviders.length === 0 ? ( Loading provider catalog… @@ -1155,7 +1178,7 @@ export function OnboardingFlow() { type="button" disabled={!canChoose || saving} aria-pressed={isSelected} - className={`flex min-h-14 items-center gap-2.5 rounded-control border border-transparent px-2.5 py-2 text-left outline-none transition-colors duration-150 focus-visible:bg-control-active disabled:cursor-not-allowed disabled:opacity-50 ${isSelected ? "bg-list-selection" : "bg-transparent hover:bg-control"}`} + className={`flex min-h-14 items-center gap-2.5 rounded-control border border-transparent px-2.5 py-2 text-left outline-none transition-colors duration-150 focus-visible:outline focus-visible:outline-2 focus-visible:outline-focus-ring disabled:cursor-not-allowed disabled:opacity-50 ${isSelected ? "bg-list-selection" : "bg-transparent hover:bg-control"}`} onClick={() => { selectProviderChoice(null); setBuiltinChoiceId(provider.id); @@ -1246,8 +1269,7 @@ export function OnboardingFlow() { focus a tile to learn more. - Phone and iPad access starts off. After setup, opt in from Settings → Remote - Access; Aiden must stay running, and Tailscale is optional. + Phone and tablet access starts off. After setup, choose Connect a device in Settings → Aiden On The Go; Aiden must stay running, and Tailscale is optional. @@ -1362,6 +1384,19 @@ export function OnboardingFlow() { + {customProvider ? ( + { if (!open) setCustomProvider(null); }} + onSaved={async () => { + const refreshed = await providersApi.list(); + queryClient.setQueryData(queryKeys.providers, refreshed); + const ready = refreshed.find((provider) => provider.id === customProvider.id); + const model = ready?.defaultModel; + if (!ready || !model || !ready.models.includes(model)) throw new Error("Choose an available default model before continuing."); + await completeProviderStep(ready.id); + persistModelSelection(ready.id, model); + }} /> + ) : null} {settingUpProvider ? ( void; onSave: (task: ScheduledTaskInput) => Promise; }) { + const [reviewing, setReviewing] = React.useState(false); + const [saveError, setSaveError] = React.useState(null); const [draft, setDraft] = React.useState(initial); const [scheduleDraft, setScheduleDraft] = React.useState(() => scheduleDraftFromCron(initial.cron), @@ -93,6 +95,8 @@ export function ScheduledTaskEditor({ React.useEffect(() => { if (open) { + setReviewing(false); + setSaveError(null); setScheduleDraft(scheduleDraftFromCron(initial.cron)); setDraft({ ...initial, @@ -233,11 +237,29 @@ export function ScheduledTaskEditor({ description="Aiden runs this task on your Mac while the app is open." size="large" busy={busy} - confirmLabel={draft.id ? "Save" : "Create"} + confirmLabel={reviewing ? (draft.id ? "Save task" : "Create task") : "Review task"} confirmDisabled={!valid} - onConfirm={() => onSave(draft)} + onConfirm={async () => { + if (!reviewing) { setReviewing(true); return; } + try { setSaveError(null); await onSave(draft); } + catch (error) { setSaveError(error instanceof Error ? error.message : "Couldn’t save this task. Your choices are still here."); } + }} > -
+ {reviewing ? ( +
+ + + + {draft.mode === "script" ? draft.script : draft.prompt} + + {draft.permission === "full" ? "Full access · Runs without asking you each time." : "Read-only · Can inspect information without making changes."} + {workspaces.find((workspace) => workspace.id === draft.workspaceId)?.name ?? "No selected workspace"} · {selectedMcpIds.length ? selectedMcpIds.map((id) => visibleMcpServers.find((server) => server.id === id)?.name ?? "Unavailable connection").join(", ") : "No connections"} · Web search {draft.webSearchEnabled ? "on" : "off"} + + Aiden must be open on this Mac for the task to run. Results appear in the task’s chat. + {saveError ? {saveError} : null} +
+ ) : <> +
-
+
+ } ); } diff --git a/renderer/components/settings/codex-provider-settings.tsx b/renderer/components/settings/codex-provider-settings.tsx index c46ba531..88ea0a8a 100644 --- a/renderer/components/settings/codex-provider-settings.tsx +++ b/renderer/components/settings/codex-provider-settings.tsx @@ -361,8 +361,7 @@ export function CodexProviderSettings({ layer = "default" }: { layer?: DialogLay - Use your ChatGPT account for Codex models. OAuth credentials stay encrypted on this - Mac and are never shown to the renderer. + Use your ChatGPT account for Codex models. Your sign-in is saved securely on this Mac. {signingOut diff --git a/renderer/components/settings/computer-use-settings.tsx b/renderer/components/settings/computer-use-settings.tsx index 0b06879a..8894b9d1 100644 --- a/renderer/components/settings/computer-use-settings.tsx +++ b/renderer/components/settings/computer-use-settings.tsx @@ -1,7 +1,7 @@ import * as React from "react"; import { useQueryClient } from "@tanstack/react-query"; import { CheckCircle2, Loader2, RefreshCw, ShieldAlert, TriangleAlert } from "lucide-react"; -import { Badge, Button, Callout, Field, FieldSet, Switch, Text, toast } from "../ui"; +import { Badge, Button, Callout, Dialog, Field, FieldSet, Switch, Text, toast } from "../ui"; import { computerUseApi } from "../../lib/ipc"; import { reduceComputerUseRefreshState } from "../../lib/computer-use-control"; import { @@ -39,6 +39,8 @@ export function ComputerUseSettings() { const queryClient = useQueryClient(); const statusQuery = useComputerUseStatus(); const settingsQuery = useSettings(); + const [enableReview, setEnableReview] = React.useState(false); + const [enableError, setEnableError] = React.useState(null); const [saving, setSaving] = React.useState(false); const [pendingEnabled, setPendingEnabled] = React.useState(null); const [requesting, setRequesting] = React.useState(false); @@ -79,16 +81,20 @@ export function ComputerUseSettings() { const toggle = async (enabled: boolean) => { if (saving) return; setSaving(true); + setEnableError(null); setPendingEnabled(enabled); try { await queryClient.cancelQueries({ queryKey: queryKeys.computerUseStatus }); const next = await computerUseApi.setEnabled(enabled); commitStatus(next); + setEnableReview(false); if (enabled && next.state !== "ready" && next.state !== "permission_required") { toast.error(next.detail); } } catch (error) { - toast.error(error instanceof Error ? error.message : "Couldn't update Computer Use."); + const message = error instanceof Error ? error.message : "Couldn't update Computer Use."; + setEnableError(message); + toast.error(message); void Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.settings }), queryClient.invalidateQueries({ queryKey: queryKeys.computerUseStatus }), @@ -136,12 +142,12 @@ export function ComputerUseSettings() { >
void toggle(checked)} + onCheckedChange={(checked) => checked ? setEnableReview(true) : void toggle(false)} disabled={saving || settingsQuery.isLoading} aria-label="Enable Computer Use beta" /> @@ -153,8 +159,8 @@ export function ComputerUseSettings() {
{presentation.label} - {status?.driverVersion ? ( - cua-driver {status.driverVersion} - ) : null} +
{refreshError ?? @@ -204,7 +208,7 @@ export function ComputerUseSettings() { disabled={requesting} > {requesting ? : } - {requesting ? "Requesting…" : "Request access"} + {requesting ? "Requesting…" : "Open Mac permissions"} ) : null} @@ -212,6 +216,14 @@ export function ComputerUseSettings() {
+ toggle(true)}> + When you turn this on in a chat, its selected AI provider may receive screenshots and text visible in your apps. Each click or typing action asks for your permission. You can stop or turn it off at any time. + Next, allow Screen Recording and Accessibility in macOS. Enabling this feature alone does not share your screen. + {enableError ? {enableError} : null} +
diff --git a/renderer/components/settings/mcp-preset-setup.tsx b/renderer/components/settings/mcp-preset-setup.tsx index 1ef333bd..d8dd017f 100644 --- a/renderer/components/settings/mcp-preset-setup.tsx +++ b/renderer/components/settings/mcp-preset-setup.tsx @@ -31,20 +31,22 @@ export function PresetSetupDialog({ const [key, setKey] = React.useState(""); const [hasKey, setHasKey] = React.useState(state.ready); const [authorized, setAuthorized] = React.useState(state.ready); + const [connectionError, setConnectionError] = React.useState(null); const [testing, setTesting] = React.useState(false); const [authorizing, setAuthorizing] = React.useState(false); const [saving, setSaving] = React.useState(false); const [toggling, setToggling] = React.useState(false); - const [enabled, setEnabled] = React.useState(server?.enabled ?? state.enabled); + const [enabled, setEnabled] = React.useState(server?.enabled ?? true); React.useEffect(() => { if (open) { + setConnectionError(null); setName(server?.name ?? preset.name); setUrl(server?.url ?? preset.url); setKey(""); setHasKey(state.ready); setAuthorized(state.ready); - setEnabled(server?.enabled ?? state.enabled); + setEnabled(server?.enabled ?? true); } }, [open, server, preset, state.enabled, state.ready]); @@ -155,6 +157,7 @@ export function PresetSetupDialog({ } description={preset.tagline} size="large" + busy={saving || testing || authorizing || toggling} confirmLabel={saving ? "Saving…" : server ? "Save" : "Connect"} confirmDisabled={ saving || @@ -166,14 +169,20 @@ export function PresetSetupDialog({ !credentialReady } onConfirm={async () => { - setSaving(true); + setSaving(true); setConnectionError(null); try { await mcpApi.save(build()); await persistKey(); await onSaved(); + if (enabled) { + const status = await mcpApi.status(build()); + if (!status.connected) throw new Error(status.error ?? "Saved, but the connection is not ready. Try connecting again."); + toast.success(`Connected — ${status.toolCount} tools available.`); + } onOpenChange(false); } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)); + const message = error instanceof Error ? error.message : String(error); + setConnectionError(message); toast.error(message); } finally { setSaving(false); } @@ -198,6 +207,9 @@ export function PresetSetupDialog({ ) : null} + Sign in or add your key to connect {preset.name}. This service receives the requests made with its tools. Review its account permissions during sign-in. +
+ Connection details +
{preset.auth.kind === "apiKey" ? ( {testing ? "Connecting…" : "Test connection"} - {credentialReady ? Ready : null} + {credentialReady ? Credentials saved : null}
+ {connectionError ? {connectionError} : null} Learn more in the{" "} void; - onSaved: () => void; + onSaved: () => void | Promise; + layer?: DialogLayer; + requireReady?: boolean; returnFocus?: () => HTMLElement | null; } @@ -62,6 +65,8 @@ export function ProviderEditor({ onOpenChange, onSaved, returnFocus, + layer, + requireReady = false, }: ProviderEditorProps) { const artworkInputRef = React.useRef(null); const artworkBusyRef = React.useRef(false); @@ -228,13 +233,17 @@ export function ProviderEditor({ toast.error(message); return; } + if (requireReady && (models.length === 0 || !defaultModel || !models.includes(defaultModel) || defaultModelIsHidden)) { + setConnectionNotice({ message: "Discover models and choose an available default before continuing.", error: true }); + return; + } setSaving(true); try { await providersApi.save(buildDraft(), keyDraft.trim() || undefined); if (models.length === 0) { toast.info("Saved without models. Discover models before sending a chat."); } - onSaved(); + await onSaved(); onOpenChange(false); } catch (error) { const message = `Couldn't save provider: ${error instanceof Error ? error.message : String(error)}`; @@ -248,6 +257,8 @@ export function ProviderEditor({ return ( { + assert.match(source, /title="Aiden On The Go"/u); + assert.match(source, /1. Connect your phone/u); + assert.match(source, /2. Scan to finish/u); + assert.match(source, /aidenRemoteApi.setupPairing/u); + assert.match(source, /Enable and show code/u); + assert.match(source, /instanceId: snapshot.instanceId/u); + assert.match(source, /phone access stays on until you turn it off/u); + assert.match(source, /setupError.*Callout/u); +}); diff --git a/renderer/components/settings/remote-access-settings.tsx b/renderer/components/settings/remote-access-settings.tsx index 86bca52f..1b8007f8 100644 --- a/renderer/components/settings/remote-access-settings.tsx +++ b/renderer/components/settings/remote-access-settings.tsx @@ -36,6 +36,8 @@ import { SelectTrigger, SelectValue, Switch, + RadioGroup, + RadioGroupItem, Text, toast, } from "../ui"; @@ -139,10 +141,11 @@ function friendlyTailscaleError(error: unknown): string { if (message.includes("tailscale_reconciliation_conflict")) return "The route changed after the uncertain update. Aiden left it untouched; inspect Tailscale Serve."; if (message.includes("tailscale_reconciliation_unhealthy")) return "The route exists but this Aiden service did not answer its health check. Nothing was claimed."; if (message.includes("tailscale_reconciliation_required")) return "Verify the previous Tailscale update before starting another route change."; + if (message.includes("tailscale_not_installed")) return "Install Tailscale on your Mac and phone, then sign in to the same private network. You can also choose On the same Wi-Fi."; if (message.includes("tailscale_not_connected")) return "Open Tailscale and sign in before connecting Aiden."; if (message.includes("tailscale_https_unavailable")) return "Enable HTTPS for this Tailscale device name before connecting Aiden."; if (message.includes("tailscale_route_busy")) return "Another Aiden profile is updating this Mac’s mobile route. Wait a moment and try again."; - return "Aiden couldn’t safely update the Tailscale route."; + return message && !message.startsWith("tailscale_") ? message : "Aiden couldn’t safely update the Tailscale route."; } function Disclosure({ @@ -210,7 +213,7 @@ function SettingsDeviceRow({
{device.name} - {device.type === "ipad" ? "iPad" : "iPhone"} · {state === "pending" + {device.type === "ipad" ? "Tablet" : "Phone"} · {state === "pending" ? "Finishing connection" : `${state === "previous" ? "Removed" : "Last seen"} ${friendlyDate(timestamp)}`} @@ -219,7 +222,7 @@ function SettingsDeviceRow({ {state === "pending" ? Finishing : null} {state === "inactive" ? Inactive : null} {state === "previous" ? Previous : null} - {onRevoke ? : null} + {onRevoke ? : null}
); } @@ -227,6 +230,12 @@ function SettingsDeviceRow({ export function RemoteAccessSettings() { const queryClient = useQueryClient(); const settingsQuery = useAidenRemoteSettings(); + const [setupTransport, setSetupTransport] = React.useState<"lan" | "tailscale" | null>(null); + const [setupReview, setSetupReview] = React.useState<{ + transport: "lan" | "tailscale"; instanceId: string; enabled: boolean; + connectionMode: AidenRemoteConnectionMode; + } | null>(null); + const [setupError, setSetupError] = React.useState(null); const [busy, setBusy] = React.useState(null); const [pairing, setPairing] = React.useState(null); const completedPairingDeviceId = React.useRef(null); @@ -378,17 +387,20 @@ export function RemoteAccessSettings() { } }; - const beginPairing = async (transport: "lan" | "tailscale") => { - if (busy) return; + const beginPairing = async (transport: "lan" | "tailscale", review?: NonNullable): Promise => { + if (busy) return false; + setSetupError(null); setBusy("pairing"); const requestGeneration = ++pairingRequestGeneration.current; try { completedPairingDeviceId.current = null; observedPairingSession.current = null; - const nextPairing = await aidenRemoteApi.beginPairing(transport); + const nextPairing = review + ? await aidenRemoteApi.setupPairing(transport, review) + : await aidenRemoteApi.beginPairing(transport); if (!mounted.current || pairingRequestGeneration.current !== requestGeneration) { await aidenRemoteApi.closePairing(nextPairing.pairingSessionId).catch(() => undefined); - return; + return false; } observedPairingSession.current = nextPairing.pairingSessionId; queryClient.setQueryData( @@ -405,10 +417,15 @@ export function RemoteAccessSettings() { ); setPairing(nextPairing); await queryClient.invalidateQueries({ queryKey: queryKeys.aidenRemote }); + return true; } catch (error) { if (mounted.current && pairingRequestGeneration.current === requestGeneration) { - toast.error(error instanceof Error ? error.message : "Aiden couldn't open pairing."); + const message = friendlyTailscaleError(error); + setSetupError(message); + toast.error(message); + await queryClient.invalidateQueries({ queryKey: queryKeys.aidenRemote }); } + return false; } finally { if (mounted.current && pairingRequestGeneration.current === requestGeneration) { setBusy(null); @@ -514,9 +531,46 @@ export function RemoteAccessSettings() { ? "Local service ready" : status.running ? "Ready" : summary; + const selectedTransport = setupTransport ?? (status.connectionMode === "lan" ? "lan" : "tailscale"); + const selectedReady = selectedTransport === "lan" ? canPairLan : canPairTailscale; + const hasSavedConnection = snapshot.devices.length > 0; + return ( <> -
+
+ +
+ { + setSetupTransport(value as "lan" | "tailscale"); setSetupError(null); + }} className="grid gap-2" aria-label="Where will you use Aiden?" disabled={busy !== null}> + {([ ["tailscale", "Away from home", "Uses Tailscale on your Mac and phone."], + ["lan", "On the same Wi-Fi", "No Tailscale needed. Use the same local network."] ] as const).map(([value, title, description]) => ( + + ))} + + + {setupError ? {setupError} : null} +
+
+ + Keep Aiden running on your Mac. You can remove a device’s access below. + +
+ + @@ -575,10 +629,10 @@ export function RemoteAccessSettings() { {status.error} ) : null} -
+
- @@ -615,7 +669,7 @@ export function RemoteAccessSettings() { )} - + : null} {groups.active.length === 0 && groups.pending.length === 0 && groups.inactive.length === 0 ? (
No devices are paired with this Mac.
) : ( @@ -828,6 +882,22 @@ export function RemoteAccessSettings() { ))} + { if (!open && !busy) setSetupReview(null); }} + title="Connect your phone to this Mac?" + description={setupReview?.transport === "tailscale" + ? "Aiden will turn on phone access, prepare its private connection through Tailscale, and show a one-time code." + : "Aiden will turn on phone access over your local network and show a one-time code. Your phone and Mac need to be on the same network."} + confirmLabel={busy === "pairing" ? "Preparing connection…" : "Enable and show code"} + busy={busy !== null} dismissDisabled={busy !== null} + onConfirm={async () => { if (setupReview && await beginPairing(setupReview.transport, setupReview)) setSetupReview(null); }}> + Paired devices can use the workspaces and capabilities this Mac allows. Your AI keys stay on this Mac; requests still go to the AI service you choose. + Keep Aiden running. Remove a device’s access here at any time. Closing the code window stops pairing; phone access stays on until you turn it off. +
Review folder browsing access + {snapshot.approvedRoots.length ? snapshot.approvedRoots.map((root) => root.label).join(", ") : "No additional folders approved for browsing."} Existing permitted workspaces and Bot access stay unchanged. +
+ {setupError ? {setupError} : null} +
+ void closePairing(open)} @@ -891,8 +961,8 @@ export function RemoteAccessSettings() { {pairingTransport === "tailscale" - ? "Enter this private address and the setup code on your iPhone or iPad." - : "Select this discovered Mac, then enter the setup code on your iPhone or iPad."} + ? "Enter this private address and the setup code on your phone or tablet." + : "Select this discovered Mac, then enter the setup code on your phone or tablet."} @@ -943,9 +1013,9 @@ export function RemoteAccessSettings() { !open && setRevokeDevice(null)} - title="Revoke this device?" + title="Remove this device’s access?" description={revokeDevice ? `“${revokeDevice.name}” will immediately lose Remote Access. Pair it again to restore access.` : undefined} - confirmLabel="Revoke" + confirmLabel="Remove access" confirmVariant="destructive" busy={busy === "revoke"} keepOpenOnConfirm diff --git a/renderer/components/settings/telegram-settings.tsx b/renderer/components/settings/telegram-settings.tsx index 001099d7..af208ab9 100644 --- a/renderer/components/settings/telegram-settings.tsx +++ b/renderer/components/settings/telegram-settings.tsx @@ -6,6 +6,9 @@ import * as React from "react"; import { useQueryClient } from "@tanstack/react-query"; import { AlertDialog, + Dialog, + Text, + Callout, Button, Field, FieldSet, @@ -43,6 +46,9 @@ export function TelegramSettings() { const providers = useProviders(); const settings = useSettings(); const workspaces = useWorkspaces(); + const [connectionReview, setConnectionReview] = React.useState(false); + const [connecting, setConnecting] = React.useState(false); + const [connectionError, setConnectionError] = React.useState(null); const [keyDraft, setKeyDraft] = React.useState(""); const [profileDraft, setProfileDraft] = React.useState(""); const [deleteProfileOpen, setDeleteProfileOpen] = React.useState(false); @@ -86,19 +92,24 @@ export function TelegramSettings() { }; const connect = async () => { + if (connecting) return; + setConnecting(true); setConnectionError(null); try { - await telegramApi.connect(); + await telegramApi.setEnabled(true); await invalidate(); - toast.success("Telegram bridge connected."); + setConnectionReview(false); + toast.success("Telegram enabled. Check its connection status below."); } catch (error) { - toast.error(error instanceof Error ? error.message : "Failed to connect."); - } + const message = error instanceof Error ? error.message : "Failed to connect."; + setConnectionError(message); toast.error(message); + await invalidate(); + } finally { setConnecting(false); } }; const disconnect = async () => { - await telegramApi.disconnect(); + await telegramApi.setEnabled(false); await invalidate(); - toast.success("Telegram bridge disconnected."); + toast.success("Telegram turned off."); }; const resetPairing = async () => { @@ -211,67 +222,8 @@ export function TelegramSettings() { ""; return ( -
- -
-
- - {activeProfile !== "default" && ( - - )} -
-
- setProfileDraft(event.target.value)} - placeholder="New profile name" - aria-label="New Telegram profile name" - /> - -
-
-
- - - - - + <> +
+
+
{folderWorkspaceCount === 0 && (

- Add a folder workspace in Settings → Workspaces to enable project automation. + Add a folder workspace from the sidebar to enable project automation.

)}
@@ -402,6 +356,114 @@ export function TelegramSettings() { )} +
+
+ {hasToken && ( + +
+ + + + {lastError ? "Needs attention" : polling ? (allowedUserId !== undefined ? "● Connected" : "Pair your Telegram account") : "○ Disconnected"} + {queuedCount > 0 ? ` · ${queuedCount} queued` : ""} + +
+
+ )} + + {!hasToken ? Save your bot token in step 1 to connect. : null} + {connectionError ? {connectionError} : null} + {allowedUserId !== undefined && ( + +
+ User ID: {allowedUserId} + +
+
+ )} + + {lastError && ( + +

{lastError}

+
+ )} + +
+
+ Advanced Telegram settings +
+ +
+
+ + {activeProfile !== "default" && ( + + )} +
+
+ setProfileDraft(event.target.value)} + placeholder="New profile name" + aria-label="New Telegram profile name" + /> + +
+
+
+ + + checked ? setConnectionReview(true) : void toggle(false)} disabled={!hasToken} /> + + - {hasToken && ( - -
- - - - {polling ? "● Polling" : "○ Idle"} - {queuedCount > 0 ? ` · ${queuedCount} queued` : ""} - -
-
- )} - - {allowedUserId !== undefined && ( - -
- User ID: {allowedUserId} - -
-
- )} - - {lastError && ( - -

{lastError}

-
- )} - {(telegram.data?.recentDiagnostics.length ?? 0) > 0 && (
  • Choose a provider above (or set one up in Settings → Providers).
  • - Toggle Enable, then send /start to your bot from Telegram to pair. + Choose Connect in step 3, review access, then send /start to your bot from Telegram to pair.
  • @@ -590,6 +612,17 @@ export function TelegramSettings() {

    +
    +
    + + Messages go through Telegram and your selected AI service. Workspace tasks run unattended: they can edit files and run commands without asking. Only the paired owner can trigger them. + {activeProfile} + {selectedProvider?.label ?? "Not configured"} · {selectedModel || "Choose a model before chatting"} + {workspaceOptions.find((option) => option.value === telegramWorkspaceId)?.label ?? "Assistant-only — no project files or tools"} + {connectionError ? {connectionError} : null} + void deleteProfile()} /> -
    + ); } diff --git a/renderer/components/settings/voice-settings.tsx b/renderer/components/settings/voice-settings.tsx index 48923acf..e89bc5be 100644 --- a/renderer/components/settings/voice-settings.tsx +++ b/renderer/components/settings/voice-settings.tsx @@ -114,9 +114,9 @@ export function VoiceSettings() { return (
    -
    +
    - OpenAI - Google Gemini - On-device (Parakeet) + Online · OpenAI + Online · Google Gemini + On this Mac · Private {provider === "gemini" ? ( diff --git a/renderer/lib/ipc.ts b/renderer/lib/ipc.ts index 9ac86617..8118cc2c 100644 --- a/renderer/lib/ipc.ts +++ b/renderer/lib/ipc.ts @@ -475,6 +475,9 @@ export const telegramApi = { }; export const aidenRemoteApi = { + setupPairing: (transport: "lan" | "tailscale", expected: { + instanceId: string; enabled: boolean; connectionMode: AidenRemoteConnectionMode; + }) => invoke("remote:setupPairing", transport, expected), get: () => invoke("remote:get"), setEnabled: (enabled: boolean) => invoke("remote:setEnabled", enabled), diff --git a/renderer/lib/mcp-preset-state.test.ts b/renderer/lib/mcp-preset-state.test.ts index a03e884a..4f6c4717 100644 --- a/renderer/lib/mcp-preset-state.test.ts +++ b/renderer/lib/mcp-preset-state.test.ts @@ -119,7 +119,7 @@ test("preset badges distinguish configured, authenticated, and enabled states", mcpPresetConnectionBadge( state(oauthPreset, { configured: true, ready: true }), ), - { label: "Ready", color: "green" }, + { label: "Configured", color: "green" }, ); }); diff --git a/renderer/lib/mcp-preset-state.ts b/renderer/lib/mcp-preset-state.ts index 04cf80c0..a954cc44 100644 --- a/renderer/lib/mcp-preset-state.ts +++ b/renderer/lib/mcp-preset-state.ts @@ -27,7 +27,7 @@ export function mcpPresetConnectionBadge( }; } if (!state.enabled) return { label: "Disabled", color: "secondary" }; - return { label: "Ready", color: "green" }; + return { label: "Configured", color: "green" }; } export function mcpServerEditorKind( diff --git a/renderer/lib/onboarding-provider.ts b/renderer/lib/onboarding-provider.ts index 67e1b283..745e9cb6 100644 --- a/renderer/lib/onboarding-provider.ts +++ b/renderer/lib/onboarding-provider.ts @@ -6,7 +6,8 @@ export type OnboardingProviderChoice = | "anthropic" | "lmstudio" | "ollama" - | "tailscale"; + | "tailscale" + | "custom"; export type OnboardingProviderDraft = Omit; @@ -45,7 +46,7 @@ export function makeOnboardingProvider( baseUrl: string, currentProviders: readonly Provider[] = [], ): OnboardingProviderDraft | null { - if (choice === "openai-signin") return null; + if (choice === "openai-signin" || choice === "custom") return null; if (choice === "openai-key") { return { id: "custom:onboarding-openai", diff --git a/renderer/lib/scheduled-mcp-access-contract.test.ts b/renderer/lib/scheduled-mcp-access-contract.test.ts index f8b684c3..dc3bad3f 100644 --- a/renderer/lib/scheduled-mcp-access-contract.test.ts +++ b/renderer/lib/scheduled-mcp-access-contract.test.ts @@ -73,3 +73,14 @@ test("desktop scheduling keeps natural language and human cadence controls prima assert.match(editor, /Advanced schedule/u); assert.doesNotMatch(editor, /Five-part cron/u); }); + + +test("schedule creation requires a final review and retains errors for correction", () => { + const editor = source("../components/scheduled-task-editor.tsx"); + assert.match(editor, /if \(!reviewing\)/u); + assert.match(editor, /Review your task/u); + assert.match(editor, /Edit choices/u); + assert.match(editor, /Runs without asking you each time/u); + assert.match(editor, /saveError/u); + assert.match(editor, /await onSave\(draft\)/u); +}); diff --git a/renderer/lib/settings-section.test.ts b/renderer/lib/settings-section.test.ts index 04b5cc71..7186259c 100644 --- a/renderer/lib/settings-section.test.ts +++ b/renderer/lib/settings-section.test.ts @@ -62,3 +62,11 @@ test("Web Search navigation advertises provider routing and privacy controls", ( "exa", ]); }); + + +test("settings can be found by the user's task without knowing feature names", () => { + for (const [query, expected] of [["connect my phone", "remoteAccess"], ["use my voice", "voice"], ["connect my ai", "providers"], ["see my screen", "computerUse"], ["do this every day", "scheduledTasks"]]) { + assert.ok(SETTINGS_DESTINATIONS.find((entry) => entry.id === expected)?.keywords.includes(query)); + } + assert.equal(SETTINGS_DESTINATIONS.find((entry) => entry.id === "remoteAccess")?.title, "Aiden On The Go"); +}); diff --git a/renderer/lib/use-voice-recorder.ts b/renderer/lib/use-voice-recorder.ts index d67b7fe8..4a1056fc 100644 --- a/renderer/lib/use-voice-recorder.ts +++ b/renderer/lib/use-voice-recorder.ts @@ -26,6 +26,11 @@ import { GeminiRecordedRetryConsent, needsGeminiRecordedRetry } from "./gemini-r type RecorderOptions = TranscribeOptions; export function useVoiceRecorder(onTranscript: (text: string) => void, options: RecorderOptions) { + const [lastError, setLastError] = React.useState(null); + const reportError = React.useCallback((message: string) => { + setLastError(message); + toast.error(message); + }, []); const [recording, setRecording] = React.useState(false); const [transcribing, setTranscribing] = React.useState(false); const [awaitingRecordedRetryConsent, setAwaitingRecordedRetryConsent] = React.useState(false); @@ -74,6 +79,7 @@ export function useVoiceRecorder(onTranscript: (text: string) => void, options: const token = operationGate.beginStart(); if (token === null) return; pendingStopRef.current = false; + setLastError(null); try { // Native permission gate before capture. const status = await window.aidenAPI.systemPreferences.getMediaAccessStatus("microphone"); @@ -81,7 +87,7 @@ export function useVoiceRecorder(onTranscript: (text: string) => void, options: if (!operationGate.isCurrent(token)) return; if (!allowed) { operationGate.finishStart(token); - toast.error( + reportError( status === "not-determined" ? "Microphone permission was not granted. Enable it in System Settings, then restart Aiden." : MICROPHONE_PERMISSION_OFF_MESSAGE, @@ -169,7 +175,7 @@ export function useVoiceRecorder(onTranscript: (text: string) => void, options: if (!approved || !operationGate.isCurrent(token)) return; } if (liveEnabled && !text && blob.size === 0) { - toast.error("No speech detected."); + reportError("No speech detected."); return; } if (!text) { @@ -185,10 +191,10 @@ export function useVoiceRecorder(onTranscript: (text: string) => void, options: } if (!operationGate.isCurrent(token)) return; if (text) onTranscript(text); - else toast.error("No speech detected."); + else reportError("No speech detected."); } catch (error) { if (!operationGate.isCurrent(token)) return; - toast.error(voiceErrorMessage(error)); + reportError(voiceErrorMessage(error)); } finally { if (liveStartRef.current === liveStart) liveStartRef.current = null; if (batchOperationIdRef.current === operationId) batchOperationIdRef.current = null; @@ -241,9 +247,9 @@ export function useVoiceRecorder(onTranscript: (text: string) => void, options: operationGate.finishStart(token); recorderRef.current = null; stopTracks(); - toast.error(microphoneCaptureErrorMessage(error)); + reportError(microphoneCaptureErrorMessage(error)); } - }, [onTranscript, operationGate, recordedRetryConsent, stopTracks]); + }, [onTranscript, operationGate, recordedRetryConsent, stopTracks, reportError]); const stop = React.useCallback(() => { pendingStopRef.current = true; @@ -283,6 +289,8 @@ export function useVoiceRecorder(onTranscript: (text: string) => void, options: ); return { + lastError, + dismissError: () => setLastError(null), recording, transcribing, liveTranscript, diff --git a/renderer/main/bots-view.test.tsx b/renderer/main/bots-view.test.tsx index cbd8e441..aa83a37e 100644 --- a/renderer/main/bots-view.test.tsx +++ b/renderer/main/bots-view.test.tsx @@ -192,26 +192,24 @@ test("bot detail surfaces the current access mode and bound model", () => { assert.match(view, /BOT_ACCESS_SUMMARIES\.custom/u); }); -test("bot editor is a five-page wizard with gated Next, Back, and a review Confirm", () => { +test("bot editor is a two-page wizard with gated Next, Back, and a review Confirm", () => { const view = source("./bots-view.tsx"); const styles = source("../styles.css"); - // Page inventory: identity, access, model, capabilities, review. + // Identity first; model, access, and final review stay together. assert.match(view, /const BOT_EDITOR_STEPS = \[/u); - assert.match(view, /title: "Identity"/u); - assert.match(view, /title: "Access"/u); - assert.match(view, /title: "Model"/u); - assert.match(view, /title: "Capabilities"/u); - assert.match(view, /title: "Review"/u); + assert.match(view, /title: "Create a bot"/u); + assert.match(view, /title: "Review model and access"/u); + assert.match(view, /usesFullAccess: state \? state.access.accessMode === "full" : false/u); // The dialog's primary action advances pages and only confirms on the last. assert.match( view, - /confirmLabel=\{isLastStep \? \(bot \? "Save changes" : "Create bot"\) : "Next"\}/u, + /confirmLabel=\{isLastStep \? \(bot \? "Save changes" : "Create a bot"\) : "Review model and access"\}/u, ); assert.match(view, /onConfirm=\{isLastStep \? save : goNext\}/u); assert.match(view, /confirmDisabled=\{saving \|\| !stepValid\[step\]\}/u); // Per-page gating, including the review page re-checking every prior page. assert.match(view, /const stepValid = \[/u); - assert.match(view, /identityReady && settingsReady,\s*\];/u); + assert.match(view, /identityReady && settingsReady,?\s*\];/u); assert.match(view, /if \(!stepValid\[step\]\) return;/u); // Back navigation and an announced step counter. assert.match(view, / Back/u); diff --git a/renderer/main/bots-view.tsx b/renderer/main/bots-view.tsx index 90c462b0..ed91b1b2 100644 --- a/renderer/main/bots-view.tsx +++ b/renderer/main/bots-view.tsx @@ -151,7 +151,7 @@ function accessDraftFromState( ? state.visionModelSelection : visionFallback; return { - usesFullAccess: state ? state.access.accessMode === "full" : botFullAccessAccepted(catalog), + usesFullAccess: state ? state.access.accessMode === "full" : false, providerId: selected?.providerId ?? fallback?.providerId, modelId: selected?.modelId ?? fallback?.modelId, visionProviderId: visionSelected?.providerId, @@ -280,11 +280,8 @@ const botChatDateFormatter = new Intl.DateTimeFormat(undefined, { /** Wizard pages for the New/Edit Bot dialog; the last page is the review/confirm step. */ const BOT_EDITOR_STEPS = [ - { title: "Identity", description: "Name this bot and describe how it should work." }, - { title: "Access", description: "Choose how much of this Mac this bot may use." }, - { title: "Model", description: "Pick the provider and model this bot uses in every chat." }, - { title: "Capabilities", description: "Review the files, commands, connections, and skills it may use." }, - { title: "Review", description: "Check every choice, then confirm to save this bot." }, + { title: "Create a bot", description: "Name your bot and describe what it should do." }, + { title: "Review model and access", description: "Review the AI model and what this bot may use, then create it." }, ] as const; function BotEditor({ @@ -299,6 +296,7 @@ function BotEditor({ const [draft, setDraft] = React.useState(() => draftFromBot(bot)); const [identityBaseline, setIdentityBaseline] = React.useState(() => draftFromBot(bot)); const [committedBot, setCommittedBot] = React.useState(bot); + const [saveError, setSaveError] = React.useState(null); const [generatingAvatar, setGeneratingAvatar] = React.useState(false); const [saving, setSaving] = React.useState(false); const [noticing, setNoticing] = React.useState(false); @@ -346,6 +344,7 @@ function BotEditor({ if (!catalog || !accessDraft) return; savingRef.current = true; setSaving(true); + setSaveError(null); try { let saved: BotDefinition; if (!committedBot) { @@ -413,7 +412,9 @@ function BotEditor({ onOpenChange(false); if (!bot) await navigate({ to: "/bots/$botId", params: { botId: saved.id } }); } catch (error) { - toast.error(error instanceof Error ? error.message : "Aiden could not save this bot."); + const message = error instanceof Error ? error.message : "Aiden could not save this bot."; + setSaveError(message); + toast.error(message); } finally { savingRef.current = false; setSaving(false); @@ -480,13 +481,7 @@ function BotEditor({ })(); // Each page gates its own Next button; the review page re-checks everything // so Confirm can never run against an incomplete draft. - const stepValid = [ - identityReady, - accessModeReady, - modelReady, - settingsReady, - identityReady && settingsReady, - ]; + const stepValid = [identityReady, identityReady && settingsReady]; const goNext = () => { if (!stepValid[step]) return; setStep((current) => Math.min(current + 1, BOT_EDITOR_STEPS.length - 1)); @@ -508,7 +503,7 @@ function BotEditor({ onOpenChange={onOpenChange} title={bot ? `Edit ${bot.name}` : "Create a bot"} description={BOT_EDITOR_STEPS[step]!.description} - confirmLabel={isLastStep ? (bot ? "Save changes" : "Create bot") : "Next"} + confirmLabel={isLastStep ? (bot ? "Save changes" : "Create a bot") : "Review model and access"} confirmDisabled={saving || !stepValid[step]} busy={saving} onConfirm={isLastStep ? save : goNext} @@ -547,6 +542,7 @@ function BotEditor({ Step {step + 1} of {BOT_EDITOR_STEPS.length}
    + {saveError ? {saveError} Your choices are still here. : null} {step === 0 ? ( <>
    @@ -579,6 +575,8 @@ function BotEditor({
    +
    + Customize appearance (optional) +
    ) : null} - + ) : null} - {step === 4 && catalog && accessDraft ? ( + {step === 1 && catalog && accessDraft ? (
    {summaryRow("Name", draft.name.trim())} @@ -1031,10 +1030,10 @@ function Roster({ bots, onCreate }: { bots: BotDefinition[]; onCreate(): void })
    ); @@ -1406,11 +1405,11 @@ export function BotsView() { Bots - Create reusable teammates that stay on top of Aiden’s existing Pi runtime. + Create a bot for work you return to, with its own instructions and conversations.
    diff --git a/renderer/main/chat-pane.tsx b/renderer/main/chat-pane.tsx index 88b01c4a..66fd76eb 100644 --- a/renderer/main/chat-pane.tsx +++ b/renderer/main/chat-pane.tsx @@ -2049,6 +2049,7 @@ export function ChatPane({ chatId }: { chatId: string }) { // without a chatId reset of its own. key={chatId} ready={ready && !imageArtifactRecoveryPending && !imageArtifactRecoveryUnavailable} + readinessSettingsSection={!chatReadinessMessage && !botReadinessMessage ? (modelReadinessMessage ? "providers" : computerUseReadinessMessage ? "computerUse" : undefined) : undefined} readinessMessage={ imageArtifactRecoveryUnavailable ? "Visual artifact staging is unavailable. Open Settings → About → Diagnostics and choose Reveal to locate the staging file that needs repair." diff --git a/renderer/shared/settings-section.ts b/renderer/shared/settings-section.ts index 22067d0a..9908e5af 100644 --- a/renderer/shared/settings-section.ts +++ b/renderer/shared/settings-section.ts @@ -24,7 +24,7 @@ export const SETTINGS_DESTINATIONS: ReadonlyArray<{ group: "Agent" | "App"; keywords: string[]; }> = [ - { id: "providers", title: "Providers", group: "Agent", keywords: ["models", "api", "keys"] }, + { id: "providers", title: "Providers", group: "Agent", keywords: ["models", "api", "keys", "connect my ai", "chatgpt", "lm studio", "ollama", "custom provider"] }, { id: "modelData", title: "Model Pad", @@ -73,15 +73,15 @@ export const SETTINGS_DESTINATIONS: ReadonlyArray<{ }, { id: "remoteAccess", - title: "Remote Access", + title: "Aiden On The Go", group: "Agent", - keywords: ["iphone", "ipad", "aiden on the go", "tailscale", "local network", "pairing"], + keywords: ["remote access", "iphone", "ipad", "android", "connect my phone", "connect my tablet", "away from home", "tailscale", "local network", "pairing"], }, { id: "scheduledTasks", title: "Scheduled tasks", group: "Agent", - keywords: ["automation", "cron", "recurring", "background", "scripts", "notifications"], + keywords: ["automation", "cron", "recurring", "background", "scripts", "notifications", "do this every day", "run later"], }, { id: "assistant", @@ -102,7 +102,7 @@ export const SETTINGS_DESTINATIONS: ReadonlyArray<{ id: "computerUse", title: "Computer Use", group: "Agent", - keywords: ["desktop", "native apps", "accessibility", "screen recording", "beta"], + keywords: ["desktop", "native apps", "accessibility", "screen recording", "beta", "control my computer", "see my screen", "permissions"], }, { id: "memory", @@ -114,7 +114,7 @@ export const SETTINGS_DESTINATIONS: ReadonlyArray<{ id: "voice", title: "Voice", group: "App", - keywords: ["microphone", "audio", "transcription", "dictation"], + keywords: ["microphone", "audio", "transcription", "dictation", "use my voice", "microphone not working", "talk to aiden"], }, { id: "shortcut", diff --git a/scripts/check-ios-shipping-target.test.mjs b/scripts/check-ios-shipping-target.test.mjs index 04bb3fb6..ce3312f5 100644 --- a/scripts/check-ios-shipping-target.test.mjs +++ b/scripts/check-ios-shipping-target.test.mjs @@ -884,7 +884,7 @@ test("the Aiden home, onboarding, composer, schedules, and activity retain the r assert.doesNotMatch(pairing, /UIDevice\.current\.userInterfaceIdiom/u); assert.match( pairing, - /onboardingActionButton\(action:[\s\S]*?Text\("Choose How to Connect"\)[\s\S]*?Label\("Open Camera", systemImage: "qrcode\.viewfinder"\)/u, + /onboardingActionButton\(action:[\s\S]*?Text\("Scan the Code"\)[\s\S]*?Label\("Open Camera", systemImage: "qrcode\.viewfinder"\)/u, ); assert.match( pairing, @@ -896,11 +896,11 @@ test("the Aiden home, onboarding, composer, schedules, and activity retain the r ); assert.match( pairing, - /Text\(isOnboardingLastPage \? "Set Up Connection" : "Continue"\)[\s\S]*?Text\("Choose How to Connect"\)[\s\S]*?Label\("Open Camera", systemImage: "qrcode\.viewfinder"\)/u, + /Text\(isOnboardingLastPage \? "Set Up Connection" : "Continue"\)[\s\S]*?Text\("Scan the Code"\)[\s\S]*?Label\("Open Camera", systemImage: "qrcode\.viewfinder"\)/u, ); assert.doesNotMatch( pairing, - /Text\("Choose How to Connect"\)[\s\S]{0,180}?\.background\(\.bar\)/u, + /Text\("Scan the Code"\)[\s\S]{0,180}?\.background\(\.bar\)/u, ); assert.doesNotMatch( pairing, diff --git a/tests/e2e/fixtures.ts b/tests/e2e/fixtures.ts index 9c986063..6062a8f3 100644 --- a/tests/e2e/fixtures.ts +++ b/tests/e2e/fixtures.ts @@ -432,7 +432,7 @@ export async function finishLmStudioOnboarding(page: Page): Promise { await onboarding.getByPlaceholder("Your name").fill(E2E_PROFILE_NAME); await next.click(); - await expect(onboarding.getByRole("heading", { name: "Add a model provider" })).toBeVisible(); + await expect(onboarding.getByRole("heading", { name: "Connect your AI" })).toBeVisible(); const lmStudio = onboarding.getByRole("button", { name: /LM Studio.*Use models running in LM Studio/u, }); @@ -710,8 +710,8 @@ export const test = base.extend({ } if (failed && rootDir) { - const child = app?.process(); try { + const child = app?.process(); await testInfo.attach("electron-process-state", { body: Buffer.from( `${JSON.stringify({ diff --git a/tests/e2e/guided-setup.spec.ts b/tests/e2e/guided-setup.spec.ts new file mode 100644 index 00000000..05c31d45 --- /dev/null +++ b/tests/e2e/guided-setup.spec.ts @@ -0,0 +1,76 @@ +import type { BotCapabilityCatalog } from "../../renderer/shared/bot-capabilities"; +import { E2E_PROFILE_NAME, expect, finishLmStudioOnboarding, test } from "./fixtures"; + +test("onboarding exposes the four primary AI choices and validates custom setup", async ({ aiden }) => { + const { page } = aiden; + const onboarding = page.locator('section[aria-label="Set up Aiden"]'); + await onboarding.getByPlaceholder("Your name").fill(E2E_PROFILE_NAME); + await onboarding.getByRole("button", { name: /^Next/u }).click(); + for (const name of [/^ChatGPT /u, /^LM Studio /u, /^Ollama /u, /^Other Custom Provider /u]) { + await expect(onboarding.getByRole("button", { name })).toBeVisible(); + } + await expect(onboarding.getByRole("button", { name: /^Other ways/u })).toHaveAttribute("aria-expanded", "false"); + await onboarding.getByRole("button", { name: /^Other Custom Provider /u }).click(); + const custom = page.getByRole("dialog", { name: "Configure Custom Provider" }); + await expect(custom).toBeVisible(); + await custom.getByRole("button", { name: "Save", exact: true }).click(); + await expect(custom.getByText("Discover models and choose an available default before continuing.")).toBeVisible(); + await custom.getByRole("button", { name: "Cancel", exact: true }).click(); + await expect(onboarding.getByRole("heading", { name: "Connect your AI" })).toBeVisible(); +}); + +test("computer control explains data access before enabling and cancellation keeps it off", async ({ aiden }) => { + const { page } = aiden; + await finishLmStudioOnboarding(page); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await page.getByRole("navigation", { name: "Settings" }).getByRole("button", { name: "Computer Use", exact: true }).click(); + const toggle = page.getByRole("switch", { name: "Enable Computer Use beta" }); + await expect(toggle).toHaveAttribute("data-state", "unchecked"); + await toggle.click(); + const review = page.getByRole("dialog", { name: "Let Aiden help with apps?" }); + await expect(review.getByText(/selected AI provider may receive screenshots/u)).toBeVisible(); + await review.getByRole("button", { name: "Cancel", exact: true }).click(); + await expect(toggle).toHaveAttribute("data-state", "unchecked"); +}); + +test("Create a bot submits limited access in two steps and retains a failed draft", async ({ aiden }) => { + const { page } = aiden; + await finishLmStudioOnboarding(page); + // This editor test isolates IPC because the test profile has no native Keychain + // authority. Real Bot storage/permission transactions run in test:bots. + const catalog: BotCapabilityCatalog = { + revision: "catalog-editor-fixture", providers: [{ id: "custom:lmstudio", label: "LM Studio", available: true, + models: [{ id: "aiden-e2e-vision", label: "Aiden E2E Vision", available: true, supportsImages: true }] }], + fileScopes: [], shellAvailable: true, connections: [], skills: [], otherCapabilities: [], + notice: { version: "bot-full-access-v1", requiresAcknowledgement: true }, + }; + await aiden.app.evaluate(({ ipcMain }, fixture) => { + for (const channel of ["bots:list", "bots:getCapabilityCatalog", "bots:create"]) ipcMain.removeHandler(channel); + ipcMain.handle("bots:list", () => []); + ipcMain.handle("bots:getCapabilityCatalog", () => fixture); + ipcMain.handle("bots:create", (_event, input: unknown) => { + (globalThis as unknown as { botEditorSubmission: unknown }).botEditorSubmission = input; + throw new Error("The test storage is unavailable."); + }); + }, catalog); + await page.getByRole("button", { name: "Bots", exact: true }).click(); + await page.getByRole("button", { name: "Create a bot", exact: true }).first().click(); + const editor = page.getByRole("dialog", { name: "Create a bot", exact: true }); + await expect(editor.getByText("Step 1 of 2")).toBeVisible(); + await editor.getByPlaceholder("Release reviewer").fill("Writing bot"); + await editor.getByPlaceholder("Describe the role, priorities, tone, and how this bot should approach work.").fill("Turn rough notes into a clear weekly update."); + await editor.getByRole("button", { name: "Review model and access", exact: true }).click(); + await expect(editor.getByText("Step 2 of 2")).toBeVisible(); + await expect(editor.getByRole("button", { name: "Custom", exact: true })).toHaveAttribute("aria-pressed", "true"); + await expect(editor.getByRole("button", { name: "Full", exact: true })).toHaveAttribute("aria-pressed", "false"); + await expect(editor.getByRole("button", { name: "Create a bot", exact: true })).toBeEnabled(); + await editor.getByRole("button", { name: "Create a bot", exact: true }).click(); + await expect(editor.getByRole("alert")).toContainText("Your choices are still here."); + const submission = await aiden.app.evaluate(() => (globalThis as unknown as { botEditorSubmission: unknown }).botEditorSubmission); + expect(submission).toMatchObject({ bot: { name: "Writing bot" }, access: { + accessMode: "custom", custom: { providerId: "custom:lmstudio", modelId: "aiden-e2e-vision", + shellEnabled: false, fileScopeIds: [], connectionIds: [], skillIds: [], otherCapabilityIds: [] }, + } }); + await editor.getByRole("button", { name: "Back", exact: true }).click(); + await expect(editor.getByPlaceholder("Release reviewer")).toHaveValue("Writing bot"); +}); diff --git a/tests/e2e/onboarding-lmstudio.spec.ts b/tests/e2e/onboarding-lmstudio.spec.ts index 9171de55..9ec7db49 100644 --- a/tests/e2e/onboarding-lmstudio.spec.ts +++ b/tests/e2e/onboarding-lmstudio.spec.ts @@ -56,7 +56,7 @@ test.describe("fresh portable config", () => { await onboarding.getByPlaceholder("Your name").fill(E2E_PROFILE_NAME); await onboarding.getByRole("button", { name: /^Next/u }).click(); - await onboarding.getByRole("button", { name: /Choose from more/u }).click(); + await onboarding.getByRole("button", { name: /Other ways/u }).click(); const google = onboarding.getByRole("button", { name: "Google Add your API key" }); await expect(google).toBeEnabled(); await google.click(); diff --git a/tests/e2e/remote-access-lifecycle.spec.ts b/tests/e2e/remote-access-lifecycle.spec.ts index 34601de8..cb454d0f 100644 --- a/tests/e2e/remote-access-lifecycle.spec.ts +++ b/tests/e2e/remote-access-lifecycle.spec.ts @@ -44,23 +44,39 @@ async function remoteHealth(port: number): Promise<{ status: number; body: unkno }); } -test("Remote Access is opt-in and remains available after the main window closes", async ({ aiden }) => { +test("guided phone setup asks once, enables access, and survives closing the main window", async ({ aiden }, testInfo) => { const { page } = aiden; await finishLmStudioOnboarding(page); await page.getByRole("button", { name: "Settings", exact: true }).click(); await page.getByRole("navigation", { name: "Settings" }) - .getByRole("button", { name: "Remote Access", exact: true }) + .getByRole("button", { name: "Aiden On The Go", exact: true }) .click(); - const enabled = page.getByRole("switch", { name: "Enable Aiden Remote Access" }); - await expect(enabled).toHaveAttribute("data-state", "unchecked"); + const choices = page.getByRole("radiogroup", { name: "Where will you use Aiden?" }); + const connect = page.getByRole("button", { name: "Connect a device", exact: true }); + // The field's label/content gap does not space children inside its content. + // Guard the actual geometry so the CTA cannot touch the last choice card again. + await expect(choices).toBeVisible(); + const choicesBox = await choices.boundingBox(); + const connectBox = await connect.boundingBox(); + expect(choicesBox).not.toBeNull(); + expect(connectBox).not.toBeNull(); + expect(connectBox!.y - (choicesBox!.y + choicesBox!.height)).toBeGreaterThanOrEqual(12); + expect(connectBox!.x).toBe(choicesBox!.x); + expect(connectBox!.width).toBeLessThan(choicesBox!.width); + await page.getByRole("group", { name: "1. Connect your phone", exact: true }) + .screenshot({ path: testInfo.outputPath("phone-setup-spacing.png") }); + + expect(await remoteStatus(page)).toMatchObject({ enabled: false, running: false }); + await page.getByRole("button", { name: "Connect a device", exact: true }).click(); + const review = page.getByRole("dialog", { name: "Connect your phone to this Mac?" }); + await expect(review).toBeVisible(); + expect(await remoteStatus(page)).toMatchObject({ enabled: false, running: false }); + await review.getByRole("button", { name: "Cancel", exact: true }).click(); expect(await remoteStatus(page)).toMatchObject({ enabled: false, running: false }); - await enabled.click(); - await expect(enabled).toHaveAttribute("data-state", "checked"); - await expect( - page.getByRole("group").filter({ has: enabled }) - .getByText("Ready for a device", { exact: true }), - ).toBeVisible(); + await page.getByRole("button", { name: "Connect a device", exact: true }).click(); + await review.getByRole("button", { name: "Enable and show code", exact: true }).click(); + await expect(page.getByRole("img", { name: "One-time Aiden pairing QR code", exact: true })).toBeVisible(); const running = await remoteStatus(page); expect(running).toMatchObject({ enabled: true, running: true }); assertHealth(await remoteHealth(running.lanPort)); diff --git a/tests/e2e/settings-model-picker.spec.ts b/tests/e2e/settings-model-picker.spec.ts index 28d4394f..416cab3b 100644 --- a/tests/e2e/settings-model-picker.spec.ts +++ b/tests/e2e/settings-model-picker.spec.ts @@ -6,7 +6,7 @@ const SETTINGS_SECTIONS = [ "Skills", "Plugins", "Web Search", - "Remote Access", + "Aiden On The Go", "Scheduled tasks", "Aiden", "Computer Use", @@ -48,19 +48,12 @@ async function assertRenderedSettingsDestination( page.getByRole("heading", { level: 1, name: "Web Search", exact: true }), ).toBeVisible(); return; - case "Remote Access": + case "Aiden On The Go": await expect( - page.getByRole("heading", { level: 2, name: "Remote Access", exact: true }), - ).toBeVisible(); - await expect( - page.getByRole("switch", { name: "Enable Aiden Remote Access" }), - ).toHaveAttribute("data-state", "unchecked"); - await expect( - page - .getByRole("group") - .filter({ has: page.getByRole("switch", { name: "Enable Aiden Remote Access" }) }) - .getByText("Off", { exact: true }), + page.getByRole("heading", { level: 2, name: "Aiden On The Go", exact: true }), ).toBeVisible(); + await expect(page.getByRole("button", { name: "Connect a device", exact: true })).toBeVisible(); + await expect(page.getByText(/This Mac settings/u)).toBeVisible(); return; case "Scheduled tasks": await expect( @@ -77,7 +70,7 @@ async function assertRenderedSettingsDestination( return; case "Voice": await expect( - page.getByRole("heading", { level: 2, name: "Voice Input", exact: true }), + page.getByRole("heading", { level: 2, name: "Use your voice", exact: true }), ).toBeVisible(); return; case "Keyboard shortcuts":