diff --git a/apps/desktop/src/store/projects.test.ts b/apps/desktop/src/store/projects.test.ts index 4d49435b46..ac8f7ed237 100644 --- a/apps/desktop/src/store/projects.test.ts +++ b/apps/desktop/src/store/projects.test.ts @@ -5,6 +5,7 @@ import { NO_PROJECT_ID, type SidebarProjectTree } from '@/app/chat/sidebar/proje import { $sidebarAgentsGrouped, setSidebarAgentsGrouped } from '@/store/layout' import { $activeGatewayProfile, $profileScope, ALL_PROFILES, setShowAllProfiles } from '@/store/profile' import { $currentCwd, $selectedStoredSessionId, $sessions, applyConfiguredDefaultProjectDir } from '@/store/session' +import type { ProjectInfo } from '@/types/hermes' import { $activeProjectId, @@ -13,8 +14,10 @@ import { $projectsRpcAvailable, $projectTree, $worktreeRefreshToken, + addProjectFolder, ALL_PROJECTS, createProject, + deleteProject, enterProject, exitProjectScope, fetchProjectSessions, @@ -27,7 +30,8 @@ import { refreshWorktrees, resolveNewSessionCwd, scanAndRecordRepos, - startWorkInRepo + startWorkInRepo, + updateProject } from './projects' import { $removedSessionIds, @@ -499,6 +503,83 @@ describe('createProject', () => { }) }) +describe('project writes while viewing all profiles', () => { + const project: ProjectInfo = { + archived: false, + board_slug: null, + color: null, + created_at: 0, + description: null, + folders: [], + icon: null, + id: 'p_1', + name: 'Warsongs', + primary_path: '/srv/ws', + slug: 'warsongs' + } + + beforeEach(() => { + vi.clearAllMocks() + $activeProjectId.set(null) + $projectsRpcAvailable.set(null) + $projects.set([project]) + $projectTree.set([ + { id: project.id, label: project.name, path: project.primary_path, color: null, icon: null, repos: [], sessionCount: 0 } + ]) + $activeGatewayProfile.set('default') + setShowAllProfiles(false) + }) + + afterEach(() => { + setShowAllProfiles(false) + $activeGatewayProfile.set('default') + }) + + it.each(['default', 'coder'])('updates appearance in the active %s profile without leaving All profiles', async profile => { + const request = vi.fn().mockResolvedValue({}) + activeGateway.mockReturnValue({ connectionState: 'open', request } as never) + $activeGatewayProfile.set(profile) + setShowAllProfiles(true) + + await expect(updateProject(project.id, { color: '#ff0000' })).resolves.toBeUndefined() + + expect(request).toHaveBeenCalledWith( + 'projects.update', + expect.objectContaining({ profile, id: project.id, color: '#ff0000' }) + ) + expect($profileScope.get()).toBe(ALL_PROFILES) + expect($projects.get()).toEqual([expect.objectContaining({ id: project.id, color: '#ff0000' })]) + }) + + it.each(['default', 'coder'])('adds a folder in the active %s profile without leaving All profiles', async profile => { + const request = vi.fn().mockResolvedValue({}) + activeGateway.mockReturnValue({ connectionState: 'open', request } as never) + $activeGatewayProfile.set(profile) + setShowAllProfiles(true) + + await expect(addProjectFolder(project.id, '/srv/ws/extra')).resolves.toBeUndefined() + + expect(request).toHaveBeenCalledWith( + 'projects.add_folder', + expect.objectContaining({ profile, id: project.id, path: '/srv/ws/extra' }) + ) + expect($profileScope.get()).toBe(ALL_PROFILES) + }) + + it.each(['default', 'coder'])('deletes a project in the active %s profile without leaving All profiles', async profile => { + const request = vi.fn().mockResolvedValue({ active_id: null, projects: [], scoped_session_ids: [] }) + activeGateway.mockReturnValue({ connectionState: 'open', request } as never) + $activeGatewayProfile.set(profile) + setShowAllProfiles(true) + + await expect(deleteProject(project.id)).resolves.toBeUndefined() + + expect(request).toHaveBeenCalledWith('projects.delete', expect.objectContaining({ profile, id: project.id })) + expect($profileScope.get()).toBe(ALL_PROFILES) + expect($projects.get()).toEqual([]) + }) +}) + describe('projects RPC capability', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/desktop/src/store/projects.ts b/apps/desktop/src/store/projects.ts index 9caab3a116..1a51ff25da 100644 --- a/apps/desktop/src/store/projects.ts +++ b/apps/desktop/src/store/projects.ts @@ -285,6 +285,17 @@ export function projectProfile(): null | string { return $profileScope.get() === ALL_PROFILES || profile === ALL_PROFILES ? null : profile } +// All profiles filters the sidebar. Writes still belong to the live gateway profile. +function writableProjectProfile(): string { + const profile = normalizeProfileKey($activeGatewayProfile.get()) + + if (!profile || profile === ALL_PROFILES) { + throw new Error('Projects are unavailable while viewing all profiles') + } + + return profile +} + function projectParams( params: Record = {}, profile: null | string = projectProfile() @@ -880,7 +891,7 @@ export async function createProject(input: CreateProjectInput): Promise( context.gateway, @@ -963,6 +974,7 @@ export async function updateProject( id: string, patch: { name?: string; color?: null | string; icon?: null | string } ): Promise { + const context = await activeProjectsContext(writableProjectProfile()) const snap = snapshotProjects() $projectTree.set( @@ -982,14 +994,18 @@ export async function updateProject( // Backend treats null/undefined as "leave unchanged"; "" clears (stores NULL). // Map explicit null → "" so "no color"/"no icon" actually clear. await persistOrRollback(snap, () => - gatewayRequest( + gatewayRequestOn( + context.gateway, 'projects.update', - projectParams({ - id, - ...patch, - ...(patch.color === null && { color: '' }), - ...(patch.icon === null && { icon: '' }) - }) + projectParams( + { + id, + ...patch, + ...(patch.color === null && { color: '' }), + ...(patch.icon === null && { icon: '' }) + }, + context.profile + ) ) ) } @@ -1031,6 +1047,7 @@ export async function addProjectFolder( path: string, opts: { label?: string; isPrimary?: boolean } = {} ): Promise { + const context = await activeProjectsContext(writableProjectProfile()) const snap = snapshotProjects() const trimmed = path.trim() @@ -1061,9 +1078,10 @@ export async function addProjectFolder( } await persistOrRollback(snap, () => - gatewayRequest( + gatewayRequestOn( + context.gateway, 'projects.add_folder', - projectParams({ id, path, label: opts.label, is_primary: opts.isPrimary ?? false }) + projectParams({ id, path, label: opts.label, is_primary: opts.isPrimary ?? false }, context.profile) ) ) reconcileProjects() @@ -1088,6 +1106,7 @@ function openSessionBelongsToProject(projectId: string, projects: ProjectInfo[]) // clicked (the entered-scope effect exits if you deleted the project you were // inside), reconciling from the server payload. A failed delete restores both. export async function deleteProject(id: string): Promise { + const context = await activeProjectsContext(writableProjectProfile()) const snap = snapshotProjects() // Capture membership BEFORE removal — the project's folders (which determine // ownership) are gone once it's dropped from the cache. @@ -1107,13 +1126,26 @@ export async function deleteProject(id: string): Promise { } await persistOrRollback(snap, async () => { - applyPayload(await gatewayRequest('projects.delete', projectParams({ id }))) + applyPayload( + await gatewayRequestOn( + context.gateway, + 'projects.delete', + projectParams({ id }, context.profile) + ) + ) }) void refreshProjectTree() } export async function setActiveProject(id: null | string): Promise { - const res = await gatewayRequest<{ active_id: null | string }>('projects.set_active', projectParams({ id })) + const context = await activeProjectsContext(writableProjectProfile()) + + const res = await gatewayRequestOn<{ active_id: null | string }>( + context.gateway, + 'projects.set_active', + projectParams({ id }, context.profile) + ) + $activeProjectId.set(res.active_id ?? null) }