perf: speed up desktop startup and unify theme-aware branding (#596)
* fix: unify startup logo and loading theme behavior Show desktop window immediately with animated splash logo Align splash/logo colors with selected app theme and default themes Keep auth loading state on full-screen logo without size jump Make project SVG icons follow active app theme Use the active theme foreground color for project icons discovered from favicons Apply server-side SVG color overrides for currentColor icons via icon request params Keep non-SVG project icons unchanged while preserving existing fallback behavior * perf: speed up desktop startup and unify loading logo visuals Desktop startup now shows UI sooner while backend boot continues in background Startup host probing uses a faster local path with safer remote fallback retries OpenChamber logo cube highlights now match splash screens consistently * fix: keep macOS traffic-light buttons in the correct position on load Stop native window title updates on macOS during app initialization Prevent title bar relayout that reset custom traffic-light positioning * fix: recover missing providers and agents after fast startup Retries provider/agent loading when connection is up but core config is still empty Prevents cold-start state where models/agents appear only after manual project switch Keeps startup responsive with throttled background recovery in app bootstrap
This commit is contained in:
committed by
GitHub
parent
037a70f114
commit
d1a41000ca
@@ -32,7 +32,7 @@
|
|||||||
"@ibm/plex": "^6.4.1",
|
"@ibm/plex": "^6.4.1",
|
||||||
"@lezer/highlight": "^1.2.3",
|
"@lezer/highlight": "^1.2.3",
|
||||||
"@octokit/rest": "^22.0.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-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@openchamber/desktop",
|
"name": "@openchamber/desktop",
|
||||||
"version": "1.8.1",
|
"version": "1.8.4",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
"@types/node": "^24.3.1",
|
"@types/node": "^24.3.1",
|
||||||
@@ -107,7 +107,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@openchamber/ui",
|
"name": "@openchamber/ui",
|
||||||
"version": "1.8.1",
|
"version": "1.8.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/autocomplete": "^6.20.0",
|
"@codemirror/autocomplete": "^6.20.0",
|
||||||
"@codemirror/commands": "^6.10.1",
|
"@codemirror/commands": "^6.10.1",
|
||||||
@@ -137,7 +137,7 @@
|
|||||||
"@fontsource/ibm-plex-sans": "^5.1.1",
|
"@fontsource/ibm-plex-sans": "^5.1.1",
|
||||||
"@ibm/plex": "^6.4.1",
|
"@ibm/plex": "^6.4.1",
|
||||||
"@lezer/highlight": "^1.2.3",
|
"@lezer/highlight": "^1.2.3",
|
||||||
"@opencode-ai/sdk": "^1.2.15",
|
"@opencode-ai/sdk": "^1.2.17",
|
||||||
"@pierre/diffs": "1.1.0-beta.13",
|
"@pierre/diffs": "1.1.0-beta.13",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
@@ -211,10 +211,10 @@
|
|||||||
},
|
},
|
||||||
"packages/vscode": {
|
"packages/vscode": {
|
||||||
"name": "openchamber",
|
"name": "openchamber",
|
||||||
"version": "1.8.1",
|
"version": "1.8.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@openchamber/ui": "workspace:*",
|
"@openchamber/ui": "workspace:*",
|
||||||
"@opencode-ai/sdk": "^1.2.15",
|
"@opencode-ai/sdk": "^1.2.17",
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
"jsonc-parser": "^3.3.1",
|
"jsonc-parser": "^3.3.1",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
@@ -234,7 +234,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@openchamber/web",
|
"name": "@openchamber/web",
|
||||||
"version": "1.8.1",
|
"version": "1.8.4",
|
||||||
"bin": {
|
"bin": {
|
||||||
"openchamber": "./bin/cli.js",
|
"openchamber": "./bin/cli.js",
|
||||||
},
|
},
|
||||||
@@ -245,7 +245,7 @@
|
|||||||
"@fontsource/ibm-plex-sans": "^5.1.1",
|
"@fontsource/ibm-plex-sans": "^5.1.1",
|
||||||
"@ibm/plex": "^6.4.1",
|
"@ibm/plex": "^6.4.1",
|
||||||
"@octokit/rest": "^22.0.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-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
@@ -836,7 +836,7 @@
|
|||||||
|
|
||||||
"@openchamber/web": ["@openchamber/web@workspace:packages/web"],
|
"@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=="],
|
"@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=="],
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -87,7 +87,7 @@
|
|||||||
"@ibm/plex": "^6.4.1",
|
"@ibm/plex": "^6.4.1",
|
||||||
"@lezer/highlight": "^1.2.3",
|
"@lezer/highlight": "^1.2.3",
|
||||||
"@octokit/rest": "^22.0.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-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
|||||||
@@ -1,11 +1,123 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en" class="h-full">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>OpenChamber</title>
|
<title>OpenChamber</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--splash-background-dark: #151313;
|
||||||
|
--splash-stroke-dark: #CECDC3;
|
||||||
|
--splash-background-light: #FFFCF0;
|
||||||
|
--splash-stroke-light: #100F0F;
|
||||||
|
|
||||||
|
--splash-background: var(--splash-background-dark);
|
||||||
|
--splash-stroke: var(--splash-stroke-dark);
|
||||||
|
--splash-face-fill: rgba(255, 255, 255, 0.15);
|
||||||
|
--splash-cell-fill: rgba(255, 255, 255, 0.35);
|
||||||
|
--splash-logo-fill: var(--splash-stroke);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-splash-variant='light'] {
|
||||||
|
--splash-background: var(--splash-background-light);
|
||||||
|
--splash-stroke: var(--splash-stroke-light);
|
||||||
|
--splash-face-fill: rgba(0, 0, 0, 0.15);
|
||||||
|
--splash-cell-fill: rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-splash-variant='dark'] {
|
||||||
|
--splash-background: var(--splash-background-dark);
|
||||||
|
--splash-stroke: var(--splash-stroke-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
@supports (color: color-mix(in srgb, white 50%, transparent)) {
|
||||||
|
:root {
|
||||||
|
--splash-face-fill: color-mix(in srgb, var(--splash-stroke) 15%, transparent);
|
||||||
|
--splash-cell-fill: color-mix(in srgb, var(--splash-stroke) 35%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root {
|
||||||
|
--splash-background: var(--splash-background-light);
|
||||||
|
--splash-stroke: var(--splash-stroke-light);
|
||||||
|
--splash-face-fill: rgba(0, 0, 0, 0.15);
|
||||||
|
--splash-cell-fill: rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--splash-background);
|
||||||
|
color: var(--splash-stroke);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logo-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-pulse {
|
||||||
|
animation: logo-pulse 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<noscript>OpenChamber requires JavaScript.</noscript>
|
<noscript>OpenChamber requires JavaScript.</noscript>
|
||||||
|
<svg width="120" height="120" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="OpenChamber loading icon">
|
||||||
|
<path d="M50 50 L8.432 26 L8.432 74 L50 98 Z" fill="var(--splash-face-fill)" stroke="var(--splash-stroke)" stroke-width="2" stroke-linejoin="round"/>
|
||||||
|
<path d="M50 50 L39.608 44 L39.608 56 L50 62 Z" fill="var(--splash-cell-fill)" opacity="0.2"/>
|
||||||
|
<path d="M39.608 44 L29.216 38 L29.216 50 L39.608 56 Z" fill="var(--splash-cell-fill)" opacity="0.45"/>
|
||||||
|
<path d="M29.216 38 L18.824 32 L18.824 44 L29.216 50 Z" fill="var(--splash-cell-fill)" opacity="0.15"/>
|
||||||
|
<path d="M18.824 32 L8.432 26 L8.432 38 L18.824 44 Z" fill="var(--splash-cell-fill)" opacity="0.55"/>
|
||||||
|
<path d="M50 62 L39.608 56 L39.608 68 L50 74 Z" fill="var(--splash-cell-fill)" opacity="0.35"/>
|
||||||
|
<path d="M39.608 56 L29.216 50 L29.216 62 L39.608 68 Z" fill="var(--splash-cell-fill)" opacity="0.1"/>
|
||||||
|
<path d="M29.216 50 L18.824 44 L18.824 56 L29.216 62 Z" fill="var(--splash-cell-fill)" opacity="0.5"/>
|
||||||
|
<path d="M18.824 44 L8.432 38 L8.432 50 L18.824 56 Z" fill="var(--splash-cell-fill)" opacity="0.25"/>
|
||||||
|
<path d="M50 74 L39.608 68 L39.608 80 L50 86 Z" fill="var(--splash-cell-fill)" opacity="0.4"/>
|
||||||
|
<path d="M39.608 68 L29.216 62 L29.216 74 L39.608 80 Z" fill="var(--splash-cell-fill)" opacity="0.3"/>
|
||||||
|
<path d="M29.216 62 L18.824 56 L18.824 68 L29.216 74 Z" fill="var(--splash-cell-fill)" opacity="0.45"/>
|
||||||
|
<path d="M18.824 56 L8.432 50 L8.432 62 L18.824 68 Z" fill="var(--splash-cell-fill)" opacity="0.15"/>
|
||||||
|
<path d="M50 86 L39.608 80 L39.608 92 L50 98 Z" fill="var(--splash-cell-fill)" opacity="0.55"/>
|
||||||
|
<path d="M39.608 80 L29.216 74 L29.216 86 L39.608 92 Z" fill="var(--splash-cell-fill)" opacity="0.2"/>
|
||||||
|
<path d="M29.216 74 L18.824 68 L18.824 80 L29.216 86 Z" fill="var(--splash-cell-fill)" opacity="0.35"/>
|
||||||
|
<path d="M18.824 68 L8.432 62 L8.432 74 L18.824 80 Z" fill="var(--splash-cell-fill)" opacity="0.1"/>
|
||||||
|
<path d="M50 50 L91.568 26 L91.568 74 L50 98 Z" fill="var(--splash-face-fill)" stroke="var(--splash-stroke)" stroke-width="2" stroke-linejoin="round"/>
|
||||||
|
<path d="M50 50 L60.392 44 L60.392 56 L50 62 Z" fill="var(--splash-cell-fill)" opacity="0.3"/>
|
||||||
|
<path d="M60.392 44 L70.784 38 L70.784 50 L60.392 56 Z" fill="var(--splash-cell-fill)" opacity="0.15"/>
|
||||||
|
<path d="M70.784 38 L81.176 32 L81.176 44 L70.784 50 Z" fill="var(--splash-cell-fill)" opacity="0.45"/>
|
||||||
|
<path d="M81.176 32 L91.568 26 L91.568 38 L81.176 44 Z" fill="var(--splash-cell-fill)" opacity="0.25"/>
|
||||||
|
<path d="M50 62 L60.392 56 L60.392 68 L50 74 Z" fill="var(--splash-cell-fill)" opacity="0.5"/>
|
||||||
|
<path d="M60.392 56 L70.784 50 L70.784 62 L60.392 68 Z" fill="var(--splash-cell-fill)" opacity="0.35"/>
|
||||||
|
<path d="M70.784 50 L81.176 44 L81.176 56 L70.784 62 Z" fill="var(--splash-cell-fill)" opacity="0.1"/>
|
||||||
|
<path d="M81.176 44 L91.568 38 L91.568 50 L81.176 56 Z" fill="var(--splash-cell-fill)" opacity="0.4"/>
|
||||||
|
<path d="M50 74 L60.392 68 L60.392 80 L50 86 Z" fill="var(--splash-cell-fill)" opacity="0.2"/>
|
||||||
|
<path d="M60.392 68 L70.784 62 L70.784 74 L60.392 80 Z" fill="var(--splash-cell-fill)" opacity="0.55"/>
|
||||||
|
<path d="M70.784 62 L81.176 56 L81.176 68 L70.784 74 Z" fill="var(--splash-cell-fill)" opacity="0.3"/>
|
||||||
|
<path d="M81.176 56 L91.568 50 L91.568 62 L81.176 68 Z" fill="var(--splash-cell-fill)" opacity="0.15"/>
|
||||||
|
<path d="M50 86 L60.392 80 L60.392 92 L50 98 Z" fill="var(--splash-cell-fill)" opacity="0.45"/>
|
||||||
|
<path d="M60.392 80 L70.784 74 L70.784 86 L60.392 92 Z" fill="var(--splash-cell-fill)" opacity="0.25"/>
|
||||||
|
<path d="M70.784 74 L81.176 68 L81.176 80 L70.784 86 Z" fill="var(--splash-cell-fill)" opacity="0.4"/>
|
||||||
|
<path d="M81.176 68 L91.568 62 L91.568 74 L81.176 80 Z" fill="var(--splash-cell-fill)" opacity="0.2"/>
|
||||||
|
<path d="M50 2 L8.432 26 L50 50 L91.568 26 Z" fill="none" stroke="var(--splash-stroke)" stroke-width="2" stroke-linejoin="round"/>
|
||||||
|
<g class="logo-pulse" transform="matrix(0.866, 0.5, -0.866, 0.5, 50, 26) scale(0.75)">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M-16 -20 L16 -20 L16 20 L-16 20 Z M-8 -12 L-8 12 L8 12 L8 -12 Z" fill="var(--splash-logo-fill)"/>
|
||||||
|
<path d="M-8 -4 L8 -4 L8 12 L-8 12 Z" fill="var(--splash-logo-fill)" fill-opacity="0.4"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -28,12 +28,18 @@ use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
|
|||||||
/// Global counter for generating unique window labels.
|
/// Global counter for generating unique window labels.
|
||||||
static WINDOW_COUNTER: AtomicU64 = AtomicU64::new(1);
|
static WINDOW_COUNTER: AtomicU64 = AtomicU64::new(1);
|
||||||
|
|
||||||
fn next_window_label() -> String {
|
fn next_window_label<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> String {
|
||||||
let n = WINDOW_COUNTER.fetch_add(1, Ordering::Relaxed);
|
loop {
|
||||||
if n == 1 {
|
let n = WINDOW_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||||
"main".to_string()
|
let candidate = if n == 1 {
|
||||||
} else {
|
"main".to_string()
|
||||||
format!("main-{n}")
|
} 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 SIDECAR_NOTIFY_PREFIX: &str = "[OpenChamberDesktopNotify] ";
|
||||||
const HEALTH_TIMEOUT: Duration = Duration::from_secs(20);
|
const HEALTH_TIMEOUT: Duration = Duration::from_secs(20);
|
||||||
const HEALTH_POLL_INTERVAL: Duration = Duration::from_millis(250);
|
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 DEFAULT_DESKTOP_PORT: u16 = 57123;
|
||||||
const WINDOW_STATE_DEBOUNCE_MS: u64 = 300;
|
const WINDOW_STATE_DEBOUNCE_MS: u64 = 300;
|
||||||
@@ -1511,12 +1521,11 @@ struct HostProbeResult {
|
|||||||
latency_ms: u64,
|
latency_ms: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
async fn probe_host_with_timeout(url: &str, timeout: Duration) -> Result<HostProbeResult, String> {
|
||||||
async fn desktop_host_probe(url: String) -> Result<HostProbeResult, String> {
|
let health = build_health_url(url).ok_or_else(|| "Invalid URL".to_string())?;
|
||||||
let health = build_health_url(&url).ok_or_else(|| "Invalid URL".to_string())?;
|
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.no_proxy()
|
.no_proxy()
|
||||||
.timeout(Duration::from_secs(2))
|
.timeout(timeout)
|
||||||
.build()
|
.build()
|
||||||
.map_err(|err| err.to_string())?;
|
.map_err(|err| err.to_string())?;
|
||||||
let started = std::time::Instant::now();
|
let started = std::time::Instant::now();
|
||||||
@@ -1549,6 +1558,11 @@ async fn desktop_host_probe(url: String) -> Result<HostProbeResult, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn desktop_host_probe(url: String) -> Result<HostProbeResult, String> {
|
||||||
|
probe_host_with_timeout(&url, STARTUP_REMOTE_PROBE_SOFT_TIMEOUT).await
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Serialize)]
|
#[derive(Clone, Serialize)]
|
||||||
#[serde(tag = "event", content = "data")]
|
#[serde(tag = "event", content = "data")]
|
||||||
enum UpdateProgressEvent {
|
enum UpdateProgressEvent {
|
||||||
@@ -1724,13 +1738,13 @@ fn maybe_show_sidecar_notification(app: &tauri::AppHandle, payload: SidecarNotif
|
|||||||
let _ = builder.show();
|
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() {
|
let client = match reqwest::Client::builder().no_proxy().build() {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(_) => return false,
|
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('/'));
|
let health_url = format!("{}/health", url.trim_end_matches('/'));
|
||||||
|
|
||||||
while std::time::Instant::now() < deadline {
|
while std::time::Instant::now() < deadline {
|
||||||
@@ -1739,12 +1753,16 @@ async fn wait_for_health(url: &str) -> bool {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tokio::time::sleep(HEALTH_POLL_INTERVAL).await;
|
tokio::time::sleep(poll_interval).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
false
|
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) {
|
fn kill_sidecar(app: tauri::AppHandle) {
|
||||||
let Some(state) = app.try_state::<SidecarState>() else {
|
let Some(state) = app.try_state::<SidecarState>() else {
|
||||||
return;
|
return;
|
||||||
@@ -1993,7 +2011,13 @@ async fn spawn_local_server(app: &tauri::AppHandle) -> Result<String> {
|
|||||||
*state.url.lock().expect("sidecar url mutex") = Some(url.clone());
|
*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());
|
kill_sidecar(app.clone());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -2455,7 +2479,7 @@ fn create_window(
|
|||||||
restore_geometry: bool,
|
restore_geometry: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let parsed = url::Url::parse(url).map_err(|err| anyhow!("Invalid URL: {err}"))?;
|
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);
|
let init_script = build_init_script(local_origin);
|
||||||
|
|
||||||
@@ -2521,6 +2545,170 @@ fn create_window(
|
|||||||
Ok(())
|
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::<serde_json::Value>(&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::<DesktopUiInjectionState>() {
|
||||||
|
*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).
|
/// Open a new window pointed at the default host (local or configured default).
|
||||||
///
|
///
|
||||||
/// Known multi-window limitations (acceptable for v1):
|
/// Known multi-window limitations (acceptable for v1):
|
||||||
@@ -2849,6 +3037,11 @@ fn main() {
|
|||||||
])
|
])
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
let handle = app.handle().clone();
|
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 {
|
tauri::async_runtime::spawn(async move {
|
||||||
let local_url = if cfg!(debug_assertions) {
|
let local_url = if cfg!(debug_assertions) {
|
||||||
let dev_url = "http://127.0.0.1:3901".to_string();
|
let dev_url = "http://127.0.0.1:3901".to_string();
|
||||||
@@ -2916,37 +3109,48 @@ fn main() {
|
|||||||
|
|
||||||
if initial_url != local_ui_url {
|
if initial_url != local_ui_url {
|
||||||
let failed_url = initial_url.clone();
|
let failed_url = initial_url.clone();
|
||||||
match desktop_host_probe(initial_url.clone()).await {
|
let soft_probe =
|
||||||
Ok(probe) if probe.status != "unreachable" => {}
|
probe_host_with_timeout(&initial_url, STARTUP_REMOTE_PROBE_SOFT_TIMEOUT).await;
|
||||||
Ok(_) => {
|
|
||||||
|
let remote_reachable = match soft_probe {
|
||||||
|
Ok(probe) if probe.status != "unreachable" => true,
|
||||||
|
Ok(_) | Err(_) => {
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"[desktop] startup host unreachable ({}), falling back to local ({})",
|
"[desktop] startup host slow/unreachable ({}), retrying with extended timeout",
|
||||||
initial_url,
|
initial_url
|
||||||
local_ui_url
|
|
||||||
);
|
);
|
||||||
initial_url = local_ui_url.clone();
|
|
||||||
// Cache the failure so open_new_window skips this host.
|
match probe_host_with_timeout(
|
||||||
if let Some(state) = handle.try_state::<DesktopUiInjectionState>() {
|
&initial_url,
|
||||||
state.unreachable_hosts.lock().expect("unreachable hosts mutex").insert(failed_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 ({})",
|
if !remote_reachable {
|
||||||
initial_url,
|
log::warn!(
|
||||||
err,
|
"[desktop] startup host unreachable after retries ({}), falling back to local ({})",
|
||||||
local_ui_url
|
initial_url,
|
||||||
);
|
local_ui_url
|
||||||
initial_url = local_ui_url.clone();
|
);
|
||||||
if let Some(state) = handle.try_state::<DesktopUiInjectionState>() {
|
initial_url = local_ui_url.clone();
|
||||||
state.unreachable_hosts.lock().expect("unreachable hosts mutex").insert(failed_url);
|
if let Some(state) = handle.try_state::<DesktopUiInjectionState>() {
|
||||||
}
|
state
|
||||||
|
.unreachable_hosts
|
||||||
|
.lock()
|
||||||
|
.expect("unreachable hosts mutex")
|
||||||
|
.insert(failed_url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(err) = create_window(&handle, &initial_url, &local_origin, true) {
|
if let Err(err) = activate_main_window(&handle, &initial_url, &local_origin) {
|
||||||
log::error!("[desktop] failed to create window: {err}");
|
log::error!("[desktop] failed to activate main window: {err}");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
"@fontsource/ibm-plex-sans": "^5.1.1",
|
"@fontsource/ibm-plex-sans": "^5.1.1",
|
||||||
"@ibm/plex": "^6.4.1",
|
"@ibm/plex": "^6.4.1",
|
||||||
"@lezer/highlight": "^1.2.3",
|
"@lezer/highlight": "^1.2.3",
|
||||||
"@opencode-ai/sdk": "^1.2.15",
|
"@opencode-ai/sdk": "^1.2.17",
|
||||||
"@pierre/diffs": "1.1.0-beta.13",
|
"@pierre/diffs": "1.1.0-beta.13",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
|||||||
@@ -95,6 +95,10 @@ const readEmbeddedSessionChatConfig = (): EmbeddedSessionChatConfig | null => {
|
|||||||
|
|
||||||
function App({ apis }: AppProps) {
|
function App({ apis }: AppProps) {
|
||||||
const { initializeApp, isInitialized, isConnected } = useConfigStore();
|
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 { error, clearError, loadSessions } = useSessionStore();
|
||||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
||||||
const setCurrentSession = useSessionStore((state) => state.setCurrentSession);
|
const setCurrentSession = useSessionStore((state) => state.setCurrentSession);
|
||||||
@@ -194,6 +198,49 @@ function App({ apis }: AppProps) {
|
|||||||
init();
|
init();
|
||||||
}, [initializeApp, isVSCodeRuntime]);
|
}, [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(() => {
|
React.useEffect(() => {
|
||||||
if (isSwitchingDirectory) {
|
if (isSwitchingDirectory) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
|
|||||||
import { syncDesktopSettings, initializeAppearancePreferences } from '@/lib/persistence';
|
import { syncDesktopSettings, initializeAppearancePreferences } from '@/lib/persistence';
|
||||||
import { applyPersistedDirectoryPreferences } from '@/lib/directoryPersistence';
|
import { applyPersistedDirectoryPreferences } from '@/lib/directoryPersistence';
|
||||||
import { DesktopHostSwitcherInline } from '@/components/desktop/DesktopHostSwitcher';
|
import { DesktopHostSwitcherInline } from '@/components/desktop/DesktopHostSwitcher';
|
||||||
|
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
|
||||||
|
|
||||||
const STATUS_CHECK_ENDPOINT = '/auth/session';
|
const STATUS_CHECK_ENDPOINT = '/auth/session';
|
||||||
|
|
||||||
@@ -61,12 +62,10 @@ const AuthShell: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const LoadingScreen: React.FC<{ message?: string }> = ({ message = 'Preparing workspace…' }) => (
|
const LoadingScreen: React.FC = () => (
|
||||||
<AuthShell>
|
<div className="flex min-h-screen items-center justify-center bg-background text-foreground">
|
||||||
<div className="w-full max-w-sm rounded-3xl border border-border/40 bg-card/90 px-6 py-5 text-center shadow-none backdrop-blur">
|
<OpenChamberLogo width={120} height={120} isAnimated />
|
||||||
<p className="typography-ui-label text-muted-foreground">{message}</p>
|
</div>
|
||||||
</div>
|
|
||||||
</AuthShell>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const ErrorScreen: React.FC<ErrorScreenProps> = ({ onRetry, errorType = 'network', retryAfter }) => {
|
const ErrorScreen: React.FC<ErrorScreenProps> = ({ onRetry, errorType = 'network', retryAfter }) => {
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog';
|
import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog';
|
||||||
import { useDrawerSwipe } from '@/hooks/useDrawerSwipe';
|
import { useDrawerSwipe } from '@/hooks/useDrawerSwipe';
|
||||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||||
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
|
|
||||||
interface MobileSessionStatusBarProps {
|
interface MobileSessionStatusBarProps {
|
||||||
onSessionSwitch?: (sessionId: string) => void;
|
onSessionSwitch?: (sessionId: string) => void;
|
||||||
@@ -606,6 +607,7 @@ function SortableProjectItem({
|
|||||||
onDelete,
|
onDelete,
|
||||||
formatProjectLabel,
|
formatProjectLabel,
|
||||||
}: SortableProjectItemProps) {
|
}: SortableProjectItemProps) {
|
||||||
|
const { currentTheme } = useThemeSystem();
|
||||||
const {
|
const {
|
||||||
attributes,
|
attributes,
|
||||||
listeners,
|
listeners,
|
||||||
@@ -623,7 +625,12 @@ function SortableProjectItem({
|
|||||||
|
|
||||||
const [imageFailed, setImageFailed] = React.useState(false);
|
const [imageFailed, setImageFailed] = React.useState(false);
|
||||||
const ProjectIcon = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
|
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;
|
const projectColorVar = project.color ? (PROJECT_COLOR_MAP[project.color] ?? null) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -852,9 +859,15 @@ function ProjectButton({
|
|||||||
onOpenEditPanel,
|
onOpenEditPanel,
|
||||||
formatProjectLabel,
|
formatProjectLabel,
|
||||||
}: ProjectButtonProps) {
|
}: ProjectButtonProps) {
|
||||||
|
const { currentTheme } = useThemeSystem();
|
||||||
const [imageFailed, setImageFailed] = React.useState(false);
|
const [imageFailed, setImageFailed] = React.useState(false);
|
||||||
const ProjectIcon = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
|
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(() => {
|
React.useEffect(() => {
|
||||||
setImageFailed(false);
|
setImageFailed(false);
|
||||||
@@ -1415,6 +1428,7 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
|
|||||||
onSessionSwitch,
|
onSessionSwitch,
|
||||||
cornerRadius,
|
cornerRadius,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { currentTheme } = useThemeSystem();
|
||||||
const sessions = useSessionStore((state) => state.sessions);
|
const sessions = useSessionStore((state) => state.sessions);
|
||||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
||||||
const sessionStatus = useSessionStore((state) => state.sessionStatus);
|
const sessionStatus = useSessionStore((state) => state.sessionStatus);
|
||||||
@@ -1454,7 +1468,12 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
|
|||||||
const activeProject = getActiveProject();
|
const activeProject = getActiveProject();
|
||||||
const currentProjectLabel = activeProject?.label || formatDirectoryName(activeProject?.path || '', homeDirectory);
|
const currentProjectLabel = activeProject?.label || formatDirectoryName(activeProject?.path || '', homeDirectory);
|
||||||
const currentProjectIcon = activeProject?.icon;
|
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 currentProjectIconBackground = activeProject?.iconBackground ?? null;
|
||||||
const currentProjectColor = activeProject?.color;
|
const currentProjectColor = activeProject?.color;
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell, requestDirect
|
|||||||
import { useLongPress } from '@/hooks/useLongPress';
|
import { useLongPress } from '@/hooks/useLongPress';
|
||||||
import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts';
|
import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts';
|
||||||
import { sessionEvents } from '@/lib/sessionEvents';
|
import { sessionEvents } from '@/lib/sessionEvents';
|
||||||
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
import type { ProjectEntry } from '@/lib/api/types';
|
import type { ProjectEntry } from '@/lib/api/types';
|
||||||
|
|
||||||
const normalize = (value: string): string => {
|
const normalize = (value: string): string => {
|
||||||
@@ -257,10 +258,16 @@ const ProjectTile: React.FC<{
|
|||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}> = ({ project, isActive, hasStreaming, hasUnread, label, expanded, projectTextVisible, onClick, onEdit, onClose }) => {
|
}> = ({ project, isActive, hasStreaming, hasUnread, label, expanded, projectTextVisible, onClick, onEdit, onClose }) => {
|
||||||
|
const { currentTheme } = useThemeSystem();
|
||||||
const [menuOpen, setMenuOpen] = React.useState(false);
|
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||||
const [iconImageFailed, setIconImageFailed] = React.useState(false);
|
const [iconImageFailed, setIconImageFailed] = React.useState(false);
|
||||||
const ProjectIcon = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
|
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 projectColorVar = project.color ? (PROJECT_COLOR_MAP[project.color] ?? null) : null;
|
||||||
const showStreamingDots = hasStreaming;
|
const showStreamingDots = hasStreaming;
|
||||||
const showAttentionDots = !hasStreaming && hasUnread;
|
const showAttentionDots = !hasStreaming && hasUnread;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { toast } from '@/components/ui';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { PROJECT_ICONS, PROJECT_COLORS, PROJECT_COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
|
import { PROJECT_ICONS, PROJECT_COLORS, PROJECT_COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
|
||||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
|
|
||||||
interface ProjectEditDialogProps {
|
interface ProjectEditDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -53,6 +54,7 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
|
|||||||
const removeProjectIcon = useProjectsStore((state) => state.removeProjectIcon);
|
const removeProjectIcon = useProjectsStore((state) => state.removeProjectIcon);
|
||||||
const discoverProjectIcon = useProjectsStore((state) => state.discoverProjectIcon);
|
const discoverProjectIcon = useProjectsStore((state) => state.discoverProjectIcon);
|
||||||
const currentIconImage = useProjectsStore((state) => state.projects.find((project) => project.id === projectId)?.iconImage ?? null);
|
const currentIconImage = useProjectsStore((state) => state.projects.find((project) => project.id === projectId)?.iconImage ?? null);
|
||||||
|
const { currentTheme } = useThemeSystem();
|
||||||
const [name, setName] = React.useState(projectName);
|
const [name, setName] = React.useState(projectName);
|
||||||
const [icon, setIcon] = React.useState<string | null>(initialIcon);
|
const [icon, setIcon] = React.useState<string | null>(initialIcon);
|
||||||
const [color, setColor] = React.useState<string | null>(initialColor);
|
const [color, setColor] = React.useState<string | null>(initialColor);
|
||||||
@@ -145,7 +147,13 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
|
|||||||
? (hasPendingUploadImageIcon
|
? (hasPendingUploadImageIcon
|
||||||
? pendingUploadIconPreviewUrl
|
? pendingUploadIconPreviewUrl
|
||||||
: (hasStoredImageIcon && !pendingRemoveImageIcon
|
: (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))
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { PROJECT_COLORS, PROJECT_ICONS, PROJECT_COLOR_MAP as COLOR_MAP, getProje
|
|||||||
import { RiCloseLine } from '@remixicon/react';
|
import { RiCloseLine } from '@remixicon/react';
|
||||||
import { WorktreeSectionContent } from '@/components/sections/openchamber/WorktreeSectionContent';
|
import { WorktreeSectionContent } from '@/components/sections/openchamber/WorktreeSectionContent';
|
||||||
import { ProjectActionsSection } from '@/components/sections/projects/ProjectActionsSection';
|
import { ProjectActionsSection } from '@/components/sections/projects/ProjectActionsSection';
|
||||||
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
|
|
||||||
export const ProjectsPage: React.FC = () => {
|
export const ProjectsPage: React.FC = () => {
|
||||||
const projects = useProjectsStore((state) => state.projects);
|
const projects = useProjectsStore((state) => state.projects);
|
||||||
@@ -19,6 +20,7 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
const discoverProjectIcon = useProjectsStore((state) => state.discoverProjectIcon);
|
const discoverProjectIcon = useProjectsStore((state) => state.discoverProjectIcon);
|
||||||
const selectedId = useUIStore((state) => state.settingsProjectsSelectedId);
|
const selectedId = useUIStore((state) => state.settingsProjectsSelectedId);
|
||||||
const setSelectedId = useUIStore((state) => state.setSettingsProjectsSelectedId);
|
const setSelectedId = useUIStore((state) => state.setSettingsProjectsSelectedId);
|
||||||
|
const { currentTheme } = useThemeSystem();
|
||||||
|
|
||||||
const selectedProject = React.useMemo(() => {
|
const selectedProject = React.useMemo(() => {
|
||||||
if (!selectedId) return null;
|
if (!selectedId) return null;
|
||||||
@@ -159,7 +161,10 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
? (hasPendingUploadImageIcon
|
? (hasPendingUploadImageIcon
|
||||||
? pendingUploadIconPreviewUrl
|
? pendingUploadIconPreviewUrl
|
||||||
: (selectedProject && hasStoredImageIcon && !pendingRemoveImageIcon
|
: (selectedProject && hasStoredImageIcon && !pendingRemoveImageIcon
|
||||||
? getProjectIconImageUrl(selectedProject)
|
? getProjectIconImageUrl(selectedProject, {
|
||||||
|
themeVariant: currentTheme.metadata.variant,
|
||||||
|
iconColor: currentTheme.colors.surface.foreground,
|
||||||
|
})
|
||||||
: null))
|
: null))
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ import { RiAddLine, RiFolderLine } from '@remixicon/react';
|
|||||||
import { isDesktopLocalOriginActive, isTauriShell, isVSCodeRuntime, requestDirectoryAccess } from '@/lib/desktop';
|
import { isDesktopLocalOriginActive, isTauriShell, isVSCodeRuntime, requestDirectoryAccess } from '@/lib/desktop';
|
||||||
import { sessionEvents } from '@/lib/sessionEvents';
|
import { sessionEvents } from '@/lib/sessionEvents';
|
||||||
import { toast } from '@/components/ui';
|
import { toast } from '@/components/ui';
|
||||||
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
|
|
||||||
export const ProjectsSidebar: React.FC<{ onItemSelect?: () => void }> = ({ onItemSelect }) => {
|
export const ProjectsSidebar: React.FC<{ onItemSelect?: () => void }> = ({ onItemSelect }) => {
|
||||||
const projects = useProjectsStore((state) => state.projects);
|
const projects = useProjectsStore((state) => state.projects);
|
||||||
const addProject = useProjectsStore((state) => state.addProject);
|
const addProject = useProjectsStore((state) => state.addProject);
|
||||||
const selectedId = useUIStore((state) => state.settingsProjectsSelectedId);
|
const selectedId = useUIStore((state) => state.settingsProjectsSelectedId);
|
||||||
const setSelectedId = useUIStore((state) => state.setSettingsProjectsSelectedId);
|
const setSelectedId = useUIStore((state) => state.setSettingsProjectsSelectedId);
|
||||||
|
const { currentTheme } = useThemeSystem();
|
||||||
const [brokenIconIds, setBrokenIconIds] = React.useState<Set<string>>(new Set());
|
const [brokenIconIds, setBrokenIconIds] = React.useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
||||||
@@ -91,7 +93,12 @@ export const ProjectsSidebar: React.FC<{ onItemSelect?: () => void }> = ({ onIte
|
|||||||
const selected = project.id === selectedId;
|
const selected = project.id === selectedId;
|
||||||
const Icon = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
|
const Icon = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
|
||||||
const imageFailureKey = `${project.id}:${project.iconImage?.updatedAt ?? 0}`;
|
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 color = project.color ? (PROJECT_COLOR_MAP[project.color] ?? null) : null;
|
||||||
const icon = imageUrl
|
const icon = imageUrl
|
||||||
? (
|
? (
|
||||||
|
|||||||
@@ -1,6 +1,20 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
|
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 {
|
interface OpenChamberLogoProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
width?: number;
|
width?: number;
|
||||||
@@ -79,6 +93,9 @@ export const OpenChamberLogo: React.FC<OpenChamberLogoProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const strokeColor = useMemo(() => {
|
const strokeColor = useMemo(() => {
|
||||||
|
if (themeContext) {
|
||||||
|
return themeContext.currentTheme.colors.surface.foreground;
|
||||||
|
}
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
const fromVars = getComputedStyle(document.documentElement).getPropertyValue('--splash-stroke').trim();
|
const fromVars = getComputedStyle(document.documentElement).getPropertyValue('--splash-stroke').trim();
|
||||||
if (fromVars) {
|
if (fromVars) {
|
||||||
@@ -86,9 +103,22 @@ export const OpenChamberLogo: React.FC<OpenChamberLogoProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return isDark ? 'white' : 'black';
|
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(() => {
|
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') {
|
if (typeof window !== 'undefined') {
|
||||||
const fromVars = getComputedStyle(document.documentElement).getPropertyValue('--splash-face-fill').trim();
|
const fromVars = getComputedStyle(document.documentElement).getPropertyValue('--splash-face-fill').trim();
|
||||||
if (fromVars) {
|
if (fromVars) {
|
||||||
@@ -96,9 +126,15 @@ export const OpenChamberLogo: React.FC<OpenChamberLogoProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return isDark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.15)';
|
return isDark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.15)';
|
||||||
}, [isDark]);
|
}, [themeContext, supportsColorMix, strokeColor, isDark]);
|
||||||
|
|
||||||
const cellHighlightColor = useMemo(() => {
|
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') {
|
if (typeof window !== 'undefined') {
|
||||||
const fromVars = getComputedStyle(document.documentElement).getPropertyValue('--splash-cell-fill').trim();
|
const fromVars = getComputedStyle(document.documentElement).getPropertyValue('--splash-cell-fill').trim();
|
||||||
if (fromVars) {
|
if (fromVars) {
|
||||||
@@ -106,7 +142,7 @@ export const OpenChamberLogo: React.FC<OpenChamberLogoProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return isDark ? 'rgba(255,255,255,0.35)' : 'rgba(0,0,0,0.4)';
|
return isDark ? 'rgba(255,255,255,0.35)' : 'rgba(0,0,0,0.4)';
|
||||||
}, [isDark]);
|
}, [themeContext, supportsColorMix, strokeColor, isDark]);
|
||||||
|
|
||||||
const logoFillColor = strokeColor;
|
const logoFillColor = strokeColor;
|
||||||
|
|
||||||
@@ -145,15 +181,6 @@ export const OpenChamberLogo: React.FC<OpenChamberLogoProps> = ({
|
|||||||
// Right face: center -> right -> bottomRight -> bottom
|
// Right face: center -> right -> bottomRight -> bottom
|
||||||
const rightFaceCells = generateFaceGrid(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 (
|
return (
|
||||||
<svg
|
<svg
|
||||||
width={width}
|
width={width}
|
||||||
@@ -180,7 +207,7 @@ export const OpenChamberLogo: React.FC<OpenChamberLogoProps> = ({
|
|||||||
key={`left-${i}`}
|
key={`left-${i}`}
|
||||||
d={cell.path}
|
d={cell.path}
|
||||||
fill={cellHighlightColor}
|
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<OpenChamberLogoProps> = ({
|
|||||||
key={`right-${i}`}
|
key={`right-${i}`}
|
||||||
d={cell.path}
|
d={cell.path}
|
||||||
fill={cellHighlightColor}
|
fill={cellHighlightColor}
|
||||||
opacity={cellOpacities[i + 16]}
|
opacity={RIGHT_FACE_CELL_OPACITIES[cell.row * 4 + cell.col] ?? 0.35}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|||||||
@@ -557,14 +557,21 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
|||||||
}, [applyIncomingThemeSync]);
|
}, [applyIncomingThemeSync]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const lightTheme = ensureThemeById(preferences.lightThemeId, 'light');
|
||||||
|
const darkTheme = ensureThemeById(preferences.darkThemeId, 'dark');
|
||||||
|
|
||||||
void updateDesktopSettings({
|
void updateDesktopSettings({
|
||||||
themeId: currentTheme.metadata.id,
|
themeId: currentTheme.metadata.id,
|
||||||
themeVariant: currentTheme.metadata.variant === 'light' ? 'light' : 'dark',
|
themeVariant: currentTheme.metadata.variant === 'light' ? 'light' : 'dark',
|
||||||
useSystemTheme: preferences.themeMode === 'system',
|
useSystemTheme: preferences.themeMode === 'system',
|
||||||
lightThemeId: preferences.lightThemeId,
|
lightThemeId: preferences.lightThemeId,
|
||||||
darkThemeId: preferences.darkThemeId,
|
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(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
|
|||||||
@@ -113,6 +113,12 @@ export const useWindowTitle = () => {
|
|||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isMac = typeof navigator !== 'undefined' && /Macintosh|Mac OS X/.test(navigator.userAgent || '');
|
||||||
|
if (isMac) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const currentWindow = getCurrentWindow();
|
const currentWindow = getCurrentWindow();
|
||||||
await currentWindow.setTitle(title);
|
await currentWindow.setTitle(title);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ export type DesktopSettings = {
|
|||||||
themeVariant?: 'light' | 'dark';
|
themeVariant?: 'light' | 'dark';
|
||||||
lightThemeId?: string;
|
lightThemeId?: string;
|
||||||
darkThemeId?: string;
|
darkThemeId?: string;
|
||||||
|
splashBgLight?: string;
|
||||||
|
splashFgLight?: string;
|
||||||
|
splashBgDark?: string;
|
||||||
|
splashFgDark?: string;
|
||||||
lastDirectory?: string;
|
lastDirectory?: string;
|
||||||
homeDirectory?: string;
|
homeDirectory?: string;
|
||||||
// Optional absolute path to `opencode` binary.
|
// Optional absolute path to `opencode` binary.
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import {
|
|||||||
} from '@remixicon/react';
|
} from '@remixicon/react';
|
||||||
import type { ProjectEntry } from '@/lib/api/types';
|
import type { ProjectEntry } from '@/lib/api/types';
|
||||||
|
|
||||||
|
type ThemeVariant = 'light' | 'dark';
|
||||||
|
|
||||||
export const PROJECT_ICONS: Array<{ key: string; Icon: RemixiconComponentType; label: string }> = [
|
export const PROJECT_ICONS: Array<{ key: string; Icon: RemixiconComponentType; label: string }> = [
|
||||||
{ key: 'code', Icon: RiCodeBoxLine, label: 'Code' },
|
{ key: 'code', Icon: RiCodeBoxLine, label: 'Code' },
|
||||||
{ key: 'terminal', Icon: RiTerminalBoxLine, label: 'Terminal' },
|
{ key: 'terminal', Icon: RiTerminalBoxLine, label: 'Terminal' },
|
||||||
@@ -64,10 +66,21 @@ export const PROJECT_COLOR_MAP: Record<string, string> = Object.fromEntries(
|
|||||||
PROJECT_COLORS.map((c) => [c.key, c.cssVar])
|
PROJECT_COLORS.map((c) => [c.key, c.cssVar])
|
||||||
);
|
);
|
||||||
|
|
||||||
export const getProjectIconImageUrl = (project: Pick<ProjectEntry, 'id' | 'iconImage'>): string | null => {
|
export const getProjectIconImageUrl = (
|
||||||
|
project: Pick<ProjectEntry, 'id' | 'iconImage'>,
|
||||||
|
options?: { themeVariant?: ThemeVariant; iconColor?: string },
|
||||||
|
): string | null => {
|
||||||
if (!project.iconImage || typeof project.iconImage.updatedAt !== 'number' || project.iconImage.updatedAt <= 0) {
|
if (!project.iconImage || typeof project.iconImage.updatedAt !== 'number' || project.iconImage.updatedAt <= 0) {
|
||||||
return null;
|
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()}`;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -229,7 +229,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@openchamber/ui": "workspace:*",
|
"@openchamber/ui": "workspace:*",
|
||||||
"@opencode-ai/sdk": "^1.2.15",
|
"@opencode-ai/sdk": "^1.2.17",
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
"jsonc-parser": "^3.3.1",
|
"jsonc-parser": "^3.3.1",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
|
|||||||
+11
-11
@@ -299,7 +299,7 @@
|
|||||||
var variant = localStorage.getItem('selectedThemeVariant');
|
var variant = localStorage.getItem('selectedThemeVariant');
|
||||||
var useSystem = localStorage.getItem('useSystemTheme');
|
var useSystem = localStorage.getItem('useSystemTheme');
|
||||||
var isDark;
|
var isDark;
|
||||||
|
|
||||||
// Check themeMode first (new storage key)
|
// Check themeMode first (new storage key)
|
||||||
if (themeMode === 'dark') {
|
if (themeMode === 'dark') {
|
||||||
isDark = true;
|
isDark = true;
|
||||||
@@ -315,7 +315,7 @@
|
|||||||
// Default to system
|
// Default to system
|
||||||
isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply theme class and data attribute
|
// Apply theme class and data attribute
|
||||||
document.documentElement.classList.add(isDark ? 'dark' : 'light');
|
document.documentElement.classList.add(isDark ? 'dark' : 'light');
|
||||||
document.documentElement.setAttribute('data-splash-variant', isDark ? 'dark' : 'light');
|
document.documentElement.setAttribute('data-splash-variant', isDark ? 'dark' : 'light');
|
||||||
@@ -380,9 +380,9 @@
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
--splash-background-dark: #151313;
|
--splash-background-dark: #151313;
|
||||||
--splash-stroke-dark: white;
|
--splash-stroke-dark: #CECDC3;
|
||||||
--splash-background-light: #F6F4EF;
|
--splash-background-light: #FFFCF0;
|
||||||
--splash-stroke-light: black;
|
--splash-stroke-light: #100F0F;
|
||||||
|
|
||||||
--splash-background: var(--splash-background-dark);
|
--splash-background: var(--splash-background-dark);
|
||||||
--splash-stroke: var(--splash-stroke-dark);
|
--splash-stroke: var(--splash-stroke-dark);
|
||||||
@@ -447,10 +447,10 @@
|
|||||||
<svg width="120" height="120" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="OpenChamber loading icon">
|
<svg width="120" height="120" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="OpenChamber loading icon">
|
||||||
<!-- Isometric cube: edge=48, centerY=50 -->
|
<!-- Isometric cube: edge=48, centerY=50 -->
|
||||||
<!-- Points: top(50,2), left(8.432,26), right(91.568,26), center(50,50), bottomLeft(8.432,74), bottomRight(91.568,74), bottom(50,98) -->
|
<!-- Points: top(50,2), left(8.432,26), right(91.568,26), center(50,50), bottomLeft(8.432,74), bottomRight(91.568,74), bottom(50,98) -->
|
||||||
|
|
||||||
<!-- Left face - base fill with stroke -->
|
<!-- Left face - base fill with stroke -->
|
||||||
<path d="M50 50 L8.432 26 L8.432 74 L50 98 Z" fill="var(--splash-face-fill)" stroke="var(--splash-stroke)" stroke-width="2" stroke-linejoin="round"/>
|
<path d="M50 50 L8.432 26 L8.432 74 L50 98 Z" fill="var(--splash-face-fill)" stroke="var(--splash-stroke)" stroke-width="2" stroke-linejoin="round"/>
|
||||||
|
|
||||||
<!-- Left face grid cells (4x4) with varying opacity -->
|
<!-- Left face grid cells (4x4) with varying opacity -->
|
||||||
<path d="M50 50 L39.608 44 L39.608 56 L50 62 Z" fill="var(--splash-cell-fill)" opacity="0.2"/>
|
<path d="M50 50 L39.608 44 L39.608 56 L50 62 Z" fill="var(--splash-cell-fill)" opacity="0.2"/>
|
||||||
<path d="M39.608 44 L29.216 38 L29.216 50 L39.608 56 Z" fill="var(--splash-cell-fill)" opacity="0.45"/>
|
<path d="M39.608 44 L29.216 38 L29.216 50 L39.608 56 Z" fill="var(--splash-cell-fill)" opacity="0.45"/>
|
||||||
@@ -468,10 +468,10 @@
|
|||||||
<path d="M39.608 80 L29.216 74 L29.216 86 L39.608 92 Z" fill="var(--splash-cell-fill)" opacity="0.2"/>
|
<path d="M39.608 80 L29.216 74 L29.216 86 L39.608 92 Z" fill="var(--splash-cell-fill)" opacity="0.2"/>
|
||||||
<path d="M29.216 74 L18.824 68 L18.824 80 L29.216 86 Z" fill="var(--splash-cell-fill)" opacity="0.35"/>
|
<path d="M29.216 74 L18.824 68 L18.824 80 L29.216 86 Z" fill="var(--splash-cell-fill)" opacity="0.35"/>
|
||||||
<path d="M18.824 68 L8.432 62 L8.432 74 L18.824 80 Z" fill="var(--splash-cell-fill)" opacity="0.1"/>
|
<path d="M18.824 68 L8.432 62 L8.432 74 L18.824 80 Z" fill="var(--splash-cell-fill)" opacity="0.1"/>
|
||||||
|
|
||||||
<!-- Right face - base fill with stroke -->
|
<!-- Right face - base fill with stroke -->
|
||||||
<path d="M50 50 L91.568 26 L91.568 74 L50 98 Z" fill="var(--splash-face-fill)" stroke="var(--splash-stroke)" stroke-width="2" stroke-linejoin="round"/>
|
<path d="M50 50 L91.568 26 L91.568 74 L50 98 Z" fill="var(--splash-face-fill)" stroke="var(--splash-stroke)" stroke-width="2" stroke-linejoin="round"/>
|
||||||
|
|
||||||
<!-- Right face grid cells (4x4) with varying opacity -->
|
<!-- Right face grid cells (4x4) with varying opacity -->
|
||||||
<path d="M50 50 L60.392 44 L60.392 56 L50 62 Z" fill="var(--splash-cell-fill)" opacity="0.3"/>
|
<path d="M50 50 L60.392 44 L60.392 56 L50 62 Z" fill="var(--splash-cell-fill)" opacity="0.3"/>
|
||||||
<path d="M60.392 44 L70.784 38 L70.784 50 L60.392 56 Z" fill="var(--splash-cell-fill)" opacity="0.15"/>
|
<path d="M60.392 44 L70.784 38 L70.784 50 L60.392 56 Z" fill="var(--splash-cell-fill)" opacity="0.15"/>
|
||||||
@@ -489,10 +489,10 @@
|
|||||||
<path d="M60.392 80 L70.784 74 L70.784 86 L60.392 92 Z" fill="var(--splash-cell-fill)" opacity="0.25"/>
|
<path d="M60.392 80 L70.784 74 L70.784 86 L60.392 92 Z" fill="var(--splash-cell-fill)" opacity="0.25"/>
|
||||||
<path d="M70.784 74 L81.176 68 L81.176 80 L70.784 86 Z" fill="var(--splash-cell-fill)" opacity="0.4"/>
|
<path d="M70.784 74 L81.176 68 L81.176 80 L70.784 86 Z" fill="var(--splash-cell-fill)" opacity="0.4"/>
|
||||||
<path d="M81.176 68 L91.568 62 L91.568 74 L81.176 80 Z" fill="var(--splash-cell-fill)" opacity="0.2"/>
|
<path d="M81.176 68 L91.568 62 L91.568 74 L81.176 80 Z" fill="var(--splash-cell-fill)" opacity="0.2"/>
|
||||||
|
|
||||||
<!-- Top face - open (no fill), only stroke -->
|
<!-- Top face - open (no fill), only stroke -->
|
||||||
<path d="M50 2 L8.432 26 L50 50 L91.568 26 Z" fill="none" stroke="var(--splash-stroke)" stroke-width="2" stroke-linejoin="round"/>
|
<path d="M50 2 L8.432 26 L50 50 L91.568 26 Z" fill="none" stroke="var(--splash-stroke)" stroke-width="2" stroke-linejoin="round"/>
|
||||||
|
|
||||||
<!-- OpenCode logo on top face -->
|
<!-- OpenCode logo on top face -->
|
||||||
<g class="logo-pulse" transform="matrix(0.866, 0.5, -0.866, 0.5, 50, 26) scale(0.75)">
|
<g class="logo-pulse" transform="matrix(0.866, 0.5, -0.866, 0.5, 50, 26) scale(0.75)">
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M-16 -20 L16 -20 L16 20 L-16 20 Z M-8 -12 L-8 12 L8 12 L8 -12 Z" fill="var(--splash-logo-fill)"/>
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M-16 -20 L16 -20 L16 20 L-16 20 Z M-8 -12 L-8 12 L8 12 L8 -12 Z" fill="var(--splash-logo-fill)"/>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
"@fontsource/ibm-plex-sans": "^5.1.1",
|
"@fontsource/ibm-plex-sans": "^5.1.1",
|
||||||
"@ibm/plex": "^6.4.1",
|
"@ibm/plex": "^6.4.1",
|
||||||
"@octokit/rest": "^22.0.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-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
|||||||
@@ -1,15 +1,26 @@
|
|||||||
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color: #111111;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
color: #f5f5f5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
<!-- OpenChamber logo - simplified for favicon -->
|
<!-- OpenChamber logo - simplified for favicon -->
|
||||||
<g transform="translate(16, 16) scale(0.48)">
|
<g transform="translate(16, 16) scale(0.48)">
|
||||||
<!-- Left face - with fill -->
|
<!-- Left face - with fill -->
|
||||||
<path d="M0 0 L-26 -15 L-26 15 L0 30 Z" fill="white" fill-opacity="0.25" stroke="white" stroke-width="4" stroke-linejoin="round"/>
|
<path d="M0 0 L-26 -15 L-26 15 L0 30 Z" fill="currentColor" fill-opacity="0.25" stroke="currentColor" stroke-width="4" stroke-linejoin="round"/>
|
||||||
<!-- Right face - with fill -->
|
<!-- Right face - with fill -->
|
||||||
<path d="M0 0 L26 -15 L26 15 L0 30 Z" fill="white" fill-opacity="0.4" stroke="white" stroke-width="4" stroke-linejoin="round"/>
|
<path d="M0 0 L26 -15 L26 15 L0 30 Z" fill="currentColor" fill-opacity="0.4" stroke="currentColor" stroke-width="4" stroke-linejoin="round"/>
|
||||||
<!-- Top face - open -->
|
<!-- Top face - open -->
|
||||||
<path d="M0 -30 L-26 -15 L0 0 L26 -15 Z" fill="none" stroke="white" stroke-width="4" stroke-linejoin="round"/>
|
<path d="M0 -30 L-26 -15 L0 0 L26 -15 Z" fill="none" stroke="currentColor" stroke-width="4" stroke-linejoin="round"/>
|
||||||
<!-- O logo - hollow rectangle frame on top face with gray fill inside -->
|
<!-- O logo - hollow rectangle frame on top face with gray fill inside -->
|
||||||
<g transform="matrix(0.866, 0.5, -0.866, 0.5, 0, -15) scale(0.55)">
|
<g transform="matrix(0.866, 0.5, -0.866, 0.5, 0, -15) scale(0.55)">
|
||||||
<rect x="-12" y="-14" width="24" height="28" fill="white" fill-opacity="0.4" stroke="white" stroke-width="8"/>
|
<rect x="-12" y="-14" width="24" height="28" fill="currentColor" fill-opacity="0.4" stroke="currentColor" stroke-width="8"/>
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 958 B After Width: | Height: | Size: 1.1 KiB |
+190
-93
@@ -784,6 +784,36 @@ const resolveZenModel = async (override) => {
|
|||||||
return validatedZenFallback || ZEN_DEFAULT_MODEL;
|
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) => {
|
const summarizeText = async (text, targetLength, zenModel) => {
|
||||||
if (!text || typeof text !== 'string' || text.trim().length === 0) return text;
|
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_SUPPORTED_MIMES = new Set(Object.keys(PROJECT_ICON_MIME_TO_EXTENSION));
|
||||||
const PROJECT_ICON_MAX_BYTES = 5 * 1024 * 1024;
|
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) => {
|
const normalizeProjectIconMime = (value) => {
|
||||||
if (typeof value !== 'string') {
|
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(/<svg\b/i);
|
||||||
|
if (svgTagIndex === -1) {
|
||||||
|
return svgMarkup;
|
||||||
|
}
|
||||||
|
|
||||||
|
const svgOpenTagEndIndex = svgMarkup.indexOf('>', svgTagIndex);
|
||||||
|
if (svgOpenTagEndIndex === -1) {
|
||||||
|
return svgMarkup;
|
||||||
|
}
|
||||||
|
|
||||||
|
const overrideStyle = `<style data-openchamber-theme-icon="1">:root{color:${color}!important;}</style>`;
|
||||||
|
return `${svgMarkup.slice(0, svgOpenTagEndIndex + 1)}${overrideStyle}${svgMarkup.slice(svgOpenTagEndIndex + 1)}`;
|
||||||
|
};
|
||||||
|
|
||||||
const findProjectById = (settings, projectId) => {
|
const findProjectById = (settings, projectId) => {
|
||||||
const projects = sanitizeProjects(settings?.projects) || [];
|
const projects = sanitizeProjects(settings?.projects) || [];
|
||||||
const index = projects.findIndex((project) => project.id === projectId);
|
const index = projects.findIndex((project) => project.id === projectId);
|
||||||
@@ -1795,6 +1878,18 @@ const sanitizeSettingsUpdate = (payload) => {
|
|||||||
if (typeof candidate.darkThemeId === 'string' && candidate.darkThemeId.length > 0) {
|
if (typeof candidate.darkThemeId === 'string' && candidate.darkThemeId.length > 0) {
|
||||||
result.darkThemeId = candidate.darkThemeId;
|
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) {
|
if (typeof candidate.lastDirectory === 'string' && candidate.lastDirectory.length > 0) {
|
||||||
result.lastDirectory = candidate.lastDirectory;
|
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) {
|
function setupProxy(app) {
|
||||||
if (app.get('opencodeProxyConfigured')) {
|
if (app.get('opencodeProxyConfigured')) {
|
||||||
return;
|
return;
|
||||||
@@ -6564,35 +6725,8 @@ async function main(options = {}) {
|
|||||||
sayTTSCapability = { available: false, voices: [], reason: 'Not macOS' };
|
sayTTSCapability = { available: false, voices: [], reason: 'Not macOS' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate stored zen model at startup – best-effort, never blocks startup
|
// Startup model validation is best-effort and runs in background.
|
||||||
try {
|
void validateZenModelAtStartup();
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const serverStartedAt = new Date().toISOString();
|
const serverStartedAt = new Date().toISOString();
|
||||||
@@ -8091,11 +8225,34 @@ async function main(options = {}) {
|
|||||||
? [preferredPath, ...projectIconPathCandidates(projectId).filter((candidate) => candidate !== preferredPath)]
|
? [preferredPath, ...projectIconPathCandidates(projectId).filter((candidate) => candidate !== preferredPath)]
|
||||||
: projectIconPathCandidates(projectId);
|
: 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) {
|
for (const iconPath of candidates) {
|
||||||
try {
|
try {
|
||||||
const data = await fsPromises.readFile(iconPath);
|
const data = await fsPromises.readFile(iconPath);
|
||||||
const ext = path.extname(iconPath).slice(1).toLowerCase();
|
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('Content-Type', contentType);
|
||||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||||
return res.send(data);
|
return res.send(data);
|
||||||
@@ -13006,69 +13163,9 @@ async function main(options = {}) {
|
|||||||
res.json({ success: true, killedCount });
|
res.json({ success: true, killedCount });
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
setupProxy(app);
|
||||||
syncFromHmrState();
|
scheduleOpenCodeApiDetection();
|
||||||
if (await isOpenCodeProcessHealthy()) {
|
void bootstrapOpenCodeAtStartup();
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
const distPath = (() => {
|
const distPath = (() => {
|
||||||
const env = typeof process.env.OPENCHAMBER_DIST_DIR === 'string' ? process.env.OPENCHAMBER_DIST_DIR.trim() : '';
|
const env = typeof process.env.OPENCHAMBER_DIST_DIR === 'string' ? process.env.OPENCHAMBER_DIST_DIR.trim() : '';
|
||||||
|
|||||||
Reference in New Issue
Block a user