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
This commit is contained in:
Neil 2026-05-22 23:32:17 -07:00 committed by GitHub
parent 7def1c7f00
commit 79b18f6fed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 467 additions and 234 deletions

View File

@ -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
}

View File

@ -20,6 +20,7 @@ const SETTINGS_CHANGED_WHITELIST_SET = new Set<string>(SETTINGS_CHANGED_WHITELIS
// items when the backing state changes.
const APPEARANCE_MENU_KEYS: readonly (keyof GlobalSettings)[] = [
'showTasksButton',
'showMobileButton',
'showTitlebarAppName'
]

View File

@ -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')
})

View File

@ -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',

View File

@ -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;
}
}

View File

@ -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 (
<div>
<div className="mp-intro-shell">
<div className="mp-eyebrow-row">
<span className="mp-eyebrow">Orca Mobile</span>
</div>
@ -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&apos;re away from your desk.
</p>
<div className="mp-platform-badges" aria-label="Supported mobile platforms">
<span className="mp-platform-label">Available on</span>
<span className="mp-platform-badge">
<IosBrandIcon />
iOS
</span>
<span className="mp-platform-badge">
<AndroidLogo />
Android
</span>
</div>
<div className="mp-cta-row">
<button type="button" className="mp-primary-action" onClick={onStart}>
<button
type="button"
className="mp-primary-action mp-flow-primary-action"
onClick={onStart}
>
Get started
<ArrowRightIcon />
<ArrowRight className="size-3.5" />
</button>
</div>
</div>
@ -75,7 +91,7 @@ export function HeroPaired({
return (
<li key={device.deviceId} className="mp-paired-row">
<div className="mp-paired-icon">
<PhoneSmallIcon />
<Smartphone className="size-4" />
</div>
<div className="mp-paired-main">
<div className="mp-paired-name">{device.name}</div>
@ -91,15 +107,15 @@ export function HeroPaired({
aria-label={`Revoke ${device.name}`}
title="Revoke device"
>
<TrashIcon />
<Trash2 className="size-3.5" />
</button>
</li>
)
})}
</ul>
<div className="mp-flow-actions">
<button type="button" className="mp-flow-back" onClick={onPairAnother}>
<ArrowLeftIcon />
<button type="button" className="mp-secondary-action" onClick={onPairAnother}>
<Smartphone className="size-3.5" />
Pair another device
</button>
<span />
@ -156,7 +172,7 @@ export function HeroFlow({
const isLast = stepIdx === 1
return (
<div>
<div className="mp-flow-card">
<div className="mp-flow-viewport">
<div className={cn('mp-flow-screen', stepIdx === 0 ? 'is-active' : 'is-past')}>
<div className="mp-step2-layout">
@ -176,6 +192,7 @@ export function HeroFlow({
aria-pressed={platform === 'ios'}
onClick={() => onPlatformChange('ios')}
>
<IosBrandIcon />
iOS
</button>
<button
@ -184,6 +201,7 @@ export function HeroFlow({
aria-pressed={platform === 'android'}
onClick={() => onPlatformChange('android')}
>
<AndroidLogo />
Android
</button>
</div>
@ -192,7 +210,7 @@ export function HeroFlow({
{installCopy.ctaLabel}
</button>
<button type="button" className="mp-text-link" onClick={onCopyInstallUrl}>
<CopyIcon />
<Copy className="size-3.5" />
Copy install link
</button>
</div>
@ -245,7 +263,7 @@ export function HeroFlow({
aria-label="Refresh network interfaces"
title="Refresh network interfaces"
>
<RefreshIcon />
<RefreshCw className="size-3.5" />
</button>
</div>
@ -257,7 +275,7 @@ export function HeroFlow({
onClick={onCopyPairingCode}
disabled={!pairingUrl || pairLoading}
>
<CopyIcon />
<Copy className="size-3.5" />
Copy pairing code
</button>
</div>
@ -289,22 +307,30 @@ export function HeroFlow({
<div className="mp-flow-actions">
<button type="button" className="mp-flow-back" onClick={onBack}>
<ArrowLeftIcon />
<ArrowLeft className="size-3" />
Back
</button>
{isLast ? (
onDone ? (
<button type="button" className="mp-primary-action" onClick={onDone}>
<button
type="button"
className="mp-primary-action mp-flow-primary-action"
onClick={onDone}
>
Done
<ArrowRightIcon />
<ArrowRight className="size-3.5" />
</button>
) : (
<span />
)
) : (
<button type="button" className="mp-flow-continue" onClick={onContinue}>
<button
type="button"
className="mp-flow-continue mp-flow-primary-action"
onClick={onContinue}
>
Continue
<ArrowRightIcon />
<ArrowRight className="size-3.5" />
</button>
)}
</div>
@ -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 (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4">
<path d="M5 12h14" />
<path d="m13 6 6 6-6 6" />
<svg className="mp-platform-brand-icon" viewBox="0 0 24 24" aria-hidden>
<path d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701" />
</svg>
)
}
function ArrowLeftIcon(): React.JSX.Element {
function AndroidLogo(): React.JSX.Element {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2">
<path d="m15 18-6-6 6-6" />
</svg>
)
}
function CopyIcon(): React.JSX.Element {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)
}
function PhoneSmallIcon(): React.JSX.Element {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75">
<rect x="6" y="3" width="12" height="18" rx="2.5" />
<line x1="11" y1="18" x2="13" y2="18" />
</svg>
)
}
function TrashIcon(): React.JSX.Element {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75">
<path d="M3 6h18" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<path d="M19 6 18 20a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
</svg>
)
}
function RefreshIcon(): React.JSX.Element {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 12a9 9 0 1 1-3-6.7" />
<path d="M21 4v5h-5" />
<path d="M3 12a9 9 0 0 0 15 6.7" />
<path d="M3 20v-5h5" />
<svg className="mp-platform-brand-icon" viewBox="0 0 24 24" aria-hidden>
<path d="M18.4395 5.5586c-.675 1.1664-1.352 2.3318-2.0274 3.498-.0366-.0155-.0742-.0286-.1113-.043-1.8249-.6957-3.484-.8-4.42-.787-1.8551.0185-3.3544.4643-4.2597.8203-.084-.1494-1.7526-3.021-2.0215-3.4864a1.1451 1.1451 0 0 0-.1406-.1914c-.3312-.364-.9054-.4859-1.379-.203-.475.282-.7136.9361-.3886 1.5019 1.9466 3.3696-.0966-.2158 1.9473 3.3593.0172.031-.4946.2642-1.3926 1.0177C2.8987 12.176.452 14.772 0 18.9902h24c-.119-1.1108-.3686-2.099-.7461-3.0683-.7438-1.9118-1.8435-3.2928-2.7402-4.1836a12.1048 12.1048 0 0 0-2.1309-1.6875c.6594-1.122 1.312-2.2559 1.9649-3.3848.2077-.3615.1886-.7956-.0079-1.1191a1.1001 1.1001 0 0 0-.8515-.5332c-.5225-.0536-.9392.3128-1.0488.5449zm-.0391 8.461c.3944.5926.324 1.3306-.1563 1.6503-.4799.3197-1.188.0985-1.582-.4941-.3944-.5927-.324-1.3307.1563-1.6504.4727-.315 1.1812-.1086 1.582.4941zM7.207 13.5273c.4803.3197.5506 1.0577.1563 1.6504-.394.5926-1.1038.8138-1.584.4941-.48-.3197-.5503-1.0577-.1563-1.6504.4008-.6021 1.1087-.8106 1.584-.4941z" />
</svg>
)
}

View File

@ -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<FlowStage | null>(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<PairedDevice[]> => {
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 (
<div className="mobile-page-root">
<div className="mp-close-button">
<div className="mp-page-toolbar">
<Tooltip>
<TooltipTrigger asChild>
<Button
@ -382,6 +388,15 @@ export default function MobilePage(): React.JSX.Element {
Close · Esc
</TooltipContent>
</Tooltip>
<Button
variant="outline"
size="sm"
className="h-8 gap-2 rounded-md bg-card px-3 text-xs font-medium shadow-xs"
onClick={toggleMobileSidebarButton}
>
{showMobileButton ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
{showMobileButton ? 'Hide from sidebar' : 'Show in sidebar'}
</Button>
</div>
<section className="mp-hero">
<div className="mp-hero-copy">

View File

@ -115,10 +115,6 @@ export function TerminalSlide(): React.JSX.Element {
<div className="mp-accessory-key"></div>
<div className="mp-accessory-key"></div>
<div className="mp-accessory-key">Ctrl+C</div>
<div className="mp-accessory-key">Ctrl+L</div>
<div className="mp-accessory-key is-icon is-add" aria-label="Add custom shortcut">
<PlusIcon />
</div>
</div>
</div>

View File

@ -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 (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={ariaLabel}
onClick={onToggle}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
checked ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
checked ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
)
}
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 })}
/>
</SearchableSetting>
<SearchableSetting
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']}
className="flex items-center justify-between gap-4 px-1 py-2"
>
<div className="space-y-0.5">
<Label>Show Orca Mobile Button</Label>
<p className="text-xs text-muted-foreground">
Show the Orca Mobile shortcut in the sidebar. It remains available from Toolbox.
</p>
</div>
<ToggleSwitchButton
checked={settings.showMobileButton !== false}
onToggle={() =>
updateSettings({ showMobileButton: !(settings.showMobileButton !== false) })
}
/>
</SearchableSetting>
</section>
) : null
].filter(Boolean)

View File

@ -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 (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={ariaLabel}
onClick={onToggle}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
checked ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
checked ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
)
}

View File

@ -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)
})
})

View File

@ -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<GlobalSettings, 'experimentalActivity'> | null | undefined
@ -22,6 +28,12 @@ export function shouldShowAgentsButton(
return settings?.experimentalActivity === true
}
export function shouldShowMobileButton(
settings: Pick<GlobalSettings, 'showMobileButton'> | 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 (
<div className="flex flex-col gap-0.5 px-2 pt-2 pb-1">
@ -246,31 +263,43 @@ const SidebarNav = React.memo(function SidebarNav() {
) : null}
</button>
) : null}
<button
type="button"
onClick={() => {
mobileOnboardingBadge.dismiss()
openMobilePage()
}}
aria-current={mobileActive ? 'page' : undefined}
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] font-medium tracking-tight transition-colors',
mobileActive
? 'bg-sidebar-accent text-sidebar-accent-foreground'
: 'text-sidebar-foreground/60 hover:bg-sidebar-foreground/8'
)}
>
<Smartphone
className={cn('size-4 shrink-0', !mobileActive && 'text-sidebar-foreground/30')}
strokeWidth={mobileActive ? 2.25 : 1.75}
/>
<span className="flex-1">Orca Mobile</span>
{mobileOnboardingBadge.visible ? (
<span className="rounded-full bg-primary px-1.5 py-px text-[10px] font-semibold text-primary-foreground">
New
</span>
) : null}
</button>
{showMobileButton ? (
<ContextMenu>
<ContextMenuTrigger asChild>
<button
type="button"
onClick={() => {
mobileOnboardingBadge.dismiss()
openMobilePage()
}}
aria-current={mobileActive ? 'page' : undefined}
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] font-medium tracking-tight transition-colors',
mobileActive
? 'bg-sidebar-accent text-sidebar-accent-foreground'
: 'text-sidebar-foreground/60 hover:bg-sidebar-foreground/8'
)}
>
<Smartphone
className={cn('size-4 shrink-0', !mobileActive && 'text-sidebar-foreground/30')}
strokeWidth={mobileActive ? 2.25 : 1.75}
/>
<span className="flex-1">Orca Mobile</span>
{mobileOnboardingBadge.visible ? (
<span className="rounded-full bg-primary px-1.5 py-px text-[10px] font-semibold text-primary-foreground">
New
</span>
) : null}
</button>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={hideMobileButton}>
<EyeOff className="size-3.5" />
Hide from sidebar
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
) : null}
<button
type="button"
onClick={() => openModal('worktree-palette')}

View File

@ -9,7 +9,8 @@ import {
HardDrive,
MessageSquareText,
School,
Settings
Settings,
Smartphone
} from 'lucide-react'
import { useAppStore } from '@/store'
import { Button } from '@/components/ui/button'
@ -254,6 +255,7 @@ const SidebarToolbar = React.memo(function SidebarToolbar() {
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
const openSkillsPage = useAppStore((s) => s.openSkillsPage)
const openSpacePage = useAppStore((s) => s.openSpacePage)
const openMobilePage = useAppStore((s) => s.openMobilePage)
const [feedbackOpen, setFeedbackOpen] = useState(false)
const lastShowOnboardingAtRef = React.useRef(0)
@ -314,6 +316,10 @@ const SidebarToolbar = React.memo(function SidebarToolbar() {
<HardDrive className="size-3.5" />
Space Analyzer
</DropdownMenuItem>
<DropdownMenuItem onSelect={openMobilePage}>
<Smartphone className="size-3.5" />
Orca Mobile
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu modal={false}>

View File

@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest'
import { shouldLoadMobileSidebarOnboardingBadge } from './mobile-sidebar-onboarding-badge'
describe('mobile sidebar onboarding badge', () => {
it('does not query mobile devices when the sidebar button is hidden', () => {
expect(shouldLoadMobileSidebarOnboardingBadge(false, false)).toBe(false)
})
it('queries mobile devices only while visible and undismissed', () => {
expect(shouldLoadMobileSidebarOnboardingBadge(true, false)).toBe(true)
expect(shouldLoadMobileSidebarOnboardingBadge(true, true)).toBe(false)
})
})

View File

@ -10,10 +10,17 @@ function readDismissed(): boolean {
}
}
export function shouldLoadMobileSidebarOnboardingBadge(
enabled: boolean,
dismissed: boolean
): boolean {
return enabled && !dismissed
}
// Why: surface a one-time "Try it" badge on the Orca Mobile sidebar entry
// for users who haven't paired any device. Clicking the row dismisses it
// permanently, mirroring the once-and-done feel of an inbox unread dot.
export function useMobileSidebarOnboardingBadge(): {
export function useMobileSidebarOnboardingBadge(enabled = true): {
visible: boolean
dismiss: () => void
} {
@ -21,7 +28,7 @@ export function useMobileSidebarOnboardingBadge(): {
const [hasPairedDevice, setHasPairedDevice] = useState<boolean | null>(null)
useEffect(() => {
if (dismissed) {
if (!shouldLoadMobileSidebarOnboardingBadge(enabled, dismissed)) {
return
}
let cancelled = false
@ -40,7 +47,7 @@ export function useMobileSidebarOnboardingBadge(): {
return () => {
cancelled = true
}
}, [dismissed])
}, [dismissed, enabled])
const dismiss = useCallback(() => {
if (dismissed) {
@ -55,7 +62,7 @@ export function useMobileSidebarOnboardingBadge(): {
}, [dismissed])
return {
visible: !dismissed && hasPairedDevice === false,
visible: enabled && !dismissed && hasPairedDevice === false,
dismiss
}
}

View File

@ -215,6 +215,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
sourceControlViewMode: 'list',
showTitlebarAppName: true,
showTasksButton: true,
showMobileButton: true,
ctrlTabOrderMode: 'mru',
// Why: switching worktrees and opening command surfaces from a focused
// terminal is a core Orca workflow; users who prefer TUI ownership opt in.

View File

@ -1622,6 +1622,9 @@ export type GlobalSettings = {
* left sidebar free of its button entirely. Hiding the button here also
* removes it from keyboard navigation. */
showTasksButton: boolean
/** Why: Orca Mobile remains reachable from the toolbox; this only controls
* whether the top-level sidebar shortcut is shown. */
showMobileButton?: boolean
/** Controls how Ctrl+Tab chooses the next visible tab. Optional for
* profiles saved before this setting existed; readers default to MRU. */
ctrlTabOrderMode?: CtrlTabOrderMode