fix(dub): serialize workflow actions

This commit is contained in:
debpalash
2026-08-11 11:50:02 +00:00
parent c88955fb37
commit 7a3367be53
4 changed files with 83 additions and 24 deletions
+12 -8
View File
@@ -36,6 +36,9 @@ export default function DubHeader({
pipelineSteps,
onPipelineStep,
}) {
const workflowActionBusy =
qcRunning || isTranslating || dubStep === 'generating' || dubStep === 'stopping';
return (
<div className="flex flex-col gap-[2px] min-w-0 px-[10px] py-[4px] shrink-0 bg-[var(--color-bg-elev-1)] rounded-md mb-[2px]">
{/* Row 1: project title (left) + actions (right). Row 2: the pipeline
@@ -87,7 +90,7 @@ export default function DubHeader({
sm
tone="stopping"
disabled
className="!flex-none [.shell-mini_&]:flex-1"
className="flex-none! [.shell-mini_&]:flex-1!"
icon={<Loader className="spinner" size={9} aria-hidden="true" />}
label={t('dub.stopping')}
aria-busy="true"
@@ -96,7 +99,7 @@ export default function DubHeader({
<FooterBtn
sm
tone="danger"
className="!flex-none hover:-translate-y-px active:translate-y-0 motion-reduce:transform-none [.shell-mini_&]:flex-1"
className="flex-none! hover:-translate-y-px active:translate-y-0 motion-reduce:transform-none [.shell-mini_&]:flex-1!"
onClick={handleDubStop}
icon={<Square size={9} aria-hidden="true" />}
label={t('dub.stop_progress', {
@@ -109,12 +112,12 @@ export default function DubHeader({
<FooterBtn
sm
tone={dubSegments.length && !isTranslating ? 'pink' : 'idle'}
className="dub-action-btn--generate !h-[30px] !flex-none !px-[11px] !text-[0.68rem] !font-semibold !tracking-[0.015em] enabled:hover:-translate-y-px enabled:active:translate-y-0 motion-reduce:transform-none enabled:shadow-[0_5px_14px_color-mix(in_srgb,var(--chrome-accent)_18%,transparent)] enabled:hover:shadow-[0_7px_18px_color-mix(in_srgb,var(--chrome-accent)_26%,transparent)] [.shell-mini_&]:flex-1"
className="dub-action-btn--generate !h-[30px] flex-none! !px-[11px] !text-[0.68rem] !font-semibold !tracking-[0.015em] enabled:hover:-translate-y-px enabled:active:translate-y-0 motion-reduce:transform-none enabled:shadow-[0_5px_14px_color-mix(in_srgb,var(--chrome-accent)_18%,transparent)] enabled:hover:shadow-[0_7px_18px_color-mix(in_srgb,var(--chrome-accent)_26%,transparent)] [.shell-mini_&]:flex-1!"
onClick={onGenerateClick}
// The multi-language batch translates between generates while
// dubStep briefly sits back at 'editing' keep the CTA inert
// during that phase so a re-click can't start a second batch.
disabled={!dubSegments.length || isTranslating}
disabled={!dubSegments.length || qcRunning || isTranslating}
icon={<Play className="fill-current" size={11} aria-hidden="true" />}
label={
multiLangMode && multiLangs.length > 1
@@ -129,7 +132,8 @@ export default function DubHeader({
<FooterBtn
sm
tone="pink"
className="!h-[30px] !flex-none !px-[10px] enabled:hover:-translate-y-px enabled:active:translate-y-0 motion-reduce:transform-none [.shell-mini_&]:flex-1"
className="!h-[30px] flex-none! !px-[10px] enabled:hover:-translate-y-px enabled:active:translate-y-0 motion-reduce:transform-none [.shell-mini_&]:flex-1!"
disabled={workflowActionBusy}
onClick={() =>
handleDubGenerate({ regenOnly: incrementalPlan.stale, preview: true })
}
@@ -143,7 +147,7 @@ export default function DubHeader({
<FooterBtn
sm
tone="idle"
className="dub-action-btn--verify !h-[30px] !flex-none !px-[9px] enabled:hover:-translate-y-px enabled:active:translate-y-0 motion-reduce:transform-none enabled:hover:shadow-[var(--shadow-sm)] [.shell-mini_&]:flex-1"
className="dub-action-btn--verify !h-[30px] flex-none! !px-[9px] enabled:hover:-translate-y-px enabled:active:translate-y-0 motion-reduce:transform-none enabled:hover:shadow-[var(--shadow-sm)] [.shell-mini_&]:flex-1!"
disabled={qcRunning || !dubSegments.length}
onClick={handleDubQc}
icon={
@@ -162,8 +166,8 @@ export default function DubHeader({
<FooterBtn
sm
tone={dubStep === 'done' ? 'green' : 'idle'}
className="dub-action-btn--export !h-[30px] !flex-none !px-[10px] !font-semibold enabled:hover:-translate-y-px enabled:active:translate-y-0 motion-reduce:transform-none enabled:shadow-[0_5px_14px_color-mix(in_srgb,var(--chrome-severity-ok)_13%,transparent)] enabled:hover:shadow-[0_7px_18px_color-mix(in_srgb,var(--chrome-severity-ok)_20%,transparent)] [.shell-mini_&]:flex-1"
disabled={dubStep !== 'done' && !dubSegments.length}
className="dub-action-btn--export !h-[30px] flex-none! !px-[10px] !font-semibold enabled:hover:-translate-y-px enabled:active:translate-y-0 motion-reduce:transform-none enabled:shadow-[0_5px_14px_color-mix(in_srgb,var(--chrome-severity-ok)_13%,transparent)] enabled:hover:shadow-[0_7px_18px_color-mix(in_srgb,var(--chrome-severity-ok)_20%,transparent)] [.shell-mini_&]:flex-1!"
disabled={workflowActionBusy || (dubStep !== 'done' && !dubSegments.length)}
onClick={() => setExportOpen(true)}
icon={<Download size={12} aria-hidden="true" />}
label={t('dub.export_btn')}
+7 -3
View File
@@ -568,9 +568,13 @@ export default function DubTab(props) {
try {
const lang = previewMode !== 'original' ? previewMode : undefined;
const res = await dubQc(dubJobId, lang);
// A generation that finishes while QC is in flight invalidates these
// measurements. Ignore the stale response instead of attaching timing
// results from the previous audio to the new dub.
if (useAppStore.getState().dubGenNonce !== dubGenNonce) return;
const byId = new Map((res.segments || []).map((q) => [String(q.seg_id), q]));
setDubSegments(
dubSegments.map((s, i) => {
setDubSegments((currentSegments) =>
currentSegments.map((s, i) => {
const q = byId.get(String(s.id ?? i));
if (!q) return s;
return {
@@ -614,7 +618,7 @@ export default function DubTab(props) {
} finally {
setQcRunning(false);
}
}, [dubJobId, qcRunning, previewMode, dubSegments, setDubSegments, t]);
}, [dubJobId, qcRunning, previewMode, dubGenNonce, setDubSegments, t]);
return (
<div className="flex-1 flex flex-col min-h-0">
+3 -1
View File
@@ -72,7 +72,7 @@ describe('DubHeader — polished workflow actions', () => {
const group = screen.getByTestId('dub-primary-actions');
expect(group).toHaveClass('flex-wrap', '[.shell-mini_&]:w-full');
for (const button of within(group).getAllByRole('button')) {
expect(button).toHaveClass('[.shell-mini_&]:flex-1', 'motion-reduce:transform-none');
expect(button).toHaveClass('[.shell-mini_&]:flex-1!', 'motion-reduce:transform-none');
}
});
@@ -81,6 +81,8 @@ describe('DubHeader — polished workflow actions', () => {
const verify = screen.getByRole('button', { name: t('dub.qc_btn') });
expect(verify).toBeDisabled();
expect(verify).toHaveAttribute('aria-busy', 'true');
expect(screen.getByRole('button', { name: t('dub.generate_dub') })).toBeDisabled();
expect(screen.getByRole('button', { name: t('dub.export_btn') })).toBeDisabled();
rerender(
<DubHeader {...makeProps({ dubStep: 'editing', dubSegments: [], incrementalPlan: null })} />,
+61 -12
View File
@@ -16,7 +16,7 @@ import { useAppStore } from '../store';
// Heavy children are stubbed; DubLeftColumn is the probe — DubTab owns both
// `hasDubbedTrack` and the previewMode auto-jump, and hands them down as props.
const captured = vi.hoisted(() => ({ left: [] }));
const captured = vi.hoisted(() => ({ left: [], header: [] }));
vi.mock('../components/dub/DubLeftColumn', () => ({
default: (props) => {
captured.left.push(props);
@@ -24,18 +24,22 @@ vi.mock('../components/dub/DubLeftColumn', () => ({
},
}));
vi.mock('../components/dub/DubHeader', () => ({
default: ({ resetDub, pipelineSteps = [], onPipelineStep }) => (
<div>
<button data-testid="reset-dub" onClick={resetDub}>
reset
</button>
{pipelineSteps.map((step) => (
<button key={step} onClick={() => onPipelineStep(step)}>
{step}
default: (props) => {
captured.header.push(props);
const { resetDub, pipelineSteps = [], onPipelineStep } = props;
return (
<div>
<button data-testid="reset-dub" onClick={resetDub}>
reset
</button>
))}
</div>
),
{pipelineSteps.map((step) => (
<button key={step} onClick={() => onPipelineStep(step)}>
{step}
</button>
))}
</div>
);
},
}));
vi.mock('../components/dub/DubRightColumn', () => ({ default: () => null }));
vi.mock('../components/dub/DubFooter', () => ({ default: () => null }));
@@ -80,6 +84,7 @@ vi.mock('../api/client', async (importOriginal) => {
});
import DubTab from '../pages/DubTab';
import { dubQc } from '../api/dub';
const noop = () => {};
function makeProps() {
@@ -151,6 +156,8 @@ describe('DubTab — completed tracks always show their tabs (restore P0)', () =
beforeEach(() => {
useAppStore.setState(baseState, true);
captured.left.length = 0;
captured.header.length = 0;
vi.mocked(dubQc).mockReset();
});
it("restored project (tracks ['bn'], language_code frozen at 'und'): switcher shows and preview jumps to the track", () => {
@@ -239,4 +246,46 @@ describe('DubTab — completed tracks always show their tabs (restore P0)', () =
fireEvent.click(screen.getByRole('button', { name: 'transcribe' }));
expect(retry).toHaveBeenCalledTimes(3);
});
it('discards QC results when a newer generation finishes while verification is in flight', async () => {
let resolveQc;
vi.mocked(dubQc).mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveQc = resolve;
}),
);
useAppStore.setState({
dubJobId: 'job1',
dubStep: 'done',
dubTracks: ['es'],
dubLangCode: 'es',
dubGenNonce: 1,
dubSegments: [{ id: 's1', text: 'Old audio' }],
});
render(<DubTab {...makeProps()} />);
let qcPromise;
act(() => {
qcPromise = captured.header.at(-1).handleDubQc();
});
expect(dubQc).toHaveBeenCalledWith('job1', 'es');
act(() => {
useAppStore.setState({
dubGenNonce: 2,
dubSegments: [{ id: 's1', text: 'New audio' }],
});
});
await act(async () => {
resolveQc({
segments: [{ seg_id: 's1', drift: 0.8, flagged: true, recognized_text: 'Old' }],
flagged_count: 1,
total: 1,
});
await qcPromise;
});
expect(useAppStore.getState().dubSegments).toEqual([{ id: 's1', text: 'New audio' }]);
});
});