summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorusvimal <[email protected]>2026-01-07 15:19:01 +0800
committerGitHub <[email protected]>2026-01-07 15:19:01 +0800
commita8c2928a8778eff5928d11ab52f9f8526cc2a7a1 (patch)
tree1e6ffdfeaae9b04f32a538543e11602d8f229419 /packages
parent79f6910697df9a6021b56d9c045493c0ae2f3b3d (diff)
downloadopencode-a8c2928a8778eff5928d11ab52f9f8526cc2a7a1.tar.gz
opencode-a8c2928a8778eff5928d11ab52f9f8526cc2a7a1.zip
perf: show window immediately during desktop startup (#6734)
Co-authored-by: Brendan Allan <[email protected]>
Diffstat (limited to 'packages')
-rw-r--r--packages/app/src/app.tsx132
-rw-r--r--packages/app/src/entry.tsx6
-rw-r--r--packages/app/src/index.ts2
-rw-r--r--packages/desktop/src-tauri/Cargo.lock18
-rw-r--r--packages/desktop/src-tauri/Cargo.toml1
-rw-r--r--packages/desktop/src-tauri/src/lib.rs227
-rw-r--r--packages/desktop/src/index.tsx43
7 files changed, 246 insertions, 183 deletions
diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx
index a2f1aa401..13d9d147e 100644
--- a/packages/app/src/app.tsx
+++ b/packages/app/src/app.tsx
@@ -1,5 +1,5 @@
import "@/index.css"
-import { ErrorBoundary, Show, Suspense, lazy, type ParentProps } from "solid-js"
+import { ErrorBoundary, Show, lazy, type ParentProps } from "solid-js"
import { Router, Route, Navigate } from "@solidjs/router"
import { MetaProvider } from "@solidjs/meta"
import { Font } from "@opencode-ai/ui/font"
@@ -20,10 +20,12 @@ import { FileProvider } from "@/context/file"
import { NotificationProvider } from "@/context/notification"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { CommandProvider } from "@/context/command"
+import { Logo } from "@opencode-ai/ui/logo"
import Layout from "@/pages/layout"
import DirectoryLayout from "@/pages/directory-layout"
import { ErrorPage } from "./pages/error"
import { iife } from "@opencode-ai/util/iife"
+import { Suspense } from "solid-js"
const Home = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session"))
@@ -31,7 +33,7 @@ const Loading = () => <div class="size-full flex items-center justify-center tex
declare global {
interface Window {
- __OPENCODE__?: { updaterEnabled?: boolean; port?: number }
+ __OPENCODE__?: { updaterEnabled?: boolean; port?: number; serverReady?: boolean }
}
}
@@ -47,16 +49,7 @@ const defaultServerUrl = iife(() => {
return window.location.origin
})
-function ServerKey(props: ParentProps) {
- const server = useServer()
- return (
- <Show when={server.url} keyed>
- {props.children}
- </Show>
- )
-}
-
-export function App() {
+export function AppBaseProviders(props: ParentProps) {
return (
<MetaProvider>
<Font />
@@ -65,57 +58,7 @@ export function App() {
<DialogProvider>
<MarkedProvider>
<DiffComponentProvider component={Diff}>
- <CodeComponentProvider component={Code}>
- <ServerProvider defaultUrl={defaultServerUrl}>
- <ServerKey>
- <GlobalSDKProvider>
- <GlobalSyncProvider>
- <Router
- root={(props) => (
- <PermissionProvider>
- <LayoutProvider>
- <NotificationProvider>
- <CommandProvider>
- <Layout>{props.children}</Layout>
- </CommandProvider>
- </NotificationProvider>
- </LayoutProvider>
- </PermissionProvider>
- )}
- >
- <Route
- path="/"
- component={() => (
- <Suspense fallback={<Loading />}>
- <Home />
- </Suspense>
- )}
- />
- <Route path="/:dir" component={DirectoryLayout}>
- <Route path="/" component={() => <Navigate href="session" />} />
- <Route
- path="/session/:id?"
- component={(p) => (
- <Show when={p.params.id ?? "new"} keyed>
- <TerminalProvider>
- <FileProvider>
- <PromptProvider>
- <Suspense fallback={<Loading />}>
- <Session />
- </Suspense>
- </PromptProvider>
- </FileProvider>
- </TerminalProvider>
- </Show>
- )}
- />
- </Route>
- </Router>
- </GlobalSyncProvider>
- </GlobalSDKProvider>
- </ServerKey>
- </ServerProvider>
- </CodeComponentProvider>
+ <CodeComponentProvider component={Code}>{props.children}</CodeComponentProvider>
</DiffComponentProvider>
</MarkedProvider>
</DialogProvider>
@@ -124,3 +67,66 @@ export function App() {
</MetaProvider>
)
}
+
+function ServerKey(props: ParentProps) {
+ const server = useServer()
+ return (
+ <Show when={server.url} keyed>
+ {props.children}
+ </Show>
+ )
+}
+
+export function AppInterface() {
+ return (
+ <ServerProvider defaultUrl={defaultServerUrl}>
+ <ServerKey>
+ <GlobalSDKProvider>
+ <GlobalSyncProvider>
+ <Router
+ root={(props) => (
+ <PermissionProvider>
+ <LayoutProvider>
+ <NotificationProvider>
+ <CommandProvider>
+ <Layout>{props.children}</Layout>
+ </CommandProvider>
+ </NotificationProvider>
+ </LayoutProvider>
+ </PermissionProvider>
+ )}
+ >
+ <Route
+ path="/"
+ component={() => (
+ <Suspense fallback={<Loading />}>
+ <Home />
+ </Suspense>
+ )}
+ />
+ <Route path="/:dir" component={DirectoryLayout}>
+ <Route path="/" component={() => <Navigate href="session" />} />
+ <Route
+ path="/session/:id?"
+ component={(p) => (
+ <Show when={p.params.id ?? "new"} keyed>
+ <TerminalProvider>
+ <FileProvider>
+ <PromptProvider>
+ <Suspense fallback={<Loading />}>
+ <Session />
+ </Suspense>
+ </PromptProvider>
+ </FileProvider>
+ </TerminalProvider>
+ </Show>
+ )}
+ />
+ </Route>
+ </Router>
+ </GlobalSyncProvider>
+ </GlobalSDKProvider>
+ </ServerKey>
+ </ServerProvider>
+ )
+}
diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx
index a915aa25b..28741098c 100644
--- a/packages/app/src/entry.tsx
+++ b/packages/app/src/entry.tsx
@@ -1,6 +1,6 @@
// @refresh reload
import { render } from "solid-js/web"
-import { App } from "@/app"
+import { AppBaseProviders, AppInterface } from "@/app"
import { Platform, PlatformProvider } from "@/context/platform"
import pkg from "../package.json"
@@ -55,7 +55,9 @@ const platform: Platform = {
render(
() => (
<PlatformProvider value={platform}>
- <App />
+ <AppBaseProviders>
+ <AppInterface />
+ </AppBaseProviders>
</PlatformProvider>
),
root!,
diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts
index cf5be9f51..df3181133 100644
--- a/packages/app/src/index.ts
+++ b/packages/app/src/index.ts
@@ -1,2 +1,2 @@
export { PlatformProvider, type Platform } from "./context/platform"
-export { App } from "./app"
+export { AppBaseProviders, AppInterface } from "./app"
diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock
index a35db0b3a..c533bf9e9 100644
--- a/packages/desktop/src-tauri/Cargo.lock
+++ b/packages/desktop/src-tauri/Cargo.lock
@@ -1178,12 +1178,28 @@ dependencies = [
]
[[package]]
+name = "futures"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
name = "futures-channel"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
dependencies = [
"futures-core",
+ "futures-sink",
]
[[package]]
@@ -1251,6 +1267,7 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [
+ "futures-channel",
"futures-core",
"futures-io",
"futures-macro",
@@ -2775,6 +2792,7 @@ dependencies = [
name = "opencode-desktop"
version = "0.0.0"
dependencies = [
+ "futures",
"gtk",
"listeners",
"semver",
diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml
index 63aafe280..af8adc119 100644
--- a/packages/desktop/src-tauri/Cargo.toml
+++ b/packages/desktop/src-tauri/Cargo.toml
@@ -36,6 +36,7 @@ serde_json = "1"
tokio = "1.48.0"
listeners = "0.3"
tauri-plugin-os = "2"
+futures = "0.3.31"
semver = "1.0.27"
[target.'cfg(target_os = "linux")'.dependencies]
diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs
index 5d1610fa3..c96abd9f4 100644
--- a/packages/desktop/src-tauri/src/lib.rs
+++ b/packages/desktop/src-tauri/src/lib.rs
@@ -2,6 +2,7 @@ mod cli;
mod window_customizer;
use cli::{get_sidecar_path, install_cli, sync_cli};
+use futures::FutureExt;
use std::{
collections::VecDeque,
net::{SocketAddr, TcpListener},
@@ -9,10 +10,9 @@ use std::{
time::{Duration, Instant},
};
use tauri::{
- path::BaseDirectory, AppHandle, LogicalSize, Manager, RunEvent, WebviewUrl, WebviewWindow,
+ path::BaseDirectory, AppHandle, LogicalSize, Manager, RunEvent, State, WebviewUrl,
+ WebviewWindow,
};
-use tauri_plugin_clipboard_manager::ClipboardExt;
-use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
use tauri_plugin_shell::ShellExt;
use tokio::net::TcpSocket;
@@ -20,7 +20,26 @@ use tokio::net::TcpSocket;
use crate::window_customizer::PinchZoomDisablePlugin;
#[derive(Clone)]
-struct ServerState(Arc<Mutex<Option<CommandChild>>>);
+struct ServerState {
+ child: Arc<Mutex<Option<CommandChild>>>,
+ status: futures::future::Shared<tokio::sync::oneshot::Receiver<Result<(), String>>>,
+}
+
+impl ServerState {
+ pub fn new(
+ child: Option<CommandChild>,
+ status: tokio::sync::oneshot::Receiver<Result<(), String>>,
+ ) -> Self {
+ Self {
+ child: Arc::new(Mutex::new(child)),
+ status: status.shared(),
+ }
+ }
+
+ pub fn set_child(&self, child: Option<CommandChild>) {
+ *self.child.lock().unwrap() = child;
+ }
+}
#[derive(Clone)]
struct LogState(Arc<Mutex<VecDeque<String>>>);
@@ -35,7 +54,7 @@ fn kill_sidecar(app: AppHandle) {
};
let Some(server_state) = server_state
- .0
+ .child
.lock()
.expect("Failed to acquire mutex lock")
.take()
@@ -49,8 +68,7 @@ fn kill_sidecar(app: AppHandle) {
println!("Killed server");
}
-#[tauri::command]
-async fn copy_logs_to_clipboard(app: AppHandle) -> Result<(), String> {
+async fn get_logs(app: AppHandle) -> Result<String, String> {
let log_state = app.try_state::<LogState>().ok_or("Log state not found")?;
let logs = log_state
@@ -58,25 +76,16 @@ async fn copy_logs_to_clipboard(app: AppHandle) -> Result<(), String> {
.lock()
.map_err(|_| "Failed to acquire log lock")?;
- let log_text = logs.iter().cloned().collect::<Vec<_>>().join("");
-
- app.clipboard()
- .write_text(log_text)
- .map_err(|e| format!("Failed to copy to clipboard: {}", e))?;
-
- Ok(())
+ Ok(logs.iter().cloned().collect::<Vec<_>>().join(""))
}
#[tauri::command]
-async fn get_logs(app: AppHandle) -> Result<String, String> {
- let log_state = app.try_state::<LogState>().ok_or("Log state not found")?;
-
- let logs = log_state
- .0
- .lock()
- .map_err(|_| "Failed to acquire log lock")?;
-
- Ok(logs.iter().cloned().collect::<Vec<_>>().join(""))
+async fn ensure_server_started(state: State<'_, ServerState>) -> Result<(), String> {
+ state
+ .status
+ .clone()
+ .await
+ .map_err(|_| "Failed to get server status".to_string())?
}
fn get_sidecar_port() -> u32 {
@@ -209,9 +218,8 @@ pub fn run() {
.plugin(PinchZoomDisablePlugin)
.invoke_handler(tauri::generate_handler![
kill_sidecar,
- copy_logs_to_clipboard,
- get_logs,
- install_cli
+ install_cli,
+ ensure_server_started
])
.setup(move |app| {
let app = app.handle().clone();
@@ -219,94 +227,93 @@ pub fn run() {
// Initialize log state
app.manage(LogState(Arc::new(Mutex::new(VecDeque::new()))));
+ // Get port and create window immediately for faster perceived startup
+ let port = get_sidecar_port();
+
+ let primary_monitor = app.primary_monitor().ok().flatten();
+ let size = primary_monitor
+ .map(|m| m.size().to_logical(m.scale_factor()))
+ .unwrap_or(LogicalSize::new(1920, 1080));
+
+ // Create window immediately with serverReady = false
+ let mut window_builder =
+ WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into()))
+ .title("OpenCode")
+ .inner_size(size.width as f64, size.height as f64)
+ .decorations(true)
+ .zoom_hotkeys_enabled(true)
+ .disable_drag_drop_handler()
+ .initialization_script(format!(
+ r#"
+ window.__OPENCODE__ ??= {{}};
+ window.__OPENCODE__.updaterEnabled = {updater_enabled};
+ window.__OPENCODE__.port = {port};
+ "#
+ ));
+
+ #[cfg(target_os = "macos")]
{
- let app = app.clone();
- tauri::async_runtime::spawn(async move {
- let port = get_sidecar_port();
-
- let should_spawn_sidecar = !is_server_running(port).await;
-
- let child = if should_spawn_sidecar {
- let child = spawn_sidecar(&app, port);
-
- let timestamp = Instant::now();
- loop {
- if timestamp.elapsed() > Duration::from_secs(7) {
- let res = app.dialog()
- .message("Failed to spawn OpenCode Server. Copy logs using the button below and send them to the team for assistance.")
- .title("Startup Failed")
- .buttons(MessageDialogButtons::OkCancelCustom("Copy Logs And Exit".to_string(), "Exit".to_string()))
- .blocking_show_with_result();
-
- if matches!(&res, MessageDialogResult::Custom(name) if name == "Copy Logs And Exit") {
- match copy_logs_to_clipboard(app.clone()).await {
- Ok(()) => println!("Logs copied to clipboard successfully"),
- Err(e) => println!("Failed to copy logs to clipboard: {}", e),
- }
- }
-
- app.exit(1);
-
- return;
- }
-
- tokio::time::sleep(Duration::from_millis(10)).await;
-
- if is_server_running(port).await {
- // give the server a little bit more time to warm up
- tokio::time::sleep(Duration::from_millis(10)).await;
-
- break;
- }
- }
-
- println!("Server ready after {:?}", timestamp.elapsed());
-
- Some(child)
- } else {
- None
- };
-
- let primary_monitor = app.primary_monitor().ok().flatten();
- let size = primary_monitor
- .map(|m| m.size().to_logical(m.scale_factor()))
- .unwrap_or(LogicalSize::new(1920, 1080));
-
- let mut window_builder =
- WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into()))
- .title("OpenCode")
- .inner_size(size.width as f64, size.height as f64)
- .decorations(true)
- .zoom_hotkeys_enabled(true)
- .disable_drag_drop_handler()
- .initialization_script(format!(
- r#"
- window.__OPENCODE__ ??= {{}};
- window.__OPENCODE__.updaterEnabled = {updater_enabled};
- window.__OPENCODE__.port = {port};
- "#
- ));
-
- #[cfg(target_os = "macos")]
- {
- window_builder = window_builder
- .title_bar_style(tauri::TitleBarStyle::Overlay)
- .hidden_title(true);
- }
-
- window_builder.build().expect("Failed to create window");
-
- app.manage(ServerState(Arc::new(Mutex::new(child))));
- });
+ window_builder = window_builder
+ .title_bar_style(tauri::TitleBarStyle::Overlay)
+ .hidden_title(true);
}
+ let window = window_builder.build().expect("Failed to create window");
+
+ let (tx, rx) = tokio::sync::oneshot::channel();
+ app.manage(ServerState::new(None, rx));
+
{
- let app = app.clone();
- tauri::async_runtime::spawn(async move {
- if let Err(e) = sync_cli(app) {
- eprintln!("Failed to sync CLI: {e}");
- }
- });
+ let app = app.clone();
+ tauri::async_runtime::spawn(async move {
+ let should_spawn_sidecar = !is_server_running(port).await;
+
+ let (child, res) = if should_spawn_sidecar {
+ let child = spawn_sidecar(&app, port);
+
+ let timestamp = Instant::now();
+ let res = loop {
+ if timestamp.elapsed() > Duration::from_secs(7) {
+ break Err(format!(
+ "Failed to spawn OpenCode Server. Logs:\n{}",
+ get_logs(app.clone()).await.unwrap()
+ ));
+ }
+
+ tokio::time::sleep(Duration::from_millis(10)).await;
+
+ if is_server_running(port).await {
+ // give the server a little bit more time to warm up
+ tokio::time::sleep(Duration::from_millis(10)).await;
+
+ break Ok(());
+ }
+ };
+
+ println!("Server ready after {:?}", timestamp.elapsed());
+
+ (Some(child), res)
+ } else {
+ (None, Ok(()))
+ };
+
+ app.state::<ServerState>().set_child(child);
+
+ if res.is_ok() {
+ let _ = window.eval("window.__OPENCODE__.serverReady = true;");
+ }
+
+ let _ = tx.send(res);
+ });
+ }
+
+ {
+ let app = app.clone();
+ tauri::async_runtime::spawn(async move {
+ if let Err(e) = sync_cli(app) {
+ eprintln!("Failed to sync CLI: {e}");
+ }
+ });
}
Ok(())
diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx
index aee2c8249..94cb887f5 100644
--- a/packages/desktop/src/index.tsx
+++ b/packages/desktop/src/index.tsx
@@ -1,20 +1,22 @@
// @refresh reload
import { render } from "solid-js/web"
-import { App, PlatformProvider, Platform } from "@opencode-ai/app"
+import { AppBaseProviders, AppInterface, PlatformProvider, Platform } from "@opencode-ai/app"
import { open, save } from "@tauri-apps/plugin-dialog"
import { open as shellOpen } from "@tauri-apps/plugin-shell"
import { type as ostype } from "@tauri-apps/plugin-os"
+import { check, Update } from "@tauri-apps/plugin-updater"
+import { invoke } from "@tauri-apps/api/core"
+import { getCurrentWindow } from "@tauri-apps/api/window"
+import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification"
+import { relaunch } from "@tauri-apps/plugin-process"
import { AsyncStorage } from "@solid-primitives/storage"
import { fetch as tauriFetch } from "@tauri-apps/plugin-http"
import { Store } from "@tauri-apps/plugin-store"
+import { Logo } from "@opencode-ai/ui/logo"
+import { Suspense, createResource, ParentProps } from "solid-js"
import { UPDATER_ENABLED } from "./updater"
import { createMenu } from "./menu"
-import { check, Update } from "@tauri-apps/plugin-updater"
-import { invoke } from "@tauri-apps/api/core"
-import { getCurrentWindow } from "@tauri-apps/api/window"
-import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification"
-import { relaunch } from "@tauri-apps/plugin-process"
import pkg from "../package.json"
const root = document.getElementById("root")
@@ -269,7 +271,34 @@ render(() => {
{ostype() === "macos" && (
<div class="mx-px bg-background-base border-b border-border-weak-base h-8" data-tauri-drag-region />
)}
- <App />
+ <AppBaseProviders>
+ <ServerGate>
+ <AppInterface />
+ </ServerGate>
+ </AppBaseProviders>
</PlatformProvider>
)
}, root!)
+
+// Gate component that waits for the server to be ready
+function ServerGate(props: ParentProps) {
+ const [status] = createResource(async () => {
+ if (window.__OPENCODE__?.serverReady) return
+ return await invoke("ensure_server_started")
+ })
+
+ return (
+ <Suspense
+ fallback={
+ <div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base">
+ <Logo class="w-xl opacity-12 animate-pulse" />
+ <div class="mt-8 text-14-regular text-text-weak">Starting server...</div>
+ </div>
+ }
+ >
+ {/* Triggers suspense/error boundaries without rendering the returned value */}
+ {(status(), null)}
+ <Suspense>{props.children}</Suspense>
+ </Suspense>
+ )
+}