diff --git a/desktop/scripts/build-sidecars.test.ts b/desktop/scripts/build-sidecars.test.ts new file mode 100644 index 00000000..60630e2a --- /dev/null +++ b/desktop/scripts/build-sidecars.test.ts @@ -0,0 +1,19 @@ +import { readFileSync } from 'node:fs' +import path from 'node:path' + +import { describe, expect, it } from 'vitest' + +function readBuildScript() { + return readFileSync(path.resolve(import.meta.dirname, 'build-sidecars.ts'), 'utf8') +} + +function extractWindowsX64BunTarget(source: string) { + const match = source.match(/case 'x86_64-pc-windows-msvc':[\s\S]*?return '([^']+)'/) + return match?.[1] ?? null +} + +describe('build-sidecars Windows x64 target mapping', () => { + it('uses the baseline Bun runtime so older CPUs do not crash with Illegal Instruction', () => { + expect(extractWindowsX64BunTarget(readBuildScript())).toBe('bun-windows-x64-baseline') + }) +}) diff --git a/desktop/scripts/build-sidecars.ts b/desktop/scripts/build-sidecars.ts index a46293dc..c113d117 100644 --- a/desktop/scripts/build-sidecars.ts +++ b/desktop/scripts/build-sidecars.ts @@ -72,7 +72,9 @@ function mapTargetTripleToBun(triple: string) { case 'x86_64-apple-darwin': return 'bun-darwin-x64' case 'x86_64-pc-windows-msvc': - return 'bun-windows-x64' + // Prefer baseline on Windows x64 so older CPUs do not crash before the + // desktop app can even start the local sidecar process. + return 'bun-windows-x64-baseline' case 'aarch64-pc-windows-msvc': return 'bun-windows-arm64' case 'x86_64-unknown-linux-gnu': diff --git a/desktop/src/components/layout/Sidebar.test.tsx b/desktop/src/components/layout/Sidebar.test.tsx index 473c41a1..06c6e454 100644 --- a/desktop/src/components/layout/Sidebar.test.tsx +++ b/desktop/src/components/layout/Sidebar.test.tsx @@ -54,9 +54,10 @@ vi.mock('../../i18n', () => ({ 'sidebar.openInFinder': 'Open in Finder', 'sidebar.openInFinderFailed': 'Could not open the project in Finder.', 'sidebar.openInFinderUnavailable': 'No file manager is available.', - 'sidebar.hideProjectFromSidebar': 'Remove from Sidebar', + 'sidebar.hideProjectFromSidebar': 'Hide from Sidebar', 'sidebar.restoreProjectToSidebar': 'Restore to Sidebar', - 'sidebar.projectHidden': '{project} was removed from the sidebar.', + 'sidebar.restoreHiddenProjects': 'Restore hidden projects ({count})', + 'sidebar.projectHidden': '{project} was hidden from the sidebar. Existing sessions were not deleted.', 'sidebar.newSessionInProject': 'New session in {project}', 'sidebar.showMoreSessions': 'Expand display', 'sidebar.showFewerSessions': 'Collapse display', @@ -517,7 +518,7 @@ describe('Sidebar', () => { expect(screen.getByRole('menuitem', { name: 'Pin Project' })).toBeInTheDocument() expect(screen.getByRole('menuitem', { name: 'Open in Finder' })).toBeInTheDocument() - expect(screen.getByRole('menuitem', { name: 'Remove from Sidebar' })).toBeInTheDocument() + expect(screen.getByRole('menuitem', { name: 'Hide from Sidebar' })).toBeInTheDocument() expect(screen.queryByRole('menuitem', { name: 'Create Permanent Worktree' })).not.toBeInTheDocument() expect(screen.queryByRole('menuitem', { name: 'Rename Project' })).not.toBeInTheDocument() expect(screen.queryByRole('menuitem', { name: 'Archive Conversations' })).not.toBeInTheDocument() @@ -564,7 +565,7 @@ describe('Sidebar', () => { render() fireEvent.click(screen.getByRole('button', { name: 'Project actions for beta' })) - fireEvent.click(screen.getByRole('menuitem', { name: 'Remove from Sidebar' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Hide from Sidebar' })) await waitFor(() => { expect(screen.queryByText('beta')).not.toBeInTheDocument() @@ -575,7 +576,7 @@ describe('Sidebar', () => { expect(JSON.parse(window.localStorage.getItem(PROJECT_HIDDEN_STORAGE_KEY) ?? '[]')).toEqual(['/workspace/beta']) expect(addToast).toHaveBeenCalledWith({ type: 'info', - message: 'beta was removed from the sidebar.', + message: 'beta was hidden from the sidebar. Existing sessions were not deleted.', }) }) @@ -596,6 +597,74 @@ describe('Sidebar', () => { expect(screen.queryByTestId('project-filter')).not.toBeInTheDocument() }) + it('restores hidden projects from the project header menu', async () => { + window.localStorage.setItem(PROJECT_HIDDEN_STORAGE_KEY, JSON.stringify(['/workspace/beta'])) + const now = new Date().toISOString() + useSessionStore.setState({ + sessions: [ + makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', now), + makeSession('beta-1', 'Beta Session', '/workspace/beta', now), + ], + }) + + render() + + expect(screen.getByText('alpha')).toBeInTheDocument() + expect(screen.queryByText('beta')).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Project menu' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Restore hidden projects (1)' })) + + await waitFor(() => { + expect(screen.getByText('beta')).toBeInTheDocument() + }) + expect(JSON.parse(window.localStorage.getItem(PROJECT_HIDDEN_STORAGE_KEY) ?? '[]')).toEqual([]) + expect(desktopUiPreferencesApiMock.updateSidebarPreferences).toHaveBeenCalledWith({ + projectOrder: [], + pinnedProjects: [], + hiddenProjects: [], + projectOrganization: 'recentProject', + projectSortBy: 'updatedAt', + }) + }) + + it('restores a hidden project when a new session is created in that project', async () => { + window.localStorage.setItem(PROJECT_HIDDEN_STORAGE_KEY, JSON.stringify(['/workspace/beta'])) + createSession.mockResolvedValue('beta-new') + const now = new Date().toISOString() + useSessionStore.setState({ + sessions: [ + makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', now), + makeSession('beta-1', 'Beta Session', '/workspace/beta', now), + ], + }) + useTabStore.setState({ + tabs: [{ sessionId: 'beta-1', title: 'Beta Session', type: 'session', status: 'idle' }], + activeTabId: 'beta-1', + }) + + render() + + expect(screen.queryByText('beta')).not.toBeInTheDocument() + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'New Session' })) + }) + + await waitFor(() => { + expect(createSession).toHaveBeenCalledWith('/workspace/beta') + expect(screen.getByText('beta')).toBeInTheDocument() + }) + expect(JSON.parse(window.localStorage.getItem(PROJECT_HIDDEN_STORAGE_KEY) ?? '[]')).toEqual([]) + expect(desktopUiPreferencesApiMock.updateSidebarPreferences).toHaveBeenCalledWith({ + projectOrder: [], + pinnedProjects: [], + hiddenProjects: [], + projectOrganization: 'recentProject', + projectSortBy: 'updatedAt', + }) + }) + it('uses server sidebar preferences across browser and desktop storage contexts', async () => { desktopUiPreferencesApiMock.getPreferences.mockResolvedValueOnce({ exists: true, diff --git a/desktop/src/components/layout/Sidebar.tsx b/desktop/src/components/layout/Sidebar.tsx index a4593ddf..8ac7685c 100644 --- a/desktop/src/components/layout/Sidebar.tsx +++ b/desktop/src/components/layout/Sidebar.tsx @@ -156,6 +156,22 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { void desktopUiPreferencesApi.updateSidebarPreferences(normalized).catch(() => undefined) }, []) + const restoreHiddenProjectForWorkDir = useCallback((workDir: string | null | undefined) => { + if (!workDir) return + setHiddenProjectKeys((current) => { + const next = new Set([...current].filter((projectKey) => !projectPathMatches(projectKey, workDir))) + if (next.size === current.size) return current + persistSidebarProjectPreferences(buildSidebarProjectPreferences( + projectOrder, + pinnedProjectKeys, + next, + projectOrganization, + projectSortBy, + )) + return next + }) + }, [persistSidebarProjectPreferences, pinnedProjectKeys, projectOrder, projectOrganization, projectSortBy]) + useEffect(() => { let cancelled = false const startRevision = sidebarPreferenceRevisionRef.current @@ -247,6 +263,7 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { const createSessionForWorkDir = useCallback(async (workDir?: string) => { try { const sessionId = await useSessionStore.getState().createSession(workDir) + restoreHiddenProjectForWorkDir(workDir) useTabStore.getState().openTab(sessionId, t('sidebar.newSession')) useChatStore.getState().connectToSession(sessionId) closeMobileDrawer() @@ -256,7 +273,7 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { message: error instanceof Error ? error.message : t('sidebar.sessionListFailed'), }) } - }, [addToast, closeMobileDrawer, t]) + }, [addToast, closeMobileDrawer, restoreHiddenProjectForWorkDir, t]) const openProjectHeaderMenu = useCallback((event: React.MouseEvent, type: SidebarHeaderMenuType) => { event.stopPropagation() @@ -355,6 +372,23 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { }) }, [hiddenProjectKeys, persistSidebarProjectPreferences, projectOrder, projectOrganization, projectSortBy]) + const restoreAllHiddenProjects = useCallback(() => { + setProjectHeaderMenu(null) + setProjectHeaderSubmenu(null) + setHiddenProjectKeys((current) => { + if (current.size === 0) return current + const next = new Set() + persistSidebarProjectPreferences(buildSidebarProjectPreferences( + projectOrder, + pinnedProjectKeys, + next, + projectOrganization, + projectSortBy, + )) + return next + }) + }, [persistSidebarProjectPreferences, pinnedProjectKeys, projectOrder, projectOrganization, projectSortBy]) + const toggleHiddenProject = useCallback((project: ProjectGroup) => { const wasHidden = hiddenProjectKeys.has(project.key) setProjectContextMenu(null) @@ -773,7 +807,7 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { {searchQuery ? t('sidebar.noMatching') : t('sidebar.noSessions')} )} - {visibleProjectGroups.length > 0 && ( + {orderedProjectGroups.length > 0 && ( void createSessionForWorkDir()} onUseExistingFolder={() => void createSessionFromExistingFolder()} + onRestoreHiddenProjects={restoreAllHiddenProjects} + hiddenProjectCount={hiddenProjectKeys.size} t={t} /> )} @@ -1102,6 +1138,8 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { onSetSortBy={updateProjectSortBy} onCreateBlank={() => void createSessionForWorkDir()} onUseExistingFolder={() => void createSessionFromExistingFolder()} + onRestoreHiddenProjects={restoreAllHiddenProjects} + hiddenProjectCount={hiddenProjectKeys.size} t={t} /> )} @@ -1266,6 +1304,8 @@ function ProjectHeaderMenu({ onSetSortBy, onCreateBlank, onUseExistingFolder, + onRestoreHiddenProjects, + hiddenProjectCount, t, }: { type: SidebarHeaderMenuType @@ -1278,6 +1318,8 @@ function ProjectHeaderMenu({ onSetSortBy: (sortBy: SidebarProjectSortBy) => void onCreateBlank: () => void onUseExistingFolder: () => void + onRestoreHiddenProjects: () => void + hiddenProjectCount: number t: ReturnType }) { const width = type === 'sort' ? 230 : type === 'create' ? 250 : 270 @@ -1344,6 +1386,14 @@ function ProjectHeaderMenu({ > {t('sidebar.sortCondition')} + {hiddenProjectCount > 0 && ( + + )} ) } @@ -1596,6 +1646,20 @@ function normalizeProjectKeyList(values: unknown): string[] { return normalized } +function normalizeProjectPathForComparison(value: string): string { + const normalized = value.replace(/\\/g, '/').replace(/\/+$/g, '') + return normalized || value +} + +function projectPathMatches(projectKey: string, workDir: string): boolean { + const normalizedProjectKey = normalizeProjectPathForComparison(projectKey) + const normalizedWorkDir = normalizeProjectPathForComparison(workDir) + + return normalizedProjectKey === normalizedWorkDir + || normalizedWorkDir.startsWith(`${normalizedProjectKey}/`) + || normalizedProjectKey.startsWith(`${normalizedWorkDir}/`) +} + function hasSidebarProjectPreferences(preferences: SidebarProjectPreferences): boolean { return preferences.projectOrder.length > 0 || preferences.pinnedProjects.length > 0 diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 76ff2c90..ff647b3b 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -46,9 +46,10 @@ export const en = { 'sidebar.openInFinder': 'Open in Finder', 'sidebar.openInFinderFailed': 'Could not open the project in Finder.', 'sidebar.openInFinderUnavailable': 'No file manager is available.', - 'sidebar.hideProjectFromSidebar': 'Remove from Sidebar', + 'sidebar.hideProjectFromSidebar': 'Hide from Sidebar', 'sidebar.restoreProjectToSidebar': 'Restore to Sidebar', - 'sidebar.projectHidden': '{project} was removed from the sidebar.', + 'sidebar.restoreHiddenProjects': 'Restore hidden projects ({count})', + 'sidebar.projectHidden': '{project} was hidden from the sidebar. Existing sessions were not deleted.', 'sidebar.newSessionInProject': 'New session in {project}', 'sidebar.showMoreSessions': 'Expand display', 'sidebar.showFewerSessions': 'Collapse display', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index f2ad0c1b..43f770bb 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -48,9 +48,10 @@ export const zh: Record = { 'sidebar.openInFinder': '在“访达”中打开', 'sidebar.openInFinderFailed': '无法在访达中打开项目。', 'sidebar.openInFinderUnavailable': '没有可用的文件管理器。', - 'sidebar.hideProjectFromSidebar': '从侧边栏移除', + 'sidebar.hideProjectFromSidebar': '从侧边栏隐藏', 'sidebar.restoreProjectToSidebar': '恢复到侧边栏', - 'sidebar.projectHidden': '已从侧边栏移除 {project}。', + 'sidebar.restoreHiddenProjects': '恢复隐藏项目({count})', + 'sidebar.projectHidden': '已从侧边栏隐藏 {project},已有会话不会被删除。', 'sidebar.newSessionInProject': '在 {project} 中新建会话', 'sidebar.showMoreSessions': '展开显示', 'sidebar.showFewerSessions': '折叠显示',