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
55 changes: 26 additions & 29 deletions client/src/pages/CodeAnimation.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,19 @@ import PageHeader from '../components/PageHeader';
import ProviderModelSelector from '../components/ProviderModelSelector';
import AlbumTrackPicker from '../components/music/AlbumTrackPicker';
import CodeAnimationPreview from '../components/codeAnimation/CodeAnimationPreview';
import InfiniteScrollFooter from '../components/ui/InfiniteScrollFooter';
import useProviderModels from '../hooks/useProviderModels';
import { usePagedCollection } from '../hooks/usePagedCollection';
import { useSocketSubscription } from '../hooks/useSocketSubscription';
import { useAutoRefetch } from '../hooks/useAutoRefetch';
import socket from '../services/socket';
import toast from '../components/ui/Toast';
import {
buildCodeAnimationPrompt,
generateCodeAnimationBrief,
getCodeAnimationJob,
getCodeAnimationOptions,
listCodeAnimationJobs,
listCodeAnimationJobPage,
listMoodBoardNames,
listTracks,
listUniverseNames,
Expand All @@ -28,7 +32,6 @@ import { formatCount, timeAgo } from '../utils/formatters';

const DRAFT_KEY = 'portos.codeAnimation.draft';
const JOB_POLL_MS = 3_000;
const GALLERY_POLL_MS = 10_000;
// Mood-board choice sentinels: follow the universe's linked board, or none.
const BOARD_FOLLOW_UNIVERSE = 'universe';
const BOARD_NONE = 'none';
Expand Down Expand Up @@ -231,7 +234,6 @@ function galleryJob(job) {
id: job.id,
status: job.status,
title: job.title,
concept: job.concept || job.input?.concept || '',
providerId: job.providerId,
model: job.model,
error: job.error,
Expand Down Expand Up @@ -289,17 +291,14 @@ export default function CodeAnimation() {
const [built, setBuilt] = useState(null);
const [starting, setStarting] = useState(false);
const [job, setJob] = useState(null);
const [savedJobs, setSavedJobs] = useState([]);
const [galleryLoaded, setGalleryLoaded] = useState(false);
const [galleryError, setGalleryError] = useState('');
const [galleryCounts, setGalleryCounts] = useState({ running: 0, completed: 0 });
const [effort, setEffort] = useState('');
const [briefEffort, setBriefEffort] = useState('');
const [pastedHtml, setPastedHtml] = useState('');
const [preview, setPreview] = useState(null);
const jobIdRef = useRef(jobId);
const hydratedJobIdRef = useRef('');
const locallyStartedJobIdRef = useRef('');
const galleryRequestRef = useRef(0);
const {
providers,
selectedProviderId,
Expand All @@ -322,21 +321,20 @@ export default function CodeAnimation() {
const update = (patch) => setDraft((prev) => ({ ...prev, ...patch }));
const updateFormat = (patch) => setDraft((prev) => ({ ...prev, format: { ...prev.format, ...patch } }));

const refreshGallery = useCallback(async () => {
const requestId = ++galleryRequestRef.current;
const rows = await listCodeAnimationJobs({ silent: true }).catch((error) => {
if (requestId !== galleryRequestRef.current) return null;
setGalleryError(error.message || 'Failed to load animation gallery');
setGalleryLoaded(true);
return null;
});
if (requestId !== galleryRequestRef.current) return;
if (!Array.isArray(rows)) return;
setSavedJobs(rows);
setGalleryError('');
setGalleryLoaded(true);
const fetchGalleryPage = useCallback(async ({ cursor, signal }) => {
const page = await listCodeAnimationJobPage({ cursor, signal });
if (!signal.aborted) setGalleryCounts(page.counts);
return page;
}, []);
useAutoRefetch(refreshGallery, GALLERY_POLL_MS, { enabled: true, pollOnly: true });
const gallery = usePagedCollection(fetchGalleryPage);
const savedJobs = gallery.items;
const setSavedJobs = gallery.setItems;
useSocketSubscription('code-animation', { onResubscribe: gallery.refreshFirst });
useEffect(() => {
const refresh = () => gallery.refreshFirst();
socket.on('code-animation:changed', refresh);
return () => socket.off('code-animation:changed', refresh);
}, [gallery.refreshFirst]);

useEffect(() => { safeWriteJsonStorage(DRAFT_KEY, draft); }, [draft]);

Expand Down Expand Up @@ -365,8 +363,8 @@ export default function CodeAnimation() {
// blank-slate idea generator.
const briefSeeds = !!(draft.universeId || draft.seedIdea.trim() || draft.concept.trim() || draft.title.trim());
const canWriteBrief = briefSeeds && !writingBrief;
const inProgressCount = savedJobs.filter((item) => item.status === 'running').length;
const completedCount = savedJobs.filter((item) => item.status === 'completed').length;
const inProgressCount = galleryCounts.running;
const completedCount = galleryCounts.completed;

// Poll the generation job named in the URL until it settles. The ref drops a
// response for a job the user has since replaced.
Expand All @@ -391,7 +389,6 @@ export default function CodeAnimation() {
if (next.status === 'completed' && next.html) setPreview({ html: next.html, audioUrl: next.audioUrl, frame: next.frame });
else setPreview(null);
if (next.status !== 'missing') {
galleryRequestRef.current += 1;
setSavedJobs((previous) => [galleryJob(next), ...previous.filter((item) => item.id !== requested)]
.sort((a, b) => (b.createdAt || '').localeCompare(a.createdAt || '')));
}
Expand Down Expand Up @@ -534,7 +531,6 @@ export default function CodeAnimation() {
setBuilt({ prompt: started.prompt, attachments: started.attachments, frame: started.frame, audioUrl: started.audioUrl, moodBoardId: started.moodBoardId, briefKey });
setJob(started);
setPreview(null);
galleryRequestRef.current += 1;
setSavedJobs((previous) => [galleryJob(started), ...previous.filter((item) => item.id !== started.id)]);
navigate(`/code-animation/${encodeURIComponent(started.id)}`);
};
Expand Down Expand Up @@ -567,9 +563,8 @@ export default function CodeAnimation() {
<Sparkles className="h-4 w-4" /> New animation
</Link>
</div>
{galleryError && <p role="status" className="text-xs text-port-error">{galleryError}</p>}
{!galleryLoaded && <p className="text-xs text-gray-500">Loading animations…</p>}
{galleryLoaded && savedJobs.length === 0 && !galleryError && (
{!gallery.loaded && <p className="text-xs text-gray-500">Loading animations…</p>}
{gallery.loaded && savedJobs.length === 0 && !gallery.error && (
<p className="text-xs text-gray-500">Generated animations will appear here so you can reopen them later.</p>
)}
{savedJobs.length > 0 && (
Expand All @@ -594,7 +589,7 @@ export default function CodeAnimation() {
>
<div className="flex items-center gap-2">
<StatusIcon className={`h-4 w-4 shrink-0 ${item.status === 'failed' ? 'text-port-error' : item.status === 'completed' ? 'text-port-success' : 'text-port-accent'}`} />
<p className="min-w-0 flex-1 truncate text-sm font-medium text-white">{item.title || item.concept || 'Untitled animation'}</p>
<p className="min-w-0 flex-1 truncate text-sm font-medium text-white">{item.title || 'Untitled animation'}</p>
</div>
<div className="mt-2 flex items-center justify-between gap-2 text-xs text-gray-500">
<span>{statusLabel}{item.model ? ` · ${item.model}` : ''}</span>
Expand All @@ -605,6 +600,8 @@ export default function CodeAnimation() {
})}
</div>
)}
<InfiniteScrollFooter hasMore={gallery.hasMore} loading={gallery.loading} error={gallery.error}
onLoadMore={gallery.loadMore} autoLoad={false} label="Load older animations" />
</section>

<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
Expand Down
38 changes: 37 additions & 1 deletion client/src/pages/CodeAnimation.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,24 @@ import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes } from 'react-router';

const pollHarness = vi.hoisted(() => ({ callbacks: new Map() }));
const socketHarness = vi.hoisted(() => ({ handlers: new Map() }));

vi.mock('../services/socket', () => ({ default: {
emit: vi.fn(),
on: vi.fn((event, handler) => {
const handlers = socketHarness.handlers.get(event) || new Set();
handlers.add(handler);
socketHarness.handlers.set(event, handlers);
}),
off: vi.fn((event, handler) => socketHarness.handlers.get(event)?.delete(handler)),
} }));

vi.mock('../services/api', () => ({
buildCodeAnimationPrompt: vi.fn(),
generateCodeAnimationBrief: vi.fn(),
getCodeAnimationJob: vi.fn(),
getCodeAnimationOptions: vi.fn(),
listCodeAnimationJobs: vi.fn().mockResolvedValue([]),
listCodeAnimationJobPage: vi.fn().mockResolvedValue({ items: [], total: 0, counts: { running: 0, completed: 0 }, nextCursor: null }),
listMoodBoardNames: vi.fn(),
listTracks: vi.fn().mockResolvedValue([]),
listUniverseNames: vi.fn(),
Expand Down Expand Up @@ -42,6 +53,7 @@ import {
buildCodeAnimationPrompt,
generateCodeAnimationBrief,
getCodeAnimationJob,
listCodeAnimationJobPage,
getCodeAnimationOptions,
listMoodBoardNames,
listTracks,
Expand Down Expand Up @@ -77,6 +89,8 @@ describe('Code Animation page', () => {
beforeEach(() => {
vi.clearAllMocks();
pollHarness.callbacks.clear();
socketHarness.handlers.clear();
listCodeAnimationJobPage.mockResolvedValue({ items: [], total: 0, counts: { running: 0, completed: 0 }, nextCursor: null });
localStorage.clear();
getCodeAnimationOptions.mockResolvedValue(OPTIONS);
listUniverseNames.mockResolvedValue([{ id: 'u1', name: 'Example Universe' }]);
Expand Down Expand Up @@ -106,6 +120,28 @@ describe('Code Animation page', () => {
expect(screen.getByText('Reference images (0/8)')).toBeInTheDocument();
});

it('loads one compact page, stays idle, and refreshes on durable changes and reconnect', async () => {
const jobs = Array.from({ length: 50 }, (_, n) => ({
id: `job-${n}`, status: 'completed', title: `Animation ${n}`, createdAt: '2026-01-01T00:00:00.000Z',
}));
listCodeAnimationJobPage.mockResolvedValue({ items: jobs, total: 1000,
counts: { running: 0, completed: 1000 }, nextCursor: 'next-page' });
await renderPage();
await waitFor(() => expect(screen.getAllByRole('link', { name: /Animation \d+/ })).toHaveLength(50));
expect(listCodeAnimationJobPage).toHaveBeenCalledTimes(1);
expect(pollHarness.callbacks.has(10_000)).toBe(false);
expect(screen.getByText('0 in progress · 1,000 completed')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Load older animations' })).toBeInTheDocument();
vi.useFakeTimers();
await act(async () => { vi.advanceTimersByTime(60_000); });
vi.useRealTimers();
expect(listCodeAnimationJobPage).toHaveBeenCalledTimes(1);
await act(async () => { for (const handler of socketHarness.handlers.get('code-animation:changed') || []) handler({ id: 'job-0' }); });
await waitFor(() => expect(listCodeAnimationJobPage).toHaveBeenCalledTimes(2));
await act(async () => { for (const handler of socketHarness.handlers.get('connect') || []) handler(); });
await waitFor(() => expect(listCodeAnimationJobPage).toHaveBeenCalledTimes(3));
});

it('builds a universe-styled prompt that follows the universe mood board by default', async () => {
const user = userEvent.setup();
await renderPage();
Expand Down
4 changes: 3 additions & 1 deletion client/src/services/apiCodeAnimation.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ export const startCodeAnimationGeneration = (brief, options) => request('/code-a
...options,
});

export const listCodeAnimationJobs = (options) => request('/code-animation/jobs', options);
export const listCodeAnimationJobPage = ({ cursor, signal, limit = 50 } = {}) =>
request(`/code-animation/jobs?limit=${limit}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`,
{ signal, silent: true });

export const getCodeAnimationJob = (id, options) =>
request(`/code-animation/generate/${encodeURIComponent(id)}`, options);
6 changes: 6 additions & 0 deletions server/lib/socketEventContracts.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ export const SOCKET_EVENT_CONTRACTS = Object.freeze({
summary: 'Release readiness observation; the last subscriber stops the observer.',
payloadSchema: { type: 'object', properties: {}, additionalProperties: false },
},
'code-animation:changed': {
direction: 'server-to-client',
summary: 'Invalidate the bounded Code Animation gallery after a durable job change.',
payloadSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'], additionalProperties: false },

},
'brain:changed': {
direction: 'server-to-client',
summary: 'Invalidate Brain summary/settings after a persisted change.',
Expand Down
13 changes: 11 additions & 2 deletions server/routes/codeAnimation.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
generateCodeAnimationBrief,
getCodeAnimationJob,
listCodeAnimationJobs,
pageCodeAnimationJobs,
getCodeAnimationOptions,
startCodeAnimationGeneration,
} from '../services/codeAnimation/index.js';
Expand Down Expand Up @@ -135,8 +136,16 @@ router.post('/generate', asyncHandler(async (req, res) => {
res.status(202).json(await startCodeAnimationGeneration(input));
}));

router.get('/jobs', asyncHandler(async (_req, res) => {
res.json(await listCodeAnimationJobs());
const jobsPageSchema = z.object({
limit: z.coerce.number().int().min(1).max(1000).optional(),
cursor: z.string().min(1).max(256).optional(),
}).strict();

router.get('/jobs', asyncHandler(async (req, res) => {
// Existing query-less callers retain the array contract.
res.json(Object.keys(req.query).length === 0
? await listCodeAnimationJobs()
: await pageCodeAnimationJobs(validateRequest(jobsPageSchema, req.query)));
}));

router.get('/generate/:id', asyncHandler(async (req, res) => {
Expand Down
56 changes: 56 additions & 0 deletions server/routes/codeAnimation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@ vi.mock('../services/codeAnimation/jobStore.js', () => ({
getCodeAnimationJobRecord: vi.fn(async (id) => codeAnimationRecords.get(id) ?? null),
isCodeAnimationJobId: (id) => /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id),
listCodeAnimationJobRecords: vi.fn(async () => [...codeAnimationRecords.values()]),
listRunningCodeAnimationJobIds: vi.fn(async () => [...codeAnimationRecords.values()].filter((job) => job.status === 'running').map((job) => job.id)),
listCodeAnimationJobPage: vi.fn(async ({ limit, cursor }) => [...codeAnimationRecords.values()]
.filter((job) => !cursor || job.createdAt < cursor.createdAt || (job.createdAt === cursor.createdAt && job.id < cursor.id))
.sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id))
.slice(0, limit + 1).map(({ id, status, title, providerId, model, createdAt }) => ({ id, status, title, providerId, model, createdAt }))),
countCodeAnimationJobs: vi.fn(async () => ({
total: codeAnimationRecords.size,
running: [...codeAnimationRecords.values()].filter((job) => job.status === 'running').length,
completed: [...codeAnimationRecords.values()].filter((job) => job.status === 'completed').length,
})),
readCodeAnimationHtml: vi.fn(async (id) => codeAnimationHtml.get(id)),
saveCodeAnimationHtml: vi.fn(async (id, html) => codeAnimationHtml.set(id, html)),
saveCodeAnimationJobRecord: vi.fn(async (job) => codeAnimationRecords.set(job.id, job)),
Expand All @@ -36,6 +46,7 @@ import { getUniverse } from '../services/universeBuilder/crud.js';
import { getBoard } from '../services/moodBoard/db.js';
import { getProviderById } from '../services/providers.js';
import { getTrack } from '../services/tracks/index.js';
import { listCodeAnimationJobPage, listCodeAnimationJobRecords } from '../services/codeAnimation/jobStore.js';
import { assertProvider, resolveProviderAndModel, runPromptThroughProvider } from '../services/promptRunner.js';
import routes from './codeAnimation.js';

Expand Down Expand Up @@ -96,6 +107,51 @@ beforeEach(() => {
resolveProviderAndModel.mockResolvedValue({ provider: { id: 'api-1', type: 'api' }, selectedModel: 'example-model' });
});

describe('GET /api/code-animation/jobs', () => {
it('reconciles only stale running records before paging and counting', async () => {
const id = '00000000-0000-4000-8000-000000000001';
codeAnimationRecords.set(id, { id, status: 'running', title: 'Interrupted', concept: 'Private brief',
createdAt: '2026-01-01T00:00:00.000Z' });
const response = await request(makeApp()).get('/api/code-animation/jobs?limit=50');
expect(response.status).toBe(200);
expect(response.body.items).toMatchObject([{ id, status: 'failed' }]);
expect(response.body.counts).toEqual({ running: 0, completed: 0 });
expect(listCodeAnimationJobRecords).not.toHaveBeenCalled();
expect((await request(makeApp()).get(`/api/code-animation/generate/${id}`)).body.error)
.toMatch(/interrupted by a server restart/);
});

it('keeps the legacy array and pages a compact thousand-job archive with stable equal-time cursors', async () => {
const createdAt = '2026-01-01T00:00:00.000Z';
for (let n = 0; n < 1000; n += 1) {
const id = `00000000-0000-4000-8000-${n.toString(16).padStart(12, '0')}`;
codeAnimationRecords.set(id, { id, status: 'completed', title: `Animation ${n}`, concept: 'x'.repeat(2000), createdAt });
}
const app = makeApp();
const legacy = await request(app).get('/api/code-animation/jobs');
expect(legacy.status).toBe(200);
expect(legacy.body).toHaveLength(1000);
expect(legacy.body[0].concept).toHaveLength(2000);

const first = await request(app).get('/api/code-animation/jobs?limit=50');
expect(first.status).toBe(200);
expect(first.body.items).toHaveLength(50);
expect(first.body.items[0]).not.toHaveProperty('concept');
expect(first.body.counts).toEqual({ running: 0, completed: 1000 });
expect(first.body.total).toBe(1000);
expect(JSON.stringify(first.body).length).toBeLessThan(JSON.stringify(legacy.body).length / 10);
expect(first.body.nextCursor).toBeTruthy();
expect(JSON.parse(Buffer.from(first.body.nextCursor, 'base64url').toString('utf8'))).toEqual([createdAt, first.body.items.at(-1).id]);
const second = await request(app).get(`/api/code-animation/jobs?limit=50&cursor=${encodeURIComponent(first.body.nextCursor)}`);
expect(second.body.items).toHaveLength(50);
expect(new Set([...first.body.items, ...second.body.items].map(({ id }) => id)).size).toBe(100);
const capped = await request(app).get('/api/code-animation/jobs?limit=1000');
expect(capped.body.items).toHaveLength(100);
expect(listCodeAnimationJobPage).toHaveBeenLastCalledWith({ limit: 100, cursor: null });
expect((await request(app).get('/api/code-animation/jobs?cursor=garbage')).status).toBe(400);
});
});

describe('POST /api/code-animation/brief', () => {
const briefResponse = (body) => ({ runId: 'run-b', text: `\`\`\`json\n${JSON.stringify(body)}\n\`\`\`` });

Expand Down
Loading
Loading