From 83e10a6777f93ba2dfb2b344314912bb9f969202 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Tue, 30 Jun 2026 21:23:38 +0700 Subject: [PATCH] fix(desktop): detect stale backend for projects.create Probe the projects.* RPC surface, block create with a clear update hint, and avoid the raw "unknown method" toast. Includes i18n for en, zh, ja, and zh-hant. Fixes NousResearch/hermes-agent#54999 --- apps/desktop/src/i18n/en.ts | 2 + apps/desktop/src/i18n/ja.ts | 2 + apps/desktop/src/i18n/types.ts | 1 + apps/desktop/src/i18n/zh-hant.ts | 2 + apps/desktop/src/i18n/zh.ts | 2 + apps/desktop/src/lib/gateway-rpc.ts | 6 +++ apps/desktop/src/store/projects.ts | 78 ++++++++++++++++++++++++----- 7 files changed, 80 insertions(+), 13 deletions(-) create mode 100644 apps/desktop/src/lib/gateway-rpc.ts diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 4f0610a31..b17ff934b 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1330,6 +1330,8 @@ export const en: Translations = { copyPath: 'Copy path', removeFromSidebar: 'Hide from sidebar', createFailed: 'Could not create project', + staleBackend: + 'Update the Hermes backend to create projects — your backend is older than this desktop app (Settings → Updates → Backend).', deleteConfirm: 'This removes the saved project from Hermes. Files, git repos, and worktrees stay untouched.', startWork: 'New worktree', newWorktreeTitle: 'New worktree', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 36c2a83ad..190474f6e 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1450,6 +1450,8 @@ export const ja = defineLocale({ copyPath: 'パスをコピー', removeFromSidebar: 'サイドバーから削除', createFailed: 'プロジェクトを作成できませんでした', + staleBackend: + 'プロジェクトを作成するには Hermes バックエンドを更新してください。バックエンドがこのデスクトップアプリより古いです(設定 → 更新 → バックエンド)。', deleteConfirm: 'Hermes から保存済みプロジェクトを削除します。ファイル・git リポジトリ・ワークツリーはそのまま残ります。', startWork: '新しいワークツリー', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index adbe452a7..4d19b3043 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1076,6 +1076,7 @@ export interface Translations { copyPath: string removeFromSidebar: string createFailed: string + staleBackend: string deleteConfirm: string startWork: string newWorktreeTitle: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 399fd22e9..9c3e9be73 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1403,6 +1403,8 @@ export const zhHant = defineLocale({ copyPath: '複製路徑', removeFromSidebar: '從側邊欄移除', createFailed: '無法建立專案', + staleBackend: + '請更新 Hermes 後端以建立專案——目前後端比桌面應用舊(設定 → 更新 → 後端)。', deleteConfirm: '這會從 Hermes 中移除已儲存的專案。檔案、git 儲存庫和工作樹維持不變。', startWork: '新增工作樹', newWorktreeTitle: '新增工作樹', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index b41b7edaa..c26db70d5 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -1510,6 +1510,8 @@ export const zh: Translations = { copyPath: '复制路径', removeFromSidebar: '从侧边栏移除', createFailed: '无法创建项目', + staleBackend: + '请更新 Hermes 后端以创建项目——当前后端比桌面应用旧(设置 → 更新 → 后端)。', deleteConfirm: '这会从 Hermes 中移除已保存的项目。文件、git 仓库和工作树保持不变。', startWork: '新建工作树', newWorktreeTitle: '新建工作树', diff --git a/apps/desktop/src/lib/gateway-rpc.ts b/apps/desktop/src/lib/gateway-rpc.ts new file mode 100644 index 000000000..a209aefbd --- /dev/null +++ b/apps/desktop/src/lib/gateway-rpc.ts @@ -0,0 +1,6 @@ +/** True when a JSON-RPC call failed because the backend predates the method. */ +export function isMissingRpcMethod(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + + return /method not found|-32601|unknown method|no such method/i.test(message) +} diff --git a/apps/desktop/src/store/projects.ts b/apps/desktop/src/store/projects.ts index 615ff9efe..bb551f824 100644 --- a/apps/desktop/src/store/projects.ts +++ b/apps/desktop/src/store/projects.ts @@ -4,8 +4,11 @@ import { liveSessionProjectId, type SidebarProjectTree } from '@/app/chat/sideba import type { HermesGitBranch } from '@/global' import { desktopDefaultCwd, selectDesktopPaths, writeDesktopFileText } from '@/lib/desktop-fs' import { desktopGit } from '@/lib/desktop-git' +import { isMissingRpcMethod } from '@/lib/gateway-rpc' import { persistentAtom } from '@/lib/persisted' +import { translateNow } from '@/i18n' import { activeGateway, ensureActiveGatewayOpen } from '@/store/gateway' +import { notify } from '@/store/notifications' import { setSidebarAgentsGrouped } from '@/store/layout' import { requestFreshSession } from '@/store/profile' import { $selectedStoredSessionId, $sessions, workspaceCwdForNewSession } from '@/store/session' @@ -26,6 +29,24 @@ export const $activeProjectId = atom(null) export const $projectTree = atom([]) export const $projectTreeLoading = atom(false) +// False when the connected backend predates the projects.* JSON-RPC surface +// (same semver label, older install). Null until the first probe. +export const $projectsRpcAvailable = atom(null) + +function markProjectsRpcSuccess(): void { + $projectsRpcAvailable.set(true) +} + +function markProjectsRpcFailure(err: unknown): void { + if (isMissingRpcMethod(err)) { + $projectsRpcAvailable.set(false) + } +} + +function projectsStaleBackendError(): Error { + return new Error(translateNow('sidebar.projects.staleBackend')) +} + // Client-side cache eviction (Apollo-style optimistic layer): ids the user just // deleted/archived. The backend tree is a snapshot that still lists them until // its next refresh, so the render-time overlay strips these so the tree matches @@ -216,7 +237,9 @@ function applyPayload(payload: ProjectsPayload): void { export async function refreshProjects(): Promise { try { applyPayload(await gatewayRequest('projects.list')) - } catch { + markProjectsRpcSuccess() + } catch (err) { + markProjectsRpcFailure(err) // Backend may not be ready; keep the last known list. } } @@ -254,7 +277,10 @@ export async function refreshProjectTree(): Promise { $removedSessionIds.set(pending) } } - } catch { + + markProjectsRpcSuccess() + } catch (err) { + markProjectsRpcFailure(err) // Backend may not be ready; keep the last known tree. } finally { $projectTreeLoading.set(false) @@ -410,17 +436,34 @@ function projectInfoToTreeNode(project: ProjectInfo): SidebarProjectTree { } export async function createProject(input: CreateProjectInput): Promise { - const res = await gatewayRequest<{ project: ProjectInfo | null }>('projects.create', { - name: input.name, - folders: input.folders ?? [], - primary_path: input.primaryPath, - slug: input.slug, - description: input.description, - icon: input.icon, - color: input.color, - board_slug: input.boardSlug, - use: input.use ?? false - }) + if ($projectsRpcAvailable.get() === false) { + throw projectsStaleBackendError() + } + + let res: { project: ProjectInfo | null } + + try { + res = await gatewayRequest<{ project: ProjectInfo | null }>('projects.create', { + name: input.name, + folders: input.folders ?? [], + primary_path: input.primaryPath, + slug: input.slug, + description: input.description, + icon: input.icon, + color: input.color, + board_slug: input.boardSlug, + use: input.use ?? false + }) + } catch (err) { + if (isMissingRpcMethod(err)) { + $projectsRpcAvailable.set(false) + throw projectsStaleBackendError() + } + + throw err + } + + markProjectsRpcSuccess() // Not optimistic (the create awaits the RPC first, so there's nothing to roll // back): apply the server's row into the cached list + tree at once, so it @@ -593,6 +636,15 @@ export interface ProjectDialogState { export const $projectDialog = atom(null) export function openProjectCreate(): void { + if ($projectsRpcAvailable.get() === false) { + notify({ + kind: 'warning', + message: translateNow('sidebar.projects.staleBackend') + }) + + return + } + $projectDialog.set({ mode: 'create' }) }