Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/desktop/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1450,6 +1450,8 @@ export const ja = defineLocale({
copyPath: 'パスをコピー',
removeFromSidebar: 'サイドバーから削除',
createFailed: 'プロジェクトを作成できませんでした',
staleBackend:
'プロジェクトを作成するには Hermes バックエンドを更新してください。バックエンドがこのデスクトップアプリより古いです(設定 → 更新 → バックエンド)。',
deleteConfirm:
'Hermes から保存済みプロジェクトを削除します。ファイル・git リポジトリ・ワークツリーはそのまま残ります。',
startWork: '新しいワークツリー',
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/i18n/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1076,6 +1076,7 @@ export interface Translations {
copyPath: string
removeFromSidebar: string
createFailed: string
staleBackend: string
deleteConfirm: string
startWork: string
newWorktreeTitle: string
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/i18n/zh-hant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1403,6 +1403,8 @@ export const zhHant = defineLocale({
copyPath: '複製路徑',
removeFromSidebar: '從側邊欄移除',
createFailed: '無法建立專案',
staleBackend:
'請更新 Hermes 後端以建立專案——目前後端比桌面應用舊(設定 → 更新 → 後端)。',
deleteConfirm: '這會從 Hermes 中移除已儲存的專案。檔案、git 儲存庫和工作樹維持不變。',
startWork: '新增工作樹',
newWorktreeTitle: '新增工作樹',
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1510,6 +1510,8 @@ export const zh: Translations = {
copyPath: '复制路径',
removeFromSidebar: '从侧边栏移除',
createFailed: '无法创建项目',
staleBackend:
'请更新 Hermes 后端以创建项目——当前后端比桌面应用旧(设置 → 更新 → 后端)。',
deleteConfirm: '这会从 Hermes 中移除已保存的项目。文件、git 仓库和工作树保持不变。',
startWork: '新建工作树',
newWorktreeTitle: '新建工作树',
Expand Down
16 changes: 16 additions & 0 deletions apps/desktop/src/lib/gateway-rpc.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
6 changes: 6 additions & 0 deletions apps/desktop/src/lib/gateway-rpc.ts
Original file line number Diff line number Diff line change
@@ -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)
}
54 changes: 54 additions & 0 deletions apps/desktop/src/store/projects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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' })
)
})
})
78 changes: 65 additions & 13 deletions apps/desktop/src/store/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -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.
}
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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' })
}

Expand Down
Loading