From 2bd44113c7ecebb73808fa786bc44cc09b338267 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Wed, 2 Sep 2026 04:43:07 -0700 Subject: [PATCH] feat(app): two switches where the social glyphs were The sidebar's bottom corner held About and a row of five brand glyphs. The glyphs were the least useful thing on that screen: they are visited once, and About already listed every one of them under Links. So they are only there now, and the corner carries the two things a Windows user does want at hand: whether CodeBurn sits in the notification area, and whether the Capacity Dock rail is on the screen edge. Both are on by default and neither renders at all until the main process says this build has a tray app staged, which is why there is no placeholder row and no greyed-out switch: on macOS, on Linux, and in a dev build with nothing staged, the corner is exactly what it always was. The switch is the shared one from Settings and onboarding, at 30x17 rather than 38x22 because a 186px sidebar has no room for the full size beside a label, and keyed off the theme tokens rather than the white overlays the bare shape uses on the dark onboarding sheet. --- app/renderer/components/AboutModal.tsx | 13 ++- app/renderer/components/Sidebar.test.tsx | 109 ++++++++++++++++++++++- app/renderer/components/Sidebar.tsx | 93 ++++++++++++++----- app/renderer/styles/plain.css | 21 ++++- 4 files changed, 205 insertions(+), 31 deletions(-) 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); }