diff --git a/apps/desktop/src/app/shell/statusbar-controls.tsx b/apps/desktop/src/app/shell/statusbar-controls.tsx index 7718682c74..a245d1dcd2 100644 --- a/apps/desktop/src/app/shell/statusbar-controls.tsx +++ b/apps/desktop/src/app/shell/statusbar-controls.tsx @@ -17,7 +17,13 @@ import { ContribRender } from '@/contrib/react/boundary' import { useI18n } from '@/i18n' import { useKeybindHint } from '@/lib/keybinds/use-keybind-hint' import { cn } from '@/lib/utils' -import { $statusbarHiddenIds, setStatusbarItemVisible, toggleStatusbarVisible } from '@/store/statusbar-prefs' +import { + $statusbarHiddenIds, + isStatusbarLayoutDefault, + resetStatusbarLayout, + setStatusbarItemVisible, + toggleStatusbarVisible +} from '@/store/statusbar-prefs' // Shared chrome styling for interactive statusbar items (button / link / menu // trigger). The 'text' variant intentionally omits hover/transition/disabled. @@ -176,6 +182,18 @@ function StatusbarVisibilityMenu({ ))} + {/* Disabled rather than hidden when nothing is customized: the row is + also how you find out there IS a shipped layout to get back to. + Groups with the hide row below — both act on the bar, not an item. */} + { + event.preventDefault() + resetStatusbarLayout() + }} + > + {copy.resetStatusbar} + )} diff --git a/apps/desktop/src/app/shell/statusbar-visibility.test.tsx b/apps/desktop/src/app/shell/statusbar-visibility.test.tsx index b4bce684bb..e65f272e05 100644 --- a/apps/desktop/src/app/shell/statusbar-visibility.test.tsx +++ b/apps/desktop/src/app/shell/statusbar-visibility.test.tsx @@ -125,6 +125,68 @@ describe('statusbar item visibility', () => { }) }) +describe('reset to defaults', () => { + it('puts a customized bar back to the shipped show/hide set', async () => { + $statusbarHiddenIds.set(['gateway-health']) + + const statusbar = bar([item('cron', 'Cron'), item('gateway-health', 'Gateway')]) + + expect(screen.queryByText('Gateway')).toBeNull() + expect(within(statusbar).getByText('Cron')).toBeTruthy() + + openContextMenu(statusbar) + fireEvent.click(await screen.findByRole('menuitem', { name: /reset to defaults/i })) + + expect($statusbarHiddenIds.get()).toEqual([...STATUSBAR_HIDDEN_BY_DEFAULT]) + expect(within(statusbar).getByText('Gateway')).toBeTruthy() + // Scoped to the bar: the menu stays open after a reset, so an unscoped query + // matches its still-listed 'Cron' checkbox row rather than a bar item. + expect(within(statusbar).queryByText('Cron')).toBeNull() + }) + + it('disables the row when the layout is already default', async () => { + const statusbar = bar([item('cron', 'Cron'), item('gateway-health', 'Gateway')]) + + openContextMenu(statusbar) + + const row = await screen.findByRole('menuitem', { name: /reset to defaults/i }) + expect(row.getAttribute('data-disabled')).not.toBeNull() + }) + + it('enables the row as soon as one item differs, in either direction', async () => { + const statusbar = bar([item('cron', 'Cron'), item('gateway-health', 'Gateway')]) + + // Showing a default-hidden item counts… + $statusbarHiddenIds.set(STATUSBAR_HIDDEN_BY_DEFAULT.filter(id => id !== 'cron')) + openContextMenu(statusbar) + expect( + (await screen.findByRole('menuitem', { name: /reset to defaults/i })).getAttribute('data-disabled') + ).toBeNull() + + // …and so does hiding a default-shown one. + $statusbarHiddenIds.set([...STATUSBAR_HIDDEN_BY_DEFAULT, 'gateway-health']) + expect( + (await screen.findByRole('menuitem', { name: /reset to defaults/i })).getAttribute('data-disabled') + ).toBeNull() + }) + + it('leaves whole-bar visibility alone — reset is about items, not the bar', async () => { + // Set to the NON-default so a reset that wrongly restored bar visibility too + // would flip this back to true and fail. StatusbarControls doesn't read the + // atom (the controller gates the mount), so the menu is still reachable here. + $statusbarVisible.set(false) + $statusbarHiddenIds.set([]) + + const statusbar = bar([item('gateway-health', 'Gateway')]) + + openContextMenu(statusbar) + fireEvent.click(await screen.findByRole('menuitem', { name: /reset to defaults/i })) + + expect($statusbarHiddenIds.get()).toEqual([...STATUSBAR_HIDDEN_BY_DEFAULT]) + expect($statusbarVisible.get()).toBe(false) + }) +}) + describe('whole-bar visibility', () => { it('hides the bar from the context menu, leaving the keybind as the way back', async () => { const statusbar = bar([item('gateway-health', 'Gateway')]) diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 35916ae65c..b0b3d46fbe 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -2561,6 +2561,7 @@ export const en: Translations = { gatewayTitle: 'Gateway', customizeTitle: 'Show in status bar', hideStatusbar: 'Hide status bar', + resetStatusbar: 'Reset to defaults', toggleApprovalMode: 'Approvals', toggleBackendVersion: 'Backend version', toggleCommandCenter: 'Command Center', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 4b34a851b0..4772ef6561 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -2153,6 +2153,7 @@ export interface Translations { gatewayTitle: string customizeTitle: string hideStatusbar: string + resetStatusbar: string toggleApprovalMode: string toggleBackendVersion: string toggleCommandCenter: string diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 471fa8767a..d33f5e1b59 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -2739,6 +2739,7 @@ export const zh: Translations = { gatewayTitle: '网关', customizeTitle: '在状态栏中显示', hideStatusbar: '隐藏状态栏', + resetStatusbar: '恢复默认设置', toggleApprovalMode: '审批', toggleBackendVersion: '后端版本', toggleCommandCenter: '命令中心', diff --git a/apps/desktop/src/store/statusbar-prefs.ts b/apps/desktop/src/store/statusbar-prefs.ts index 612c9be18e..7318e18d7e 100644 --- a/apps/desktop/src/store/statusbar-prefs.ts +++ b/apps/desktop/src/store/statusbar-prefs.ts @@ -51,3 +51,20 @@ export function setStatusbarItemVisible(id: string, visible: boolean) { $statusbarHiddenIds.set(visible ? hidden.filter(entry => entry !== id) : [...hidden, id]) } + +/** Pure so the menu can derive its reset row's disabled state from the hidden + * list it already subscribes to, rather than reading the atom out of band. + * Set-compared: order is incidental (items are appended as they're hidden) and + * a duplicated id shouldn't read as a customization. */ +export function isStatusbarLayoutDefault(hidden: readonly string[]) { + const ids = new Set(hidden) + + return ids.size === STATUSBAR_HIDDEN_BY_DEFAULT.length && STATUSBAR_HIDDEN_BY_DEFAULT.every(id => ids.has(id)) +} + +/** Put the show/hide set back to what ships. Only touches item layout — whole-bar + * visibility is a separate preference, and resetting from the bar's own menu + * shouldn't make the bar the user is right-clicking disappear. */ +export function resetStatusbarLayout() { + $statusbarHiddenIds.set([...STATUSBAR_HIDDEN_BY_DEFAULT]) +}