Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 46 additions & 11 deletions web/src/pages/Projects.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,16 @@ import {
FolderOpenIcon,
ArchiveBoxIcon,
TrashIcon,
WrenchScrewdriverIcon,
} from '@heroicons/react/24/outline';
import Header from '../components/Header';
import { createProject, getProjects, updateProject, deleteProject } from '../api/client';
import {
createProject,
getProjects,
updateProject,
deleteProject,
repairProjectLinkHealth,
} from '../api/client';
import { useAuthStore } from '../stores/authStore';
import { useToastStore } from '../stores/toastStore';
import { normalizeProjectSlug } from '../utils/projectSlug';
Expand Down Expand Up @@ -230,6 +237,7 @@ export default function Projects() {
const [isSavingProject, setIsSavingProject] = useState(false);
const [deletingProjectId, setDeletingProjectId] = useState(null);
const [togglingArchiveProjectId, setTogglingArchiveProjectId] = useState(null);
const [isRepairingLinks, setIsRepairingLinks] = useState(false);

const loadProjects = useCallback(async () => {
setIsLoadingProjects(true);
Expand Down Expand Up @@ -342,6 +350,22 @@ export default function Projects() {
}
};

const handleRepairMissingLinks = async () => {
setIsRepairingLinks(true);
try {
const result = await repairProjectLinkHealth({ limit: 200 });
showToast(
`Link repair complete · repaired ${result?.repaired ?? 0}, failed ${result?.failed ?? 0}`,
result?.failed ? 'warning' : 'success',
);
await loadProjects();
} catch (err) {
showToast(err?.response?.data?.error?.message || err.message || 'Failed to repair project links', 'error');
} finally {
setIsRepairingLinks(false);
}
};

return (
<div className="flex flex-col h-full">
<Header title="Projects" subtitle="Registry first. Project detail and files live one level down." />
Expand All @@ -358,16 +382,27 @@ export default function Projects() {
</div>

{isAdmin() && (
<button
className="btn-primary inline-flex items-center gap-2"
onClick={() => {
setEditingProject(null);
setShowCreateForm((prev) => !prev);
}}
>
<PlusIcon className="w-4 h-4" />
{showCreateForm ? 'Close' : 'New Project'}
</button>
<div className="flex items-center gap-2">
<button
className="btn-secondary inline-flex items-center gap-2 disabled:opacity-50"
onClick={handleRepairMissingLinks}
disabled={isRepairingLinks}
title="Recreate missing /projects links for main and assigned agents"
>
<WrenchScrewdriverIcon className="w-4 h-4" />
{isRepairingLinks ? 'Fixing links…' : 'Fix missing links'}
</button>
<button
className="btn-primary inline-flex items-center gap-2"
onClick={() => {
setEditingProject(null);
setShowCreateForm((prev) => !prev);
}}
>
<PlusIcon className="w-4 h-4" />
{showCreateForm ? 'Close' : 'New Project'}
</button>
</div>
)}
</div>

Expand Down
41 changes: 39 additions & 2 deletions web/src/pages/Projects.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,25 @@ vi.mock('../api/client', () => ({
createProject: vi.fn(),
updateProject: vi.fn(),
deleteProject: vi.fn(),
repairProjectLinkHealth: vi.fn(),
}));

vi.mock('../stores/authStore', () => ({
useAuthStore: () => ({ isAdmin: () => true }),
}));

const showToast = vi.fn();

vi.mock('../stores/toastStore', () => ({
useToastStore: () => ({ showToast: vi.fn() }),
useToastStore: () => ({ showToast }),
}));

const { getProjects, updateProject } = await import('../api/client');
const { getProjects, updateProject, repairProjectLinkHealth } = await import('../api/client');

describe('Projects', () => {
beforeEach(() => {
vi.clearAllMocks();
showToast.mockReset();
});

it('renders registry summary and project cards', async () => {
Expand Down Expand Up @@ -159,4 +163,37 @@ describe('Projects', () => {
expect(updateProject).toHaveBeenCalledWith('p1', expect.objectContaining({ status: 'archived' }));
});
});

it('repairs missing links from the projects registry', async () => {
getProjects.mockResolvedValue([
{
id: 'p1',
slug: 'project-alpha',
name: 'Project Alpha',
description: 'Sample project description',
root_path: '/projects/project-alpha',
status: 'active',
assigned_agents: 2,
updated_at: '2026-03-12T19:00:00.000Z',
},
]);
repairProjectLinkHealth.mockResolvedValue({ repaired: 2, failed: 0 });

render(
<MemoryRouter>
<Projects />
</MemoryRouter>,
);

await waitFor(() => {
expect(screen.getByRole('button', { name: 'Fix missing links' })).toBeInTheDocument();
});

fireEvent.click(screen.getByRole('button', { name: 'Fix missing links' }));

await waitFor(() => {
expect(repairProjectLinkHealth).toHaveBeenCalledWith({ limit: 200 });
expect(showToast).toHaveBeenCalledWith('Link repair complete · repaired 2, failed 0', 'success');
});
});
});
Loading