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…' }) => (
-
-
-
+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 (