mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-11-05 22:10:59 +00:00
* refactor(app): move the bridge handlers out of main.ts createBridgeHandlers and the argv builders now live in app/electron/bridge-handlers.ts with no electron import, so a host other than Electron can run the same channel to argv mapping. main.ts keeps its default deps and re-exports, so the desktop app and its tests are unchanged. Two additive hooks for that host: scopeProjectFilter narrows the saved project filter before it becomes argv, and CODEBURN_NODE_BIN runs the bundled CLI with a named Node instead of Electron-as-Node. * feat(app): let the renderer run inside an editor The bridge can now say it is the VS Code host. When it does, the chord shortcuts and their hints step aside for the editor's own, Settings hands theme, language, default period and refresh to the editor's settings, the top bar shows a this workspace / all projects switch, and the editor can send a section, a period or a refresh to an open dashboard. formatResetTime moves to lib/format.ts so the status bar and the summary use the Plans wording. New strings are in all six locales. Nothing changes in the desktop app. * feat(app): build the renderer for VS Code webviews app/vite.vscode.config.ts builds two webview entries into vscode/dist/webview: the whole dashboard and the activity bar summary. renderer/vscode/bridge.ts implements window.codeburn over postMessage, forwarding only the channels in channels.ts; telemetry, update checks and the desktop companions are answered in the webview. localStorage is prefixed per scope so one workspace's cached views never paint in another's, and the theme follows the editor's light, dark and high contrast kinds through its --vscode-* tokens. * feat(vscode): CodeBurn extension for VS Code and its forks A status bar item with today's spend (~ when estimated) and a tooltip with the week, the month, this workspace, the top model and plan quotas; an activity bar summary; the desktop dashboard in an editor tab, scoped to the open workspace; eight commands and eight settings. The bundled CLI runs as the desktop's resident serve child through the shared client. Current VS Code, Cursor and Antigravity ship Node 24, so it runs on the editor's own Node; an older editor gets a system Node 22.13+ or a degraded run that says what it cannot read. A workspace folder maps to a rooted --project the way the CLI keys sessions, worktrees folding into their main repository. npm run package:vscode builds the .vsix. Unit tests cover the bridge host, status bar formatting, workspace mapping, settings and runtime choice; test:smoke drives a real VS Code against a fixture home. * ci, docs: build, test and package the VS Code extension A vscode job typechecks, unit tests, packages the .vsix, runs the smoke test in VS Code under xvfb and uploads the .vsix. docs/vscode.md covers the architecture, the measured editor runtimes and how to test. * fix(vscode): keep the currency setting out of workspace settings Changing codeburn.currency runs the CLI's own currency command, which every CodeBurn app shares, so a repository's .vscode/settings.json must not be able to set it: the setting is application scoped now. The smoke test also checks that the dashboard's renderer booted inside the webview and called the host, not only that its tab opened. * feat(vscode): version the extension 1.0.0, apart from the CLI The extension carried the CLI's 0.9.25. It now has its own version, 1.0.0. The host passes it to the webview, and the dashboard's nav footer, About dialog and Settings show it there instead of the renderer's package version; Settings adds "Bundled CLI 0.9.25" beneath it. The desktop app is unchanged. Nothing in the build or packaging writes the extension's version from the CLI. * fix(vscode): run the smoke test in Cursor, keep the demo shim out Cursor 3 opens its Agents window on a fresh profile, which starts no extension host, so the smoke run against Cursor.app hung; it now passes --classic for Cursor. The webview build no longer copies renderer/public/demo-shim.js, a browser demo harness, into the .vsix. * feat(vscode): a quiet star line in the summary, and a Star on GitHub command From the third day of use, the activity bar summary ends with the desktop's "Enjoying CodeBurn? Star us on GitHub" line, in the same six languages, with a close button. Clicking it or closing it hides it for good (kept in the extension's globalState). It is never a notification. The new "CodeBurn: Star on GitHub" command opens the repository page. No tracking parameters, no telemetry. * docs(vscode): marketplace screenshots in the README Five screenshots of the extension on real data (Overview in dark and light, the why it cost view, the status bar hover and the activity bar summary), 1600 px wide, each with a one-line caption. They live in vscode/media/screenshots, stay out of the .vsix, and the package script points vsce at the raw GitHub URLs on main. * fix(vscode): review fixes for settings trust, serve lifecycle and the tooltip - codeburn.provider and codeburn.quotaProviders are application scoped, like currency, so a repository's settings cannot turn quota requests back on, even in Restricted Mode. - deactivate removes the serve pid file once the child is stopped, and changing codeburn.nodePath restarts the serve child on the new Node. - codeburn.currency is applied at startup when the CLI's currency differs, not only when the setting changes. - The dashboard's Settings no longer shows per-plan quota switches in the editor, where the editor setting rewrites them on every boot; it points to the editor settings instead. - Workspace and model names are escaped in the status bar tooltip. - The README lists the dashboard actions that reach the network. - The staged CLI drops its dependencies' tests and TypeScript sources (466 files, 9.9 MB to 9.2 MB).
194 lines
8.1 KiB
TypeScript
194 lines
8.1 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
|
|
import { FlameMark } from './FlameMark'
|
|
import { ProviderLogo } from './ProviderLogo'
|
|
import { motionClass, motionEnabled, reducedMotion } from '../lib/motion'
|
|
import { codeburn } from '../lib/ipc'
|
|
import type { ScanProgressEvent } from '../lib/types'
|
|
import { displayVersion } from '../lib/platform'
|
|
import loaderVideo from '../assets/splash-loader.webm'
|
|
import { t } from '../i18n'
|
|
|
|
const MIN_ON_SCREEN_MS = 600
|
|
const CROSSFADE_MS = 250
|
|
|
|
type Phase = 'lit' | 'out' | 'done'
|
|
type ProvStatus = 'pending' | 'active' | 'done'
|
|
|
|
type Progress = {
|
|
/** Detected providers, in the order the CLI reports them. */
|
|
order: string[]
|
|
status: Record<string, ProvStatus>
|
|
claudeDone: number
|
|
claudeTotal: number
|
|
/** True only for a genuine cold hydration (the CLI's `providers` event carries
|
|
* `cold: true`). A warm launch's incremental re-parse of a few changed files
|
|
* emits the same providers/tick stream WITHOUT this, so it never reveals the
|
|
* indexing detail. */
|
|
cold: boolean
|
|
}
|
|
|
|
const EMPTY: Progress = { order: [], status: {}, claudeDone: 0, claudeTotal: 0, cold: false }
|
|
|
|
function reduceProgress(state: Progress, event: ScanProgressEvent): Progress {
|
|
switch (event.kind) {
|
|
case 'providers': {
|
|
const status: Record<string, ProvStatus> = {}
|
|
for (const p of event.providers) status[p] = state.status[p] ?? 'pending'
|
|
return { ...state, order: event.providers, status, cold: state.cold || event.cold === true }
|
|
}
|
|
case 'provider': {
|
|
const order = state.order.includes(event.provider) ? state.order : [...state.order, event.provider]
|
|
// 'skipped' (a permission-locked provider) is terminal like 'done' so it
|
|
// settles the strip instead of showing as perpetually active.
|
|
const next: ProvStatus = event.state === 'done' || event.state === 'skipped' ? 'done' : 'active'
|
|
return { ...state, order, status: { ...state.status, [event.provider]: next } }
|
|
}
|
|
case 'tick':
|
|
return { ...state, claudeDone: event.done, claudeTotal: event.total }
|
|
case 'done': {
|
|
const status = { ...state.status }
|
|
for (const p of state.order) status[p] = 'done'
|
|
return { ...state, status }
|
|
}
|
|
// 'keepalive' and anything a newer CLI adds: proof of life, no state change.
|
|
// A pinned/dev CLI can be newer than this app, so never fall off the switch.
|
|
default:
|
|
return state
|
|
}
|
|
}
|
|
|
|
function providerLabel(id: string): string {
|
|
return id.split(/[-\s]+/).filter(Boolean).map(part => part.charAt(0).toUpperCase() + part.slice(1)).join(' ')
|
|
}
|
|
|
|
/**
|
|
* Compact first-run indexing status: one line of copy (with a live counter when
|
|
* Claude is parsing), a small horizontal strip of provider logos (dim = pending,
|
|
* lit = active, settled = done), and one muted note. Deliberately no per-provider
|
|
* text rows -- a machine with 12 providers must not turn the splash into a list.
|
|
*/
|
|
function SplashStatus({ progress }: { progress: Progress }) {
|
|
const active = progress.order.find(id => progress.status[id] === 'active') ?? null
|
|
const counter = active === 'claude' && progress.claudeTotal > 0
|
|
? ` · ${progress.claudeDone.toLocaleString('en-US')}/${progress.claudeTotal.toLocaleString('en-US')}`
|
|
: ''
|
|
const line = active
|
|
? t('onboarding.splash.indexingProvider', { provider: providerLabel(active), counter })
|
|
: t('onboarding.splash.indexingGeneric')
|
|
return (
|
|
<div className="splash-status">
|
|
<div className="splash-status-line">{line}</div>
|
|
{progress.order.length > 0 && (
|
|
<div className="splash-prov-strip">
|
|
{progress.order.map(id => (
|
|
<span key={id} className={`splash-prov ${progress.status[id] ?? 'pending'}`} title={providerLabel(id)}>
|
|
<ProviderLogo provider={id} size={15} />
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="splash-status-note">{t('onboarding.splash.oneTimeScanNote')}</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Full-window branded startup loader -- the same scanning moment as the menubar
|
|
* app's ignite-while-loading flame. Mounts once with the app and stays up while
|
|
* the FIRST overview fetch has neither data nor error. On first data it holds a
|
|
* floor of MIN_ON_SCREEN_MS (so a warm cache does not flash-blink) then
|
|
* crossfades out. A first-fetch error dismisses it instantly, so the user is
|
|
* never trapped behind branding; reduced motion swaps instantly with no fade.
|
|
* A `done` latch means later loading states -- polls, filter changes -- never
|
|
* bring it back.
|
|
*
|
|
* On a genuinely cold first run the overview warmup streams per-provider scan
|
|
* progress (main.ts forwards the CLI's stderr) and the leading `providers` event
|
|
* carries `cold: true`; that — and only that — reveals the compact indexing
|
|
* status block (see SplashStatus). A warm launch's incremental re-parse emits the
|
|
* same provider/tick stream without the cold flag, so the splash stays a plain
|
|
* flame and dismisses the moment data arrives.
|
|
*/
|
|
export function Splash({ hasData, hasError }: { hasData: boolean; hasError: boolean }) {
|
|
const [phase, setPhase] = useState<Phase>('lit')
|
|
const [progress, setProgress] = useState<Progress>(EMPTY)
|
|
const [reveal, setReveal] = useState(false)
|
|
const shownAt = useRef(Date.now())
|
|
const done = useRef(false)
|
|
const videoRef = useRef<HTMLVideoElement>(null)
|
|
|
|
// Subscribe once to cold-start progress. `codeburn` is undefined outside the
|
|
// Electron preload (e.g. unit tests); guard so the splash still renders.
|
|
useEffect(() => {
|
|
if (!codeburn || typeof codeburn.onProgress !== 'function') return
|
|
return codeburn.onProgress(event => setProgress(prev => reduceProgress(prev, event)))
|
|
}, [])
|
|
|
|
// The brand video plays once (no loop) and then holds no decoder. While the
|
|
// window is hidden, pause it so a minimized/occluded launch does not stack
|
|
// media decode on the cold hydration CPU spike; resume on show unless it has
|
|
// already ended.
|
|
useEffect(() => {
|
|
const video = videoRef.current
|
|
if (!video) return
|
|
const sync = () => {
|
|
if (document.visibilityState === 'hidden') video.pause()
|
|
else if (!video.ended) void video.play().catch(() => {})
|
|
}
|
|
document.addEventListener('visibilitychange', sync)
|
|
return () => document.removeEventListener('visibilitychange', sync)
|
|
}, [])
|
|
|
|
// Only a genuine cold hydration reveals the indexing detail. Warm launches
|
|
// never set `cold`, so the strip stays hidden and the flame dismisses fast.
|
|
useEffect(() => { if (progress.cold) setReveal(true) }, [progress.cold])
|
|
|
|
useEffect(() => {
|
|
if (done.current) return
|
|
if (!hasData && !hasError) return
|
|
|
|
// First resolution of the first fetch. An error or a reduced-motion
|
|
// preference swaps instantly; otherwise honor the on-screen floor first.
|
|
if (hasError || reducedMotion()) {
|
|
done.current = true
|
|
setPhase('done')
|
|
return
|
|
}
|
|
const wait = Math.max(0, MIN_ON_SCREEN_MS - (Date.now() - shownAt.current))
|
|
const timer = setTimeout(() => setPhase('out'), wait)
|
|
return () => clearTimeout(timer)
|
|
}, [hasData, hasError])
|
|
|
|
useEffect(() => {
|
|
if (phase !== 'out') return
|
|
const timer = setTimeout(() => {
|
|
done.current = true
|
|
setPhase('done')
|
|
}, CROSSFADE_MS)
|
|
return () => clearTimeout(timer)
|
|
}, [phase])
|
|
|
|
if (phase === 'done' || typeof document === 'undefined') return null
|
|
|
|
const base = phase === 'out' ? 'splash splash-out' : 'splash'
|
|
const showDetail = reveal && phase === 'lit'
|
|
return createPortal(
|
|
<div className={motionClass(base, 'splash-lit')} aria-hidden="true">
|
|
{motionEnabled() ? (
|
|
// The animated burn as VP9-with-alpha, floating directly on the splash
|
|
// gradient while the first scan runs. Static mark under reduced motion.
|
|
<video ref={videoRef} className="splash-video" src={loaderVideo} width={232} height={232} autoPlay muted playsInline />
|
|
) : (
|
|
<div className="splash-mark">
|
|
<FlameMark size={76} />
|
|
</div>
|
|
)}
|
|
<div className="splash-word">CodeBurn</div>
|
|
<div className="splash-version">v{displayVersion()}</div>
|
|
{showDetail && <SplashStatus progress={progress} />}
|
|
</div>,
|
|
document.body,
|
|
)
|
|
}
|