diff --git a/packages/electron-app/electron.vite.config.ts b/packages/electron-app/electron.vite.config.ts
index 612ef2cf..a8550e0b 100644
--- a/packages/electron-app/electron.vite.config.ts
+++ b/packages/electron-app/electron.vite.config.ts
@@ -6,6 +6,7 @@ const uiRoot = resolve(__dirname, "../ui")
const uiSrc = resolve(uiRoot, "src")
const uiRendererRoot = resolve(uiRoot, "src/renderer")
const uiRendererEntry = resolve(uiRendererRoot, "index.html")
+const uiRendererLoadingEntry = resolve(uiRendererRoot, "loading.html")
export default defineConfig({
main: {
@@ -54,7 +55,10 @@ export default defineConfig({
build: {
outDir: resolve(__dirname, "dist/renderer"),
rollupOptions: {
- input: uiRendererEntry,
+ input: {
+ main: uiRendererEntry,
+ loading: uiRendererLoadingEntry,
+ },
},
},
},
diff --git a/packages/electron-app/electron/main/main.ts b/packages/electron-app/electron/main/main.ts
index 0f12de87..47163616 100644
--- a/packages/electron-app/electron/main/main.ts
+++ b/packages/electron-app/electron/main/main.ts
@@ -30,22 +30,63 @@ function getIconPath() {
return join(mainDirname, "../resources/icon.png")
}
-function getLoadingHtmlPath() {
+type LoadingTarget =
+ | { type: "url"; source: string }
+ | { type: "file"; source: string }
+
+function resolveDevLoadingUrl(): string | null {
if (app.isPackaged) {
- return join(process.resourcesPath, "loading.html")
+ return null
+ }
+ const devBase = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL
+ if (!devBase) {
+ return null
}
- const distResources = join(mainDirname, "../resources/loading.html")
- if (existsSync(distResources)) {
- return distResources
+ try {
+ const normalized = devBase.endsWith("/") ? devBase : `${devBase}/`
+ return new URL("loading.html", normalized).toString()
+ } catch (error) {
+ console.warn("[cli] failed to construct dev loading URL", devBase, error)
+ return null
+ }
+}
+
+function resolveLoadingTarget(): LoadingTarget {
+ const devUrl = resolveDevLoadingUrl()
+ if (devUrl) {
+ return { type: "url", source: devUrl }
+ }
+ const filePath = resolveLoadingFilePath()
+ return { type: "file", source: filePath }
+}
+
+function resolveLoadingFilePath() {
+ const candidates = [
+ join(app.getAppPath(), "dist/renderer/loading.html"),
+ join(process.resourcesPath, "dist/renderer/loading.html"),
+ join(mainDirname, "../dist/renderer/loading.html"),
+ ]
+
+ for (const candidate of candidates) {
+ if (existsSync(candidate)) {
+ return candidate
+ }
}
- const devResources = join(mainDirname, "../electron/resources/loading.html")
- if (existsSync(devResources)) {
- return devResources
- }
+ return join(app.getAppPath(), "dist/renderer/loading.html")
+}
- return join(process.cwd(), "electron/resources/loading.html")
+function loadLoadingScreen(window: BrowserWindow) {
+ const target = resolveLoadingTarget()
+ const loader =
+ target.type === "url"
+ ? window.loadURL(target.source)
+ : window.loadFile(target.source)
+
+ loader.catch((error) => {
+ console.error("[cli] failed to load loading screen:", error)
+ })
}
let cachedPreloadPath: string | null = null
@@ -116,10 +157,9 @@ function createWindow() {
mainWindow.webContents.session.setSpellCheckerEnabled(false)
}
- const loadingHtml = getLoadingHtmlPath()
showingLoadingScreen = true
currentCliUrl = null
- mainWindow.loadFile(loadingHtml).catch((error) => console.error("[cli] failed to load loading screen:", error))
+ loadLoadingScreen(mainWindow)
if (process.env.NODE_ENV === "development") {
mainWindow.webContents.openDevTools({ mode: "detach" })
@@ -156,8 +196,7 @@ function showLoadingScreen(force = false) {
showingLoadingScreen = true
currentCliUrl = null
pendingCliUrl = null
- const loadingHtml = getLoadingHtmlPath()
- mainWindow.loadFile(loadingHtml).catch((error) => console.error("[cli] failed to load loading screen:", error))
+ loadLoadingScreen(mainWindow)
}
function startCliPreload(url: string) {
diff --git a/packages/electron-app/electron/resources/loading.html b/packages/electron-app/electron/resources/loading.html
deleted file mode 100644
index a8582a87..00000000
--- a/packages/electron-app/electron/resources/loading.html
+++ /dev/null
@@ -1,206 +0,0 @@
-
-
-
-
-
- CodeNomad
-
-
-
-
-

-
-
CodeNomad
-
-
-
-
-
Warming up the AI neurons…
-
-
-
-
-
-
-
-
-
diff --git a/packages/tauri-app/scripts/prebuild.js b/packages/tauri-app/scripts/prebuild.js
index 17f4453f..fadc6133 100644
--- a/packages/tauri-app/scripts/prebuild.js
+++ b/packages/tauri-app/scripts/prebuild.js
@@ -1,45 +1,89 @@
#!/usr/bin/env node
-const fs = require("fs");
-const path = require("path");
-const { execSync } = require("child_process");
+const fs = require("fs")
+const path = require("path")
+const { execSync } = require("child_process")
-const root = path.resolve(__dirname, "..");
-const workspaceRoot = path.resolve(root, "..", "..");
-const serverRoot = path.resolve(root, "..", "server");
-const dest = path.resolve(root, "src-tauri", "resources", "server");
+const root = path.resolve(__dirname, "..")
+const workspaceRoot = path.resolve(root, "..", "..")
+const serverRoot = path.resolve(root, "..", "server")
+const uiRoot = path.resolve(root, "..", "ui")
+const uiDist = path.resolve(uiRoot, "src", "renderer", "dist")
+const serverDest = path.resolve(root, "src-tauri", "resources", "server")
+const uiLoadingDest = path.resolve(root, "src-tauri", "resources", "ui-loading")
-const sources = ["dist", "public", "node_modules", "package.json"];
+const sources = ["dist", "public", "node_modules", "package.json"]
function ensureServerBuild() {
- const distPath = path.join(serverRoot, "dist");
- const publicPath = path.join(serverRoot, "public");
+ const distPath = path.join(serverRoot, "dist")
+ const publicPath = path.join(serverRoot, "public")
if (fs.existsSync(distPath) && fs.existsSync(publicPath)) {
- return;
+ return
}
- console.log("[prebuild] server build missing; running workspace build...");
+ console.log("[prebuild] server build missing; running workspace build...")
execSync("npm --workspace @neuralnomads/codenomad run build", {
cwd: workspaceRoot,
stdio: "inherit",
- });
+ })
if (!fs.existsSync(distPath) || !fs.existsSync(publicPath)) {
- throw new Error("[prebuild] server artifacts still missing after build");
+ throw new Error("[prebuild] server artifacts still missing after build")
}
}
-ensureServerBuild();
-
-fs.rmSync(dest, { recursive: true, force: true });
-fs.mkdirSync(dest, { recursive: true });
-
-for (const name of sources) {
- const from = path.join(serverRoot, name);
- const to = path.join(dest, name);
- if (!fs.existsSync(from)) {
- console.warn(`[prebuild] skipped missing ${from}`);
- continue;
+function ensureUiBuild() {
+ const loadingHtml = path.join(uiDist, "loading.html")
+ if (fs.existsSync(loadingHtml)) {
+ return
+ }
+
+ console.log("[prebuild] ui build missing; running workspace build...")
+ execSync("npm --workspace @codenomad/ui run build", {
+ cwd: workspaceRoot,
+ stdio: "inherit",
+ })
+
+ if (!fs.existsSync(loadingHtml)) {
+ throw new Error("[prebuild] ui loading assets missing after build")
}
- fs.cpSync(from, to, { recursive: true });
- console.log(`[prebuild] copied ${from} -> ${to}`);
}
+
+function copyServerArtifacts() {
+ fs.rmSync(serverDest, { recursive: true, force: true })
+ fs.mkdirSync(serverDest, { recursive: true })
+
+ for (const name of sources) {
+ const from = path.join(serverRoot, name)
+ const to = path.join(serverDest, name)
+ if (!fs.existsSync(from)) {
+ console.warn(`[prebuild] skipped missing ${from}`)
+ continue
+ }
+ fs.cpSync(from, to, { recursive: true })
+ console.log(`[prebuild] copied ${from} -> ${to}`)
+ }
+}
+
+function copyUiLoadingAssets() {
+ const loadingSource = path.join(uiDist, "loading.html")
+ const assetsSource = path.join(uiDist, "assets")
+
+ if (!fs.existsSync(loadingSource)) {
+ throw new Error("[prebuild] cannot find built loading.html")
+ }
+
+ fs.rmSync(uiLoadingDest, { recursive: true, force: true })
+ fs.mkdirSync(uiLoadingDest, { recursive: true })
+
+ fs.copyFileSync(loadingSource, path.join(uiLoadingDest, "loading.html"))
+ if (fs.existsSync(assetsSource)) {
+ fs.cpSync(assetsSource, path.join(uiLoadingDest, "assets"), { recursive: true })
+ }
+
+ console.log(`[prebuild] prepared UI loading assets from ${uiDist}`)
+}
+
+ensureServerBuild()
+ensureUiBuild()
+copyServerArtifacts()
+copyUiLoadingAssets()
diff --git a/packages/tauri-app/src-tauri/tauri.conf.json b/packages/tauri-app/src-tauri/tauri.conf.json
index 178f3570..faa954bd 100644
--- a/packages/tauri-app/src-tauri/tauri.conf.json
+++ b/packages/tauri-app/src-tauri/tauri.conf.json
@@ -4,9 +4,9 @@
"version": "0.1.0",
"identifier": "ai.opencode.client",
"build": {
- "beforeDevCommand": "",
+ "beforeDevCommand": "npm run prebuild",
"beforeBuildCommand": "npm run bundle:server",
- "frontendDist": "../src"
+ "frontendDist": "resources/ui-loading"
},
"app": {
"withGlobalTauri": true,
@@ -14,7 +14,7 @@
{
"label": "main",
"title": "CodeNomad",
- "url": "index.html",
+ "url": "loading.html",
"width": 1400,
"height": 900,
"minWidth": 800,
@@ -34,9 +34,8 @@
"bundle": {
"active": true,
"resources": [
- "../src/index.html",
- "../src/icon.png",
- "resources/server"
+ "resources/server",
+ "resources/ui-loading"
],
"icon": ["icon.icns", "icon.ico", "icon.png"],
"targets": ["app", "appimage", "deb", "rpm", "nsis"]
diff --git a/packages/tauri-app/src/icon.png b/packages/tauri-app/src/icon.png
deleted file mode 100644
index 4c08915d..00000000
Binary files a/packages/tauri-app/src/icon.png and /dev/null differ
diff --git a/packages/tauri-app/src/index.html b/packages/tauri-app/src/index.html
deleted file mode 100644
index 1707683d..00000000
--- a/packages/tauri-app/src/index.html
+++ /dev/null
@@ -1,197 +0,0 @@
-
-
-
-
-
- CodeNomad
-
-
-
-
-

-
-
CodeNomad
-
-
-
-
-
Warming up the AI neurons…
-
-
-
-
-
-
-
-
-
-
diff --git a/packages/ui/src/renderer/loading.html b/packages/ui/src/renderer/loading.html
new file mode 100644
index 00000000..19b3eef6
--- /dev/null
+++ b/packages/ui/src/renderer/loading.html
@@ -0,0 +1,26 @@
+
+
+
+
+
+ CodeNomad
+
+
+
+
+
+
+
diff --git a/packages/ui/src/renderer/loading/loading.css b/packages/ui/src/renderer/loading/loading.css
new file mode 100644
index 00000000..532e1543
--- /dev/null
+++ b/packages/ui/src/renderer/loading/loading.css
@@ -0,0 +1,111 @@
+:root {
+ color-scheme: dark;
+}
+
+body {
+ margin: 0;
+ min-height: 100vh;
+ background-color: var(--surface-base, #0f141f);
+ color: var(--text-primary, #cfd4dc);
+ font-family: var(--font-family-sans, "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 32px;
+}
+
+button {
+ border: none;
+ background: none;
+ font: inherit;
+ color: inherit;
+}
+
+.loading-wrapper {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 24px;
+ max-width: 520px;
+ width: 100%;
+ text-align: center;
+}
+
+.loading-logo {
+ width: 180px;
+ height: auto;
+ filter: drop-shadow(0 20px 60px rgba(0, 0, 0, 0.45));
+}
+
+.loading-heading {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.loading-title {
+ font-size: 2.8rem;
+ font-weight: 600;
+ margin: 0;
+ color: var(--text-primary, #f4f6fb);
+}
+
+.loading-status {
+ margin: 0;
+ font-size: 1rem;
+ color: var(--text-muted, #aeb3c4);
+}
+
+.loading-card {
+ margin-top: 12px;
+ width: 100%;
+ max-width: 420px;
+ padding: 22px;
+ border-radius: 18px;
+ background: rgba(13, 16, 24, 0.85);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ box-shadow: 0 25px 60px rgba(0, 0, 0, 0.55);
+}
+
+.loading-row {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 14px;
+ font-size: 0.95rem;
+}
+
+.spinner {
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ border: 2px solid rgba(255, 255, 255, 0.18);
+ border-top-color: #6ce3ff;
+ animation: spin 0.9s linear infinite;
+}
+
+.phrase-controls {
+ margin-top: 12px;
+ font-size: 0.9rem;
+ color: var(--text-muted, #8f96a9);
+}
+
+.phrase-controls button {
+ color: #8fb5ff;
+ cursor: pointer;
+}
+
+.loading-error {
+ margin-top: 12px;
+ color: #ff9ea9;
+ font-size: 0.95rem;
+}
+
+@keyframes spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
diff --git a/packages/ui/src/renderer/loading/main.tsx b/packages/ui/src/renderer/loading/main.tsx
new file mode 100644
index 00000000..65c0c581
--- /dev/null
+++ b/packages/ui/src/renderer/loading/main.tsx
@@ -0,0 +1,160 @@
+import { createSignal, onCleanup, onMount } from "solid-js"
+import { render } from "solid-js/web"
+import iconUrl from "../../images/CodeNomad-Icon.png"
+import "../../index.css"
+import "./loading.css"
+
+const phrases = [
+ "Warming up the AI neurons…",
+ "Convincing the AI to stop daydreaming…",
+ "Polishing the AI’s code goggles…",
+ "Asking the AI to stop reorganizing your files…",
+ "Feeding the AI additional coffee…",
+ "Teaching the AI not to delete node_modules (again)…",
+ "Telling the AI to act natural before you arrive…",
+ "Asking the AI to please stop rewriting history…",
+ "Letting the AI stretch before its coding sprint…",
+ "Persuading the AI to give you keyboard control…",
+]
+
+interface CliStatus {
+ state?: string
+ url?: string | null
+ error?: string | null
+}
+
+interface TauriBridge {
+ invoke?: (cmd: string, args?: Record) => Promise
+ event?: {
+ listen: (event: string, handler: (payload: { payload: unknown }) => void) => Promise<() => void>
+ }
+}
+
+declare global {
+ interface Window {
+ __TAURI__?: TauriBridge
+ }
+}
+
+function pickPhrase(previous?: string) {
+ const filtered = phrases.filter((phrase) => phrase !== previous)
+ const source = filtered.length > 0 ? filtered : phrases
+ const index = Math.floor(Math.random() * source.length)
+ return source[index]
+}
+
+function navigateTo(url?: string | null) {
+ if (!url) return
+ window.location.replace(url)
+}
+
+function getTauriBridge(): TauriBridge | null {
+ if (typeof window === "undefined") {
+ return null
+ }
+ const bridge = (window as any).__TAURI__ as TauriBridge | undefined
+ if (!bridge || !bridge.event || !bridge.invoke) {
+ return null
+ }
+ return bridge
+}
+
+function LoadingApp() {
+ const [phrase, setPhrase] = createSignal(pickPhrase())
+ const [error, setError] = createSignal(null)
+ const [status, setStatus] = createSignal("Starting services…")
+
+ const changePhrase = () => setPhrase(pickPhrase(phrase()))
+
+ onMount(() => {
+ setPhrase(pickPhrase())
+ const tauriBridge = getTauriBridge()
+ const unsubscribers: Array<() => void> = []
+
+ async function bootstrapTauri() {
+ if (!tauriBridge || !tauriBridge.event || !tauriBridge.invoke) {
+ return
+ }
+ try {
+ const readyUnlisten = await tauriBridge.event.listen("cli:ready", (event) => {
+ const payload = (event?.payload as CliStatus) || {}
+ setError(null)
+ setStatus("Launching CodeNomad…")
+ navigateTo(payload.url)
+ })
+ const errorUnlisten = await tauriBridge.event.listen("cli:error", (event) => {
+ const payload = (event?.payload as CliStatus) || {}
+ if (payload.error) {
+ setError(payload.error)
+ setStatus("Encountered an issue")
+ }
+ })
+ const statusUnlisten = await tauriBridge.event.listen("cli:status", (event) => {
+ const payload = (event?.payload as CliStatus) || {}
+ if (payload.state && payload.state !== "ready") {
+ setStatus(payload.state === "starting" ? "Starting services…" : "Preparing CodeNomad…")
+ setError(null)
+ }
+ if (payload.state === "error" && payload.error) {
+ setError(payload.error)
+ setStatus("Encountered an issue")
+ }
+ })
+ unsubscribers.push(readyUnlisten, errorUnlisten, statusUnlisten)
+
+ const result = await tauriBridge.invoke("cli_get_status")
+ if (result?.state === "ready" && result.url) {
+ navigateTo(result.url)
+ } else if (result?.state === "error" && result.error) {
+ setError(result.error)
+ setStatus("Encountered an issue")
+ }
+ } catch (err) {
+ setError(String(err))
+ setStatus("Encountered an issue")
+ }
+ }
+
+ void bootstrapTauri()
+
+ onCleanup(() => {
+ unsubscribers.forEach((unsubscribe) => {
+ try {
+ unsubscribe()
+ } catch {
+ /* noop */
+ }
+ })
+ })
+ })
+
+ return (
+
+

+
+
CodeNomad
+
{status()}
+
+
+
+
+
+
+ {error() &&
{error()}
}
+
+
+ )
+}
+
+const root = document.getElementById("loading-root")
+
+if (!root) {
+ throw new Error("Loading root element not found")
+}
+
+render(() => , root)
diff --git a/packages/ui/vite.config.ts b/packages/ui/vite.config.ts
index 7653f414..b0a441bd 100644
--- a/packages/ui/vite.config.ts
+++ b/packages/ui/vite.config.ts
@@ -24,5 +24,11 @@ export default defineConfig({
},
build: {
outDir: "dist",
+ rollupOptions: {
+ input: {
+ main: resolve(__dirname, "./src/renderer/index.html"),
+ loading: resolve(__dirname, "./src/renderer/loading.html"),
+ },
+ },
},
})