From 79b18f6fed3b434a660d8f43d2bc9288ecd3c0ea Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 22 May 2026 23:32:17 -0700 Subject: [PATCH] Fix Orca Mobile layout and sidebar controls (#2663) * Fix Orca Mobile layout and sidebar controls * Use brand icons for mobile platform badges * Add readable card for mobile pairing steps * Align mobile flow primary actions * Fix mobile sidebar toggle defaults --- src/main/index.ts | 14 +- src/main/ipc/settings.ts | 1 + src/main/menu/register-app-menu.test.ts | 18 +- src/main/menu/register-app-menu.ts | 11 + src/renderer/src/assets/mobile-page.css | 304 ++++++++++++------ .../src/components/mobile/MobileHero.tsx | 111 +++---- .../src/components/mobile/MobilePage.tsx | 19 +- .../mobile/slides/TerminalSlide.tsx | 4 - .../components/settings/AppearancePane.tsx | 55 ++-- .../settings/SettingsToggleSwitchButton.tsx | 30 ++ .../components/sidebar/SidebarNav.test.tsx | 11 +- .../src/components/sidebar/SidebarNav.tsx | 83 +++-- .../src/components/sidebar/SidebarToolbar.tsx | 8 +- .../mobile-sidebar-onboarding-badge.test.ts | 13 + .../mobile-sidebar-onboarding-badge.ts | 15 +- src/shared/constants.ts | 1 + src/shared/types.ts | 3 + 17 files changed, 467 insertions(+), 234 deletions(-) create mode 100644 src/renderer/src/components/settings/SettingsToggleSwitchButton.tsx create mode 100644 src/renderer/src/components/sidebar/mobile-sidebar-onboarding-badge.test.ts diff --git a/src/main/index.ts b/src/main/index.ts index 56d26e079..2c8657661 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -32,7 +32,11 @@ import { OrcaRuntimeService } from './runtime/orca-runtime' import { OrcaRuntimeRpcServer } from './runtime/runtime-rpc' import { awaitRuntimeFileWatcherUnsubscribes } from './runtime/orca-runtime-files' import { clearRuntimeMetadataIfOwned } from './runtime/runtime-metadata' -import { registerAppMenu, rebuildAppMenu } from './menu/register-app-menu' +import { + getNextDefaultOnAppearanceSettingValue, + registerAppMenu, + rebuildAppMenu +} from './menu/register-app-menu' import { checkForUpdatesFromMenu, isQuittingForUpdate } from './updater' import { configureDevUserDataPath, @@ -1086,11 +1090,14 @@ app.whenReady().then(async () => { return } const current = store.getSettings() - store.updateSettings({ [key]: !current[key] }) + // Why: these appearance settings are default-on for older profiles, so + // a missing persisted value must toggle from visible -> hidden. + const next = getNextDefaultOnAppearanceSettingValue(current[key]) + store.updateSettings({ [key]: next }) // Why: settings:get returns the current snapshot; renderer tracks // settings through window.api.settings.get(). Push the new value so // the sidebar/titlebar re-render without waiting for a round-trip. - mainWindow?.webContents.send('settings:changed', { [key]: !current[key] }) + mainWindow?.webContents.send('settings:changed', { [key]: next }) rebuildAppMenu() }, getAppearanceState: () => { @@ -1098,6 +1105,7 @@ app.whenReady().then(async () => { const ui = store?.getUI() return { showTasksButton: settings?.showTasksButton !== false, + showMobileButton: settings?.showMobileButton !== false, showTitlebarAppName: settings?.showTitlebarAppName !== false, statusBarVisible: ui?.statusBarVisible !== false } diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index f65cf9469..c8bc4103d 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -20,6 +20,7 @@ const SETTINGS_CHANGED_WHITELIST_SET = new Set(SETTINGS_CHANGED_WHITELIS // items when the backing state changes. const APPEARANCE_MENU_KEYS: readonly (keyof GlobalSettings)[] = [ 'showTasksButton', + 'showMobileButton', 'showTitlebarAppName' ] diff --git a/src/main/menu/register-app-menu.test.ts b/src/main/menu/register-app-menu.test.ts index c234dcee4..9f36660f6 100644 --- a/src/main/menu/register-app-menu.test.ts +++ b/src/main/menu/register-app-menu.test.ts @@ -19,7 +19,7 @@ vi.mock('electron', () => ({ } })) -import { registerAppMenu } from './register-app-menu' +import { getNextDefaultOnAppearanceSettingValue, registerAppMenu } from './register-app-menu' const isMac = process.platform === 'darwin' @@ -38,6 +38,7 @@ function buildMenuOptions() { onToggleAppearance: vi.fn(), getAppearanceState: vi.fn(() => ({ showTasksButton: true, + showMobileButton: true, showTitlebarAppName: true, statusBarVisible: true })) @@ -57,6 +58,12 @@ function getSubmenu( } describe('registerAppMenu', () => { + it('toggles missing default-on appearance settings from visible to hidden', () => { + expect(getNextDefaultOnAppearanceSettingValue(undefined)).toBe(false) + expect(getNextDefaultOnAppearanceSettingValue(true)).toBe(false) + expect(getNextDefaultOnAppearanceSettingValue(false)).toBe(true) + }) + beforeEach(() => { buildFromTemplateMock.mockReset() setApplicationMenuMock.mockReset() @@ -247,6 +254,7 @@ describe('registerAppMenu', () => { const options = buildMenuOptions() options.getAppearanceState.mockReturnValue({ showTasksButton: false, + showMobileButton: true, showTitlebarAppName: true, statusBarVisible: true }) @@ -262,6 +270,10 @@ describe('registerAppMenu', () => { expect(tasksItem?.type).toBe('checkbox') expect(tasksItem?.checked).toBe(false) + const mobileItem = appearanceSubmenu.find((item) => item.label === 'Show Orca Mobile Button') + expect(mobileItem?.type).toBe('checkbox') + expect(mobileItem?.checked).toBe(true) + const titlebarItem = appearanceSubmenu.find((item) => item.label === 'Show Titlebar App Name') expect(titlebarItem?.checked).toBe(true) @@ -280,11 +292,15 @@ describe('registerAppMenu', () => { appearanceSubmenu .find((item) => item.label === 'Show Tasks Button') ?.click?.({} as never, {} as never, {} as never) + appearanceSubmenu + .find((item) => item.label === 'Show Orca Mobile Button') + ?.click?.({} as never, {} as never, {} as never) appearanceSubmenu .find((item) => item.label === 'Show Titlebar App Name') ?.click?.({} as never, {} as never, {} as never) expect(options.onToggleAppearance).toHaveBeenCalledWith('showTasksButton') + expect(options.onToggleAppearance).toHaveBeenCalledWith('showMobileButton') expect(options.onToggleAppearance).toHaveBeenCalledWith('showTitlebarAppName') }) diff --git a/src/main/menu/register-app-menu.ts b/src/main/menu/register-app-menu.ts index a67693bfd..be55c9853 100644 --- a/src/main/menu/register-app-menu.ts +++ b/src/main/menu/register-app-menu.ts @@ -8,12 +8,17 @@ import { export type AppearanceMenuState = { showTasksButton: boolean + showMobileButton: boolean showTitlebarAppName: boolean statusBarVisible: boolean } export type AppearanceMenuKey = keyof AppearanceMenuState +export function getNextDefaultOnAppearanceSettingValue(current: boolean | undefined): boolean { + return !(current !== false) +} + type RegisterAppMenuOptions = { onOpenSettings: () => void onOpenFeatureTour: (window?: Electron.BaseWindow | null) => void @@ -212,6 +217,12 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void { checked: appearance.showTasksButton, click: () => onToggleAppearance('showTasksButton') }, + { + label: 'Show Orca Mobile Button', + type: 'checkbox', + checked: appearance.showMobileButton, + click: () => onToggleAppearance('showMobileButton') + }, { label: 'Show Titlebar App Name', type: 'checkbox', diff --git a/src/renderer/src/assets/mobile-page.css b/src/renderer/src/assets/mobile-page.css index 0898276a7..31320133c 100644 --- a/src/renderer/src/assets/mobile-page.css +++ b/src/renderer/src/assets/mobile-page.css @@ -2,16 +2,23 @@ 1:1 design spec for the in-app Mobile page; styles are scoped under .mobile-page-root so they don't leak into the rest of the app shell. */ -.mobile-page-root .mp-close-button { +.mobile-page-root .mp-page-toolbar { position: absolute; top: 12px; left: 12px; - z-index: 2; + z-index: 3; + display: flex; + align-items: center; + gap: 4px; } .mobile-page-root { /* Real mobile-app tokens (from mobile/src/theme/mobile-theme.ts) — used inside .mp-device-screen so simulated screens match the app exactly. */ + --mp-flow-card-padding: 24px; + --mp-flow-card-border-width: 1px; + --mp-flow-card-inset: calc(var(--mp-flow-card-padding) + var(--mp-flow-card-border-width)); + --mp-flow-shell-height: 400px; --m-bg-base: #111111; --m-bg-panel: #1a1a1a; --m-bg-raised: #242424; @@ -38,85 +45,36 @@ .mobile-page-root .mp-hero { position: relative; display: grid; - grid-template-columns: minmax(0, 660px) minmax(0, 470px); + grid-template-columns: minmax(360px, 560px) minmax(300px, 420px); justify-content: center; align-items: center; - gap: clamp(40px, 4.5vw, 70px); - padding: clamp(44px, 5.5vw, 72px) clamp(36px, 4.5vw, 64px); + gap: 56px; + padding: 56px; width: 100%; height: 100%; - overflow: auto; - scrollbar-width: thin; - /* Why: layer four large soft radial blobs across the whole hero so the - blue/indigo glow fills the dead space the corner-pinned blobs used to - leave empty. Each blob is animated below for slow drift. */ + overflow: hidden; background-color: transparent; - background-image: - radial-gradient( - ellipse 40% 35% at 90% 10%, - rgba(99, 102, 241, 0.28) 0%, - rgba(99, 102, 241, 0.08) 30%, - transparent 55% - ), - radial-gradient( - ellipse 38% 32% at 10% 95%, - rgba(59, 130, 246, 0.22) 0%, - rgba(59, 130, 246, 0.06) 35%, - transparent 60% - ), - /* Why: theme-aware so dots are visible in light mode too — hardcoded - white dots disappear on the light background. */ - radial-gradient( - circle, - color-mix(in srgb, var(--foreground) 8%, transparent) 1px, - transparent 1.2px - ); - background-size: - 200% 200%, - 200% 200%, - 5px 5px; - background-position: - 90% 10%, - 10% 95%, - 0 0; - background-repeat: no-repeat, no-repeat, repeat; - /* Why: animating background-position drifts the four blobs slowly across - the hero. The dot grid (last layer) stays fixed at 0 0. */ - animation: mp-hero-bg-drift 32s ease-in-out infinite alternate; - will-change: background-position; -} - -@keyframes mp-hero-bg-drift { - 0% { - background-position: - 90% 10%, - 10% 95%, - 0 0; - } - 100% { - background-position: - 75% 25%, - 25% 80%, - 0 0; - } -} - -@media (prefers-reduced-motion: reduce) { - .mobile-page-root .mp-hero { - animation: none; - } + /* Why: the page should feel branded without putting copy over decorative + blobs; a token-based dot texture stays quiet in both themes. */ + background-image: radial-gradient( + circle, + color-mix(in srgb, var(--foreground) 8%, transparent) 1px, + transparent 1.2px + ); + background-size: 5px 5px; + background-repeat: repeat; } .mobile-page-root .mp-hero-copy { position: relative; z-index: 1; - max-width: 660px; + max-width: 560px; display: flex; flex-direction: column; /* Why: min-height (rather than fixed height) keeps the intro/flow - screens at a uniform 420px box for centering, while letting the + screens at a uniform 400px box for centering, while letting the paired view grow when the device list needs more room. */ - min-height: 420px; + min-height: var(--mp-flow-shell-height); } .mobile-page-root .mp-hero-copy > * { @@ -125,6 +83,11 @@ flex-direction: column; } +.mobile-page-root .mp-intro-shell, +.mobile-page-root .mp-flow-card { + min-height: var(--mp-flow-shell-height); +} + .mobile-page-root .mp-eyebrow-row { display: flex; align-items: center; @@ -136,28 +99,63 @@ color: var(--muted-foreground); font-size: 14px; font-weight: 600; - letter-spacing: 0.08em; + letter-spacing: 0; text-transform: uppercase; } .mobile-page-root .mp-h1 { margin: 0; color: var(--foreground); - font-size: clamp(46px, 5vw, 72px); + font-size: 60px; font-weight: 650; - letter-spacing: -0.04em; - line-height: 1.04; + letter-spacing: 0; + line-height: 1.06; } .mobile-page-root .mp-lead { max-width: 580px; - margin: 24px 0 38px; + margin: 20px 0 22px; color: var(--muted-foreground); - font-size: 20px; + font-size: 18px; font-weight: 450; line-height: 1.55; } +.mobile-page-root .mp-platform-badges { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + margin: 0 0 18px; +} + +.mobile-page-root .mp-platform-label { + color: var(--muted-foreground); + font-size: 13px; + font-weight: 600; +} + +.mobile-page-root .mp-platform-badge { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 30px; + padding: 0 11px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--card); + color: var(--foreground); + font-size: 13px; + font-weight: 600; +} + +.mobile-page-root .mp-platform-brand-icon { + width: 16px; + height: 16px; + flex: 0 0 16px; + fill: currentColor; +} + .mobile-page-root .mp-paired-list { list-style: none; margin: 18px 0 0; @@ -172,7 +170,11 @@ at ~64px each plus 8px gap, so 4 × 64 + 3 × 8 ≈ 280px. */ max-height: 288px; overflow-y: auto; - scrollbar-width: thin; + scrollbar-width: none; +} + +.mobile-page-root .mp-paired-list::-webkit-scrollbar { + display: none; } .mobile-page-root .mp-paired-row { @@ -182,7 +184,7 @@ padding: 10px 12px; border: 1px solid var(--border); border-radius: 10px; - background: color-mix(in srgb, var(--foreground) 2%, transparent); + background: var(--card); } .mobile-page-root .mp-paired-icon { @@ -258,6 +260,10 @@ gap: 14px; margin-top: auto; padding-top: 16px; + /* Why: the intro CTA should land exactly where the step-flow primary + button appears, so users can click through without moving the pointer. */ + padding-right: var(--mp-flow-card-inset); + padding-bottom: var(--mp-flow-card-inset); } .mobile-page-root .mp-primary-action { @@ -285,8 +291,49 @@ height: 13px; } +.mobile-page-root .mp-flow-primary-action { + width: 150px; + justify-content: center; + white-space: nowrap; +} + +.mobile-page-root .mp-secondary-action { + box-sizing: border-box; + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 36px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--card); + color: var(--foreground); + padding: 0 14px; + font-size: 13px; + font-weight: 600; + line-height: 1; + cursor: pointer; + transition: + background-color 140ms ease, + color 140ms ease; +} + +.mobile-page-root .mp-secondary-action:hover { + background: var(--accent); +} + /* Step flow ─────────────────────────────────────────────────────── */ +.mobile-page-root .mp-flow-card { + display: flex; + flex-direction: column; + flex: 1 1 auto; + padding: var(--mp-flow-card-padding); + border: var(--mp-flow-card-border-width) solid var(--border); + border-radius: var(--radius-xl); + background: color-mix(in srgb, var(--card) 94%, var(--background)); + color: var(--card-foreground); +} + .mobile-page-root .mp-flow-header { margin-bottom: 24px; } @@ -430,6 +477,9 @@ } .mobile-page-root .mp-platform-toggle button { + display: inline-flex; + align-items: center; + gap: 6px; border: 0; padding: 5px 12px; border-radius: 999px; @@ -539,7 +589,7 @@ .mobile-page-root .mp-step2-layout { display: grid; grid-template-columns: minmax(0, 1fr) auto; - gap: 32px; + gap: 28px; align-items: start; } @@ -549,7 +599,7 @@ so push the QR down by roughly that combined height. */ .mobile-page-root .mp-step2-layout > .mp-qr, .mobile-page-root .mp-step2-layout > .mp-qr-stack { - margin-top: 88px; + margin-top: 72px; } .mobile-page-root .mp-step2-copy { @@ -559,9 +609,9 @@ .mobile-page-root .mp-h2 { margin: 0; color: var(--foreground); - font-size: clamp(28px, 3vw, 38px); + font-size: 34px; font-weight: 650; - letter-spacing: -0.02em; + letter-spacing: 0; line-height: 1.08; } @@ -581,6 +631,9 @@ } .mobile-page-root .mp-tab-toggle button { + display: inline-flex; + align-items: center; + gap: 6px; border: 0; background: transparent; padding: 0 0 6px; @@ -809,12 +862,13 @@ z-index: 1; display: grid; place-items: center; - min-height: 830px; + min-height: 0; + height: min(100%, calc(100vh - 112px)); } .mobile-page-root .mp-phone-frame { position: relative; - width: min(100%, 470px); + width: min(100%, 420px, calc((100vh - 112px) * 9 / 19.5)); aspect-ratio: 9 / 19.5; border-radius: 48px; background: #0a0a0a; @@ -891,9 +945,10 @@ 'Segoe UI', sans-serif; font-size: 13px; - /* Why: phone-frame grew 50% but slide content uses fixed px tokens; zoom - scales children in layout space so they fill the bigger frame proportionally. */ - zoom: 1.35; + box-sizing: border-box; + /* Why: the phone frame is viewport-fit now; the preview content must stay + legible without reintroducing the clipped, over-zoomed mobile mock. */ + zoom: 1.08; } .mobile-page-root .mp-wl-chrome, @@ -906,7 +961,7 @@ display: flex; align-items: center; justify-content: space-between; - padding: 26px 11px 12px; + padding: 26px 16px 12px; } .mobile-page-root .mp-app-brand { @@ -947,7 +1002,7 @@ .mobile-page-root .mp-scroll-region { flex: 1; overflow: hidden; - padding: 0 11px; + padding: 0 16px 16px; } .mobile-page-root .mp-greeting { @@ -958,7 +1013,7 @@ color: var(--m-text-primary); font-size: 22px; font-weight: 800; - letter-spacing: -0.01em; + letter-spacing: 0; } .mobile-page-root .mp-stat-row { @@ -979,7 +1034,7 @@ color: var(--m-text-primary); font-size: 17px; font-weight: 700; - letter-spacing: -0.02em; + letter-spacing: 0; } .mobile-page-root .mp-stat-label { @@ -1610,7 +1665,7 @@ color: var(--m-text-primary); font-size: 15px; font-weight: 600; - letter-spacing: -0.01em; + letter-spacing: 0; } .mobile-page-root .mp-session-meta-row { @@ -1749,13 +1804,8 @@ display: flex; align-items: center; gap: 4px; - padding: 4px 8px; - overflow-x: auto; - scrollbar-width: none; -} - -.mobile-page-root .mp-accessory-content::-webkit-scrollbar { - display: none; + padding: 4px 7px; + overflow: hidden; } .mobile-page-root .mp-accessory-key { @@ -1763,12 +1813,12 @@ align-items: center; justify-content: center; flex: 0 0 auto; - min-width: 36px; - padding: 4px 10px; + min-width: 24px; + padding: 4px 6px; border-radius: 6px; background: var(--m-bg-raised); color: var(--m-text-secondary); - font-size: 12px; + font-size: 11px; font-family: var(--m-mono); } @@ -1832,8 +1882,62 @@ /* Narrow viewport: stack the hero so the phone falls below the copy */ @media (max-width: 920px) { + .mobile-page-root { + --mp-flow-card-padding: 20px; + } + .mobile-page-root .mp-hero { grid-template-columns: 1fr; - padding: 56px 32px 48px; + grid-template-rows: auto minmax(0, 1fr); + align-items: start; + gap: 24px; + padding: 52px 28px 28px; + } + + .mobile-page-root .mp-hero-copy { + min-height: 0; + } + + .mobile-page-root .mp-h1 { + font-size: 44px; + } + + .mobile-page-root .mp-stage { + height: min(420px, 42vh); + } + + .mobile-page-root .mp-phone-frame { + width: min(290px, 100%); + } +} + +@media (max-width: 1180px) and (min-width: 921px) { + .mobile-page-root .mp-hero { + grid-template-columns: minmax(330px, 520px) minmax(280px, 360px); + gap: 40px; + padding: 52px 40px 40px; + } + + .mobile-page-root .mp-h1 { + font-size: 52px; + } +} + +@media (max-height: 840px) and (min-width: 921px) { + .mobile-page-root .mp-hero { + padding: 48px; + } + + .mobile-page-root .mp-hero-copy { + min-height: var(--mp-flow-shell-height); + } + + .mobile-page-root .mp-h1 { + font-size: 50px; + } + + .mobile-page-root .mp-lead { + margin-bottom: 16px; + font-size: 17px; } } diff --git a/src/renderer/src/components/mobile/MobileHero.tsx b/src/renderer/src/components/mobile/MobileHero.tsx index ed0d12d21..b9daa7d59 100644 --- a/src/renderer/src/components/mobile/MobileHero.tsx +++ b/src/renderer/src/components/mobile/MobileHero.tsx @@ -1,3 +1,4 @@ +import { ArrowLeft, ArrowRight, Copy, RefreshCw, Smartphone, Trash2 } from 'lucide-react' import { cn } from '../../lib/utils' import type { MobileNetworkInterface } from '../settings/mobile-network-interface-selection' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' @@ -26,7 +27,7 @@ function getDeviceLabel(): string { export function HeroIntro({ onStart }: { onStart: () => void }): React.JSX.Element { return ( -
+
Orca Mobile
@@ -35,10 +36,25 @@ export function HeroIntro({ onStart }: { onStart: () => void }): React.JSX.Eleme Control Orca from your phone. Check on agents, review changes, and kick off tasks while you're away from your desk.

+
+ Available on + + + iOS + + + + Android + +
-
@@ -75,7 +91,7 @@ export function HeroPaired({ return (
  • - +
    {device.name}
    @@ -91,15 +107,15 @@ export function HeroPaired({ aria-label={`Revoke ${device.name}`} title="Revoke device" > - +
  • ) })}
    - @@ -156,7 +172,7 @@ export function HeroFlow({ const isLast = stepIdx === 1 return ( -
    +
    @@ -176,6 +192,7 @@ export function HeroFlow({ aria-pressed={platform === 'ios'} onClick={() => onPlatformChange('ios')} > + iOS
    @@ -192,7 +210,7 @@ export function HeroFlow({ {installCopy.ctaLabel}
    @@ -245,7 +263,7 @@ export function HeroFlow({ aria-label="Refresh network interfaces" title="Refresh network interfaces" > - +
    @@ -257,7 +275,7 @@ export function HeroFlow({ onClick={onCopyPairingCode} disabled={!pairingUrl || pairLoading} > - + Copy pairing code
    @@ -289,22 +307,30 @@ export function HeroFlow({
    {isLast ? ( onDone ? ( - ) : ( ) ) : ( - )}
    @@ -312,58 +338,19 @@ export function HeroFlow({ ) } -function ArrowRightIcon(): React.JSX.Element { +// Why: these are exact filled brand paths, not generic line approximations. +function IosBrandIcon(): React.JSX.Element { return ( - - - + + ) } -function ArrowLeftIcon(): React.JSX.Element { +function AndroidLogo(): React.JSX.Element { return ( - - - - ) -} - -function CopyIcon(): React.JSX.Element { - return ( - - - - - ) -} - -function PhoneSmallIcon(): React.JSX.Element { - return ( - - - - - ) -} - -function TrashIcon(): React.JSX.Element { - return ( - - - - - - ) -} - -function RefreshIcon(): React.JSX.Element { - return ( - - - - - + + ) } diff --git a/src/renderer/src/components/mobile/MobilePage.tsx b/src/renderer/src/components/mobile/MobilePage.tsx index 2816234fa..e794fb3fc 100644 --- a/src/renderer/src/components/mobile/MobilePage.tsx +++ b/src/renderer/src/components/mobile/MobilePage.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import QRCodeBrowser from 'qrcode/lib/browser' import { toast } from 'sonner' -import { X } from 'lucide-react' +import { Eye, EyeOff, X } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useAppStore } from '@/store' @@ -70,6 +70,8 @@ export default function MobilePage(): React.JSX.Element { // change while already in paired view. const lastStageRef = useRef(null) const closeMobilePage = useAppStore((s) => s.closeMobilePage) + const showMobileButton = useAppStore((s) => s.settings?.showMobileButton !== false) + const updateSettings = useAppStore((s) => s.updateSettings) const loadDevices = useCallback(async (): Promise => { try { @@ -334,6 +336,10 @@ export default function MobilePage(): React.JSX.Element { } } + const toggleMobileSidebarButton = useCallback(() => { + void updateSettings({ showMobileButton: !showMobileButton }) + }, [showMobileButton, updateSettings]) + // Why: mirror Automations/Tasks — Esc first exits field focus, then closes the page. useEffect(() => { function onKeyDown(event: KeyboardEvent): void { @@ -365,7 +371,7 @@ export default function MobilePage(): React.JSX.Element { return (
    -
    +
    diff --git a/src/renderer/src/components/mobile/slides/TerminalSlide.tsx b/src/renderer/src/components/mobile/slides/TerminalSlide.tsx index c01ef029f..c8263cccb 100644 --- a/src/renderer/src/components/mobile/slides/TerminalSlide.tsx +++ b/src/renderer/src/components/mobile/slides/TerminalSlide.tsx @@ -115,10 +115,6 @@ export function TerminalSlide(): React.JSX.Element {
    Ctrl+C
    -
    Ctrl+L
    -
    - -
    diff --git a/src/renderer/src/components/settings/AppearancePane.tsx b/src/renderer/src/components/settings/AppearancePane.tsx index 757c3f91b..3ee7be079 100644 --- a/src/renderer/src/components/settings/AppearancePane.tsx +++ b/src/renderer/src/components/settings/AppearancePane.tsx @@ -10,6 +10,7 @@ import { useShortcutLabel } from '@/hooks/useShortcutLabel' import { FontAutocomplete } from './SettingsFormControls' import { DEFAULT_APP_FONT_FAMILY } from '../../../../shared/constants' import { useAvailableStatusBarToggles } from '../status-bar/use-available-status-bar-toggles' +import { SettingsToggleSwitchButton as ToggleSwitchButton } from './SettingsToggleSwitchButton' type AppearancePaneProps = { settings: GlobalSettings @@ -18,35 +19,6 @@ type AppearancePaneProps = { fontSuggestions: string[] } -function ToggleSwitchButton({ - checked, - onToggle, - ariaLabel -}: { - checked: boolean - onToggle: () => void - ariaLabel?: string -}): React.JSX.Element { - return ( - - ) -} - const STATUS_BAR_TOGGLES: readonly { id: StatusBarItem title: string @@ -162,6 +134,11 @@ const SIDEBAR_ENTRIES: SettingsSearchEntry[] = [ title: 'Show Tasks Button', description: 'Show the Tasks button at the top of the left sidebar.', keywords: ['tasks', 'sidebar', 'button', 'hide', 'show', 'github', 'linear'] + }, + { + title: 'Show Orca Mobile Button', + description: 'Show the Orca Mobile button at the top of the left sidebar.', + keywords: ['mobile', 'phone', 'sidebar', 'button', 'hide', 'show', 'toolbox'] } ] @@ -403,6 +380,26 @@ export function AppearancePane({ onToggle={() => updateSettings({ showTasksButton: !settings.showTasksButton })} /> + + +
    + +

    + Show the Orca Mobile shortcut in the sidebar. It remains available from Toolbox. +

    +
    + + updateSettings({ showMobileButton: !(settings.showMobileButton !== false) }) + } + /> +
    ) : null ].filter(Boolean) diff --git a/src/renderer/src/components/settings/SettingsToggleSwitchButton.tsx b/src/renderer/src/components/settings/SettingsToggleSwitchButton.tsx new file mode 100644 index 000000000..df28dd436 --- /dev/null +++ b/src/renderer/src/components/settings/SettingsToggleSwitchButton.tsx @@ -0,0 +1,30 @@ +import type React from 'react' + +export function SettingsToggleSwitchButton({ + checked, + onToggle, + ariaLabel +}: { + checked: boolean + onToggle: () => void + ariaLabel?: string +}): React.JSX.Element { + return ( + + ) +} diff --git a/src/renderer/src/components/sidebar/SidebarNav.test.tsx b/src/renderer/src/components/sidebar/SidebarNav.test.tsx index 56fc3363b..95063a96c 100644 --- a/src/renderer/src/components/sidebar/SidebarNav.test.tsx +++ b/src/renderer/src/components/sidebar/SidebarNav.test.tsx @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { getDefaultSettings } from '../../../../shared/constants' -import { shouldShowAgentsButton } from './SidebarNav' +import { shouldShowAgentsButton, shouldShowMobileButton } from './SidebarNav' describe('SidebarNav', () => { it('hides the Agents entry while settings are loading', () => { @@ -24,4 +24,13 @@ describe('SidebarNav', () => { }) ).toBe(true) }) + + it('shows the Mobile entry by default for older settings', () => { + expect(shouldShowMobileButton(null)).toBe(true) + expect(shouldShowMobileButton({})).toBe(true) + }) + + it('hides the Mobile entry when the sidebar setting is off', () => { + expect(shouldShowMobileButton({ showMobileButton: false })).toBe(false) + }) }) diff --git a/src/renderer/src/components/sidebar/SidebarNav.tsx b/src/renderer/src/components/sidebar/SidebarNav.tsx index d1d3c42f4..c2da7e168 100644 --- a/src/renderer/src/components/sidebar/SidebarNav.tsx +++ b/src/renderer/src/components/sidebar/SidebarNav.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Bell, CalendarClock, Github, Gitlab, List, Search, Smartphone } from 'lucide-react' +import { Bell, CalendarClock, EyeOff, Github, Gitlab, List, Search, Smartphone } from 'lucide-react' import { useAppStore } from '@/store' import { useRepoMap } from '@/store/selectors' import { cn } from '@/lib/utils' @@ -15,6 +15,12 @@ import { import { useActivityUnreadCount } from '@/components/activity/useActivityUnreadCount' import { useShortcutLabel } from '@/hooks/useShortcutLabel' import { useMobileSidebarOnboardingBadge } from './mobile-sidebar-onboarding-badge' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger +} from '@/components/ui/context-menu' export function shouldShowAgentsButton( settings: Pick | null | undefined @@ -22,6 +28,12 @@ export function shouldShowAgentsButton( return settings?.experimentalActivity === true } +export function shouldShowMobileButton( + settings: Pick | null | undefined +): boolean { + return settings?.showMobileButton !== false +} + const SidebarNav = React.memo(function SidebarNav() { const worktreePaletteShortcut = useShortcutLabel('worktree.palette') const openTaskPage = useAppStore((s) => s.openTaskPage) @@ -29,6 +41,7 @@ const SidebarNav = React.memo(function SidebarNav() { const openActivityPage = useAppStore((s) => s.openActivityPage) const openMobilePage = useAppStore((s) => s.openMobilePage) const openModal = useAppStore((s) => s.openModal) + const updateSettings = useAppStore((s) => s.updateSettings) const activeView = useAppStore((s) => s.activeView) const repos = useAppStore((s) => s.repos) const repoMap = useRepoMap() @@ -45,6 +58,7 @@ const SidebarNav = React.memo(function SidebarNav() { const linearStatusChecked = useAppStore((s) => s.linearStatusChecked) const checkLinearConnection = useAppStore((s) => s.checkLinearConnection) const showAgentsButton = useAppStore((s) => shouldShowAgentsButton(s.settings)) + const showMobileButton = useAppStore((s) => shouldShowMobileButton(s.settings)) const preferredVisibleTaskProviders = React.useMemo( () => normalizeVisibleTaskProviders(rawVisibleTaskProviders), [rawVisibleTaskProviders] @@ -120,7 +134,10 @@ const SidebarNav = React.memo(function SidebarNav() { const activityActive = activeView === 'activity' const mobileActive = activeView === 'mobile' const activityUnreadCount = useActivityUnreadCount(showAgentsButton, 'sidebar-badge') - const mobileOnboardingBadge = useMobileSidebarOnboardingBadge() + const mobileOnboardingBadge = useMobileSidebarOnboardingBadge(showMobileButton) + const hideMobileButton = React.useCallback(() => { + void updateSettings({ showMobileButton: false }) + }, [updateSettings]) return (
    @@ -246,31 +263,43 @@ const SidebarNav = React.memo(function SidebarNav() { ) : null} ) : null} - + {showMobileButton ? ( + + + + + + + + Hide from sidebar + + + + ) : null}