feat(desktop): add custom project display names

This commit is contained in:
程序员阿江(Relakkes)
2026-08-11 02:32:22 +08:00
parent c23632d2c4
commit a0afcc7efa
31 changed files with 2748 additions and 135 deletions
@@ -4,6 +4,7 @@ import { desktopUiPreferencesApi, getProfileAvatarUrl } from './desktopUiPrefere
const preferences = {
schemaVersion: 3,
projectDisplayNames: {},
profile: {
displayName: 'cc-haha',
subtitle: 'github.com/NanmiCoder/cc-haha',
@@ -58,6 +59,62 @@ describe('desktopUiPreferencesApi', () => {
)
})
it('updates project display names and sends null to reset one', async () => {
setBaseUrl('http://127.0.0.1:49237')
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock
.mockResolvedValueOnce(new Response(JSON.stringify({
ok: true,
projectKey: '/workspace/apps/../project',
displayName: 'Project alias',
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify({
ok: true,
projectKey: '/workspace/apps/../project',
displayName: null,
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}))
await expect(desktopUiPreferencesApi.updateProjectDisplayName(
'/workspace/apps/../project',
'Project alias',
)).resolves.toEqual({
ok: true,
projectKey: '/workspace/apps/../project',
displayName: 'Project alias',
})
await expect(desktopUiPreferencesApi.updateProjectDisplayName(
'/workspace/apps/../project',
null,
)).resolves.toEqual({
ok: true,
projectKey: '/workspace/apps/../project',
displayName: null,
})
expect(fetchMock).toHaveBeenNthCalledWith(
1,
'http://127.0.0.1:49237/api/desktop-ui/preferences/project-display-name',
expect.objectContaining({
method: 'PUT',
body: JSON.stringify({ projectKey: '/workspace/apps/../project', displayName: 'Project alias' }),
}),
)
expect(fetchMock).toHaveBeenNthCalledWith(
2,
'http://127.0.0.1:49237/api/desktop-ui/preferences/project-display-name',
expect.objectContaining({
method: 'PUT',
body: JSON.stringify({ projectKey: '/workspace/apps/../project', displayName: null }),
}),
)
})
it('reads only the pet projection through the scoped preference endpoint', async () => {
setBaseUrl('http://127.0.0.1:49237')
const fetchMock = vi.spyOn(globalThis, 'fetch')
+14
View File
@@ -30,6 +30,7 @@ export type DesktopUiPreferences = {
sidebar: SidebarProjectPreferences
profile: DesktopProfilePreferences
pet: DesktopPetPreferences
projectDisplayNames: Record<string, string>
}
export type DesktopUiPreferencesResponse = {
@@ -46,6 +47,12 @@ export type DesktopPetPreferencesUpdateResponse =
| { ok: true; preferences: DesktopUiPreferences }
| { ok: true; pet: DesktopPetPreferences }
export type ProjectDisplayNameUpdateResponse = {
ok: true
projectKey: string
displayName: string | null
}
export const desktopUiPreferencesApi = {
getPreferences() {
return api.get<DesktopUiPreferencesResponse>('/api/desktop-ui/preferences')
@@ -62,6 +69,13 @@ export const desktopUiPreferencesApi = {
)
},
updateProjectDisplayName(projectKey: string, displayName: string | null) {
return api.put<ProjectDisplayNameUpdateResponse>(
'/api/desktop-ui/preferences/project-display-name',
{ projectKey, displayName },
)
},
updateProfilePreferences(profile: Pick<DesktopProfilePreferences, 'displayName' | 'subtitle'>) {
return api.put<{ ok: true; preferences: DesktopUiPreferences }>(
'/api/desktop-ui/preferences/profile',
@@ -113,6 +113,10 @@ import { useWorkflowStore } from '../../stores/workflowStore'
import { workflowsApi } from '../../api/workflows'
import { browserHost } from '../../lib/desktopHost/browserHost'
import { settingsApi } from '../../api/settings'
import {
captureProjectDisplayNameHydrationRevision,
hydrateProjectDisplayNames,
} from '../../stores/projectDisplayNameStore'
/**
* Opens the run-location pill's menu. Directory, branch and worktree all live
@@ -197,6 +201,9 @@ describe('ChatInput file mentions', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.createRepositoryBranch.mockReset()
act(() => {
hydrateProjectDisplayNames({}, Number.MAX_SAFE_INTEGER)
})
mocks.webviewDragHandlers.length = 0
Reflect.deleteProperty(window, 'desktopHost')
delete (window as Window & { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__
@@ -285,6 +292,9 @@ describe('ChatInput file mentions', () => {
afterEach(() => {
vi.restoreAllMocks()
act(() => {
hydrateProjectDisplayNames({}, Number.MAX_SAFE_INTEGER)
})
vi.unstubAllGlobals()
if (originalOffsetWidth) {
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', originalOffsetWidth)
@@ -891,6 +901,64 @@ describe('ChatInput file mentions', () => {
expect(screen.queryByRole('button', { name: /^Location/ })).not.toBeInTheDocument()
})
it('reactively uses the active session project root display name while retaining the active worktree path in context', async () => {
const worktreePath = '/repo/.claude/worktrees/desktop-main-12345678'
useSessionStore.setState({
sessions: [{
id: sessionId,
title: 'Project',
createdAt: '2026-05-01T00:00:00.000Z',
modifiedAt: '2026-05-01T00:00:00.000Z',
messageCount: 1,
projectPath: '/repo',
projectRoot: '/repo',
workDir: worktreePath,
workDirExists: true,
}],
activeSessionId: sessionId,
})
mocks.getGitInfo.mockResolvedValue({
branch: 'main',
repoName: 'repo',
workDir: worktreePath,
changedFiles: 0,
worktree: {
enabled: true,
path: worktreePath,
plannedPath: null,
sourceWorkDir: '/repo',
slug: 'desktop-main-12345678',
branch: 'main',
},
})
render(<ChatInput />)
const chip = await screen.findByTestId('run-location-readonly')
expect(chip).toHaveTextContent('repo')
act(() => {
hydrateProjectDisplayNames(
{ '/repo': 'Personal repo' },
captureProjectDisplayNameHydrationRevision(),
)
})
expect(chip).toHaveTextContent('Personal repo')
// Once the alias takes over the label, the worktree details tooltip is the
// only place still naming the real project root and the active worktree
// path — the toolbar variant carries no `title` for a worktree.
expect(chip).not.toHaveAttribute('title')
fireEvent.mouseEnter(screen.getByTestId('worktree-details-trigger'))
const tooltip = await screen.findByRole('tooltip')
// Exact-text lookups: '/repo' is a prefix of the worktree path, so a
// substring assertion would pass even if the project root row went missing.
expect(within(tooltip).getByText('/repo')).toBeInTheDocument()
expect(within(tooltip).getByText(worktreePath)).toBeInTheDocument()
})
// The narrow layouts never adopted the in-toolbar pill: there is no room for
// it beside the model selector, so they keep the location on its own line
// below the panel.
@@ -1434,6 +1434,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
) : (
<ProjectContextChip
workDir={resolvedWorkDir}
projectRoot={activeSession?.projectRoot}
repoName={gitInfo?.repoName || null}
branch={gitInfo?.branch || null}
sourceWorkDir={gitInfo?.worktree?.sourceWorkDir || null}
@@ -1539,6 +1540,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
{messageCount > 0 ? (
<ProjectContextChip
workDir={resolvedWorkDir}
projectRoot={activeSession?.projectRoot}
repoName={gitInfo?.repoName || null}
branch={gitInfo?.branch || null}
sourceWorkDir={gitInfo?.worktree?.sourceWorkDir || null}
@@ -1,6 +1,10 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import '@testing-library/jest-dom'
import {
captureProjectDisplayNameHydrationRevision,
hydrateProjectDisplayNames,
} from '../../stores/projectDisplayNameStore'
import { ProjectContextChip } from './ProjectContextChip'
afterEach(() => {
@@ -8,6 +12,18 @@ afterEach(() => {
})
describe('ProjectContextChip', () => {
beforeEach(() => {
act(() => {
hydrateProjectDisplayNames({}, Number.MAX_SAFE_INTEGER)
})
})
afterEach(() => {
act(() => {
hydrateProjectDisplayNames({}, Number.MAX_SAFE_INTEGER)
})
})
it('shows only the source project label and worktree marker for isolated worktrees', () => {
vi.useFakeTimers()
render(
@@ -62,6 +78,53 @@ describe('ProjectContextChip', () => {
expect(trigger).toHaveAttribute('aria-describedby', tooltip.id)
})
it('prioritizes the project-root display name and retains raw worktree context in its tooltip', () => {
vi.useFakeTimers()
const projectRoot = '/workspace/canonical-project'
const sourceWorkDir = '/workspace/OpenCutSkill'
const worktreePath = '/workspace/OpenCutSkill/.claude/worktrees/desktop-main-54a09f85'
render(
<ProjectContextChip
workDir={worktreePath}
projectRoot={projectRoot}
repoName="OpenCutSkill"
branch="main"
sourceWorkDir={sourceWorkDir}
isWorktree
worktreeSlug="desktop-main-54a09f85"
worktreePath={worktreePath}
/>,
)
const chip = screen.getByTestId('run-location-outside')
expect(chip).toHaveTextContent('OpenCutSkill')
act(() => {
hydrateProjectDisplayNames(
{
[projectRoot]: 'OpenCut Studio',
[sourceWorkDir]: 'Source project name',
},
captureProjectDisplayNameHydrationRevision(),
)
})
expect(chip).toHaveTextContent('OpenCut Studio')
expect(chip).not.toHaveTextContent('Source project name')
// The alias replaces the folder name on the chip, so the real directory has
// to stay reachable somewhere. For a worktree that is the details tooltip —
// the chip itself carries no `title` in this mode.
expect(chip).not.toHaveAttribute('title')
fireEvent.mouseEnter(screen.getByTestId('worktree-details-trigger'))
act(() => { vi.advanceTimersByTime(400) })
const tooltip = screen.getByRole('tooltip')
expect(tooltip).toHaveTextContent(projectRoot)
expect(tooltip).toHaveTextContent(worktreePath)
})
it('does not show worktree details for a normal checkout', () => {
render(
<ProjectContextChip
@@ -2,10 +2,12 @@ import { GitFork } from 'lucide-react'
import { Tooltip } from '@/components/ui/Tooltip'
import { useTranslation } from '../../i18n'
import { getFileNameFromPath } from '../../lib/composerAttachments'
import { useProjectDisplayName } from '../../stores/projectDisplayNameStore'
import { getWorktreeDisplayName, WorktreeDetails } from './WorktreeDetails'
type Props = {
workDir?: string | null
projectRoot?: string | null
repoName?: string | null
branch?: string | null
sourceWorkDir?: string | null
@@ -30,6 +32,7 @@ function basename(path: string | null | undefined): string {
export function ProjectContextChip({
workDir,
projectRoot,
repoName,
branch,
sourceWorkDir,
@@ -41,7 +44,9 @@ export function ProjectContextChip({
}: Props) {
const t = useTranslation()
const labelRoot = isWorktree ? (sourceWorkDir || workDir) : workDir
const label = branch ? (repoName || basename(labelRoot)) : (basename(labelRoot) || repoName || '')
const displayNameKey = projectRoot ?? sourceWorkDir ?? workDir ?? ''
const displayName = useProjectDisplayName(displayNameKey)
const label = displayName || (branch ? (repoName || basename(labelRoot)) : (basename(labelRoot) || repoName || ''))
const worktreeName = getWorktreeDisplayName(worktreeSlug, worktreePath) || 'isolated'
const isToolbar = variant === 'toolbar'
// The chip variant hides the branch for worktrees — the worktree name already
@@ -50,10 +55,17 @@ export function ProjectContextChip({
const showBranch = !!branch && (isToolbar || !isWorktree)
const title = [
label,
displayName ? `project root: ${displayNameKey}` : null,
branch ? `branch: ${branch}` : null,
workDir ? `cwd: ${workDir}` : null,
].filter(Boolean).join('\n')
const worktreeDetails = <WorktreeDetails name={worktreeName} path={worktreePath} />
const worktreeDetails = (
<WorktreeDetails
name={worktreeName}
path={worktreePath}
projectRoot={displayName ? displayNameKey : null}
/>
)
if (!label) return null
@@ -1,9 +1,9 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { useState, type ComponentProps } from 'react'
import { act, useState, type ComponentProps } from 'react'
import { flushSync } from 'react-dom'
import { createRoot } from 'react-dom/client'
import { create } from 'zustand'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import '@testing-library/jest-dom'
const viewportMocks = vi.hoisted(() => ({
@@ -104,6 +104,10 @@ vi.mock('../../i18n', () => ({
}))
import { RepositoryLaunchControls } from './RepositoryLaunchControls'
import {
captureProjectDisplayNameHydrationRevision,
hydrateProjectDisplayNames,
} from '../../stores/projectDisplayNameStore'
const HEAD_COMMIT = 'a'.repeat(40)
const OTHER_COMMIT = 'b'.repeat(40)
@@ -243,6 +247,9 @@ async function openBranchView() {
describe('RepositoryLaunchControls', () => {
beforeEach(() => {
act(() => {
hydrateProjectDisplayNames({}, Number.MAX_SAFE_INTEGER)
})
viewportMocks.isMobile = false
viewportMocks.isTauri = false
apiMocks.getRepositoryContext.mockReset()
@@ -257,6 +264,12 @@ describe('RepositoryLaunchControls', () => {
Element.prototype.scrollIntoView = vi.fn()
})
afterEach(() => {
act(() => {
hydrateProjectDisplayNames({}, Number.MAX_SAFE_INTEGER)
})
})
it('collapses directory, branch and worktree into a single pill', async () => {
renderControls()
@@ -270,6 +283,28 @@ describe('RepositoryLaunchControls', () => {
expect(screen.queryByRole('button', { name: /Select worktree mode:/ })).not.toBeInTheDocument()
})
it('uses the repository-root display name when repository context canonicalizes the requested path', async () => {
const workDir = '/repo/subdirectory'
apiMocks.getRepositoryContext.mockResolvedValue({
...okRepositoryContext,
workDir: '/repo',
repoRoot: '/repo',
})
renderControls({ workDir })
await screen.findByRole('button', { name: 'Location: cc-haha / main' })
act(() => {
hydrateProjectDisplayNames(
{ '/repo': 'Custom repository' },
captureProjectDisplayNameHydrationRevision(),
)
})
const pill = await screen.findByRole('button', { name: 'Location: Custom repository / main' })
expect(pill).toHaveAttribute('title', `${workDir}\nBranch: main`)
expect(within(pill).getByText('main')).toBeInTheDocument()
})
it('truncates the branch from the start so its tail survives', async () => {
renderControls()
@@ -19,6 +19,7 @@ import {
} from '../../api/sessions'
import { useTranslation } from '../../i18n'
import { useUIStore } from '../../stores/uiStore'
import { useProjectDisplayName } from '../../stores/projectDisplayNameStore'
import { RecentProjectsPanel } from '@/components/composite/DirectoryPicker'
import { useDismissable } from '@/hooks/useDismissable'
import { useMobileViewport } from '../../hooks/useMobileViewport'
@@ -145,6 +146,7 @@ export function RepositoryLaunchControls({
const isMobileBrowser = useMobileViewport() && !isDesktopRuntime()
const isToolbar = placement === 'toolbar' && !isMobileBrowser
const [context, setContext] = useState<RepositoryContextResult | null>(null)
const [contextSourceWorkDir, setContextSourceWorkDir] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [menuOpen, setMenuOpen] = useState(false)
@@ -207,23 +209,28 @@ export function RepositoryLaunchControls({
useEffect(() => {
if (!workDir) {
setContext(null)
setContextSourceWorkDir(null)
setError(null)
setLoading(false)
onBranchChange(null)
return
}
const requestedWorkDir = workDir
let cancelled = false
setContextSourceWorkDir(null)
setLoading(true)
setError(null)
sessionsApi.getRepositoryContext(workDir)
sessionsApi.getRepositoryContext(requestedWorkDir)
.then((result) => {
if (cancelled) return
setContext(result)
setContextSourceWorkDir(requestedWorkDir)
})
.catch((err) => {
if (cancelled) return
setContext(null)
setContextSourceWorkDir(null)
setError(err instanceof Error ? err.message : String(err))
})
.finally(() => {
@@ -500,14 +507,18 @@ export function RepositoryLaunchControls({
// reading either one unguarded decides on the wrong repo.
useEffect(() => {
if (!revealAfterPick || loading) return
const settled = context?.workDir === revealAfterPick
const settled = contextSourceWorkDir === revealAfterPick
|| (!!error && workDir === revealAfterPick)
if (!settled) return
setRevealAfterPick(null)
if (context?.state !== 'ok') closeMenu()
}, [revealAfterPick, loading, context, error, workDir, closeMenu])
}, [revealAfterPick, loading, context, contextSourceWorkDir, error, workDir, closeMenu])
const repoLabel = context?.repoName || basename(context?.repoRoot) || basename(workDir)
const projectKey = contextSourceWorkDir === workDir && context
? (context.repoRoot || context.workDir)
: workDir
const displayName = useProjectDisplayName(projectKey)
const repoLabel = displayName || context?.repoName || basename(context?.repoRoot) || basename(workDir)
const selectedBranchName = isGitReady ? (selectedBranch?.name ?? null) : null
// The worktree cards name the branch their changes would land on.
const branchLabel = selectedBranch?.name ?? context?.currentBranch ?? t('repoLaunch.noBranch')
@@ -8,11 +8,26 @@ export function getWorktreeDisplayName(
return slug || (path ? getFileNameFromPath(path) : null)
}
export function WorktreeDetails({ name, path }: { name: string; path?: string | null }) {
export function WorktreeDetails({ name, path, projectRoot }: {
name: string
path?: string | null
/**
* Passed only when a custom display name is standing in for the folder name.
* The chip then shows the alias and drops its `title`, so this tooltip is the
* one place left that still tells you which real directory it points at.
*/
projectRoot?: string | null
}) {
const t = useTranslation()
return (
<dl className="grid max-w-[280px] grid-cols-[auto_minmax(0,1fr)] gap-x-2 gap-y-1">
{projectRoot ? (
<>
<dt className="opacity-70">{t('sidebar.projectEditor.realPath')}</dt>
<dd className="min-w-0 break-all font-mono text-[11px]">{projectRoot}</dd>
</>
) : null}
<dt className="opacity-70">{t('sidebar.worktree')}</dt>
<dd className="min-w-0 break-all font-mono text-[11px]">{name}</dd>
{path ? (
@@ -1,4 +1,5 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import '@testing-library/jest-dom'
@@ -18,15 +19,25 @@ import { DirectoryPicker } from './DirectoryPicker'
import { sessionsApi } from '../../api/sessions'
import { filesystemApi } from '../../api/filesystem'
import { browserHost } from '../../lib/desktopHost/browserHost'
import {
captureProjectDisplayNameHydrationRevision,
hydrateProjectDisplayNames,
} from '../../stores/projectDisplayNameStore'
describe('DirectoryPicker', () => {
let originalInnerWidth: number
beforeEach(() => {
originalInnerWidth = window.innerWidth
act(() => {
hydrateProjectDisplayNames({}, Number.MAX_SAFE_INTEGER)
})
})
afterEach(() => {
act(() => {
hydrateProjectDisplayNames({}, Number.MAX_SAFE_INTEGER)
})
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth })
Reflect.deleteProperty(window, 'desktopHost')
vi.restoreAllMocks()
@@ -44,6 +55,56 @@ describe('DirectoryPicker', () => {
expect(screen.getByRole('button')).not.toHaveTextContent('desktop-feature-rail-12345678')
})
it('reactively updates an exact selected path to its custom display name while retaining its raw path tooltip', () => {
const projectPath = '/workspace/project'
render(<DirectoryPicker value={projectPath} onChange={vi.fn()} />)
const trigger = screen.getByRole('button')
expect(trigger).toHaveTextContent('project')
act(() => {
hydrateProjectDisplayNames(
{ [projectPath]: 'Custom project' },
captureProjectDisplayNameHydrationRevision(),
)
})
expect(trigger).toHaveTextContent('Custom project')
expect(trigger).toHaveAttribute('title', projectPath)
})
it('uses a custom display name for an exact recent project while selecting its real path', async () => {
const projectPath = '/workspace/project'
vi.mocked(sessionsApi.getRecentProjects).mockResolvedValue({
projects: [{
projectPath,
realPath: projectPath,
projectName: 'project',
repoName: 'NanmiCoder/OpenCutSkill',
branch: 'main',
isGit: true,
modifiedAt: '2026-05-07T00:00:00.000Z',
sessionCount: 1,
}],
})
const onChange = vi.fn()
render(<DirectoryPicker value="" onChange={onChange} />)
fireEvent.click(screen.getByRole('button'))
await screen.findByText('NanmiCoder/OpenCutSkill')
act(() => {
hydrateProjectDisplayNames(
{ [projectPath]: 'Custom project' },
captureProjectDisplayNameHydrationRevision(),
)
})
const label = await screen.findByText('Custom project')
fireEvent.click(label.closest('button')!)
expect(onChange).toHaveBeenCalledWith(projectPath)
})
it('does not duplicate the branch in the selected project chip', async () => {
vi.mocked(sessionsApi.getRecentProjects).mockResolvedValue({
projects: [{
@@ -5,6 +5,7 @@ import { sessionsApi, type RecentProject } from '../../api/sessions'
import { filesystemApi } from '../../api/filesystem'
import { useTranslation } from '../../i18n'
import { useMobileViewport } from '../../hooks/useMobileViewport'
import { useProjectDisplayName } from '../../stores/projectDisplayNameStore'
import { getDesktopHost } from '../../lib/desktopHost'
import {
getCachedRecentProjects,
@@ -43,6 +44,55 @@ function projectNameFromPath(filePath: string) {
return displayRoot.split('/').filter(Boolean).pop() || filePath
}
function RecentProjectItem({
project,
value,
touch,
onSelect,
}: {
project: RecentProject
value: string
touch: boolean
onSelect: (path: string) => void
}) {
const displayName = useProjectDisplayName(project.realPath)
const isSelected = project.realPath === value
const label = displayName || project.repoName || project.projectName
return (
<button
onClick={() => onSelect(project.realPath)}
className={`flex w-full items-center gap-3 px-4 text-left transition-colors hover:bg-[var(--color-surface-hover)] ${
touch ? 'min-h-[72px] py-3.5' : 'py-3'
} ${
isSelected ? 'bg-[var(--color-surface-selected)]' : ''
}`}
>
{project.isGit ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-secondary)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="w-5 flex-shrink-0">
<circle cx="18" cy="18" r="3" /><circle cx="6" cy="6" r="3" />
<path d="M13 6h3a2 2 0 0 1 2 2v7" /><line x1="6" y1="9" x2="6" y2="21" />
</svg>
) : (
<span className="material-symbols-outlined w-5 flex-shrink-0 text-center text-[20px] text-[var(--color-text-secondary)]">folder</span>
)}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-[var(--color-text-primary)]">
{label}
</div>
<div className="truncate font-mono text-[11px] text-[var(--color-text-tertiary)]">
{project.realPath}
</div>
</div>
{isSelected && (
<span className="material-symbols-outlined flex-shrink-0 text-[18px] text-[var(--color-brand)]" style={{ fontVariationSettings: "'FILL' 1" }}>
check
</span>
)}
</button>
)
}
type PanelProps = {
value: string
/**
@@ -246,42 +296,15 @@ export function RecentProjectsPanel({
) : projects.length === 0 ? (
<EmptyState description={t('dirPicker.noRecent')} variant="plain" size="sm" />
) : (
projects.map((project) => {
const isSelected = project.realPath === value
return (
<button
key={project.projectPath}
onClick={() => handleSelect(project.realPath)}
className={`flex w-full items-center gap-3 px-4 text-left transition-colors hover:bg-[var(--color-surface-hover)] ${
touch ? 'min-h-[72px] py-3.5' : 'py-3'
} ${
isSelected ? 'bg-[var(--color-surface-selected)]' : ''
}`}
>
{project.isGit ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-secondary)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="w-5 flex-shrink-0">
<circle cx="18" cy="18" r="3" /><circle cx="6" cy="6" r="3" />
<path d="M13 6h3a2 2 0 0 1 2 2v7" /><line x1="6" y1="9" x2="6" y2="21" />
</svg>
) : (
<span className="material-symbols-outlined w-5 flex-shrink-0 text-center text-[20px] text-[var(--color-text-secondary)]">folder</span>
)}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-[var(--color-text-primary)]">
{project.repoName || project.projectName}
</div>
<div className="truncate font-mono text-[11px] text-[var(--color-text-tertiary)]">
{project.realPath}
</div>
</div>
{isSelected && (
<span className="material-symbols-outlined flex-shrink-0 text-[18px] text-[var(--color-brand)]" style={{ fontVariationSettings: "'FILL' 1" }}>
check
</span>
)}
</button>
)
})
projects.map((project) => (
<RecentProjectItem
key={project.projectPath}
project={project}
value={value}
touch={touch}
onSelect={handleSelect}
/>
))
)}
</div>
<div className="border-t border-[var(--color-border)]">
@@ -359,8 +382,10 @@ export function DirectoryPicker({ value, onChange, variant = 'chip', isGitProjec
// Find selected project info
const selectedProject = projects.find((p) => p.realPath === value)
const selectedProjectKey = selectedProject?.realPath ?? value
const selectedDisplayName = useProjectDisplayName(selectedProjectKey)
const isWorkbar = variant === 'workbar'
const selectedLabel = selectedProject?.repoName || selectedProject?.projectName || projectNameFromPath(value)
const selectedLabel = selectedDisplayName || selectedProject?.repoName || selectedProject?.projectName || projectNameFromPath(value)
const showGitIcon = selectedProject?.isGit || isGitProject
const triggerClassName = isWorkbar
? 'max-w-full ' + (isMobileBrowser ? 'min-h-11 ' : '') + 'group inline-flex h-9 min-w-0 items-center gap-2 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3.5 text-[13.5px] font-medium leading-none text-[var(--color-text-primary)] transition-[background-color,color,border-color] duration-150 ease-out hover:border-[var(--color-outline)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface)] disabled:cursor-not-allowed disabled:opacity-50'
@@ -1,8 +1,13 @@
import { useEffect } from 'react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useUIStore } from '../../stores/uiStore'
import { useSessionStore } from '../../stores/sessionStore'
import {
hydrateProjectDisplayNames,
resolveProjectDisplayName,
} from '../../stores/projectDisplayNameStore'
const mocks = vi.hoisted(() => ({
initializeDesktopServerUrl: vi.fn(),
@@ -90,7 +95,27 @@ vi.mock('../../i18n', () => ({
}))
vi.mock('./Sidebar', () => ({
Sidebar: () => <aside>sidebar loaded</aside>,
Sidebar: ({
desktopUiPreferencesRequest,
onDesktopUiPreferencesConsumed,
}: {
desktopUiPreferencesRequest?: Promise<unknown> | null
onDesktopUiPreferencesConsumed?: (request: Promise<unknown>) => void
}) => {
useEffect(() => {
if (!desktopUiPreferencesRequest) return
let cancelled = false
void desktopUiPreferencesRequest
.finally(() => {
if (!cancelled) onDesktopUiPreferencesConsumed?.(desktopUiPreferencesRequest)
})
.catch(() => undefined)
return () => {
cancelled = true
}
}, [desktopUiPreferencesRequest, onDesktopUiPreferencesConsumed])
return <aside>sidebar loaded</aside>
},
}))
vi.mock('./ContentRouter', () => ({
@@ -132,6 +157,9 @@ import { AppShell } from './AppShell'
describe('AppShell boot flow', () => {
beforeEach(() => {
vi.clearAllMocks()
act(() => {
hydrateProjectDisplayNames({}, Number.MAX_SAFE_INTEGER)
})
mocks.isTauriRuntime = false
mocks.isMobile = false
mocks.initializeDesktopServerUrl.mockResolvedValue('http://127.0.0.1:3456')
@@ -143,6 +171,7 @@ describe('AppShell boot flow', () => {
schemaVersion: 3,
sidebar: {},
profile: {},
projectDisplayNames: {},
pet: {
enabled: false,
selectedPetId: 'dada-code',
@@ -159,6 +188,7 @@ describe('AppShell boot flow', () => {
schemaVersion: 3,
sidebar: {},
profile: {},
projectDisplayNames: {},
pet: {
enabled: false,
selectedPetId: 'dada-code',
@@ -301,6 +331,16 @@ describe('AppShell boot flow', () => {
expect(screen.queryByText('app.serverFailed')).not.toBeInTheDocument()
})
it('keeps the app usable when desktop UI preferences are unavailable', async () => {
mocks.getDesktopUiPreferences.mockRejectedValueOnce(new Error('preferences unavailable'))
render(<AppShell />)
expect(await screen.findByText('sidebar loaded')).toBeInTheDocument()
expect(screen.getByText('content loaded')).toBeInTheDocument()
expect(screen.queryByText('app.serverFailed')).not.toBeInTheDocument()
})
it('reconnects the restored active session tab after boot', async () => {
mocks.tabState.activeTabId = 'session-1'
mocks.tabState.tabs = [
@@ -422,6 +462,7 @@ describe('AppShell boot flow', () => {
schemaVersion: 3,
sidebar: {},
profile: {},
projectDisplayNames: {},
pet: {
enabled: true,
selectedPetId: 'dada-code',
@@ -522,6 +563,44 @@ describe('AppShell boot flow', () => {
expect(screen.queryByText('h5 connection view')).not.toBeInTheDocument()
})
it('hydrates project display names before the closed H5 drawer mounts Sidebar', async () => {
mocks.isMobile = true
mocks.getDesktopUiPreferences.mockResolvedValueOnce({
exists: true,
preferences: {
schemaVersion: 5,
sidebar: {},
profile: {},
projectDisplayNames: { '/workspace/project': 'Mobile alias' },
pet: {
enabled: false,
selectedPetId: 'dada-code',
size: 144,
collapsed: false,
motionEnabled: true,
lastSessionId: null,
},
},
})
render(<AppShell />)
await screen.findByText('content loaded')
expect(screen.queryByText('sidebar loaded')).not.toBeInTheDocument()
await waitFor(() => {
expect(resolveProjectDisplayName('/workspace/project')).toBe('Mobile alias')
})
expect(mocks.getDesktopUiPreferences).toHaveBeenCalledTimes(1)
await act(async () => {
fireEvent.click(screen.getByTestId('mobile-sidebar-toggle'))
await Promise.resolve()
})
expect(screen.getByText('sidebar loaded')).toBeInTheDocument()
expect(mocks.getDesktopUiPreferences).toHaveBeenCalledTimes(1)
})
it('renders a mobile drawer toggle and backdrop in browser H5 mode', async () => {
mocks.isMobile = true
+38 -10
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState, type HTMLAttributes } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState, type HTMLAttributes } from 'react'
import { Sidebar } from './Sidebar'
import { ContentRouter } from './ContentRouter'
import { ToastContainer } from '@/components/layout/Toast'
@@ -17,7 +17,14 @@ import {
isH5ConnectionRequiredError,
} from '../../lib/desktopRuntime'
import { getDesktopHost } from '../../lib/desktopHost'
import { desktopUiPreferencesApi } from '../../api/desktopUiPreferences'
import {
desktopUiPreferencesApi,
type DesktopUiPreferencesResponse,
} from '../../api/desktopUiPreferences'
import {
captureProjectDisplayNameHydrationRevision,
hydrateProjectDisplayNames,
} from '../../stores/projectDisplayNameStore'
import { openDesktopNotificationTarget } from '../../lib/desktopNotificationNavigation'
import { TabBar } from './TabBar'
import { StartupErrorView } from './StartupErrorView'
@@ -47,6 +54,12 @@ export function AppShell() {
const [h5StartupError, setH5StartupError] = useState<H5ConnectionRequiredError | null>(null)
const [bootstrapNonce, setBootstrapNonce] = useState(0)
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
const [desktopUiPreferencesRequest, setDesktopUiPreferencesRequest] = useState<
Promise<DesktopUiPreferencesResponse> | null
>(null)
const consumeDesktopUiPreferencesRequest = useCallback((request: Promise<DesktopUiPreferencesResponse>) => {
setDesktopUiPreferencesRequest((current) => current === request ? null : current)
}, [])
const t = useTranslation()
const traceLaunch = useMemo(() => getTraceLaunchRequest(), [])
const desktopRuntime = isDesktopRuntime()
@@ -107,24 +120,34 @@ export function AppShell() {
setReady(false)
setStartupError(null)
setH5StartupError(null)
setDesktopUiPreferencesRequest(null)
}
try {
await initializeDesktopServerUrl()
await fetchSettings()
if (cancelled) return
if (!cancelled) {
setReady(true)
}
if (desktopRuntime && !traceLaunch.windowMode) {
void desktopUiPreferencesApi.getPreferences()
if (!traceLaunch.windowMode) {
const displayNameHydrationRevision = captureProjectDisplayNameHydrationRevision()
const preferencesRequest = desktopUiPreferencesApi.getPreferences()
setDesktopUiPreferencesRequest(preferencesRequest)
void preferencesRequest
.then(({ preferences }) => {
if (preferences.pet.enabled) return getDesktopHost().pets.show()
if (cancelled) return
hydrateProjectDisplayNames(
preferences.projectDisplayNames ?? {},
displayNameHydrationRevision,
)
if (desktopRuntime && preferences.pet.enabled) {
return getDesktopHost().pets.show()
}
})
.catch(() => undefined)
}
setReady(true)
void (async () => {
if (traceLaunch.windowMode) return
@@ -308,7 +331,12 @@ export function AppShell() {
{...sidebarHiddenProps}
>
{!isMobileShell || effectiveSidebarOpen ? (
<Sidebar isMobile={isMobileShell} onRequestClose={() => setEffectiveSidebarOpen(false)} />
<Sidebar
isMobile={isMobileShell}
onRequestClose={() => setEffectiveSidebarOpen(false)}
desktopUiPreferencesRequest={desktopUiPreferencesRequest}
onDesktopUiPreferencesConsumed={consumeDesktopUiPreferencesRequest}
/>
) : null}
{!isMobileShell ? (
<div
@@ -0,0 +1,245 @@
import '@testing-library/jest-dom'
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useState } from 'react'
vi.mock('../../i18n', () => ({
useTranslation: () => (key: string, params?: Record<string, string | number>) => {
const translations: Record<string, string> = {
'common.cancel': 'Cancel',
'common.save': 'Save',
'sidebar.projectEditor.createTitle': 'Create project',
'sidebar.projectEditor.editTitle': 'Edit project',
'sidebar.projectEditor.name': 'Project name',
'sidebar.projectEditor.nameHint': 'Up to {count} characters',
'sidebar.projectEditor.nameRequired': 'A project name is required.',
'sidebar.projectEditor.nameTooLong': 'Project names can be at most 80 characters.',
'sidebar.projectEditor.sourceFolder': 'Source folder',
'sidebar.projectEditor.sourceFolderRequired': 'Choose a source folder.',
'sidebar.projectEditor.realPath': 'Real path',
'sidebar.projectEditor.realPathHint': 'This project path is fixed and cannot be changed here.',
'sidebar.projectEditor.restoreFolderName': 'Restore folder name',
'sidebar.projectEditor.removeFromSidebar': 'Remove from sidebar',
'sidebar.projectEditor.removeFromSidebarHint': 'This only removes the project from the sidebar. Its sessions and files remain unchanged.',
'sidebar.projectEditor.create': 'Create project',
'sidebar.projectEditor.actionFailed': 'Could not save the project changes.',
}
let value = translations[key] ?? key
for (const [name, replacement] of Object.entries(params ?? {})) {
value = value.replace(new RegExp(`\\{${name}\\}`, 'g'), String(replacement))
}
return value
},
}))
vi.mock('@/components/composite/DirectoryPicker', () => ({
DirectoryPicker: ({ value, onChange }: { value: string; onChange: (path: string) => void }) => (
<button type="button" aria-label="Choose source folder" onClick={() => onChange('/workspace/selected ')}>
{value || 'Choose source folder'}
</button>
),
}))
import { ProjectEditorModal } from './ProjectEditorModal'
function CreateHarness({
onSubmit,
onSourceFolderChange,
}: {
onSubmit: ReturnType<typeof vi.fn>
onSourceFolderChange: ReturnType<typeof vi.fn>
}) {
const [sourceFolder, setSourceFolder] = useState('')
return (
<ProjectEditorModal
open
mode="create"
sourceFolder={sourceFolder}
logicalRoot="/workspace/logical-root "
suggestedName="Suggested project"
onSourceFolderChange={(path) => {
onSourceFolderChange(path)
setSourceFolder(path)
}}
onClose={vi.fn()}
onSubmit={onSubmit}
/>
)
}
afterEach(cleanup)
describe('ProjectEditorModal', () => {
it('validates normalized names and preserves exact source and logical paths', async () => {
const onSubmit = vi.fn()
const onSourceFolderChange = vi.fn()
render(<CreateHarness onSubmit={onSubmit} onSourceFolderChange={onSourceFolderChange} />)
const name = screen.getByRole('textbox', { name: /Project name/ })
expect(name).not.toHaveAttribute('maxlength')
fireEvent.change(name, { target: { value: '' } })
fireEvent.click(screen.getByRole('button', { name: 'Create project' }))
expect(onSubmit).not.toHaveBeenCalled()
expect(screen.getByText('A project name is required.')).toHaveAttribute('role', 'alert')
expect(screen.getByText('Choose a source folder.')).toHaveAttribute('role', 'alert')
expect(document.activeElement).toBe(name)
fireEvent.click(screen.getByRole('button', { name: 'Choose source folder' }))
expect(onSourceFolderChange).toHaveBeenCalledWith('/workspace/selected ')
fireEvent.change(name, { target: { value: 'x'.repeat(81) } })
fireEvent.click(screen.getByRole('button', { name: 'Create project' }))
expect(onSubmit).not.toHaveBeenCalled()
expect(screen.getByText('Project names can be at most 80 characters.')).toHaveAttribute('role', 'alert')
fireEvent.change(name, { target: { value: ' Client workspace ' } })
fireEvent.click(screen.getByRole('button', { name: 'Create project' }))
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
name: 'Client workspace',
sourceFolder: '/workspace/selected ',
logicalRoot: '/workspace/logical-root ',
})
})
})
it('adopts late suggestions until a user edits the name', async () => {
const onSubmit = vi.fn()
const baseProps = {
open: true as const,
mode: 'create' as const,
sourceFolder: '/workspace/checkout',
onSourceFolderChange: vi.fn(),
onClose: vi.fn(),
onSubmit,
}
const { rerender } = render(
<ProjectEditorModal
{...baseProps}
suggestedName="checkout"
/>,
)
const name = screen.getByRole('textbox', { name: /Project name/ })
expect(name).toHaveValue('checkout')
rerender(
<ProjectEditorModal
{...baseProps}
logicalRoot="/workspace/repository"
suggestedName="repository"
/>,
)
await waitFor(() => expect(name).toHaveValue('repository'))
fireEvent.change(name, { target: { value: 'My custom name' } })
rerender(
<ProjectEditorModal
{...baseProps}
logicalRoot="/workspace/late-root"
suggestedName="late-root"
/>,
)
await waitFor(() => expect(name).toHaveValue('My custom name'))
})
it('does not trim the fallback folder name', () => {
render(
<ProjectEditorModal
open
mode="create"
sourceFolder="/workspace/folder "
onSourceFolderChange={vi.fn()}
onClose={vi.fn()}
onSubmit={vi.fn()}
/>,
)
expect(screen.getByRole('textbox', { name: /Project name/ })).toHaveValue('folder ')
})
it('keeps the real path read-only and delegates edit-only actions', async () => {
const onSubmit = vi.fn()
const onRestoreFolderName = vi.fn()
const onRemoveFromSidebar = vi.fn()
render(
<ProjectEditorModal
open
mode="edit"
initialName="Client workspace"
logicalRoot="/workspace/client"
suggestedName="client"
onClose={vi.fn()}
onSubmit={onSubmit}
onRestoreFolderName={onRestoreFolderName}
onRemoveFromSidebar={onRemoveFromSidebar}
/>,
)
const path = screen.getByRole('textbox', { name: 'Real path' })
expect(path).toHaveValue('/workspace/client')
expect(path).toHaveAttribute('readonly')
expect(path).toHaveAttribute('aria-readonly', 'true')
expect(screen.getByText('This only removes the project from the sidebar. Its sessions and files remain unchanged.')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Restore folder name' }))
await waitFor(() => expect(onRestoreFolderName).toHaveBeenCalledTimes(1))
expect(screen.getByRole('textbox', { name: /Project name/ })).toHaveValue('client')
const removeFromSidebar = screen.getByRole('button', { name: 'Remove from sidebar' })
expect(removeFromSidebar).toHaveClass('border-[var(--color-error)]')
fireEvent.click(removeFromSidebar)
await waitFor(() => expect(onRemoveFromSidebar).toHaveBeenCalledTimes(1))
fireEvent.change(screen.getByRole('textbox', { name: /Project name/ }), { target: { value: 'Renamed client' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
name: 'Renamed client',
sourceFolder: '/workspace/client',
logicalRoot: '/workspace/client',
})
})
})
it('prevents duplicate submissions and dismissals while an action is pending', async () => {
let resolveSubmit!: () => void
const onSubmit = vi.fn(() => new Promise<void>((resolve) => {
resolveSubmit = resolve
}))
const onClose = vi.fn()
render(
<ProjectEditorModal
open
mode="edit"
initialName="Client workspace"
logicalRoot="/workspace/client"
onClose={onClose}
onSubmit={onSubmit}
/>,
)
const save = screen.getByRole('button', { name: 'Save' })
fireEvent.click(save)
fireEvent.click(save)
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
await waitFor(() => expect(save).toHaveAttribute('aria-busy', 'true'))
const dialog = screen.getByRole('dialog', { name: 'Edit project' })
fireEvent.keyDown(document, { key: 'Escape' })
fireEvent.click(dialog.previousElementSibling!)
fireEvent.click(within(dialog).getByRole('button', { name: 'Close dialog' }))
expect(onClose).not.toHaveBeenCalled()
await act(async () => resolveSubmit())
await waitFor(() => expect(save).not.toHaveAttribute('aria-busy'))
fireEvent.keyDown(document, { key: 'Escape' })
expect(onClose).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,275 @@
import { useEffect, useId, useRef, useState } from 'react'
import { DirectoryPicker } from '@/components/composite/DirectoryPicker'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Modal } from '@/components/ui/Modal'
import { useTranslation } from '../../i18n'
export type ProjectEditorSubmission = {
name: string
sourceFolder: string
logicalRoot: string
}
type ProjectEditorAction = () => void | Promise<void>
type ProjectEditorBaseProps = {
open: boolean
initialName?: string
logicalRoot?: string
suggestedName?: string
loading?: boolean
error?: string | null
onClose: () => void
onSubmit: (submission: ProjectEditorSubmission) => void | Promise<void>
}
type CreateProjectEditorProps = ProjectEditorBaseProps & {
mode: 'create'
sourceFolder: string
onSourceFolderChange: (path: string) => void
}
type EditProjectEditorProps = ProjectEditorBaseProps & {
mode: 'edit'
logicalRoot: string
onRestoreFolderName?: ProjectEditorAction
onRemoveFromSidebar?: ProjectEditorAction
}
export type ProjectEditorModalProps = CreateProjectEditorProps | EditProjectEditorProps
function folderName(path: string): string {
const segments = path.replace(/[\\/]+$/, '').split(/[\\/]+/).filter(Boolean)
return segments[segments.length - 1] ?? ''
}
function normalizeProjectName(name: string): string {
return name.trim().replace(/\s+/g, ' ')
}
function messageFromError(error: unknown, fallback: string): string {
return error instanceof Error && error.message ? error.message : fallback
}
export function ProjectEditorModal(props: ProjectEditorModalProps) {
const t = useTranslation()
const {
open,
mode,
initialName,
logicalRoot,
suggestedName,
loading = false,
error,
onClose,
onSubmit,
} = props
const sourceFolder = mode === 'create' ? props.sourceFolder : logicalRoot ?? ''
const suggestedFolderName = suggestedName?.trim() || folderName(logicalRoot || sourceFolder)
const [name, setName] = useState(() => initialName ?? suggestedFolderName)
const [showValidation, setShowValidation] = useState(false)
const [pending, setPending] = useState(false)
const [actionError, setActionError] = useState<string | null>(null)
const nameInputId = useId()
const sourceFolderRef = useRef<HTMLDivElement>(null)
const nameWasEditedRef = useRef(false)
const useSuggestedNameRef = useRef(!initialName)
const pendingRef = useRef(false)
const wasOpenRef = useRef(open)
const previousModeRef = useRef(mode)
const busy = loading || pending
const normalizedName = normalizeProjectName(name)
const nameError = !normalizedName
? t('sidebar.projectEditor.nameRequired')
: normalizedName.length > 80
? t('sidebar.projectEditor.nameTooLong')
: undefined
const sourceFolderError = mode === 'create' && !sourceFolder.trim()
? t('sidebar.projectEditor.sourceFolderRequired')
: undefined
const displayedError = error || actionError
useEffect(() => {
const opened = open && !wasOpenRef.current
const modeChanged = open && previousModeRef.current !== mode
if (opened || modeChanged) {
nameWasEditedRef.current = false
useSuggestedNameRef.current = !initialName
setName(initialName ?? suggestedFolderName)
setShowValidation(false)
setActionError(null)
} else if (open && !nameWasEditedRef.current && useSuggestedNameRef.current) {
setName(suggestedFolderName)
}
wasOpenRef.current = open
previousModeRef.current = mode
}, [initialName, mode, open, suggestedFolderName])
const runAction = async (action: ProjectEditorAction, afterSuccess?: () => void) => {
if (loading || pendingRef.current) return
pendingRef.current = true
setPending(true)
setActionError(null)
try {
await action()
afterSuccess?.()
} catch (actionFailure) {
setActionError(messageFromError(actionFailure, t('sidebar.projectEditor.actionFailed')))
} finally {
pendingRef.current = false
setPending(false)
}
}
const handleClose = () => {
if (!busy) onClose()
}
const focusInvalidField = () => {
if (nameError) {
document.getElementById(nameInputId)?.focus()
return
}
sourceFolderRef.current?.querySelector<HTMLButtonElement>('button')?.focus()
}
const handleSubmit = () => {
if (busy) return
if (nameError || sourceFolderError) {
setShowValidation(true)
focusInvalidField()
return
}
const submissionLogicalRoot = logicalRoot?.trim() ? logicalRoot : sourceFolder
void runAction(() => onSubmit({
name: normalizedName,
sourceFolder,
logicalRoot: submissionLogicalRoot,
}))
}
const handleRestoreFolderName = () => {
if (mode !== 'edit' || !props.onRestoreFolderName) return
void runAction(props.onRestoreFolderName, () => {
nameWasEditedRef.current = false
useSuggestedNameRef.current = true
setName(suggestedFolderName)
})
}
const handleRemoveFromSidebar = () => {
if (mode !== 'edit' || !props.onRemoveFromSidebar) return
void runAction(props.onRemoveFromSidebar)
}
return (
<Modal
open={open}
onClose={handleClose}
title={t(mode === 'create' ? 'sidebar.projectEditor.createTitle' : 'sidebar.projectEditor.editTitle')}
width={520}
footer={(
<>
<Button variant="secondary" onClick={handleClose} disabled={busy}>
{t('common.cancel')}
</Button>
<Button onClick={handleSubmit} loading={busy}>
{t(mode === 'create' ? 'sidebar.projectEditor.create' : 'common.save')}
</Button>
</>
)}
>
<fieldset disabled={busy} className="m-0 min-w-0 border-0 p-0">
<div className="flex flex-col gap-5">
{displayedError && (
<p role="alert" className="rounded-[var(--radius-md)] border border-[var(--color-error)] bg-[var(--color-error-container)] px-3 py-2 text-sm text-[var(--color-on-error-container)]">
{displayedError}
</p>
)}
<Input
id={nameInputId}
label={t('sidebar.projectEditor.name')}
required
value={name}
onChange={(event) => {
nameWasEditedRef.current = true
useSuggestedNameRef.current = false
setName(event.target.value)
}}
hint={t('sidebar.projectEditor.nameHint', { count: 80 })}
error={showValidation ? nameError : undefined}
/>
{mode === 'create' ? (
<div
ref={sourceFolderRef}
role="group"
aria-labelledby="project-editor-source-folder"
aria-describedby={showValidation && sourceFolderError ? 'project-editor-source-folder-error' : undefined}
className="flex flex-col gap-1.5"
>
<span id="project-editor-source-folder" className="text-sm font-medium text-[var(--color-text-primary)]">
{t('sidebar.projectEditor.sourceFolder')}
<span className="ml-0.5 text-[var(--color-error)]">*</span>
</span>
<DirectoryPicker value={sourceFolder} onChange={props.onSourceFolderChange} variant="workbar" />
{showValidation && sourceFolderError && (
<p id="project-editor-source-folder-error" role="alert" className="text-xs text-[var(--color-error)]">
{sourceFolderError}
</p>
)}
</div>
) : (
<Input
label={t('sidebar.projectEditor.realPath')}
value={logicalRoot}
readOnly
aria-readonly="true"
hint={t('sidebar.projectEditor.realPathHint')}
className="font-mono text-xs"
/>
)}
{mode === 'edit' && (
<div className="flex flex-col gap-3 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3">
{props.onRestoreFolderName && (
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<p className="text-sm font-medium text-[var(--color-text-primary)]">
{t('sidebar.projectEditor.restoreFolderName')}
</p>
<p className="mt-0.5 text-xs text-[var(--color-text-tertiary)]">
{suggestedFolderName}
</p>
</div>
<Button variant="secondary" size="sm" onClick={handleRestoreFolderName}>
{t('sidebar.projectEditor.restoreFolderName')}
</Button>
</div>
)}
{props.onRemoveFromSidebar && (
<div className="flex flex-wrap items-start justify-between gap-3 border-t border-[var(--color-border)] pt-3">
<p className="max-w-[320px] text-xs leading-5 text-[var(--color-text-secondary)]">
{t('sidebar.projectEditor.removeFromSidebarHint')}
</p>
<Button variant="danger-outline" size="sm" onClick={handleRemoveFromSidebar}>
{t('sidebar.projectEditor.removeFromSidebar')}
</Button>
</div>
)}
</div>
)}
</div>
</fieldset>
</Modal>
)
}
+689 -32
View File
@@ -1,16 +1,29 @@
import { useState } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import '@testing-library/jest-dom'
import type {
DesktopUiPreferencesResponse,
SidebarProjectPreferences,
} from '../../api/desktopUiPreferences'
const desktopUiPreferencesApiMock = vi.hoisted(() => ({
getPreferences: vi.fn(),
updateSidebarPreferences: vi.fn(),
updateProjectDisplayName: vi.fn(),
}))
vi.mock('../../api/desktopUiPreferences', () => ({
desktopUiPreferencesApi: desktopUiPreferencesApiMock,
}))
const repositoryContextMock = vi.hoisted(() => vi.fn())
vi.mock('../../api/sessions', () => ({
sessionsApi: {
getRepositoryContext: repositoryContextMock,
},
}))
const openTargetStoreMock = vi.hoisted(() => ({
ensureTargets: vi.fn(),
openTarget: vi.fn(),
@@ -49,6 +62,8 @@ vi.mock('../../i18n', () => ({
'sidebar.sortByCreatedAt': 'Created time',
'sidebar.sortByUpdatedAt': 'Updated time',
'sidebar.newBlankProject': 'New blank project',
'sidebar.newBlankSession': 'New blank session',
'sidebar.projectEditor.editTitle': 'Edit project',
'sidebar.useExistingFolder': 'Use existing folder',
'sidebar.chooseProjectFolderUnavailable': 'Folder selection is only available in the desktop app.',
'sidebar.projectActions': 'Project actions for {project}',
@@ -109,11 +124,91 @@ vi.mock('../../i18n', () => ({
},
}))
vi.mock('./ProjectEditorModal', () => ({
ProjectEditorModal: (props: {
open: boolean
mode: 'create' | 'edit'
sourceFolder?: string
logicalRoot?: string
initialName?: string
suggestedName?: string
loading?: boolean
error?: string | null
onClose: () => void
onSourceFolderChange?: (path: string) => void
onSubmit: (submission: { name: string; sourceFolder: string; logicalRoot: string }) => void | Promise<void>
onRestoreFolderName?: () => void | Promise<void>
onRemoveFromSidebar?: () => void | Promise<void>
}) => {
if (!props.open) return null
const sourceFolder = props.sourceFolder ?? props.logicalRoot ?? ''
const logicalRoot = props.logicalRoot ?? sourceFolder
const name = props.mode === 'create' ? 'Created project' : 'Edited project'
return (
<div
role="dialog"
aria-label={`${props.mode} project editor`}
data-testid="project-editor-modal"
data-source-folder={sourceFolder}
data-logical-root={logicalRoot}
data-initial-name={props.initialName ?? ''}
data-suggested-name={props.suggestedName ?? ''}
data-loading={props.loading ? 'true' : 'false'}
>
{props.mode === 'create' && (
<>
<button type="button" onClick={() => props.onSourceFolderChange?.('/workspace/repository/packages/app')}>
Choose project source
</button>
<button type="button" onClick={() => props.onSourceFolderChange?.('/workspace/other')}>
Choose other project source
</button>
</>
)}
<button
type="button"
onClick={() => void props.onSubmit({ name, sourceFolder, logicalRoot })}
>
Submit {props.mode} project
</button>
{props.suggestedName && (
<button
type="button"
onClick={() => void props.onSubmit({
name: props.suggestedName!.trim().replace(/\s+/g, ' '),
sourceFolder,
logicalRoot,
})}
>
Submit folder project name
</button>
)}
{props.onRestoreFolderName && (
<button type="button" onClick={() => void props.onRestoreFolderName?.()}>
Restore folder name
</button>
)}
{props.onRemoveFromSidebar && (
<button type="button" onClick={() => void props.onRemoveFromSidebar?.()}>
Remove project from sidebar
</button>
)}
<button type="button" onClick={props.onClose}>Close project editor</button>
</div>
)
},
}))
import { Sidebar } from './Sidebar'
import { useChatStore } from '../../stores/chatStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useTabStore } from '../../stores/tabStore'
import { useUIStore } from '../../stores/uiStore'
import {
captureProjectDisplayNameHydrationRevision,
hydrateProjectDisplayNames,
} from '../../stores/projectDisplayNameStore'
import type { SessionListItem } from '../../types/session'
import type { PerSessionState } from '../../stores/chatStore'
@@ -169,6 +264,80 @@ function makeChatSessionState(overrides: Partial<PerSessionState> = {}): PerSess
}
}
function createDeferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, resolve, reject }
}
function makeDesktopUiPreferencesResponse({
exists = true,
sidebar = {
projectOrder: [],
pinnedProjects: [],
hiddenProjects: [],
projectOrganization: 'recentProject',
projectSortBy: 'updatedAt',
},
projectDisplayNames = {},
}: {
exists?: boolean
sidebar?: SidebarProjectPreferences
projectDisplayNames?: Record<string, string>
} = {}): DesktopUiPreferencesResponse {
return {
exists,
preferences: {
schemaVersion: 5,
sidebar,
projectDisplayNames,
profile: {
displayName: 'cc-haha',
subtitle: 'github.com/NanmiCoder/cc-haha',
avatarFile: null,
avatarUpdatedAt: null,
},
pet: {
enabled: false,
selectedPetId: 'dada-code',
size: 144,
showTaskPanel: true,
collapsed: false,
motionEnabled: true,
lastSessionId: null,
},
},
}
}
function SidebarDrawerHarness({ request }: { request: Promise<DesktopUiPreferencesResponse> }) {
const [open, setOpen] = useState(true)
const [preferencesRequest, setPreferencesRequest] = useState<
Promise<DesktopUiPreferencesResponse> | null
>(request)
return (
<>
<button type="button" onClick={() => setOpen((current) => !current)}>
{open ? 'Close drawer harness' : 'Open drawer harness'}
</button>
{open && (
<Sidebar
isMobile
desktopUiPreferencesRequest={preferencesRequest}
onDesktopUiPreferencesConsumed={(consumedRequest) => {
setPreferencesRequest((current) => current === consumedRequest ? null : current)
}}
/>
)}
</>
)
}
function makeDataTransfer() {
const data = new Map<string, string>()
return {
@@ -208,9 +377,18 @@ describe('Sidebar', () => {
deleteSession.mockReset()
deleteSessions.mockReset()
addToast.mockReset()
desktopUiPreferencesApiMock.getPreferences.mockReset()
desktopUiPreferencesApiMock.updateSidebarPreferences.mockReset()
desktopUiPreferencesApiMock.getPreferences.mockRejectedValue(new Error('server unavailable'))
desktopUiPreferencesApiMock.updateProjectDisplayName.mockReset()
desktopUiPreferencesApiMock.updateProjectDisplayName.mockImplementation((projectKey: string, displayName: string | null) => Promise.resolve({
ok: true,
projectKey,
displayName,
}))
repositoryContextMock.mockReset()
repositoryContextMock.mockImplementation(async (workDir: string) => ({ repoRoot: null, workDir }))
act(() => {
hydrateProjectDisplayNames({}, Number.MAX_SAFE_INTEGER)
})
desktopUiPreferencesApiMock.updateSidebarPreferences.mockResolvedValue({
ok: true,
preferences: {
@@ -260,6 +438,9 @@ describe('Sidebar', () => {
afterEach(() => {
vi.useRealTimers()
cleanup()
act(() => {
hydrateProjectDisplayNames({}, Number.MAX_SAFE_INTEGER)
})
useTabStore.setState({ tabs: [], activeTabId: null })
window.localStorage.removeItem(PROJECT_ORDER_STORAGE_KEY)
window.localStorage.removeItem(PROJECT_PINNED_STORAGE_KEY)
@@ -525,10 +706,10 @@ describe('Sidebar', () => {
expect(screen.getByTestId('sidebar-projects-header')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'New project' }))
expect(screen.getByRole('menuitem', { name: 'New blank project' })).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: 'New blank session' })).toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByRole('menuitem', { name: 'New blank project' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'New blank session' }))
})
await waitFor(() => {
@@ -537,6 +718,454 @@ describe('Sidebar', () => {
})
})
it('keeps project controls available when there are no projects', () => {
render(<Sidebar />)
expect(screen.getByTestId('sidebar-projects-header')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'New project' }))
expect(screen.getByRole('menuitem', { name: 'New blank session' })).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: 'Use existing folder' })).toBeInTheDocument()
})
it('uses hydrated display names while preserving the project path used by actions', async () => {
act(() => {
hydrateProjectDisplayNames(
{ '/workspace/alpha': 'Custom alpha' },
captureProjectDisplayNameHydrationRevision(),
)
})
createSession.mockResolvedValue('custom-alpha-new')
useSessionStore.setState({
sessions: [makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', new Date().toISOString())],
})
render(<Sidebar />)
await waitFor(() => expect(screen.getByText('Custom alpha')).toBeInTheDocument())
const header = screen.getByRole('button', { name: 'Collapse Custom alpha' })
expect(header).toHaveAttribute('title', '/workspace/alpha')
fireEvent.click(screen.getByRole('button', { name: 'New session in Custom alpha' }))
await waitFor(() => expect(createSession).toHaveBeenCalledWith('/workspace/alpha'))
})
it('creates a named project at its resolved root while launching the selected source folder', async () => {
repositoryContextMock.mockResolvedValue({ repoRoot: '/workspace/repository' })
createSession.mockResolvedValue('created-project-session')
render(<Sidebar />)
fireEvent.click(screen.getByRole('button', { name: 'New project' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Use existing folder' }))
const editor = screen.getByTestId('project-editor-modal')
expect(editor).toHaveAttribute('data-source-folder', '')
fireEvent.click(screen.getByRole('button', { name: 'Choose project source' }))
await waitFor(() => expect(repositoryContextMock).toHaveBeenCalledWith('/workspace/repository/packages/app'))
await waitFor(() => {
expect(editor).toHaveAttribute('data-source-folder', '/workspace/repository/packages/app')
expect(editor).toHaveAttribute('data-logical-root', '/workspace/repository')
expect(editor).toHaveAttribute('data-suggested-name', 'repository')
})
fireEvent.click(screen.getByRole('button', { name: 'Submit create project' }))
await waitFor(() => {
expect(desktopUiPreferencesApiMock.updateProjectDisplayName).toHaveBeenCalledWith(
'/workspace/repository',
'Created project',
)
expect(createSession).toHaveBeenCalledWith('/workspace/repository/packages/app')
expect(connectToSession).toHaveBeenCalledWith('created-project-session')
})
expect(createSession.mock.invocationCallOrder[0]).toBeLessThan(
desktopUiPreferencesApiMock.updateProjectDisplayName.mock.invocationCallOrder[0]!,
)
expect(repositoryContextMock).toHaveBeenCalledTimes(1)
})
it('opens the session before repository context resolves and falls back after lookup failure', async () => {
const repositoryContext = createDeferred<{ repoRoot: string | null; workDir?: string }>()
repositoryContextMock.mockReturnValueOnce(repositoryContext.promise)
createSession.mockResolvedValue('created-project-session')
render(<Sidebar />)
fireEvent.click(screen.getByRole('button', { name: 'New project' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Use existing folder' }))
fireEvent.click(screen.getByRole('button', { name: 'Choose project source' }))
fireEvent.click(screen.getByRole('button', { name: 'Submit create project' }))
await waitFor(() => {
expect(createSession).toHaveBeenCalledWith('/workspace/repository/packages/app')
expect(connectToSession).toHaveBeenCalledWith('created-project-session')
expect(screen.queryByTestId('project-editor-modal')).not.toBeInTheDocument()
})
expect(desktopUiPreferencesApiMock.updateProjectDisplayName).not.toHaveBeenCalled()
await act(async () => {
repositoryContext.reject(new Error('repository context unavailable'))
await repositoryContext.promise.catch(() => undefined)
})
await waitFor(() => expect(desktopUiPreferencesApiMock.updateProjectDisplayName).toHaveBeenCalledWith(
'/workspace/repository/packages/app',
'Created project',
))
})
it('resets instead of persisting a redundant alias when creating with the folder name', async () => {
repositoryContextMock.mockResolvedValue({
repoRoot: '/workspace/repository',
workDir: '/workspace/repository/packages/app',
})
createSession.mockResolvedValue('created-project-session')
render(<Sidebar />)
fireEvent.click(screen.getByRole('button', { name: 'New project' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Use existing folder' }))
fireEvent.click(screen.getByRole('button', { name: 'Choose project source' }))
await waitFor(() => expect(screen.getByTestId('project-editor-modal')).toHaveAttribute(
'data-suggested-name',
'repository',
))
fireEvent.click(screen.getByRole('button', { name: 'Submit folder project name' }))
await waitFor(() => {
expect(connectToSession).toHaveBeenCalledWith('created-project-session')
expect(screen.queryByTestId('project-editor-modal')).not.toBeInTheDocument()
})
expect(desktopUiPreferencesApiMock.updateProjectDisplayName).toHaveBeenCalledWith(
'/workspace/repository',
null,
)
})
it('opens the session before reporting a project display-name save failure', async () => {
let rejectDisplayNameSave!: (error: Error) => void
const displayNameSave = new Promise<never>((_resolve, reject) => {
rejectDisplayNameSave = reject
})
repositoryContextMock.mockResolvedValue({
repoRoot: '/workspace/repository',
workDir: '/workspace/repository/packages/app',
})
createSession.mockResolvedValue('created-project-session')
desktopUiPreferencesApiMock.updateProjectDisplayName.mockReturnValueOnce(displayNameSave)
render(<Sidebar />)
fireEvent.click(screen.getByRole('button', { name: 'New project' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Use existing folder' }))
fireEvent.click(screen.getByRole('button', { name: 'Choose project source' }))
await waitFor(() => expect(repositoryContextMock).toHaveBeenCalled())
fireEvent.click(screen.getByRole('button', { name: 'Submit create project' }))
await waitFor(() => {
expect(createSession).toHaveBeenCalledWith('/workspace/repository/packages/app')
expect(connectToSession).toHaveBeenCalledWith('created-project-session')
expect(screen.queryByTestId('project-editor-modal')).not.toBeInTheDocument()
})
expect(addToast).not.toHaveBeenCalled()
await act(async () => {
rejectDisplayNameSave(new Error('Display name save failed'))
await displayNameSave.catch(() => undefined)
})
await waitFor(() => expect(addToast).toHaveBeenCalledWith({
type: 'error',
message: 'Display name save failed',
}))
expect(deleteSession).not.toHaveBeenCalled()
expect(deleteSessions).not.toHaveBeenCalled()
})
it('does not persist an orphan display name when session creation fails', async () => {
repositoryContextMock.mockResolvedValue({
repoRoot: '/workspace/repository',
workDir: '/workspace/repository/packages/app',
})
createSession.mockRejectedValueOnce(new Error('Session creation failed'))
render(<Sidebar />)
fireEvent.click(screen.getByRole('button', { name: 'New project' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Use existing folder' }))
fireEvent.click(screen.getByRole('button', { name: 'Choose project source' }))
await waitFor(() => expect(repositoryContextMock).toHaveBeenCalled())
fireEvent.click(screen.getByRole('button', { name: 'Submit create project' }))
await waitFor(() => {
expect(createSession).toHaveBeenCalledWith('/workspace/repository/packages/app')
expect(screen.getByTestId('project-editor-modal')).toBeInTheDocument()
})
expect(desktopUiPreferencesApiMock.updateProjectDisplayName).not.toHaveBeenCalled()
expect(connectToSession).not.toHaveBeenCalled()
})
it('uses the resolved non-git workDir as the project display-name key', async () => {
repositoryContextMock.mockResolvedValue({
repoRoot: null,
workDir: '/real/workspace/project',
})
createSession.mockResolvedValue('created-project-session')
render(<Sidebar />)
fireEvent.click(screen.getByRole('button', { name: 'New project' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Use existing folder' }))
fireEvent.click(screen.getByRole('button', { name: 'Choose project source' }))
await waitFor(() => {
const editor = screen.getByTestId('project-editor-modal')
expect(editor).toHaveAttribute('data-source-folder', '/workspace/repository/packages/app')
expect(editor).toHaveAttribute('data-logical-root', '/real/workspace/project')
expect(editor).toHaveAttribute('data-suggested-name', 'project')
})
fireEvent.click(screen.getByRole('button', { name: 'Submit create project' }))
await waitFor(() => {
expect(desktopUiPreferencesApiMock.updateProjectDisplayName).toHaveBeenCalledWith(
'/real/workspace/project',
'Created project',
)
expect(createSession).toHaveBeenCalledWith('/workspace/repository/packages/app')
})
})
it('keeps the latest logical root when an earlier lookup finishes late', async () => {
let resolveFirstLookup: (value: { repoRoot: string | null }) => void = () => undefined
const firstLookup = new Promise<{ repoRoot: string | null }>((resolve) => {
resolveFirstLookup = resolve
})
repositoryContextMock
.mockImplementationOnce(() => firstLookup)
.mockResolvedValueOnce({ repoRoot: '/workspace/other' })
render(<Sidebar />)
fireEvent.click(screen.getByRole('button', { name: 'New project' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Use existing folder' }))
fireEvent.click(screen.getByRole('button', { name: 'Choose project source' }))
fireEvent.click(screen.getByRole('button', { name: 'Choose other project source' }))
await waitFor(() => expect(repositoryContextMock).toHaveBeenNthCalledWith(2, '/workspace/other'))
await waitFor(() => {
const editor = screen.getByTestId('project-editor-modal')
expect(editor).toHaveAttribute('data-source-folder', '/workspace/other')
expect(editor).toHaveAttribute('data-logical-root', '/workspace/other')
expect(editor).toHaveAttribute('data-suggested-name', 'other')
})
await act(async () => {
resolveFirstLookup({ repoRoot: '/workspace/repository' })
await Promise.resolve()
})
const editor = screen.getByTestId('project-editor-modal')
expect(editor).toHaveAttribute('data-source-folder', '/workspace/other')
expect(editor).toHaveAttribute('data-logical-root', '/workspace/other')
expect(editor).toHaveAttribute('data-suggested-name', 'other')
})
it('edits, resets, and removes a project without changing its real path or sessions', async () => {
act(() => {
hydrateProjectDisplayNames(
{ '/workspace/alpha': 'Custom alpha' },
captureProjectDisplayNameHydrationRevision(),
)
})
useSessionStore.setState({
sessions: [makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', new Date().toISOString())],
})
render(<Sidebar />)
fireEvent.click(screen.getByRole('button', { name: 'Project actions for Custom alpha' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Edit project' }))
const editor = screen.getByTestId('project-editor-modal')
expect(editor).toHaveAttribute('data-logical-root', '/workspace/alpha')
expect(editor).toHaveAttribute('data-initial-name', 'Custom alpha')
fireEvent.click(screen.getByRole('button', { name: 'Submit edit project' }))
await waitFor(() => {
expect(desktopUiPreferencesApiMock.updateProjectDisplayName).toHaveBeenNthCalledWith(
1,
'/workspace/alpha',
'Edited project',
)
expect(screen.getByText('Edited project')).toBeInTheDocument()
})
expect(screen.getByRole('button', { name: 'Collapse Edited project' })).toHaveAttribute('title', '/workspace/alpha')
fireEvent.click(screen.getByRole('button', { name: 'Project actions for Edited project' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Edit project' }))
fireEvent.click(screen.getByRole('button', { name: 'Restore folder name' }))
await waitFor(() => {
expect(desktopUiPreferencesApiMock.updateProjectDisplayName).toHaveBeenNthCalledWith(
2,
'/workspace/alpha',
null,
)
expect(screen.getByText('alpha')).toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: 'Close project editor' }))
fireEvent.click(screen.getByRole('button', { name: 'Project actions for alpha' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Edit project' }))
fireEvent.click(screen.getByRole('button', { name: 'Remove project from sidebar' }))
await waitFor(() => expect(screen.queryByTestId('sidebar-project-group-workspace-alpha')).not.toBeInTheDocument())
expect(deleteSession).not.toHaveBeenCalled()
expect(deleteSessions).not.toHaveBeenCalled()
})
it('resets the alias when an edit saves the default folder name', async () => {
act(() => {
hydrateProjectDisplayNames(
{ '/workspace/alpha': 'Custom alpha' },
captureProjectDisplayNameHydrationRevision(),
)
})
useSessionStore.setState({
sessions: [makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', new Date().toISOString())],
})
render(<Sidebar />)
fireEvent.click(screen.getByRole('button', { name: 'Project actions for Custom alpha' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Edit project' }))
fireEvent.click(screen.getByRole('button', { name: 'Submit folder project name' }))
await waitFor(() => {
expect(desktopUiPreferencesApiMock.updateProjectDisplayName).toHaveBeenCalledWith(
'/workspace/alpha',
null,
)
expect(screen.getByText('alpha')).toBeInTheDocument()
})
})
it('resets the default name before a stale alias hydration finishes', async () => {
const hydrationRevision = captureProjectDisplayNameHydrationRevision()
useSessionStore.setState({
sessions: [makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', new Date().toISOString())],
})
render(<Sidebar />)
fireEvent.click(screen.getByRole('button', { name: 'Project actions for alpha' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Edit project' }))
fireEvent.click(screen.getByRole('button', { name: 'Submit folder project name' }))
await waitFor(() => expect(desktopUiPreferencesApiMock.updateProjectDisplayName).toHaveBeenCalledWith(
'/workspace/alpha',
null,
))
act(() => {
hydrateProjectDisplayNames(
{ '/workspace/alpha': 'Stale custom alpha' },
hydrationRevision,
)
})
expect(screen.getByText('alpha')).toBeInTheDocument()
expect(screen.queryByText('Stale custom alpha')).not.toBeInTheDocument()
})
it('recognizes a normalized whitespace basename as the default project name', async () => {
const projectKey = '/workspace/My project '
act(() => {
hydrateProjectDisplayNames(
{ [projectKey]: 'Custom project' },
captureProjectDisplayNameHydrationRevision(),
)
})
useSessionStore.setState({
sessions: [makeSession('project-1', 'Project Session', projectKey, new Date().toISOString())],
})
render(<Sidebar />)
fireEvent.click(screen.getByRole('button', { name: 'Project actions for Custom project' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Edit project' }))
fireEvent.click(screen.getByRole('button', { name: 'Submit folder project name' }))
await waitFor(() => expect(desktopUiPreferencesApiMock.updateProjectDisplayName).toHaveBeenCalledWith(
projectKey,
null,
))
})
it('keeps remove-from-sidebar idempotent after delayed preferences hide the project', async () => {
const preferencesResponse = createDeferred<DesktopUiPreferencesResponse>()
useSessionStore.setState({
sessions: [makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', new Date().toISOString())],
})
render(<Sidebar desktopUiPreferencesRequest={preferencesResponse.promise} />)
fireEvent.click(screen.getByRole('button', { name: 'Project actions for alpha' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Edit project' }))
await act(async () => {
preferencesResponse.resolve(makeDesktopUiPreferencesResponse({
sidebar: {
projectOrder: [],
pinnedProjects: [],
hiddenProjects: ['/workspace/alpha'],
projectOrganization: 'recentProject',
projectSortBy: 'updatedAt',
},
}))
await preferencesResponse.promise
})
await waitFor(() => {
expect(screen.queryByTestId('sidebar-project-group-workspace-alpha')).not.toBeInTheDocument()
expect(screen.getByTestId('project-editor-modal')).toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: 'Remove project from sidebar' }))
await waitFor(() => expect(screen.queryByTestId('project-editor-modal')).not.toBeInTheDocument())
expect(screen.queryByTestId('sidebar-project-group-workspace-alpha')).not.toBeInTheDocument()
expect(desktopUiPreferencesApiMock.updateSidebarPreferences).not.toHaveBeenCalled()
})
it('sorts same-named projects by their stable real-path keys', async () => {
act(() => {
hydrateProjectDisplayNames(
{
'/workspace/alpha': 'Same name',
'/workspace/beta': 'Same name',
},
captureProjectDisplayNameHydrationRevision(),
)
})
useSessionStore.setState({
sessions: [
makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', '2026-05-01T00:00:00.000Z'),
makeSession('beta-1', 'Beta Session', '/workspace/beta', '2026-05-02T00:00:00.000Z'),
],
})
render(<Sidebar />)
fireEvent.click(screen.getByRole('button', { name: 'Project menu' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Organize sidebar' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'By project' }))
await waitFor(() => {
expect(screen.getAllByTestId(/^sidebar-project-group-/).map((group) => group.getAttribute('data-testid'))).toEqual([
'sidebar-project-group-workspace-alpha',
'sidebar-project-group-workspace-beta',
])
})
})
it('persists project header sort preferences through desktop UI settings', async () => {
useSessionStore.setState({
sessions: [
@@ -615,6 +1244,7 @@ describe('Sidebar', () => {
fireEvent.click(screen.getByRole('button', { name: 'Project actions for alpha' }))
expect(screen.getByRole('menuitem', { name: 'Edit project' })).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: 'Pin Project' })).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: 'Open in Finder' })).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: 'Hide from Sidebar' })).toBeInTheDocument()
@@ -765,19 +1395,15 @@ describe('Sidebar', () => {
})
it('uses server sidebar preferences across browser and desktop storage contexts', async () => {
desktopUiPreferencesApiMock.getPreferences.mockResolvedValueOnce({
exists: true,
preferences: {
schemaVersion: 1,
sidebar: {
projectOrder: ['/workspace/beta', '/workspace/alpha'],
pinnedProjects: ['/workspace/beta'],
hiddenProjects: ['/workspace/alpha'],
projectOrganization: 'recentProject',
projectSortBy: 'updatedAt',
},
const preferencesRequest = Promise.resolve(makeDesktopUiPreferencesResponse({
sidebar: {
projectOrder: ['/workspace/beta', '/workspace/alpha'],
pinnedProjects: ['/workspace/beta'],
hiddenProjects: ['/workspace/alpha'],
projectOrganization: 'recentProject',
projectSortBy: 'updatedAt',
},
})
}))
const now = new Date().toISOString()
useSessionStore.setState({
sessions: [
@@ -786,11 +1412,18 @@ describe('Sidebar', () => {
],
})
render(<Sidebar />)
const onPreferencesConsumed = vi.fn()
render(
<Sidebar
desktopUiPreferencesRequest={preferencesRequest}
onDesktopUiPreferencesConsumed={onPreferencesConsumed}
/>,
)
await waitFor(() => {
expect(screen.queryByText('alpha')).not.toBeInTheDocument()
expect(screen.getByText('beta')).toBeInTheDocument()
expect(onPreferencesConsumed).toHaveBeenCalledWith(preferencesRequest)
})
expect(JSON.parse(window.localStorage.getItem(PROJECT_ORDER_STORAGE_KEY) ?? '[]')).toEqual([
'/workspace/beta',
@@ -800,20 +1433,44 @@ describe('Sidebar', () => {
expect(JSON.parse(window.localStorage.getItem(PROJECT_HIDDEN_STORAGE_KEY) ?? '[]')).toEqual(['/workspace/alpha'])
})
it('migrates cached local sidebar preferences when the server file is missing after update', async () => {
desktopUiPreferencesApiMock.getPreferences.mockResolvedValueOnce({
exists: false,
preferences: {
schemaVersion: 1,
sidebar: {
projectOrder: [],
pinnedProjects: [],
hiddenProjects: [],
projectOrganization: 'recentProject',
projectSortBy: 'updatedAt',
},
},
it('invalidates stale bootstrap preferences before a mobile drawer remount', async () => {
const preferencesResponse = createDeferred<DesktopUiPreferencesResponse>()
useSessionStore.setState({
sessions: [makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', new Date().toISOString())],
})
render(<SidebarDrawerHarness request={preferencesResponse.promise} />)
fireEvent.click(screen.getByRole('button', { name: 'Project actions for alpha' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Hide from Sidebar' }))
await waitFor(() => {
expect(screen.queryByTestId('sidebar-project-group-workspace-alpha')).not.toBeInTheDocument()
expect(desktopUiPreferencesApiMock.updateSidebarPreferences).toHaveBeenCalledWith({
projectOrder: [],
pinnedProjects: [],
hiddenProjects: ['/workspace/alpha'],
projectOrganization: 'recentProject',
projectSortBy: 'updatedAt',
})
})
fireEvent.click(screen.getByRole('button', { name: 'Close drawer harness' }))
fireEvent.click(screen.getByRole('button', { name: 'Open drawer harness' }))
expect(screen.queryByTestId('sidebar-project-group-workspace-alpha')).not.toBeInTheDocument()
await act(async () => {
preferencesResponse.resolve(makeDesktopUiPreferencesResponse())
await preferencesResponse.promise
})
expect(screen.queryByTestId('sidebar-project-group-workspace-alpha')).not.toBeInTheDocument()
expect(JSON.parse(window.localStorage.getItem(PROJECT_HIDDEN_STORAGE_KEY) ?? '[]')).toEqual([
'/workspace/alpha',
])
})
it('migrates cached local sidebar preferences when the server file is missing after update', async () => {
const preferencesRequest = Promise.resolve(makeDesktopUiPreferencesResponse({ exists: false }))
window.localStorage.setItem(PROJECT_HIDDEN_STORAGE_KEY, JSON.stringify(['/workspace/beta']))
useSessionStore.setState({
sessions: [
@@ -822,7 +1479,7 @@ describe('Sidebar', () => {
],
})
render(<Sidebar />)
render(<Sidebar desktopUiPreferencesRequest={preferencesRequest} />)
await waitFor(() => {
expect(desktopUiPreferencesApiMock.updateSidebarPreferences).toHaveBeenCalledWith({
+271 -33
View File
@@ -13,18 +13,29 @@ import { Spinner } from '@/components/ui/Spinner'
import { useDismissable } from '@/hooks/useDismissable'
import { GlobalSearchModal } from '../search/GlobalSearchModal'
import { FindInPageModal } from '../search/FindInPageModal'
import { ProjectEditorModal, type ProjectEditorSubmission } from './ProjectEditorModal'
import { sessionsApi } from '../../api/sessions'
import type { SessionListItem } from '../../types/session'
import { useTabStore, SETTINGS_TAB_ID, SCHEDULED_TAB_ID, MARKET_TAB_ID } from '../../stores/tabStore'
import { useChatStore } from '../../stores/chatStore'
import { useOpenTargetStore } from '../../stores/openTargetStore'
import { desktopUiPreferencesApi, type SidebarProjectPreferences } from '../../api/desktopUiPreferences'
import {
resetProjectDisplayName,
resolveProjectDisplayName,
setProjectDisplayName,
useProjectDisplayNameRevision,
} from '../../stores/projectDisplayNameStore'
import {
desktopUiPreferencesApi,
type DesktopUiPreferencesResponse,
type SidebarProjectPreferences,
} from '../../api/desktopUiPreferences'
import { getDesktopHost } from '../../lib/desktopHost'
import { hasRunningBackgroundTasks } from '../../lib/backgroundTasks'
import { getSessionWorkspaceState } from '../../lib/sessionWorkspace'
const desktopHost = getDesktopHost()
const isDesktopRuntime = desktopHost.isDesktop
const canUseNativeDialogs = desktopHost.capabilities.dialogs
const isWindows = typeof navigator !== 'undefined' && /Win/.test(navigator.platform)
const SESSION_LIST_AUTO_REFRESH_MS = 30_000
const SESSION_LIST_FOCUS_REFRESH_MIN_MS = 5_000
@@ -48,9 +59,23 @@ type ProjectGroup = {
sessions: SessionListItem[]
}
type ProjectEditorState =
| {
mode: 'create'
sourceFolder: string
logicalRoot: string
suggestedName: string
}
| {
mode: 'edit'
logicalRoot: string
}
type SidebarProps = {
isMobile?: boolean
onRequestClose?: () => void
desktopUiPreferencesRequest?: Promise<DesktopUiPreferencesResponse> | null
onDesktopUiPreferencesConsumed?: (request: Promise<DesktopUiPreferencesResponse>) => void
}
type SessionScrollAnchor = {
@@ -58,7 +83,12 @@ type SessionScrollAnchor = {
topOffset: number
}
export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
export function Sidebar({
isMobile = false,
onRequestClose,
desktopUiPreferencesRequest,
onDesktopUiPreferencesConsumed,
}: SidebarProps) {
const t = useTranslation()
const sessions = useSessionStore((s) => s.sessions)
const isLoading = useSessionStore((s) => s.isLoading)
@@ -105,6 +135,10 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
const [projectSortBy, setProjectSortByState] = useState<SidebarProjectSortBy>(() => readStoredProjectSortBy())
const [draggingProjectKey, setDraggingProjectKey] = useState<string | null>(null)
const [projectDropTarget, setProjectDropTarget] = useState<{ key: string; position: 'before' | 'after' } | null>(null)
const [projectEditor, setProjectEditor] = useState<ProjectEditorState | null>(null)
const [projectEditorError, setProjectEditorError] = useState<string | null>(null)
const [projectEditorLoading, setProjectEditorLoading] = useState(false)
const projectDisplayNameRevision = useProjectDisplayNameRevision()
const suppressProjectClickRef = useRef<string | null>(null)
const sessionContextMenuRef = useRef<HTMLDivElement>(null)
const projectContextMenuRef = useRef<HTMLDivElement>(null)
@@ -112,6 +146,11 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
const projectHeaderSubmenuRef = useRef<HTMLDivElement>(null)
const projectHeaderActionsRef = useRef<HTMLDivElement>(null)
const sidebarPreferenceRevisionRef = useRef(0)
const projectRootLookupRevisionRef = useRef(0)
const projectRootLookupRef = useRef<{
sourceFolder: string
request: ReturnType<typeof sessionsApi.getRepositoryContext>
} | null>(null)
const sessionScrollAreaRef = useRef<HTMLDivElement>(null)
const pendingSessionScrollAnchorRef = useRef<SessionScrollAnchor | null>(null)
const refreshSessionsNow = useSessionListAutoRefresh(fetchSessions)
@@ -177,7 +216,10 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
// Title filtering moved into the global search modal (Cmd+K); the list shows all sessions.
const filteredSessions = sessions
const projectGroups = useMemo(() => groupByProject(filteredSessions, projectSortBy), [filteredSessions, projectSortBy])
const projectGroups = useMemo(
() => groupByProject(filteredSessions, projectSortBy, resolveProjectDisplayName),
[filteredSessions, projectDisplayNameRevision, projectSortBy],
)
const orderedProjectGroups = useMemo(
() => applyProjectOrder(projectGroups, projectOrder, pinnedProjectKeys, projectOrganization, projectSortBy),
[projectGroups, projectOrder, pinnedProjectKeys, projectOrganization, projectSortBy],
@@ -237,8 +279,12 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
const normalized = normalizeSidebarProjectPreferences(preferences)
sidebarPreferenceRevisionRef.current += 1
writeCachedSidebarProjectPreferences(normalized)
if (desktopUiPreferencesRequest) {
const request = desktopUiPreferencesRequest
queueMicrotask(() => onDesktopUiPreferencesConsumed?.(request))
}
void desktopUiPreferencesApi.updateSidebarPreferences(normalized).catch(() => undefined)
}, [])
}, [desktopUiPreferencesRequest, onDesktopUiPreferencesConsumed])
const restoreHiddenProjectForWorkDir = useCallback((workDir: string | null | undefined) => {
if (!workDir) return
@@ -257,12 +303,16 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
}, [persistSidebarProjectPreferences, pinnedProjectKeys, projectOrder, projectOrganization, projectSortBy])
useEffect(() => {
if (!desktopUiPreferencesRequest) return
let cancelled = false
const request = desktopUiPreferencesRequest
const startRevision = sidebarPreferenceRevisionRef.current
void desktopUiPreferencesApi.getPreferences()
void request
.then((response) => {
if (cancelled || startRevision !== sidebarPreferenceRevisionRef.current) return
if (cancelled) return
if (startRevision !== sidebarPreferenceRevisionRef.current) return
const localPreferences = readCachedSidebarProjectPreferences()
const serverPreferences = normalizeSidebarProjectPreferences(response.preferences.sidebar)
@@ -278,11 +328,14 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
.catch(() => {
// The sidebar remains usable with the local cache if the server is still booting.
})
.finally(() => {
if (!cancelled) onDesktopUiPreferencesConsumed?.(request)
})
return () => {
cancelled = true
}
}, [applySidebarProjectPreferences])
}, [applySidebarProjectPreferences, desktopUiPreferencesRequest, onDesktopUiPreferencesConsumed])
const handleContextMenu = useCallback((e: React.MouseEvent, id: string) => {
e.preventDefault()
@@ -414,32 +467,134 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
))
}, [hiddenProjectKeys, persistSidebarProjectPreferences, pinnedProjectKeys, projectOrganization])
const createSessionFromExistingFolder = useCallback(async () => {
const closeProjectEditor = useCallback(() => {
projectRootLookupRevisionRef.current += 1
projectRootLookupRef.current = null
setProjectEditor(null)
setProjectEditorError(null)
}, [])
const openProjectCreator = useCallback(() => {
projectRootLookupRevisionRef.current += 1
projectRootLookupRef.current = null
setProjectHeaderMenu(null)
setProjectHeaderSubmenu(null)
if (!canUseNativeDialogs) {
addToast({
type: 'error',
message: t('sidebar.chooseProjectFolderUnavailable'),
})
setProjectEditorError(null)
setProjectEditor({
mode: 'create',
sourceFolder: '',
logicalRoot: '',
suggestedName: '',
})
}, [])
const updateProjectCreatorSourceFolder = useCallback((sourceFolder: string) => {
const lookupRevision = ++projectRootLookupRevisionRef.current
const fallbackSuggestedName = sourceFolder ? projectTitle(sourceFolder) : ''
setProjectEditor((current) => current?.mode === 'create'
? {
...current,
sourceFolder,
logicalRoot: sourceFolder,
suggestedName: fallbackSuggestedName,
}
: current)
if (!sourceFolder.trim()) {
projectRootLookupRef.current = null
return
}
try {
const selected = await getDesktopHost().dialogs.open({
directory: true,
multiple: false,
title: t('sidebar.useExistingFolder'),
const request = sessionsApi.getRepositoryContext(sourceFolder)
projectRootLookupRef.current = { sourceFolder, request }
void request
.then((context) => {
if (projectRootLookupRevisionRef.current !== lookupRevision) return
const logicalRoot = context.repoRoot || context.workDir || sourceFolder
setProjectEditor((current) => current?.mode === 'create' && current.sourceFolder === sourceFolder
? {
...current,
logicalRoot,
suggestedName: projectTitle(logicalRoot),
}
: current)
})
if (typeof selected === 'string' && selected.trim()) {
await createSessionForWorkDir(selected)
.catch(() => {
if (projectRootLookupRef.current?.request === request) {
projectRootLookupRef.current = null
}
})
}, [])
const submitProjectCreation = useCallback(async ({ name, sourceFolder, logicalRoot }: ProjectEditorSubmission) => {
setProjectEditorLoading(true)
setProjectEditorError(null)
try {
const cachedLookup = projectRootLookupRef.current
const contextRequest = (cachedLookup?.sourceFolder === sourceFolder
? cachedLookup.request
: sessionsApi.getRepositoryContext(sourceFolder))
.catch(() => null)
const sessionId = await useSessionStore.getState().createSession(sourceFolder)
restoreHiddenProjectForWorkDir(sourceFolder)
useTabStore.getState().openTab(sessionId, t('sidebar.newSession'))
useChatStore.getState().connectToSession(sessionId)
closeMobileDrawer()
closeProjectEditor()
const context = await contextRequest
const resolvedLogicalRoot = context?.repoRoot || context?.workDir || logicalRoot || sourceFolder
try {
await saveProjectDisplayName(resolvedLogicalRoot, name)
} catch (displayNameError) {
addToast({
type: 'error',
message: displayNameError instanceof Error
? displayNameError.message
: t('sidebar.projectEditor.actionFailed'),
})
}
} catch (error) {
addToast({
type: 'error',
message: error instanceof Error ? error.message : t('sidebar.sessionListFailed'),
})
setProjectEditorError(error instanceof Error ? error.message : t('sidebar.sessionListFailed'))
} finally {
setProjectEditorLoading(false)
}
}, [addToast, createSessionForWorkDir, t])
}, [addToast, closeMobileDrawer, closeProjectEditor, restoreHiddenProjectForWorkDir, t])
const openProjectEditor = useCallback((project: ProjectGroup) => {
if (project.key === 'unknown' || !project.workDir) return
projectRootLookupRevisionRef.current += 1
setProjectContextMenu(null)
setProjectEditorError(null)
setProjectEditor({ mode: 'edit', logicalRoot: project.key })
}, [])
const submitProjectEdit = useCallback(async ({ name, logicalRoot }: ProjectEditorSubmission) => {
setProjectEditorLoading(true)
setProjectEditorError(null)
try {
await saveProjectDisplayName(logicalRoot, name)
closeProjectEditor()
} catch (error) {
setProjectEditorError(error instanceof Error ? error.message : t('sidebar.sessionListFailed'))
} finally {
setProjectEditorLoading(false)
}
}, [closeProjectEditor, t])
const restoreProjectFolderName = useCallback(async (logicalRoot: string) => {
setProjectEditorLoading(true)
setProjectEditorError(null)
try {
await resetProjectDisplayName(logicalRoot)
} catch (error) {
setProjectEditorError(error instanceof Error ? error.message : t('sidebar.sessionListFailed'))
throw error
} finally {
setProjectEditorLoading(false)
}
}, [t])
const togglePinnedProject = useCallback((projectKey: string) => {
setProjectContextMenu(null)
@@ -493,6 +648,23 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
}
}, [addToast, hiddenProjectKeys, persistSidebarProjectPreferences, pinnedProjectKeys, projectOrder, projectOrganization, projectSortBy, t])
const hideProjectFromSidebar = useCallback((project: ProjectGroup) => {
setProjectContextMenu(null)
if (hiddenProjectKeys.has(project.key)) return
setHiddenProjectKeys((current) => {
if (current.has(project.key)) return current
const next = new Set(current)
next.add(project.key)
persistSidebarProjectPreferences(buildSidebarProjectPreferences(projectOrder, pinnedProjectKeys, next, projectOrganization, projectSortBy))
return next
})
addToast({
type: 'info',
message: t('sidebar.projectHidden', { project: project.title }),
})
}, [addToast, hiddenProjectKeys, persistSidebarProjectPreferences, pinnedProjectKeys, projectOrder, projectOrganization, projectSortBy, t])
const openProjectInFinder = useCallback(async (project: ProjectGroup) => {
setProjectContextMenu(null)
try {
@@ -912,7 +1084,7 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
<EmptyState variant="inline" title={t('sidebar.noSessions')} />
</div>
)}
{orderedProjectGroups.length > 0 && (
{!showInitialLoading && (
<ProjectHeaderActions
title={t('sidebar.projects')}
menuLabel={t('sidebar.projectMenu')}
@@ -1225,6 +1397,14 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
style={positionProjectMenu(projectContextMenu.x, projectContextMenu.y)}
onClick={(event) => event.stopPropagation()}
>
{project.key !== 'unknown' && project.workDir && (
<ProjectMenuItem
icon={<SquarePen size={18} aria-hidden="true" />}
onClick={() => openProjectEditor(project)}
>
{t('sidebar.projectEditor.editTitle')}
</ProjectMenuItem>
)}
<ProjectMenuItem
icon={pinned ? <PinOff size={18} aria-hidden="true" /> : <Pin size={18} aria-hidden="true" />}
onClick={() => togglePinnedProject(project.key)}
@@ -1260,7 +1440,7 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
onSetOrganization={updateProjectOrganization}
onSetSortBy={updateProjectSortBy}
onCreateBlank={() => void createSessionForWorkDir()}
onUseExistingFolder={() => void createSessionFromExistingFolder()}
onUseExistingFolder={openProjectCreator}
onRestoreHiddenProjects={restoreAllHiddenProjects}
hiddenProjectCount={hiddenProjectKeys.size}
t={t}
@@ -1279,13 +1459,54 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
onSetOrganization={updateProjectOrganization}
onSetSortBy={updateProjectSortBy}
onCreateBlank={() => void createSessionForWorkDir()}
onUseExistingFolder={() => void createSessionFromExistingFolder()}
onUseExistingFolder={openProjectCreator}
onRestoreHiddenProjects={restoreAllHiddenProjects}
hiddenProjectCount={hiddenProjectKeys.size}
t={t}
/>
)}
{projectEditor?.mode === 'create' && (
<ProjectEditorModal
open
mode="create"
sourceFolder={projectEditor.sourceFolder}
logicalRoot={projectEditor.logicalRoot}
suggestedName={projectEditor.suggestedName}
loading={projectEditorLoading}
error={projectEditorError}
onClose={closeProjectEditor}
onSourceFolderChange={updateProjectCreatorSourceFolder}
onSubmit={submitProjectCreation}
/>
)}
{projectEditor?.mode === 'edit' && (() => {
const customDisplayName = resolveProjectDisplayName(projectEditor.logicalRoot)
const project = orderedProjectGroups.find((group) => group.key === projectEditor.logicalRoot)
return (
<ProjectEditorModal
open
mode="edit"
logicalRoot={projectEditor.logicalRoot}
initialName={customDisplayName ?? undefined}
suggestedName={projectTitle(projectEditor.logicalRoot)}
loading={projectEditorLoading}
error={projectEditorError}
onClose={closeProjectEditor}
onSubmit={submitProjectEdit}
onRestoreFolderName={customDisplayName
? () => restoreProjectFolderName(projectEditor.logicalRoot)
: undefined}
onRemoveFromSidebar={project
? () => {
hideProjectFromSidebar(project)
closeProjectEditor()
}
: undefined}
/>
)
})()}
<ConfirmDialog
open={pendingDeleteSessionId !== null}
onClose={() => setPendingDeleteSessionId(null)}
@@ -1517,7 +1738,7 @@ const ProjectHeaderMenu = forwardRef<HTMLDivElement, {
return (
<div ref={ref} role="menu" className={className} style={style} onClick={(event) => event.stopPropagation()}>
<HeaderMenuItem icon={<SquarePen size={18} aria-hidden="true" />} onClick={onCreateBlank}>
{t('sidebar.newBlankProject')}
{t('sidebar.newBlankSession')}
</HeaderMenuItem>
<HeaderMenuItem icon={<FolderOpen size={18} aria-hidden="true" />} onClick={onUseExistingFolder}>
{t('sidebar.useExistingFolder')}
@@ -1620,7 +1841,11 @@ function HeaderMenuItem({
)
}
function groupByProject(sessions: SessionListItem[], sortBy: SidebarProjectSortBy): ProjectGroup[] {
function groupByProject(
sessions: SessionListItem[],
sortBy: SidebarProjectSortBy,
displayNameForProject: (projectKey: string) => string | null,
): ProjectGroup[] {
const groupsByKey = new Map<string, SessionListItem[]>()
for (const session of sessions) {
const key = getSessionProjectKey(session)
@@ -1635,7 +1860,7 @@ function groupByProject(sessions: SessionListItem[], sortBy: SidebarProjectSortB
const projectRoot = newest?.projectRoot || newest?.workDir || key
return {
key,
title: projectTitle(projectRoot),
title: displayNameForProject(key) || projectTitle(projectRoot),
subtitle: projectSubtitle(projectRoot, key),
workDir: projectRoot || newest?.workDir || undefined,
sessions: sortedSessions,
@@ -1657,7 +1882,10 @@ function applyProjectOrder(
const aPinned = pinnedProjectKeys.has(a.key)
const bPinned = pinnedProjectKeys.has(b.key)
if (aPinned !== bPinned) return aPinned ? -1 : 1
if (organization === 'project') return a.title.localeCompare(b.title)
if (organization === 'project') {
const titleOrder = a.title.localeCompare(b.title)
return titleOrder || a.key.localeCompare(b.key)
}
const aIndex = orderIndex.get(a.key)
const bIndex = orderIndex.get(b.key)
if (aIndex !== undefined && bIndex !== undefined) return aIndex - bIndex
@@ -1908,6 +2136,16 @@ function projectTitle(pathLike: string | null | undefined): string {
return normalized || 'Unknown project'
}
async function saveProjectDisplayName(projectKey: string, displayName: string): Promise<void> {
const defaultDisplayName = projectTitle(projectKey).trim().replace(/\s+/g, ' ')
if (displayName === defaultDisplayName) {
await resetProjectDisplayName(projectKey)
return
}
if (resolveProjectDisplayName(projectKey) === displayName) return
await setProjectDisplayName(projectKey, displayName)
}
function projectSubtitle(projectRoot: string | null | undefined, fallbackKey: string): string | null {
if (!projectRoot) return fallbackKey === 'unknown' ? null : fallbackKey
return compactProjectPath(projectRoot)
@@ -79,7 +79,8 @@ function preferencesResponse(pet: DesktopPetPreferences) {
return {
exists: true,
preferences: {
schemaVersion: 4,
schemaVersion: 5,
projectDisplayNames: {},
sidebar: {
projectOrder: [],
pinnedProjects: [],
+16
View File
@@ -80,6 +80,22 @@ export const en = {
'sidebar.sortByCreatedAt': 'Created time',
'sidebar.sortByUpdatedAt': 'Updated time',
'sidebar.newBlankProject': 'New blank project',
'sidebar.newBlankSession': 'New blank session',
'sidebar.projectEditor.createTitle': 'Create project',
'sidebar.projectEditor.editTitle': 'Edit project',
'sidebar.projectEditor.name': 'Project name',
'sidebar.projectEditor.nameHint': 'Up to {count} characters',
'sidebar.projectEditor.nameRequired': 'A project name is required.',
'sidebar.projectEditor.nameTooLong': 'Project names can be at most 80 characters.',
'sidebar.projectEditor.sourceFolder': 'Source folder',
'sidebar.projectEditor.sourceFolderRequired': 'Choose a source folder.',
'sidebar.projectEditor.realPath': 'Real path',
'sidebar.projectEditor.realPathHint': 'This project path is fixed and cannot be changed here.',
'sidebar.projectEditor.restoreFolderName': 'Restore folder name',
'sidebar.projectEditor.removeFromSidebar': 'Remove from sidebar',
'sidebar.projectEditor.removeFromSidebarHint': 'This only removes the project from the sidebar. Its sessions and files remain unchanged.',
'sidebar.projectEditor.create': 'Create project',
'sidebar.projectEditor.actionFailed': 'Could not save the project changes.',
'sidebar.useExistingFolder': 'Use existing folder',
'sidebar.chooseProjectFolderUnavailable': 'Folder selection is only available in the desktop app.',
'sidebar.projectActions': 'Project actions for {project}',
+16
View File
@@ -82,6 +82,22 @@ export const jp: Record<TranslationKey, string> = {
'sidebar.sortByCreatedAt': '作成日時',
'sidebar.sortByUpdatedAt': '更新日時',
'sidebar.newBlankProject': '新しい空のプロジェクト',
'sidebar.newBlankSession': '新しい空のセッション',
'sidebar.projectEditor.createTitle': 'プロジェクトを作成',
'sidebar.projectEditor.editTitle': 'プロジェクトを編集',
'sidebar.projectEditor.name': 'プロジェクト名',
'sidebar.projectEditor.nameHint': '最大 {count} 文字',
'sidebar.projectEditor.nameRequired': 'プロジェクト名を入力してください。',
'sidebar.projectEditor.nameTooLong': 'プロジェクト名は最大 80 文字です。',
'sidebar.projectEditor.sourceFolder': 'ソースフォルダ',
'sidebar.projectEditor.sourceFolderRequired': 'ソースフォルダを選択してください。',
'sidebar.projectEditor.realPath': '実際のパス',
'sidebar.projectEditor.realPathHint': 'このプロジェクトのパスは固定されており、ここでは変更できません。',
'sidebar.projectEditor.restoreFolderName': 'フォルダ名に戻す',
'sidebar.projectEditor.removeFromSidebar': 'サイドバーから削除',
'sidebar.projectEditor.removeFromSidebarHint': 'これはサイドバーからプロジェクトを外すだけです。既存のセッションとファイルは変更されません。',
'sidebar.projectEditor.create': 'プロジェクトを作成',
'sidebar.projectEditor.actionFailed': 'プロジェクトの変更を保存できませんでした。',
'sidebar.useExistingFolder': '既存のフォルダを使用',
'sidebar.chooseProjectFolderUnavailable': 'フォルダの選択はデスクトップアプリでのみ利用できます。',
'sidebar.projectActions': '{project} のプロジェクト操作',
+16
View File
@@ -82,6 +82,22 @@ export const kr: Record<TranslationKey, string> = {
'sidebar.sortByCreatedAt': '생성 시간',
'sidebar.sortByUpdatedAt': '업데이트 시간',
'sidebar.newBlankProject': '새 빈 프로젝트',
'sidebar.newBlankSession': '새 빈 세션',
'sidebar.projectEditor.createTitle': '프로젝트 만들기',
'sidebar.projectEditor.editTitle': '프로젝트 편집',
'sidebar.projectEditor.name': '프로젝트 이름',
'sidebar.projectEditor.nameHint': '최대 {count}자',
'sidebar.projectEditor.nameRequired': '프로젝트 이름을 입력하세요.',
'sidebar.projectEditor.nameTooLong': '프로젝트 이름은 최대 80자까지 입력할 수 있습니다.',
'sidebar.projectEditor.sourceFolder': '원본 폴더',
'sidebar.projectEditor.sourceFolderRequired': '원본 폴더를 선택하세요.',
'sidebar.projectEditor.realPath': '실제 경로',
'sidebar.projectEditor.realPathHint': '이 프로젝트 경로는 고정되어 여기서 변경할 수 없습니다.',
'sidebar.projectEditor.restoreFolderName': '폴더 이름으로 복원',
'sidebar.projectEditor.removeFromSidebar': '사이드바에서 제거',
'sidebar.projectEditor.removeFromSidebarHint': '사이드바에서만 프로젝트를 제거합니다. 기존 세션과 파일은 변경되지 않습니다.',
'sidebar.projectEditor.create': '프로젝트 만들기',
'sidebar.projectEditor.actionFailed': '프로젝트 변경 사항을 저장하지 못했습니다.',
'sidebar.useExistingFolder': '기존 폴더 사용',
'sidebar.chooseProjectFolderUnavailable': '폴더 선택은 데스크톱 앱에서만 사용할 수 있습니다.',
'sidebar.projectActions': '{project}의 프로젝트 작업',
+16
View File
@@ -82,6 +82,22 @@ export const zh: Record<TranslationKey, string> = {
'sidebar.sortByCreatedAt': '建立時間',
'sidebar.sortByUpdatedAt': '更新時間',
'sidebar.newBlankProject': '新建空白專案',
'sidebar.newBlankSession': '新建空白會話',
'sidebar.projectEditor.createTitle': '建立專案',
'sidebar.projectEditor.editTitle': '編輯專案',
'sidebar.projectEditor.name': '專案名稱',
'sidebar.projectEditor.nameHint': '最多 {count} 個字元',
'sidebar.projectEditor.nameRequired': '請輸入專案名稱。',
'sidebar.projectEditor.nameTooLong': '專案名稱最多 80 個字元。',
'sidebar.projectEditor.sourceFolder': '來源資料夾',
'sidebar.projectEditor.sourceFolderRequired': '請選擇來源資料夾。',
'sidebar.projectEditor.realPath': '真實路徑',
'sidebar.projectEditor.realPathHint': '專案真實路徑固定,無法在這裡修改。',
'sidebar.projectEditor.restoreFolderName': '恢復資料夾名稱',
'sidebar.projectEditor.removeFromSidebar': '從側邊欄移除',
'sidebar.projectEditor.removeFromSidebarHint': '這只會從側邊欄移除專案,現有會話和檔案不會改變。',
'sidebar.projectEditor.create': '建立專案',
'sidebar.projectEditor.actionFailed': '無法儲存專案變更。',
'sidebar.useExistingFolder': '使用現有資料夾',
'sidebar.chooseProjectFolderUnavailable': '選擇資料夾僅在桌面端可用。',
'sidebar.projectActions': '{project} 專案操作',
+16
View File
@@ -82,6 +82,22 @@ export const zh: Record<TranslationKey, string> = {
'sidebar.sortByCreatedAt': '创建时间',
'sidebar.sortByUpdatedAt': '更新时间',
'sidebar.newBlankProject': '新建空白项目',
'sidebar.newBlankSession': '新建空白会话',
'sidebar.projectEditor.createTitle': '创建项目',
'sidebar.projectEditor.editTitle': '编辑项目',
'sidebar.projectEditor.name': '项目名称',
'sidebar.projectEditor.nameHint': '最多 {count} 个字符',
'sidebar.projectEditor.nameRequired': '请输入项目名称。',
'sidebar.projectEditor.nameTooLong': '项目名称最多 80 个字符。',
'sidebar.projectEditor.sourceFolder': '源文件夹',
'sidebar.projectEditor.sourceFolderRequired': '请选择源文件夹。',
'sidebar.projectEditor.realPath': '真实路径',
'sidebar.projectEditor.realPathHint': '项目真实路径固定,无法在这里修改。',
'sidebar.projectEditor.restoreFolderName': '恢复文件夹名称',
'sidebar.projectEditor.removeFromSidebar': '从侧边栏移除',
'sidebar.projectEditor.removeFromSidebarHint': '这只会从侧边栏移除项目,已有会话和文件不会改变。',
'sidebar.projectEditor.create': '创建项目',
'sidebar.projectEditor.actionFailed': '无法保存项目更改。',
'sidebar.useExistingFolder': '使用现有文件夹',
'sidebar.chooseProjectFolderUnavailable': '选择文件夹仅在桌面端可用。',
'sidebar.projectActions': '{project} 项目操作',
@@ -0,0 +1,177 @@
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { desktopUiPreferencesApi } from '../api/desktopUiPreferences'
import {
captureProjectDisplayNameHydrationRevision,
hydrateProjectDisplayNames,
resetProjectDisplayName,
resolveProjectDisplayName,
setProjectDisplayName,
subscribeProjectDisplayNameChanges,
useProjectDisplayNameRevision,
} from './projectDisplayNameStore'
vi.mock('../api/desktopUiPreferences', () => ({
desktopUiPreferencesApi: {
updateProjectDisplayName: vi.fn(),
},
}))
function createDeferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, resolve, reject }
}
const updateProjectDisplayName = vi.mocked(desktopUiPreferencesApi.updateProjectDisplayName)
beforeEach(() => {
hydrateProjectDisplayNames({}, Number.MAX_SAFE_INTEGER)
updateProjectDisplayName.mockReset()
})
afterEach(() => {
cleanup()
hydrateProjectDisplayNames({}, Number.MAX_SAFE_INTEGER)
})
describe('projectDisplayNameStore', () => {
it('resolves only the exact project key and not a normalized path variant', async () => {
const projectKey = '/workspace/apps/../project'
updateProjectDisplayName.mockResolvedValue({ ok: true, projectKey, displayName: 'Project alias' })
await setProjectDisplayName(projectKey, 'Project alias')
expect(resolveProjectDisplayName(projectKey)).toBe('Project alias')
expect(resolveProjectDisplayName('/workspace/project')).toBeNull()
})
it('notifies subscribers when the resolved display name changes', async () => {
const listener = vi.fn()
const unsubscribe = subscribeProjectDisplayNameChanges(listener)
updateProjectDisplayName.mockResolvedValue({
ok: true,
projectKey: '/workspace/project',
displayName: 'Renamed',
})
await setProjectDisplayName('/workspace/project', 'Renamed')
unsubscribe()
await setProjectDisplayName('/workspace/project', 'Renamed again')
expect(listener).toHaveBeenCalledTimes(1)
})
it('publishes a revision snapshot for consumers that resolve multiple project keys', async () => {
const { result } = renderHook(() => useProjectDisplayNameRevision())
const initialRevision = result.current
updateProjectDisplayName.mockResolvedValue({
ok: true,
projectKey: '/workspace/project',
displayName: 'Renamed',
})
await act(async () => {
await setProjectDisplayName('/workspace/project', 'Renamed')
})
expect(result.current).toBeGreaterThan(initialRevision)
})
it('optimistically updates one key and rolls back only that key after a failed save', async () => {
const hydrationRevision = captureProjectDisplayNameHydrationRevision()
hydrateProjectDisplayNames({
'/workspace/one': 'One',
'/workspace/two': 'Two',
}, hydrationRevision)
updateProjectDisplayName.mockRejectedValueOnce(new Error('save failed'))
const save = setProjectDisplayName('/workspace/one', 'Renamed one')
expect(resolveProjectDisplayName('/workspace/one')).toBe('Renamed one')
expect(resolveProjectDisplayName('/workspace/two')).toBe('Two')
await expect(save).rejects.toThrow('save failed')
expect(resolveProjectDisplayName('/workspace/one')).toBe('One')
expect(resolveProjectDisplayName('/workspace/two')).toBe('Two')
})
it('does not let an earlier failed mutation roll back a later mutation for the same key', async () => {
const firstSave = createDeferred<{ ok: true; projectKey: string; displayName: string | null }>()
const secondSave = createDeferred<{ ok: true; projectKey: string; displayName: string | null }>()
let secondSaveStarted!: () => void
const secondSaveStartedPromise = new Promise<void>((resolve) => {
secondSaveStarted = resolve
})
updateProjectDisplayName
.mockImplementationOnce(() => firstSave.promise)
.mockImplementationOnce(() => {
secondSaveStarted()
return secondSave.promise
})
const first = setProjectDisplayName('/workspace/project', 'First')
const second = setProjectDisplayName('/workspace/project', 'Second')
firstSave.reject(new Error('first save failed'))
await expect(first).rejects.toThrow('first save failed')
await secondSaveStartedPromise
expect(resolveProjectDisplayName('/workspace/project')).toBe('Second')
secondSave.resolve({ ok: true, projectKey: '/workspace/project', displayName: 'Second' })
await expect(second).resolves.toBeUndefined()
expect(resolveProjectDisplayName('/workspace/project')).toBe('Second')
})
it('keeps a post-request local mutation when a stale preference response hydrates', async () => {
const hydrationRevision = captureProjectDisplayNameHydrationRevision()
const saveResponse = createDeferred<{ ok: true; projectKey: string; displayName: string | null }>()
updateProjectDisplayName.mockReturnValueOnce(saveResponse.promise)
const save = setProjectDisplayName('/workspace/project', 'Local name')
hydrateProjectDisplayNames({
'/workspace/project': 'Stale server name',
'/workspace/other': 'Other server name',
}, hydrationRevision)
expect(resolveProjectDisplayName('/workspace/project')).toBe('Local name')
expect(resolveProjectDisplayName('/workspace/other')).toBe('Other server name')
saveResponse.resolve({ ok: true, projectKey: '/workspace/project', displayName: 'Local name' })
await save
})
it('keeps a mutation that settles after preference hydration starts', async () => {
const projectKey = '/workspace/project'
const saveResponse = createDeferred<{ ok: true; projectKey: string; displayName: string | null }>()
updateProjectDisplayName.mockReturnValueOnce(saveResponse.promise)
const save = setProjectDisplayName(projectKey, 'Local name')
const hydrationRevision = captureProjectDisplayNameHydrationRevision()
saveResponse.resolve({ ok: true, projectKey, displayName: 'Local name' })
await save
hydrateProjectDisplayNames({ [projectKey]: 'Stale server name' }, hydrationRevision)
expect(resolveProjectDisplayName(projectKey)).toBe('Local name')
const nextHydrationRevision = captureProjectDisplayNameHydrationRevision()
hydrateProjectDisplayNames({ [projectKey]: 'New server name' }, nextHydrationRevision)
expect(resolveProjectDisplayName(projectKey)).toBe('New server name')
})
it('resets a project display name through the shared update endpoint', async () => {
const projectKey = '/workspace/project'
const hydrationRevision = captureProjectDisplayNameHydrationRevision()
hydrateProjectDisplayNames({ [projectKey]: 'Custom name' }, hydrationRevision)
updateProjectDisplayName.mockResolvedValue({ ok: true, projectKey, displayName: null })
const reset = resetProjectDisplayName(projectKey)
expect(resolveProjectDisplayName(projectKey)).toBeNull()
await expect(reset).resolves.toBeUndefined()
expect(updateProjectDisplayName).toHaveBeenCalledWith(projectKey, null)
})
})
@@ -0,0 +1,164 @@
import { useSyncExternalStore } from 'react'
import { desktopUiPreferencesApi } from '../api/desktopUiPreferences'
export type ProjectDisplayNames = Record<string, string>
type DisplayName = string | null
type PendingMutation = {
displayName: DisplayName
}
type Listener = () => void
const resolvedDisplayNames = new Map<string, string>()
const confirmedDisplayNames = new Map<string, string>()
const confirmedMutationRevisions = new Map<string, number>()
const pendingMutations = new Map<string, PendingMutation[]>()
const writeQueues = new Map<string, Promise<void>>()
const listeners = new Set<Listener>()
let hydrationBarrierRevision = 0
let snapshotRevision = 0
export function resolveProjectDisplayName(projectKey: string): string | null {
return resolvedDisplayNames.get(projectKey) ?? null
}
export function subscribeProjectDisplayNameChanges(listener: Listener): () => void {
listeners.add(listener)
return () => listeners.delete(listener)
}
export function useProjectDisplayName(projectKey: string): string | null {
return useSyncExternalStore(
subscribeProjectDisplayNameChanges,
() => resolveProjectDisplayName(projectKey),
() => resolveProjectDisplayName(projectKey),
)
}
export function useProjectDisplayNameRevision(): number {
return useSyncExternalStore(
subscribeProjectDisplayNameChanges,
() => snapshotRevision,
() => snapshotRevision,
)
}
export function captureProjectDisplayNameHydrationRevision(): number {
return ++hydrationBarrierRevision
}
export function hydrateProjectDisplayNames(
projectDisplayNames: Readonly<ProjectDisplayNames>,
hydrationRevision: number,
): void {
const hydratedDisplayNames = new Map(Object.entries(projectDisplayNames))
const projectKeys = new Set<string>([
...resolvedDisplayNames.keys(),
...confirmedDisplayNames.keys(),
...confirmedMutationRevisions.keys(),
...pendingMutations.keys(),
...hydratedDisplayNames.keys(),
])
let changed = false
for (const projectKey of projectKeys) {
const confirmedMutationRevision = confirmedMutationRevisions.get(projectKey)
if (confirmedMutationRevision === undefined || confirmedMutationRevision < hydrationRevision) {
setDisplayName(confirmedDisplayNames, projectKey, hydratedDisplayNames.get(projectKey) ?? null)
confirmedMutationRevisions.delete(projectKey)
}
changed = syncResolvedDisplayName(projectKey) || changed
}
if (changed) notifyListeners()
}
export function setProjectDisplayName(projectKey: string, displayName: string): Promise<void> {
return persistProjectDisplayName(projectKey, displayName)
}
export function resetProjectDisplayName(projectKey: string): Promise<void> {
return persistProjectDisplayName(projectKey, null)
}
function persistProjectDisplayName(projectKey: string, displayName: DisplayName): Promise<void> {
const mutation: PendingMutation = { displayName }
const mutations = pendingMutations.get(projectKey) ?? []
mutations.push(mutation)
pendingMutations.set(projectKey, mutations)
if (syncResolvedDisplayName(projectKey)) notifyListeners()
const runMutation = async () => {
try {
const response = await desktopUiPreferencesApi.updateProjectDisplayName(projectKey, displayName)
settleMutation(projectKey, mutation, response.displayName)
} catch (error) {
settleMutation(projectKey, mutation)
throw error
}
}
const previousWrite = writeQueues.get(projectKey)
const request = previousWrite ? previousWrite.then(runMutation) : runMutation()
const queuedWrite = request.catch(() => undefined)
writeQueues.set(projectKey, queuedWrite)
void queuedWrite.then(() => {
if (writeQueues.get(projectKey) === queuedWrite) {
writeQueues.delete(projectKey)
}
})
return request
}
function settleMutation(
projectKey: string,
mutation: PendingMutation,
confirmedDisplayName?: DisplayName,
): void {
const mutations = pendingMutations.get(projectKey)
const mutationIndex = mutations?.indexOf(mutation) ?? -1
if (mutationIndex === -1 || !mutations) return
mutations.splice(mutationIndex, 1)
if (mutations.length === 0) {
pendingMutations.delete(projectKey)
}
if (confirmedDisplayName !== undefined) {
setDisplayName(confirmedDisplayNames, projectKey, confirmedDisplayName)
confirmedMutationRevisions.set(projectKey, ++hydrationBarrierRevision)
}
if (syncResolvedDisplayName(projectKey)) notifyListeners()
}
function syncResolvedDisplayName(projectKey: string): boolean {
const mutations = pendingMutations.get(projectKey)
const latestMutation = mutations?.[mutations.length - 1]
const displayName = latestMutation
? latestMutation.displayName
: confirmedDisplayNames.get(projectKey) ?? null
const currentDisplayName = resolvedDisplayNames.get(projectKey) ?? null
if (currentDisplayName === displayName) return false
setDisplayName(resolvedDisplayNames, projectKey, displayName)
return true
}
function setDisplayName(target: Map<string, string>, projectKey: string, displayName: DisplayName): void {
if (displayName === null) {
target.delete(projectKey)
} else {
target.set(projectKey, displayName)
}
}
function notifyListeners(): void {
snapshotRevision += 1
for (const listener of listeners) listener()
}
@@ -12,6 +12,16 @@ const checks: Check[] = [
title: 'Server persistent JSON migrations',
command: ['bun', 'test', './src/server/__tests__/persistence-upgrade.test.ts'],
},
{
title: 'Desktop UI preference migrations',
command: [
'bun',
'test',
'./src/server/__tests__/desktop-ui-preferences.test.ts',
'--test-name-pattern',
'normalizes old schema files',
],
},
{
title: 'Desktop localStorage migrations',
command: ['bun', 'run', 'test', '--', '--run', 'src/lib/persistenceMigrations.test.ts'],
@@ -66,7 +66,7 @@ describe('DesktopUiPreferencesService', () => {
expect(result.exists).toBe(false)
expect(result.preferences).toEqual({
schemaVersion: 4,
schemaVersion: 5,
profile: {
displayName: 'cc-haha',
subtitle: 'github.com/NanmiCoder/cc-haha',
@@ -74,6 +74,7 @@ describe('DesktopUiPreferencesService', () => {
avatarUpdatedAt: null,
},
pet: DEFAULT_PET_PREFERENCES,
projectDisplayNames: {},
sidebar: {
projectOrder: [],
pinnedProjects: [],
@@ -89,13 +90,22 @@ describe('DesktopUiPreferencesService', () => {
await fs.writeFile(
path.join(tmpDir, 'cc-haha', 'desktop-ui.json'),
JSON.stringify({
schemaVersion: 2,
schemaVersion: 4,
futureField: { keep: true },
sidebar: {
projectOrder: ['/workspace/alpha', 42, '/workspace/alpha', '/workspace/beta'],
pinnedProjects: ['/workspace/beta'],
hiddenProjects: [null, '/workspace/gamma'],
},
projectDisplayNames: {
'/workspace/alpha': ' Alpha project ',
'/workspace/beta': 42,
'': 'Missing key',
' ': 'Whitespace key',
'/workspace/blank': ' ',
'/workspace/too-long-name': 'n'.repeat(81),
['x'.repeat(4_097)]: 'Overlong key',
},
}),
'utf-8',
)
@@ -110,7 +120,7 @@ describe('DesktopUiPreferencesService', () => {
expect(before.exists).toBe(true)
expect(before.preferences).toEqual({
schemaVersion: 4,
schemaVersion: 5,
futureField: { keep: true },
profile: {
displayName: 'cc-haha',
@@ -119,6 +129,9 @@ describe('DesktopUiPreferencesService', () => {
avatarUpdatedAt: null,
},
pet: DEFAULT_PET_PREFERENCES,
projectDisplayNames: {
'/workspace/alpha': 'Alpha project',
},
sidebar: {
projectOrder: ['/workspace/alpha', '/workspace/beta'],
pinnedProjects: ['/workspace/beta'],
@@ -128,7 +141,7 @@ describe('DesktopUiPreferencesService', () => {
},
})
expect(after).toEqual({
schemaVersion: 4,
schemaVersion: 5,
futureField: { keep: true },
profile: {
displayName: 'cc-haha',
@@ -137,6 +150,9 @@ describe('DesktopUiPreferencesService', () => {
avatarUpdatedAt: null,
},
pet: DEFAULT_PET_PREFERENCES,
projectDisplayNames: {
'/workspace/alpha': 'Alpha project',
},
sidebar: {
projectOrder: ['/workspace/gamma'],
pinnedProjects: [],
@@ -160,9 +176,79 @@ describe('DesktopUiPreferencesService', () => {
expect(result.preferences.sidebar.hiddenProjects).toEqual([])
expect(result.preferences.profile.displayName).toBe('cc-haha')
expect(result.preferences.pet).toEqual(DEFAULT_PET_PREFERENCES)
expect(result.preferences.projectDisplayNames).toEqual({})
expect(files.some((name) => name.startsWith('desktop-ui.json.invalid-'))).toBe(true)
})
test('sets and resets exact project display name keys without prototype pollution', async () => {
const service = new DesktopUiPreferencesService()
const projectKey = ' /workspace/alpha '
await expect(service.updateProjectDisplayName({
projectKey,
displayName: ' Alpha project ',
})).resolves.toEqual({
projectKey,
displayName: 'Alpha project',
})
await service.updateProjectDisplayName({ projectKey: '__proto__', displayName: 'Prototype project' })
await service.updateProjectDisplayName({ projectKey: 'constructor', displayName: 'Constructor project' })
const { preferences } = await service.readPreferences()
expect(Object.getPrototypeOf(preferences.projectDisplayNames)).toBe(Object.prototype)
expect(Object.entries(preferences.projectDisplayNames)).toEqual([
[projectKey, 'Alpha project'],
['__proto__', 'Prototype project'],
['constructor', 'Constructor project'],
])
expect(Object.hasOwn(preferences.projectDisplayNames, '__proto__')).toBe(true)
await expect(service.updateProjectDisplayName({ projectKey, displayName: null })).resolves.toEqual({
projectKey,
displayName: null,
})
expect(Object.entries((await service.readPreferences()).preferences.projectDisplayNames)).toEqual([
['__proto__', 'Prototype project'],
['constructor', 'Constructor project'],
])
})
test('merges concurrent project display name changes for different keys', async () => {
const firstRenderer = new DesktopUiPreferencesService()
const secondRenderer = new DesktopUiPreferencesService()
await Promise.all([
firstRenderer.updateProjectDisplayName({
projectKey: '/workspace/alpha',
displayName: 'Alpha',
}),
secondRenderer.updateProjectDisplayName({
projectKey: '/workspace/beta',
displayName: 'Beta',
}),
])
expect((await firstRenderer.readPreferences()).preferences.projectDisplayNames).toEqual({
'/workspace/alpha': 'Alpha',
'/workspace/beta': 'Beta',
})
})
test('preserves project display names when updating sidebar, profile, and pet preferences', async () => {
const service = new DesktopUiPreferencesService()
await service.updateProjectDisplayName({
projectKey: '/workspace/alpha',
displayName: 'Alpha',
})
await service.updateSidebarPreferences({ projectOrder: ['/workspace/alpha'] })
await service.updateProfilePreferences({ displayName: 'Local Operator' })
await service.updatePetPreferences({ collapsed: true })
expect((await service.readPreferences()).preferences.projectDisplayNames).toEqual({
'/workspace/alpha': 'Alpha',
})
})
test('normalizes invalid pet preferences while preserving unrelated fields', async () => {
await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true })
await fs.writeFile(
@@ -221,7 +307,7 @@ describe('DesktopUiPreferencesService', () => {
})
expect(after).toMatchObject({
schemaVersion: 4,
schemaVersion: 5,
futureField: { keep: true },
profile: {
displayName: 'Local Operator',
@@ -300,7 +386,7 @@ describe('DesktopUiPreferencesService', () => {
const after = await new DesktopUiPreferencesService().updatePetPreferences({ enabled: true })
expect(after).toMatchObject({
schemaVersion: 4,
schemaVersion: 5,
futureField: { keep: true },
pet: {
futurePetField: { keep: 'pet-too' },
@@ -343,6 +429,9 @@ describe('DesktopUiPreferencesService', () => {
...DEFAULT_PET_PREFERENCES,
futurePet: { keep: 'pet' },
},
projectDisplayNames: {
'/workspace/future': 'Future project',
},
}),
'utf-8',
)
@@ -358,6 +447,9 @@ describe('DesktopUiPreferencesService', () => {
enabled: true,
futurePet: { keep: 'pet' },
},
projectDisplayNames: {
'/workspace/future': 'Future project',
},
})
expect(await readDesktopUiFile()).toEqual(after)
@@ -376,6 +468,7 @@ describe('DesktopUiPreferencesService', () => {
},
profile: { futureProfile: { keep: 'profile' } },
pet: { futurePet: { keep: 'pet' } },
projectDisplayNames: { '/workspace/future': 'Future project' },
})
})
@@ -389,7 +482,7 @@ describe('DesktopUiPreferencesService', () => {
})
expect(after).toEqual({
schemaVersion: 4,
schemaVersion: 5,
profile: {
displayName: 'Claude Captain',
subtitle: 'local.example/profile',
@@ -397,6 +490,7 @@ describe('DesktopUiPreferencesService', () => {
avatarUpdatedAt: null,
},
pet: DEFAULT_PET_PREFERENCES,
projectDisplayNames: {},
sidebar: {
projectOrder: [],
pinnedProjects: [],
@@ -481,7 +575,7 @@ describe('desktop UI preferences API', () => {
expect(putBody).toEqual({
ok: true,
preferences: {
schemaVersion: 4,
schemaVersion: 5,
profile: {
displayName: 'cc-haha',
subtitle: 'github.com/NanmiCoder/cc-haha',
@@ -489,6 +583,7 @@ describe('desktop UI preferences API', () => {
avatarUpdatedAt: null,
},
pet: DEFAULT_PET_PREFERENCES,
projectDisplayNames: {},
sidebar: {
projectOrder: ['/workspace/beta', '/workspace/alpha'],
pinnedProjects: ['/workspace/beta'],
@@ -507,7 +602,7 @@ describe('desktop UI preferences API', () => {
expect(getBody).toEqual({
exists: true,
preferences: {
schemaVersion: 4,
schemaVersion: 5,
profile: {
displayName: 'cc-haha',
subtitle: 'github.com/NanmiCoder/cc-haha',
@@ -515,6 +610,7 @@ describe('desktop UI preferences API', () => {
avatarUpdatedAt: null,
},
pet: DEFAULT_PET_PREFERENCES,
projectDisplayNames: {},
sidebar: {
projectOrder: ['/workspace/beta', '/workspace/alpha'],
pinnedProjects: ['/workspace/beta'],
@@ -526,6 +622,67 @@ describe('desktop UI preferences API', () => {
})
})
test('sets and resets a project display name through the API without canonicalizing its key', async () => {
const projectKey = ' /workspace/alpha '
const setReq = makeRequest('PUT', '/api/desktop-ui/preferences/project-display-name', {
projectKey,
displayName: ' Alpha project ',
})
const setRes = await handleDesktopUiApi(setReq.req, setReq.url, setReq.segments)
expect(setRes.status).toBe(200)
await expect(setRes.json()).resolves.toEqual({
ok: true,
projectKey,
displayName: 'Alpha project',
})
expect((await new DesktopUiPreferencesService().readPreferences()).preferences.projectDisplayNames).toEqual({
[projectKey]: 'Alpha project',
})
const resetReq = makeRequest('PUT', '/api/desktop-ui/preferences/project-display-name', {
projectKey,
displayName: null,
})
const resetRes = await handleDesktopUiApi(resetReq.req, resetReq.url, resetReq.segments)
expect(resetRes.status).toBe(200)
await expect(resetRes.json()).resolves.toEqual({
ok: true,
projectKey,
displayName: null,
})
expect((await new DesktopUiPreferencesService().readPreferences()).preferences.projectDisplayNames).toEqual({})
})
test('rejects invalid project display name updates without overwriting stored names', async () => {
const validReq = makeRequest('PUT', '/api/desktop-ui/preferences/project-display-name', {
projectKey: '/workspace/alpha',
displayName: 'Alpha',
})
expect((await handleDesktopUiApi(validReq.req, validReq.url, validReq.segments)).status).toBe(200)
for (const [body, message] of [
[{ projectKey: '', displayName: 'Replacement' }, 'projectKey must be a non-empty string up to 4096 characters'],
[{ projectKey: ' ', displayName: 'Replacement' }, 'projectKey must be a non-empty string up to 4096 characters'],
[{ projectKey: '/workspace/alpha', displayName: ' ' }, 'displayName must be a non-empty string up to 80 characters'],
[{ projectKey: '/workspace/alpha', displayName: 42 }, 'displayName must be a non-empty string up to 80 characters'],
[{ projectKey: '/workspace/alpha', displayName: 'n'.repeat(81) }, 'displayName must be a non-empty string up to 80 characters'],
[{ projectKey: 'x'.repeat(4_097), displayName: 'Replacement' }, 'projectKey must be a non-empty string up to 4096 characters'],
] as const) {
const invalidReq = makeRequest('PUT', '/api/desktop-ui/preferences/project-display-name', body)
const invalidRes = await handleDesktopUiApi(invalidReq.req, invalidReq.url, invalidReq.segments)
expect(invalidRes.status).toBe(400)
await expect(invalidRes.json()).resolves.toMatchObject({
error: 'BAD_REQUEST',
message,
})
}
expect((await new DesktopUiPreferencesService().readPreferences()).preferences.projectDisplayNames).toEqual({
'/workspace/alpha': 'Alpha',
})
})
test('persists normalized pet preferences through the API', async () => {
const putReq = makeRequest('PUT', '/api/desktop-ui/preferences/pet', {
enabled: true,
@@ -543,7 +700,7 @@ describe('desktop UI preferences API', () => {
await expect(putRes.json()).resolves.toMatchObject({
ok: true,
preferences: {
schemaVersion: 4,
schemaVersion: 5,
pet: {
enabled: true,
selectedPetId: 'seedy',
@@ -568,6 +568,7 @@ describe('remote H5 auth and CORS integration', () => {
for (const [method, pathName] of [
['GET', '/api/providers'],
['GET', '/api/desktop-ui/preferences'],
['PUT', '/api/desktop-ui/preferences/project-display-name'],
['GET', '/api/filesystem'],
['GET', '/api/computer-use/authorized-apps'],
['GET', '/api/settings/user'],
@@ -32,6 +32,7 @@ describe('pet access capability policy', () => {
for (const [method, pathname] of [
['GET', '/api/providers'],
['GET', '/api/desktop-ui/preferences'],
['PUT', '/api/desktop-ui/preferences/project-display-name'],
['GET', '/api/filesystem'],
['GET', '/api/computer-use/authorized-apps'],
['GET', '/api/settings/user'],
+8
View File
@@ -3,6 +3,7 @@
*
* GET /api/desktop-ui/preferences — read cc-haha UI preferences
* PUT /api/desktop-ui/preferences/sidebar — persist sidebar project preferences
* PUT /api/desktop-ui/preferences/project-display-name — set or reset one project display name
* PUT /api/desktop-ui/preferences/profile — persist local profile preferences
* GET /api/desktop-ui/preferences/pet — read only desktop pet preferences
* PUT /api/desktop-ui/preferences/pet — patch desktop pet preferences
@@ -46,6 +47,13 @@ export async function handleDesktopUiApi(
})
}
if (detail === 'project-display-name') {
if (req.method !== 'PUT') throw methodNotAllowed(req.method)
const body = await parseJsonBody(req)
const update = await desktopUiPreferencesService.updateProjectDisplayName(body)
return Response.json({ ok: true, ...update })
}
if (detail === 'pet') {
if (req.method === 'GET') {
const result = await desktopUiPreferencesService.readPreferences()
@@ -6,8 +6,11 @@ import { ApiError } from '../middleware/errorHandler.js'
import { readRecoverableJsonFile } from './recoverableJsonFile.js'
import { ensurePersistentStorageUpgraded } from './persistentStorageMigrations.js'
const CURRENT_DESKTOP_UI_PREFERENCES_SCHEMA_VERSION = 4
const CURRENT_DESKTOP_UI_PREFERENCES_SCHEMA_VERSION = 5
const MAX_PROJECT_PREFERENCE_ENTRIES = 2_000
const MAX_PROJECT_DISPLAY_NAME_ENTRIES = 2_000
const MAX_PROJECT_DISPLAY_NAME_KEY_LENGTH = 4_096
const MAX_PROJECT_DISPLAY_NAME_LENGTH = 80
const MAX_PROFILE_DISPLAY_NAME_LENGTH = 80
const MAX_PROFILE_SUBTITLE_LENGTH = 160
const MAX_PROFILE_AVATAR_BYTES = 2_000_000
@@ -50,11 +53,14 @@ export type DesktopPetPreferences = {
lastSessionId: string | null
}
export type ProjectDisplayNames = Record<string, string>
export type DesktopUiPreferences = {
schemaVersion: number
sidebar: SidebarProjectPreferences
profile: DesktopProfilePreferences
pet: DesktopPetPreferences
projectDisplayNames: ProjectDisplayNames
[key: string]: unknown
}
@@ -88,12 +94,17 @@ const DEFAULT_PET_PREFERENCES: DesktopPetPreferences = {
lastSessionId: null,
}
function createProjectDisplayNames(): ProjectDisplayNames {
return Object.fromEntries([]) as ProjectDisplayNames
}
function defaultPreferences(): DesktopUiPreferences {
return {
schemaVersion: CURRENT_DESKTOP_UI_PREFERENCES_SCHEMA_VERSION,
sidebar: { ...DEFAULT_SIDEBAR_PROJECT_PREFERENCES },
profile: { ...DEFAULT_PROFILE_PREFERENCES },
pet: { ...DEFAULT_PET_PREFERENCES },
projectDisplayNames: createProjectDisplayNames(),
}
}
@@ -112,6 +123,81 @@ function normalizeStringArray(value: unknown): string[] {
return normalized
}
function isValidProjectDisplayNameKey(value: unknown): value is string {
return typeof value === 'string' &&
value.length > 0 &&
value.trim().length > 0 &&
value.length <= MAX_PROJECT_DISPLAY_NAME_KEY_LENGTH
}
function normalizeProjectDisplayName(value: unknown): string | null {
if (typeof value !== 'string') return null
const normalized = value.trim().replace(/\s+/g, ' ')
if (normalized.length === 0 || normalized.length > MAX_PROJECT_DISPLAY_NAME_LENGTH) {
return null
}
return normalized
}
function setProjectDisplayName(
projectDisplayNames: ProjectDisplayNames,
projectKey: string,
displayName: string,
): void {
Object.defineProperty(projectDisplayNames, projectKey, {
value: displayName,
enumerable: true,
configurable: true,
writable: true,
})
}
export function normalizeProjectDisplayNames(value: unknown): ProjectDisplayNames {
const normalized = createProjectDisplayNames()
if (!value || typeof value !== 'object' || Array.isArray(value)) return normalized
let entryCount = 0
for (const [projectKey, displayName] of Object.entries(value)) {
if (!isValidProjectDisplayNameKey(projectKey)) continue
const normalizedDisplayName = normalizeProjectDisplayName(displayName)
if (!normalizedDisplayName) continue
setProjectDisplayName(normalized, projectKey, normalizedDisplayName)
entryCount += 1
if (entryCount >= MAX_PROJECT_DISPLAY_NAME_ENTRIES) break
}
return normalized
}
function validateProjectDisplayNameUpdate(value: unknown): {
projectKey: string
displayName: string | null
} {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw ApiError.badRequest('Project display name update must be an object')
}
const patch = value as Record<string, unknown>
if (!isValidProjectDisplayNameKey(patch.projectKey)) {
throw ApiError.badRequest(
`projectKey must be a non-empty string up to ${MAX_PROJECT_DISPLAY_NAME_KEY_LENGTH} characters`,
)
}
if (patch.displayName === null) {
return { projectKey: patch.projectKey, displayName: null }
}
const displayName = normalizeProjectDisplayName(patch.displayName)
if (!displayName) {
throw ApiError.badRequest(
`displayName must be a non-empty string up to ${MAX_PROJECT_DISPLAY_NAME_LENGTH} characters`,
)
}
return { projectKey: patch.projectKey, displayName }
}
export function normalizeSidebarProjectPreferences(value: unknown): SidebarProjectPreferences {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return { ...DEFAULT_SIDEBAR_PROJECT_PREFERENCES }
@@ -256,6 +342,7 @@ function normalizeDesktopUiPreferences(value: unknown): DesktopUiPreferences | n
sidebar: normalizeSidebarProjectPreferences(record.sidebar),
profile: normalizeProfilePreferences(record.profile),
pet: normalizeDesktopPetPreferences(record.pet),
projectDisplayNames: normalizeProjectDisplayNames(record.projectDisplayNames),
}
}
@@ -368,6 +455,7 @@ export class DesktopUiPreferencesService {
}),
profile: normalizeProfilePreferences(preferences.profile),
pet: normalizeDesktopPetPreferences(preferences.pet),
projectDisplayNames: normalizeProjectDisplayNames(preferences.projectDisplayNames),
}
await this.writePreferences(nextPreferences)
@@ -400,6 +488,7 @@ export class DesktopUiPreferencesService {
avatarUpdatedAt: currentProfile.avatarUpdatedAt,
},
pet: normalizeDesktopPetPreferences(preferences.pet),
projectDisplayNames: normalizeProjectDisplayNames(preferences.projectDisplayNames),
}
await this.writePreferences(nextPreferences)
@@ -407,6 +496,43 @@ export class DesktopUiPreferencesService {
})
}
async updateProjectDisplayName(value: unknown): Promise<{
projectKey: string
displayName: string | null
}> {
const update = validateProjectDisplayNameUpdate(value)
const filePath = this.getPreferencesPath()
return this.withWriteLock(filePath, async () => {
const { preferences } = await this.readPreferences()
const projectDisplayNames = normalizeProjectDisplayNames(preferences.projectDisplayNames)
if (update.displayName === null) {
delete projectDisplayNames[update.projectKey]
} else {
if (
!Object.hasOwn(projectDisplayNames, update.projectKey) &&
Object.keys(projectDisplayNames).length >= MAX_PROJECT_DISPLAY_NAME_ENTRIES
) {
throw ApiError.badRequest(
`Project display names cannot exceed ${MAX_PROJECT_DISPLAY_NAME_ENTRIES} entries`,
)
}
setProjectDisplayName(projectDisplayNames, update.projectKey, update.displayName)
}
const nextPreferences: DesktopUiPreferences = {
...preferences,
schemaVersion: preferences.schemaVersion,
sidebar: normalizeSidebarProjectPreferences(preferences.sidebar),
profile: normalizeProfilePreferences(preferences.profile),
pet: normalizeDesktopPetPreferences(preferences.pet),
projectDisplayNames,
}
await this.writePreferences(nextPreferences)
return update
})
}
async updatePetPreferences(pet: unknown): Promise<DesktopUiPreferences> {
const filePath = this.getPreferencesPath()
return this.withWriteLock(filePath, async () => {
@@ -424,6 +550,7 @@ export class DesktopUiPreferencesService {
...currentPet,
...patch,
}),
projectDisplayNames: normalizeProjectDisplayNames(preferences.projectDisplayNames),
}
await this.writePreferences(nextPreferences)
@@ -474,6 +601,7 @@ export class DesktopUiPreferencesService {
avatarUpdatedAt: new Date().toISOString(),
},
pet: normalizeDesktopPetPreferences(preferences.pet),
projectDisplayNames: normalizeProjectDisplayNames(preferences.projectDisplayNames),
}
await this.writePreferences(nextPreferences)
@@ -500,6 +628,7 @@ export class DesktopUiPreferencesService {
avatarUpdatedAt: null,
},
pet: normalizeDesktopPetPreferences(preferences.pet),
projectDisplayNames: normalizeProjectDisplayNames(preferences.projectDisplayNames),
}
await this.writePreferences(nextPreferences)