diff --git a/client/src/components/creative-director/ProjectLibrary.jsx b/client/src/components/creative-director/ProjectLibrary.jsx
new file mode 100644
index 0000000000..38b1233ee1
--- /dev/null
+++ b/client/src/components/creative-director/ProjectLibrary.jsx
@@ -0,0 +1,74 @@
+import { useMemo } from 'react';
+import useUrlParams from '../../hooks/useUrlParams.js';
+import { matchHaystack, tokenizeQuery } from '../../lib/mediaSearch.js';
+
+const SORTS = { unfinished: 'Unfinished first', newest: 'Newest created', updated: 'Recently updated', name: 'Name A–Z' };
+
+export default function ProjectLibrary({ projects, children }) {
+ const [params, updateParams] = useUrlParams();
+ const query = params.get('q') || '';
+ const statuses = useMemo(() => [...new Set(projects.map(p => p.status).filter(Boolean))].sort(), [projects]);
+ const requestedStatus = params.get('status');
+ const status = requestedStatus === 'unfinished' || statuses.includes(requestedStatus) ? requestedStatus : 'all';
+ const sort = Object.hasOwn(SORTS, params.get('sort')) ? params.get('sort') : 'unfinished';
+ const compact = params.get('layout') === 'list';
+ const visible = useMemo(() => {
+ const tokens = tokenizeQuery(query);
+ return projects.filter(p => (
+ (status === 'all' || (status === 'unfinished' ? p.status !== 'complete' : p.status === status)) &&
+ matchHaystack([p.name, p.id, p.status, p.modelId, p.directive?.goal, p.userStory, p.styleSpec].filter(Boolean).join(' ').toLowerCase(), tokens)
+ )).sort((a, b) => {
+ if (sort === 'unfinished') {
+ const completed = Number(a.status === 'complete') - Number(b.status === 'complete');
+ if (completed) return completed;
+ }
+ if (sort !== 'name') {
+ const aTime = (sort === 'updated' && Date.parse(a.updatedAt)) || Date.parse(a.createdAt) || 0;
+ const bTime = (sort === 'updated' && Date.parse(b.updatedAt)) || Date.parse(b.createdAt) || 0;
+ if (aTime !== bTime) return bTime - aTime;
+ }
+ return (a.name || '').localeCompare(b.name || '') || a.id.localeCompare(b.id);
+ });
+ }, [projects, query, status, sort]);
+ const controlClass = 'w-full bg-port-bg border border-port-border rounded px-3 py-2 text-sm';
+
+ return (
+
+
+
+
+ updateParams({ q: e.target.value }, { replace: true })} placeholder="Name, brief, model, or ID…" className={controlClass} />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Showing {visible.length} of {projects.length} projects
+ {(query || status !== 'all') && }
+
+ {projects.length > 0 && visible.length === 0 && No projects match your search and filters.
}
+
+ {visible.map(p => children(p, compact))}
+
+
+ );
+}
diff --git a/client/src/pages/CreativeDirector.jsx b/client/src/pages/CreativeDirector.jsx
index 6dc8c104e9..07d963fa63 100644
--- a/client/src/pages/CreativeDirector.jsx
+++ b/client/src/pages/CreativeDirector.jsx
@@ -23,6 +23,7 @@ import Drawer from '../components/Drawer';
import DirectiveComposer from '../components/creative-director/DirectiveComposer.jsx';
import CreativeDirectorModelsDrawer from '../components/creative-director/CreativeDirectorModelsDrawer.jsx';
import VideoDraftDrawer from '../components/creative-director/VideoDraftDrawer.jsx';
+import ProjectLibrary from '../components/creative-director/ProjectLibrary.jsx';
import ProjectPreview from '../components/creative-director/ProjectPreview.jsx';
const ASPECT_RATIOS = ['16:9', '9:16', '1:1'];
@@ -139,6 +140,12 @@ export default function CreativeDirector({ basePath = '/creative-director', brow
// a later, unrelated project created from this list page (#1808 review).
const clearRemix = () => { setRemixIds([]); setRemixIngredients([]); };
+ const clearProjectFilters = () => setSearchParams(prev => {
+ const next = new URLSearchParams(prev);
+ ['q', 'status', 'sort'].forEach(key => next.delete(key));
+ return next;
+ }, { replace: true });
+
const handleCreate = async (e) => {
e.preventDefault();
if (!form.name.trim() || !form.modelId) {
@@ -161,7 +168,8 @@ export default function CreativeDirector({ basePath = '/creative-director', brow
};
try {
const created = await createCreativeDirectorProject(payload, { silent: true });
- setProjects((prev) => [...prev, created]);
+ setProjects((prev) => [created, ...prev]);
+ clearProjectFilters();
setShowForm(false);
setForm((f) => ({ ...f, name: '', styleSpec: '', userStory: '', startingImageFile: '' }));
toast.success(`Created "${created.name}"`);
@@ -274,6 +282,7 @@ export default function CreativeDirector({ basePath = '/creative-director', brow
if (!created) return;
toast.success('Test clip render started');
setProjects((prev) => [created, ...prev]);
+ clearProjectFilters();
};
if (loading) {
@@ -493,12 +502,12 @@ export default function CreativeDirector({ basePath = '/creative-director', brow
No projects yet. Open Creative Director to create a video draft.
)}
-
- {projects.map((p) => (
+
+ {(p, compact) => (
-
+ {!compact &&
}
-
+
{p.name}
{p.id}
@@ -530,8 +539,8 @@ export default function CreativeDirector({ basePath = '/creative-director', brow
}
- ))}
-
+ )}
+
{
+ const projects = [
+ { id: 'finished', name: 'Alpha finished', status: 'complete', createdAt: '2026-09-08', updatedAt: '2026-09-09' },
+ { id: 'older', name: 'Beta paused', status: 'paused', createdAt: '2026-09-01', updatedAt: '2026-09-08', userStory: 'Mountain traveler' },
+ { id: 'new', name: 'Zeta draft', status: 'draft', createdAt: '2026-09-07', updatedAt: '2026-09-07' },
+ { id: 'legacy', name: 'Legacy project', status: 'failed' },
+ ];
+ const names = () => screen.getAllByRole('link', { name: /^Open (Alpha|Beta|Zeta|Legacy)/ }).map(link => link.getAttribute('aria-label'));
+ beforeEach(() => { vi.clearAllMocks(); cdApi.listCreativeDirectorProjects.mockResolvedValue(projects); });
+
+ it('prioritizes new unfinished work and supports alternate ordering and a compact view', async () => {
+ const user = userEvent.setup();
+ await renderPage();
+ expect(names()).toEqual(['Open Zeta draft', 'Open Beta paused', 'Open Legacy project', 'Open Alpha finished']);
+ await user.selectOptions(screen.getByLabelText('Sort projects'), 'newest');
+ expect(names()[0]).toBe('Open Alpha finished');
+ await user.selectOptions(screen.getByLabelText('Sort projects'), 'updated');
+ expect(names().slice(0, 3)).toEqual(['Open Alpha finished', 'Open Beta paused', 'Open Zeta draft']);
+ await user.selectOptions(screen.getByLabelText('Sort projects'), 'name');
+ await user.selectOptions(screen.getByLabelText('View'), 'list');
+ expect(names()).toEqual(['Open Alpha finished', 'Open Beta paused', 'Open Legacy project', 'Open Zeta draft']);
+ expect(screen.queryByText('no render yet')).toBeNull();
+ expect(screen.getByRole('link', { name: 'Open Zeta draft' })).toHaveAttribute('href', '/creative-director/new/overview');
+ });
+
+ it('combines bookmarkable text and status filters and recovers from no matches', async () => {
+ const user = userEvent.setup();
+ render();
+ await screen.findByText('Showing 1 of 4 projects');
+ expect(names()).toEqual(['Open Beta paused']);
+ await user.selectOptions(screen.getByLabelText('Status'), 'draft');
+ expect(screen.getByText('No projects match your search and filters.')).toBeTruthy();
+ await user.click(screen.getByRole('button', { name: 'Clear filters' }));
+ expect(names()).toHaveLength(4);
+ expect(screen.getByLabelText('View')).toHaveValue('list');
+ expect(cdApi.listCreativeDirectorProjects).toHaveBeenCalledTimes(1);
+ });
+
+ it('shows newly created work even when the previous filters hid drafts', async () => {
+ const user = userEvent.setup();
+ cdApi.createCreativeDirectorProject.mockResolvedValue({ id: 'created', name: 'Newly created', status: 'draft', createdAt: '2026-09-09' });
+ render();
+ await user.click(await screen.findByRole('button', { name: 'New project' }));
+ await user.type(screen.getByLabelText('Name'), 'Newly created');
+ await user.click(screen.getByRole('button', { name: 'Create' }));
+ await screen.findByRole('link', { name: 'Open Newly created' });
+ expect(screen.getAllByRole('link', { name: /^Open / })[0]).toHaveAttribute('aria-label', 'Open Newly created');
+ expect(screen.getByLabelText('Search projects')).toHaveValue('');
+ expect(screen.getByLabelText('Status')).toHaveValue('all');
+ });
+});
+
+ it('reveals a smoke-test project when started from a filtered library', async () => {
+ vi.clearAllMocks();
+ cdApi.listCreativeDirectorProjects.mockResolvedValue([{ id: 'finished', name: 'Finished example', status: 'complete' }]);
+ cdApi.createSmokeTestCreativeDirectorProject.mockResolvedValue({ id: 'smoke-new', name: 'New test clip', status: 'planning', createdAt: '2026-09-09' });
+ const user = userEvent.setup();
+ render();
+ await screen.findByRole('button', { name: 'New project' });
+ await openMenu(user);
+ await user.click(screen.getByRole('menuitem', { name: ITEM_LABEL }));
+ await user.click(screen.getByRole('button', { name: CONFIRM_LABEL }));
+ await screen.findByRole('link', { name: 'Open New test clip' });
+ expect(screen.getByLabelText('Status')).toHaveValue('all');
+ expect(screen.getByLabelText('Search projects')).toHaveValue('');
+});