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
This commit is contained in:
xxxigm 2026-06-30 21:23:38 +07:00
parent 217b3c283a
commit 83e10a6777
7 changed files with 80 additions and 13 deletions

View file

@ -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',

View file

@ -1450,6 +1450,8 @@ export const ja = defineLocale({
copyPath: 'パスをコピー',
removeFromSidebar: 'サイドバーから削除',
createFailed: 'プロジェクトを作成できませんでした',
staleBackend:
'プロジェクトを作成するには Hermes バックエンドを更新してください。バックエンドがこのデスクトップアプリより古いです(設定 → 更新 → バックエンド)。',
deleteConfirm:
'Hermes から保存済みプロジェクトを削除します。ファイル・git リポジトリ・ワークツリーはそのまま残ります。',
startWork: '新しいワークツリー',

View file

@ -1076,6 +1076,7 @@ export interface Translations {
copyPath: string
removeFromSidebar: string
createFailed: string
staleBackend: string
deleteConfirm: string
startWork: string
newWorktreeTitle: string

View file

@ -1403,6 +1403,8 @@ export const zhHant = defineLocale({
copyPath: '複製路徑',
removeFromSidebar: '從側邊欄移除',
createFailed: '無法建立專案',
staleBackend:
'請更新 Hermes 後端以建立專案——目前後端比桌面應用舊(設定 → 更新 → 後端)。',
deleteConfirm: '這會從 Hermes 中移除已儲存的專案。檔案、git 儲存庫和工作樹維持不變。',
startWork: '新增工作樹',
newWorktreeTitle: '新增工作樹',

View file

@ -1510,6 +1510,8 @@ export const zh: Translations = {
copyPath: '复制路径',
removeFromSidebar: '从侧边栏移除',
createFailed: '无法创建项目',
staleBackend:
'请更新 Hermes 后端以创建项目——当前后端比桌面应用旧(设置 → 更新 → 后端)。',
deleteConfirm: '这会从 Hermes 中移除已保存的项目。文件、git 仓库和工作树保持不变。',
startWork: '新建工作树',
newWorktreeTitle: '新建工作树',

View file

@ -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)
}

View file

@ -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 | string>(null)
export const $projectTree = atom<SidebarProjectTree[]>([])
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<boolean | null>(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<void> {
try {
applyPayload(await gatewayRequest<ProjectsPayload>('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<void> {
$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<ProjectInfo | null> {
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 | ProjectDialogState>(null)
export function openProjectCreate(): void {
if ($projectsRpcAvailable.get() === false) {
notify({
kind: 'warning',
message: translateNow('sidebar.projects.staleBackend')
})
return
}
$projectDialog.set({ mode: 'create' })
}