diff --git a/frontend/src/components/repo/RepoList.test.tsx b/frontend/src/components/repo/RepoList.test.tsx
new file mode 100644
index 000000000..f61bf1c78
--- /dev/null
+++ b/frontend/src/components/repo/RepoList.test.tsx
@@ -0,0 +1,44 @@
+import { describe, expect, it, vi } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { createUseSettingsMock } from '@/test/test-utils'
+import { RepoList } from './RepoList'
+
+const mocks = vi.hoisted(() => ({ listRepos: vi.fn() }))
+
+vi.mock('@/api/repos', () => ({
+ listRepos: mocks.listRepos,
+ deleteRepo: vi.fn(),
+ updateRepoOrder: vi.fn(),
+}))
+
+vi.mock('@/hooks/useSettings', () => ({
+ useSettings: () => createUseSettingsMock(),
+}))
+
+vi.mock('@/hooks/useMobile', () => ({ useMobile: () => true }))
+
+function renderRepoList(onAddRepo: () => void) {
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
+
+ return render(
+
+
+ ,
+ )
+}
+
+describe('RepoList', () => {
+ it('calls onAddRepo once from the empty state', async () => {
+ mocks.listRepos.mockResolvedValue([])
+ const onAddRepo = vi.fn()
+ const user = userEvent.setup()
+
+ renderRepoList(onAddRepo)
+
+ await user.click(await screen.findByRole('button', { name: 'Add repository' }))
+
+ expect(onAddRepo).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/frontend/src/components/repo/RepoList.tsx b/frontend/src/components/repo/RepoList.tsx
index b222b2af2..968f3af41 100644
--- a/frontend/src/components/repo/RepoList.tsx
+++ b/frontend/src/components/repo/RepoList.tsx
@@ -6,8 +6,9 @@ import { CSS } from "@dnd-kit/utilities"
import { listRepos, deleteRepo, updateRepoOrder, type DeleteBranchChoice } from "@/api/repos"
import { fetchReposGitStatus } from "@/api/git"
import { DeleteDialog } from "@/components/ui/delete-dialog"
+import { Button } from "@/components/ui/button"
import { RadioOptionGroup, type RadioOption } from "@/components/ui/radio-option-group"
-import { GitBranch, Search, GripVertical } from "lucide-react"
+import { GitBranch, Search, GripVertical, Plus } from "lucide-react"
import type { Repo } from "@/api/types"
import type { GitStatusResponse } from "@/types/git"
import { RepoCard } from "./RepoCard"
@@ -135,7 +136,7 @@ function StaticRepoCard({
)
}
-export function RepoList() {
+export function RepoList({ onAddRepo }: { onAddRepo: () => void }) {
const queryClient = useQueryClient()
const isMobile = useMobile()
const { preferences, updateSettings } = useSettings()
@@ -341,6 +342,10 @@ export function RepoList() {
No repositories yet. Add one to get started.
+
)
@@ -413,6 +418,7 @@ export function RepoList() {
hasClonedRepos={hasClonedRepos}
selectionMode={selectionMode}
onSelectionModeChange={handleSelectionModeChange}
+ onAddRepo={onAddRepo}
/>
diff --git a/frontend/src/components/repo/RepoListControls.test.tsx b/frontend/src/components/repo/RepoListControls.test.tsx
new file mode 100644
index 000000000..f4b2ceb36
--- /dev/null
+++ b/frontend/src/components/repo/RepoListControls.test.tsx
@@ -0,0 +1,70 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { RepoListControls } from './RepoListControls'
+
+const mocks = vi.hoisted(() => ({ mobile: { current: false } }))
+
+vi.mock('@/hooks/useMobile', () => ({
+ isMobileViewport: () => mocks.mobile.current,
+ useMobile: () => mocks.mobile.current,
+}))
+
+function baseProps() {
+ return {
+ searchQuery: '',
+ onSearchChange: vi.fn(),
+ filterMode: 'recent' as const,
+ onFilterModeChange: vi.fn(),
+ sortMode: 'recent' as const,
+ onSortModeChange: vi.fn(),
+ filteredCount: 3,
+ attentionCount: 0,
+ selectedCount: 0,
+ allVisibleSelected: false,
+ onSelectAll: vi.fn(),
+ onClearSelection: vi.fn(),
+ onDelete: vi.fn(),
+ hasLocalRepos: false,
+ hasClonedRepos: false,
+ selectionMode: false,
+ onSelectionModeChange: vi.fn(),
+ onAddRepo: vi.fn(),
+ }
+}
+
+describe('RepoListControls', () => {
+ beforeEach(() => {
+ mocks.mobile.current = false
+ })
+
+ it('renders the add repository button after the search input on mobile', () => {
+ mocks.mobile.current = true
+
+ render(
)
+
+ const search = screen.getByRole('textbox')
+ const addButton = screen.getByRole('button', { name: 'Add repository' })
+
+ expect(
+ search.compareDocumentPosition(addButton) & Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy()
+ })
+
+ it('calls onAddRepo once when the mobile button is clicked', async () => {
+ mocks.mobile.current = true
+ const props = baseProps()
+ const user = userEvent.setup()
+
+ render(
)
+ await user.click(screen.getByRole('button', { name: 'Add repository' }))
+
+ expect(props.onAddRepo).toHaveBeenCalledTimes(1)
+ })
+
+ it('does not render the add repository button on desktop', () => {
+ render(
)
+
+ expect(screen.queryByRole('button', { name: 'Add repository' })).not.toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/components/repo/RepoListControls.tsx b/frontend/src/components/repo/RepoListControls.tsx
index ba24ca882..ce069de7e 100644
--- a/frontend/src/components/repo/RepoListControls.tsx
+++ b/frontend/src/components/repo/RepoListControls.tsx
@@ -1,5 +1,5 @@
import { useState } from "react";
-import { SlidersHorizontal, Trash2, X } from "lucide-react";
+import { Plus, SlidersHorizontal, Trash2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { RepoSearchInput } from "./RepoSearchInput";
import {
@@ -31,6 +31,7 @@ interface RepoListControlsProps {
hasClonedRepos: boolean;
selectionMode: boolean;
onSelectionModeChange: (enabled: boolean) => void;
+ onAddRepo: () => void;
}
const FILTER_OPTIONS: { value: RepoFilterMode; label: string }[] = [
@@ -65,6 +66,7 @@ export function RepoListControls({
hasClonedRepos,
selectionMode,
onSelectionModeChange,
+ onAddRepo,
}: RepoListControlsProps) {
const isMobile = useMobile();
const [showMenu, setShowMenu] = useState(false);
@@ -122,10 +124,29 @@ export function RepoListControls({
+ {isMobile && (
+
+ )}
+
{isMobile ? (
-
diff --git a/frontend/src/pages/Repos.tsx b/frontend/src/pages/Repos.tsx
index 753858c0f..394b22f8a 100644
--- a/frontend/src/pages/Repos.tsx
+++ b/frontend/src/pages/Repos.tsx
@@ -62,7 +62,11 @@ export function Repos() {
All Schedules
{isLocalServer ? (
- setAddRepoOpen(true)} size="sm">
+ setAddRepoOpen(true)}
+ size="sm"
+ className="hidden md:inline-flex"
+ >
Repo
@@ -73,7 +77,7 @@ export function Repos() {
- {isLocalServer ? : }
+ {isLocalServer ? setAddRepoOpen(true)} /> : }
{isLocalServer ? (