diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 7120ed664e0..351702deda3 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1358,6 +1358,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 2e54f9d787e..37655844e29 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1466,6 +1466,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 08bcb39ada2..dd3a176a07e 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1104,6 +1104,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 6c4e37c6f18..03c1d244911 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1419,6 +1419,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 a28495d4a64..3cd51e03df6 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -1538,6 +1538,8 @@ export const zh: Translations = { copyPath: '复制路径', removeFromSidebar: '从侧边栏移除', createFailed: '无法创建项目', + staleBackend: + '请更新 Hermes 后端以创建项目——当前后端比桌面应用旧(设置 → 更新 → 后端)。', deleteConfirm: '这会从 Hermes 中移除已保存的项目。文件、git 仓库和工作树保持不变。', startWork: '新建工作树', newWorktreeTitle: '新建工作树', diff --git a/apps/desktop/src/lib/gateway-rpc.test.ts b/apps/desktop/src/lib/gateway-rpc.test.ts new file mode 100644 index 00000000000..6da30b87b76 --- /dev/null +++ b/apps/desktop/src/lib/gateway-rpc.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' + +import { isMissingRpcMethod } from './gateway-rpc' + +describe('isMissingRpcMethod', () => { + it('detects JSON-RPC method-not-found errors', () => { + expect(isMissingRpcMethod(new Error('unknown method: projects.create'))).toBe(true) + expect(isMissingRpcMethod(new Error('Method not found'))).toBe(true) + expect(isMissingRpcMethod(new Error('RPC failed: -32601'))).toBe(true) + }) + + it('ignores unrelated failures', () => { + expect(isMissingRpcMethod(new Error('Hermes gateway is not connected'))).toBe(false) + expect(isMissingRpcMethod(new Error('no such project'))).toBe(false) + }) +}) diff --git a/apps/desktop/src/lib/gateway-rpc.ts b/apps/desktop/src/lib/gateway-rpc.ts new file mode 100644 index 00000000000..a209aefbd00 --- /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.test.ts b/apps/desktop/src/store/projects.test.ts index 9e80fe5b889..24bc2a6f854 100644 --- a/apps/desktop/src/store/projects.test.ts +++ b/apps/desktop/src/store/projects.test.ts @@ -5,15 +5,26 @@ import { $sidebarAgentsGrouped } from '@/store/layout' import { $activeProjectId, $projectScope, + $projectsRpcAvailable, $worktreeRefreshToken, ALL_PROJECTS, createProject, enterProject, exitProjectScope, + openProjectCreate, pickProjectFolder, + refreshProjects, refreshWorktrees } from './projects' +vi.mock('@/i18n', () => ({ + translateNow: (key: string) => key +})) + +vi.mock('@/store/notifications', () => ({ + notify: vi.fn() +})) + vi.mock('@/lib/desktop-fs', () => ({ desktopDefaultCwd: vi.fn(), isDesktopFsRemoteMode: vi.fn(), @@ -33,6 +44,8 @@ const selectDesktopPaths = vi.mocked(fs.selectDesktopPaths) const gw = await import('@/store/gateway') const activeGateway = vi.mocked(gw.activeGateway) +const notifications = await import('@/store/notifications') +const notify = vi.mocked(notifications.notify) describe('project scope', () => { beforeEach(() => { @@ -115,6 +128,7 @@ describe('createProject', () => { vi.clearAllMocks() $sidebarAgentsGrouped.set(false) $activeProjectId.set(null) + $projectsRpcAvailable.set(null) }) it('creates the project and flips into the grouped view so a blank slate shows it', async () => { @@ -139,4 +153,44 @@ describe('createProject', () => { expect($sidebarAgentsGrouped.get()).toBe(true) expect($activeProjectId.get()).toBe('p_new') }) + + it('marks the backend stale and surfaces a friendly error when projects.create is missing', async () => { + activeGateway.mockReturnValue({ + connectionState: 'open', + request: vi.fn().mockRejectedValue(new Error('unknown method: projects.create')) + } as never) + + await expect(createProject({ folders: ['/srv/demo'], name: 'Demo' })).rejects.toThrow( + 'sidebar.projects.staleBackend' + ) + expect($projectsRpcAvailable.get()).toBe(false) + }) +}) + +describe('projects RPC capability', () => { + beforeEach(() => { + vi.clearAllMocks() + $projectsRpcAvailable.set(null) + }) + + it('marks the backend stale when projects.list is missing', async () => { + activeGateway.mockReturnValue({ + connectionState: 'open', + request: vi.fn().mockRejectedValue(new Error('unknown method: projects.list')) + } as never) + + await refreshProjects() + + expect($projectsRpcAvailable.get()).toBe(false) + }) + + it('blocks opening the create dialog once the backend is known stale', () => { + $projectsRpcAvailable.set(false) + + openProjectCreate() + + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'warning', message: 'sidebar.projects.staleBackend' }) + ) + }) }) diff --git a/apps/desktop/src/store/projects.ts b/apps/desktop/src/store/projects.ts index 615ff9efe22..bb551f8241f 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' }) }