diff --git a/client/src/pages/CodeAnimation.jsx b/client/src/pages/CodeAnimation.jsx
index 9d4bad3a05..8cc82c06e7 100644
--- a/client/src/pages/CodeAnimation.jsx
+++ b/client/src/pages/CodeAnimation.jsx
@@ -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,
@@ -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';
@@ -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,
@@ -289,9 +291,7 @@ 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('');
@@ -299,7 +299,6 @@ export default function CodeAnimation() {
const jobIdRef = useRef(jobId);
const hydratedJobIdRef = useRef('');
const locallyStartedJobIdRef = useRef('');
- const galleryRequestRef = useRef(0);
const {
providers,
selectedProviderId,
@@ -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]);
@@ -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.
@@ -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 || '')));
}
@@ -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)}`);
};
@@ -567,9 +563,8 @@ export default function CodeAnimation() {
New animation
- {galleryError &&
{galleryError}
}
- {!galleryLoaded && Loading animations…
}
- {galleryLoaded && savedJobs.length === 0 && !galleryError && (
+ {!gallery.loaded && Loading animations…
}
+ {gallery.loaded && savedJobs.length === 0 && !gallery.error && (
Generated animations will appear here so you can reopen them later.
)}
{savedJobs.length > 0 && (
@@ -594,7 +589,7 @@ export default function CodeAnimation() {
>
-
{item.title || item.concept || 'Untitled animation'}
+
{item.title || 'Untitled animation'}
{statusLabel}{item.model ? ` · ${item.model}` : ''}
@@ -605,6 +600,8 @@ export default function CodeAnimation() {
})}
)}
+
diff --git a/client/src/pages/CodeAnimation.test.jsx b/client/src/pages/CodeAnimation.test.jsx
index 1492961e35..ee4dcc2bbe 100644
--- a/client/src/pages/CodeAnimation.test.jsx
+++ b/client/src/pages/CodeAnimation.test.jsx
@@ -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(),
@@ -42,6 +53,7 @@ import {
buildCodeAnimationPrompt,
generateCodeAnimationBrief,
getCodeAnimationJob,
+ listCodeAnimationJobPage,
getCodeAnimationOptions,
listMoodBoardNames,
listTracks,
@@ -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' }]);
@@ -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();
diff --git a/client/src/services/apiCodeAnimation.js b/client/src/services/apiCodeAnimation.js
index fd5eac3f1d..303d12f29e 100644
--- a/client/src/services/apiCodeAnimation.js
+++ b/client/src/services/apiCodeAnimation.js
@@ -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);
diff --git a/server/lib/socketEventContracts.js b/server/lib/socketEventContracts.js
index e06a9239ad..59322c2355 100644
--- a/server/lib/socketEventContracts.js
+++ b/server/lib/socketEventContracts.js
@@ -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.',
diff --git a/server/routes/codeAnimation.js b/server/routes/codeAnimation.js
index c1c43723a5..55b4d25917 100644
--- a/server/routes/codeAnimation.js
+++ b/server/routes/codeAnimation.js
@@ -21,6 +21,7 @@ import {
generateCodeAnimationBrief,
getCodeAnimationJob,
listCodeAnimationJobs,
+ pageCodeAnimationJobs,
getCodeAnimationOptions,
startCodeAnimationGeneration,
} from '../services/codeAnimation/index.js';
@@ -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) => {
diff --git a/server/routes/codeAnimation.test.js b/server/routes/codeAnimation.test.js
index 1fad6c3980..63216ce8d0 100644
--- a/server/routes/codeAnimation.test.js
+++ b/server/routes/codeAnimation.test.js
@@ -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)),
@@ -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';
@@ -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\`\`\`` });
diff --git a/server/services/codeAnimation/index.js b/server/services/codeAnimation/index.js
index 2b75ee09d1..79a62e246a 100644
--- a/server/services/codeAnimation/index.js
+++ b/server/services/codeAnimation/index.js
@@ -14,6 +14,7 @@
import { randomUUID } from 'crypto';
import { ServerError } from '../../lib/errorHandler.js';
+import { emitCodeAnimationChanged } from '../socket.js';
import { PATHS } from '../../lib/paths.js';
import { makePathResolver, resolveGalleryImage, resolveImageRef } from '../../lib/pathSafety.js';
import { universeVisualStyleTokens } from '../../lib/universeVisualStyle.js';
@@ -25,6 +26,9 @@ import {
getCodeAnimationJobRecord,
isCodeAnimationJobId,
listCodeAnimationJobRecords,
+ listRunningCodeAnimationJobIds,
+ listCodeAnimationJobPage,
+ countCodeAnimationJobs,
readCodeAnimationHtml,
saveCodeAnimationHtml,
saveCodeAnimationJobRecord,
@@ -428,6 +432,7 @@ async function reconcileJob(job) {
updatedAt: now,
};
await saveCodeAnimationJobRecord(interrupted);
+ emitCodeAnimationChanged(job.id);
return interrupted;
}
@@ -440,6 +445,45 @@ export async function listCodeAnimationJobs() {
}));
}
+const JOB_PAGE_SIZE = 50;
+const JOB_PAGE_MAX = 100;
+
+function decodeJobCursor(value) {
+ if (!value) return null;
+ let tuple;
+ try { tuple = JSON.parse(Buffer.from(value, 'base64url').toString('utf8')); } catch { /* invalid cursor */ }
+ if (!Array.isArray(tuple) || tuple.length !== 2
+ || typeof tuple[0] !== 'string' || Number.isNaN(Date.parse(tuple[0]))
+ || !isCodeAnimationJobId(tuple[1])) {
+ throw new ServerError('Invalid job cursor', { status: 400, code: 'INVALID_CURSOR' });
+ }
+ return { createdAt: new Date(tuple[0]).toISOString(), id: tuple[1] };
+}
+
+export async function pageCodeAnimationJobs({ limit = JOB_PAGE_SIZE, cursor } = {}) {
+ const size = Math.min(limit, JOB_PAGE_MAX);
+ const after = decodeJobCursor(cursor);
+ // Reconcile only the small running subset before counting or reading a page.
+ const runningIds = await listRunningCodeAnimationJobIds();
+ await Promise.all(runningIds.filter((id) => !activeJobs.has(id)).map(async (id) => {
+ const record = await getCodeAnimationJobRecord(id);
+ if (record) await reconcileJob(record);
+ }));
+ const [rows, counts] = await Promise.all([
+ listCodeAnimationJobPage({ limit: size, cursor: after }),
+ countCodeAnimationJobs(),
+ ]);
+ const items = rows.slice(0, size);
+ const last = items.at(-1);
+ return {
+ items,
+ total: counts.total,
+ counts: { running: counts.running, completed: counts.completed },
+ nextCursor: rows.length > size && last
+ ? Buffer.from(JSON.stringify([last.createdAt, last.id])).toString('base64url') : null,
+ };
+}
+
async function runGeneration({ provider, model, effort, prompt, referencePaths }) {
const { runPromptThroughProvider } = await import('../promptRunner.js');
const result = await runPromptThroughProvider({
@@ -498,6 +542,7 @@ export async function startCodeAnimationGeneration(input) {
activeJobs.delete(id);
throw error;
});
+ emitCodeAnimationChanged(id);
console.log(`🎞️ Code animation generation ${id.slice(0, 8)} started on ${provider.id}`);
runGeneration({ provider, model: input.model, effort: input.effort, prompt: built.prompt, referencePaths: built.referencePaths })
.then(async ({ html, provider: ranOn, model, runId }) => {
@@ -512,6 +557,7 @@ export async function startCodeAnimationGeneration(input) {
completedAt,
updatedAt: completedAt,
});
+ emitCodeAnimationChanged(id);
activeJobs.delete(id);
console.log(`✅ Code animation generation ${id.slice(0, 8)} completed (${html.length} chars)`);
})
@@ -519,7 +565,7 @@ export async function startCodeAnimationGeneration(input) {
const message = String(error?.message || error || 'Generation failed').slice(0, 2_000);
const completedAt = new Date().toISOString();
const failed = { ...job, status: 'failed', error: message, completedAt, updatedAt: completedAt };
- await saveCodeAnimationJobRecord(failed).catch((persistError) => {
+ await saveCodeAnimationJobRecord(failed).then(() => emitCodeAnimationChanged(id)).catch((persistError) => {
console.error(`❌ Code animation generation ${id.slice(0, 8)} status could not be saved: ${persistError.message}`);
});
activeJobs.delete(id);
@@ -547,6 +593,7 @@ export async function getCodeAnimationJob(id) {
updatedAt: completedAt,
};
await saveCodeAnimationJobRecord(job);
+ emitCodeAnimationChanged(id);
return { ...job, html: null };
}
}
diff --git a/server/services/codeAnimation/jobStore.js b/server/services/codeAnimation/jobStore.js
index ef74ec060e..d5c8a78eb0 100644
--- a/server/services/codeAnimation/jobStore.js
+++ b/server/services/codeAnimation/jobStore.js
@@ -34,6 +34,34 @@ export async function listCodeAnimationJobRecords() {
return rows;
}
+export async function listRunningCodeAnimationJobIds() {
+ const { rows } = await query("SELECT id FROM code_animation_jobs WHERE status = 'running'");
+ return rows.map(({ id }) => id);
+}
+
+export async function listCodeAnimationJobPage({ limit, cursor }) {
+ const { rows } = await query(
+ `SELECT id, status, COALESCE(NULLIF(title, ''), LEFT(concept, 120)) AS title,
+ provider_id AS "providerId", model, created_at AS "createdAt"
+ FROM code_animation_jobs
+ WHERE ($1::timestamptz IS NULL OR (created_at, id) < ($1::timestamptz, $2::text))
+ ORDER BY created_at DESC, id DESC
+ LIMIT $3`,
+ [cursor?.createdAt ?? null, cursor?.id ?? null, limit + 1],
+ );
+ return rows;
+}
+
+export async function countCodeAnimationJobs() {
+ const { rows } = await query(
+ `SELECT COUNT(*)::integer AS total,
+ COUNT(*) FILTER (WHERE status = 'running')::integer AS running,
+ COUNT(*) FILTER (WHERE status = 'completed')::integer AS completed
+ FROM code_animation_jobs`,
+ );
+ return rows[0];
+}
+
export async function getCodeAnimationJobRecord(id) {
if (!isCodeAnimationJobId(id)) return null;
const { rows } = await query('SELECT data FROM code_animation_jobs WHERE id = $1', [id]);
diff --git a/server/services/codeAnimation/jobStore.test.js b/server/services/codeAnimation/jobStore.test.js
new file mode 100644
index 0000000000..73b0ee2737
--- /dev/null
+++ b/server/services/codeAnimation/jobStore.test.js
@@ -0,0 +1,21 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('../../lib/db.js', () => ({ query: vi.fn() }));
+
+import { query } from '../../lib/db.js';
+import { listCodeAnimationJobPage } from './jobStore.js';
+
+beforeEach(() => vi.clearAllMocks());
+
+describe('Code Animation persisted history', () => {
+ it('uses the text-id keyset index and reads only one compact lookahead page', async () => {
+ query.mockResolvedValue({ rows: [] });
+ await listCodeAnimationJobPage({ limit: 50, cursor: {
+ createdAt: '2026-01-01T00:00:00.000Z', id: '00000000-0000-4000-8000-000000000001',
+ } });
+ const [sql, params] = query.mock.calls[0];
+ expect(sql).toContain('(created_at, id) < ($1::timestamptz, $2::text)');
+ expect(sql).toContain("LEFT(concept, 120)");
+ expect(params).toEqual(['2026-01-01T00:00:00.000Z', '00000000-0000-4000-8000-000000000001', 51]);
+ });
+});
diff --git a/server/services/socket.js b/server/services/socket.js
index c23232c3ab..d8fb61c254 100644
--- a/server/services/socket.js
+++ b/server/services/socket.js
@@ -63,6 +63,7 @@ const agentSubscribers = new Set();
const instanceSubscribers = new Set();
// Store loop subscribers
const loopSubscribers = new Set();
+const codeAnimationSubscribers = new Set();
// Store Beeper realtime subscribers (#33). Invalidation frames and transport
// liveness ONLY — see setupBeeperEventForwarding for why this may never be a
// global emit.
@@ -80,7 +81,11 @@ export function getIo() {
return ioInstance;
}
-const ALL_SUBSCRIBER_SETS = [cosSubscribers, errorSubscribers, notificationSubscribers, agentSubscribers, instanceSubscribers, loopSubscribers, beeperSubscribers, fableLoomSubscribers];
+const ALL_SUBSCRIBER_SETS = [cosSubscribers, errorSubscribers, notificationSubscribers, agentSubscribers, instanceSubscribers, loopSubscribers, codeAnimationSubscribers, beeperSubscribers, fableLoomSubscribers];
+
+export function emitCodeAnimationChanged(id) {
+ broadcastToSet(codeAnimationSubscribers, 'code-animation:changed', { id });
+}
function broadcastToSet(set, event, data) {
const disconnected = [];
@@ -157,6 +162,7 @@ function registerSubscriptionHandlers(socket, _io) {
registerSubscriber(socket, 'agents', agentSubscribers);
registerSubscriber(socket, 'instances', instanceSubscribers);
registerSubscriber(socket, 'loops', loopSubscribers);
+ registerSubscriber(socket, 'code-animation', codeAnimationSubscribers);
registerSubscriber(socket, 'beeper', beeperSubscribers);
registerSubscriber(socket, 'fableloom', fableLoomSubscribers);
}