diff --git a/bun.lock b/bun.lock index 1ba6efea..9e37394a 100644 --- a/bun.lock +++ b/bun.lock @@ -32,7 +32,7 @@ "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.2.15", + "@opencode-ai/sdk": "^1.2.17", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -98,7 +98,7 @@ }, "packages/desktop": { "name": "@openchamber/desktop", - "version": "1.8.1", + "version": "1.8.4", "devDependencies": { "@tauri-apps/cli": "^2", "@types/node": "^24.3.1", @@ -107,7 +107,7 @@ }, "packages/ui": { "name": "@openchamber/ui", - "version": "1.8.1", + "version": "1.8.4", "dependencies": { "@codemirror/autocomplete": "^6.20.0", "@codemirror/commands": "^6.10.1", @@ -137,7 +137,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "^1.2.15", + "@opencode-ai/sdk": "^1.2.17", "@pierre/diffs": "1.1.0-beta.13", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", @@ -211,10 +211,10 @@ }, "packages/vscode": { "name": "openchamber", - "version": "1.8.1", + "version": "1.8.4", "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "^1.2.15", + "@opencode-ai/sdk": "^1.2.17", "adm-zip": "^0.5.16", "jsonc-parser": "^3.3.1", "react": "^19.1.1", @@ -234,7 +234,7 @@ }, "packages/web": { "name": "@openchamber/web", - "version": "1.8.1", + "version": "1.8.4", "bin": { "openchamber": "./bin/cli.js", }, @@ -245,7 +245,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.2.15", + "@opencode-ai/sdk": "^1.2.17", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -836,7 +836,7 @@ "@openchamber/web": ["@openchamber/web@workspace:packages/web"], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.2.15", "", {}, "sha512-NUJNlyBCdZ4R0EBLjJziEQOp2XbRPJosaMcTcWSWO5XJPKGUpz0u8ql+5cR8K+v2RJ+hp2NobtNwpjEYfe6BRQ=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.2.17", "", {}, "sha512-HdeLeyJ2/Yl/NBHqw9pGFBnkIXuf0Id1kX1GMXDcnZwbJROUJ6TtrW/wLngTYW478E4CCm1jwknjxxmDuxzVMQ=="], "@pierre/diffs": ["@pierre/diffs@1.1.0-beta.13", "", { "dependencies": { "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-D35rxDu5V7XHX5aVGU6PF12GhscL+I+9QYgxK/i3h0d2XSirAxDdVNm49aYwlOhgmdvL0NbS1IHxPswVB5yJvw=="], diff --git a/package.json b/package.json index 597af8ad..88a7eaa8 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,7 @@ "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.2.15", + "@opencode-ai/sdk": "^1.2.17", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/packages/desktop/noop-dist/index.html b/packages/desktop/noop-dist/index.html index 4efca0fd..b5972346 100644 --- a/packages/desktop/noop-dist/index.html +++ b/packages/desktop/noop-dist/index.html @@ -1,11 +1,123 @@ - + OpenChamber + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index 91da941c..9c1a9eb0 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -28,12 +28,18 @@ use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder}; /// Global counter for generating unique window labels. static WINDOW_COUNTER: AtomicU64 = AtomicU64::new(1); -fn next_window_label() -> String { - let n = WINDOW_COUNTER.fetch_add(1, Ordering::Relaxed); - if n == 1 { - "main".to_string() - } else { - format!("main-{n}") +fn next_window_label(app: &tauri::AppHandle) -> String { + loop { + let n = WINDOW_COUNTER.fetch_add(1, Ordering::Relaxed); + let candidate = if n == 1 { + "main".to_string() + } else { + format!("main-{n}") + }; + + if !app.webview_windows().contains_key(&candidate) { + return candidate; + } } } @@ -1166,6 +1172,10 @@ const SIDECAR_NAME: &str = "openchamber-server"; const SIDECAR_NOTIFY_PREFIX: &str = "[OpenChamberDesktopNotify] "; const HEALTH_TIMEOUT: Duration = Duration::from_secs(20); const HEALTH_POLL_INTERVAL: Duration = Duration::from_millis(250); +const LOCAL_SIDECAR_HEALTH_TIMEOUT: Duration = Duration::from_secs(8); +const LOCAL_SIDECAR_HEALTH_POLL_INTERVAL: Duration = Duration::from_millis(100); +const STARTUP_REMOTE_PROBE_SOFT_TIMEOUT: Duration = Duration::from_secs(2); +const STARTUP_REMOTE_PROBE_HARD_TIMEOUT: Duration = Duration::from_secs(10); const DEFAULT_DESKTOP_PORT: u16 = 57123; const WINDOW_STATE_DEBOUNCE_MS: u64 = 300; @@ -1511,12 +1521,11 @@ struct HostProbeResult { latency_ms: u64, } -#[tauri::command] -async fn desktop_host_probe(url: String) -> Result { - let health = build_health_url(&url).ok_or_else(|| "Invalid URL".to_string())?; +async fn probe_host_with_timeout(url: &str, timeout: Duration) -> Result { + let health = build_health_url(url).ok_or_else(|| "Invalid URL".to_string())?; let client = reqwest::Client::builder() .no_proxy() - .timeout(Duration::from_secs(2)) + .timeout(timeout) .build() .map_err(|err| err.to_string())?; let started = std::time::Instant::now(); @@ -1549,6 +1558,11 @@ async fn desktop_host_probe(url: String) -> Result { } } +#[tauri::command] +async fn desktop_host_probe(url: String) -> Result { + probe_host_with_timeout(&url, STARTUP_REMOTE_PROBE_SOFT_TIMEOUT).await +} + #[derive(Clone, Serialize)] #[serde(tag = "event", content = "data")] enum UpdateProgressEvent { @@ -1724,13 +1738,13 @@ fn maybe_show_sidecar_notification(app: &tauri::AppHandle, payload: SidecarNotif let _ = builder.show(); } -async fn wait_for_health(url: &str) -> bool { +async fn wait_for_health_with(url: &str, timeout: Duration, poll_interval: Duration) -> bool { let client = match reqwest::Client::builder().no_proxy().build() { Ok(c) => c, Err(_) => return false, }; - let deadline = std::time::Instant::now() + HEALTH_TIMEOUT; + let deadline = std::time::Instant::now() + timeout; let health_url = format!("{}/health", url.trim_end_matches('/')); while std::time::Instant::now() < deadline { @@ -1739,12 +1753,16 @@ async fn wait_for_health(url: &str) -> bool { return true; } } - tokio::time::sleep(HEALTH_POLL_INTERVAL).await; + tokio::time::sleep(poll_interval).await; } false } +async fn wait_for_health(url: &str) -> bool { + wait_for_health_with(url, HEALTH_TIMEOUT, HEALTH_POLL_INTERVAL).await +} + fn kill_sidecar(app: tauri::AppHandle) { let Some(state) = app.try_state::() else { return; @@ -1993,7 +2011,13 @@ async fn spawn_local_server(app: &tauri::AppHandle) -> Result { *state.url.lock().expect("sidecar url mutex") = Some(url.clone()); } - if !wait_for_health(&url).await { + if !wait_for_health_with( + &url, + LOCAL_SIDECAR_HEALTH_TIMEOUT, + LOCAL_SIDECAR_HEALTH_POLL_INTERVAL, + ) + .await + { kill_sidecar(app.clone()); continue; } @@ -2455,7 +2479,7 @@ fn create_window( restore_geometry: bool, ) -> Result<()> { let parsed = url::Url::parse(url).map_err(|err| anyhow!("Invalid URL: {err}"))?; - let label = next_window_label(); + let label = next_window_label(app); let init_script = build_init_script(local_origin); @@ -2521,6 +2545,170 @@ fn create_window( Ok(()) } +fn create_startup_window(app: &tauri::AppHandle, restore_geometry: bool) -> Result<()> { + if app.get_webview_window("main").is_some() { + return Ok(()); + } + + let restored_state = if restore_geometry { + read_desktop_window_state_from_disk() + } else { + None + }; + + let splash_script = build_startup_splash_script(); + + let mut builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::App("index.html".into())) + .title("OpenChamber") + .inner_size(1280.0, 800.0) + .min_inner_size(MIN_WINDOW_WIDTH as f64, MIN_WINDOW_HEIGHT as f64) + .decorations(true) + .visible(true) + .initialization_script(&splash_script) + .background_throttling(BackgroundThrottlingPolicy::Disabled); + + let apply_restored_state = restored_state + .as_ref() + .map(|state| is_window_state_visible(app, state)) + .unwrap_or(false); + + if let Some(state) = restored_state.as_ref().filter(|_| apply_restored_state) { + let restored_width = state.width.max(MIN_RESTORE_WINDOW_WIDTH); + let restored_height = state.height.max(MIN_RESTORE_WINDOW_HEIGHT); + builder = builder + .inner_size(restored_width as f64, restored_height as f64) + .position(state.x as f64, state.y as f64); + } + + #[cfg(target_os = "macos")] + { + builder = builder + .hidden_title(true) + .title_bar_style(tauri::TitleBarStyle::Overlay) + .traffic_light_position(tauri::Position::Logical(tauri::LogicalPosition { + x: 17.0, + y: 26.0, + })); + } + + let window = builder.build()?; + + if let Some(state) = restored_state.as_ref().filter(|_| apply_restored_state) { + if state.maximized || state.fullscreen { + let _ = window.maximize(); + } + } + + let _ = window.show(); + let _ = window.set_focus(); + + Ok(()) +} + +fn build_startup_splash_script() -> String { + let settings = fs::read_to_string(settings_file_path()) + .ok() + .and_then(|raw| serde_json::from_str::(&raw).ok()); + + let theme_mode = settings + .as_ref() + .and_then(|value| value.get("themeMode")) + .and_then(|value| value.as_str()) + .and_then(|value| match value.trim() { + "light" => Some("light"), + "dark" => Some("dark"), + "system" => Some("system"), + _ => None, + }); + + let use_system_theme = settings + .as_ref() + .and_then(|value| value.get("useSystemTheme")) + .and_then(|value| value.as_bool()) + .unwrap_or(true); + + let theme_variant = settings + .as_ref() + .and_then(|value| value.get("themeVariant")) + .and_then(|value| value.as_str()) + .and_then(|value| match value.trim() { + "light" => Some("light"), + "dark" => Some("dark"), + _ => None, + }); + + let effective_mode = theme_mode + .or_else(|| { + if use_system_theme { + Some("system") + } else { + None + } + }) + .or(theme_variant) + .unwrap_or("system"); + + let splash_bg_light = settings + .as_ref() + .and_then(|value| value.get("splashBgLight")) + .and_then(|value| value.as_str()) + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .unwrap_or(""); + let splash_fg_light = settings + .as_ref() + .and_then(|value| value.get("splashFgLight")) + .and_then(|value| value.as_str()) + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .unwrap_or(""); + let splash_bg_dark = settings + .as_ref() + .and_then(|value| value.get("splashBgDark")) + .and_then(|value| value.as_str()) + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .unwrap_or(""); + let splash_fg_dark = settings + .as_ref() + .and_then(|value| value.get("splashFgDark")) + .and_then(|value| value.as_str()) + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .unwrap_or(""); + + let mode_json = serde_json::to_string(effective_mode).unwrap_or_else(|_| "\"system\"".into()); + let bg_light_json = serde_json::to_string(splash_bg_light).unwrap_or_else(|_| "\"\"".into()); + let fg_light_json = serde_json::to_string(splash_fg_light).unwrap_or_else(|_| "\"\"".into()); + let bg_dark_json = serde_json::to_string(splash_bg_dark).unwrap_or_else(|_| "\"\"".into()); + let fg_dark_json = serde_json::to_string(splash_fg_dark).unwrap_or_else(|_| "\"\"".into()); + + format!( + "(function(){{try{{var mode={mode_json};var bgLight={bg_light_json};var fgLight={fg_light_json};var bgDark={bg_dark_json};var fgDark={fg_dark_json};var root=document.documentElement;if(bgLight)root.style.setProperty('--splash-background-light',bgLight);if(fgLight)root.style.setProperty('--splash-stroke-light',fgLight);if(bgDark)root.style.setProperty('--splash-background-dark',bgDark);if(fgDark)root.style.setProperty('--splash-stroke-dark',fgDark);var prefersDark=false;try{{prefersDark=!!(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches);}}catch(_e){{}}var dark=mode==='dark'?true:(mode==='light'?false:prefersDark);root.setAttribute('data-splash-variant',dark?'dark':'light');root.style.setProperty('color-scheme',dark?'dark':'light');}}catch(_e){{}}}})();" + ) +} + +fn activate_main_window(app: &tauri::AppHandle, url: &str, local_origin: &str) -> Result<()> { + let parsed = url::Url::parse(url).map_err(|err| anyhow!("Invalid URL: {err}"))?; + let init_script = build_init_script(local_origin); + + if let Some(state) = app.try_state::() { + *state.script.lock().expect("desktop ui injection mutex") = Some(init_script); + *state + .local_origin + .lock() + .expect("desktop local origin mutex") = Some(local_origin.to_string()); + } + + if let Some(window) = app.get_webview_window("main") { + window.navigate(parsed).map_err(|err| anyhow!(err.to_string()))?; + let _ = window.set_focus(); + return Ok(()); + } + + create_window(app, url, local_origin, true) +} + /// Open a new window pointed at the default host (local or configured default). /// /// Known multi-window limitations (acceptable for v1): @@ -2849,6 +3037,11 @@ fn main() { ]) .setup(|app| { let handle = app.handle().clone(); + + if let Err(err) = create_startup_window(&handle, true) { + log::error!("[desktop] failed to create startup window: {err}"); + } + tauri::async_runtime::spawn(async move { let local_url = if cfg!(debug_assertions) { let dev_url = "http://127.0.0.1:3901".to_string(); @@ -2916,37 +3109,48 @@ fn main() { if initial_url != local_ui_url { let failed_url = initial_url.clone(); - match desktop_host_probe(initial_url.clone()).await { - Ok(probe) if probe.status != "unreachable" => {} - Ok(_) => { + let soft_probe = + probe_host_with_timeout(&initial_url, STARTUP_REMOTE_PROBE_SOFT_TIMEOUT).await; + + let remote_reachable = match soft_probe { + Ok(probe) if probe.status != "unreachable" => true, + Ok(_) | Err(_) => { log::warn!( - "[desktop] startup host unreachable ({}), falling back to local ({})", - initial_url, - local_ui_url + "[desktop] startup host slow/unreachable ({}), retrying with extended timeout", + initial_url ); - initial_url = local_ui_url.clone(); - // Cache the failure so open_new_window skips this host. - if let Some(state) = handle.try_state::() { - state.unreachable_hosts.lock().expect("unreachable hosts mutex").insert(failed_url); + + match probe_host_with_timeout( + &initial_url, + STARTUP_REMOTE_PROBE_HARD_TIMEOUT, + ) + .await + { + Ok(probe) if probe.status != "unreachable" => true, + Ok(_) | Err(_) => false, } } - Err(err) => { - log::warn!( - "[desktop] startup host probe failed ({}): {}, falling back to local ({})", - initial_url, - err, - local_ui_url - ); - initial_url = local_ui_url.clone(); - if let Some(state) = handle.try_state::() { - state.unreachable_hosts.lock().expect("unreachable hosts mutex").insert(failed_url); - } + }; + + if !remote_reachable { + log::warn!( + "[desktop] startup host unreachable after retries ({}), falling back to local ({})", + initial_url, + local_ui_url + ); + initial_url = local_ui_url.clone(); + if let Some(state) = handle.try_state::() { + state + .unreachable_hosts + .lock() + .expect("unreachable hosts mutex") + .insert(failed_url); } } } - if let Err(err) = create_window(&handle, &initial_url, &local_origin, true) { - log::error!("[desktop] failed to create window: {err}"); + if let Err(err) = activate_main_window(&handle, &initial_url, &local_origin) { + log::error!("[desktop] failed to activate main window: {err}"); } }); diff --git a/packages/ui/package.json b/packages/ui/package.json index 6a11dc55..4c078281 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -39,7 +39,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "^1.2.15", + "@opencode-ai/sdk": "^1.2.17", "@pierre/diffs": "1.1.0-beta.13", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 91237013..2bdd29cb 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -95,6 +95,10 @@ const readEmbeddedSessionChatConfig = (): EmbeddedSessionChatConfig | null => { function App({ apis }: AppProps) { const { initializeApp, isInitialized, isConnected } = useConfigStore(); + const providersCount = useConfigStore((state) => state.providers.length); + const agentsCount = useConfigStore((state) => state.agents.length); + const loadProviders = useConfigStore((state) => state.loadProviders); + const loadAgents = useConfigStore((state) => state.loadAgents); const { error, clearError, loadSessions } = useSessionStore(); const currentSessionId = useSessionStore((state) => state.currentSessionId); const setCurrentSession = useSessionStore((state) => state.setCurrentSession); @@ -194,6 +198,49 @@ function App({ apis }: AppProps) { init(); }, [initializeApp, isVSCodeRuntime]); + const startupRecoveryInProgressRef = React.useRef(false); + const startupRecoveryLastAttemptRef = React.useRef(0); + + React.useEffect(() => { + if (isVSCodeRuntime) { + return; + } + if (!isConnected) { + return; + } + if (providersCount > 0 && agentsCount > 0) { + return; + } + if (startupRecoveryInProgressRef.current) { + return; + } + + const now = Date.now(); + if (now - startupRecoveryLastAttemptRef.current < 750) { + return; + } + + startupRecoveryLastAttemptRef.current = now; + startupRecoveryInProgressRef.current = true; + + const repair = async () => { + try { + if (providersCount === 0) { + await loadProviders(); + } + if (agentsCount === 0) { + await loadAgents(); + } + } catch { + // Keep UI responsive; we'll retry on next cycle. + } finally { + startupRecoveryInProgressRef.current = false; + } + }; + + void repair(); + }, [agentsCount, isConnected, isVSCodeRuntime, loadAgents, loadProviders, providersCount]); + React.useEffect(() => { if (isSwitchingDirectory) { return; diff --git a/packages/ui/src/components/auth/SessionAuthGate.tsx b/packages/ui/src/components/auth/SessionAuthGate.tsx index d112d4c4..4fcbcadd 100644 --- a/packages/ui/src/components/auth/SessionAuthGate.tsx +++ b/packages/ui/src/components/auth/SessionAuthGate.tsx @@ -6,6 +6,7 @@ import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop'; import { syncDesktopSettings, initializeAppearancePreferences } from '@/lib/persistence'; import { applyPersistedDirectoryPreferences } from '@/lib/directoryPersistence'; import { DesktopHostSwitcherInline } from '@/components/desktop/DesktopHostSwitcher'; +import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo'; const STATUS_CHECK_ENDPOINT = '/auth/session'; @@ -61,12 +62,10 @@ const AuthShell: React.FC<{ children: React.ReactNode }> = ({ children }) => ( ); -const LoadingScreen: React.FC<{ message?: string }> = ({ message = 'Preparing workspace…' }) => ( - -
-

{message}

-
-
+const LoadingScreen: React.FC = () => ( +
+ +
); const ErrorScreen: React.FC = ({ onRetry, errorType = 'network', retryAfter }) => { diff --git a/packages/ui/src/components/chat/MobileSessionStatusBar.tsx b/packages/ui/src/components/chat/MobileSessionStatusBar.tsx index ec25747c..b3b3360f 100644 --- a/packages/ui/src/components/chat/MobileSessionStatusBar.tsx +++ b/packages/ui/src/components/chat/MobileSessionStatusBar.tsx @@ -51,6 +51,7 @@ import { Button } from '@/components/ui/button'; import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog'; import { useDrawerSwipe } from '@/hooks/useDrawerSwipe'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; +import { useThemeSystem } from '@/contexts/useThemeSystem'; interface MobileSessionStatusBarProps { onSessionSwitch?: (sessionId: string) => void; @@ -606,6 +607,7 @@ function SortableProjectItem({ onDelete, formatProjectLabel, }: SortableProjectItemProps) { + const { currentTheme } = useThemeSystem(); const { attributes, listeners, @@ -623,7 +625,12 @@ function SortableProjectItem({ const [imageFailed, setImageFailed] = React.useState(false); const ProjectIcon = project.icon ? PROJECT_ICON_MAP[project.icon] : null; - const projectIconImageUrl = !imageFailed ? getProjectIconImageUrl(project) : null; + const projectIconImageUrl = !imageFailed + ? getProjectIconImageUrl(project, { + themeVariant: currentTheme.metadata.variant, + iconColor: currentTheme.colors.surface.foreground, + }) + : null; const projectColorVar = project.color ? (PROJECT_COLOR_MAP[project.color] ?? null) : null; return ( @@ -852,9 +859,15 @@ function ProjectButton({ onOpenEditPanel, formatProjectLabel, }: ProjectButtonProps) { + const { currentTheme } = useThemeSystem(); const [imageFailed, setImageFailed] = React.useState(false); const ProjectIcon = project.icon ? PROJECT_ICON_MAP[project.icon] : null; - const projectIconImageUrl = !imageFailed ? getProjectIconImageUrl(project) : null; + const projectIconImageUrl = !imageFailed + ? getProjectIconImageUrl(project, { + themeVariant: currentTheme.metadata.variant, + iconColor: currentTheme.colors.surface.foreground, + }) + : null; React.useEffect(() => { setImageFailed(false); @@ -1415,6 +1428,7 @@ export const MobileSessionStatusBar: React.FC = ({ onSessionSwitch, cornerRadius, }) => { + const { currentTheme } = useThemeSystem(); const sessions = useSessionStore((state) => state.sessions); const currentSessionId = useSessionStore((state) => state.currentSessionId); const sessionStatus = useSessionStore((state) => state.sessionStatus); @@ -1454,7 +1468,12 @@ export const MobileSessionStatusBar: React.FC = ({ const activeProject = getActiveProject(); const currentProjectLabel = activeProject?.label || formatDirectoryName(activeProject?.path || '', homeDirectory); const currentProjectIcon = activeProject?.icon; - const currentProjectIconImageUrl = activeProject ? getProjectIconImageUrl(activeProject) : null; + const currentProjectIconImageUrl = activeProject + ? getProjectIconImageUrl(activeProject, { + themeVariant: currentTheme.metadata.variant, + iconColor: currentTheme.colors.surface.foreground, + }) + : null; const currentProjectIconBackground = activeProject?.iconBackground ?? null; const currentProjectColor = activeProject?.color; diff --git a/packages/ui/src/components/layout/NavRail.tsx b/packages/ui/src/components/layout/NavRail.tsx index 9a540740..1b5116d6 100644 --- a/packages/ui/src/components/layout/NavRail.tsx +++ b/packages/ui/src/components/layout/NavRail.tsx @@ -48,6 +48,7 @@ import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell, requestDirect import { useLongPress } from '@/hooks/useLongPress'; import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts'; import { sessionEvents } from '@/lib/sessionEvents'; +import { useThemeSystem } from '@/contexts/useThemeSystem'; import type { ProjectEntry } from '@/lib/api/types'; const normalize = (value: string): string => { @@ -257,10 +258,16 @@ const ProjectTile: React.FC<{ onEdit: () => void; onClose: () => void; }> = ({ project, isActive, hasStreaming, hasUnread, label, expanded, projectTextVisible, onClick, onEdit, onClose }) => { + const { currentTheme } = useThemeSystem(); const [menuOpen, setMenuOpen] = React.useState(false); const [iconImageFailed, setIconImageFailed] = React.useState(false); const ProjectIcon = project.icon ? PROJECT_ICON_MAP[project.icon] : null; - const projectIconImageUrl = !iconImageFailed ? getProjectIconImageUrl(project) : null; + const projectIconImageUrl = !iconImageFailed + ? getProjectIconImageUrl(project, { + themeVariant: currentTheme.metadata.variant, + iconColor: currentTheme.colors.surface.foreground, + }) + : null; const projectColorVar = project.color ? (PROJECT_COLOR_MAP[project.color] ?? null) : null; const showStreamingDots = hasStreaming; const showAttentionDots = !hasStreaming && hasUnread; diff --git a/packages/ui/src/components/layout/ProjectEditDialog.tsx b/packages/ui/src/components/layout/ProjectEditDialog.tsx index c6296bab..7613636d 100644 --- a/packages/ui/src/components/layout/ProjectEditDialog.tsx +++ b/packages/ui/src/components/layout/ProjectEditDialog.tsx @@ -12,6 +12,7 @@ import { toast } from '@/components/ui'; import { cn } from '@/lib/utils'; import { PROJECT_ICONS, PROJECT_COLORS, PROJECT_COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta'; import { useProjectsStore } from '@/stores/useProjectsStore'; +import { useThemeSystem } from '@/contexts/useThemeSystem'; interface ProjectEditDialogProps { open: boolean; @@ -53,6 +54,7 @@ export const ProjectEditDialog: React.FC = ({ const removeProjectIcon = useProjectsStore((state) => state.removeProjectIcon); const discoverProjectIcon = useProjectsStore((state) => state.discoverProjectIcon); const currentIconImage = useProjectsStore((state) => state.projects.find((project) => project.id === projectId)?.iconImage ?? null); + const { currentTheme } = useThemeSystem(); const [name, setName] = React.useState(projectName); const [icon, setIcon] = React.useState(initialIcon); const [color, setColor] = React.useState(initialColor); @@ -145,7 +147,13 @@ export const ProjectEditDialog: React.FC = ({ ? (hasPendingUploadImageIcon ? pendingUploadIconPreviewUrl : (hasStoredImageIcon && !pendingRemoveImageIcon - ? getProjectIconImageUrl({ id: projectId, iconImage: currentIconImage ?? null }) + ? getProjectIconImageUrl( + { id: projectId, iconImage: currentIconImage ?? null }, + { + themeVariant: currentTheme.metadata.variant, + iconColor: currentTheme.colors.surface.foreground, + }, + ) : null)) : null; diff --git a/packages/ui/src/components/sections/projects/ProjectsPage.tsx b/packages/ui/src/components/sections/projects/ProjectsPage.tsx index 372eec36..607d7415 100644 --- a/packages/ui/src/components/sections/projects/ProjectsPage.tsx +++ b/packages/ui/src/components/sections/projects/ProjectsPage.tsx @@ -10,6 +10,7 @@ import { PROJECT_COLORS, PROJECT_ICONS, PROJECT_COLOR_MAP as COLOR_MAP, getProje import { RiCloseLine } from '@remixicon/react'; import { WorktreeSectionContent } from '@/components/sections/openchamber/WorktreeSectionContent'; import { ProjectActionsSection } from '@/components/sections/projects/ProjectActionsSection'; +import { useThemeSystem } from '@/contexts/useThemeSystem'; export const ProjectsPage: React.FC = () => { const projects = useProjectsStore((state) => state.projects); @@ -19,6 +20,7 @@ export const ProjectsPage: React.FC = () => { const discoverProjectIcon = useProjectsStore((state) => state.discoverProjectIcon); const selectedId = useUIStore((state) => state.settingsProjectsSelectedId); const setSelectedId = useUIStore((state) => state.setSettingsProjectsSelectedId); + const { currentTheme } = useThemeSystem(); const selectedProject = React.useMemo(() => { if (!selectedId) return null; @@ -159,7 +161,10 @@ export const ProjectsPage: React.FC = () => { ? (hasPendingUploadImageIcon ? pendingUploadIconPreviewUrl : (selectedProject && hasStoredImageIcon && !pendingRemoveImageIcon - ? getProjectIconImageUrl(selectedProject) + ? getProjectIconImageUrl(selectedProject, { + themeVariant: currentTheme.metadata.variant, + iconColor: currentTheme.colors.surface.foreground, + }) : null)) : null; diff --git a/packages/ui/src/components/sections/projects/ProjectsSidebar.tsx b/packages/ui/src/components/sections/projects/ProjectsSidebar.tsx index c1b4e059..cd85a727 100644 --- a/packages/ui/src/components/sections/projects/ProjectsSidebar.tsx +++ b/packages/ui/src/components/sections/projects/ProjectsSidebar.tsx @@ -10,12 +10,14 @@ import { RiAddLine, RiFolderLine } from '@remixicon/react'; import { isDesktopLocalOriginActive, isTauriShell, isVSCodeRuntime, requestDirectoryAccess } from '@/lib/desktop'; import { sessionEvents } from '@/lib/sessionEvents'; import { toast } from '@/components/ui'; +import { useThemeSystem } from '@/contexts/useThemeSystem'; export const ProjectsSidebar: React.FC<{ onItemSelect?: () => void }> = ({ onItemSelect }) => { const projects = useProjectsStore((state) => state.projects); const addProject = useProjectsStore((state) => state.addProject); const selectedId = useUIStore((state) => state.settingsProjectsSelectedId); const setSelectedId = useUIStore((state) => state.setSettingsProjectsSelectedId); + const { currentTheme } = useThemeSystem(); const [brokenIconIds, setBrokenIconIds] = React.useState>(new Set()); const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); @@ -91,7 +93,12 @@ export const ProjectsSidebar: React.FC<{ onItemSelect?: () => void }> = ({ onIte const selected = project.id === selectedId; const Icon = project.icon ? PROJECT_ICON_MAP[project.icon] : null; const imageFailureKey = `${project.id}:${project.iconImage?.updatedAt ?? 0}`; - const imageUrl = brokenIconIds.has(imageFailureKey) ? null : getProjectIconImageUrl(project); + const imageUrl = brokenIconIds.has(imageFailureKey) + ? null + : getProjectIconImageUrl(project, { + themeVariant: currentTheme.metadata.variant, + iconColor: currentTheme.colors.surface.foreground, + }); const color = project.color ? (PROJECT_COLOR_MAP[project.color] ?? null) : null; const icon = imageUrl ? ( diff --git a/packages/ui/src/components/ui/OpenChamberLogo.tsx b/packages/ui/src/components/ui/OpenChamberLogo.tsx index c28ffba9..60e9f755 100644 --- a/packages/ui/src/components/ui/OpenChamberLogo.tsx +++ b/packages/ui/src/components/ui/OpenChamberLogo.tsx @@ -1,6 +1,20 @@ import React, { useMemo } from 'react'; import { useOptionalThemeSystem } from '@/contexts/useThemeSystem'; +const LEFT_FACE_CELL_OPACITIES = [ + 0.2, 0.45, 0.15, 0.55, + 0.35, 0.1, 0.5, 0.25, + 0.4, 0.3, 0.45, 0.15, + 0.55, 0.2, 0.35, 0.1, +]; + +const RIGHT_FACE_CELL_OPACITIES = [ + 0.3, 0.15, 0.45, 0.25, + 0.5, 0.35, 0.1, 0.4, + 0.2, 0.55, 0.3, 0.15, + 0.45, 0.25, 0.4, 0.2, +]; + interface OpenChamberLogoProps { className?: string; width?: number; @@ -79,6 +93,9 @@ export const OpenChamberLogo: React.FC = ({ } const strokeColor = useMemo(() => { + if (themeContext) { + return themeContext.currentTheme.colors.surface.foreground; + } if (typeof window !== 'undefined') { const fromVars = getComputedStyle(document.documentElement).getPropertyValue('--splash-stroke').trim(); if (fromVars) { @@ -86,9 +103,22 @@ export const OpenChamberLogo: React.FC = ({ } } return isDark ? 'white' : 'black'; - }, [isDark]); + }, [themeContext, isDark]); + + const supportsColorMix = useMemo(() => { + if (typeof window === 'undefined' || typeof CSS === 'undefined' || typeof CSS.supports !== 'function') { + return false; + } + return CSS.supports('color', 'color-mix(in srgb, white 50%, transparent)'); + }, []); const fillColor = useMemo(() => { + if (themeContext) { + if (supportsColorMix) { + return `color-mix(in srgb, ${strokeColor} 15%, transparent)`; + } + return isDark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.15)'; + } if (typeof window !== 'undefined') { const fromVars = getComputedStyle(document.documentElement).getPropertyValue('--splash-face-fill').trim(); if (fromVars) { @@ -96,9 +126,15 @@ export const OpenChamberLogo: React.FC = ({ } } return isDark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.15)'; - }, [isDark]); + }, [themeContext, supportsColorMix, strokeColor, isDark]); const cellHighlightColor = useMemo(() => { + if (themeContext) { + if (supportsColorMix) { + return `color-mix(in srgb, ${strokeColor} 35%, transparent)`; + } + return isDark ? 'rgba(255,255,255,0.35)' : 'rgba(0,0,0,0.4)'; + } if (typeof window !== 'undefined') { const fromVars = getComputedStyle(document.documentElement).getPropertyValue('--splash-cell-fill').trim(); if (fromVars) { @@ -106,7 +142,7 @@ export const OpenChamberLogo: React.FC = ({ } } return isDark ? 'rgba(255,255,255,0.35)' : 'rgba(0,0,0,0.4)'; - }, [isDark]); + }, [themeContext, supportsColorMix, strokeColor, isDark]); const logoFillColor = strokeColor; @@ -145,15 +181,6 @@ export const OpenChamberLogo: React.FC = ({ // Right face: center -> right -> bottomRight -> bottom const rightFaceCells = generateFaceGrid(center, right, bottomRight, bottom); - // Generate random opacity values for cells (stable per component instance) - const cellOpacities = useMemo(() => { - const opacities: number[] = []; - for (let i = 0; i < 32; i++) { // 16 cells per face * 2 faces - opacities.push(0.1 + Math.random() * 0.5); // Random opacity 0.1-0.6 - } - return opacities; - }, []); - return ( = ({ key={`left-${i}`} d={cell.path} fill={cellHighlightColor} - opacity={cellOpacities[i]} + opacity={LEFT_FACE_CELL_OPACITIES[cell.row * 4 + (3 - cell.col)] ?? 0.35} /> ))} @@ -199,7 +226,7 @@ export const OpenChamberLogo: React.FC = ({ key={`right-${i}`} d={cell.path} fill={cellHighlightColor} - opacity={cellOpacities[i + 16]} + opacity={RIGHT_FACE_CELL_OPACITIES[cell.row * 4 + cell.col] ?? 0.35} /> ))} diff --git a/packages/ui/src/contexts/ThemeSystemContext.tsx b/packages/ui/src/contexts/ThemeSystemContext.tsx index 79132f9f..8f74c9ed 100644 --- a/packages/ui/src/contexts/ThemeSystemContext.tsx +++ b/packages/ui/src/contexts/ThemeSystemContext.tsx @@ -557,14 +557,21 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro }, [applyIncomingThemeSync]); useEffect(() => { + const lightTheme = ensureThemeById(preferences.lightThemeId, 'light'); + const darkTheme = ensureThemeById(preferences.darkThemeId, 'dark'); + void updateDesktopSettings({ themeId: currentTheme.metadata.id, themeVariant: currentTheme.metadata.variant === 'light' ? 'light' : 'dark', useSystemTheme: preferences.themeMode === 'system', lightThemeId: preferences.lightThemeId, darkThemeId: preferences.darkThemeId, + splashBgLight: lightTheme.colors.surface.background, + splashFgLight: lightTheme.colors.surface.foreground, + splashBgDark: darkTheme.colors.surface.background, + splashFgDark: darkTheme.colors.surface.foreground, }); - }, [currentTheme.metadata.id, currentTheme.metadata.variant, preferences.themeMode, preferences.lightThemeId, preferences.darkThemeId]); + }, [currentTheme.metadata.id, currentTheme.metadata.variant, ensureThemeById, preferences.themeMode, preferences.lightThemeId, preferences.darkThemeId]); useEffect(() => { if (typeof window === 'undefined') { diff --git a/packages/ui/src/hooks/useWindowTitle.ts b/packages/ui/src/hooks/useWindowTitle.ts index 4cc4e510..a3914f04 100644 --- a/packages/ui/src/hooks/useWindowTitle.ts +++ b/packages/ui/src/hooks/useWindowTitle.ts @@ -113,6 +113,12 @@ export const useWindowTitle = () => { if (cancelled) { return; } + + const isMac = typeof navigator !== 'undefined' && /Macintosh|Mac OS X/.test(navigator.userAgent || ''); + if (isMac) { + return; + } + const currentWindow = getCurrentWindow(); await currentWindow.setTitle(title); } catch { diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index d79053f9..48ac4306 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -41,6 +41,10 @@ export type DesktopSettings = { themeVariant?: 'light' | 'dark'; lightThemeId?: string; darkThemeId?: string; + splashBgLight?: string; + splashFgLight?: string; + splashBgDark?: string; + splashFgDark?: string; lastDirectory?: string; homeDirectory?: string; // Optional absolute path to `opencode` binary. diff --git a/packages/ui/src/lib/projectMeta.ts b/packages/ui/src/lib/projectMeta.ts index 195de2ed..1116f3ae 100644 --- a/packages/ui/src/lib/projectMeta.ts +++ b/packages/ui/src/lib/projectMeta.ts @@ -22,6 +22,8 @@ import { } from '@remixicon/react'; import type { ProjectEntry } from '@/lib/api/types'; +type ThemeVariant = 'light' | 'dark'; + export const PROJECT_ICONS: Array<{ key: string; Icon: RemixiconComponentType; label: string }> = [ { key: 'code', Icon: RiCodeBoxLine, label: 'Code' }, { key: 'terminal', Icon: RiTerminalBoxLine, label: 'Terminal' }, @@ -64,10 +66,21 @@ export const PROJECT_COLOR_MAP: Record = Object.fromEntries( PROJECT_COLORS.map((c) => [c.key, c.cssVar]) ); -export const getProjectIconImageUrl = (project: Pick): string | null => { +export const getProjectIconImageUrl = ( + project: Pick, + options?: { themeVariant?: ThemeVariant; iconColor?: string }, +): string | null => { if (!project.iconImage || typeof project.iconImage.updatedAt !== 'number' || project.iconImage.updatedAt <= 0) { return null; } - return `/api/projects/${encodeURIComponent(project.id)}/icon?v=${project.iconImage.updatedAt}`; + const params = new URLSearchParams({ v: String(project.iconImage.updatedAt) }); + if (typeof options?.iconColor === 'string' && options.iconColor.trim()) { + params.set('iconColor', options.iconColor.trim()); + } + if (options?.themeVariant === 'light' || options?.themeVariant === 'dark') { + params.set('theme', options.themeVariant); + } + + return `/api/projects/${encodeURIComponent(project.id)}/icon?${params.toString()}`; }; diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 40a9561e..b6ed5cca 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -229,7 +229,7 @@ }, "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "^1.2.15", + "@opencode-ai/sdk": "^1.2.17", "adm-zip": "^0.5.16", "jsonc-parser": "^3.3.1", "react": "^19.1.1", diff --git a/packages/web/index.html b/packages/web/index.html index 4aea0538..6f112fa5 100644 --- a/packages/web/index.html +++ b/packages/web/index.html @@ -299,7 +299,7 @@ var variant = localStorage.getItem('selectedThemeVariant'); var useSystem = localStorage.getItem('useSystemTheme'); var isDark; - + // Check themeMode first (new storage key) if (themeMode === 'dark') { isDark = true; @@ -315,7 +315,7 @@ // Default to system isDark = window.matchMedia('(prefers-color-scheme: dark)').matches; } - + // Apply theme class and data attribute document.documentElement.classList.add(isDark ? 'dark' : 'light'); document.documentElement.setAttribute('data-splash-variant', isDark ? 'dark' : 'light'); @@ -380,9 +380,9 @@ :root { --splash-background-dark: #151313; - --splash-stroke-dark: white; - --splash-background-light: #F6F4EF; - --splash-stroke-light: black; + --splash-stroke-dark: #CECDC3; + --splash-background-light: #FFFCF0; + --splash-stroke-light: #100F0F; --splash-background: var(--splash-background-dark); --splash-stroke: var(--splash-stroke-dark); @@ -447,10 +447,10 @@ - + - + @@ -468,10 +468,10 @@ - + - + @@ -489,10 +489,10 @@ - + - + diff --git a/packages/web/package.json b/packages/web/package.json index 57473062..ce8c6d65 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -28,7 +28,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.2.15", + "@opencode-ai/sdk": "^1.2.17", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/packages/web/public/favicon.svg b/packages/web/public/favicon.svg index efb177bd..f8353384 100644 --- a/packages/web/public/favicon.svg +++ b/packages/web/public/favicon.svg @@ -1,15 +1,26 @@ + - + - + - + - + diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 02036a09..f87aa22d 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -784,6 +784,36 @@ const resolveZenModel = async (override) => { return validatedZenFallback || ZEN_DEFAULT_MODEL; }; +const validateZenModelAtStartup = async () => { + try { + const freeModels = await fetchFreeZenModels(); + const freeModelIds = freeModels.map((m) => m.id); + + if (freeModelIds.length > 0) { + validatedZenFallback = freeModelIds[0]; + + const settings = await readSettingsFromDisk(); + const storedModel = typeof settings?.zenModel === 'string' ? settings.zenModel.trim() : ''; + + if (!storedModel || !freeModelIds.includes(storedModel)) { + const fallback = freeModelIds[0]; + console.log( + storedModel + ? `[zen] Stored model "${storedModel}" not found in free models, falling back to "${fallback}"` + : `[zen] No model configured, setting default to "${fallback}"` + ); + await persistSettings({ zenModel: fallback }); + } else { + console.log(`[zen] Stored model "${storedModel}" verified as available`); + } + } else { + console.warn('[zen] No free models returned from API, skipping validation'); + } + } catch (error) { + console.warn('[zen] Startup model validation failed (non-blocking):', error?.message || error); + } +}; + const summarizeText = async (text, targetLength, zenModel) => { if (!text || typeof text !== 'string' || text.trim().length === 0) return text; @@ -1148,6 +1178,11 @@ const PROJECT_ICON_EXTENSION_TO_MIME = Object.fromEntries( ); const PROJECT_ICON_SUPPORTED_MIMES = new Set(Object.keys(PROJECT_ICON_MIME_TO_EXTENSION)); const PROJECT_ICON_MAX_BYTES = 5 * 1024 * 1024; +const PROJECT_ICON_THEME_COLORS = { + light: '#111111', + dark: '#f5f5f5', +}; +const PROJECT_ICON_HEX_COLOR_PATTERN = /^#(?:[\da-fA-F]{3}|[\da-fA-F]{4}|[\da-fA-F]{6}|[\da-fA-F]{8})$/; const normalizeProjectIconMime = (value) => { if (typeof value !== 'string') { @@ -1230,6 +1265,54 @@ const parseProjectIconDataUrl = (value) => { } }; +const normalizeProjectIconThemeVariant = (value) => { + if (typeof value !== 'string') { + return null; + } + + const normalized = value.trim().toLowerCase(); + if (normalized === 'light' || normalized === 'dark') { + return normalized; + } + return null; +}; + +const normalizeProjectIconColor = (value) => { + if (typeof value !== 'string') { + return null; + } + + const normalized = value.trim(); + if (!PROJECT_ICON_HEX_COLOR_PATTERN.test(normalized)) { + return null; + } + return normalized; +}; + +const applyProjectIconSvgTheme = (svgMarkup, themeVariant, iconColor) => { + if (typeof svgMarkup !== 'string') { + return svgMarkup; + } + + const color = iconColor || PROJECT_ICON_THEME_COLORS[themeVariant]; + if (!color) { + return svgMarkup; + } + + const svgTagIndex = svgMarkup.search(/', svgTagIndex); + if (svgOpenTagEndIndex === -1) { + return svgMarkup; + } + + const overrideStyle = ``; + return `${svgMarkup.slice(0, svgOpenTagEndIndex + 1)}${overrideStyle}${svgMarkup.slice(svgOpenTagEndIndex + 1)}`; +}; + const findProjectById = (settings, projectId) => { const projects = sanitizeProjects(settings?.projects) || []; const index = projects.findIndex((project) => project.id === projectId); @@ -1795,6 +1878,18 @@ const sanitizeSettingsUpdate = (payload) => { if (typeof candidate.darkThemeId === 'string' && candidate.darkThemeId.length > 0) { result.darkThemeId = candidate.darkThemeId; } + if (typeof candidate.splashBgLight === 'string' && candidate.splashBgLight.trim().length > 0) { + result.splashBgLight = candidate.splashBgLight.trim(); + } + if (typeof candidate.splashFgLight === 'string' && candidate.splashFgLight.trim().length > 0) { + result.splashFgLight = candidate.splashFgLight.trim(); + } + if (typeof candidate.splashBgDark === 'string' && candidate.splashBgDark.trim().length > 0) { + result.splashBgDark = candidate.splashBgDark.trim(); + } + if (typeof candidate.splashFgDark === 'string' && candidate.splashFgDark.trim().length > 0) { + result.splashFgDark = candidate.splashFgDark.trim(); + } if (typeof candidate.lastDirectory === 'string' && candidate.lastDirectory.length > 0) { result.lastDirectory = candidate.lastDirectory; } @@ -5929,6 +6024,72 @@ async function refreshOpenCodeAfterConfigChange(reason, options = {}) { } } +async function bootstrapOpenCodeAtStartup() { + try { + syncFromHmrState(); + if (await isOpenCodeProcessHealthy()) { + console.log(`[HMR] Reusing existing OpenCode process on port ${openCodePort}`); + } else if (ENV_SKIP_OPENCODE_START && ENV_EFFECTIVE_PORT) { + const label = ENV_CONFIGURED_OPENCODE_HOST ? ENV_CONFIGURED_OPENCODE_HOST.origin : `http://localhost:${ENV_EFFECTIVE_PORT}`; + console.log(`Using external OpenCode server at ${label} (skip-start mode)`); + openCodeBaseUrl = ENV_CONFIGURED_OPENCODE_HOST?.origin ?? null; + setOpenCodePort(ENV_EFFECTIVE_PORT); + isOpenCodeReady = true; + isExternalOpenCode = true; + lastOpenCodeError = null; + openCodeNotReadySince = 0; + syncToHmrState(); + } else if (ENV_EFFECTIVE_PORT && await probeExternalOpenCode(ENV_EFFECTIVE_PORT, ENV_CONFIGURED_OPENCODE_HOST?.origin)) { + const label = ENV_CONFIGURED_OPENCODE_HOST ? ENV_CONFIGURED_OPENCODE_HOST.origin : `http://localhost:${ENV_EFFECTIVE_PORT}`; + console.log(`Auto-detected existing OpenCode server at ${label}`); + openCodeBaseUrl = ENV_CONFIGURED_OPENCODE_HOST?.origin ?? null; + setOpenCodePort(ENV_EFFECTIVE_PORT); + isOpenCodeReady = true; + isExternalOpenCode = true; + lastOpenCodeError = null; + openCodeNotReadySince = 0; + syncToHmrState(); + } else if (!ENV_EFFECTIVE_PORT && await probeExternalOpenCode(4096)) { + console.log('Auto-detected existing OpenCode server on default port 4096'); + setOpenCodePort(4096); + isOpenCodeReady = true; + isExternalOpenCode = true; + lastOpenCodeError = null; + openCodeNotReadySince = 0; + syncToHmrState(); + } else { + if (ENV_EFFECTIVE_PORT) { + console.log(`Using OpenCode port from environment: ${ENV_EFFECTIVE_PORT}`); + setOpenCodePort(ENV_EFFECTIVE_PORT); + } else { + openCodePort = null; + syncToHmrState(); + } + + lastOpenCodeError = null; + openCodeProcess = await startOpenCode(); + syncToHmrState(); + } + await waitForOpenCodePort(); + try { + await waitForOpenCodeReady(); + } catch (error) { + console.error(`OpenCode readiness check failed: ${error.message}`); + scheduleOpenCodeApiDetection(); + } + scheduleOpenCodeApiDetection(); + startHealthMonitoring(); + void startGlobalEventWatcher().catch((error) => { + console.warn(`Global event watcher startup failed: ${error?.message || error}`); + }); + } catch (error) { + console.error(`Failed to start OpenCode: ${error.message}`); + console.log('Continuing without OpenCode integration...'); + lastOpenCodeError = error.message; + scheduleOpenCodeApiDetection(); + } +} + function setupProxy(app) { if (app.get('opencodeProxyConfigured')) { return; @@ -6564,35 +6725,8 @@ async function main(options = {}) { sayTTSCapability = { available: false, voices: [], reason: 'Not macOS' }; } - // Validate stored zen model at startup – best-effort, never blocks startup - try { - const freeModels = await fetchFreeZenModels(); - const freeModelIds = freeModels.map((m) => m.id); - - if (freeModelIds.length > 0) { - // Set the validated fallback to the first available free model - validatedZenFallback = freeModelIds[0]; - - const settings = await readSettingsFromDisk(); - const storedModel = typeof settings?.zenModel === 'string' ? settings.zenModel.trim() : ''; - - if (!storedModel || !freeModelIds.includes(storedModel)) { - const fallback = freeModelIds[0]; - console.log( - storedModel - ? `[zen] Stored model "${storedModel}" not found in free models, falling back to "${fallback}"` - : `[zen] No model configured, setting default to "${fallback}"` - ); - await persistSettings({ zenModel: fallback }); - } else { - console.log(`[zen] Stored model "${storedModel}" verified as available`); - } - } else { - console.warn('[zen] No free models returned from API, skipping validation'); - } - } catch (error) { - console.warn('[zen] Startup model validation failed (non-blocking):', error?.message || error); - } + // Startup model validation is best-effort and runs in background. + void validateZenModelAtStartup(); const app = express(); const serverStartedAt = new Date().toISOString(); @@ -8091,11 +8225,34 @@ async function main(options = {}) { ? [preferredPath, ...projectIconPathCandidates(projectId).filter((candidate) => candidate !== preferredPath)] : projectIconPathCandidates(projectId); + const themeQuery = Array.isArray(req.query?.theme) ? req.query.theme[0] : req.query?.theme; + const requestedThemeVariant = normalizeProjectIconThemeVariant(themeQuery); + const iconColorQuery = Array.isArray(req.query?.iconColor) ? req.query.iconColor[0] : req.query?.iconColor; + const requestedIconColor = normalizeProjectIconColor(iconColorQuery); + for (const iconPath of candidates) { try { const data = await fsPromises.readFile(iconPath); const ext = path.extname(iconPath).slice(1).toLowerCase(); - const contentType = metadataMime || PROJECT_ICON_EXTENSION_TO_MIME[ext] || 'application/octet-stream'; + const resolvedMime = metadataMime || PROJECT_ICON_EXTENSION_TO_MIME[ext] || 'application/octet-stream'; + const contentType = resolvedMime === 'image/svg+xml' ? 'image/svg+xml; charset=utf-8' : resolvedMime; + + if (resolvedMime === 'image/svg+xml' && requestedThemeVariant) { + const svgMarkup = data.toString('utf8'); + const themedSvgMarkup = applyProjectIconSvgTheme(svgMarkup, requestedThemeVariant, requestedIconColor); + res.setHeader('Content-Type', contentType); + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); + return res.send(themedSvgMarkup); + } + + if (resolvedMime === 'image/svg+xml' && requestedIconColor) { + const svgMarkup = data.toString('utf8'); + const themedSvgMarkup = applyProjectIconSvgTheme(svgMarkup, requestedThemeVariant, requestedIconColor); + res.setHeader('Content-Type', contentType); + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); + return res.send(themedSvgMarkup); + } + res.setHeader('Content-Type', contentType); res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); return res.send(data); @@ -13006,69 +13163,9 @@ async function main(options = {}) { res.json({ success: true, killedCount }); }); - try { - syncFromHmrState(); - if (await isOpenCodeProcessHealthy()) { - console.log(`[HMR] Reusing existing OpenCode process on port ${openCodePort}`); - } else if (ENV_SKIP_OPENCODE_START && ENV_EFFECTIVE_PORT) { - const label = ENV_CONFIGURED_OPENCODE_HOST ? ENV_CONFIGURED_OPENCODE_HOST.origin : `http://localhost:${ENV_EFFECTIVE_PORT}`; - console.log(`Using external OpenCode server at ${label} (skip-start mode)`); - openCodeBaseUrl = ENV_CONFIGURED_OPENCODE_HOST?.origin ?? null; - setOpenCodePort(ENV_EFFECTIVE_PORT); - isOpenCodeReady = true; - isExternalOpenCode = true; - lastOpenCodeError = null; - openCodeNotReadySince = 0; - syncToHmrState(); - } else if (ENV_EFFECTIVE_PORT && await probeExternalOpenCode(ENV_EFFECTIVE_PORT, ENV_CONFIGURED_OPENCODE_HOST?.origin)) { - const label = ENV_CONFIGURED_OPENCODE_HOST ? ENV_CONFIGURED_OPENCODE_HOST.origin : `http://localhost:${ENV_EFFECTIVE_PORT}`; - console.log(`Auto-detected existing OpenCode server at ${label}`); - openCodeBaseUrl = ENV_CONFIGURED_OPENCODE_HOST?.origin ?? null; - setOpenCodePort(ENV_EFFECTIVE_PORT); - isOpenCodeReady = true; - isExternalOpenCode = true; - lastOpenCodeError = null; - openCodeNotReadySince = 0; - syncToHmrState(); - } else if (!ENV_EFFECTIVE_PORT && await probeExternalOpenCode(4096)) { - console.log('Auto-detected existing OpenCode server on default port 4096'); - setOpenCodePort(4096); - isOpenCodeReady = true; - isExternalOpenCode = true; - lastOpenCodeError = null; - openCodeNotReadySince = 0; - syncToHmrState(); - } else { - if (ENV_EFFECTIVE_PORT) { - console.log(`Using OpenCode port from environment: ${ENV_EFFECTIVE_PORT}`); - setOpenCodePort(ENV_EFFECTIVE_PORT); - } else { - openCodePort = null; - syncToHmrState(); - } - - lastOpenCodeError = null; - openCodeProcess = await startOpenCode(); - syncToHmrState(); - } - await waitForOpenCodePort(); - try { - await waitForOpenCodeReady(); - } catch (error) { - console.error(`OpenCode readiness check failed: ${error.message}`); - scheduleOpenCodeApiDetection(); - } - setupProxy(app); - scheduleOpenCodeApiDetection(); - startHealthMonitoring(); - void startGlobalEventWatcher(); - } catch (error) { - console.error(`Failed to start OpenCode: ${error.message}`); - console.log('Continuing without OpenCode integration...'); - lastOpenCodeError = error.message; - setupProxy(app); - scheduleOpenCodeApiDetection(); - } + setupProxy(app); + scheduleOpenCodeApiDetection(); + void bootstrapOpenCodeAtStartup(); const distPath = (() => { const env = typeof process.env.OPENCHAMBER_DIST_DIR === 'string' ? process.env.OPENCHAMBER_DIST_DIR.trim() : '';