diff --git a/.changeset/persist-fast-model-v3.md b/.changeset/persist-fast-model-v3.md new file mode 100644 index 00000000..65f493ef --- /dev/null +++ b/.changeset/persist-fast-model-v3.md @@ -0,0 +1,6 @@ +--- +"@open-codesign/shared": patch +"@open-codesign/desktop": patch +--- + +Preserve `modelFast`, `imageGeneration`, and `designSystem` when other settings writes rebuild the on-disk v3 config, so the fast model and related optionals are not cleared after the next provider or import save. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ff8b37cc..a14dbe06 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -18,6 +18,7 @@ "test": "vitest run --passWithNoTests" }, "dependencies": { + "@monaco-editor/react": "^4.7.0", "@open-codesign/artifacts": "workspace:*", "@open-codesign/core": "workspace:*", "@open-codesign/exporters": "workspace:*", @@ -27,31 +28,34 @@ "@open-codesign/shared": "workspace:*", "@open-codesign/templates": "workspace:*", "@open-codesign/ui": "workspace:*", + "@shikijs/monaco": "^4.0.2", "better-sqlite3": "^12.9.0", "electron-log": "^5", "electron-updater": "^6.3.9", "lucide-react": "^1.8.0", + "monaco-editor": "^0.55.1", "react": "^19.0.0", "react-dom": "^19.0.0", + "shiki": "^4.0.2", "smol-toml": "^1.6.1", "zip-lib": "^1.0.4", "zustand": "^5.0.2" }, "devDependencies": { - "@tailwindcss/postcss": "^4.2.4", + "@tailwindcss/postcss": "^4.0.0", "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.10.2", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", - "electron": "^39.8.9", + "electron": "^39.8.8", "electron-builder": "^26.8.1", "electron-builder-squirrel-windows": "26.8.1", "electron-vite": "^2.3.0", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", - "tailwindcss": "^4.2.4", + "tailwindcss": "^4.0.0", "typescript": "^5.7.2", "vite": "^6.0.5", "vitest": "^2.1.8" diff --git a/apps/desktop/src/main/codex-oauth-ipc.ts b/apps/desktop/src/main/codex-oauth-ipc.ts index fdcd7436..f57791d6 100644 --- a/apps/desktop/src/main/codex-oauth-ipc.ts +++ b/apps/desktop/src/main/codex-oauth-ipc.ts @@ -18,6 +18,7 @@ import { ERROR_CODES, type ProviderEntry, hydrateConfig, + preservedV3OptionalsForWrite, } from '@open-codesign/shared'; import { configDir, writeConfig } from './config'; import { ipcMain, shell } from './electron-runtime'; @@ -133,7 +134,7 @@ async function persistProviderMutation( activeModel: cfg?.activeModel ?? '', secrets: cfg?.secrets ?? {}, providers: nextProviders, - ...(cfg?.designSystem !== undefined ? { designSystem: cfg.designSystem } : {}), + ...preservedV3OptionalsForWrite(cfg), }); await writeConfig(next); setCachedConfig(next); @@ -155,7 +156,7 @@ async function claimActiveProviderIfUnset(): Promise { activeModel: CHATGPT_CODEX_PROVIDER.defaultModel, secrets: cfg.secrets, providers: cfg.providers, - ...(cfg.designSystem !== undefined ? { designSystem: cfg.designSystem } : {}), + ...preservedV3OptionalsForWrite(cfg), }); await writeConfig(next); setCachedConfig(next); diff --git a/apps/desktop/src/main/image-generation-settings.ts b/apps/desktop/src/main/image-generation-settings.ts index 1ca27b08..8c94fedc 100644 --- a/apps/desktop/src/main/image-generation-settings.ts +++ b/apps/desktop/src/main/image-generation-settings.ts @@ -21,6 +21,7 @@ import { type ImageGenerationSize, ImageGenerationSizeSchema, hydrateConfig, + preservedV3OptionalsForWrite, } from '@open-codesign/shared'; import { writeConfig } from './config'; import { ipcMain } from './electron-runtime'; @@ -281,7 +282,7 @@ async function updateImageGenerationSettings( activeModel: cfg.activeModel, secrets: cfg.secrets, providers: cfg.providers, - ...(cfg.designSystem !== undefined ? { designSystem: cfg.designSystem } : {}), + ...preservedV3OptionalsForWrite(cfg, { skipImageGeneration: true }), imageGeneration: parsed, }); await writeConfig(config); diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index d06d8e43..51888985 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -129,14 +129,27 @@ const USE_AGENT_RUNTIME = (() => { const IS_VITEST = process.env['VITEST'] === 'true'; +/** No OS title bar; renderer draws TopBar with -webkit-app-region. Register once (not in createWindow — macOS can call createWindow again on activate). */ +function registerWindowChromeIpc(): void { + ipcMain.on('window:minimize', () => mainWindow?.minimize()); + ipcMain.on('window:maximize', () => { + if (!mainWindow) return; + if (mainWindow.isMaximized()) mainWindow.unmaximize(); + else mainWindow.maximize(); + }); + ipcMain.on('window:close', () => mainWindow?.close()); +} + function createWindow(): void { + // frame: false removes the native title bar and window controls; we provide + // a custom top bar in the renderer (see TopBar, WindowControls). mainWindow = new BrowserWindow({ width: 1280, height: 820, minWidth: 960, minHeight: 640, - autoHideMenuBar: process.platform !== 'darwin', - titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default', + frame: false, + autoHideMenuBar: true, backgroundColor: BRAND.backgroundColor, icon: join(__dirname, '../../resources/icon.png'), show: false, @@ -148,7 +161,12 @@ function createWindow(): void { }, }); - mainWindow.on('ready-to-show', () => mainWindow?.show()); + mainWindow.on('ready-to-show', () => { + if (process.platform === 'win32' || process.platform === 'linux') { + mainWindow?.setMenuBarVisibility(false); + } + mainWindow?.show(); + }); // Null the reference on close so stale IPC sends from async emitters // (autoUpdater, long-running generate runs) become clean no-ops rather // than throwing "Object has been destroyed" on a discarded webContents. @@ -1084,7 +1102,12 @@ function registerIpcHandlers(db: Database | null): void { // Inline-comment edits don't need to be tied to whatever provider was // pinned in the original generate; resolve fresh against the canonical // active provider so a switch in Settings takes effect immediately. - const hint = payload.model ?? { provider: cfg.provider, modelId: cfg.modelPrimary }; + // Prefer modelFast when configured — it's cheaper for quick edits. + const fastModelId = cfg.modelFast ?? null; + const hint = payload.model ?? { + provider: cfg.provider, + modelId: fastModelId ?? cfg.modelPrimary, + }; const active = resolveActiveModel(cfg, hint); const allowKeyless = active.allowKeyless; const apiKey = await resolveApiKeyForActive(active.model.provider, allowKeyless); @@ -1359,6 +1382,7 @@ if (!IS_VITEST) { registerDiagnosticsIpc(diagnosticsDb); setupAutoUpdater(); registerAppMenu(); + registerWindowChromeIpc(); createWindow(); void scheduleStartupUpdateCheck(); diff --git a/apps/desktop/src/main/onboarding-ipc.ts b/apps/desktop/src/main/onboarding-ipc.ts index c1803768..0ecf72e6 100644 --- a/apps/desktop/src/main/onboarding-ipc.ts +++ b/apps/desktop/src/main/onboarding-ipc.ts @@ -22,6 +22,8 @@ import { hydrateConfig, isSupportedOnboardingProvider, modelsEndpointUrl, + preservedV3OptionalsForWrite, + toPersistedV3, } from '@open-codesign/shared'; import { buildAuthHeadersForWire } from './auth-headers'; import { defaultConfigDir, readConfig, writeConfig } from './config'; @@ -180,6 +182,7 @@ function toState(cfg: Config | null): OnboardingState { hasKey: false, provider: null, modelPrimary: null, + modelFast: null, baseUrl: null, designSystem: null, }; @@ -191,6 +194,7 @@ function toState(cfg: Config | null): OnboardingState { hasKey: false, provider: active, modelPrimary: null, + modelFast: null, baseUrl: null, designSystem: cfg.designSystem ?? null, }; @@ -199,6 +203,7 @@ function toState(cfg: Config | null): OnboardingState { hasKey: true, provider: active, modelPrimary: cfg.activeModel, + modelFast: cfg.modelFast ?? null, baseUrl: cfg.providers[active]?.baseUrl ?? null, designSystem: cfg.designSystem ?? null, }; @@ -224,6 +229,7 @@ export async function setDesignSystem( activeModel: cfg.activeModel, secrets: cfg.secrets, providers: cfg.providers, + ...preservedV3OptionalsForWrite(cfg, { clearDesignSystem: designSystem === null }), ...(designSystem !== null ? { designSystem: StoredDesignSystem.parse(designSystem) } : {}), }); await writeConfig(next); @@ -376,9 +382,7 @@ async function runSetProviderAndModels(input: SetProviderAndModelsInput): Promis activeModel: nextActiveModel, secrets: nextSecrets, providers: nextProviders, - ...(cachedConfig?.designSystem !== undefined - ? { designSystem: cachedConfig.designSystem } - : {}), + ...preservedV3OptionalsForWrite(cachedConfig), }); await writeConfig(next); cachedConfig = next; @@ -428,7 +432,7 @@ async function runDeleteProvider(raw: unknown): Promise { activeModel: '', secrets: {}, providers: nextProviders, - ...(cfg.designSystem !== undefined ? { designSystem: cfg.designSystem } : {}), + ...preservedV3OptionalsForWrite(cfg), }); await writeConfig(emptyNext); cachedConfig = emptyNext; @@ -441,7 +445,7 @@ async function runDeleteProvider(raw: unknown): Promise { activeModel: modelPrimary, secrets: nextSecrets, providers: nextProviders, - ...(cfg.designSystem !== undefined ? { designSystem: cfg.designSystem } : {}), + ...preservedV3OptionalsForWrite(cfg), }); await writeConfig(next); cachedConfig = next; @@ -472,7 +476,7 @@ async function runSetActiveProvider(raw: unknown): Promise { activeModel: modelPrimary, secrets: cfg.secrets, providers: cfg.providers, - ...(cfg.designSystem !== undefined ? { designSystem: cfg.designSystem } : {}), + ...preservedV3OptionalsForWrite(cfg), }); await writeConfig(next); cachedConfig = next; @@ -540,7 +544,7 @@ async function runResetOnboarding(): Promise { activeModel: cfg.activeModel, secrets: {}, providers: cfg.providers, - ...(cfg.designSystem !== undefined ? { designSystem: cfg.designSystem } : {}), + ...preservedV3OptionalsForWrite(cfg), }); await writeConfig(next); cachedConfig = next; @@ -652,9 +656,7 @@ async function runAddCustomProvider(input: AddCustomProviderInput): Promise { activeModel, secrets: nextSecrets, providers: nextProviders, - ...(cachedConfig?.designSystem !== undefined - ? { designSystem: cachedConfig.designSystem } - : {}), + ...preservedV3OptionalsForWrite(cachedConfig), }); await writeConfig(next); cachedConfig = next; @@ -933,9 +933,7 @@ async function runImportClaudeCode(imported: ClaudeCodeImport): Promise activeModel: nextActiveModel, secrets: nextSecrets, providers: nextProviders, - ...(cachedConfig?.designSystem !== undefined - ? { designSystem: cachedConfig.designSystem } - : {}), + ...preservedV3OptionalsForWrite(cachedConfig), }); await writeConfig(next); cachedConfig = next; @@ -1027,9 +1023,7 @@ async function runImportOpencode(imported: OpencodeImport): Promise => { + const input = raw as { modelFast: string | null }; + if (typeof input !== 'object' || input === null || !('modelFast' in input)) { + throw new CodesignError( + 'config:v1:set-fast-model expects { modelFast: string | null }', + ERROR_CODES.IPC_BAD_INPUT, + ); + } + const modelFast = input.modelFast === '' ? null : input.modelFast; + if (cachedConfig === null) { + throw new CodesignError('No configuration found', ERROR_CODES.CONFIG_MISSING); + } + cachedConfig = hydrateConfig({ + ...toPersistedV3(cachedConfig), + modelFast: modelFast ?? undefined, + }); + await writeConfig(cachedConfig); + return toState(cachedConfig); + }); + ipcMain.handle( 'config:v1:detect-external-configs', async (): Promise => { diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 9cc4fe11..96330cff 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -314,6 +314,8 @@ const api = { 'config:v1:set-active-provider-and-model', input, ) as Promise, + setFastModel: (modelFast: string | null) => + ipcRenderer.invoke('config:v1:set-fast-model', { modelFast }) as Promise, testEndpoint: (input: { wire: WireApi; baseUrl: string; @@ -572,6 +574,9 @@ const api = { }, openExternal: (url: string) => ipcRenderer.invoke('codesign:v1:open-external', url) as Promise, + minimize: () => ipcRenderer.send('window:minimize'), + maximize: () => ipcRenderer.send('window:maximize'), + close: () => ipcRenderer.send('window:close'), }; contextBridge.exposeInMainWorld('codesign', api); diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 3d2e9283..0829812e 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -29,7 +29,7 @@ export function App() { const switchDesign = useCodesignStore((s) => s.switchDesign); const sendPrompt = useCodesignStore((s) => s.sendPrompt); const isGenerating = useCodesignStore( - (s) => s.isGenerating && s.generatingDesignId === s.currentDesignId, + (s) => s.currentDesignId !== null && s.activeGenerations.has(s.currentDesignId), ); const setView = useCodesignStore((s) => s.setView); const view = useCodesignStore((s) => s.view); diff --git a/apps/desktop/src/renderer/src/components/CanvasTabBar.tsx b/apps/desktop/src/renderer/src/components/CanvasTabBar.tsx index 5036f617..51da7ec0 100644 --- a/apps/desktop/src/renderer/src/components/CanvasTabBar.tsx +++ b/apps/desktop/src/renderer/src/components/CanvasTabBar.tsx @@ -1,5 +1,5 @@ import { useT } from '@open-codesign/i18n'; -import { FolderOpen, X } from 'lucide-react'; +import { Clock, Code2, FolderOpen, X } from 'lucide-react'; import { useCodesignStore } from '../store'; function fileTabLabel(path: string): string { @@ -25,9 +25,31 @@ export function CanvasTabBar() { {tabs.map((tab, index) => { const isActive = index === active; const isFiles = tab.kind === 'files'; - const label = isFiles ? t('canvas.filesTab') : fileTabLabel((tab as { path: string }).path); - const title = isFiles ? t('canvas.filesTab') : (tab as { path: string }).path; - const key: string = isFiles ? 'files' : `file:${(tab as { path: string }).path}`; + const isHistory = tab.kind === 'history'; + const isCode = tab.kind === 'code'; + const isFile = tab.kind === 'file'; + const isPinned = isFiles || isHistory || isCode; + const label = isFiles + ? t('canvas.filesTab') + : isHistory + ? t('canvas.historyTab') + : isCode + ? t('canvas.codeTab') + : fileTabLabel((tab as { path: string }).path); + const title = isFiles + ? t('canvas.filesTab') + : isHistory + ? t('canvas.historyTab') + : isCode + ? t('canvas.codeTab') + : (tab as { path: string }).path; + const key: string = isFiles + ? 'files' + : isHistory + ? 'history' + : isCode + ? 'code' + : `file:${(tab as { path: string }).path}`; return (
{isFiles ? : null} + {isHistory ? : null} + {isCode ? : null} {label} - {isFiles ? null : ( + {isPinned ? null : ( + {logs.length > 0 && ( + + )} +
+ + {open && ( +
+ {logs.length === 0 ? ( +
+ No output yet. +
+ ) : ( + logs.map((entry, i) => ( + + )) + )} +
+
+ )} +
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/FilesPanel.tsx b/apps/desktop/src/renderer/src/components/FilesPanel.tsx index 5b4627e3..7da32039 100644 --- a/apps/desktop/src/renderer/src/components/FilesPanel.tsx +++ b/apps/desktop/src/renderer/src/components/FilesPanel.tsx @@ -23,8 +23,7 @@ export function FilesPanel() { const t = useT(); const currentDesignId = useCodesignStore((s) => s.currentDesignId); const designs = useCodesignStore((s) => s.designs); - const isGenerating = useCodesignStore((s) => s.isGenerating); - const generatingDesignId = useCodesignStore((s) => s.generatingDesignId); + const activeGenerations = useCodesignStore((s) => s.activeGenerations); const openFileTab = useCodesignStore((s) => s.openCanvasFileTab); const requestWorkspaceRebind = useCodesignStore((s) => s.requestWorkspaceRebind); const { files, loading } = useDesignFiles(currentDesignId); @@ -33,7 +32,8 @@ export function FilesPanel() { const currentDesign = designs.find((d) => d.id === currentDesignId); const workspacePath = currentDesign?.workspacePath ?? null; - const isCurrentDesignGenerating = isGenerating && generatingDesignId === currentDesignId; + const isCurrentDesignGenerating = + currentDesignId !== null && activeGenerations.has(currentDesignId); useEffect(() => { if (!workspacePath || !currentDesignId) { diff --git a/apps/desktop/src/renderer/src/components/FilesTabView.tsx b/apps/desktop/src/renderer/src/components/FilesTabView.tsx index 9aa1a1b4..5fd21aed 100644 --- a/apps/desktop/src/renderer/src/components/FilesTabView.tsx +++ b/apps/desktop/src/renderer/src/components/FilesTabView.tsx @@ -17,15 +17,15 @@ function WorkspaceSection() { const t = useT(); const currentDesignId = useCodesignStore((s) => s.currentDesignId); const designs = useCodesignStore((s) => s.designs); - const isGenerating = useCodesignStore((s) => s.isGenerating); - const generatingDesignId = useCodesignStore((s) => s.generatingDesignId); + const activeGenerations = useCodesignStore((s) => s.activeGenerations); const requestWorkspaceRebind = useCodesignStore((s) => s.requestWorkspaceRebind); const [picking, setPicking] = useState(false); const [folderExists, setFolderExists] = useState(null); const currentDesign = designs.find((d) => d.id === currentDesignId); const workspacePath = currentDesign?.workspacePath ?? null; - const isCurrentDesignGenerating = isGenerating && generatingDesignId === currentDesignId; + const isCurrentDesignGenerating = + currentDesignId !== null && activeGenerations.has(currentDesignId); const disabled = picking || isCurrentDesignGenerating; useEffect(() => { @@ -317,7 +317,7 @@ export function FilesTabView() { pushIframeError(`SET_MODE postMessage failed: ${reason}`); } }} - className="w-full h-full bg-white border-0 block" + className="w-full h-full bg-transparent border-0 block" /> ) : (
diff --git a/apps/desktop/src/renderer/src/components/HistoryPanel.tsx b/apps/desktop/src/renderer/src/components/HistoryPanel.tsx new file mode 100644 index 00000000..85995551 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/HistoryPanel.tsx @@ -0,0 +1,187 @@ +import { buildSrcdoc } from '@open-codesign/runtime'; +import type { DesignSnapshot } from '@open-codesign/shared'; +import { Clock, RotateCcw, X } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; +import { useCodesignStore } from '../store'; + +interface HistoryPanelProps { + onClose: () => void; +} + +function formatDate(iso: string): string { + const d = new Date(iso); + return d.toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +export function HistoryPanel({ onClose }: HistoryPanelProps) { + const currentDesignId = useCodesignStore((s) => s.currentDesignId); + const setPreviewHtml = useCodesignStore((s) => s.setPreviewHtml); + const loadCommentsForCurrentDesign = useCodesignStore((s) => s.loadCommentsForCurrentDesign); + const pushToast = useCodesignStore((s) => s.pushToast); + + const [snapshots, setSnapshots] = useState([]); + const [loading, setLoading] = useState(true); + const [selected, setSelected] = useState(null); + const [restoring, setRestoring] = useState(false); + const [previewLoading, setPreviewLoading] = useState(false); + + useEffect(() => { + if (!currentDesignId || !window.codesign) return; + setLoading(true); + window.codesign.snapshots + .list(currentDesignId) + .then((rows) => { + setSnapshots(rows); + setSelected(rows[0] ?? null); + }) + .catch(() => {}) + .finally(() => setLoading(false)); + }, [currentDesignId]); + + const previewSrc = useMemo( + () => (selected ? buildSrcdoc(selected.artifactSource) : null), + [selected], + ); + + useEffect(() => { + setPreviewLoading(Boolean(previewSrc)); + }, [previewSrc]); + + async function handleRestore() { + if (!selected || !currentDesignId || !window.codesign) return; + setRestoring(true); + try { + const restored = await window.codesign.snapshots.create({ + designId: currentDesignId, + parentId: selected.id, + type: 'fork', + prompt: selected.prompt, + artifactType: selected.artifactType, + artifactSource: selected.artifactSource, + }); + setPreviewHtml(restored.artifactSource); + await loadCommentsForCurrentDesign(); + pushToast({ variant: 'success', title: 'Restored to selected version' }); + onClose(); + } catch (err) { + pushToast({ + variant: 'error', + title: 'Restore failed', + description: err instanceof Error ? err.message : String(err), + }); + } finally { + setRestoring(false); + } + } + + return ( +
+ {/* Header */} +
+ + + Version History + + +
+ +
+ {/* Snapshot list */} +
+ {loading ? ( +
Loading…
+ ) : snapshots.length === 0 ? ( +
+ No history yet. +
+ ) : ( + snapshots.map((snap, i) => ( + + )) + )} +
+ + {/* Preview area */} +
+ {previewSrc ? ( +
+