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.
This commit is contained in:
iamtoruk 2026-09-02 04:43:07 -07:00
parent c625d020e6
commit 2bd44113c7
4 changed files with 205 additions and 31 deletions

View file

@ -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: <svg viewBox="0 0 24 24"><path d="M12 .5C5.37.5 0 5.87 0 12.5c0 5.3 3.44 9.8 8.21 11.39.6.11.82-.26.82-.58 0-.29-.01-1.05-.02-2.06-3.34.73-4.04-1.61-4.04-1.61-.55-1.39-1.34-1.76-1.34-1.76-1.09-.75.08-.73.08-.73 1.2.09 1.84 1.24 1.84 1.24 1.07 1.83 2.81 1.3 3.5.99.11-.78.42-1.3.76-1.6-2.67-.3-5.47-1.33-5.47-5.93 0-1.31.47-2.38 1.24-3.22-.12-.3-.54-1.52.12-3.18 0 0 1.01-.32 3.3 1.23a11.5 11.5 0 0 1 6 0c2.29-1.55 3.3-1.23 3.3-1.23.66 1.66.24 2.88.12 3.18.77.84 1.23 1.91 1.23 3.22 0 4.61-2.8 5.62-5.48 5.92.43.37.81 1.1.81 2.22 0 1.6-.01 2.9-.01 3.29 0 .32.22.7.83.58A12 12 0 0 0 24 12.5C24 5.87 18.63.5 12 .5z" /></svg> },
{ label: 'Discord', url: 'https://discord.com/invite/w2sw8mCqep', icon: <svg viewBox="0 0 24 24"><path d="M20.32 4.37A19.8 19.8 0 0 0 15.45 3c-.21.38-.46.9-.63 1.31a18.3 18.3 0 0 0-5.47 0C8.71 3.9 8.45 3.38 8.24 3a19.7 19.7 0 0 0-4.88 1.37C.86 8.75.05 13.02.45 17.23a19.9 19.9 0 0 0 6 3.03c.48-.66.91-1.36 1.28-2.11-.7-.26-1.37-.58-2-.96.17-.12.33-.25.49-.38a14.2 14.2 0 0 0 12.16 0c.16.14.32.26.49.38-.63.38-1.31.7-2 .96.37.75.8 1.45 1.28 2.11a19.8 19.8 0 0 0 6-3.03c.47-4.87-.8-9.1-3.83-12.86zM8.02 14.65c-1.18 0-2.15-1.08-2.15-2.41 0-1.33.95-2.42 2.15-2.42 1.2 0 2.17 1.09 2.15 2.42 0 1.33-.95 2.41-2.15 2.41zm7.96 0c-1.18 0-2.15-1.08-2.15-2.41 0-1.33.95-2.42 2.15-2.42 1.2 0 2.17 1.09 2.15 2.42 0 1.33-.95 2.41-2.15 2.41z" /></svg> },
{ label: 'X', url: 'https://x.com/_codeburn', icon: <svg viewBox="0 0 24 24"><path d="M18.9 1.15h3.68l-8.04 9.19L24 22.85h-7.4l-5.8-7.58-6.63 7.58H.49l8.6-9.83L0 1.15h7.59l5.24 6.93 6.07-6.93zm-1.29 19.5h2.04L6.49 3.24H4.3l13.31 17.41z" /></svg> },
{ label: 'YouTube', url: 'https://www.youtube.com/@codeburnn', icon: <svg viewBox="0 0 24 24"><path d="M23.5 6.2a3.02 3.02 0 0 0-2.12-2.14C19.5 3.5 12 3.5 12 3.5s-7.5 0-9.38.56A3.02 3.02 0 0 0 .5 6.2 31.5 31.5 0 0 0 0 12a31.5 31.5 0 0 0 .5 5.8 3.02 3.02 0 0 0 2.12 2.14C4.5 20.5 12 20.5 12 20.5s7.5 0 9.38-.56A3.02 3.02 0 0 0 23.5 17.8 31.5 31.5 0 0 0 24 12a31.5 31.5 0 0 0-.5-5.8zM9.55 15.57V8.43L15.82 12l-6.27 3.57z" /></svg> },
{ label: 'LinkedIn', url: 'https://www.linkedin.com/showcase/codeburnn/', icon: <svg viewBox="0 0 256 256"><path fill="currentColor" d="M218.123 218.127h-37.931v-59.403c0-14.165-.253-32.4-19.728-32.4-19.756 0-22.779 15.434-22.779 31.369v60.43h-37.93V95.967h36.413v16.694h.51a39.907 39.907 0 0 1 35.928-19.733c38.445 0 45.533 25.288 45.533 58.186l-.016 67.013ZM56.955 79.27c-12.157.002-22.014-9.852-22.016-22.009-.002-12.157 9.851-22.014 22.008-22.016 12.157-.003 22.014 9.851 22.016 22.008A22.013 22.013 0 0 1 56.955 79.27m18.966 138.858H37.95V95.967h37.97v122.16ZM237.033.018H18.89C8.58-.098.125 8.161-.001 18.471v219.053c.122 10.315 8.576 18.582 18.89 18.474h218.144c10.336.128 18.823-8.139 18.966-18.474V18.454c-.147-10.33-8.635-18.588-18.966-18.453" /></svg> },
]
function openExternal(event: MouseEvent<HTMLAnchorElement>, 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)

View file

@ -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(<Sidebar active="overview" onNavigate={() => {}} />)
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(<Sidebar active="overview" onNavigate={() => {}} />)
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(<Sidebar active="overview" onNavigate={() => {}} />)
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(<Sidebar active="overview" onNavigate={() => {}} />)
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()
})
})

View file

@ -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: <svg viewBox="0 0 24 24"><path d="M12 .5C5.37.5 0 5.87 0 12.5c0 5.3 3.44 9.8 8.21 11.39.6.11.82-.26.82-.58 0-.29-.01-1.05-.02-2.06-3.34.73-4.04-1.61-4.04-1.61-.55-1.39-1.34-1.76-1.34-1.76-1.09-.75.08-.73.08-.73 1.2.09 1.84 1.24 1.84 1.24 1.07 1.83 2.81 1.3 3.5.99.11-.78.42-1.3.76-1.6-2.67-.3-5.47-1.33-5.47-5.93 0-1.31.47-2.38 1.24-3.22-.12-.3-.54-1.52.12-3.18 0 0 1.01-.32 3.3 1.23a11.5 11.5 0 0 1 6 0c2.29-1.55 3.3-1.23 3.3-1.23.66 1.66.24 2.88.12 3.18.77.84 1.23 1.91 1.23 3.22 0 4.61-2.8 5.62-5.48 5.92.43.37.81 1.1.81 2.22 0 1.6-.01 2.9-.01 3.29 0 .32.22.7.83.58A12 12 0 0 0 24 12.5C24 5.87 18.63.5 12 .5z" /></svg> },
{ label: 'Discord', url: 'https://discord.com/invite/w2sw8mCqep', icon: <svg viewBox="0 0 24 24"><path d="M20.32 4.37A19.8 19.8 0 0 0 15.45 3c-.21.38-.46.9-.63 1.31a18.3 18.3 0 0 0-5.47 0C8.71 3.9 8.45 3.38 8.24 3a19.7 19.7 0 0 0-4.88 1.37C.86 8.75.05 13.02.45 17.23a19.9 19.9 0 0 0 6 3.03c.48-.66.91-1.36 1.28-2.11-.7-.26-1.37-.58-2-.96.17-.12.33-.25.49-.38a14.2 14.2 0 0 0 12.16 0c.16.14.32.26.49.38-.63.38-1.31.7-2 .96.37.75.8 1.45 1.28 2.11a19.8 19.8 0 0 0 6-3.03c.47-4.87-.8-9.1-3.83-12.86zM8.02 14.65c-1.18 0-2.15-1.08-2.15-2.41 0-1.33.95-2.42 2.15-2.42 1.2 0 2.17 1.09 2.15 2.42 0 1.33-.95 2.41-2.15 2.41zm7.96 0c-1.18 0-2.15-1.08-2.15-2.41 0-1.33.95-2.42 2.15-2.42 1.2 0 2.17 1.09 2.15 2.42 0 1.33-.95 2.41-2.15 2.41z" /></svg> },
{ label: 'X', url: 'https://x.com/_codeburn', icon: <svg viewBox="0 0 24 24"><path d="M18.9 1.15h3.68l-8.04 9.19L24 22.85h-7.4l-5.8-7.58-6.63 7.58H.49l8.6-9.83L0 1.15h7.59l5.24 6.93 6.07-6.93zm-1.29 19.5h2.04L6.49 3.24H4.3l13.31 17.41z" /></svg> },
{ label: 'YouTube', url: 'https://www.youtube.com/@codeburnn', icon: <svg viewBox="0 0 24 24"><path d="M23.5 6.2a3.02 3.02 0 0 0-2.12-2.14C19.5 3.5 12 3.5 12 3.5s-7.5 0-9.38.56A3.02 3.02 0 0 0 .5 6.2 31.5 31.5 0 0 0 0 12a31.5 31.5 0 0 0 .5 5.8 3.02 3.02 0 0 0 2.12 2.14C4.5 20.5 12 20.5 12 20.5s7.5 0 9.38-.56A3.02 3.02 0 0 0 23.5 17.8 31.5 31.5 0 0 0 24 12a31.5 31.5 0 0 0-.5-5.8zM9.55 15.57V8.43L15.82 12l-6.27 3.57z" /></svg> },
{ label: 'LinkedIn', url: 'https://www.linkedin.com/showcase/codeburnn/', icon: <svg viewBox="0 0 256 256"><path fill="currentColor" d="M218.123 218.127h-37.931v-59.403c0-14.165-.253-32.4-19.728-32.4-19.756 0-22.779 15.434-22.779 31.369v60.43h-37.93V95.967h36.413v16.694h.51a39.907 39.907 0 0 1 35.928-19.733c38.445 0 45.533 25.288 45.533 58.186l-.016 67.013ZM56.955 79.27c-12.157.002-22.014-9.852-22.016-22.009-.002-12.157 9.851-22.014 22.008-22.016 12.157-.003 22.014 9.851 22.016 22.008A22.013 22.013 0 0 1 56.955 79.27m18.966 138.858H37.95V95.967h37.97v122.16ZM237.033.018H18.89C8.58-.098.125 8.161-.001 18.471v219.053c.122 10.315 8.576 18.582 18.89 18.474h218.144c10.336.128 18.823-8.139 18.966-18.474V18.454c-.147-10.33-8.635-18.588-18.966-18.453" /></svg> },
]
export function Sidebar({
active,
onNavigate,
@ -83,24 +76,76 @@ export function Sidebar({
</div>
))}
<div className="push" />
<CompanionSwitches />
<div className="foot">
<a className="about" href="#about" onClick={event => { event.preventDefault(); setAboutOpen(true) }}>About</a>
<div className="social">
{SOCIALS.map(social => (
<a
key={social.label}
href={social.url}
title={social.label}
aria-label={social.label}
onClick={event => { event.preventDefault(); void codeburn.openExternal(social.url) }}
>
{social.icon}
</a>
))}
</div>
</div>
</nav>
{aboutOpen ? <AboutModal socials={SOCIALS} onClose={() => setAboutOpen(false)} /> : null}
{aboutOpen ? <AboutModal onClose={() => 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<CompanionStatus | null>(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) => (
<div className="companion-row">
<span className="companion-label" title={hint}>{label}</span>
<button
type="button"
role="switch"
aria-checked={status[key]}
aria-label={label}
title={hint}
disabled={busy !== null}
className={status[key] ? 'switch sm on' : 'switch sm'}
onClick={() => toggle(key)}
>
<span className="switch-knob" />
</button>
</div>
)
return (
<div className="companion">
{row('menuBar', 'Menu bar', 'Show CodeBurn in the Windows notification area')}
{row('sidebar', 'Sidebar', 'Show the Capacity Dock rail on the screen edge')}
</div>
)
}

View file

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