From 9cfcd24b6e8716326d33275e5f688bd7aa3324a1 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 18 Feb 2026 13:42:24 +0000 Subject: [PATCH] Add projects routes and top-level navbar navigation --- apps/web/src/App.tsx | 16 ++ apps/web/src/components/RepoSelector.tsx | 11 +- apps/web/src/components/UserMenu.tsx | 263 +++++++++++------- apps/web/src/pages/CreateWorkspace.tsx | 21 +- .../agent-settings-section.test.tsx | 4 +- .../tests/unit/components/user-menu.test.tsx | 78 ++++++ .../unit/pages/create-workspace.test.tsx | 37 ++- ...ect-creation-dropdowns-and-navbar-links.md | 61 ++++ 8 files changed, 374 insertions(+), 117 deletions(-) create mode 100644 apps/web/tests/unit/components/user-menu.test.tsx create mode 100644 tasks/backlog/2026-02-18-project-creation-dropdowns-and-navbar-links.md diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index c2f4c0d..7982ff3 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -33,6 +33,14 @@ export default function App() { } /> + + + + } + /> } /> + + + + } + /> { - if (!value || value.startsWith('http') || value.startsWith('git@')) { + if (value.startsWith('http') || value.startsWith('git@')) { setFilteredRepos([]); return; } + if (!value) { + setFilteredRepos(repositories.slice(0, 25)); + return; + } + const searchTerm = value.toLowerCase(); const filtered = repositories.filter( (repo) => repo.fullName.toLowerCase().includes(searchTerm) @@ -155,7 +160,7 @@ export function RepoSelector({ } // Show dropdown if we have repos and user is typing a search term - if (repositories.length > 0 && newValue && !newValue.startsWith('http') && !newValue.startsWith('git@')) { + if (repositories.length > 0 && !newValue.startsWith('http') && !newValue.startsWith('git@')) { setShowDropdown(true); } else { setShowDropdown(false); @@ -176,7 +181,7 @@ export function RepoSelector({ }; const handleFocus = () => { - if (repositories.length > 0 && value && !value.startsWith('http') && !value.startsWith('git@')) { + if (repositories.length > 0 && !value.startsWith('http') && !value.startsWith('git@')) { setShowDropdown(true); } }; diff --git a/apps/web/src/components/UserMenu.tsx b/apps/web/src/components/UserMenu.tsx index 5ac1d12..706f652 100644 --- a/apps/web/src/components/UserMenu.tsx +++ b/apps/web/src/components/UserMenu.tsx @@ -1,14 +1,30 @@ import { useState, useRef, useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { Link, useLocation, useNavigate } from 'react-router-dom'; import { useAuth } from './AuthProvider'; import { signOut } from '../lib/auth'; +const PRIMARY_NAV_ITEMS = [ + { label: 'Dashboard', path: '/dashboard' }, + { label: 'Projects', path: '/projects' }, + { label: 'Nodes', path: '/nodes' }, + { label: 'Settings', path: '/settings' }, +]; + +function isNavItemActive(path: string, pathname: string): boolean { + if (path === '/dashboard') { + return pathname === '/dashboard'; + } + + return pathname === path || pathname.startsWith(`${path}/`); +} + /** * User menu with avatar and dropdown. */ export function UserMenu() { const { user } = useAuth(); const navigate = useNavigate(); + const location = useLocation(); const [isOpen, setIsOpen] = useState(false); const menuRef = useRef(null); @@ -34,92 +50,160 @@ export function UserMenu() { if (!user) return null; return ( -
- -
- )} - - {user.name || user.email} - - - - - + {user.image ? ( + {user.name + ) : ( +
+ {(user.name || user.email).charAt(0).toUpperCase()} +
+ )} + + {user.name || user.email} + + + + + - {isOpen && ( -
-
-

- {user.name || 'User'} -

-

{user.email}

-
+ {isOpen && ( +
+
+

+ {user.name || 'User'} +

+

{user.email}

+
+ + {PRIMARY_NAV_ITEMS.map((item) => ( + + ))} + +
- {[ - { label: 'Dashboard', path: '/dashboard' }, - { label: 'Projects', path: '/projects' }, - { label: 'Nodes', path: '/nodes' }, - { label: 'Settings', path: '/settings' }, - ].map((item) => ( - ))} - -
- - -
- )} +
+ )} + ); } diff --git a/apps/web/src/pages/CreateWorkspace.tsx b/apps/web/src/pages/CreateWorkspace.tsx index e45cbe1..39d05b0 100644 --- a/apps/web/src/pages/CreateWorkspace.tsx +++ b/apps/web/src/pages/CreateWorkspace.tsx @@ -97,10 +97,17 @@ type LocationState = { nodeId?: string; }; -export function CreateWorkspace() { +interface CreateWorkspaceProps { + mode?: 'workspace' | 'project'; +} + +export function CreateWorkspace({ mode = 'workspace' }: CreateWorkspaceProps) { const navigate = useNavigate(); const location = useLocation(); const locationState = (location.state as LocationState | null) ?? null; + const entityLabel = mode === 'project' ? 'Project' : 'Workspace'; + const entityLabelLower = entityLabel.toLowerCase(); + const listPath = mode === 'project' ? '/projects' : '/dashboard'; const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -238,8 +245,8 @@ export function CreateWorkspace() { return ( navigate('/dashboard')} + title={`Create ${entityLabel}`} + onBack={() => navigate(listPath)} maxWidth="md" headerRight={} > @@ -251,7 +258,7 @@ export function CreateWorkspace() { {!checkingPrereqs && anyMissing && (

- Complete the items below before creating a workspace. + Complete the items below before creating a {entityLabelLower}.

)} @@ -312,7 +319,7 @@ export function CreateWorkspace() {
-
diff --git a/apps/web/tests/unit/components/agent-settings-section.test.tsx b/apps/web/tests/unit/components/agent-settings-section.test.tsx index 3b594b2..fd83f19 100644 --- a/apps/web/tests/unit/components/agent-settings-section.test.tsx +++ b/apps/web/tests/unit/components/agent-settings-section.test.tsx @@ -130,7 +130,9 @@ describe('AgentSettingsSection', () => { const bypassRadio = screen.getByTestId('permission-mode-claude-code-bypassPermissions'); fireEvent.click(bypassRadio); - expect(screen.getByText(/disables all safety prompts/i)).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText(/disables all safety prompts/i)).toBeInTheDocument(); + }); }); it('calls saveAgentSettings on save', async () => { diff --git a/apps/web/tests/unit/components/user-menu.test.tsx b/apps/web/tests/unit/components/user-menu.test.tsx new file mode 100644 index 0000000..a04482a --- /dev/null +++ b/apps/web/tests/unit/components/user-menu.test.tsx @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; + +const mocks = vi.hoisted(() => ({ + signOut: vi.fn(), +})); + +vi.mock('../../../src/components/AuthProvider', () => ({ + useAuth: () => ({ + user: { + id: 'user_123', + email: 'dev@example.com', + name: 'Dev User', + }, + }), +})); + +vi.mock('../../../src/lib/auth', () => ({ + signOut: mocks.signOut, +})); + +import { UserMenu } from '../../../src/components/UserMenu'; + +function LocationProbe() { + const location = useLocation(); + return
{location.pathname}
; +} + +function renderUserMenu(initialEntry = '/dashboard') { + return render( + + + + + + + )} + /> + + + ); +} + +describe('UserMenu', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders top-level primary navigation links', () => { + renderUserMenu(); + + expect(screen.getByRole('link', { name: 'Dashboard' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Projects' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Nodes' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Settings' })).toBeInTheDocument(); + }); + + it('navigates to projects from top-level nav without opening profile menu', () => { + renderUserMenu(); + + fireEvent.click(screen.getByRole('link', { name: 'Projects' })); + + expect(screen.getByTestId('location')).toHaveTextContent('/projects'); + }); + + it('keeps sign out action in the profile menu', () => { + renderUserMenu(); + + fireEvent.click(screen.getByRole('button', { name: 'D' })); + fireEvent.click(screen.getByRole('button', { name: 'Sign out' })); + + expect(mocks.signOut).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/tests/unit/pages/create-workspace.test.tsx b/apps/web/tests/unit/pages/create-workspace.test.tsx index 1042630..299db86 100644 --- a/apps/web/tests/unit/pages/create-workspace.test.tsx +++ b/apps/web/tests/unit/pages/create-workspace.test.tsx @@ -26,14 +26,19 @@ vi.mock('../../../src/components/UserMenu', () => ({ import { CreateWorkspace } from '../../../src/pages/CreateWorkspace'; -function renderCreateWorkspace() { +function renderCreateWorkspace( + mode: 'workspace' | 'project' = 'workspace', + initialEntry: string = '/create' +) { return render( - + - } /> + } /> + } /> } /> } /> } /> + } /> ); @@ -85,10 +90,11 @@ describe('CreateWorkspace', () => { it('shows branch dropdown after selecting a repository', async () => { renderCreateWorkspace(); await screen.findByLabelText('Workspace Name'); + await waitFor(() => { + expect(mocks.listRepositories).toHaveBeenCalled(); + }); - // Type in the repo input to trigger the dropdown const repoInput = screen.getByLabelText('Repository'); - fireEvent.change(repoInput, { target: { value: 'my-repo' } }); fireEvent.focus(repoInput); // Select the repo from dropdown @@ -114,12 +120,24 @@ describe('CreateWorkspace', () => { expect(branchOptions).toHaveLength(3); }); + it('shows repository dropdown options when the field is focused', async () => { + renderCreateWorkspace(); + await screen.findByLabelText('Workspace Name'); + + const repoInput = screen.getByLabelText('Repository'); + fireEvent.focus(repoInput); + + expect(await screen.findByText('octo/my-repo')).toBeInTheDocument(); + }); + it('defaults branch to repository default branch when selected', async () => { renderCreateWorkspace(); await screen.findByLabelText('Workspace Name'); + await waitFor(() => { + expect(mocks.listRepositories).toHaveBeenCalled(); + }); const repoInput = screen.getByLabelText('Repository'); - fireEvent.change(repoInput, { target: { value: 'my-repo' } }); fireEvent.focus(repoInput); const repoOption = await screen.findByText('octo/my-repo'); @@ -211,4 +229,11 @@ describe('CreateWorkspace', () => { expect(screen.queryByText('Setup Required')).not.toBeInTheDocument(); expect(screen.queryByText('Checking prerequisites...')).not.toBeInTheDocument(); }); + + it('uses project copy in project mode', async () => { + renderCreateWorkspace('project', '/projects/new'); + + expect(await screen.findByLabelText('Project Name')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Create Project' })).toBeInTheDocument(); + }); }); diff --git a/tasks/backlog/2026-02-18-project-creation-dropdowns-and-navbar-links.md b/tasks/backlog/2026-02-18-project-creation-dropdowns-and-navbar-links.md new file mode 100644 index 0000000..7fd481c --- /dev/null +++ b/tasks/backlog/2026-02-18-project-creation-dropdowns-and-navbar-links.md @@ -0,0 +1,61 @@ +# Project Creation Dropdowns and Top-Level Navbar Links + +**Created**: 2026-02-18 +**Status**: completed + +## Request + +Update the project creation experience to match workspace creation behavior by using dropdown selectors for repository and branch. Update the navbar so primary destinations (dashboard, projects, etc.) are accessible directly without opening the profile menu. + +## Preflight Classification + +- `ui-change` +- `cross-component-change` +- `business-logic-change` +- `docs-sync-change` + +## Assumptions + +1. Project creation already has repository/branch data-fetching hooks or API utilities reused by workspace creation. +2. Navbar currently hides primary navigation behind the avatar/profile menu on at least some breakpoints. +3. No API contract changes are required; this is a web UI behavior change. + +## Impact Analysis + +- **Web UI screens affected**: Project creation form and top navigation layout. +- **Shared components affected**: Potentially common repo/branch picker components used by workspace creation. +- **Behavioral risk**: Incorrect branch defaulting or stale repo/branch options if dropdown state management is wrong. +- **Mobile risk**: Top-level nav additions can overflow on small screens and must preserve tap targets. + +## Constitution Check Plan (Principle XI) + +- Confirm no hardcoded internal URLs are introduced. +- Confirm no new hardcoded limits/timeouts/identifiers are introduced. +- Reuse existing config-driven URL/navigation patterns. + +## Implementation Plan + +1. Locate project creation form and workspace creation repo/branch selector implementation. +2. Reuse or extract selector logic so project creation uses dropdowns for repository and branch. +3. Update navbar to expose key primary destinations as top-level items while keeping profile menu for account actions. +4. Add/update web unit tests for project create form and navbar rendering/navigation behavior. +5. Run targeted web tests plus lint/typecheck for impacted packages. +6. Update documentation references if behavior/docs mention old navigation or project creation inputs. + +## Validation Checklist + +- [x] Project creation shows repository dropdown and branch dropdown +- [x] Branch options update correctly when repository changes +- [x] Top-level navbar includes dashboard/projects (and other primary destinations) +- [x] Primary destinations are accessible without profile menu +- [x] Mobile layout remains usable (single-column where relevant, no overflow) +- [x] Tests added/updated and passing +- [x] Docs updated in same PR if any referenced behavior changed + +## Completion Notes + +- Added project-oriented routes (`/projects`, `/projects/new`) and reused the workspace creation flow in project mode. +- Added persistent top-level primary navigation in the header via `UserMenu` while retaining profile dropdown actions. +- Repo selector now behaves as a true dropdown on focus (shows available repositories without requiring typed input first). +- Mobile screenshot captured at `.codex/tmp/playwright-screenshots/landing-mobile.png`. +- Docs review outcome: no user-facing docs currently describe these navbar or frontend route details, so no additional docs required beyond this task record.