From a0afcc7efa06c73ba3e9f344dc7d6102cca728e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 11 Aug 2026 02:32:22 +0800 Subject: [PATCH] feat(desktop): add custom project display names --- desktop/src/api/desktopUiPreferences.test.ts | 57 ++ desktop/src/api/desktopUiPreferences.ts | 14 + .../src/components/chat/ChatInput.test.tsx | 68 ++ desktop/src/components/chat/ChatInput.tsx | 2 + .../chat/ProjectContextChip.test.tsx | 65 +- .../components/chat/ProjectContextChip.tsx | 16 +- .../chat/RepositoryLaunchControls.test.tsx | 39 +- .../chat/RepositoryLaunchControls.tsx | 19 +- .../src/components/chat/WorktreeDetails.tsx | 17 +- .../composite/DirectoryPicker.test.tsx | 61 ++ .../components/composite/DirectoryPicker.tsx | 99 ++- .../src/components/layout/AppShell.test.tsx | 81 +- desktop/src/components/layout/AppShell.tsx | 48 +- .../layout/ProjectEditorModal.test.tsx | 245 ++++++ .../components/layout/ProjectEditorModal.tsx | 275 +++++++ .../src/components/layout/Sidebar.test.tsx | 721 +++++++++++++++++- desktop/src/components/layout/Sidebar.tsx | 304 +++++++- .../src/features/pets/PetSettings.test.tsx | 3 +- desktop/src/i18n/locales/en.ts | 16 + desktop/src/i18n/locales/jp.ts | 16 + desktop/src/i18n/locales/kr.ts | 16 + desktop/src/i18n/locales/zh-TW.ts | 16 + desktop/src/i18n/locales/zh.ts | 16 + .../stores/projectDisplayNameStore.test.ts | 177 +++++ desktop/src/stores/projectDisplayNameStore.ts | 164 ++++ scripts/quality-gate/persistence-upgrade.ts | 10 + .../__tests__/desktop-ui-preferences.test.ts | 177 ++++- src/server/__tests__/h5-access-auth.test.ts | 1 + .../__tests__/pet-access-policy.test.ts | 1 + src/server/api/desktop-ui.ts | 8 + .../services/desktopUiPreferencesService.ts | 131 +++- 31 files changed, 2748 insertions(+), 135 deletions(-) create mode 100644 desktop/src/components/layout/ProjectEditorModal.test.tsx create mode 100644 desktop/src/components/layout/ProjectEditorModal.tsx create mode 100644 desktop/src/stores/projectDisplayNameStore.test.ts create mode 100644 desktop/src/stores/projectDisplayNameStore.ts diff --git a/desktop/src/api/desktopUiPreferences.test.ts b/desktop/src/api/desktopUiPreferences.test.ts index b5e283f9..7815c4a7 100644 --- a/desktop/src/api/desktopUiPreferences.test.ts +++ b/desktop/src/api/desktopUiPreferences.test.ts @@ -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') diff --git a/desktop/src/api/desktopUiPreferences.ts b/desktop/src/api/desktopUiPreferences.ts index 08af748c..966e454f 100644 --- a/desktop/src/api/desktopUiPreferences.ts +++ b/desktop/src/api/desktopUiPreferences.ts @@ -30,6 +30,7 @@ export type DesktopUiPreferences = { sidebar: SidebarProjectPreferences profile: DesktopProfilePreferences pet: DesktopPetPreferences + projectDisplayNames: Record } 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('/api/desktop-ui/preferences') @@ -62,6 +69,13 @@ export const desktopUiPreferencesApi = { ) }, + updateProjectDisplayName(projectKey: string, displayName: string | null) { + return api.put( + '/api/desktop-ui/preferences/project-display-name', + { projectKey, displayName }, + ) + }, + updateProfilePreferences(profile: Pick) { return api.put<{ ok: true; preferences: DesktopUiPreferences }>( '/api/desktop-ui/preferences/profile', diff --git a/desktop/src/components/chat/ChatInput.test.tsx b/desktop/src/components/chat/ChatInput.test.tsx index fdc3a516..05ad9761 100644 --- a/desktop/src/components/chat/ChatInput.test.tsx +++ b/desktop/src/components/chat/ChatInput.test.tsx @@ -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() + + 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. diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index 4e1c4af5..663f1a1d 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -1434,6 +1434,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro ) : ( 0 ? ( { @@ -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( + , + ) + + 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( + const worktreeDetails = ( + + ) if (!label) return null diff --git a/desktop/src/components/chat/RepositoryLaunchControls.test.tsx b/desktop/src/components/chat/RepositoryLaunchControls.test.tsx index 08e7d6d4..96d113d0 100644 --- a/desktop/src/components/chat/RepositoryLaunchControls.test.tsx +++ b/desktop/src/components/chat/RepositoryLaunchControls.test.tsx @@ -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() diff --git a/desktop/src/components/chat/RepositoryLaunchControls.tsx b/desktop/src/components/chat/RepositoryLaunchControls.tsx index 36cde0a5..63c3c754 100644 --- a/desktop/src/components/chat/RepositoryLaunchControls.tsx +++ b/desktop/src/components/chat/RepositoryLaunchControls.tsx @@ -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(null) + const [contextSourceWorkDir, setContextSourceWorkDir] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(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') diff --git a/desktop/src/components/chat/WorktreeDetails.tsx b/desktop/src/components/chat/WorktreeDetails.tsx index ddafaeff..95b4e77b 100644 --- a/desktop/src/components/chat/WorktreeDetails.tsx +++ b/desktop/src/components/chat/WorktreeDetails.tsx @@ -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 (
+ {projectRoot ? ( + <> +
{t('sidebar.projectEditor.realPath')}
+
{projectRoot}
+ + ) : null}
{t('sidebar.worktree')}
{name}
{path ? ( diff --git a/desktop/src/components/composite/DirectoryPicker.test.tsx b/desktop/src/components/composite/DirectoryPicker.test.tsx index e30d1e4d..09906052 100644 --- a/desktop/src/components/composite/DirectoryPicker.test.tsx +++ b/desktop/src/components/composite/DirectoryPicker.test.tsx @@ -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() + + 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() + + 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: [{ diff --git a/desktop/src/components/composite/DirectoryPicker.tsx b/desktop/src/components/composite/DirectoryPicker.tsx index ebc2de79..36ce31cf 100644 --- a/desktop/src/components/composite/DirectoryPicker.tsx +++ b/desktop/src/components/composite/DirectoryPicker.tsx @@ -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 ( + + ) +} + type PanelProps = { value: string /** @@ -246,42 +296,15 @@ export function RecentProjectsPanel({ ) : projects.length === 0 ? ( ) : ( - projects.map((project) => { - const isSelected = project.realPath === value - return ( - - ) - }) + projects.map((project) => ( + + )) )}
@@ -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' diff --git a/desktop/src/components/layout/AppShell.test.tsx b/desktop/src/components/layout/AppShell.test.tsx index 6ca295db..d64df0ce 100644 --- a/desktop/src/components/layout/AppShell.test.tsx +++ b/desktop/src/components/layout/AppShell.test.tsx @@ -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: () => , + Sidebar: ({ + desktopUiPreferencesRequest, + onDesktopUiPreferencesConsumed, + }: { + desktopUiPreferencesRequest?: Promise | null + onDesktopUiPreferencesConsumed?: (request: Promise) => void + }) => { + useEffect(() => { + if (!desktopUiPreferencesRequest) return + let cancelled = false + void desktopUiPreferencesRequest + .finally(() => { + if (!cancelled) onDesktopUiPreferencesConsumed?.(desktopUiPreferencesRequest) + }) + .catch(() => undefined) + return () => { + cancelled = true + } + }, [desktopUiPreferencesRequest, onDesktopUiPreferencesConsumed]) + return + }, })) 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() + + 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() + + 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 diff --git a/desktop/src/components/layout/AppShell.tsx b/desktop/src/components/layout/AppShell.tsx index 9b1d0911..b7ed04fb 100644 --- a/desktop/src/components/layout/AppShell.tsx +++ b/desktop/src/components/layout/AppShell.tsx @@ -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(null) const [bootstrapNonce, setBootstrapNonce] = useState(0) const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false) + const [desktopUiPreferencesRequest, setDesktopUiPreferencesRequest] = useState< + Promise | null + >(null) + const consumeDesktopUiPreferencesRequest = useCallback((request: Promise) => { + 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 ? ( - setEffectiveSidebarOpen(false)} /> + setEffectiveSidebarOpen(false)} + desktopUiPreferencesRequest={desktopUiPreferencesRequest} + onDesktopUiPreferencesConsumed={consumeDesktopUiPreferencesRequest} + /> ) : null} {!isMobileShell ? (
({ + useTranslation: () => (key: string, params?: Record) => { + const translations: Record = { + '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 }) => ( + + ), +})) + +import { ProjectEditorModal } from './ProjectEditorModal' + +function CreateHarness({ + onSubmit, + onSourceFolderChange, +}: { + onSubmit: ReturnType + onSourceFolderChange: ReturnType +}) { + const [sourceFolder, setSourceFolder] = useState('') + + return ( + { + 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() + + 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( + , + ) + + const name = screen.getByRole('textbox', { name: /Project name/ }) + expect(name).toHaveValue('checkout') + + rerender( + , + ) + await waitFor(() => expect(name).toHaveValue('repository')) + + fireEvent.change(name, { target: { value: 'My custom name' } }) + rerender( + , + ) + + await waitFor(() => expect(name).toHaveValue('My custom name')) + }) + + it('does not trim the fallback folder name', () => { + render( + , + ) + + 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( + , + ) + + 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((resolve) => { + resolveSubmit = resolve + })) + const onClose = vi.fn() + render( + , + ) + + 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) + }) +}) diff --git a/desktop/src/components/layout/ProjectEditorModal.tsx b/desktop/src/components/layout/ProjectEditorModal.tsx new file mode 100644 index 00000000..307ce554 --- /dev/null +++ b/desktop/src/components/layout/ProjectEditorModal.tsx @@ -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 + +type ProjectEditorBaseProps = { + open: boolean + initialName?: string + logicalRoot?: string + suggestedName?: string + loading?: boolean + error?: string | null + onClose: () => void + onSubmit: (submission: ProjectEditorSubmission) => void | Promise +} + +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(null) + const nameInputId = useId() + const sourceFolderRef = useRef(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('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 ( + + + + + )} + > +
+
+ {displayedError && ( +

+ {displayedError} +

+ )} + + { + nameWasEditedRef.current = true + useSuggestedNameRef.current = false + setName(event.target.value) + }} + hint={t('sidebar.projectEditor.nameHint', { count: 80 })} + error={showValidation ? nameError : undefined} + /> + + {mode === 'create' ? ( +
+ + {t('sidebar.projectEditor.sourceFolder')} + * + + + {showValidation && sourceFolderError && ( + + )} +
+ ) : ( + + )} + + {mode === 'edit' && ( +
+ {props.onRestoreFolderName && ( +
+
+

+ {t('sidebar.projectEditor.restoreFolderName')} +

+

+ {suggestedFolderName} +

+
+ +
+ )} + + {props.onRemoveFromSidebar && ( +
+

+ {t('sidebar.projectEditor.removeFromSidebarHint')} +

+ +
+ )} +
+ )} +
+
+
+ ) +} diff --git a/desktop/src/components/layout/Sidebar.test.tsx b/desktop/src/components/layout/Sidebar.test.tsx index ed14186a..32448a85 100644 --- a/desktop/src/components/layout/Sidebar.test.tsx +++ b/desktop/src/components/layout/Sidebar.test.tsx @@ -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 + onRestoreFolderName?: () => void | Promise + onRemoveFromSidebar?: () => void | Promise + }) => { + 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 ( +
+ {props.mode === 'create' && ( + <> + + + + )} + + {props.suggestedName && ( + + )} + {props.onRestoreFolderName && ( + + )} + {props.onRemoveFromSidebar && ( + + )} + +
+ ) + }, +})) + 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 = {}): PerSess } } +function createDeferred() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((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 +} = {}): 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 }) { + const [open, setOpen] = useState(true) + const [preferencesRequest, setPreferencesRequest] = useState< + Promise | null + >(request) + + return ( + <> + + {open && ( + { + setPreferencesRequest((current) => current === consumedRequest ? null : current) + }} + /> + )} + + ) +} + function makeDataTransfer() { const data = new Map() 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() + + 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() + + 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() + + 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() + + 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() + + 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((_resolve, reject) => { + rejectDisplayNameSave = reject + }) + repositoryContextMock.mockResolvedValue({ + repoRoot: '/workspace/repository', + workDir: '/workspace/repository/packages/app', + }) + createSession.mockResolvedValue('created-project-session') + desktopUiPreferencesApiMock.updateProjectDisplayName.mockReturnValueOnce(displayNameSave) + + render() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + useSessionStore.setState({ + sessions: [makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', new Date().toISOString())], + }) + + render() + + 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() + + 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() + const onPreferencesConsumed = vi.fn() + render( + , + ) 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() + useSessionStore.setState({ + sessions: [makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', new Date().toISOString())], }) + + render() + + 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() + render() await waitFor(() => { expect(desktopUiPreferencesApiMock.updateSidebarPreferences).toHaveBeenCalledWith({ diff --git a/desktop/src/components/layout/Sidebar.tsx b/desktop/src/components/layout/Sidebar.tsx index 5f9a4b6a..9395e131 100644 --- a/desktop/src/components/layout/Sidebar.tsx +++ b/desktop/src/components/layout/Sidebar.tsx @@ -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 | null + onDesktopUiPreferencesConsumed?: (request: Promise) => 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(() => readStoredProjectSortBy()) const [draggingProjectKey, setDraggingProjectKey] = useState(null) const [projectDropTarget, setProjectDropTarget] = useState<{ key: string; position: 'before' | 'after' } | null>(null) + const [projectEditor, setProjectEditor] = useState(null) + const [projectEditorError, setProjectEditorError] = useState(null) + const [projectEditorLoading, setProjectEditorLoading] = useState(false) + const projectDisplayNameRevision = useProjectDisplayNameRevision() const suppressProjectClickRef = useRef(null) const sessionContextMenuRef = useRef(null) const projectContextMenuRef = useRef(null) @@ -112,6 +146,11 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { const projectHeaderSubmenuRef = useRef(null) const projectHeaderActionsRef = useRef(null) const sidebarPreferenceRevisionRef = useRef(0) + const projectRootLookupRevisionRef = useRef(0) + const projectRootLookupRef = useRef<{ + sourceFolder: string + request: ReturnType + } | null>(null) const sessionScrollAreaRef = useRef(null) const pendingSessionScrollAnchorRef = useRef(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) {
)} - {orderedProjectGroups.length > 0 && ( + {!showInitialLoading && ( event.stopPropagation()} > + {project.key !== 'unknown' && project.workDir && ( + + )}