Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions app/electron/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,14 @@ const CHANNELS = [
'codeburn:telemetryOnboarded',
'codeburn:telemetryTrack',
'codeburn:getUpdateStatus',
'codeburn:pluginList',
'codeburn:pluginInfo',
'codeburn:pluginAdd',
'codeburn:pluginRemove',
'codeburn:pluginVerify',
'codeburn:syncAutoStatus',
'codeburn:syncAutoEnable',
'codeburn:syncAutoDisable',
] as const

const ARGV_CASES: Array<{ channel: string; args: unknown[]; argv: string[] }> = [
Expand Down Expand Up @@ -120,6 +128,20 @@ const ARGV_CASES: Array<{ channel: string; args: unknown[]; argv: string[] }> =
{ channel: 'codeburn:setPlan', args: ['claude-max', 'claude'], argv: ['plan', 'set', 'claude-max', '--provider', 'claude'] },
{ channel: 'codeburn:resetPlan', args: ['cursor'], argv: ['plan', 'reset', '--provider', 'cursor'] },
{ channel: 'codeburn:exportData', args: ['json', 'all', '/tmp/codeburn-export'], argv: ['export', '-f', 'json', '-o', '/tmp/codeburn-export', '--provider', 'all'] },
// Plugin management
{ channel: 'codeburn:pluginList', args: [], argv: ['plugin', 'list', '--json'] },
{ channel: 'codeburn:pluginInfo', args: ['test-plugin'], argv: ['plugin', 'info', 'test-plugin', '--json'] },
{ channel: 'codeburn:pluginAdd', args: ['/path/to/plugin'], argv: ['plugin', 'add', '/path/to/plugin'] },
{ channel: 'codeburn:pluginAdd', args: ['teams'], argv: ['plugin', 'add', 'teams'] },
{ channel: 'codeburn:pluginRemove', args: ['test-plugin'], argv: ['plugin', 'remove', 'test-plugin', '--confirm'] },
{ channel: 'codeburn:pluginVerify', args: ['test-plugin'], argv: ['plugin', 'verify', 'test-plugin'] },
// Sync auto
{ channel: 'codeburn:syncAutoStatus', args: [], argv: ['sync', 'auto', 'status', '--json'] },
{ channel: 'codeburn:syncAutoEnable', args: ['daily', false, false], argv: ['sync', 'auto', 'enable', '--cadence', 'daily'] },
{ channel: 'codeburn:syncAutoEnable', args: ['hourly', true, false], argv: ['sync', 'auto', 'enable', '--cadence', 'hourly', '--attribution'] },
{ channel: 'codeburn:syncAutoEnable', args: ['daily', false, true], argv: ['sync', 'auto', 'enable', '--cadence', 'daily', '--accept'] },
{ channel: 'codeburn:syncAutoEnable', args: ['hourly', true, true], argv: ['sync', 'auto', 'enable', '--cadence', 'hourly', '--attribution', '--accept'] },
{ channel: 'codeburn:syncAutoDisable', args: [], argv: ['sync', 'auto', 'disable'] },
]

function flattenMenuItems(items: any[]): any[] {
Expand Down
27 changes: 27 additions & 0 deletions app/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,33 @@ export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, re
// One-shot read of the cached update-availability status. The check itself
// runs in the background (launch + 24h); this returns whatever is known.
'codeburn:getUpdateStatus': async () => ({ ok: true, value: deps.getUpdateStatus ? await deps.getUpdateStatus() : NO_UPDATE_STATUS }),
// Plugin management reads (all return parsed JSON)
'codeburn:pluginList': run(() => ['plugin', 'list', '--json']),
'codeburn:pluginInfo': run((name: string) => ['plugin', 'info', vToken(name), '--json']),
'codeburn:syncAutoStatus': run(() => ['sync', 'auto', 'status', '--json']),
// Plugin management mutations
'codeburn:pluginAdd': runAction((source: string) => ['plugin', 'add', vToken(source)]),
'codeburn:pluginRemove': runAction((name: string) => ['plugin', 'remove', vToken(name), '--confirm']),
'codeburn:pluginVerify': runAction((name: string) => ['plugin', 'verify', vToken(name)]),
// Sync auto enable: special case - when accept=false, capture disclosure text from stdout
'codeburn:syncAutoEnable': async (cadence?: string, attribution?: boolean, accept?: boolean) => {
try {
const args = ['sync', 'auto', 'enable', '--cadence', cadence === 'hourly' ? 'hourly' : 'daily']
if (attribution) args.push('--attribution')
if (accept) args.push('--accept')

const result = await deps.spawnCliAction(args)
// When accept=false, the disclosure text is in stdout, we return it for display
// When accept=true, it succeeds with no special output needed
if (!accept && result.stdout) {
return { ok: true, value: { ok: true, disclosure: result.stdout, code: result.code } }
}
return { ok: true, value: { ...result, stderr: sanitizeError(result.stderr) } }
} catch (err) {
return { ok: false, error: toEnvelopeError(err) }
}
},
'codeburn:syncAutoDisable': runAction(() => ['sync', 'auto', 'disable']),
}
}

Expand Down
10 changes: 10 additions & 0 deletions app/electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,16 @@ const bridge = {
ipcRenderer.on('codeburn:update', listener)
return () => { ipcRenderer.removeListener('codeburn:update', listener) }
},
// Plugin management
pluginList: () => invoke('codeburn:pluginList'),
pluginInfo: (name: string) => invoke('codeburn:pluginInfo', name),
pluginAdd: (source: string) => invoke('codeburn:pluginAdd', source),
pluginRemove: (name: string) => invoke('codeburn:pluginRemove', name),
pluginVerify: (name: string) => invoke('codeburn:pluginVerify', name),
// Sync auto
syncAutoStatus: () => invoke('codeburn:syncAutoStatus'),
syncAutoEnable: (cadence: string, attribution: boolean, accept: boolean) => invoke('codeburn:syncAutoEnable', cadence, attribution, accept),
syncAutoDisable: () => invoke('codeburn:syncAutoDisable'),
platform: process.platform,
arch: process.arch,
}
Expand Down
4 changes: 4 additions & 0 deletions app/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { Compare } from './sections/Compare'
import { Plans } from './sections/Plans'
import { Settings, type SettingsPane } from './sections/Settings'
import { SpendContent } from './sections/Spend'
import { PluginsSection } from './sections/Plugins'
import type { DateRange, MenubarPayload, ModelReportRow, Period, Scope, TelemetryStatus } from './lib/types'

// Bucket raw dollar amounts before they leave the machine: telemetry carries
Expand Down Expand Up @@ -116,6 +117,7 @@ const SECTION_TITLES: Record<Section, string> = {
compare: 'Compare',
plans: 'Plans',
settings: 'Settings',
plugins: 'Plugins',
}

const STANDARD_PERIODS: Period[] = ['today', 'week', '30days', 'month', 'all', 'lifetime']
Expand Down Expand Up @@ -537,6 +539,8 @@ function AppMain() {
<Plans period={period} refreshToken={refreshToken} onNavigate={navigate} ready={ready} />
) : section === 'settings' ? (
<Settings period={period} refreshToken={refreshToken} onNavigate={navigate} initialPane={settingsPane} claudeConfigs={claudeConfigs} claudeConfigSource={claudeConfigSource} onConfigMutated={onConfigMutated} scope={scope} onScopeChange={onScopeChange} />
) : section === 'plugins' ? (
<PluginsSection />
) : (
<>
<TopBar
Expand Down
6 changes: 3 additions & 3 deletions app/renderer/components/Sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@ describe('Sidebar', () => {
it.each([
['darwin', '⌘'],
['win32', 'Ctrl+'],
] as const)('renders all nine nav items in the desktop order with %s keycaps', (platform, mod) => {
] as const)('renders all nav items in the desktop order with %s keycaps', (platform, mod) => {
setPlatform(platform)
render(<Sidebar active="overview" onNavigate={() => {}} />)
const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const labels = screen.getAllByRole('button').map(item => item.textContent?.replace(/(⌘|Ctrl\+)[\d,]/, ''))
expect(labels).toEqual(['Overview', 'Sessions', 'Pull requests', 'Spend', 'Optimize', 'Models', 'Compare', 'Plans', 'Settings'])
const labels = screen.getAllByRole('button').map(item => item.textContent?.replace(/(⌘|Ctrl\+)[\d,.]/, ''))
expect(labels).toEqual(['Overview', 'Sessions', 'Pull requests', 'Spend', 'Optimize', 'Models', 'Compare', 'Plans', 'Settings', 'Plugins'])
expect(screen.getByRole('button', { name: new RegExp(`Sessions.*${esc(mod)}2`) })).toBeInTheDocument()
expect(screen.getByRole('button', { name: new RegExp(`Pull requests.*${esc(mod)}3`) })).toBeInTheDocument()
expect(screen.getByRole('button', { name: new RegExp(`Compare.*${esc(mod)}7`) })).toBeInTheDocument()
Expand Down
5 changes: 4 additions & 1 deletion app/renderer/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { shortcutLabel } from '../lib/platform'
import { AboutModal, type SocialLink } from './AboutModal'
import { FlameMark } from './FlameMark'

export type Section = 'overview' | 'sessions' | 'pullRequests' | 'spend' | 'optimize' | 'models' | 'compare' | 'plans' | 'settings'
export type Section = 'overview' | 'sessions' | 'pullRequests' | 'spend' | 'optimize' | 'models' | 'compare' | 'plans' | 'settings' | 'plugins'

export const NAV_ITEMS: Array<{ id: Section; label: string; key: string; icon: ReactNode }> = [
{ id: 'overview', label: 'Overview', key: '1', icon: (
Expand Down Expand Up @@ -35,6 +35,9 @@ export const NAV_ITEMS: Array<{ id: Section; label: string; key: string; icon: R
{ id: 'settings', label: 'Settings', key: ',', icon: (
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" /></svg>
) },
{ id: 'plugins', label: 'Plugins', key: '.', icon: (
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="1"/><path d="M12 2v6m0 8v6M4.22 4.22l4.24 4.24m5.08 5.08l4.24 4.24M2 12h6m8 0h6M4.22 19.78l4.24-4.24m5.08-5.08l4.24-4.24"/></svg>
) },
]

const SOCIALS: SocialLink[] = [
Expand Down
166 changes: 166 additions & 0 deletions app/renderer/components/TeamRegistry.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { ReactNode, useEffect, useState } from 'react'
import type { MenubarPayload } from '../lib/types'

export type TeamTab = 'teams.week' | 'teams.status' | string

interface TeamRegistryProps {
payload: MenubarPayload | null
loading?: boolean
onNavigate?: (tab: TeamTab) => void
}

interface TeamTabConfig {
id: string
label: string
icon: ReactNode
}

export function useTeamTabs(payload: MenubarPayload | null): TeamTabConfig[] {
const [tabs, setTabs] = useState<TeamTabConfig[]>([])

useEffect(() => {
if (!payload?.current) {
setTabs([])
return
}

const newTabs: TeamTabConfig[] = []
const pluginsRecord = (payload.current as any).plugins

if (!pluginsRecord || typeof pluginsRecord !== 'object') {
setTabs([])
return
}

for (const key of Object.keys(pluginsRecord)) {
if (key.startsWith('teams.')) {
if (key === 'teams.week') {
newTabs.push({
id: 'teams.week',
label: 'Team Week',
icon: <svg viewBox="0 0 24 24"><path d="M9 11l3 3L22 4" /><path d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>,
})
} else if (key === 'teams.status') {
newTabs.push({
id: 'teams.status',
label: 'Team Status',
icon: <svg viewBox="0 0 24 24"><path d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>,
})
} else {
newTabs.push({
id: key,
label: `Unknown (${key})`,
icon: <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" /></svg>,
})
}
}
}

setTabs(newTabs)
}, [payload])

return tabs
}

export function TeamTabContent({ payload, tab }: { payload: MenubarPayload; tab: string }): ReactNode {
if (!payload.current) return null

const pluginsRecord = (payload.current as any).plugins ?? {}
const tabData = pluginsRecord[tab]

if (!tabData) {
return <div style={{ padding: '1rem', color: 'var(--mut)' }}>Team data not available</div>
}

if (tab === 'teams.week') {
if (typeof tabData === 'object' && 'spend' in tabData && 'sessions' in tabData) {
return (
<div style={{ padding: '1.5rem', display: 'flex', gap: '1.5rem' }}>
<div style={{
flex: 1,
padding: '1rem',
background: 'var(--fill)',
borderRadius: '8px',
border: '1px solid var(--line)',
}}>
<div style={{ fontSize: '0.875rem', color: 'var(--mut)', marginBottom: '0.5rem' }}>Total spend</div>
<div style={{ fontSize: '1.5rem', fontWeight: 600 }}>${tabData.spend?.toFixed(2) ?? '0.00'}</div>
</div>
<div style={{
flex: 1,
padding: '1rem',
background: 'var(--fill)',
borderRadius: '8px',
border: '1px solid var(--line)',
}}>
<div style={{ fontSize: '0.875rem', color: 'var(--mut)', marginBottom: '0.5rem' }}>Sessions</div>
<div style={{ fontSize: '1.5rem', fontWeight: 600 }}>{tabData.sessions ?? 0}</div>
</div>
{Array.isArray(tabData.topWorkUnits) && (
<div style={{
flex: 1,
padding: '1rem',
background: 'var(--fill)',
borderRadius: '8px',
border: '1px solid var(--line)',
}}>
<div style={{ fontSize: '0.875rem', color: 'var(--mut)', marginBottom: '0.75rem' }}>Top work units</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{tabData.topWorkUnits.slice(0, 3).map((unit: any, i: number) => (
<div key={i} style={{ fontSize: '0.75rem', color: 'var(--ink)' }}>
{unit.name}
</div>
))}
</div>
</div>
)}
</div>
)
}
return <div style={{ padding: '1rem', color: 'var(--mut)' }}>Team week data not available</div>
}

if (tab === 'teams.status') {
if (typeof tabData === 'string' && tabData.startsWith('http')) {
return (
<div style={{ padding: '1.5rem' }}>
<a
href={tabData}
onClick={e => {
e.preventDefault()
const { codeburn } = require('../lib/ipc')
void codeburn.openExternal(tabData)
}}
style={{
display: 'inline-block',
padding: '0.75rem 1.5rem',
background: 'var(--accent)',
color: 'white',
borderRadius: '6px',
textDecoration: 'none',
fontWeight: 600,
cursor: 'pointer',
}}
>
View team status
</a>
</div>
)
}
return <div style={{ padding: '1rem', color: 'var(--mut)' }}>Team status not available</div>
}

if (typeof tabData === 'object' && tabData.schemaVersion && (tabData as any).schemaVersion > 1) {
return (
<div style={{ padding: '1.5rem', color: 'var(--warn)' }}>
Update CodeBurn Desktop to view this content. This plugin data requires a newer version of CodeBurn.
</div>
)
}

return (
<div style={{ padding: '1rem', color: 'var(--mut)' }}>
Unknown team tab: {tab}
</div>
)
}
10 changes: 10 additions & 0 deletions app/renderer/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -727,4 +727,14 @@ export interface CodeburnBridge {
completeOnboarding(enabled: boolean): Promise<TelemetryStatus | null>
telemetryTrack(name: string, props?: Record<string, unknown>): Promise<boolean>
openExternal(url: string): Promise<void>
// Plugin management
pluginList(): Promise<unknown>
pluginInfo(name: string): Promise<unknown>
pluginAdd(source: string): Promise<ActionResult>
pluginRemove(name: string): Promise<ActionResult>
pluginVerify(name: string): Promise<ActionResult>
// Sync auto
syncAutoStatus(): Promise<unknown>
syncAutoEnable(cadence: string, attribution: boolean, accept: boolean): Promise<unknown>
syncAutoDisable(): Promise<ActionResult>
}
Loading
Loading