fix(desktop): route project writes to the live profile in All profiles (#112943)
* fix(desktop): route project writes to the live profile in All profiles All profiles is a sidebar filter, not a write target. Appearance, rename, add-folder, and delete now use the active gateway profile, matching create. * test(desktop): type the all-profiles write fixture as ProjectInfo --------- Co-authored-by: Guillaume <guillaume.eynaud@icloud.com>
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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<string, unknown> = {},
|
||||
profile: null | string = projectProfile()
|
||||
@@ -880,7 +891,7 @@ export async function createProject(input: CreateProjectInput): Promise<ProjectI
|
||||
try {
|
||||
// All profiles filters the sidebar, not the owner of a new project.
|
||||
// Capture the live route so reconnecting cannot retarget the write.
|
||||
const context = await activeProjectsContext(normalizeProfileKey($activeGatewayProfile.get()))
|
||||
const context = await activeProjectsContext(writableProjectProfile())
|
||||
|
||||
res = await gatewayRequestOn<{ project: ProjectInfo | null }>(
|
||||
context.gateway,
|
||||
@@ -963,6 +974,7 @@ export async function updateProject(
|
||||
id: string,
|
||||
patch: { name?: string; color?: null | string; icon?: null | string }
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
}
|
||||
|
||||
await persistOrRollback(snap, async () => {
|
||||
applyPayload(await gatewayRequest<ProjectsPayload>('projects.delete', projectParams({ id })))
|
||||
applyPayload(
|
||||
await gatewayRequestOn<ProjectsPayload>(
|
||||
context.gateway,
|
||||
'projects.delete',
|
||||
projectParams({ id }, context.profile)
|
||||
)
|
||||
)
|
||||
})
|
||||
void refreshProjectTree()
|
||||
}
|
||||
|
||||
export async function setActiveProject(id: null | string): Promise<void> {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user