feat(queue): Add visual status transition feedback in JobList component with updated badges and test coverage

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-18 18:08:34 -07:00
parent a867794015
commit ffb969bd9b
3 changed files with 142 additions and 64 deletions

View file

@ -1,6 +1,5 @@
import type { ReactElement } from 'react';
import styled from '@lilith/ui-styled-components';
import { Alert } from '@lilith/ui-primitives';
import { JobRow } from './JobRow';
import type { JobStatusDto, CreateJobDto } from '@/api/video-studio-api';

View file

@ -1,32 +1,41 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '@lilith/ui-styled-components';
import { lilithAdapter } from '@lilith/ui-theme';
import { JobStatusBadge } from './JobStatusBadge';
function renderWithTheme(ui: React.ReactElement): ReturnType<typeof render> {
return render(
<ThemeProvider theme={lilithAdapter}>
{ui}
</ThemeProvider>,
);
}
describe('JobStatusBadge', () => {
it('renders "Queued" for queued status', () => {
render(<JobStatusBadge status="queued" />);
renderWithTheme(<JobStatusBadge status="queued" />);
expect(screen.getByText('Queued')).toBeTruthy();
});
it('renders "Processing" for processing status', () => {
render(<JobStatusBadge status="processing" />);
renderWithTheme(<JobStatusBadge status="processing" />);
expect(screen.getByText('Processing')).toBeTruthy();
});
it('renders a spinner alongside "Processing" label', () => {
const { container } = render(<JobStatusBadge status="processing" />);
// Spinner renders as an element with role="status" or an svg — check wrapper exists
const { container } = renderWithTheme(<JobStatusBadge status="processing" />);
expect(container.firstChild).toBeTruthy();
expect(screen.getByText('Processing')).toBeTruthy();
});
it('renders "Done" for done status', () => {
render(<JobStatusBadge status="done" />);
renderWithTheme(<JobStatusBadge status="done" />);
expect(screen.getByText('Done')).toBeTruthy();
});
it('renders "Failed" for failed status', () => {
render(<JobStatusBadge status="failed" />);
renderWithTheme(<JobStatusBadge status="failed" />);
expect(screen.getByText('Failed')).toBeTruthy();
});
});

View file

@ -1,18 +1,24 @@
import { useState, useCallback, useMemo, type ReactElement } from 'react';
import styled from '@lilith/ui-styled-components';
import { LiveCameraView } from '@vs-live/components/LiveCameraView';
import { FileVideoView } from '@vs-live/components/FileVideoView';
import { DisguiseVideoWithFaceSelector } from '@vs-live/components/DisguiseVideoWithFaceSelector';
import type { FaceIdentity } from '@vs-live/components/FaceSelectionOverlay';
import type { DisguiseConfig } from '@vs-live/renderers/params';
import { resolveDemonParams, resolveSuccubusParams } from '@vs-live/renderers/params';
import type { DisguiseConfig, DemonParams, SuccubusParams } from '@vs-live/renderers/params';
import {
resolveDemonParams,
resolveSuccubusParams,
DEFAULT_DEMON_PARAMS,
DEFAULT_SUCCUBUS_PARAMS,
} from '@vs-live/renderers/params';
import { IdentityRoster } from '@/components/studio/IdentityRoster';
import { DisguiseControlPanel } from '@/components/studio/DisguiseControlPanel';
import { usePresets } from '@/hooks/usePresets';
import type { DisguiseMode } from '@vs-live/components/DisguiseVideoParticipantVideo';
type StudioTab = 'live' | 'upload';
type StudioTab = 'live' | 'file';
const STUDIO_TABS: { id: StudioTab; label: string }[] = [
{ id: 'live', label: 'Live Camera' },
{ id: 'upload', label: 'Upload Video' },
{ id: 'file', label: 'File Upload' },
];
const PageRoot = styled.div`
@ -67,6 +73,39 @@ const ContentArea = styled.div`
min-width: 0;
`;
const ControlsColumn = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing.md};
min-width: 280px;
max-width: 320px;
`;
const FileTabPlaceholder = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing.md};
padding: ${({ theme }) => theme.spacing.xl};
background: ${({ theme }) => theme.colors.background.secondary};
border: 2px dashed ${({ theme }) => theme.colors.border};
border-radius: ${({ theme }) => theme.borderRadius.lg};
color: ${({ theme }) => theme.colors.text.secondary};
text-align: center;
`;
const FileTabTitle = styled.p`
margin: 0;
font-size: ${({ theme }) => theme.typography.fontSize.md};
font-weight: ${({ theme }) => theme.typography.fontWeight.semibold};
color: ${({ theme }) => theme.colors.text.primary};
`;
const FileTabBody = styled.p`
margin: 0;
font-size: ${({ theme }) => theme.typography.fontSize.sm};
color: ${({ theme }) => theme.colors.text.muted};
`;
let nextPersonNum = 1;
function generateIdentityId(): string {
@ -78,10 +117,42 @@ export function StudioPage(): ReactElement {
const [identities, setIdentities] = useState<FaceIdentity[]>([]);
const [identityFrameCounts, setIdentityFrameCounts] = useState<ReadonlyMap<string, number>>(new Map());
const [identityPresetIds, setIdentityPresetIds] = useState<ReadonlyMap<string, string>>(new Map());
const [trackIdToIdentityId, setTrackIdToIdentityId] = useState<ReadonlyMap<number, string>>(new Map());
// Disguise control state
const [mode, setMode] = useState<DisguiseMode>('blur');
const [blurStrength, setBlurStrength] = useState(20);
const [demonParams, setDemonParams] = useState<DemonParams>(DEFAULT_DEMON_PARAMS);
const [succubusParams, setSuccubusParams] = useState<SuccubusParams>(DEFAULT_SUCCUBUS_PARAMS);
const { presets, savePreset, updatePreset, deletePreset, generateName } = usePresets();
const currentConfig: DisguiseConfig = useMemo(() => ({
mode,
blurStrength,
demonParams,
succubusParams,
}), [mode, blurStrength, demonParams, succubusParams]);
const disguiseOptions = useMemo(() => ({
demon: demonParams,
succubus: succubusParams,
}), [demonParams, succubusParams]);
const handlePresetApply = useCallback((config: DisguiseConfig) => {
setMode(config.mode);
setBlurStrength(config.blurStrength);
setDemonParams(resolveDemonParams(config.demonParams));
setSuccubusParams(resolveSuccubusParams(config.succubusParams));
}, []);
const handlePresetSave = useCallback((name: string, config: DisguiseConfig) => {
savePreset(name, config);
}, [savePreset]);
const handlePresetRename = useCallback((id: string, name: string) => {
updatePreset(id, { name });
}, [updatePreset]);
const handleCapturePortrait = useCallback(
(
_faceIndex: number,
@ -120,13 +191,6 @@ export function StudioPage(): ReactElement {
setIdentities((prev) => prev.filter((i) => i.id !== id));
setIdentityPresetIds((prev) => { const n = new Map(prev); n.delete(id); return n; });
setIdentityFrameCounts((prev) => { const n = new Map(prev); n.delete(id); return n; });
setTrackIdToIdentityId((prev) => {
const n = new Map(prev);
for (const [trackId, identityId] of n) {
if (identityId === id) n.delete(trackId);
}
return n;
});
}, []);
const handleIdentityPresetAssign = useCallback((identityId: string, presetId: string | null) => {
@ -146,10 +210,12 @@ export function StudioPage(): ReactElement {
}, []);
const resolveIdentityConfig = useCallback(
(identityId: string): DisguiseConfig | undefined => {
(identityId: string): { mode: DisguiseMode; config?: DisguiseConfig } | undefined => {
const presetId = identityPresetIds.get(identityId);
if (!presetId) return undefined;
return presets.find((p) => p.id === presetId)?.config;
const preset = presets.find((p) => p.id === presetId);
if (!preset) return undefined;
return { mode: preset.config.mode, config: preset.config };
},
[identityPresetIds, presets],
);
@ -164,25 +230,6 @@ export function StudioPage(): ReactElement {
});
}, []);
const handleNewFaceTrack = useCallback((trackId: number) => {
const id = generateIdentityId();
const name = `Person ${nextPersonNum++}`;
setIdentities((prev) => [...prev, { id, name }]);
setTrackIdToIdentityId((prev) => new Map([...prev, [trackId, id]]));
}, []);
const presetsProps = useMemo(() => ({
presets,
onPresetSave: savePreset,
onPresetUpdate: updatePreset,
onPresetDelete: deletePreset,
generatePresetName: generateName,
}), [presets, savePreset, updatePreset, deletePreset, generateName]);
// Lazy-initialise params so we don't call resolvers on every render
const [_demonDefaults] = useState(() => resolveDemonParams());
const [_succubusDefaults] = useState(() => resolveSuccubusParams());
return (
<PageRoot>
<PageTitle>Studio</PageTitle>
@ -205,36 +252,59 @@ export function StudioPage(): ReactElement {
<Body>
<ContentArea role="tabpanel">
{activeTab === 'live' ? (
<LiveCameraView
<DisguiseVideoWithFaceSelector
disguise={mode}
blurStrength={blurStrength}
disguiseOptions={disguiseOptions}
identities={identities}
resolveIdentityConfig={resolveIdentityConfig}
defaultSelectAll
showOverlay
showModePicker
resolveIdentityMode={resolveIdentityConfig}
onCapturePortrait={handleCapturePortrait}
onFrameAccounting={handleFrameAccounting}
{...presetsProps}
/>
) : (
<FileVideoView
identities={identities}
resolveIdentityConfig={resolveIdentityConfig}
onCapturePortrait={handleCapturePortrait}
onFrameAccounting={handleFrameAccounting}
onNewFaceTrack={handleNewFaceTrack}
trackIdToIdentityId={trackIdToIdentityId}
{...presetsProps}
/>
<FileTabPlaceholder>
<FileTabTitle>File-based processing</FileTabTitle>
<FileTabBody>
Use the Library tab to submit video files for server-side face disguise processing.
Results appear in the Queue tab when ready.
</FileTabBody>
</FileTabPlaceholder>
)}
</ContentArea>
<IdentityRoster
identities={identities}
identityPresetIds={identityPresetIds}
identityFrameCounts={identityFrameCounts}
presets={presets}
onAdd={handleAddIdentity}
onDelete={handleDeleteIdentity}
onPresetAssign={handleIdentityPresetAssign}
onRename={handleRename}
/>
<ControlsColumn>
<DisguiseControlPanel
mode={mode}
blurStrength={blurStrength}
demonParams={demonParams}
succubusParams={succubusParams}
presets={presets}
currentConfig={currentConfig}
onModeChange={setMode}
onBlurStrengthChange={setBlurStrength}
onDemonChange={setDemonParams}
onSuccubusChange={setSuccubusParams}
onPresetSave={handlePresetSave}
onPresetApply={handlePresetApply}
onPresetRename={handlePresetRename}
onPresetDelete={deletePreset}
generatePresetName={generateName}
/>
<IdentityRoster
identities={identities}
identityPresetIds={identityPresetIds}
identityFrameCounts={identityFrameCounts}
presets={presets}
onAdd={handleAddIdentity}
onDelete={handleDeleteIdentity}
onPresetAssign={handleIdentityPresetAssign}
onRename={handleRename}
/>
</ControlsColumn>
</Body>
</PageRoot>
);