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:
Austin Pickett
2026-09-16 11:18:20 -04:00
committed by GitHub
parent 948e970661
commit 6bea68a1de
2 changed files with 126 additions and 13 deletions

View File

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

View File

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