mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 08:03:22 +00:00
Add settings deep-link to expand Appearance's Window accordion for one-s
- Replaces the searchQuery-based redirect (fragile, flashed filter UI) with a dedicated appearanceAccordionDeepLink store field that force-opens the correct accordion and scrolls to the target row - Rebuilds the status-bar change notice as a plain elevated card instead of a Popover, since PopoverContent's glass/backdrop-filter defaults fought the opaque callout styling and needed heavy overrides - Simplifies the light/dark card CSS tokens accordingly
This commit is contained in:
@@ -1209,38 +1209,52 @@
|
||||
background: color-mix(in srgb, var(--worktree-sidebar-foreground) 14%, var(--worktree-sidebar));
|
||||
}
|
||||
|
||||
/* Why: status-bar usage-display change callout sits over same-luminance
|
||||
terminal/titlebar chrome. Light and dark use separate opaque palettes (not
|
||||
translucent glass) so the surface behind cannot bleed through. */
|
||||
.status-bar-change-notice-popover {
|
||||
/* Why: one-shot status-bar callout sits over same-luminance titlebar/terminal
|
||||
chrome. Opaque elevated surfaces (not glass) so the surface cannot bleed
|
||||
through; light and dark use separate token mixes. */
|
||||
.status-bar-change-notice-card {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
z-index: 60;
|
||||
/* Why: kill default PopoverContent glass; blur + alpha re-darkens the fill. */
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 16%, var(--border));
|
||||
background: var(--card);
|
||||
color: var(--card-foreground);
|
||||
box-shadow:
|
||||
0 0 0 1px color-mix(in srgb, var(--foreground) 6%, transparent),
|
||||
0 14px 36px color-mix(in srgb, var(--foreground) 14%, transparent),
|
||||
0 2px 8px color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
}
|
||||
|
||||
/* ── Light mode ────────────────────────────────────────
|
||||
Paper card over light chrome (#fff / #f5f5f5). White top + soft cool gray
|
||||
bottom so it lifts off the titlebar without looking like a hole. */
|
||||
:root:not(.dark) .status-bar-change-notice-popover,
|
||||
:root:not(.dark) .status-bar-change-notice-popover[data-slot='popover-content'] {
|
||||
background: linear-gradient(165deg, #ffffff 0%, #f7f7f8 52%, #eef0f3 100%) !important;
|
||||
border-color: #c8cdd6 !important;
|
||||
color: #0a0a0a !important;
|
||||
/* Light: pure paper above light chrome — lift with a cool edge, not gray fill. */
|
||||
:root:not(.dark) .status-bar-change-notice-card {
|
||||
background: #ffffff;
|
||||
border-color: #c4c9d2;
|
||||
color: #0a0a0a;
|
||||
box-shadow:
|
||||
0 0 0 1px rgb(15 23 42 / 0.06),
|
||||
0 18px 40px rgb(15 23 42 / 0.14),
|
||||
0 4px 12px rgb(15 23 42 / 0.08),
|
||||
inset 0 1px 0 #ffffff !important;
|
||||
0 16px 40px rgb(15 23 42 / 0.16),
|
||||
0 3px 10px rgb(15 23 42 / 0.08);
|
||||
}
|
||||
|
||||
:root:not(.dark) .status-bar-change-notice-popover .text-muted-foreground {
|
||||
color: #52525b !important;
|
||||
:root:not(.dark) .status-bar-change-notice-card .text-muted-foreground {
|
||||
color: #52525b;
|
||||
}
|
||||
|
||||
:root:not(.dark) .status-bar-change-notice-popover::after {
|
||||
/* Dark: lifted charcoal above #0a0a0a / #171717 — not translucent glass. */
|
||||
.dark .status-bar-change-notice-card {
|
||||
background: #2a2a2e;
|
||||
border-color: color-mix(in srgb, #ffffff 26%, transparent);
|
||||
color: #fafafa;
|
||||
box-shadow:
|
||||
0 0 0 1px color-mix(in srgb, #ffffff 12%, transparent),
|
||||
0 18px 44px rgb(0 0 0 / 0.62),
|
||||
0 3px 12px rgb(0 0 0 / 0.4);
|
||||
}
|
||||
|
||||
.dark .status-bar-change-notice-card .text-muted-foreground {
|
||||
color: color-mix(in srgb, #fafafa 68%, transparent);
|
||||
}
|
||||
|
||||
/* Why: caret points down at the usage meters this notice explains. */
|
||||
.status-bar-change-notice-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 1.25rem;
|
||||
@@ -1248,47 +1262,17 @@
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
transform: rotate(45deg);
|
||||
border-right: 1px solid #c8cdd6;
|
||||
border-bottom: 1px solid #c8cdd6;
|
||||
background: #eef0f3;
|
||||
border-right: 1px solid #c4c9d2;
|
||||
border-bottom: 1px solid #c4c9d2;
|
||||
background: #ffffff;
|
||||
box-shadow: 2px 2px 5px rgb(15 23 42 / 0.1);
|
||||
}
|
||||
|
||||
/* ── Dark mode ─────────────────────────────────────────
|
||||
Lifted charcoal over near-black terminal/titlebar (#0a0a0a / #171717). */
|
||||
.dark .status-bar-change-notice-popover,
|
||||
.dark .status-bar-change-notice-popover[data-slot='popover-content'] {
|
||||
background: linear-gradient(
|
||||
165deg,
|
||||
color-mix(in srgb, #ffffff 18%, #2a2a2e) 0%,
|
||||
#252528 48%,
|
||||
#1a1a1d 100%
|
||||
) !important;
|
||||
border-color: color-mix(in srgb, #ffffff 28%, transparent) !important;
|
||||
color: #fafafa !important;
|
||||
box-shadow:
|
||||
0 0 0 1px color-mix(in srgb, #ffffff 14%, transparent),
|
||||
0 20px 48px rgb(0 0 0 / 0.65),
|
||||
0 4px 14px rgb(0 0 0 / 0.45),
|
||||
inset 0 1px 0 color-mix(in srgb, #ffffff 16%, transparent) !important;
|
||||
}
|
||||
|
||||
.dark .status-bar-change-notice-popover .text-muted-foreground {
|
||||
color: color-mix(in srgb, #fafafa 68%, transparent) !important;
|
||||
}
|
||||
|
||||
.dark .status-bar-change-notice-popover::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 1.25rem;
|
||||
bottom: -6px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
transform: rotate(45deg);
|
||||
border-right: 1px solid color-mix(in srgb, #ffffff 28%, transparent);
|
||||
border-bottom: 1px solid color-mix(in srgb, #ffffff 28%, transparent);
|
||||
background: #1a1a1d;
|
||||
box-shadow: 2px 2px 8px rgb(0 0 0 / 0.5);
|
||||
.dark .status-bar-change-notice-card::after {
|
||||
border-right-color: color-mix(in srgb, #ffffff 26%, transparent);
|
||||
border-bottom-color: color-mix(in srgb, #ffffff 26%, transparent);
|
||||
background: #2a2a2e;
|
||||
box-shadow: 2px 2px 8px rgb(0 0 0 / 0.45);
|
||||
}
|
||||
|
||||
/* Why: the first-run Mobile Emulator intro sits directly under that menu row;
|
||||
|
||||
@@ -23,7 +23,9 @@ const mocks = vi.hoisted(() => ({
|
||||
usagePercentageDisplay: 'used' as 'used' | 'remaining',
|
||||
setUsagePercentageDisplay: vi.fn(),
|
||||
recordFeatureInteraction: vi.fn(),
|
||||
setWorktreeCardMode: vi.fn()
|
||||
setWorktreeCardMode: vi.fn(),
|
||||
appearanceAccordionDeepLink: null as 'interface' | 'terminal' | 'window' | null,
|
||||
clearAppearanceAccordionDeepLink: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useLayoutEffect, useState } from 'react'
|
||||
import { AppWindow, PanelLeft, TerminalSquare } from 'lucide-react'
|
||||
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
@@ -10,6 +10,7 @@ import { AppearanceWindowSidebarSection } from './AppearanceWindowSidebarSection
|
||||
import { SearchableSetting } from './SearchableSetting'
|
||||
import { matchesSettingsSearch, normalizeSettingsSearchQuery } from './settings-search'
|
||||
import { useAppStore } from '../../store'
|
||||
import { USAGE_PERCENTAGE_DISPLAY_SETTING_ID } from './appearance-usage-percentage-search'
|
||||
import {
|
||||
getAppIconEntries,
|
||||
getAppearancePaneSearchEntries,
|
||||
@@ -75,6 +76,10 @@ export function AppearancePane({
|
||||
warpThemes
|
||||
}: AppearancePaneProps): React.JSX.Element {
|
||||
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
|
||||
const appearanceAccordionDeepLink = useAppStore((state) => state.appearanceAccordionDeepLink)
|
||||
const clearAppearanceAccordionDeepLink = useAppStore(
|
||||
(state) => state.clearAppearanceAccordionDeepLink
|
||||
)
|
||||
const isSearching = normalizeSettingsSearchQuery(searchQuery).length > 0
|
||||
const isWebClient = isWebClientLocation()
|
||||
// Why: the system tray behavior is desktop-Electron Windows-only; a Windows
|
||||
@@ -84,6 +89,26 @@ export function AppearancePane({
|
||||
const [manuallyOpenSection, setManuallyOpenSection] = useState<AppearanceSectionKey | null>(
|
||||
'interface'
|
||||
)
|
||||
|
||||
// Why: nested deep links (e.g. Usage percentages) land under Window & Sidebar;
|
||||
// expand that accordion before Settings scrolls so the row is actually visible.
|
||||
useLayoutEffect(() => {
|
||||
if (!appearanceAccordionDeepLink) {
|
||||
return
|
||||
}
|
||||
setManuallyOpenSection(appearanceAccordionDeepLink)
|
||||
clearAppearanceAccordionDeepLink()
|
||||
// Why: accordion expand is layout-synchronous; scroll on the next frame so
|
||||
// the target has non-zero height when Settings (or this fallback) scrolls.
|
||||
const frameId = requestAnimationFrame(() => {
|
||||
document
|
||||
.getElementById(USAGE_PERCENTAGE_DISPLAY_SETTING_ID)
|
||||
?.scrollIntoView({ block: 'nearest' })
|
||||
})
|
||||
return () => {
|
||||
cancelAnimationFrame(frameId)
|
||||
}
|
||||
}, [appearanceAccordionDeepLink, clearAppearanceAccordionDeepLink])
|
||||
const interfaceTitle = translate(
|
||||
'auto.components.settings.AppearancePane.interfaceTitle',
|
||||
'Interface'
|
||||
|
||||
@@ -65,6 +65,7 @@ import { SettingsSidebar } from './SettingsSidebar'
|
||||
import { SettingsSetupGuidePane } from './SettingsSetupGuidePane'
|
||||
import { ActiveSettingsSectionProvider, SettingsSection } from './SettingsSection'
|
||||
import { getSettingsSectionSearchEntries, rankSettingsSearchItems } from './settings-search'
|
||||
import { resolveAppearanceAccordionDeepLink } from './appearance-usage-percentage-search'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { isIntentionalAppRestartInProgress } from '@/lib/updater-beforeunload'
|
||||
import { registerWindowCloseGuard } from '../window-close-request-coordinator'
|
||||
@@ -618,6 +619,14 @@ function Settings(): React.JSX.Element {
|
||||
)
|
||||
pendingNavSectionRef.current = paneSectionId
|
||||
pendingScrollTargetRef.current = settingsNavigationTarget.sectionId ?? paneSectionId
|
||||
// Why: Appearance nests status-bar controls under a collapsed accordion;
|
||||
// force that accordion open before scrolling so the row is actually visible.
|
||||
if (settingsNavigationTarget.pane === 'appearance') {
|
||||
const accordion = resolveAppearanceAccordionDeepLink(settingsNavigationTarget.sectionId)
|
||||
if (accordion) {
|
||||
useAppStore.getState().setAppearanceAccordionDeepLink(accordion)
|
||||
}
|
||||
}
|
||||
if (settingsNavigationTarget.intent === 'add-quick-command') {
|
||||
setQuickCommandAddIntentSignal((signal) => signal + 1)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
resolveAppearanceAccordionDeepLink,
|
||||
USAGE_PERCENTAGE_DISPLAY_SETTING_ID
|
||||
} from './appearance-usage-percentage-search'
|
||||
|
||||
describe('resolveAppearanceAccordionDeepLink', () => {
|
||||
it('maps the usage percentage row to the Window accordion', () => {
|
||||
expect(resolveAppearanceAccordionDeepLink(USAGE_PERCENTAGE_DISPLAY_SETTING_ID)).toBe('window')
|
||||
})
|
||||
|
||||
it('returns null for unknown or missing section ids', () => {
|
||||
expect(resolveAppearanceAccordionDeepLink(undefined)).toBeNull()
|
||||
expect(resolveAppearanceAccordionDeepLink('something-else')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,22 @@ import { translateSearchKeyword } from './settings-search-keywords'
|
||||
/** Stable Settings deep-link / scroll target for the Used/Remaining control. */
|
||||
export const USAGE_PERCENTAGE_DISPLAY_SETTING_ID = 'usage-percentage-display'
|
||||
|
||||
/** Appearance accordion keys that can be force-opened for nested deep links. */
|
||||
export type AppearanceAccordionSection = 'interface' | 'terminal' | 'window'
|
||||
|
||||
/**
|
||||
* Map a Settings subsection id to the Appearance accordion that must be open
|
||||
* before the row is visible (collapsed accordion content is not user-visible).
|
||||
*/
|
||||
export function resolveAppearanceAccordionDeepLink(
|
||||
sectionId: string | undefined
|
||||
): AppearanceAccordionSection | null {
|
||||
if (sectionId === USAGE_PERCENTAGE_DISPLAY_SETTING_ID) {
|
||||
return 'window'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const getUsagePercentageDisplayEntry = createLocalizedCatalog(() => ({
|
||||
title: translate(
|
||||
'auto.components.settings.appearance.search.usagePercentageDisplayTitle',
|
||||
|
||||
+14
-41
@@ -1,9 +1,10 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, createContext, useContext, type ReactNode } from 'react'
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { UsagePercentageDisplayChangeNotice } from './UsagePercentageDisplayChangeNotice'
|
||||
import { USAGE_PERCENTAGE_DISPLAY_SETTING_ID } from '../settings/appearance-usage-percentage-search'
|
||||
|
||||
const storeState = {
|
||||
persistedUIReady: true,
|
||||
@@ -11,7 +12,6 @@ const storeState = {
|
||||
dismissUsagePercentageDisplayChangeNotice: vi.fn(),
|
||||
statusBarVisible: true,
|
||||
activeModal: 'none' as string,
|
||||
setSettingsSearchQuery: vi.fn(),
|
||||
openSettingsTarget: vi.fn(),
|
||||
openSettingsPage: vi.fn()
|
||||
}
|
||||
@@ -25,29 +25,6 @@ vi.mock('@/store', () => ({
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/popover', () => {
|
||||
const OpenContext = createContext(false)
|
||||
return {
|
||||
Popover: ({ open, children }: { open?: boolean; children: ReactNode }) => (
|
||||
<OpenContext.Provider value={open === true}>
|
||||
<div data-testid="popover" data-open={open ? 'true' : 'false'}>
|
||||
{children}
|
||||
</div>
|
||||
</OpenContext.Provider>
|
||||
),
|
||||
PopoverAnchor: ({ children }: { children: ReactNode }) => (
|
||||
<div data-testid="popover-anchor">{children}</div>
|
||||
),
|
||||
PopoverContent: ({ children }: { children: ReactNode }) => {
|
||||
const open = useContext(OpenContext)
|
||||
if (!open) {
|
||||
return null
|
||||
}
|
||||
return <div data-testid="popover-content">{children}</div>
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('UsagePercentageDisplayChangeNotice', () => {
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
@@ -59,6 +36,8 @@ describe('UsagePercentageDisplayChangeNotice', () => {
|
||||
storeState.statusBarVisible = true
|
||||
storeState.activeModal = 'none'
|
||||
storeState.dismissUsagePercentageDisplayChangeNotice = vi.fn()
|
||||
storeState.openSettingsPage = vi.fn()
|
||||
storeState.openSettingsTarget = vi.fn()
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
@@ -72,7 +51,7 @@ describe('UsagePercentageDisplayChangeNotice', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('opens the callout next to usage meters after a short delay when eligible', () => {
|
||||
it('shows the callout above usage meters after a short delay when eligible', () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<UsagePercentageDisplayChangeNotice hasVisibleUsageMeters>
|
||||
@@ -81,11 +60,11 @@ describe('UsagePercentageDisplayChangeNotice', () => {
|
||||
)
|
||||
})
|
||||
|
||||
expect(container.querySelector('[data-open="true"]')).toBeNull()
|
||||
expect(container.querySelector('.status-bar-change-notice-card')).toBeNull()
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1_800)
|
||||
})
|
||||
expect(container.querySelector('[data-open="true"]')).not.toBeNull()
|
||||
expect(container.querySelector('.status-bar-change-notice-card')).not.toBeNull()
|
||||
expect(container.textContent).toContain('usage-meters')
|
||||
expect(container.textContent).toContain('Usage now shows % used')
|
||||
expect(container.textContent).toContain('Prefer remaining? Change it in Settings.')
|
||||
@@ -102,8 +81,7 @@ describe('UsagePercentageDisplayChangeNotice', () => {
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2_000)
|
||||
})
|
||||
expect(container.querySelector('[data-open="true"]')).toBeNull()
|
||||
expect(container.textContent).not.toContain('Usage now shows % used')
|
||||
expect(container.querySelector('.status-bar-change-notice-card')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not open when the notice was already dismissed', () => {
|
||||
@@ -118,8 +96,7 @@ describe('UsagePercentageDisplayChangeNotice', () => {
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2_000)
|
||||
})
|
||||
expect(container.querySelector('[data-open="true"]')).toBeNull()
|
||||
expect(container.textContent).not.toContain('Usage now shows % used')
|
||||
expect(container.querySelector('.status-bar-change-notice-card')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not open while another modal is open', () => {
|
||||
@@ -134,10 +111,10 @@ describe('UsagePercentageDisplayChangeNotice', () => {
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2_000)
|
||||
})
|
||||
expect(container.querySelector('[data-open="true"]')).toBeNull()
|
||||
expect(container.querySelector('.status-bar-change-notice-card')).toBeNull()
|
||||
})
|
||||
|
||||
it('opens Appearance with a Usage percentages filter after wiping prior search', () => {
|
||||
it('deep-links to the Usage percentages setting without a search filter', () => {
|
||||
const callOrder: string[] = []
|
||||
storeState.openSettingsPage = vi.fn(() => {
|
||||
callOrder.push('openSettingsPage')
|
||||
@@ -145,9 +122,6 @@ describe('UsagePercentageDisplayChangeNotice', () => {
|
||||
storeState.openSettingsTarget = vi.fn(() => {
|
||||
callOrder.push('openSettingsTarget')
|
||||
})
|
||||
storeState.setSettingsSearchQuery = vi.fn(() => {
|
||||
callOrder.push('setSettingsSearchQuery')
|
||||
})
|
||||
storeState.dismissUsagePercentageDisplayChangeNotice = vi.fn()
|
||||
|
||||
act(() => {
|
||||
@@ -169,13 +143,12 @@ describe('UsagePercentageDisplayChangeNotice', () => {
|
||||
openSettingsButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
})
|
||||
|
||||
// Why: openSettingsPage clears search first; the filter must be applied after.
|
||||
expect(callOrder).toEqual(['openSettingsPage', 'openSettingsTarget', 'setSettingsSearchQuery'])
|
||||
expect(callOrder).toEqual(['openSettingsPage', 'openSettingsTarget'])
|
||||
expect(storeState.openSettingsTarget).toHaveBeenCalledWith({
|
||||
pane: 'appearance',
|
||||
repoId: null
|
||||
repoId: null,
|
||||
sectionId: USAGE_PERCENTAGE_DISPLAY_SETTING_ID
|
||||
})
|
||||
expect(storeState.setSettingsSearchQuery).toHaveBeenCalledWith('Usage percentages')
|
||||
expect(storeState.dismissUsagePercentageDisplayChangeNotice).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,32 +1,29 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { BarChart3, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useAppStore } from '@/store'
|
||||
import { shouldShowUsagePercentageDisplayChangeNotice } from '../../../../shared/usage-percentage-display-change-notice'
|
||||
import { USAGE_PERCENTAGE_DISPLAY_SETTING_ID } from '../settings/appearance-usage-percentage-search'
|
||||
|
||||
// Why: let startup modals settle before the status-bar callout competes for focus.
|
||||
const SHOW_DELAY_MS = 1_800
|
||||
|
||||
function openUsagePercentageSettings(): void {
|
||||
const store = useAppStore.getState()
|
||||
// Why: openSettingsPage always wipes the search filter first — set the
|
||||
// Appearance target and filter after that wipe. The search string force-opens
|
||||
// Appearance → Window & Sidebar and surfaces the Usage percentages control.
|
||||
// Why: openSettingsPage wipes any leftover search; do not re-apply a search
|
||||
// filter — deep-link to the stable row id and let Appearance expand Window.
|
||||
store.openSettingsPage()
|
||||
store.openSettingsTarget({ pane: 'appearance', repoId: null })
|
||||
store.setSettingsSearchQuery(
|
||||
translate(
|
||||
'auto.components.settings.appearance.search.usagePercentageDisplayTitle',
|
||||
'Usage percentages'
|
||||
)
|
||||
)
|
||||
store.openSettingsTarget({
|
||||
pane: 'appearance',
|
||||
repoId: null,
|
||||
sectionId: USAGE_PERCENTAGE_DISPLAY_SETTING_ID
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Anchors a one-time high-contrast callout above the status-bar usage meters
|
||||
* after the default flipped from remaining → used. Permanent dismiss only.
|
||||
* One-time elevated callout anchored above the status-bar usage meters after
|
||||
* the default flipped from remaining → used. Permanent dismiss only.
|
||||
*/
|
||||
export function UsagePercentageDisplayChangeNotice({
|
||||
children,
|
||||
@@ -67,89 +64,87 @@ export function UsagePercentageDisplayChangeNotice({
|
||||
|
||||
const open = eligible && delayElapsed
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
const onKeyDown = (event: KeyboardEvent): void => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
}
|
||||
}, [dismiss, open])
|
||||
|
||||
const openSettings = (): void => {
|
||||
dismiss()
|
||||
openUsagePercentageSettings()
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open}>
|
||||
<PopoverAnchor asChild>
|
||||
<div className="flex items-center gap-3">{children}</div>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={12}
|
||||
// Why: permanent dismiss is intentional only via Got it / X / Settings —
|
||||
// outside click must not silently clear a one-shot education surface.
|
||||
onInteractOutside={(event) => {
|
||||
event.preventDefault()
|
||||
}}
|
||||
onOpenAutoFocus={(event) => {
|
||||
// Why: keep focus in the terminal/workspace; this is a soft callout,
|
||||
// not a modal that should steal keyboard context.
|
||||
event.preventDefault()
|
||||
}}
|
||||
onEscapeKeyDown={(event) => {
|
||||
event.preventDefault()
|
||||
dismiss()
|
||||
}}
|
||||
// Why: reuse elevated glass popover chrome (border + blur + shadow) and
|
||||
// layer status-bar-change-notice so the caret + contrast boost still apply.
|
||||
className="status-bar-change-notice-popover w-[340px] max-w-[calc(100vw-24px)] overflow-visible p-3.5"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="flex size-6 shrink-0 items-center justify-center rounded-full border border-border bg-secondary text-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<BarChart3 className="size-3.5" />
|
||||
</span>
|
||||
<div className="text-sm font-semibold leading-snug">
|
||||
{translate(
|
||||
'auto.components.status.bar.UsagePercentageDisplayChangeNotice.title',
|
||||
'Usage now shows % used'
|
||||
)}
|
||||
<div className="relative flex items-center gap-3">
|
||||
{children}
|
||||
{open ? (
|
||||
<div
|
||||
role="status"
|
||||
className="status-bar-change-notice-card absolute bottom-full left-0 z-50 mb-2.5 w-[320px] max-w-[calc(100vw-24px)] rounded-lg p-3.5"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="flex size-6 shrink-0 items-center justify-center rounded-full border border-border bg-secondary text-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<BarChart3 className="size-3.5" />
|
||||
</span>
|
||||
<div className="text-sm font-semibold leading-snug">
|
||||
{translate(
|
||||
'auto.components.status.bar.UsagePercentageDisplayChangeNotice.title',
|
||||
'Usage now shows % used'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm leading-5 text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.status.bar.UsagePercentageDisplayChangeNotice.body',
|
||||
'Prefer remaining? Change it in Settings.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm leading-5 text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.status.bar.UsagePercentageDisplayChangeNotice.body',
|
||||
'Prefer remaining? Change it in Settings.'
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0"
|
||||
onClick={dismiss}
|
||||
aria-label={translate(
|
||||
'auto.components.status.bar.UsagePercentageDisplayChangeNotice.dismiss',
|
||||
'Dismiss'
|
||||
)}
|
||||
</p>
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button variant="default" size="sm" className="min-w-0 flex-1" onClick={openSettings}>
|
||||
{translate(
|
||||
'auto.components.status.bar.UsagePercentageDisplayChangeNotice.openSettings',
|
||||
'Open Settings'
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" className="w-[84px]" onClick={dismiss}>
|
||||
{translate(
|
||||
'auto.components.status.bar.UsagePercentageDisplayChangeNotice.gotIt',
|
||||
'Got it'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0"
|
||||
onClick={dismiss}
|
||||
aria-label={translate(
|
||||
'auto.components.status.bar.UsagePercentageDisplayChangeNotice.dismiss',
|
||||
'Dismiss'
|
||||
)}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button variant="default" size="sm" className="min-w-0 flex-1" onClick={openSettings}>
|
||||
{translate(
|
||||
'auto.components.status.bar.UsagePercentageDisplayChangeNotice.openSettings',
|
||||
'Open Settings'
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" className="w-[84px]" onClick={dismiss}>
|
||||
{translate(
|
||||
'auto.components.status.bar.UsagePercentageDisplayChangeNotice.gotIt',
|
||||
'Got it'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -747,6 +747,16 @@ export type UISlice = {
|
||||
} | null
|
||||
openSettingsTarget: (target: NonNullable<UISlice['settingsNavigationTarget']>) => void
|
||||
clearSettingsTarget: () => void
|
||||
/**
|
||||
* One-shot Appearance accordion to expand for nested Settings deep links
|
||||
* (e.g. Usage percentages lives under Window & Sidebar). Cleared when
|
||||
* Appearance consumes it.
|
||||
*/
|
||||
appearanceAccordionDeepLink: 'interface' | 'terminal' | 'window' | null
|
||||
setAppearanceAccordionDeepLink: (
|
||||
section: NonNullable<UISlice['appearanceAccordionDeepLink']>
|
||||
) => void
|
||||
clearAppearanceAccordionDeepLink: () => void
|
||||
activeModal:
|
||||
| 'none'
|
||||
| 'create-worktree'
|
||||
@@ -1499,6 +1509,9 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
|
||||
settingsNavigationTarget: null,
|
||||
openSettingsTarget: (target) => set({ settingsNavigationTarget: target }),
|
||||
clearSettingsTarget: () => set({ settingsNavigationTarget: null }),
|
||||
appearanceAccordionDeepLink: null,
|
||||
setAppearanceAccordionDeepLink: (section) => set({ appearanceAccordionDeepLink: section }),
|
||||
clearAppearanceAccordionDeepLink: () => set({ appearanceAccordionDeepLink: null }),
|
||||
|
||||
activeModal: 'none',
|
||||
modalData: {},
|
||||
|
||||
Reference in New Issue
Block a user