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"), + }, + }, }, })