diff --git a/app/renderer/components/AboutModal.tsx b/app/renderer/components/AboutModal.tsx index d1f7563f..4cb1bc56 100644 --- a/app/renderer/components/AboutModal.tsx +++ b/app/renderer/components/AboutModal.tsx @@ -12,12 +12,23 @@ export type SocialLink = { icon: ReactNode } +/** Where CodeBurn lives on the web. These used to sit as a row of glyphs in the sidebar's + * bottom corner, which is where the two Windows switches are now; About was already listing + * them under Links, so that is the one place they appear. */ +export const SOCIALS: SocialLink[] = [ + { label: 'GitHub', url: 'https://github.com/getagentseal/codeburn', icon: }, + { label: 'Discord', url: 'https://discord.com/invite/w2sw8mCqep', icon: }, + { label: 'X', url: 'https://x.com/_codeburn', icon: }, + { label: 'YouTube', url: 'https://www.youtube.com/@codeburnn', icon: }, + { label: 'LinkedIn', url: 'https://www.linkedin.com/showcase/codeburnn/', icon: }, +] + function openExternal(event: MouseEvent, url: string): void { event.preventDefault() void codeburn.openExternal(url) } -export function AboutModal({ socials, onClose }: { socials: SocialLink[]; onClose: () => void }) { +export function AboutModal({ socials = SOCIALS, onClose }: { socials?: SocialLink[]; onClose: () => void }) { const status = useUpdateStatus() const [checked, setChecked] = useState(false) diff --git a/app/renderer/components/Sidebar.test.tsx b/app/renderer/components/Sidebar.test.tsx index 7f1495e7..01081bd6 100644 --- a/app/renderer/components/Sidebar.test.tsx +++ b/app/renderer/components/Sidebar.test.tsx @@ -1,16 +1,31 @@ // @vitest-environment jsdom -import { afterEach, describe, it, expect, vi } from 'vitest' -import { render, screen, fireEvent } from '@testing-library/react' +import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { Sidebar } from './Sidebar' +// The preload bridge is read once at module load, so the switches need it mocked rather than +// assigned onto `window` after the fact. +const bridge = vi.hoisted(() => ({ + companionStatus: vi.fn(), + setMenuBarEnabled: vi.fn(), + setSidebarEnabled: vi.fn(), + openExternal: vi.fn(), +})) +vi.mock('../lib/ipc', () => ({ codeburn: bridge, normalizeCliError: (err: unknown) => err })) + function setPlatform(platform: string): void { ;(window as unknown as { codeburn?: { platform?: string } }).codeburn = { platform } } describe('Sidebar', () => { + beforeEach(() => { + bridge.companionStatus.mockResolvedValue({ supported: false, menuBar: false, sidebar: false, store: false }) + }) + afterEach(() => { delete (window as unknown as { codeburn?: { platform?: string } }).codeburn + vi.clearAllMocks() }) it.each([ @@ -48,4 +63,94 @@ describe('Sidebar', () => { // motionEnabled() is off under vitest, so the idle flicker never attaches. expect(container.querySelector('.fm-flicker')).toBeNull() }) + + it('keeps About in the corner and the social glyphs out of it', async () => { + const { container } = render( {}} />) + expect(screen.getByRole('link', { name: 'About' })).toBeInTheDocument() + expect(container.querySelector('.foot .social')).toBeNull() + // They moved into the About modal, which lists them under Links. + fireEvent.click(screen.getByRole('link', { name: 'About' })) + expect(await screen.findByRole('link', { name: /GitHub/ })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /LinkedIn/ })).toBeInTheDocument() + }) +}) + +describe('Sidebar companion switches', () => { + const SUPPORTED = { supported: true, menuBar: true, sidebar: true, store: false } + + afterEach(() => { vi.clearAllMocks() }) + + async function renderSwitches(status = SUPPORTED) { + bridge.companionStatus.mockResolvedValue(status) + render( {}} />) + return screen.findByRole('switch', { name: 'Menu bar' }) + } + + it('shows nothing where the main process reports no bundled tray app', async () => { + bridge.companionStatus.mockResolvedValue({ supported: false, menuBar: false, sidebar: false, store: false }) + render( {}} />) + + await waitFor(() => expect(bridge.companionStatus).toHaveBeenCalled()) + expect(screen.queryByRole('switch')).toBeNull() + }) + + it('survives a preload that has never heard of them', async () => { + bridge.companionStatus.mockRejectedValue(new Error('no such channel')) + render( {}} />) + + await waitFor(() => expect(bridge.companionStatus).toHaveBeenCalled()) + expect(screen.queryByRole('switch')).toBeNull() + }) + + it('renders both switches on, in the sidebar corner', async () => { + const menuBar = await renderSwitches() + + expect(menuBar).toHaveAttribute('aria-checked', 'true') + expect(screen.getByRole('switch', { name: 'Sidebar' })).toHaveAttribute('aria-checked', 'true') + }) + + it('turning Menu bar off sends false and renders the status that came back', async () => { + const menuBar = await renderSwitches() + bridge.setMenuBarEnabled.mockResolvedValue({ ...SUPPORTED, menuBar: false }) + + fireEvent.click(menuBar) + + expect(bridge.setMenuBarEnabled).toHaveBeenCalledWith(false) + await waitFor(() => expect(menuBar).toHaveAttribute('aria-checked', 'false')) + expect(screen.getByRole('switch', { name: 'Sidebar' })).toHaveAttribute('aria-checked', 'true') + }) + + it('turning Sidebar off leaves Menu bar alone', async () => { + await renderSwitches() + bridge.setSidebarEnabled.mockResolvedValue({ ...SUPPORTED, sidebar: false }) + + fireEvent.click(screen.getByRole('switch', { name: 'Sidebar' })) + + expect(bridge.setSidebarEnabled).toHaveBeenCalledWith(false) + await waitFor(() => expect(screen.getByRole('switch', { name: 'Sidebar' })).toHaveAttribute('aria-checked', 'false')) + expect(screen.getByRole('switch', { name: 'Menu bar' })).toHaveAttribute('aria-checked', 'true') + }) + + /// An install the person cancelled at the UAC prompt comes back unchanged, and the switch + /// has to show that rather than the state it optimistically painted. + it('stays where it was when the main process reports no change', async () => { + const menuBar = await renderSwitches({ ...SUPPORTED, menuBar: false }) + bridge.setMenuBarEnabled.mockResolvedValue({ ...SUPPORTED, menuBar: false }) + + fireEvent.click(menuBar) + + await waitFor(() => expect(bridge.setMenuBarEnabled).toHaveBeenCalledWith(true)) + expect(menuBar).toHaveAttribute('aria-checked', 'false') + }) + + it('refuses a second click while one is still in flight', async () => { + const menuBar = await renderSwitches() + bridge.setMenuBarEnabled.mockReturnValue(new Promise(() => {})) + + fireEvent.click(menuBar) + fireEvent.click(screen.getByRole('switch', { name: 'Sidebar' })) + + expect(bridge.setMenuBarEnabled).toHaveBeenCalledTimes(1) + expect(bridge.setSidebarEnabled).not.toHaveBeenCalled() + }) }) diff --git a/app/renderer/components/Sidebar.tsx b/app/renderer/components/Sidebar.tsx index b9f22cd7..0a8938cd 100644 --- a/app/renderer/components/Sidebar.tsx +++ b/app/renderer/components/Sidebar.tsx @@ -1,8 +1,9 @@ -import { useState, type ReactNode } from 'react' +import { useEffect, useState, type ReactNode } from 'react' import { codeburn } from '../lib/ipc' import { shortcutLabel } from '../lib/platform' -import { AboutModal, type SocialLink } from './AboutModal' +import type { CompanionStatus } from '../lib/types' +import { AboutModal } from './AboutModal' import { FlameMark } from './FlameMark' export type Section = 'overview' | 'sessions' | 'pullRequests' | 'spend' | 'optimize' | 'models' | 'compare' | 'plans' | 'settings' | 'plugins' @@ -40,14 +41,6 @@ export const NAV_ITEMS: Array<{ id: Section; label: string; key: string; icon: R ) }, ] -const SOCIALS: SocialLink[] = [ - { label: 'GitHub', url: 'https://github.com/getagentseal/codeburn', icon: }, - { label: 'Discord', url: 'https://discord.com/invite/w2sw8mCqep', icon: }, - { label: 'X', url: 'https://x.com/_codeburn', icon: }, - { label: 'YouTube', url: 'https://www.youtube.com/@codeburnn', icon: }, - { label: 'LinkedIn', url: 'https://www.linkedin.com/showcase/codeburnn/', icon: }, -] - export function Sidebar({ active, onNavigate, @@ -83,24 +76,76 @@ export function Sidebar({ ))}
+ - {aboutOpen ? setAboutOpen(false)} /> : null} + {aboutOpen ? setAboutOpen(false)} /> : null} ) } + +/** + * The two surfaces the Windows desktop app carries besides its own window: the tray app + * ("Menu bar") and the Capacity Dock rail it draws ("Sidebar"). Both are on by default and + * live above About, where the social glyphs used to be. + * + * Nothing renders until the main process says this build has a tray app staged, which is why + * there is no placeholder row and no disabled switch: on macOS, on Linux, and in a dev build + * with nothing staged, the corner is exactly what it always was. + */ +function CompanionSwitches() { + const [status, setStatus] = useState(null) + const [busy, setBusy] = useState<'menuBar' | 'sidebar' | null>(null) + + useEffect(() => { + let live = true + // `codeburn` is the preload bridge, absent in a plain browser and under tests, and + // `companionStatus` is absent on a preload that predates these two switches. + void codeburn?.companionStatus?.() + .then(next => { if (live) setStatus(next) }) + .catch(() => {}) + return () => { live = false } + }, []) + + if (!status?.supported) return null + + // Every setter answers with the whole status, so a switch shows what took rather than what + // was asked for: an install the person cancelled at the UAC prompt leaves it where it was. + const toggle = (key: 'menuBar' | 'sidebar') => { + if (busy) return + const call = key === 'menuBar' ? codeburn.setMenuBarEnabled : codeburn.setSidebarEnabled + if (!call) return + setBusy(key) + void call.call(codeburn, !status[key]) + .then(setStatus) + .catch(() => {}) + .finally(() => setBusy(null)) + } + + const row = (key: 'menuBar' | 'sidebar', label: string, hint: string) => ( +
+ {label} + +
+ ) + + return ( +
+ {row('menuBar', 'Menu bar', 'Show CodeBurn in the Windows notification area')} + {row('sidebar', 'Sidebar', 'Show the Capacity Dock rail on the screen edge')} +
+ ) +} diff --git a/app/renderer/styles/plain.css b/app/renderer/styles/plain.css index 47ff670c..3f64bd13 100644 --- a/app/renderer/styles/plain.css +++ b/app/renderer/styles/plain.css @@ -130,10 +130,18 @@ body { overflow: hidden; background: var(--bg); color: var(--ink); } .foot { display: flex; align-items: center; gap: 10px; margin-top: 4px; padding: 11px 8px 2px; border-top: 1px solid var(--line); } .foot .about { color: var(--mut); text-decoration: none; font-size: 12px; font-weight: 520; cursor: pointer; } .foot .about:hover { color: var(--ink); } -.foot .social { display: flex; gap: 2px; margin-left: auto; } -.foot .social a { color: var(--mut2); display: flex; padding: 5px; border-radius: 6px; cursor: pointer; } -.foot .social a:hover { color: var(--accent); background: var(--hover); } -.foot .social svg { width: 15px; height: 15px; fill: currentColor; stroke: none; } +/* Windows only: the tray app and the Capacity Dock rail, above About. Same 8px gutter and + 12.5px type as a nav item, so the corner reads as one column rather than two. */ +.companion { padding: 8px 8px 2px; border-top: 1px solid var(--line); } +.companion-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; } +.companion-label { color: var(--mut); font-size: 12px; font-weight: 480; } +.companion-row .switch { margin-left: auto; } +/* The bare .switch is painted for the dark onboarding sheet; on the sidebar it keys off the + theme tokens, exactly as the Settings surface does. */ +.companion .switch { border-color: var(--line2); background: var(--hover); } +.companion .switch .switch-knob { background: var(--mut); } +.companion .switch.on { background: var(--accent); border-color: var(--accent); } +.companion .switch.on .switch-knob { background: #fff; } /* About overlay: a compact, portal-free card using the reskin's surface tokens. */ .about-modal-backdrop { @@ -1093,6 +1101,11 @@ html.page-hidden *::after { .switch.on { background: #f2701c; border-color: #f2701c; } .switch.on .switch-knob { transform: translateX(16px); background: #fff; } .switch:focus-visible { outline: 1px solid #f2701c; outline-offset: 1px; } +/* Compact variant for the 186px sidebar, where the full-size switch crowds the label out. */ +.switch.sm { width: 30px; height: 17px; } +.switch.sm .switch-knob { width: 12px; height: 12px; } +.switch.sm.on .switch-knob { transform: translateX(13px); } +.switch:disabled { opacity: .55; cursor: default; } /* On themed (Settings) surfaces the same switch keys off theme tokens. */ .set-p .switch { border-color: var(--line2); background: var(--hover); } .set-p .switch .switch-knob { background: var(--mut); }