From c461cee790c7933283612e4ca65cb6ca3098f893 Mon Sep 17 00:00:00 2001 From: Bobby Lansing Date: Tue, 21 Jul 2026 23:23:17 -0700 Subject: [PATCH 1/4] fix: persist session renames to state.db and surface failures Renames previously updated the local sessions.json cache first and swallowed state.db errors, so the UI looked successful until the next syncSessionCache restored the old Hermes title. Write the durable DB row first (honoring unique titles and the 100-char cap), only then mirror into the cache, and toast/rollback when the write fails. Co-authored-by: Cursor --- lat.md/sidebar-navigation.md | 6 + src/main/session-cache.ts | 85 +++++++--- .../screens/Layout/SidebarRecentSessions.tsx | 18 ++- .../src/screens/Sessions/Sessions.tsx | 24 ++- src/shared/i18n/locales/en/sessions.ts | 6 + tests/session-cache-sync.test.ts | 149 +++++++++++++++++- 6 files changed, 250 insertions(+), 38 deletions(-) diff --git a/lat.md/sidebar-navigation.md b/lat.md/sidebar-navigation.md index 518091eff..3dafd21f7 100644 --- a/lat.md/sidebar-navigation.md +++ b/lat.md/sidebar-navigation.md @@ -42,6 +42,12 @@ The menu is styled light-based and stroke-free (`.sidebar-session-menu`): no 1px Each action calls an existing desktop API with an optimistic local update and rollback on failure: Rename → `updateSessionTitle` (inline `.sidebar-recent-session-rename` input), Move → [[src/main/session-context-folder-store.ts#setSessionContextFolder]] then a `hermes-session-context-folder-changed` event so other surfaces re-group, Delete → a confirmation dialog (portal overlay) then [[src/main/sessions.ts#deleteSessionRows|deleteSession]]. Deleting the open chat calls `onSessionDeleted`, which [[src/renderer/src/screens/Layout/Layout.tsx#Layout]] uses to drop to a fresh New Chat. +### Rename persistence + +Session renames must survive `syncSessionCache`, so the durable `state.db` write happens before the JSON cache is updated. + +[[src/main/session-cache.ts#updateSessionTitle]] trims/collapses whitespace, enforces Hermes' 100-character cap ([[src/main/session-cache.ts#MAX_SESSION_TITLE_LENGTH]]), rejects empty titles, and checks uniqueness against Hermes' `idx_sessions_title_unique` before `UPDATE`ing. Failures throw (duplicate / not found / unavailable) instead of being swallowed — the sidebar and Sessions modal roll back their optimistic title, toast the error, and keep the inline editor open. A successful rename updates `sessions.json` only after `state.db` commits, so the next sync cannot resurrect the old name. + Pinned rows are a desktop-only affordance: their ids live in `localStorage` (`hermes.sidebar.pinnedSessions`), and pinned sessions are pulled out of the normal grouping into a collapsible **Pinned** section at the top of the list. ## Full-list modal diff --git a/src/main/session-cache.ts b/src/main/session-cache.ts index b592ec342..df64923e1 100644 --- a/src/main/session-cache.ts +++ b/src/main/session-cache.ts @@ -1,11 +1,6 @@ import { existsSync, readFileSync } from "fs"; import { join } from "path"; -import { - activeStateDbPath, - profileHome, - getActiveProfileNameSync, - safeWriteFile, -} from "./utils"; +import { profileHome, getActiveProfileNameSync, safeWriteFile } from "./utils"; import Database from "better-sqlite3"; import { t } from "../shared/i18n"; import { getAppLocale } from "./locale"; @@ -258,31 +253,71 @@ export function listCachedSessions(limit = 50, offset = 0): CachedSession[] { return cache.sessions.slice(offset, offset + limit); } -// Update title for a specific session +/** Hermes Agent caps session titles at 100 characters (schema + CLI). */ +export const MAX_SESSION_TITLE_LENGTH = 100; + +/** + * Persist a user-chosen session title to state.db, then mirror it into the + * desktop sessions.json cache. + * + * Order matters: the durable DB write must succeed before the cache is + * updated. The previous cache-first + swallow-errors approach left the UI + * looking renamed while the next syncSessionCache restored the old DB title + * (Hermes enforces UNIQUE non-NULL titles via idx_sessions_title_unique). + */ export function updateSessionTitle(sessionId: string, title: string): void { + const locale = getAppLocale(); + const trimmed = title.trim().replace(/\s+/g, " "); + if (!trimmed) { + throw new Error(t("sessions.renameInvalid", locale)); + } + if (trimmed.length > MAX_SESSION_TITLE_LENGTH) { + throw new Error( + t("sessions.renameTooLong", locale, { + max: String(MAX_SESSION_TITLE_LENGTH), + }), + ); + } + + const db = getDbConnection(false); + if (!db) { + throw new Error(t("sessions.renameUnavailable", locale)); + } + + // Match Hermes SessionDB.set_session_title: reject conflicts before write so + // we never partially update the JSON cache on a UNIQUE constraint failure. + const conflict = db + .prepare("SELECT id FROM sessions WHERE title = ? AND id != ?") + .get(trimmed, sessionId) as { id: string } | undefined; + if (conflict) { + throw new Error(t("sessions.renameDuplicate", locale, { title: trimmed })); + } + + let changes = 0; + try { + changes = db + .prepare("UPDATE sessions SET title = ? WHERE id = ?") + .run(trimmed, sessionId).changes; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (/UNIQUE|constraint/i.test(message)) { + throw new Error( + t("sessions.renameDuplicate", locale, { title: trimmed }), + ); + } + throw err instanceof Error ? err : new Error(message); + } + + if (changes === 0) { + throw new Error(t("sessions.renameNotFound", locale)); + } + const cache = readCache(); const idx = cache.sessions.findIndex((s) => s.id === sessionId); if (idx >= 0) { - cache.sessions[idx].title = title; + cache.sessions[idx].title = trimmed; writeCache(cache); } - // Also persist in state.db so the rename survives cache rebuilds - try { - const dbPath = activeStateDbPath(); - if (existsSync(dbPath)) { - const db = new Database(dbPath); - try { - db.prepare("UPDATE sessions SET title = ? WHERE id = ?").run( - title, - sessionId, - ); - } finally { - db.close(); - } - } - } catch { - // ignore DB errors — cache update above is the fast path - } } // Remove a session entry from the local cache. Called after the underlying diff --git a/src/renderer/src/screens/Layout/SidebarRecentSessions.tsx b/src/renderer/src/screens/Layout/SidebarRecentSessions.tsx index ee94105db..4e858ad87 100644 --- a/src/renderer/src/screens/Layout/SidebarRecentSessions.tsx +++ b/src/renderer/src/screens/Layout/SidebarRecentSessions.tsx @@ -8,6 +8,7 @@ import { type RefObject, } from "react"; import { createPortal } from "react-dom"; +import toast from "react-hot-toast"; import { useI18n } from "../../components/useI18n"; import { ChevronDown, @@ -516,21 +517,32 @@ const SidebarRecentSessions = memo(function SidebarRecentSessions({ return; } const previous = current?.title ?? ""; - // Optimistic local update; roll back if the write fails. + // Optimistic local update; roll back if the durable write fails so a + // subsequent sync cannot resurrect a cache-only title. setSessions((prev) => prev.map((s) => (s.id === id ? { ...s, title: trimmed } : s)), ); - if (editingIdRef.current === id) cancelRename(); try { await window.hermesAPI.updateSessionTitle(id, trimmed); + if (editingIdRef.current === id) cancelRename(); } catch (err) { console.error("Failed to rename session", id, err); setSessions((prev) => prev.map((s) => (s.id === id ? { ...s, title: previous } : s)), ); + const message = + err instanceof Error && err.message + ? err.message + : t("sessions.renameFailed"); + toast.error(message); + // Keep the inline editor open so the user can pick a unique name. + setTimeout(() => { + renameInputRef.current?.focus(); + renameInputRef.current?.select(); + }, 0); } }, - [cancelRename], + [cancelRename, t], ); const handleMoveToProject = useCallback( diff --git a/src/renderer/src/screens/Sessions/Sessions.tsx b/src/renderer/src/screens/Sessions/Sessions.tsx index f5a3c85cd..2575d16d9 100644 --- a/src/renderer/src/screens/Sessions/Sessions.tsx +++ b/src/renderer/src/screens/Sessions/Sessions.tsx @@ -1,4 +1,5 @@ import { useEffect, useState, useRef, useCallback, useMemo, memo } from "react"; +import toast from "react-hot-toast"; import { Plus, Search, X, ChatBubble, Trash, Pencil } from "../../assets/icons"; import { useI18n } from "../../components/useI18n"; @@ -417,6 +418,12 @@ function Sessions({ }); try { await window.hermesAPI.updateSessionTitle(sessionId, trimmed); + // Guard: only clear editing state if the user hasn't started editing + // a different session while this request was in flight. + if (editingSessionIdRef.current === sessionId) { + setEditingSessionId(null); + setEditingTitle(""); + } } catch (err) { console.error("Failed to rename session", sessionId, err); // Rollback optimistic update @@ -432,15 +439,18 @@ function Sessions({ : r, ), ); - } - // Guard: only clear editing state if the user hasn't started editing - // a different session while this request was in flight. - if (editingSessionIdRef.current === sessionId) { - setEditingSessionId(null); - setEditingTitle(""); + const message = + err instanceof Error && err.message + ? err.message + : t("sessions.renameFailed"); + toast.error(message); + setTimeout(() => { + renameInputRef.current?.focus(); + renameInputRef.current?.select(); + }, 0); } }, - [cancelRename], + [cancelRename, t], ); const cancelDelete = useCallback((): void => { diff --git a/src/shared/i18n/locales/en/sessions.ts b/src/shared/i18n/locales/en/sessions.ts index 1e336819e..65216babc 100644 --- a/src/shared/i18n/locales/en/sessions.ts +++ b/src/shared/i18n/locales/en/sessions.ts @@ -16,6 +16,12 @@ export default { messageSingular: "msg", delete: "Delete conversation", rename: "Rename conversation", + renameFailed: "Couldn't rename conversation", + renameInvalid: "Enter a valid name (max 100 characters)", + renameTooLong: "Title too long (max {{max}} characters)", + renameUnavailable: "Session database unavailable", + renameDuplicate: 'Title "{{title}}" is already in use', + renameNotFound: "Session not found", deleteConfirmTitle: "Delete conversation", deleteConfirm: "Delete this conversation? This cannot be undone — both the messages and the session record will be permanently removed.", diff --git a/tests/session-cache-sync.test.ts b/tests/session-cache-sync.test.ts index b05880a7c..7c02876dd 100644 --- a/tests/session-cache-sync.test.ts +++ b/tests/session-cache-sync.test.ts @@ -46,7 +46,17 @@ vi.mock("../src/main/utils", () => ({ // Stub the i18n + locale modules so the cache code doesn't need the // renderer-side translation files at test time. vi.mock("../src/shared/i18n", () => ({ - t: (key: string) => key, + t: (key: string, _lang?: string, options?: Record) => { + const messages: Record = { + "sessions.newConversation": "sessions.newConversation", + "sessions.renameInvalid": "Enter a valid name (max 100 characters)", + "sessions.renameTooLong": `Title too long (max ${options?.max ?? 100} characters)`, + "sessions.renameUnavailable": "Session database unavailable", + "sessions.renameDuplicate": `Title "${options?.title ?? ""}" is already in use`, + "sessions.renameNotFound": "Session not found", + }; + return messages[key] ?? key; + }, })); vi.mock("../src/main/locale", () => ({ getAppLocale: () => "en", @@ -143,6 +153,32 @@ vi.mock("better-sqlite3", () => { return { changes: 1 }; } + // Desktop rename path — mirrors Hermes `idx_sessions_title_unique` + // (NULLs allowed; non-NULL titles must be unique across sessions). + if ( + this.sql.includes("UPDATE sessions SET title") && + this.sql.includes("WHERE id = ?") + ) { + const [title, id] = args; + const session = this.store.sessions.get(String(id)); + if (!session) return { changes: 0 }; + const nextTitle = + title === null || title === undefined ? null : String(title); + if (nextTitle !== null) { + for (const other of this.store.sessions.values()) { + if (other.id !== session.id && other.title === nextTitle) { + const err = new Error( + "UNIQUE constraint failed: sessions.title", + ) as Error & { code: string }; + err.code = "SQLITE_CONSTRAINT_UNIQUE"; + throw err; + } + } + } + session.title = nextTitle; + return { changes: 1 }; + } + throw new Error(`Unhandled fake run SQL: ${this.sql}`); } @@ -179,7 +215,7 @@ vi.mock("better-sqlite3", () => { throw new Error(`Unhandled fake all SQL: ${this.sql}`); } - get(...args: unknown[]): { content: string } | undefined { + get(...args: unknown[]): { content: string } | { id: string } | undefined { // `tableExists` probes sqlite_master before reading context folders // (issue #27). The desktop context-folder table is never created in // these tests, so report it absent — sessions then resolve to a null @@ -201,6 +237,20 @@ vi.mock("better-sqlite3", () => { return match ? { content: match.content } : undefined; } + // Uniqueness probe used by updateSessionTitle before writing. + if ( + this.sql.includes("SELECT id FROM sessions") && + this.sql.includes("WHERE title = ?") && + this.sql.includes("AND id != ?") + ) { + const [title, sessionId] = args; + const match = Array.from(this.store.sessions.values()).find( + (session) => + session.title === String(title) && session.id !== String(sessionId), + ); + return match ? { id: match.id } : undefined; + } + throw new Error(`Unhandled fake get SQL: ${this.sql}`); } } @@ -229,7 +279,11 @@ vi.mock("better-sqlite3", () => { }); import Database from "better-sqlite3"; -import { syncSessionCache } from "../src/main/session-cache"; +import { + listCachedSessions, + syncSessionCache, + updateSessionTitle, +} from "../src/main/session-cache"; import { closeDbConnection } from "../src/main/db"; const CACHE_FILE = join(TEST_HOME, "desktop", "sessions.json"); @@ -628,3 +682,92 @@ describe("syncSessionCache", () => { expect(elapsed).toBeLessThan(500); }, 30000); }); + +describe("updateSessionTitle", () => { + // @lat: [[sidebar-navigation#Sidebar recent sessions#Row context menu#Rename persistence]] + it("persists a rename to state.db so the next sync keeps the new title", () => { + const future = Math.floor(Date.now() / 1000) + 600; + seedDb([ + { + id: "s1", + started_at: future, + message_count: 2, + title: "Old title", + firstUserMessage: "hi", + }, + ]); + syncSessionCache(); + + updateSessionTitle("s1", "Renamed chat"); + + expect(listCachedSessions(10)[0]?.title).toBe("Renamed chat"); + const afterSync = syncSessionCache(); + expect(afterSync[0]?.title).toBe("Renamed chat"); + }); + + it("rejects duplicate titles without leaving a dirty cache entry", () => { + const future = Math.floor(Date.now() / 1000) + 600; + seedDb([ + { + id: "s1", + started_at: future, + message_count: 1, + title: "Taken name", + firstUserMessage: "one", + }, + { + id: "s2", + started_at: future + 1, + message_count: 1, + title: "Other chat", + firstUserMessage: "two", + }, + ]); + syncSessionCache(); + + expect(() => updateSessionTitle("s2", "Taken name")).toThrow( + /already in use/i, + ); + + const cached = listCachedSessions(10); + expect(cached.find((s) => s.id === "s2")?.title).toBe("Other chat"); + + // Sync must not have been poisoned by a cache-only write either. + const afterSync = syncSessionCache(); + expect(afterSync.find((s) => s.id === "s2")?.title).toBe("Other chat"); + }); + + it("throws when the session id is unknown", () => { + const future = Math.floor(Date.now() / 1000) + 600; + seedDb([ + { + id: "s1", + started_at: future, + message_count: 1, + title: "Exists", + firstUserMessage: "hi", + }, + ]); + syncSessionCache(); + + expect(() => updateSessionTitle("missing", "Nope")).toThrow(/not found/i); + }); + + it("rejects empty or oversized titles before touching the DB", () => { + const future = Math.floor(Date.now() / 1000) + 600; + seedDb([ + { + id: "s1", + started_at: future, + message_count: 1, + title: "Exists", + firstUserMessage: "hi", + }, + ]); + syncSessionCache(); + + expect(() => updateSessionTitle("s1", " ")).toThrow(/valid/i); + expect(() => updateSessionTitle("s1", "x".repeat(101))).toThrow(/100/); + expect(listCachedSessions(10)[0]?.title).toBe("Exists"); + }); +}); From b4f347195eb4d93fbb16647c55ac6cb5a3c979ba Mon Sep 17 00:00:00 2001 From: Bobby Lansing Date: Tue, 21 Jul 2026 23:27:18 -0700 Subject: [PATCH 2/4] refactor: share session title normalize and rename confirm flow Pull title policy into src/shared/session-title and the optimistic rename UX into confirmSessionRename so the sidebar and Sessions modal stop duplicating toast/rollback/refocus logic. Keeps both screens under the 1k-line ceiling and prevents UI/DB whitespace drift on rename. Co-authored-by: Cursor --- lat.md/sidebar-navigation.md | 2 +- src/main/session-cache.ts | 56 ++++++----- .../screens/Layout/SidebarRecentSessions.tsx | 54 +++++------ .../src/screens/Sessions/Sessions.tsx | 93 ++++++++----------- .../screens/Sessions/confirmSessionRename.ts | 56 +++++++++++ src/shared/session-title.test.ts | 45 +++++++++ src/shared/session-title.ts | 39 ++++++++ 7 files changed, 235 insertions(+), 110 deletions(-) create mode 100644 src/renderer/src/screens/Sessions/confirmSessionRename.ts create mode 100644 src/shared/session-title.test.ts create mode 100644 src/shared/session-title.ts diff --git a/lat.md/sidebar-navigation.md b/lat.md/sidebar-navigation.md index 3dafd21f7..591d8a791 100644 --- a/lat.md/sidebar-navigation.md +++ b/lat.md/sidebar-navigation.md @@ -46,7 +46,7 @@ Each action calls an existing desktop API with an optimistic local update and ro Session renames must survive `syncSessionCache`, so the durable `state.db` write happens before the JSON cache is updated. -[[src/main/session-cache.ts#updateSessionTitle]] trims/collapses whitespace, enforces Hermes' 100-character cap ([[src/main/session-cache.ts#MAX_SESSION_TITLE_LENGTH]]), rejects empty titles, and checks uniqueness against Hermes' `idx_sessions_title_unique` before `UPDATE`ing. Failures throw (duplicate / not found / unavailable) instead of being swallowed — the sidebar and Sessions modal roll back their optimistic title, toast the error, and keep the inline editor open. A successful rename updates `sessions.json` only after `state.db` commits, so the next sync cannot resurrect the old name. +Title policy lives in [[src/shared/session-title.ts]] (`normalizeSessionTitle`, `MAX_SESSION_TITLE_LENGTH`) so the renderer optimistic path and [[src/main/session-cache.ts#updateSessionTitle]] cannot diverge. Main writes `state.db` first (Hermes `idx_sessions_title_unique`), then mirrors into `sessions.json`; failures throw. Both the sidebar and Sessions modal call [[src/renderer/src/screens/Sessions/confirmSessionRename.ts#confirmSessionRename]] for optimistic update, toast/rollback, and keep-editor-on-failure. Pinned rows are a desktop-only affordance: their ids live in `localStorage` (`hermes.sidebar.pinnedSessions`), and pinned sessions are pulled out of the normal grouping into a collapsible **Pinned** section at the top of the list. diff --git a/src/main/session-cache.ts b/src/main/session-cache.ts index df64923e1..39a7ed1ed 100644 --- a/src/main/session-cache.ts +++ b/src/main/session-cache.ts @@ -3,10 +3,19 @@ import { join } from "path"; import { profileHome, getActiveProfileNameSync, safeWriteFile } from "./utils"; import Database from "better-sqlite3"; import { t } from "../shared/i18n"; +import { + isSessionTitleUniqueViolation, + MAX_SESSION_TITLE_LENGTH, + normalizeSessionTitle, + validateNormalizedSessionTitle, +} from "../shared/session-title"; import { getAppLocale } from "./locale"; import { getDbConnection } from "./db"; import { getSessionContextFolders } from "./session-context-folder-store"; +// Re-export for callers/docs that historically imported the cap from here. +export { MAX_SESSION_TITLE_LENGTH } from "../shared/session-title"; + /** * The session cache lives alongside its own profile's data so profiles * don't share a single cache file. The default profile keeps @@ -253,9 +262,6 @@ export function listCachedSessions(limit = 50, offset = 0): CachedSession[] { return cache.sessions.slice(offset, offset + limit); } -/** Hermes Agent caps session titles at 100 characters (schema + CLI). */ -export const MAX_SESSION_TITLE_LENGTH = 100; - /** * Persist a user-chosen session title to state.db, then mirror it into the * desktop sessions.json cache. @@ -267,16 +273,23 @@ export const MAX_SESSION_TITLE_LENGTH = 100; */ export function updateSessionTitle(sessionId: string, title: string): void { const locale = getAppLocale(); - const trimmed = title.trim().replace(/\s+/g, " "); - if (!trimmed) { - throw new Error(t("sessions.renameInvalid", locale)); - } - if (trimmed.length > MAX_SESSION_TITLE_LENGTH) { - throw new Error( - t("sessions.renameTooLong", locale, { - max: String(MAX_SESSION_TITLE_LENGTH), - }), - ); + const normalized = normalizeSessionTitle(title); + const validation = validateNormalizedSessionTitle(normalized); + switch (validation) { + case "empty": + throw new Error(t("sessions.renameInvalid", locale)); + case "too_long": + throw new Error( + t("sessions.renameTooLong", locale, { + max: String(MAX_SESSION_TITLE_LENGTH), + }), + ); + case null: + break; + default: { + const _exhaustive: never = validation; + throw new Error(String(_exhaustive)); + } } const db = getDbConnection(false); @@ -288,24 +301,25 @@ export function updateSessionTitle(sessionId: string, title: string): void { // we never partially update the JSON cache on a UNIQUE constraint failure. const conflict = db .prepare("SELECT id FROM sessions WHERE title = ? AND id != ?") - .get(trimmed, sessionId) as { id: string } | undefined; + .get(normalized, sessionId) as { id: string } | undefined; if (conflict) { - throw new Error(t("sessions.renameDuplicate", locale, { title: trimmed })); + throw new Error( + t("sessions.renameDuplicate", locale, { title: normalized }), + ); } let changes = 0; try { changes = db .prepare("UPDATE sessions SET title = ? WHERE id = ?") - .run(trimmed, sessionId).changes; + .run(normalized, sessionId).changes; } catch (err) { - const message = err instanceof Error ? err.message : String(err); - if (/UNIQUE|constraint/i.test(message)) { + if (isSessionTitleUniqueViolation(err)) { throw new Error( - t("sessions.renameDuplicate", locale, { title: trimmed }), + t("sessions.renameDuplicate", locale, { title: normalized }), ); } - throw err instanceof Error ? err : new Error(message); + throw err instanceof Error ? err : new Error(String(err)); } if (changes === 0) { @@ -315,7 +329,7 @@ export function updateSessionTitle(sessionId: string, title: string): void { const cache = readCache(); const idx = cache.sessions.findIndex((s) => s.id === sessionId); if (idx >= 0) { - cache.sessions[idx].title = trimmed; + cache.sessions[idx].title = normalized; writeCache(cache); } } diff --git a/src/renderer/src/screens/Layout/SidebarRecentSessions.tsx b/src/renderer/src/screens/Layout/SidebarRecentSessions.tsx index 4e858ad87..57567d87b 100644 --- a/src/renderer/src/screens/Layout/SidebarRecentSessions.tsx +++ b/src/renderer/src/screens/Layout/SidebarRecentSessions.tsx @@ -8,7 +8,6 @@ import { type RefObject, } from "react"; import { createPortal } from "react-dom"; -import toast from "react-hot-toast"; import { useI18n } from "../../components/useI18n"; import { ChevronDown, @@ -20,6 +19,7 @@ import { Pin, X, } from "../../assets/icons"; +import { confirmSessionRename } from "../Sessions/confirmSessionRename"; import SidebarSessionMenu, { type SidebarMenuProject, type SidebarMenuTarget, @@ -510,37 +510,27 @@ const SidebarRecentSessions = memo(function SidebarRecentSessions({ const confirmRename = useCallback( async (id: string, value: string): Promise => { - const trimmed = value.trim(); - const current = sessionsRef.current.find((s) => s.id === id); - if (!trimmed || trimmed === (current?.title ?? "")) { - cancelRename(); - return; - } - const previous = current?.title ?? ""; - // Optimistic local update; roll back if the durable write fails so a - // subsequent sync cannot resurrect a cache-only title. - setSessions((prev) => - prev.map((s) => (s.id === id ? { ...s, title: trimmed } : s)), - ); - try { - await window.hermesAPI.updateSessionTitle(id, trimmed); - if (editingIdRef.current === id) cancelRename(); - } catch (err) { - console.error("Failed to rename session", id, err); - setSessions((prev) => - prev.map((s) => (s.id === id ? { ...s, title: previous } : s)), - ); - const message = - err instanceof Error && err.message - ? err.message - : t("sessions.renameFailed"); - toast.error(message); - // Keep the inline editor open so the user can pick a unique name. - setTimeout(() => { - renameInputRef.current?.focus(); - renameInputRef.current?.select(); - }, 0); - } + const previous = + sessionsRef.current.find((s) => s.id === id)?.title ?? ""; + await confirmSessionRename({ + sessionId: id, + value, + currentTitle: previous, + isStillEditing: () => editingIdRef.current === id, + applyOptimistic: (title) => + setSessions((prev) => + prev.map((s) => (s.id === id ? { ...s, title } : s)), + ), + rollback: () => + setSessions((prev) => + prev.map((s) => (s.id === id ? { ...s, title: previous } : s)), + ), + clearEditing: cancelRename, + inputRef: renameInputRef, + fallbackErrorMessage: t("sessions.renameFailed"), + persist: (sessionId, title) => + window.hermesAPI.updateSessionTitle(sessionId, title), + }); }, [cancelRename, t], ); diff --git a/src/renderer/src/screens/Sessions/Sessions.tsx b/src/renderer/src/screens/Sessions/Sessions.tsx index 2575d16d9..6c68a5929 100644 --- a/src/renderer/src/screens/Sessions/Sessions.tsx +++ b/src/renderer/src/screens/Sessions/Sessions.tsx @@ -1,7 +1,7 @@ import { useEffect, useState, useRef, useCallback, useMemo, memo } from "react"; -import toast from "react-hot-toast"; import { Plus, Search, X, ChatBubble, Trash, Pencil } from "../../assets/icons"; import { useI18n } from "../../components/useI18n"; +import { confirmSessionRename } from "./confirmSessionRename"; interface CachedSession { id: string; @@ -394,63 +394,44 @@ function Sessions({ const confirmRename = useCallback( async (sessionId: string, newTitle: string): Promise => { - const trimmed = newTitle.trim(); - if (!trimmed) { - cancelRename(); - return; - } - // Capture old titles so we can roll back on failure. - let oldSessionTitle = ""; - let oldSearchResultTitle = ""; - // Optimistic update - setSessions((prev) => { - oldSessionTitle = prev.find((s) => s.id === sessionId)?.title ?? ""; - return prev.map((s) => - s.id === sessionId ? { ...s, title: trimmed } : s, - ); + const oldSessionTitle = + sessions.find((s) => s.id === sessionId)?.title ?? ""; + const oldSearchResultTitle = + searchResults.find((r) => r.sessionId === sessionId)?.title ?? ""; + await confirmSessionRename({ + sessionId, + value: newTitle, + currentTitle: oldSessionTitle, + isStillEditing: () => editingSessionIdRef.current === sessionId, + applyOptimistic: (title) => { + setSessions((prev) => + prev.map((s) => (s.id === sessionId ? { ...s, title } : s)), + ); + setSearchResults((prev) => + prev.map((r) => (r.sessionId === sessionId ? { ...r, title } : r)), + ); + }, + rollback: () => { + setSessions((prev) => + prev.map((s) => + s.id === sessionId ? { ...s, title: oldSessionTitle } : s, + ), + ); + setSearchResults((prev) => + prev.map((r) => + r.sessionId === sessionId + ? { ...r, title: oldSearchResultTitle } + : r, + ), + ); + }, + clearEditing: cancelRename, + inputRef: renameInputRef, + fallbackErrorMessage: t("sessions.renameFailed"), + persist: (id, title) => window.hermesAPI.updateSessionTitle(id, title), }); - setSearchResults((prev) => { - oldSearchResultTitle = - prev.find((r) => r.sessionId === sessionId)?.title ?? ""; - return prev.map((r) => - r.sessionId === sessionId ? { ...r, title: trimmed } : r, - ); - }); - try { - await window.hermesAPI.updateSessionTitle(sessionId, trimmed); - // Guard: only clear editing state if the user hasn't started editing - // a different session while this request was in flight. - if (editingSessionIdRef.current === sessionId) { - setEditingSessionId(null); - setEditingTitle(""); - } - } catch (err) { - console.error("Failed to rename session", sessionId, err); - // Rollback optimistic update - setSessions((prev) => - prev.map((s) => - s.id === sessionId ? { ...s, title: oldSessionTitle } : s, - ), - ); - setSearchResults((prev) => - prev.map((r) => - r.sessionId === sessionId - ? { ...r, title: oldSearchResultTitle } - : r, - ), - ); - const message = - err instanceof Error && err.message - ? err.message - : t("sessions.renameFailed"); - toast.error(message); - setTimeout(() => { - renameInputRef.current?.focus(); - renameInputRef.current?.select(); - }, 0); - } }, - [cancelRename, t], + [cancelRename, searchResults, sessions, t], ); const cancelDelete = useCallback((): void => { diff --git a/src/renderer/src/screens/Sessions/confirmSessionRename.ts b/src/renderer/src/screens/Sessions/confirmSessionRename.ts new file mode 100644 index 000000000..eacee2396 --- /dev/null +++ b/src/renderer/src/screens/Sessions/confirmSessionRename.ts @@ -0,0 +1,56 @@ +import type { RefObject } from "react"; +import toast from "react-hot-toast"; +import { normalizeSessionTitle } from "../../../../shared/session-title"; + +export interface ConfirmSessionRenameOptions { + sessionId: string; + value: string; + currentTitle: string; + /** True when this session is still the one being edited. */ + isStillEditing: () => boolean; + applyOptimistic: (title: string) => void; + rollback: () => void; + clearEditing: () => void; + inputRef: RefObject; + fallbackErrorMessage: string; + persist: (sessionId: string, title: string) => Promise; +} + +export type ConfirmSessionRenameResult = "cancelled" | "saved" | "failed"; + +/** + * Shared inline-rename flow for the sidebar and Sessions modal: normalize, + * no-op cancel, optimistic update, durable persist, then clear editing on + * success or toast + rollback + refocus on failure. + */ +export async function confirmSessionRename( + opts: ConfirmSessionRenameOptions, +): Promise { + const normalized = normalizeSessionTitle(opts.value); + if (!normalized || normalized === opts.currentTitle) { + opts.clearEditing(); + return "cancelled"; + } + + opts.applyOptimistic(normalized); + try { + await opts.persist(opts.sessionId, normalized); + if (opts.isStillEditing()) opts.clearEditing(); + return "saved"; + } catch (err) { + console.error("Failed to rename session", opts.sessionId, err); + opts.rollback(); + const message = + err instanceof Error && err.message + ? err.message + : opts.fallbackErrorMessage; + toast.error(message); + if (opts.isStillEditing()) { + setTimeout(() => { + opts.inputRef.current?.focus(); + opts.inputRef.current?.select(); + }, 0); + } + return "failed"; + } +} diff --git a/src/shared/session-title.test.ts b/src/shared/session-title.test.ts new file mode 100644 index 000000000..9c9ea1306 --- /dev/null +++ b/src/shared/session-title.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { + isSessionTitleUniqueViolation, + MAX_SESSION_TITLE_LENGTH, + normalizeSessionTitle, + validateNormalizedSessionTitle, +} from "./session-title"; + +describe("normalizeSessionTitle", () => { + it("trims and collapses internal whitespace", () => { + expect(normalizeSessionTitle(" Hello World ")).toBe("Hello World"); + }); +}); + +describe("validateNormalizedSessionTitle", () => { + it("rejects empty and oversized titles", () => { + expect(validateNormalizedSessionTitle("")).toBe("empty"); + expect( + validateNormalizedSessionTitle("x".repeat(MAX_SESSION_TITLE_LENGTH + 1)), + ).toBe("too_long"); + expect(validateNormalizedSessionTitle("ok")).toBeNull(); + }); +}); + +describe("isSessionTitleUniqueViolation", () => { + it("matches sessions.title UNIQUE failures only", () => { + expect( + isSessionTitleUniqueViolation( + new Error("UNIQUE constraint failed: sessions.title"), + ), + ).toBe(true); + + const coded = new Error( + "UNIQUE constraint failed: sessions.title", + ) as Error & { code: string }; + coded.code = "SQLITE_CONSTRAINT_UNIQUE"; + expect(isSessionTitleUniqueViolation(coded)).toBe(true); + + expect( + isSessionTitleUniqueViolation( + new Error("CHECK constraint failed: message_count"), + ), + ).toBe(false); + }); +}); diff --git a/src/shared/session-title.ts b/src/shared/session-title.ts new file mode 100644 index 000000000..d2b2d6e2c --- /dev/null +++ b/src/shared/session-title.ts @@ -0,0 +1,39 @@ +/** + * Session title policy shared by main (state.db writes) and the renderer + * (optimistic UI). Mirrors Hermes Agent: unique non-NULL titles, 100-char + * max, whitespace collapsed. + */ + +/** Hermes Agent caps session titles at 100 characters (schema + CLI). */ +export const MAX_SESSION_TITLE_LENGTH = 100; + +/** Trim and collapse internal whitespace so UI and DB agree on the title. */ +export function normalizeSessionTitle(title: string): string { + return title.trim().replace(/\s+/g, " "); +} + +export type SessionTitleValidationError = "empty" | "too_long"; + +/** + * Validate an already-normalized title. Returns an error code, or null when + * the title is acceptable for a durable write. + */ +export function validateNormalizedSessionTitle( + title: string, +): SessionTitleValidationError | null { + if (!title) return "empty"; + if (title.length > MAX_SESSION_TITLE_LENGTH) return "too_long"; + return null; +} + +/** True when a SQLite error is the sessions.title UNIQUE constraint. */ +export function isSessionTitleUniqueViolation(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err ?? ""); + if (/UNIQUE constraint failed:\s*sessions\.title/i.test(message)) { + return true; + } + if (!err || typeof err !== "object") return false; + const code = "code" in err ? String((err as { code: unknown }).code) : ""; + // better-sqlite3: SQLITE_CONSTRAINT_UNIQUE; message usually names the column. + return code === "SQLITE_CONSTRAINT_UNIQUE" && /title/i.test(message); +} From 5f0ee6e8c53aa7b3b4962569862877395b72eb51 Mon Sep 17 00:00:00 2001 From: Bobby Lansing Date: Wed, 22 Jul 2026 06:27:19 -0700 Subject: [PATCH 3/4] Fix dev blank screen and remote cron/dashboard API routing Dev: lazy-load Office to keep drei off the Chat startup path, pre-bundle Three.js deps (including SkeletonUtils), clear corrupt Vite cache, and recover from stale optimize-dep 504s with bounded reloads. Remote: route cron through dashboard /api/cron/jobs with gateway /api/jobs fallback on 404/405; reject dashboard HTML /health as gateway-ready; prefer /api/status for remote connection tests when not on legacy transport. Co-authored-by: Cursor --- electron.vite.config.ts | 15 ++- lat.md/chat-commands.md | 2 + lat.md/main-process.md | 10 +- lat.md/sidebar-navigation.md | 2 +- package-lock.json | 58 ++++------- package.json | 3 +- scripts/ensure-vite-deps.mjs | 19 ++++ scripts/vite-wait-for-drei.ts | 75 ++++++++++++++ src/main/app/start.ts | 26 +++++ src/main/cronjobs.ts | 110 +++++++++++++++------ src/main/hermes.ts | 83 ++++++++++------ src/renderer/src/main.tsx | 9 ++ src/renderer/src/screens/Layout/Layout.tsx | 11 ++- src/renderer/src/screens/Office/Office.tsx | 43 ++++---- tests/remote-mode-url-and-spawn.test.ts | 99 +++++++++++++++++-- 15 files changed, 429 insertions(+), 136 deletions(-) create mode 100644 scripts/ensure-vite-deps.mjs create mode 100644 scripts/vite-wait-for-drei.ts diff --git a/electron.vite.config.ts b/electron.vite.config.ts index b3f595fcf..6c1a0b22c 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -2,6 +2,7 @@ import { resolve } from "path"; import { defineConfig } from "electron-vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; +import { waitForDreiPrebundle } from "./scripts/vite-wait-for-drei"; const rendererPort = Number(process.env.HERMES_DESKTOP_RENDERER_PORT || 0); @@ -36,11 +37,17 @@ export default defineConfig({ alias: { "@renderer": resolve("src/renderer/src"), }, - // Ensure a single Three.js instance across our code, @react-three/fiber, - // drei and troika — multiple copies break `instanceof THREE.*` checks in - // the ported office agent renderer. dedupe: ["three"], }, - plugins: [tailwindcss(), react()], + optimizeDeps: { + include: [ + "@react-three/drei", + "@react-three/fiber", + "three", + "troika-three-text", + "three/examples/jsm/utils/SkeletonUtils.js", + ], + }, + plugins: [waitForDreiPrebundle(), tailwindcss(), react()], }, }); diff --git a/lat.md/chat-commands.md b/lat.md/chat-commands.md index 913a9860a..f15464a05 100644 --- a/lat.md/chat-commands.md +++ b/lat.md/chat-commands.md @@ -43,6 +43,8 @@ This matches the reference `apps/desktop`, which has no `/v1` chat path at all ( So `ensureClient` distinguishes two failures: a **genuinely absent** dashboard (`startDashboard` → `running:false`) latches the negative flag and (auto mode) drops to legacy gateway `/v1`; a **transient** WS drop while the dashboard is up (a "socket hang up" from a tunnel blip) instead **retries the connect** (up to 3×, re-running `startDashboard` each time to re-establish the SSH tunnel). If it still can't connect, it throws a `dashboardWasReachable`-tagged error so `sendMessage` **fails the turn for the user to retry** rather than 405-ing on `/v1`. +[[src/main/hermes.ts#isApiServerReady]] also rejects dashboard SPA HTML `/health` responses so a dashboard URL is never mistaken for a gateway with `/v1`. Remote connection tests prefer `/api/status` when chat transport is not `legacy` ([[src/main/hermes.ts#testRemoteConnection]]). + ## Completion text reconciliation On `message.complete` the desktop reconciles the text streamed via `message.delta` with the turn's `final_response`, because a last-turn-only final would otherwise clobber text streamed before a tool call (#746). diff --git a/lat.md/main-process.md b/lat.md/main-process.md index 9097e481c..c4cdeb2be 100644 --- a/lat.md/main-process.md +++ b/lat.md/main-process.md @@ -44,6 +44,12 @@ The packaged renderer keeps its meta CSP aligned with the production response CS Because electron-vite emits a bundled main file at `out/main/index.js`, packaged renderer loading resolves `../renderer/index.html` from `__dirname` to reach `out/renderer/index.html`. +## Dev Vite loading + +Local `npm run dev` can show a blank window when Vite's dependency pre-bundler races Electron startup — especially the Office 3D stack (`@react-three/drei`, ~3.5MB). + +[[electron.vite.config.ts]] pre-bundles `@react-three/fiber`, `three`, `troika-three-text`, and `three/examples/jsm/utils/SkeletonUtils.js` (Office rigged GLBs import it; discovering it on first Office visit re-optimizes and can delete `@react-three_drei.js`). [[scripts/vite-wait-for-drei.ts#waitForDreiPrebundle]] blocks the first HTML response until `@react-three_drei.js` exists. [[scripts/ensure-vite-deps.mjs]] clears a corrupt cache when only the `.map` remains. [[src/renderer/src/screens/Layout/Layout.tsx]] lazy-loads the Office tab so Chat startup never waits on drei. [[src/renderer/src/main.tsx]] reloads on `vite:preloadError`; [[src/main/app/start.ts]] reloads once when `#root` is still empty four seconds after load. Use `npm run dev:clean` when deps look stale. + ## App Chrome Helpers Menu, updater, and context-menu behavior live in focused modules. @@ -74,8 +80,8 @@ Direct Remote mode also supports browser-authenticated dashboards through [[remo The dashboard is **not** a `/v1` superset (a long-standing misconception in earlier comments): `hermes_cli/web_server.py` has no `/v1/chat`, `/v1/responses`, or `/v1/runs` routes and does not proxy `/v1` to the gateway. -- **Gateway api_server** (port 8642, `API_SERVER_KEY` auth) serves `/v1` chat (`/v1/chat/completions`, `/v1/responses`, `/v1/runs`) + `/health`. This is the **no-build** transport — no Node, no web dist — used by `remote` mode and the SSH gateway fallback. See [[main-process#SSH api_server provisioning]]. -- **Dashboard** (`hermes dashboard`, port 9119, session-token auth) serves the model library, session list (`/api/*`), and the chat **WebSocket** (`/api/ws`) — surfaces the gateway api_server does not. Local chat uses `/api/ws`; over SSH the renderer's dashboard transport uses it too, when a dashboard is available. +- **Gateway api_server** (port 8642, `API_SERVER_KEY` auth) serves `/v1` chat (`/v1/chat/completions`, `/v1/responses`, `/v1/runs`) + JSON `/health`. This is the **no-build** transport — no Node, no web dist — used by `remote` mode and the SSH gateway fallback. See [[main-process#SSH api_server provisioning]]. +- **Dashboard** (`hermes dashboard`, port 9119, session-token auth) serves the model library, session list (`/api/*`), cron at `/api/cron/jobs`, and the chat **WebSocket** (`/api/ws`) — surfaces the gateway api_server does not. Local chat uses `/api/ws`; over SSH the renderer's dashboard transport uses it too, when a dashboard is available. Its SPA answers GET `/health` with HTML 200, which must **not** be treated as gateway readiness ([[src/main/hermes.ts#isApiServerReady]] rejects `text/html`). [[src/main/ssh-remote.ts#sshEnsureDashboard]] ensures the gateway is up, builds the web dist if missing ([[src/main/ssh-remote.ts#sshEnsureDashboardDist]] resolves the real install root via [[src/main/ssh-remote.ts#sshResolveDashboardRoot]] — a system-wide install lives at `/usr/local/lib/hermes-agent`, NOT under `$HOME`, so a hardcoded `~/.hermes/hermes-agent` path wrongly reported "no web dist" and forced every connection into basic chat; it now detects an already-built dist wherever hermes lives, or builds it with the vendored Node at `~/.hermes/node`, single shared in-flight build), then starts the **unified machine** `hermes dashboard --host 127.0.0.1 --port --no-open --skip-build` ([[src/main/ssh-remote.ts#sshStartDashboard]]) with the session token in its env. **One dashboard serves every profile** (no `--profile`, no `--isolated`): `ensureDashboardInner` is machine-scoped (profile=undefined → default port + default token), and per-profile data is selected per-request via `?profile=` ([[src/main/remote-sessions.ts#RemoteSessionConfig]]`.profile`, applied in `dashboardApiUrl`). This is REQUIRED because the desktop has a single global SSH tunnel that can only point at one remote port: the desktop queries multiple profiles at once (e.g. `default` for the machine view + the active named profile), so per-profile dashboard ports (an earlier `--isolated` attempt) made those concurrent queries resolve different ports and thrash the one tunnel ("SSH tunnel is not active"). Readiness requires both the public `/api/status` probe ([[src/main/ssh-remote.ts#sshWaitDashboardReady]], [[src/main/ssh-remote.ts#sshDashboardRunning]]) and an authenticated `/api/sessions` probe ([[src/main/ssh-remote.ts#sshDashboardAuthenticated]]). If the preferred port belongs to a stale dashboard with another token or an unrelated HTTP service, the desktop leaves that process alone, allocates a free loopback port, and persists it as `HERMES_DESKTOP_DASHBOARD_PORT` (one canonical line, deduped) in the **default** `.env`. [[src/main/dashboard.ts#sshDashboardConnectionFromConfig]] and [[src/main/ipc/register.ts#getSshDashboardSessionConfig]] then `ensureSshTunnel` to that single dashboard port and build the connection (model library, sessions, and the `/api/ws` chat WS), carrying the requested `profile`. diff --git a/lat.md/sidebar-navigation.md b/lat.md/sidebar-navigation.md index 591d8a791..cc17b9795 100644 --- a/lat.md/sidebar-navigation.md +++ b/lat.md/sidebar-navigation.md @@ -82,7 +82,7 @@ The primary path tunnels to the remote **unified machine dashboard** (see [[main Managed SSH installs can store Hermes outside the SSH user's home or under a different `HERMES_HOME`, so Office/Agents must read profiles from the actual remote runtime rather than a `~/.hermes` filesystem scan. -[[src/main/ssh-remote.ts#buildRemoteHermesCmd]] probes per-user launcher hooks (`$HOME/.config/hermes-desktop/remote-hermes`, `$HOME/.hermes/desktop-remote-hermes`) before the default venv/PATH locations, letting a deployment supply its own wrapper that sets the right command, service user, and `HERMES_HOME`. [[src/main/ssh-remote.ts#sshListProfiles]] detects whether such a launcher actually exists in one round trip, then [[src/main/ssh-remote.ts#selectSshProfiles]] treats a present launcher as authoritative — preferring its profiles over the scan even on an equal count, so a managed `default`-only install shows live gateway state instead of stale home-directory data. Named-profile Schedules route the same way through [[src/main/ssh-remote.ts#sshRunCron]], while the default profile keeps the existing HTTP `/api/jobs` path. +[[src/main/ssh-remote.ts#buildRemoteHermesCmd]] probes per-user launcher hooks (`$HOME/.config/hermes-desktop/remote-hermes`, `$HOME/.hermes/desktop-remote-hermes`) before the default venv/PATH locations, letting a deployment supply its own wrapper that sets the right command, service user, and `HERMES_HOME`. [[src/main/ssh-remote.ts#sshListProfiles]] detects whether such a launcher actually exists in one round trip, then [[src/main/ssh-remote.ts#selectSshProfiles]] treats a present launcher as authoritative — preferring its profiles over the scan even on an equal count, so a managed `default`-only install shows live gateway state instead of stale home-directory data. Named-profile Schedules route the same way through [[src/main/ssh-remote.ts#sshRunCron]], while the default profile uses HTTP cron via [[src/main/cronjobs.ts#listCronJobs]]: prefer dashboard `/api/cron/jobs`, fall back to gateway `/api/jobs` on 404/405. Every SSH-invoked `hermes` command resolves the CLI through `buildRemoteHermesCmd`, never a bare `hermes` — a non-interactive SSH shell does not source the profile that puts the CLI on PATH, so a bare invocation fails with "command not found" on otherwise-healthy remotes. This covers gateway lifecycle ([[src/main/ssh-remote.ts#buildGatewayStartCommand]] / [[src/main/ssh-remote.ts#buildGatewayStopCommand]] non-systemd branch, for both named and default profiles), skills ([[src/main/ssh-remote.ts#sshInstallSkill]], [[src/main/ssh-remote.ts#sshUninstallSkill]], [[src/main/ssh-remote.ts#sshSearchSkills]]), and profile create/delete ([[src/main/ssh-remote.ts#sshCreateProfile]] / [[src/main/ssh-remote.ts#sshDeleteProfile]], which also use the **singular** `profile` subcommand). The systemd branch still prefers `systemctl` when a `hermes.service` unit exists, and [[src/main/ssh-remote.ts#buildGatewayStatusCommand]] remains a pid-file liveness check. The non-systemd branch launches the gateway with `gateway run` (foreground, backgrounded via `nohup`), **not** `gateway start` — `gateway start` drives the systemd/launchd service and fails with "Gateway service is not installed" on a bare VPS that never ran `hermes gateway install`, whereas `gateway run` launches the gateway and its api_server directly and writes the pid file the status/stop commands read. diff --git a/package-lock.json b/package-lock.json index 6574f9279..27781b246 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "hermes-desktop", - "version": "0.7.0", + "version": "0.7.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hermes-desktop", - "version": "0.7.0", + "version": "0.7.4", "hasInstallScript": true, "dependencies": { "@electron-toolkit/preload": "^3.0.2", @@ -136,7 +136,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -511,7 +510,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -535,7 +533,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -1017,6 +1014,7 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, + "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1038,6 +1036,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -1054,6 +1053,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -1068,6 +1068,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 10.0.0" } @@ -1077,7 +1078,6 @@ "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", "license": "MIT", - "peer": true, "dependencies": { "@emotion/memoize": "^0.9.0" } @@ -2115,7 +2115,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -2244,7 +2243,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -2940,7 +2938,6 @@ "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.6.1.tgz", "integrity": "sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.17.8", "@types/webxr": "*", @@ -3552,7 +3549,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -4067,7 +4063,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -4395,7 +4390,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4429,7 +4423,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4440,7 +4433,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4483,7 +4475,6 @@ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.183.1.tgz", "integrity": "sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==", "license": "MIT", - "peer": true, "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", @@ -4575,7 +4566,6 @@ "integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.0", "@typescript-eslint/types": "8.58.0", @@ -4838,7 +4828,6 @@ "integrity": "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.8", @@ -5352,7 +5341,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -5504,7 +5492,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5544,7 +5531,6 @@ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -6228,7 +6214,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -6899,7 +6884,8 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/cross-env": { "version": "7.0.3", @@ -6999,8 +6985,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/data-urls": { "version": "5.0.0", @@ -7338,7 +7323,6 @@ "integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", @@ -7514,7 +7498,6 @@ "integrity": "sha512-q6+LiQIcTadSyvtPgLDQkCtVA9jQJXQVMrQcctfOJILh6OFMN+UJJLRkuUTy8CZDYeCIBn1ZycqsL1dAXugxZA==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^22.7.7", @@ -7766,6 +7749,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -7786,6 +7770,7 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -8127,7 +8112,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8188,7 +8172,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -9665,7 +9648,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.29.2" }, @@ -10509,7 +10491,6 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -10672,7 +10653,6 @@ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "devOptional": true, "license": "MPL-2.0", - "peer": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -12327,6 +12307,7 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -13123,6 +13104,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -13140,6 +13122,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -13217,7 +13200,6 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -13459,7 +13441,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -13469,7 +13450,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -14004,6 +13984,7 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -14034,7 +14015,6 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -15098,6 +15078,7 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -15159,8 +15140,7 @@ "version": "0.183.2", "resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz", "integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/three-mesh-bvh": { "version": "0.8.3", @@ -15594,7 +15574,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15947,7 +15926,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -16537,7 +16515,6 @@ "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.1.8", "@vitest/mocker": "4.1.8", @@ -17056,7 +17033,6 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 0d15eb7ab..64547816c 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "typecheck": "npm run typecheck:node && npm run typecheck:web", "typecheck:sandbox": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/hermes-sandbox.ps1 typecheck", "start": "electron-vite preview", - "dev": "electron-vite dev", + "dev": "node scripts/ensure-vite-deps.mjs && electron-vite dev", + "dev:clean": "rm -rf node_modules/.vite && npm run dev", "dev:sandbox": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/hermes-sandbox.ps1 dev", "sandbox:sync-config": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/hermes-sandbox.ps1 sync-config", "dev:fresh": "HERMES_HOME=$(mktemp -d -t hermes-fresh) electron-vite dev", diff --git a/scripts/ensure-vite-deps.mjs b/scripts/ensure-vite-deps.mjs new file mode 100644 index 000000000..043627464 --- /dev/null +++ b/scripts/ensure-vite-deps.mjs @@ -0,0 +1,19 @@ +#!/usr/bin/env node +/** + * Dev-only guard: Vite can leave a stale @react-three/drei pre-bundle (.map + * without the .js) after an interrupted re-optimize, which 504s lazy Office loads. + */ +import fs from "node:fs"; +import path from "node:path"; + +const cacheRoot = path.join("node_modules", ".vite"); +const depsDir = path.join(cacheRoot, "deps"); +const dreiJs = path.join(depsDir, "@react-three_drei.js"); +const dreiMap = path.join(depsDir, "@react-three_drei.js.map"); + +if (fs.existsSync(dreiMap) && !fs.existsSync(dreiJs)) { + console.warn( + "[dev] Removing corrupt Vite dep cache (@react-three_drei.js missing)", + ); + fs.rmSync(cacheRoot, { recursive: true, force: true }); +} diff --git a/scripts/vite-wait-for-drei.ts b/scripts/vite-wait-for-drei.ts new file mode 100644 index 000000000..8abeb2c47 --- /dev/null +++ b/scripts/vite-wait-for-drei.ts @@ -0,0 +1,75 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { Plugin } from "vite"; + +const DREI_FILE = "@react-three_drei.js"; +const WAIT_MS = 120_000; + +/** + * Delay the first HTML response until `@react-three/drei` is pre-bundled. + * electron-vite opens Electron while Vite is still optimizing; an early page + * load races the optimizer and can leave `@react-three_drei.js` missing. + * @lat [[main-process#Dev Vite loading]] + */ +export function waitForDreiPrebundle(): Plugin { + return { + name: "hermes:wait-for-drei-prebundle", + apply: "serve", + configureServer(server) { + const client = server.environments.client; + const optimizer = client.depsOptimizer; + if (!optimizer) return; + + let ready: Promise | undefined; + + server.middlewares.use((req, res, next) => { + if (req.method !== "GET" && req.method !== "HEAD") { + next(); + return; + } + const pathname = (req.url ?? "/").split("?")[0] ?? "/"; + const isDocument = pathname === "/" || pathname.startsWith("/index.html"); + if (!isDocument) { + next(); + return; + } + + ready ??= waitForDreiFile(client.config.cacheDir, optimizer); + void ready.then( + () => next(), + (err: unknown) => + next(err instanceof Error ? err : new Error(String(err))), + ); + }); + }, + }; +} + +async function waitForDreiFile( + cacheDir: string, + optimizer: NonNullable< + import("vite").DevEnvironment["depsOptimizer"] + >, +): Promise { + await optimizer.init(); + const scanProcessing = ( + optimizer as typeof optimizer & { scanProcessing?: Promise } + ).scanProcessing; + if (scanProcessing) await scanProcessing; + + const filePath = path.join(cacheDir, "deps", DREI_FILE); + const deadline = Date.now() + WAIT_MS; + while (Date.now() < deadline) { + try { + const stat = fs.statSync(filePath); + if (stat.size > 1_000_000) return; + } catch { + // not ready yet + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + + throw new Error( + `@react-three/drei pre-bundle missing after ${WAIT_MS / 1000}s. Run: npm run dev:clean`, + ); +} diff --git a/src/main/app/start.ts b/src/main/app/start.ts index 67ca847a0..98f03cdb3 100644 --- a/src/main/app/start.ts +++ b/src/main/app/start.ts @@ -199,6 +199,32 @@ function createWindow(): void { if (OPEN_DEVTOOLS_ON_START) { mainWindow?.webContents.openDevTools({ mode: "detach" }); } + // Vite can 504 stale optimized-dep hashes on the first Electron load after + // a re-optimize; reload once when React never mounts into #root. + // @lat [[main-process#Dev Vite loading]] + if (is.dev && process.env["ELECTRON_RENDERER_URL"]) { + const wc = mainWindow?.webContents; + if (!wc) return; + let reloaded = false; + setTimeout(() => { + if (reloaded || wc.isDestroyed()) return; + void wc + .executeJavaScript( + "Boolean(document.getElementById('root')?.firstElementChild)", + true, + ) + .then((mounted) => { + if (!mounted && !reloaded && !wc.isDestroyed()) { + reloaded = true; + console.warn( + "[dev] Blank renderer after load — reloading for Vite deps", + ); + wc.reload(); + } + }) + .catch(() => {}); + }, 4000); + } }); // Let mid-turn gateway sudo/secret prompts parent their modal to this window. diff --git a/src/main/cronjobs.ts b/src/main/cronjobs.ts index b461a97f3..b3da6e259 100644 --- a/src/main/cronjobs.ts +++ b/src/main/cronjobs.ts @@ -245,16 +245,76 @@ async function isCronFallbackHealthy( async function remoteJsonError(res: Response): Promise { try { - const body = (await res.json()) as { error?: string }; - return body.error || `HTTP ${res.status}`; + const body = (await res.json()) as { error?: string; detail?: string }; + return body.error || body.detail || `HTTP ${res.status}`; } catch { return `HTTP ${res.status}`; } } +/** Dashboard (`hermes dashboard` / `serve`) cron REST root. */ +const DASHBOARD_CRON_BASE = "/api/cron/jobs"; +/** Gateway api_server cron REST root (legacy remote/SSH transport). */ +const GATEWAY_CRON_BASE = "/api/jobs"; + +function isUnsupportedCronRoute(res: Response): boolean { + return res.status === 404 || res.status === 405; +} + +/** Dashboard returns a bare array; gateway wraps `{ jobs: [...] }`. */ +export function cronJobsFromResponseBody( + body: unknown, +): Record[] { + if (Array.isArray(body)) return body as Record[]; + if ( + body && + typeof body === "object" && + Array.isArray((body as { jobs?: unknown }).jobs) + ) { + return (body as { jobs: Record[] }).jobs; + } + return []; +} + +/** + * Prefer the dashboard cron surface; fall back to the gateway `/api/jobs` + * routes when the URL is a legacy api_server (404/405 on the dashboard path). + */ +async function remoteFetchCron( + jobPath: string, + init: RequestInit = {}, +): Promise { + const dashboardRes = await remoteFetch( + `${DASHBOARD_CRON_BASE}${jobPath}`, + init, + ); + if (!isUnsupportedCronRoute(dashboardRes)) return dashboardRes; + + // Gateway uses `/run` where the dashboard uses `/trigger`. + const gatewayPath = jobPath.endsWith("/trigger") + ? `${jobPath.slice(0, -"/trigger".length)}/run` + : jobPath; + return remoteFetch(`${GATEWAY_CRON_BASE}${gatewayPath}`, init); +} + +function normalizeCronJobRows( + raw: Record[], + includeDisabled: boolean, +): CronJob[] { + const jobs: CronJob[] = []; + for (const job of raw) { + const normalized = normalizeJob(job); + if (!normalized) continue; + if (!includeDisabled && !normalized.enabled) continue; + jobs.push(normalized); + } + return jobs; +} + /** * Read cron jobs from the jobs.json file (async to avoid blocking the main process). - * In remote mode, fetches from the Hermes API server's /api/jobs endpoint instead. + * In remote/SSH dashboard mode, fetches `/api/cron/jobs` (with gateway `/api/jobs` + * fallback for legacy transport). */ export async function listCronJobs( includeDisabled = true, @@ -275,22 +335,21 @@ export async function listCronJobs( if (isRemoteMode()) { try { - const qs = includeDisabled ? "?include_disabled=true" : ""; - const res = await remoteFetch(`/api/jobs${qs}`); + // Dashboard list has no include_disabled flag — filter client-side. + // Gateway accepts ?include_disabled= when we fall back. + let res = await remoteFetch(DASHBOARD_CRON_BASE); + if (isUnsupportedCronRoute(res)) { + const qs = includeDisabled ? "?include_disabled=true" : ""; + res = await remoteFetch(`${GATEWAY_CRON_BASE}${qs}`); + } if (!res.ok) { console.error("[CRON] remote list failed:", await remoteJsonError(res)); return []; } - const body = (await res.json()) as { jobs?: Record[] }; - const raw = body.jobs || []; - const jobs: CronJob[] = []; - for (const job of raw) { - const normalized = normalizeJob(job); - if (!normalized) continue; - if (!includeDisabled && !normalized.enabled) continue; - jobs.push(normalized); - } - return jobs; + return normalizeCronJobRows( + cronJobsFromResponseBody(await res.json()), + includeDisabled, + ); } catch (err) { console.error("[CRON] remote list error:", err); return []; @@ -304,16 +363,7 @@ export async function listCronJobs( const content = await readFile(filePath, "utf-8"); const parsed = JSON.parse(content); const raw = Array.isArray(parsed) ? parsed : parsed.jobs || []; - const jobs: CronJob[] = []; - - for (const job of raw) { - const normalized = normalizeJob(job); - if (!normalized) continue; - if (!includeDisabled && !normalized.enabled) continue; - jobs.push(normalized); - } - - return jobs; + return normalizeCronJobRows(raw, includeDisabled); } catch (err) { console.error("[CRON] Failed to read jobs file:", err); return []; @@ -376,7 +426,7 @@ export async function createCronJob( if (isRemoteMode()) { try { - const res = await remoteFetch("/api/jobs", { + const res = await remoteFetchCron("", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -410,7 +460,7 @@ export async function removeCronJob( } if (isRemoteMode()) { try { - const res = await remoteFetch(`/api/jobs/${encodeURIComponent(jobId)}`, { + const res = await remoteFetchCron(`/${encodeURIComponent(jobId)}`, { method: "DELETE", }); if (!res.ok) { @@ -435,8 +485,10 @@ async function remoteJobAction( return { success: sshResult.success, error: sshResult.error }; } try { - const res = await remoteFetch( - `/api/jobs/${encodeURIComponent(jobId)}/${action}`, + // Dashboard exposes `/trigger`; gateway uses `/run`. remoteFetchCron maps. + const dashboardAction = action === "run" ? "trigger" : action; + const res = await remoteFetchCron( + `/${encodeURIComponent(jobId)}/${dashboardAction}`, { method: "POST" }, ); if (!res.ok) { diff --git a/src/main/hermes.ts b/src/main/hermes.ts index 0e0febad2..f405fd45f 100644 --- a/src/main/hermes.ts +++ b/src/main/hermes.ts @@ -939,7 +939,11 @@ function isApiServerReady(profile?: string): Promise { url, { method: "GET", timeout: 1500, headers: getRemoteAuthHeader() }, (res) => { - resolve(res.statusCode === 200); + // Dashboard SPA answers GET /health with HTML 200. That is NOT the + // gateway api_server — treating it as ready causes POST /v1 → 405. + const contentType = String(res.headers?.["content-type"] || ""); + const looksLikeHtml = contentType.toLowerCase().includes("text/html"); + resolve(res.statusCode === 200 && !looksLikeHtml); res.resume(); }, ); @@ -3382,35 +3386,56 @@ export function testRemoteConnection( url: string, apiKey?: string, ): Promise { - return new Promise((resolve) => { - const conn = getConnectionConfig(); - const configuredOAuth = - apiKey === undefined && - conn.mode === "remote" && - conn.remoteAuthMode === "oauth" && - normaliseRemoteUrl(conn.remoteUrl) === normaliseRemoteUrl(url); - const target = `${normaliseRemoteUrl(url)}${ - configuredOAuth ? "/api/status" : "/health" - }`; - const mod = target.startsWith("https") ? https : http; - const headers: Record = {}; - const resolvedApiKey = resolveRemoteApiKey(url, apiKey); - if (resolvedApiKey) headers.Authorization = `Bearer ${resolvedApiKey}`; - const req = mod.request( - target, - { method: "GET", timeout: 5000, headers }, - (res) => { - resolve(res.statusCode === 200); - res.resume(); - }, - ); - req.on("error", () => resolve(false)); - req.on("timeout", () => { - req.destroy(); - resolve(false); + const conn = getConnectionConfig(); + const normalized = normaliseRemoteUrl(url); + const configuredOAuth = + apiKey === undefined && + conn.mode === "remote" && + conn.remoteAuthMode === "oauth" && + normaliseRemoteUrl(conn.remoteUrl) === normalized; + const prefersDashboard = + conn.mode === "remote" && + conn.remoteChatTransport !== "legacy" && + normaliseRemoteUrl(conn.remoteUrl) === normalized; + + const headers: Record = {}; + const resolvedApiKey = resolveRemoteApiKey(url, apiKey); + if (resolvedApiKey) headers.Authorization = `Bearer ${resolvedApiKey}`; + + const probe = (path: string): Promise => + new Promise((resolve) => { + const target = `${normalized}${path}`; + const mod = target.startsWith("https") ? https : http; + const req = mod.request( + target, + { method: "GET", timeout: 5000, headers }, + (res) => { + if (path === "/health") { + const contentType = String(res.headers?.["content-type"] || ""); + const looksLikeHtml = contentType + .toLowerCase() + .includes("text/html"); + resolve(res.statusCode === 200 && !looksLikeHtml); + } else { + resolve(res.statusCode === 200); + } + res.resume(); + }, + ); + req.on("error", () => resolve(false)); + req.on("timeout", () => { + req.destroy(); + resolve(false); + }); + req.end(); }); - req.end(); - }); + + // Dashboard/OAuth remotes: /api/status is the real liveness probe. Fall back + // to gateway /health for auto-mode users still pointed at an api_server URL. + if (configuredOAuth || prefersDashboard) { + return probe("/api/status").then((ok) => (ok ? true : probe("/health"))); + } + return probe("/health"); } async function waitForApiServerStopped( diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 0402ab7c5..80bf72e36 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -6,6 +6,15 @@ import App from "./App"; import { I18nProvider } from "./components/I18nProvider"; import { initAnalytics } from "./utils/analytics"; +// Vite returns 504 for stale optimized-dep hashes; reload once so Electron +// picks up the new browserHash (browsers get this from @vite/client automatically). +// @lat [[main-process#Dev Vite loading]] +if (import.meta.env.DEV) { + window.addEventListener("vite:preloadError", () => { + window.location.reload(); + }); +} + const appName = import.meta.env.VITE_HERMES_DESKTOP_APP_NAME?.trim(); document.title = appName || "Hermes One"; diff --git a/src/renderer/src/screens/Layout/Layout.tsx b/src/renderer/src/screens/Layout/Layout.tsx index 99ab50fc5..b316a6056 100644 --- a/src/renderer/src/screens/Layout/Layout.tsx +++ b/src/renderer/src/screens/Layout/Layout.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect, useMemo, useRef } from "react"; +import { lazy, Suspense, useState, useCallback, useEffect, useMemo, useRef } from "react"; import Chat from "../Chat/Chat"; import { dbItemsToChatMessages, @@ -27,7 +27,6 @@ import Skills from "../Skills/Skills"; import Memory from "../Memory/Memory"; import Tools from "../Tools/Tools"; import Gateway from "../Gateway/Gateway"; -import Office from "../Office/Office"; import Providers from "../Providers/Providers"; import Schedules from "../Schedules/Schedules"; import Kanban from "../Kanban/Kanban"; @@ -52,6 +51,10 @@ import { import type { LucideIcon } from "lucide-react"; import { useI18n } from "../../components/useI18n"; +// Office pulls @react-three/* (~3MB prebundle). Lazy-load so dev startup and +// the default Chat tab never block on Vite's drei optimizer finishing. +const Office = lazy(() => import("../Office/Office")); + type View = | "chat" | "discover" @@ -930,7 +933,9 @@ function Layout({ {visitedViews.has("office") && (
- + + +
)} diff --git a/src/renderer/src/screens/Office/Office.tsx b/src/renderer/src/screens/Office/Office.tsx index c703119da..75b0e4a57 100644 --- a/src/renderer/src/screens/Office/Office.tsx +++ b/src/renderer/src/screens/Office/Office.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Crown, DoorOpen, @@ -14,7 +14,6 @@ import type { GpuStatus } from "../../../../shared/gpu"; import { useI18n } from "../../components/useI18n"; import oneChatIcon from "../../assets/images/one-chat.svg"; import OneChatModal from "./OneChatModal"; -import Office3D from "./office3d/Office3D"; import RepInteractionPanel from "./RepInteractionPanel"; import { officeAgentsChanged, profilesToOfficeAgents } from "./office3d/agents"; import { @@ -37,6 +36,8 @@ import type { BuildingId, OfficeLocation } from "./office3d/core/locations"; import type { AgentPlace, OfficeAgent } from "./office3d/core/types"; import type { PlayerInteraction } from "./office3d/interactions/proximity"; +const Office3D = lazy(() => import("./office3d/Office3D")); + function isEditableTarget(t: EventTarget | null): boolean { return ( t instanceof HTMLElement && @@ -516,24 +517,26 @@ function Office({ visible, profile }: OfficeProps): React.JSX.Element {
- + + + {/* Walk-mode toggle: drop in as an avatar / return to the sky view. */} {!devMode && ( diff --git a/tests/remote-mode-url-and-spawn.test.ts b/tests/remote-mode-url-and-spawn.test.ts index 0ac629761..cd1f6e4fd 100644 --- a/tests/remote-mode-url-and-spawn.test.ts +++ b/tests/remote-mode-url-and-spawn.test.ts @@ -105,6 +105,9 @@ import http from "http"; afterEach(() => { vi.unstubAllGlobals(); + connModeRef.mode = "local"; + sshTunnelUrlRef.value = "http://localhost:18642"; + sshLocalPortRef.value = 18642; }); describe("normaliseRemoteUrl", () => { @@ -204,12 +207,13 @@ describe("Cron SSH fallback", () => { if (target === "http://127.0.0.1:18642/health") { return { ok: true } as Response; } - if (target === "http://127.0.0.1:18642/api/jobs?include_disabled=true") { + if (target === "http://127.0.0.1:18642/api/cron/jobs") { return { ok: true, - json: async () => ({ - jobs: [{ id: "job-1", name: "Daily", schedule: "0 9 * * *" }], - }), + status: 200, + json: async () => [ + { id: "job-1", name: "Daily", schedule: "0 9 * * *" }, + ], } as Response; } throw new Error(`unexpected fetch target: ${target}`); @@ -228,7 +232,51 @@ describe("Cron SSH fallback", () => { ); expect(fetchSpy).toHaveBeenNthCalledWith( 2, - "http://127.0.0.1:18642/api/jobs?include_disabled=true", + "http://127.0.0.1:18642/api/cron/jobs", + expect.any(Object), + ); + }); + + it("falls back to gateway /api/jobs when the dashboard cron route is unsupported", async () => { + connModeRef.mode = "remote"; + sshTunnelUrlRef.value = "http://example.com"; + + const fetchSpy = vi.fn(async (target: string) => { + if (target === "http://example.com/api/cron/jobs") { + return { + ok: false, + status: 405, + json: async () => ({ detail: "Method Not Allowed" }), + } as Response; + } + if ( + target === "http://example.com/api/jobs?include_disabled=true" + ) { + return { + ok: true, + status: 200, + json: async () => ({ + jobs: [{ id: "gw-1", name: "Legacy", schedule: "0 9 * * *" }], + }), + } as Response; + } + throw new Error(`unexpected fetch target: ${target}`); + }); + vi.stubGlobal("fetch", fetchSpy); + + const { listCronJobs } = await import("../src/main/cronjobs"); + const jobs = await listCronJobs(); + + expect(jobs).toHaveLength(1); + expect(jobs[0].id).toBe("gw-1"); + expect(fetchSpy).toHaveBeenNthCalledWith( + 1, + "http://example.com/api/cron/jobs", + expect.any(Object), + ); + expect(fetchSpy).toHaveBeenNthCalledWith( + 2, + "http://example.com/api/jobs?include_disabled=true", expect.any(Object), ); }); @@ -266,6 +314,17 @@ describe("Cron SSH fallback", () => { }); }); +describe("cronJobsFromResponseBody", () => { + it("accepts both dashboard arrays and gateway {jobs} wrappers", async () => { + const { cronJobsFromResponseBody } = await import("../src/main/cronjobs"); + expect(cronJobsFromResponseBody([{ id: "a" }])).toEqual([{ id: "a" }]); + expect(cronJobsFromResponseBody({ jobs: [{ id: "b" }] })).toEqual([ + { id: "b" }, + ]); + expect(cronJobsFromResponseBody(null)).toEqual([]); + }); +}); + describe("testRemoteConnection URL probe", () => { it("strips trailing /v1 before appending /health", async () => { // Capture the URL handed to http.request by the health probe. @@ -280,7 +339,11 @@ describe("testRemoteConnection URL probe", () => { // Find the response callback (last arg) and immediately fire it // with a fake 200 so the promise resolves cleanly. const cb = rest[rest.length - 1] as (res: unknown) => void; - cb({ statusCode: 200, resume: () => {} }); + cb({ + statusCode: 200, + headers: { "content-type": "application/json" }, + resume: () => {}, + }); // Stub minimal request handle return { on: () => {}, @@ -297,6 +360,30 @@ describe("testRemoteConnection URL probe", () => { reqSpy.mockRestore(); }); + + it("rejects dashboard SPA HTML /health as not a gateway api_server", async () => { + const reqSpy = vi + .spyOn(http, "request") + .mockImplementation((_target: unknown, ...rest: unknown[]) => { + const cb = rest[rest.length - 1] as (res: unknown) => void; + cb({ + statusCode: 200, + headers: { "content-type": "text/html; charset=utf-8" }, + resume: () => {}, + }); + return { + on: () => {}, + end: () => {}, + destroy: () => {}, + } as unknown as ReturnType; + }); + + await expect( + testRemoteConnection("http://127.0.0.1:9119"), + ).resolves.toBe(false); + + reqSpy.mockRestore(); + }); }); describe("startGateway / restartGateway in remote mode", () => { From 840ef4695b17ef125522cf675f37e944737cbd02 Mon Sep 17 00:00:00 2001 From: Fathah KA <48355244+fathah@users.noreply.github.com> Date: Thu, 23 Jul 2026 08:27:42 +0530 Subject: [PATCH 4/4] fix: address review feedback on dev-reload guards - Throttle the dev vite:preloadError reload via sessionStorage so a persistent failure surfaces an error instead of a reload loop - Make dev:clean cross-platform (ensure-vite-deps.mjs --force) instead of Unix-only rm -rf - Fix TS6133 unused param in vite-wait-for-drei middleware --- lat.md/main-process.md | 2 +- package.json | 2 +- scripts/ensure-vite-deps.mjs | 7 ++++++- scripts/vite-wait-for-drei.ts | 2 +- src/renderer/src/main.tsx | 11 +++++++++++ 5 files changed, 20 insertions(+), 4 deletions(-) diff --git a/lat.md/main-process.md b/lat.md/main-process.md index c4cdeb2be..b26897443 100644 --- a/lat.md/main-process.md +++ b/lat.md/main-process.md @@ -48,7 +48,7 @@ Because electron-vite emits a bundled main file at `out/main/index.js`, packaged Local `npm run dev` can show a blank window when Vite's dependency pre-bundler races Electron startup — especially the Office 3D stack (`@react-three/drei`, ~3.5MB). -[[electron.vite.config.ts]] pre-bundles `@react-three/fiber`, `three`, `troika-three-text`, and `three/examples/jsm/utils/SkeletonUtils.js` (Office rigged GLBs import it; discovering it on first Office visit re-optimizes and can delete `@react-three_drei.js`). [[scripts/vite-wait-for-drei.ts#waitForDreiPrebundle]] blocks the first HTML response until `@react-three_drei.js` exists. [[scripts/ensure-vite-deps.mjs]] clears a corrupt cache when only the `.map` remains. [[src/renderer/src/screens/Layout/Layout.tsx]] lazy-loads the Office tab so Chat startup never waits on drei. [[src/renderer/src/main.tsx]] reloads on `vite:preloadError`; [[src/main/app/start.ts]] reloads once when `#root` is still empty four seconds after load. Use `npm run dev:clean` when deps look stale. +[[electron.vite.config.ts]] pre-bundles `@react-three/fiber`, `three`, `troika-three-text`, and `three/examples/jsm/utils/SkeletonUtils.js` (Office rigged GLBs import it; discovering it on first Office visit re-optimizes and can delete `@react-three_drei.js`). [[scripts/vite-wait-for-drei.ts#waitForDreiPrebundle]] blocks the first HTML response until `@react-three_drei.js` exists. `scripts/ensure-vite-deps.mjs` clears a corrupt cache when only the `.map` remains. [[src/renderer/src/screens/Layout/Layout.tsx]] lazy-loads the Office tab so Chat startup never waits on drei. [[src/renderer/src/main.tsx]] reloads on `vite:preloadError` (sessionStorage-throttled to one reload per 15s so persistent failures surface instead of looping); [[src/main/app/start.ts]] reloads once when `#root` is still empty four seconds after load. Use `npm run dev:clean` (cross-platform: `ensure-vite-deps.mjs --force`) when deps look stale. ## App Chrome Helpers diff --git a/package.json b/package.json index 6d31a12c4..2cba5a213 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "typecheck:sandbox": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/hermes-sandbox.ps1 typecheck", "start": "electron-vite preview", "dev": "node scripts/ensure-vite-deps.mjs && electron-vite dev", - "dev:clean": "rm -rf node_modules/.vite && npm run dev", + "dev:clean": "node scripts/ensure-vite-deps.mjs --force && electron-vite dev", "dev:sandbox": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/hermes-sandbox.ps1 dev", "sandbox:sync-config": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/hermes-sandbox.ps1 sync-config", "dev:fresh": "HERMES_HOME=$(mktemp -d -t hermes-fresh) electron-vite dev", diff --git a/scripts/ensure-vite-deps.mjs b/scripts/ensure-vite-deps.mjs index 043627464..423d13f97 100644 --- a/scripts/ensure-vite-deps.mjs +++ b/scripts/ensure-vite-deps.mjs @@ -2,16 +2,21 @@ /** * Dev-only guard: Vite can leave a stale @react-three/drei pre-bundle (.map * without the .js) after an interrupted re-optimize, which 504s lazy Office loads. + * Pass --force to always clear the cache (cross-platform `dev:clean`). */ import fs from "node:fs"; import path from "node:path"; +const force = process.argv.includes("--force"); const cacheRoot = path.join("node_modules", ".vite"); const depsDir = path.join(cacheRoot, "deps"); const dreiJs = path.join(depsDir, "@react-three_drei.js"); const dreiMap = path.join(depsDir, "@react-three_drei.js.map"); -if (fs.existsSync(dreiMap) && !fs.existsSync(dreiJs)) { +if (force) { + console.warn("[dev] Clearing Vite dep cache (--force)"); + fs.rmSync(cacheRoot, { recursive: true, force: true }); +} else if (fs.existsSync(dreiMap) && !fs.existsSync(dreiJs)) { console.warn( "[dev] Removing corrupt Vite dep cache (@react-three_drei.js missing)", ); diff --git a/scripts/vite-wait-for-drei.ts b/scripts/vite-wait-for-drei.ts index 8abeb2c47..cbc3c6ea8 100644 --- a/scripts/vite-wait-for-drei.ts +++ b/scripts/vite-wait-for-drei.ts @@ -22,7 +22,7 @@ export function waitForDreiPrebundle(): Plugin { let ready: Promise | undefined; - server.middlewares.use((req, res, next) => { + server.middlewares.use((req, _res, next) => { if (req.method !== "GET" && req.method !== "HEAD") { next(); return; diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 80bf72e36..a2f3b5f80 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -8,9 +8,20 @@ import { initAnalytics } from "./utils/analytics"; // Vite returns 504 for stale optimized-dep hashes; reload once so Electron // picks up the new browserHash (browsers get this from @vite/client automatically). +// sessionStorage-throttled so a persistent failure surfaces instead of +// trapping the renderer in a reload loop (the guard survives the reload). // @lat [[main-process#Dev Vite loading]] if (import.meta.env.DEV) { + const RELOAD_AT_KEY = "hermes:vite-preload-reload-at"; window.addEventListener("vite:preloadError", () => { + const last = Number(sessionStorage.getItem(RELOAD_AT_KEY) || 0); + if (Date.now() - last < 15_000) { + console.error( + "[dev] vite:preloadError persists after reload — run: npm run dev:clean", + ); + return; + } + sessionStorage.setItem(RELOAD_AT_KEY, String(Date.now())); window.location.reload(); }); }