Kaynağa Gözat

File Manager: queue several selected files as separate jobs (#3462) (#3471)

Eifel-Joe 1 gün önce
ebeveyn
işleme
1f6b709075
29 değiştirilmiş dosya ile 2460 ekleme ve 112 silme
  1. 1 0
      frontend/scripts/check-i18n-parity.mjs
  2. 85 0
      frontend/src/__tests__/components/MultiFileModeToggle.test.tsx
  3. 77 0
      frontend/src/__tests__/components/PrintModalMultiFileRules.test.ts
  4. 785 0
      frontend/src/__tests__/components/PrintModalSeparateJobs.test.tsx
  5. 266 0
      frontend/src/__tests__/components/PrintModalSeparateJobsQueue.test.ts
  6. 167 1
      frontend/src/__tests__/pages/FileManagerPage.test.tsx
  7. 66 0
      frontend/src/components/PrintModal/MultiFileModeToggle.tsx
  8. 45 16
      frontend/src/components/PrintModal/VariantCandidates.tsx
  9. 428 68
      frontend/src/components/PrintModal/index.tsx
  10. 55 0
      frontend/src/components/PrintModal/multiFile.ts
  11. 185 0
      frontend/src/components/PrintModal/separateJobs.ts
  12. 31 5
      frontend/src/components/PrintModal/types.ts
  13. 13 1
      frontend/src/i18n/locales/de.ts
  14. 13 1
      frontend/src/i18n/locales/en.ts
  15. 13 1
      frontend/src/i18n/locales/es.ts
  16. 13 1
      frontend/src/i18n/locales/fr.ts
  17. 13 1
      frontend/src/i18n/locales/it.ts
  18. 13 1
      frontend/src/i18n/locales/ja.ts
  19. 13 1
      frontend/src/i18n/locales/ko.ts
  20. 13 1
      frontend/src/i18n/locales/nl.ts
  21. 13 1
      frontend/src/i18n/locales/pl.ts
  22. 13 1
      frontend/src/i18n/locales/pt-BR.ts
  23. 13 1
      frontend/src/i18n/locales/ru.ts
  24. 13 1
      frontend/src/i18n/locales/sv.ts
  25. 13 1
      frontend/src/i18n/locales/tr.ts
  26. 13 1
      frontend/src/i18n/locales/uk.ts
  27. 13 1
      frontend/src/i18n/locales/zh-CN.ts
  28. 13 1
      frontend/src/i18n/locales/zh-TW.ts
  29. 61 6
      frontend/src/pages/FileManagerPage.tsx

+ 1 - 0
frontend/scripts/check-i18n-parity.mjs

@@ -230,6 +230,7 @@ const FR_COGNATES = [
   'Photos', '{{count}} photo', '{{count}} photos',  // file details photo strip (#3077) — same word in French
   'Version 2',  // stream overlay artwork picker (#3177) — same word in French
   'Page {{page}}',  // Manyfold model list pager (#1471) — "page" is the same word in French
+  'Alternatives',  // #3462 multi-file print mode label — same word in French
 ];
 
 // Italian cognates.

+ 85 - 0
frontend/src/__tests__/components/MultiFileModeToggle.test.tsx

@@ -0,0 +1,85 @@
+/** Mode toggle for several selected sliced files (#3462) and the retitled candidate list. */
+
+import { describe, it, expect, vi } from 'vitest';
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { MultiFileModeToggle } from '../../components/PrintModal/MultiFileModeToggle';
+import { VariantCandidates } from '../../components/PrintModal/VariantCandidates';
+
+describe('MultiFileModeToggle', () => {
+  it('renders a named radiogroup with two radios following value', () => {
+    render(<MultiFileModeToggle value="separate" onChange={() => {}} blockedModel={null} />);
+    expect(screen.getByRole('radiogroup', { name: 'What the selected files become' })).toBeInTheDocument();
+    expect(screen.getByRole('radio', { name: 'Alternatives' })).toHaveAttribute('aria-checked', 'false');
+    expect(screen.getByRole('radio', { name: 'Separate jobs' })).toHaveAttribute('aria-checked', 'true');
+  });
+
+  it('calls onChange("separate") when Separate jobs is clicked', async () => {
+    const onChange = vi.fn();
+    render(<MultiFileModeToggle value="alternatives" onChange={onChange} blockedModel={null} />);
+    await userEvent.click(screen.getByRole('radio', { name: 'Separate jobs' }));
+    expect(onChange).toHaveBeenCalledWith('separate');
+  });
+
+  it('disables Alternatives and shows the blocked text when blockedModel is set', async () => {
+    const onChange = vi.fn();
+    render(<MultiFileModeToggle value="separate" onChange={onChange} blockedModel="A1" />);
+    const alt = screen.getByRole('radio', { name: 'Alternatives' });
+    expect(alt).toBeDisabled();
+    expect(screen.getByText(/More than one file is sliced for A1\./)).toBeInTheDocument();
+    expect(screen.queryByText(/One queue item per file/)).not.toBeInTheDocument();
+    await userEvent.click(alt);
+    expect(onChange).not.toHaveBeenCalled();
+  });
+
+  it('offers neither mode while disabled', async () => {
+    // The dialog disables it while the files are being queued.
+    const onChange = vi.fn();
+    render(<MultiFileModeToggle value="separate" onChange={onChange} blockedModel={null} disabled />);
+    const alt = screen.getByRole('radio', { name: 'Alternatives' });
+    expect(alt).toBeDisabled();
+    expect(screen.getByRole('radio', { name: 'Separate jobs' })).toBeDisabled();
+    await userEvent.click(alt);
+    expect(onChange).not.toHaveBeenCalled();
+    // The hint stays: it still says what is being queued.
+    expect(screen.getByText(/One queue item per file/)).toBeInTheDocument();
+  });
+
+  it('shows the hint of the selected mode when not blocked', () => {
+    const { rerender } = render(<MultiFileModeToggle value="alternatives" onChange={() => {}} blockedModel={null} />);
+    expect(screen.getByText(/The first matching printer to free up/)).toBeInTheDocument();
+    rerender(<MultiFileModeToggle value="separate" onChange={() => {}} blockedModel={null} />);
+    expect(screen.getByText(/One queue item per file/)).toBeInTheDocument();
+  });
+});
+
+describe('VariantCandidates title/help props', () => {
+  const props = {
+    candidates: [{ id: 1, filename: 'a.gcode.3mf', sliced_for_model: 'X1C' }],
+    onReorder: () => {},
+    plateByFile: {},
+    onPlateChange: () => {},
+  };
+
+  it('shows a custom title and help instead of the defaults', () => {
+    render(<VariantCandidates {...props} title="Queue order" help="Custom help line" />);
+    expect(screen.getByText('Queue order')).toBeInTheDocument();
+    expect(screen.getByText('Custom help line')).toBeInTheDocument();
+    expect(screen.queryByText('Printer alternatives')).not.toBeInTheDocument();
+  });
+
+  it('renders no help paragraph for an empty help', () => {
+    // The mode toggle above the list explains the mode; a second line here
+    // would repeat it.
+    render(<VariantCandidates {...props} help="" />);
+    expect(screen.queryByText(/One job, one queue slot/)).not.toBeInTheDocument();
+    const section = screen.getByText('Printer alternatives').parentElement!.parentElement!;
+    expect(section.querySelector('p')).toBeNull();
+  });
+
+  it('keeps the read-only note when help is empty', () => {
+    render(<VariantCandidates {...props} readOnly readOnlyNote="Set when queued" help="" />);
+    expect(screen.getByText('Set when queued')).toBeInTheDocument();
+  });
+});

+ 77 - 0
frontend/src/__tests__/components/PrintModalMultiFileRules.test.ts

@@ -0,0 +1,77 @@
+/**
+ * Pure rules for choosing between "Alternatives" and "Separate jobs" when
+ * several sliced files are selected (#3462).
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+  commonModel,
+  defaultMultiFileMode,
+  duplicateModel,
+  filesIncompatibleWith,
+  filesNotSlicedFor,
+} from '../../components/PrintModal/multiFile';
+import type { VariantCandidate } from '../../components/PrintModal/VariantCandidates';
+
+const f = (id: number, model: string | null): VariantCandidate => ({
+  id,
+  filename: `file${id}.gcode.3mf`,
+  sliced_for_model: model,
+});
+
+describe('duplicateModel', () => {
+  it('returns the spelling of the first occurrence, ignoring case', () => {
+    expect(duplicateModel([f(1, 'A1'), f(2, 'a1'), f(3, 'P1S')])).toBe('A1');
+  });
+  it('returns null when all models differ', () => {
+    expect(duplicateModel([f(1, 'A1'), f(2, 'P1S')])).toBeNull();
+  });
+  it('never counts null models as duplicates', () => {
+    expect(duplicateModel([f(1, null), f(2, null)])).toBeNull();
+  });
+});
+
+describe('defaultMultiFileMode', () => {
+  it('is separate when two files share a model', () => {
+    expect(defaultMultiFileMode([f(1, 'A1'), f(2, 'A1')])).toBe('separate');
+    expect(defaultMultiFileMode([f(1, 'A1'), f(2, 'A1'), f(3, 'X1C')])).toBe('separate');
+  });
+  it('is separate when a model is unknown', () => {
+    expect(defaultMultiFileMode([f(1, 'A1'), f(2, null)])).toBe('separate');
+  });
+  it('is alternatives when every file has a distinct known model', () => {
+    expect(defaultMultiFileMode([f(1, 'A1'), f(2, 'P1S')])).toBe('alternatives');
+  });
+});
+
+describe('commonModel', () => {
+  it('returns the shared model, ignoring case', () => {
+    expect(commonModel([f(1, 'A1'), f(2, 'a1')])).toBe('A1');
+  });
+  it('returns null for differing models', () => {
+    expect(commonModel([f(1, 'A1'), f(2, 'P1S')])).toBeNull();
+  });
+  it('returns null when a model is unknown', () => {
+    expect(commonModel([f(1, 'A1'), f(2, null)])).toBeNull();
+  });
+});
+
+describe('filesIncompatibleWith', () => {
+  const files = [f(1, 'A1'), f(2, 'P1S'), f(3, null)];
+  it('returns only the files sliced for another model', () => {
+    expect(filesIncompatibleWith(files, 'A1').map((x) => x.id)).toEqual([2]);
+  });
+  it('returns nothing without a target model', () => {
+    expect(filesIncompatibleWith(files, null)).toEqual([]);
+  });
+});
+
+describe('filesNotSlicedFor', () => {
+  const files = [f(1, 'A1'), f(2, 'P1S'), f(3, null)];
+  it('returns files whose model differs exactly from the printer model', () => {
+    expect(filesNotSlicedFor(files, 'A1').map((x) => x.id)).toEqual([2]);
+  });
+  it('returns nothing without a printer model', () => {
+    expect(filesNotSlicedFor(files, null)).toEqual([]);
+  });
+});

+ 785 - 0
frontend/src/__tests__/components/PrintModalSeparateJobs.test.tsx

@@ -0,0 +1,785 @@
+/**
+ * PrintModal with several sliced files marked in the File Manager (#3462).
+ *
+ * The selection used to always become printer alternatives (#671): one job, the
+ * first free printer of a matching model prints its file. Two files for the same
+ * model could therefore not be queued at all ("variants must target different
+ * printers"). The dialog now asks what the selection is: alternatives, or one
+ * queue item per file in list order, with the ordinary printer picker and
+ * options applying to every item.
+ */
+
+import type { ComponentProps } from 'react';
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { fireEvent, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { delay, http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { PrintModal } from '../../components/PrintModal';
+import type { VariantCandidate } from '../../components/PrintModal/VariantCandidates';
+import { DEFAULT_PRINT_OPTIONS, DEFAULT_SCHEDULE_OPTIONS } from '../../components/PrintModal/types';
+
+const PART_A: VariantCandidate = { id: 11, filename: 'part-a.gcode.3mf', sliced_for_model: 'A1' };
+const PART_B: VariantCandidate = { id: 12, filename: 'part-b.gcode.3mf', sliced_for_model: 'A1' };
+const PART_C: VariantCandidate = { id: 13, filename: 'part-c.gcode.3mf', sliced_for_model: 'P1S' };
+// No sliced-for model, so the printer picker lists every printer.
+const PART_D: VariantCandidate = { id: 14, filename: 'part-d.gcode.3mf', sliced_for_model: null };
+const BY_ID: Record<number, VariantCandidate> = { 11: PART_A, 12: PART_B, 13: PART_C, 14: PART_D };
+
+type Body = Record<string, unknown>;
+
+let posted: Body[];
+/** Answers each POST /queue/ by its position; the default accepts it. */
+let respond: (body: Body, index: number) => Response | Promise<Response>;
+
+function mockBackend(printers: Array<Record<string, unknown>> = [
+  { id: 1, name: 'A1 Test', model: 'A1', ip_address: '192.0.2.1', is_active: true, enabled: true },
+  { id: 2, name: 'P1S Test', model: 'P1S', ip_address: '192.0.2.2', is_active: true, enabled: true },
+]) {
+  server.use(
+    http.get('/api/v1/printers/', () => HttpResponse.json(printers)),
+    http.get('/api/v1/printers/:id/status', () =>
+      HttpResponse.json({ connected: true, state: 'IDLE', ams: [], vt_tray: [] }),
+    ),
+    http.get('/api/v1/printers/available-filaments', () =>
+      HttpResponse.json([
+        { type: 'PLA', color: '#FFFFFF', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic', extruder_id: null },
+      ]),
+    ),
+    http.get('/api/v1/library/files/:id', ({ params }) => {
+      const file = BY_ID[Number(params.id)];
+      return HttpResponse.json({
+        id: file.id,
+        filename: file.filename,
+        file_type: 'gcode.3mf',
+        sliced_for_model: file.sliced_for_model,
+      });
+    }),
+    http.get('/api/v1/library/files/:id/plates', ({ params }) =>
+      HttpResponse.json({ file_id: Number(params.id), filename: 'x', plates: [], is_multi_plate: false }),
+    ),
+    http.get('/api/v1/library/files/:id/filament-requirements', () =>
+      HttpResponse.json({
+        filaments: [{ slot_id: 1, type: 'PLA', color: '#FFFFFF', used_grams: 15, used_meters: 5 }],
+      }),
+    ),
+    http.post('/api/v1/queue/', async ({ request }) => {
+      const body = (await request.json()) as Body;
+      posted.push(body);
+      return respond(body, posted.length - 1);
+    }),
+  );
+}
+
+function renderModal(
+  files: VariantCandidate[],
+  props: Pick<
+    ComponentProps<typeof PrintModal>,
+    'separateJobsAllowed' | 'onClose' | 'onSuccess' | 'resumeSeparateJobs' | 'onSeparateJobsProgress'
+  > = {},
+) {
+  render(
+    <PrintModal
+      mode="create"
+      libraryFileId={files[0].id}
+      variantFiles={files}
+      archiveName="parts"
+      {...props}
+      onClose={props.onClose ?? (() => {})}
+    />,
+  );
+}
+
+const printButton = () => screen.getByRole('button', { name: /^Print$/i });
+
+/** Picks a printer card and waits until its status is in and Print is enabled. */
+async function pickPrinterAndWait(user: ReturnType<typeof userEvent.setup>, name: string) {
+  await user.click(await screen.findByText(name));
+  await waitFor(() => expect(printButton()).toBeEnabled());
+}
+
+const fileIds = (bodies: Body[]) => bodies.map((b) => b.library_file_id);
+
+const ALTERNATIVES_LIST_HELP = 'One job, one queue slot. The first matching printer to free up runs its file.';
+const ALTERNATIVES_HINT = 'One job. The first matching printer to free up prints its file.';
+const SEPARATE_HINT = /One queue item per file/;
+
+describe('PrintModal with several selected files (#3462)', () => {
+  beforeEach(() => {
+    posted = [];
+    respond = () => HttpResponse.json({ id: 1, status: 'pending' });
+    mockBackend();
+  });
+
+  it('keeps a version group as alternatives, without the toggle', async () => {
+    renderModal([PART_A, PART_C]);
+    expect(await screen.findByText('part-c.gcode.3mf')).toBeInTheDocument();
+    expect(screen.getByText('Printer alternatives')).toBeInTheDocument();
+    expect(screen.getByText(ALTERNATIVES_LIST_HELP)).toBeInTheDocument();
+    expect(screen.queryByRole('radiogroup')).not.toBeInTheDocument();
+  });
+
+  it('explains each mode once, under the toggle', async () => {
+    const user = userEvent.setup();
+    renderModal([PART_A, PART_C], { separateJobsAllowed: true });
+
+    await screen.findByRole('radio', { name: 'Alternatives' });
+    expect(screen.getAllByText(ALTERNATIVES_HINT)).toHaveLength(1);
+    expect(screen.queryByText(ALTERNATIVES_LIST_HELP)).not.toBeInTheDocument();
+
+    await user.click(screen.getByRole('radio', { name: 'Separate jobs' }));
+    expect(screen.getAllByText(SEPARATE_HINT)).toHaveLength(1);
+    expect(screen.queryByText(ALTERNATIVES_HINT)).not.toBeInTheDocument();
+  });
+
+  it('starts on separate jobs for two files of one model and shows the printer picker', async () => {
+    const user = userEvent.setup();
+    renderModal([PART_A, PART_B], { separateJobsAllowed: true });
+
+    expect(await screen.findByRole('radio', { name: 'Separate jobs' })).toHaveAttribute('aria-checked', 'true');
+    expect(screen.getByRole('radio', { name: 'Alternatives' })).toBeDisabled();
+    expect(screen.getByText('Queue order')).toBeInTheDocument();
+    // The reason Alternatives is blocked is the one explanation shown.
+    expect(screen.getAllByText(/More than one file is sliced for A1\./)).toHaveLength(1);
+    expect(screen.queryByText(SEPARATE_HINT)).not.toBeInTheDocument();
+
+    // A selected printer would bring up the first file's tray mapping; it is
+    // not shown, because each file is mapped against its own 3MF at dispatch.
+    await pickPrinterAndWait(user, 'A1 Test');
+    expect(screen.queryByText(/Filament Mapping/)).not.toBeInTheDocument();
+
+    // Same for the override panel of "Any A1".
+    await user.click(screen.getByRole('button', { name: /Any A1/ }));
+    await new Promise((resolve) => setTimeout(resolve, 200));
+    expect(screen.queryByText(/Filament Override/)).not.toBeInTheDocument();
+  });
+
+  it('starts on alternatives for files of different models, without a printer picker', async () => {
+    renderModal([PART_A, PART_C], { separateJobsAllowed: true });
+
+    expect(await screen.findByRole('radio', { name: 'Alternatives' })).toHaveAttribute('aria-checked', 'true');
+    expect(screen.getByText('Printer alternatives')).toBeInTheDocument();
+    expect(screen.queryByText('A1 Test')).not.toBeInTheDocument();
+    expect(screen.queryByText('Specific Printer')).not.toBeInTheDocument();
+  });
+
+  it('queues one item per file in list order on the chosen printer', async () => {
+    const user = userEvent.setup();
+    renderModal([PART_A, PART_B], { separateJobsAllowed: true });
+
+    await pickPrinterAndWait(user, 'A1 Test');
+    await user.click(printButton());
+
+    await waitFor(() => expect(posted).toHaveLength(2));
+    expect(fileIds(posted)).toEqual([11, 12]);
+    for (const body of posted) {
+      expect(body.printer_id).toBe(1);
+      expect(body).not.toHaveProperty('variants');
+      // Each file's own 3MF decides its trays at dispatch.
+      expect(body.ams_mapping).toBeUndefined();
+      expect(body.filament_overrides).toBeUndefined();
+      // Nothing that belongs to the first file alone, nor a batch tying the
+      // files together: the server prices and batches each file on its own.
+      expect(body).not.toHaveProperty('estimated_cost');
+      expect(body).not.toHaveProperty('skip_filament_check');
+      expect(body).not.toHaveProperty('nozzle_rack_choice');
+      expect(body).not.toHaveProperty('batch_id');
+    }
+  });
+
+  it('counts the items of every listed file in the quantity hint', async () => {
+    // Each file is posted with the quantity, so 2 files x 2 make 4 items.
+    renderModal([PART_A, PART_B], { separateJobsAllowed: true });
+
+    await screen.findByRole('radio', { name: 'Separate jobs' });
+    fireEvent.change(screen.getByLabelText('Quantity'), { target: { value: '2' } });
+
+    expect(await screen.findByText('Creates 4 queue items')).toBeInTheDocument();
+  });
+
+  it('warns per printer model about the files sliced for another model', async () => {
+    // Every selected printer gets every file, so each printer's model is
+    // checked against all of them. A warning, not a block, as for one file.
+    const user = userEvent.setup();
+    renderModal([PART_A, PART_C], { separateJobsAllowed: true });
+
+    await user.click(await screen.findByRole('radio', { name: 'Separate jobs' }));
+    await pickPrinterAndWait(user, 'A1 Test');
+    expect(screen.getByText('Not sliced for A1: part-c.gcode.3mf')).toBeInTheDocument();
+    expect(screen.queryByText(/Not sliced for P1S/)).not.toBeInTheDocument();
+
+    await user.click(screen.getByText('P1S Test'));
+    expect(await screen.findByText('Not sliced for P1S: part-a.gcode.3mf')).toBeInTheDocument();
+    expect(screen.getByText('Not sliced for A1: part-c.gcode.3mf')).toBeInTheDocument();
+    expect(printButton()).toBeEnabled();
+  });
+
+  describe('while the printer status is still loading', () => {
+    beforeEach(() => {
+      server.use(
+        http.get('/api/v1/printers/:id/status', async () => {
+          await delay('infinite');
+          return HttpResponse.json({});
+        }),
+      );
+    });
+
+    it('lets separate jobs be queued, which send no AMS mapping to wait for', async () => {
+      const user = userEvent.setup();
+      renderModal([PART_A, PART_B], { separateJobsAllowed: true });
+
+      await user.click(await screen.findByText('A1 Test'));
+
+      await waitFor(() => expect(printButton()).toBeEnabled());
+      expect(screen.queryByText(/Waiting for AMS status/)).not.toBeInTheDocument();
+    });
+
+    it('still holds a single file until the AMS status is in (#2589)', async () => {
+      const user = userEvent.setup();
+      renderModal([PART_A]);
+
+      await user.click(await screen.findByText('A1 Test'));
+
+      expect(await screen.findByText('Waiting for AMS status from A1 Test…')).toBeInTheDocument();
+      expect(printButton()).toBeDisabled();
+    });
+  });
+
+  it('keeps the mode while the files are being queued', async () => {
+    // A switch mid-run could leave the rest of a partly queued run as
+    // alternatives, with the toggle already gone.
+    respond = () => new Promise<Response>(() => {});
+    const user = userEvent.setup();
+    renderModal([PART_A, PART_C], { separateJobsAllowed: true });
+
+    await user.click(await screen.findByRole('radio', { name: 'Separate jobs' }));
+    await pickPrinterAndWait(user, 'A1 Test');
+    await user.click(printButton());
+
+    await waitFor(() => expect(posted).toHaveLength(1));
+    expect(screen.getByRole('radio', { name: 'Alternatives' })).toBeDisabled();
+    expect(screen.getByRole('radio', { name: 'Separate jobs' })).toBeDisabled();
+  });
+
+  it('treats a file queued on one of two printers as partly queued', async () => {
+    // part-a goes onto the A1 and is refused by the P1S: one item is in the
+    // queue, although no file went through on every printer.
+    respond = (_body, index) =>
+      index === 0
+        ? HttpResponse.json({ id: 1, status: 'pending' })
+        : HttpResponse.json({ detail: 'refused' }, { status: 400 });
+    const user = userEvent.setup();
+    renderModal([PART_A, PART_C], { separateJobsAllowed: true });
+
+    await user.click(await screen.findByRole('radio', { name: 'Separate jobs' }));
+    await user.click(await screen.findByText('A1 Test'));
+    await pickPrinterAndWait(user, 'P1S Test');
+    await user.click(printButton());
+
+    const message = await screen.findByText(/Not queued: part-a\.gcode\.3mf \(refused\)/);
+    expect(posted).toHaveLength(2);
+    expect(message).toHaveTextContent('Queued before it: part-a.gcode.3mf.');
+    expect(message).not.toHaveTextContent('None');
+    await waitFor(() =>
+      expect(screen.queryByRole('radiogroup', { name: 'What the selected files become' })).not.toBeInTheDocument(),
+    );
+
+    // A second Print sends part-a only to the printer that refused it: the A1
+    // already has it, and queuing it there again would print it twice.
+    respond = () => HttpResponse.json({ id: 2, status: 'pending' });
+    await user.click(printButton());
+    await waitFor(() => expect(posted).toHaveLength(5));
+    expect(posted.slice(2).map((b) => [b.library_file_id, b.printer_id])).toEqual([
+      [11, 2],
+      [13, 1],
+      [13, 2],
+    ]);
+  });
+
+  it('follows the reordered list', async () => {
+    const user = userEvent.setup();
+    renderModal([PART_A, PART_B], { separateJobsAllowed: true });
+
+    await pickPrinterAndWait(user, 'A1 Test');
+    await user.click(screen.getAllByRole('button', { name: 'Move up' })[1]);
+    await user.click(printButton());
+
+    await waitFor(() => expect(posted).toHaveLength(2));
+    expect(fileIds(posted)).toEqual([12, 11]);
+  });
+
+  describe('with a multi-plate file first', () => {
+    beforeEach(() => {
+      server.use(
+        http.get('/api/v1/library/files/:id/plates', ({ params }) =>
+          Number(params.id) === 11
+            ? HttpResponse.json({
+                file_id: 11,
+                filename: 'part-a.gcode.3mf',
+                is_multi_plate: true,
+                plates: [1, 2].map((index) => ({
+                  index,
+                  name: `Plate ${index}`,
+                  objects: ['part'],
+                  filaments: [{ slot_id: 1, type: 'PLA', color: '#FFFFFF' }],
+                  has_thumbnail: false,
+                  thumbnail_url: null,
+                  bed_type: null,
+                  print_time_seconds: 600,
+                })),
+              })
+            : HttpResponse.json({ file_id: Number(params.id), filename: 'x', plates: [], is_multi_plate: false }),
+        ),
+      );
+    });
+
+    it('queues the plate chosen in the list', async () => {
+      const user = userEvent.setup();
+      renderModal([PART_A, PART_B], { separateJobsAllowed: true });
+
+      await user.selectOptions(await screen.findByLabelText('Plate for part-a.gcode.3mf'), '2');
+      await pickPrinterAndWait(user, 'A1 Test');
+      // The first file's plate summary belongs to a single-file print.
+      expect(screen.queryByTestId('selected-plates-summary')).not.toBeInTheDocument();
+      await user.click(printButton());
+
+      await waitFor(() => expect(posted).toHaveLength(2));
+      expect(posted[0]).toMatchObject({ library_file_id: 11, plate_id: 2 });
+      expect(posted[1]).toMatchObject({ library_file_id: 12, plate_id: null });
+    });
+
+    it('keeps the global quantity and sends it with every item', async () => {
+      // The first file's per-plate steppers would own the number (#342) and
+      // hide this field, but nothing would send them: the same trap as #3101.
+      const user = userEvent.setup();
+      renderModal([PART_A, PART_B], { separateJobsAllowed: true });
+
+      await screen.findByLabelText('Plate for part-a.gcode.3mf');
+      await pickPrinterAndWait(user, 'A1 Test');
+      fireEvent.change(screen.getByLabelText('Quantity'), { target: { value: '3' } });
+      await user.click(printButton());
+
+      await waitFor(() => expect(posted).toHaveLength(2));
+      expect(posted.map((b) => b.quantity)).toEqual([3, 3]);
+    });
+  });
+
+  it('blocks "any A1" while a listed file cannot run on an A1', async () => {
+    const user = userEvent.setup();
+    renderModal([PART_A, PART_C], { separateJobsAllowed: true });
+
+    await user.click(await screen.findByRole('radio', { name: 'Separate jobs' }));
+    await user.click(screen.getByRole('button', { name: /Any Model/ }));
+    await user.selectOptions(screen.getByDisplayValue('Select a model...'), 'A1');
+
+    expect(await screen.findByText('Cannot be dispatched to A1 printers: part-c.gcode.3mf')).toBeInTheDocument();
+    expect(printButton()).toBeDisabled();
+  });
+
+  it('switching to separate jobs and back queues the alternatives job unchanged', async () => {
+    const user = userEvent.setup();
+    renderModal([PART_A, PART_C], { separateJobsAllowed: true });
+
+    await user.click(await screen.findByRole('radio', { name: 'Separate jobs' }));
+    await pickPrinterAndWait(user, 'A1 Test');
+    await user.click(screen.getByRole('radio', { name: 'Alternatives' }));
+
+    // The printer chosen for separate jobs is gone with its picker.
+    expect(screen.queryByText('A1 Test')).not.toBeInTheDocument();
+    await waitFor(() => expect(printButton()).toBeEnabled());
+    expect(screen.queryByText(/Filament Mapping/)).not.toBeInTheDocument();
+    await user.click(printButton());
+
+    await waitFor(() => expect(posted).toHaveLength(1));
+    await new Promise((resolve) => setTimeout(resolve, 100));
+    expect(posted).toHaveLength(1);
+    expect(posted[0].variants).toHaveLength(2);
+    expect(posted[0]).not.toHaveProperty('printer_id');
+    expect(posted[0]).not.toHaveProperty('target_model');
+  });
+
+  it('does not carry a location picked for separate jobs into the alternatives', async () => {
+    // The alternatives job always sends target_location, and no control in
+    // that mode shows it, so a leftover would narrow it out of sight.
+    server.use(
+      http.get('/api/v1/printers/', () =>
+        HttpResponse.json([
+          { id: 1, name: 'A1 Test', model: 'A1', location: 'Shop', ip_address: '192.0.2.1', is_active: true, enabled: true },
+          { id: 2, name: 'P1S Test', model: 'P1S', ip_address: '192.0.2.2', is_active: true, enabled: true },
+        ]),
+      ),
+    );
+    const user = userEvent.setup();
+    renderModal([PART_A, PART_C], { separateJobsAllowed: true });
+
+    await user.click(await screen.findByRole('radio', { name: 'Separate jobs' }));
+    await user.click(screen.getByRole('button', { name: /Any Model/ }));
+    await user.selectOptions(screen.getByDisplayValue('Select a model...'), 'A1');
+    await user.selectOptions(await screen.findByDisplayValue('Any location'), 'Shop');
+    await user.click(screen.getByRole('radio', { name: 'Alternatives' }));
+
+    await waitFor(() => expect(printButton()).toBeEnabled());
+    await user.click(printButton());
+
+    await waitFor(() => expect(posted).toHaveLength(1));
+    expect(posted[0].variants).toHaveLength(2);
+    expect(posted[0].target_location).toBeNull();
+  });
+
+  it('stops at a refused file, drops what was queued, and continues on resubmit', async () => {
+    const onClose = vi.fn();
+    respond = (_body, index) =>
+      index === 1
+        ? HttpResponse.json({ detail: 'refused' }, { status: 400 })
+        : HttpResponse.json({ id: index + 1, status: 'pending' });
+    const user = userEvent.setup();
+    renderModal([PART_A, PART_B], { separateJobsAllowed: true, onClose });
+
+    await pickPrinterAndWait(user, 'A1 Test');
+    await user.click(printButton());
+
+    expect(await screen.findByText(/Not queued: part-b\.gcode\.3mf \(refused\)/)).toBeInTheDocument();
+    expect(fileIds(posted)).toEqual([11, 12]);
+    await waitFor(() => expect(screen.queryByText('part-a.gcode.3mf')).not.toBeInTheDocument());
+    expect(screen.getByText('part-b.gcode.3mf')).toBeInTheDocument();
+    expect(onClose).not.toHaveBeenCalled();
+    // What is left is the rest of these separate jobs, not a new choice: with
+    // one file left, "Alternatives" would post a job with a single candidate.
+    expect(screen.queryByRole('radiogroup', { name: 'What the selected files become' })).not.toBeInTheDocument();
+    // With the toggle gone, the list carries the explanation, once.
+    expect(screen.getAllByText(SEPARATE_HINT)).toHaveLength(1);
+
+    await waitFor(() => expect(printButton()).toBeEnabled());
+    await user.click(printButton());
+
+    await waitFor(() => expect(posted).toHaveLength(3));
+    expect(fileIds(posted.slice(2))).toEqual([12]);
+    expect(posted[2]).not.toHaveProperty('variants');
+    expect(posted[2].printer_id).toBe(1);
+    await waitFor(() => expect(onClose).toHaveBeenCalled());
+  });
+
+  it('checks the model of the files still listed after the first one was queued', async () => {
+    // The dialog's own file (the first one) is queued and gone; "Any P1S" for
+    // the P1S file that is left must not be judged by the A1 file's model.
+    respond = (_body, index) =>
+      index === 0
+        ? HttpResponse.json({ id: 1, status: 'pending' })
+        : HttpResponse.json({ detail: 'refused' }, { status: 400 });
+    const user = userEvent.setup();
+    renderModal([PART_A, PART_C], { separateJobsAllowed: true });
+
+    await user.click(await screen.findByRole('radio', { name: 'Separate jobs' }));
+    await pickPrinterAndWait(user, 'P1S Test');
+    await user.click(printButton());
+    await screen.findByText(/Not queued: part-c\.gcode\.3mf/);
+    await waitFor(() => expect(screen.queryByText('part-a.gcode.3mf')).not.toBeInTheDocument());
+
+    respond = () => HttpResponse.json({ id: 9, status: 'pending' });
+    await user.click(screen.getByRole('button', { name: /Any P1S/ }));
+    await waitFor(() => expect(printButton()).toBeEnabled());
+    await user.click(printButton());
+
+    await waitFor(() => expect(posted).toHaveLength(3));
+    expect(posted[2]).toMatchObject({ library_file_id: 13, target_model: 'P1S', printer_id: null });
+  });
+
+  it('keeps Alternatives blocked for two files of one model, also on a click', async () => {
+    renderModal([PART_A, PART_B], { separateJobsAllowed: true });
+
+    const alternatives = await screen.findByRole('radio', { name: 'Alternatives' });
+    fireEvent.click(alternatives);
+
+    expect(screen.getByRole('radio', { name: 'Separate jobs' })).toHaveAttribute('aria-checked', 'true');
+    expect(alternatives).toHaveAttribute('aria-checked', 'false');
+    expect(screen.getByText('Queue order')).toBeInTheDocument();
+    expect(screen.queryByText('Printer alternatives')).not.toBeInTheDocument();
+  });
+
+  it('queues the rest of an ASAP run after the items already inserted (#3471 review)', async () => {
+    // part-a goes in at 1, part-b is refused. The second Print must put
+    // part-b at 2, behind part-a, not ahead of it at 1.
+    respond = (_body, index) =>
+      index === 1
+        ? HttpResponse.json({ detail: 'refused' }, { status: 400 })
+        : HttpResponse.json({ id: index + 1, status: 'pending' });
+    // The queue as the server then has it: part-a (item 1) on top.
+    server.use(http.get('/api/v1/queue/', () => HttpResponse.json([{ id: 1, position: 1, batch_id: null }])));
+    const user = userEvent.setup();
+    renderModal([PART_A, PART_B], { separateJobsAllowed: true });
+
+    await pickPrinterAndWait(user, 'A1 Test');
+    await user.click(screen.getByRole('button', { name: /^ASAP$/i }));
+    await user.click(printButton());
+    await screen.findByText(/Not queued: part-b\.gcode\.3mf/);
+    expect(posted[0]).toMatchObject({ library_file_id: 11, insert_at_top: true, insert_position: 1 });
+
+    await waitFor(() => expect(printButton()).toBeEnabled());
+    await user.click(printButton());
+    await waitFor(() => expect(posted).toHaveLength(3));
+    expect(posted[2]).toMatchObject({ library_file_id: 12, insert_at_top: true, insert_position: 2 });
+  });
+
+  it('closes without a "0 items queued" message when nothing is left to send (#3471 review)', async () => {
+    // Everything goes through except part-c on the P1S. With the P1S
+    // deselected, part-c on the A1 is already queued: nothing to post.
+    respond = (_body, index) =>
+      index === 3
+        ? HttpResponse.json({ detail: 'refused' }, { status: 400 })
+        : HttpResponse.json({ id: index + 1, status: 'pending' });
+    const onClose = vi.fn();
+    const onSuccess = vi.fn();
+    const user = userEvent.setup();
+    renderModal([PART_A, PART_C], { separateJobsAllowed: true, onClose, onSuccess });
+
+    await user.click(await screen.findByRole('radio', { name: 'Separate jobs' }));
+    await user.click(await screen.findByText('A1 Test'));
+    await pickPrinterAndWait(user, 'P1S Test');
+    await user.click(printButton());
+    await screen.findByText(/Not queued: part-c\.gcode\.3mf/);
+    expect(posted).toHaveLength(4);
+
+    await user.click(screen.getByText('P1S Test'));
+    await waitFor(() => expect(printButton()).toBeEnabled());
+    await user.click(printButton());
+
+    await waitFor(() => expect(onClose).toHaveBeenCalled());
+    expect(onSuccess).toHaveBeenCalled();
+    expect(posted).toHaveLength(4);
+    expect(screen.queryByText(/0 items queued/)).not.toBeInTheDocument();
+  });
+
+  describe('while a file\'s plate list is still loading (#3471 review)', () => {
+    let releasePlates: () => void;
+    beforeEach(() => {
+      const platesLoaded = new Promise<void>((resolve) => {
+        releasePlates = resolve;
+      });
+      server.use(
+        http.get('/api/v1/library/files/:id/plates', async ({ params }) => {
+          if (Number(params.id) !== 12) {
+            return HttpResponse.json({ file_id: Number(params.id), filename: 'x', plates: [], is_multi_plate: false });
+          }
+          await platesLoaded;
+          return HttpResponse.json({
+            file_id: 12,
+            filename: 'part-b.gcode.3mf',
+            is_multi_plate: true,
+            plates: [1, 2].map((index) => ({
+              index,
+              name: `Plate ${index}`,
+              objects: ['part'],
+              filaments: [{ slot_id: 1, type: 'PLA', color: '#FFFFFF' }],
+              has_thumbnail: false,
+              thumbnail_url: null,
+              bed_type: null,
+              print_time_seconds: 600,
+            })),
+          });
+        }),
+      );
+    });
+
+    it('holds Print for separate jobs until the plate is chosen', async () => {
+      const user = userEvent.setup();
+      renderModal([PART_A, PART_B], { separateJobsAllowed: true });
+
+      await user.click(await screen.findByText('A1 Test'));
+      // Long enough for the printer status; only the plate list is missing.
+      await delay(300);
+      expect(printButton()).toBeDisabled();
+
+      releasePlates();
+      await screen.findByLabelText('Plate for part-b.gcode.3mf');
+      await waitFor(() => expect(printButton()).toBeEnabled());
+      await user.click(printButton());
+
+      await waitFor(() => expect(posted).toHaveLength(2));
+      expect(posted[1]).toMatchObject({ library_file_id: 12, plate_id: 1 });
+    });
+
+    it('holds Print for alternatives the same way', async () => {
+      const user = userEvent.setup();
+      renderModal([PART_C, PART_B]);
+
+      await screen.findByText('part-b.gcode.3mf');
+      await delay(300);
+      expect(printButton()).toBeDisabled();
+
+      releasePlates();
+      await screen.findByLabelText('Plate for part-b.gcode.3mf');
+      await waitFor(() => expect(printButton()).toBeEnabled());
+      await user.click(printButton());
+
+      await waitFor(() => expect(posted).toHaveLength(1));
+      expect(posted[0].variants).toEqual(
+        expect.arrayContaining([expect.objectContaining({ library_file_id: 12, plate_id: 1 })]),
+      );
+    });
+  });
+
+  describe('carried over from an earlier dialog (#3471 review)', () => {
+    it('reports what a refused run got into the queue', async () => {
+      respond = (_body, index) =>
+        index === 1
+          ? HttpResponse.json({ detail: 'refused' }, { status: 400 })
+          : HttpResponse.json({ id: index + 1, status: 'pending' });
+      const onSeparateJobsProgress = vi.fn();
+      const user = userEvent.setup();
+      renderModal([PART_A, PART_B], { separateJobsAllowed: true, onSeparateJobsProgress });
+
+      await pickPrinterAndWait(user, 'A1 Test');
+      await user.click(screen.getByRole('button', { name: /^ASAP$/i }));
+      await user.click(printButton());
+      await screen.findByText(/Not queued: part-b\.gcode\.3mf/);
+
+      expect(onSeparateJobsProgress).toHaveBeenLastCalledWith({
+        queuedPairs: ['11:1'],
+        asapInserted: 1,
+        queuedItemIds: [1],
+        queuedBatchIds: [],
+        queuedFileIds: [11],
+        remainingFileIds: [12],
+        // What the rest is queued with when the dialog is opened again.
+        settings: expect.objectContaining({
+          assignmentMode: 'printer',
+          printerIds: [1],
+          targetModel: null,
+          quantity: 1,
+          scheduleOptions: expect.objectContaining({ scheduleType: 'asap' }),
+        }),
+      });
+    });
+
+    it('skips the pairs and continues the ASAP positions of the earlier dialog', async () => {
+      const user = userEvent.setup();
+      renderModal([PART_A, PART_B], {
+        separateJobsAllowed: true,
+        resumeSeparateJobs: { queuedPairs: ['11:1'], asapInserted: 1 },
+      });
+
+      // The rest of a partly queued run: no new choice of mode.
+      await screen.findByText('part-b.gcode.3mf');
+      expect(screen.queryByRole('radiogroup', { name: 'What the selected files become' })).not.toBeInTheDocument();
+
+      await pickPrinterAndWait(user, 'A1 Test');
+      await user.click(screen.getByRole('button', { name: /^ASAP$/i }));
+      await user.click(printButton());
+
+      await waitFor(() => expect(posted).toHaveLength(1));
+      expect(posted[0]).toMatchObject({ library_file_id: 12, printer_id: 1, insert_position: 2 });
+    });
+
+    it("finishes the run with the earlier dialog's printers, plates and options", async () => {
+      server.use(
+        http.get('/api/v1/library/files/:id/plates', ({ params }) =>
+          HttpResponse.json({
+            file_id: Number(params.id),
+            filename: 'x',
+            is_multi_plate: true,
+            plates: [
+              { index: 1, name: 'One', objects: [], has_thumbnail: false, filaments: [] },
+              { index: 3, name: 'Three', objects: [], has_thumbnail: false, filaments: [] },
+            ],
+          }),
+        ),
+      );
+      const user = userEvent.setup();
+      renderModal([PART_D], {
+        separateJobsAllowed: true,
+        resumeSeparateJobs: {
+          queuedPairs: ['14:1'],
+          asapInserted: 0,
+          settings: {
+            plateByFile: { 14: 3 },
+            assignmentMode: 'printer',
+            printerIds: [1, 2],
+            targetModel: null,
+            targetLocation: null,
+            quantity: 1,
+            printOptions: {
+              ...DEFAULT_PRINT_OPTIONS,
+              timelapse: true,
+            },
+            scheduleOptions: { ...DEFAULT_SCHEDULE_OPTIONS, scheduleType: 'queue', requireManualStart: true },
+            costCenterId: null,
+          },
+        },
+      });
+
+      // Nothing picked by hand: the run's printers, plate and options are back.
+      await screen.findByLabelText('Plate for part-d.gcode.3mf');
+      await waitFor(() => expect(printButton()).toBeEnabled());
+      await user.click(printButton());
+
+      await waitFor(() => expect(posted).toHaveLength(1));
+      expect(posted[0]).toMatchObject({
+        library_file_id: 14,
+        printer_id: 2,
+        plate_id: 3,
+        timelapse: true,
+        manual_start: true,
+      });
+    });
+
+    it('puts the rest behind where the run ends in the queue now', async () => {
+      // The run's item 5 moved to position 3 since the refusal.
+      server.use(
+        http.get('/api/v1/queue/', () =>
+          HttpResponse.json([
+            { id: 9, position: 1, batch_id: null },
+            { id: 8, position: 2, batch_id: null },
+            { id: 5, position: 3, batch_id: null },
+            { id: 7, position: 4, batch_id: null },
+          ]),
+        ),
+      );
+      const user = userEvent.setup();
+      renderModal([PART_A, PART_B], {
+        separateJobsAllowed: true,
+        resumeSeparateJobs: { queuedPairs: ['11:1'], asapInserted: 1, queuedItemIds: [5] },
+      });
+
+      await screen.findByText('part-b.gcode.3mf');
+      await pickPrinterAndWait(user, 'A1 Test');
+      await user.click(screen.getByRole('button', { name: /^ASAP$/i }));
+      await user.click(printButton());
+
+      await waitFor(() => expect(posted).toHaveLength(1));
+      expect(posted[0]).toMatchObject({ library_file_id: 12, insert_position: 4 });
+    });
+
+    it('falls back to the positions taken when the queue cannot be read', async () => {
+      server.use(http.get('/api/v1/queue/', () => HttpResponse.json({ detail: 'boom' }, { status: 500 })));
+      const user = userEvent.setup();
+      renderModal([PART_A, PART_B], {
+        separateJobsAllowed: true,
+        resumeSeparateJobs: { queuedPairs: ['11:1'], asapInserted: 1, queuedItemIds: [5] },
+      });
+
+      await screen.findByText('part-b.gcode.3mf');
+      await pickPrinterAndWait(user, 'A1 Test');
+      await user.click(screen.getByRole('button', { name: /^ASAP$/i }));
+      await user.click(printButton());
+
+      await waitFor(() => expect(posted).toHaveLength(1));
+      expect(posted[0]).toMatchObject({ library_file_id: 12, insert_position: 2 });
+    });
+
+    it('continues a single file that one printer already took', async () => {
+      const user = userEvent.setup();
+      renderModal([PART_D], {
+        separateJobsAllowed: true,
+        resumeSeparateJobs: { queuedPairs: ['14:1'], asapInserted: 0 },
+      });
+
+      await user.click(await screen.findByText('A1 Test'));
+      await pickPrinterAndWait(user, 'P1S Test');
+      await user.click(printButton());
+
+      await waitFor(() => expect(posted).toHaveLength(1));
+      expect(posted.map((b) => [b.library_file_id, b.printer_id])).toEqual([[14, 2]]);
+    });
+  });
+});

+ 266 - 0
frontend/src/__tests__/components/PrintModalSeparateJobsQueue.test.ts

@@ -0,0 +1,266 @@
+/**
+ * Queue loop for "Separate jobs" when several sliced files are selected (#3462).
+ */
+
+import { describe, it, expect } from 'vitest';
+import { fileHasQueuedPair, queueSeparateJobs, runAsapTail } from '../../components/PrintModal/separateJobs';
+import type { VariantCandidate } from '../../components/PrintModal/VariantCandidates';
+import type { PrintQueueItemCreate } from '../../api/client';
+
+const file = (id: number): VariantCandidate => ({
+  id,
+  filename: `f${id}.gcode.3mf`,
+  sliced_for_model: 'A1',
+});
+
+const buildItem = (f: VariantCandidate, _plate: number | null, printerId: number | null) =>
+  ({ library_file_id: f.id, printer_id: printerId }) as PrintQueueItemCreate;
+
+function setup(failOnFileId?: number) {
+  const calls: PrintQueueItemCreate[] = [];
+  const addToQueue = async (data: PrintQueueItemCreate) => {
+    if (failOnFileId !== undefined && data.library_file_id === failOnFileId) {
+      throw new Error('nope');
+    }
+    calls.push(data);
+  };
+  return { calls, addToQueue };
+}
+
+describe('queueSeparateJobs', () => {
+  it('queues files outside, printers inside', async () => {
+    const { calls, addToQueue } = setup();
+    const result = await queueSeparateJobs({
+      files: [file(11), file(12)],
+      plateByFile: {},
+      printerIds: [1, 2],
+      quantity: 1,
+      asap: false,
+      buildItem,
+      addToQueue,
+    });
+    expect(calls.map((c) => [c.library_file_id, c.printer_id])).toEqual([
+      [11, 1],
+      [11, 2],
+      [12, 1],
+      [12, 2],
+    ]);
+    expect(result).toEqual({
+      itemsCreated: 4,
+      asapInserted: 0,
+      queuedFileIds: [11, 12],
+      queuedPairs: ['11:1', '11:2', '12:1', '12:2'],
+      // The test's addToQueue returns nothing, so there are no ids to keep.
+      queuedItemIds: [],
+      queuedBatchIds: [],
+      failure: null,
+    });
+  });
+
+  it('skips file and printer pairs a previous submit already queued', async () => {
+    const { calls, addToQueue } = setup();
+    const progress: Array<[number, number]> = [];
+    const result = await queueSeparateJobs({
+      files: [file(11), file(12)],
+      plateByFile: {},
+      printerIds: [1, 2],
+      alreadyQueued: new Set(['11:1']),
+      quantity: 1,
+      asap: false,
+      buildItem,
+      addToQueue,
+      onProgress: (c, t) => progress.push([c, t]),
+    });
+    expect(calls.map((c) => [c.library_file_id, c.printer_id])).toEqual([
+      [11, 2],
+      [12, 1],
+      [12, 2],
+    ]);
+    expect(result.itemsCreated).toBe(3);
+    expect(result.queuedFileIds).toEqual([11, 12]);
+    expect(result.queuedPairs).toEqual(['11:2', '12:1', '12:2']);
+    expect(progress).toEqual([[1, 3], [2, 3], [3, 3]]);
+  });
+
+  it('reports the pairs queued before a refusal', async () => {
+    const calls: PrintQueueItemCreate[] = [];
+    const result = await queueSeparateJobs({
+      files: [file(11), file(12)],
+      plateByFile: {},
+      printerIds: [1, 2],
+      quantity: 1,
+      asap: false,
+      buildItem,
+      addToQueue: async (data) => {
+        if (data.library_file_id === 11 && data.printer_id === 2) throw new Error('nope');
+        calls.push(data);
+      },
+    });
+    expect(result.queuedPairs).toEqual(['11:1']);
+    expect(result.queuedFileIds).toEqual([]);
+    expect(result.failure?.file.id).toBe(11);
+  });
+
+  it('passes the chosen plate, null when the file has none', async () => {
+    const plates: Array<number | null> = [];
+    const { addToQueue } = setup();
+    await queueSeparateJobs({
+      files: [file(11), file(12)],
+      plateByFile: { 11: 3 },
+      printerIds: [null],
+      quantity: 1,
+      asap: false,
+      buildItem: (f, plateId, printerId) => {
+        plates.push(plateId);
+        return buildItem(f, plateId, printerId);
+      },
+      addToQueue,
+    });
+    expect(plates).toEqual([3, null]);
+  });
+
+  it('sets quantity only above 1', async () => {
+    const two = setup();
+    await queueSeparateJobs({
+      files: [file(11)], plateByFile: {}, printerIds: [1], quantity: 2, asap: false,
+      buildItem, addToQueue: two.addToQueue,
+    });
+    expect(two.calls[0].quantity).toBe(2);
+
+    const one = setup();
+    await queueSeparateJobs({
+      files: [file(11)], plateByFile: {}, printerIds: [1], quantity: 1, asap: false,
+      buildItem, addToQueue: one.addToQueue,
+    });
+    expect('quantity' in one.calls[0]).toBe(false);
+  });
+
+  it('numbers ASAP positions by quantity, and omits them otherwise', async () => {
+    const asap = setup();
+    await queueSeparateJobs({
+      files: [file(11), file(12)], plateByFile: {}, printerIds: [1], quantity: 2, asap: true,
+      buildItem, addToQueue: asap.addToQueue,
+    });
+    expect(asap.calls.map((c) => [c.insert_at_top, c.insert_position])).toEqual([
+      [true, 1],
+      [true, 3],
+    ]);
+
+    const plain = setup();
+    await queueSeparateJobs({
+      files: [file(11), file(12)], plateByFile: {}, printerIds: [1], quantity: 2, asap: false,
+      buildItem, addToQueue: plain.addToQueue,
+    });
+    for (const c of plain.calls) {
+      expect('insert_at_top' in c).toBe(false);
+      expect('insert_position' in c).toBe(false);
+    }
+  });
+
+  it('continues the ASAP positions of an earlier submit and reports the new count', async () => {
+    // Two items went in at 1 and 2 before a refusal; the rest must follow
+    // them, not jump ahead at 1.
+    const { calls, addToQueue } = setup();
+    const result = await queueSeparateJobs({
+      files: [file(13)], plateByFile: {}, printerIds: [1], quantity: 1, asap: true,
+      asapInsertedBefore: 2,
+      buildItem, addToQueue,
+    });
+    expect(calls[0].insert_position).toBe(3);
+    expect(result.asapInserted).toBe(3);
+  });
+
+  it('reports the ASAP count reached before a refusal', async () => {
+    const { addToQueue } = setup(12);
+    const result = await queueSeparateJobs({
+      files: [file(11), file(12)], plateByFile: {}, printerIds: [1], quantity: 2, asap: true,
+      buildItem, addToQueue,
+    });
+    expect(result.asapInserted).toBe(2);
+  });
+
+  it('stops at the first refusal and reports it', async () => {
+    const { calls, addToQueue } = setup(12);
+    const result = await queueSeparateJobs({
+      files: [file(11), file(12), file(13)],
+      plateByFile: {},
+      printerIds: [1],
+      quantity: 1,
+      asap: false,
+      buildItem,
+      addToQueue,
+    });
+    expect(result.itemsCreated).toBe(1);
+    expect(result.queuedFileIds).toEqual([11]);
+    expect(result.failure?.file.id).toBe(12);
+    expect(result.failure?.message).toBe('nope');
+    expect(calls.map((c) => c.library_file_id)).toEqual([11]);
+  });
+
+  it('reports progress as current/total', async () => {
+    const { addToQueue } = setup();
+    const progress: Array<[number, number]> = [];
+    await queueSeparateJobs({
+      files: [file(11), file(12)],
+      plateByFile: {},
+      printerIds: [1, 2],
+      quantity: 1,
+      asap: false,
+      buildItem,
+      addToQueue,
+      onProgress: (c, t) => progress.push([c, t]),
+    });
+    expect(progress).toEqual([[1, 4], [2, 4], [3, 4], [4, 4]]);
+  });
+});
+
+describe('fileHasQueuedPair', () => {
+  it('finds a file on any printer, and only that file', () => {
+    const pairs = new Set(['11:2', '112:1']);
+    expect(fileHasQueuedPair(pairs, 11)).toBe(true);
+    expect(fileHasQueuedPair(pairs, 1)).toBe(false);
+    expect(fileHasQueuedPair(pairs, 12)).toBe(false);
+    expect(fileHasQueuedPair(new Set(['13:null']), 13)).toBe(true);
+  });
+});
+
+describe('runAsapTail (#3471 review)', () => {
+  const queue = [
+    { id: 1, position: 1, batch_id: null },
+    { id: 2, position: 2, batch_id: 40 },
+    { id: 3, position: 3, batch_id: 40 },
+    { id: 4, position: 4, batch_id: null },
+  ];
+
+  it("is the last position of the run's own items", () => {
+    expect(runAsapTail(queue, [1], [])).toBe(1);
+  });
+
+  it('counts the copies of a batch the run created', () => {
+    expect(runAsapTail(queue, [2], [40])).toBe(3);
+  });
+
+  it('is 0 when none of them is pending any more', () => {
+    expect(runAsapTail(queue, [99], [77])).toBe(0);
+  });
+});
+
+describe('queueSeparateJobs records what it created (#3471 review)', () => {
+  it("keeps each POST's item id and batch", async () => {
+    let next = 0;
+    const result = await queueSeparateJobs({
+      files: [file(11), file(12)],
+      plateByFile: {},
+      printerIds: [1],
+      quantity: 2,
+      asap: false,
+      buildItem,
+      addToQueue: async () => {
+        next++;
+        return { id: 100 + next, batch_id: 200 + next };
+      },
+    });
+    expect(result.queuedItemIds).toEqual([101, 102]);
+    expect(result.queuedBatchIds).toEqual([201, 202]);
+  });
+});

+ 167 - 1
frontend/src/__tests__/pages/FileManagerPage.test.tsx

@@ -1069,7 +1069,7 @@ describe('FileManagerPage', () => {
 
       // A second one makes it one job for either file.
       await user.click(root.getByText('Cube'));
-      expect(await within(toolbar()).findByRole('button', { name: 'Print (2 alternatives)' })).toBeInTheDocument();
+      expect(await within(toolbar()).findByRole('button', { name: 'Print (2 files)' })).toBeInTheDocument();
     });
 
     it('offers Combine to 3MF for STLs ticked across columns', async () => {
@@ -1326,6 +1326,172 @@ describe('FileManagerPage', () => {
         expect(screen.queryByRole('button', { name: /^Print$/ })).not.toBeInTheDocument();
       });
     });
+
+    // #3462: an explicit multi-selection may mean several jobs, so the dialog
+    // offers the choice; a version group is by definition alternatives.
+    it('lets a multi-selection become alternatives or separate jobs (#3462)', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
+
+      await user.click(screen.getByText('Benchy').closest('div[class*="cursor-pointer"]') as HTMLElement);
+      await user.click(screen.getByText('Cube').closest('div[class*="cursor-pointer"]') as HTMLElement);
+
+      await user.click(await screen.findByRole('button', { name: 'Print (2 files)' }));
+
+      const group = await screen.findByRole('radiogroup', { name: 'What the selected files become' });
+      expect(within(group).getByRole('radio', { name: /Alternatives/ })).toBeInTheDocument();
+      expect(within(group).getByRole('radio', { name: /Separate jobs/ })).toBeInTheDocument();
+    });
+
+    // #3471 review: a run closed after a refusal must not queue the files that
+    // went through a second time when Print is pressed again.
+    it('drops the queued files from the selection when a partly queued run is closed', async () => {
+      const posted: Array<Record<string, unknown>> = [];
+      server.use(
+        http.get('/api/v1/library/files/:id/plates', ({ params }) =>
+          HttpResponse.json({ file_id: Number(params.id), filename: 'x', plates: [], is_multi_plate: false })
+        ),
+        http.post('/api/v1/queue/', async ({ request }) => {
+          const body = (await request.json()) as Record<string, unknown>;
+          posted.push(body);
+          return posted.length === 2
+            ? HttpResponse.json({ detail: 'refused' }, { status: 400 })
+            : HttpResponse.json({ id: posted.length, status: 'pending' });
+        })
+      );
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
+
+      await user.click(screen.getByText('Benchy').closest('div[class*="cursor-pointer"]') as HTMLElement);
+      await user.click(screen.getByText('Cube').closest('div[class*="cursor-pointer"]') as HTMLElement);
+      await user.click(await screen.findByRole('button', { name: 'Print (2 files)' }));
+      await user.click(await screen.findByRole('radio', { name: /Separate jobs/ }));
+      await user.click(await screen.findByText('Test Printer'));
+      // The dialog's Print; with one file selected the toolbar has one too.
+      const print = () => screen.getAllByRole('button', { name: /^Print$/ }).at(-1) as HTMLElement;
+      await waitFor(() => expect(print()).toBeEnabled());
+      await user.click(print());
+      await screen.findByText(/Not queued: cube\.gcode\.3mf/);
+      expect(posted.map((b) => b.library_file_id)).toEqual([1, 3]);
+
+      await user.click(screen.getByRole('button', { name: 'Cancel' }));
+      await waitFor(() =>
+        expect(screen.queryByRole('radiogroup', { name: 'What the selected files become' })).not.toBeInTheDocument()
+      );
+
+      // Only cube is still selected, and its dialog is the rest of that run.
+      const toolbar = () => screen.getByText(/\d selected/).closest('div') as HTMLElement;
+      expect(within(toolbar()).getByText('1 selected')).toBeInTheDocument();
+      await user.click(within(toolbar()).getByRole('button', { name: /^Print$/ }));
+      await user.click(await screen.findByText('Test Printer'));
+      await waitFor(() => expect(print()).toBeEnabled());
+      await user.click(print());
+
+      await waitFor(() => expect(posted).toHaveLength(3));
+      expect(posted[2]).toMatchObject({ library_file_id: 3, printer_id: 1 });
+    });
+
+    // #3471 review: with the rest of a run selected, Print on another file's
+    // card shows that selection too, so it has to be the run's rest as well:
+    // a new run would queue again what a printer already has, and lose the
+    // printer, options and ASAP positions the run was submitted with.
+    it("resumes a partly queued run from another file's card Print", async () => {
+      const extra = [
+        { ...mockFiles[2], id: 5, filename: 'gear.gcode.3mf', file_path: '/library/gear.gcode.3mf', print_name: 'Gear' },
+        { ...mockFiles[2], id: 6, filename: 'nut.gcode.3mf', file_path: '/library/nut.gcode.3mf', print_name: 'Nut' },
+      ];
+      const posted: Array<Record<string, unknown>> = [];
+      server.use(
+        http.get('/api/v1/library/files', ({ request }) =>
+          HttpResponse.json(
+            new URL(request.url).searchParams.get('folder_id') ? filesForRequest(request) : [...mockFiles, ...extra]
+          )
+        ),
+        http.get('/api/v1/library/files/:id/plates', ({ params }) =>
+          HttpResponse.json({ file_id: Number(params.id), filename: 'x', plates: [], is_multi_plate: false })
+        ),
+        http.post('/api/v1/queue/', async ({ request }) => {
+          const body = (await request.json()) as Record<string, unknown>;
+          posted.push(body);
+          return posted.length === 2
+            ? HttpResponse.json({ detail: 'refused' }, { status: 400 })
+            : HttpResponse.json({ id: 100 + posted.length, status: 'pending' });
+        })
+      );
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await waitFor(() => expect(screen.getByText('Nut')).toBeInTheDocument());
+      const card = (name: string) => screen.getByText(name).closest('div[class*="cursor-pointer"]') as HTMLElement;
+
+      for (const name of ['Benchy', 'Cube', 'Gear']) await user.click(card(name));
+      await user.click(await screen.findByRole('button', { name: 'Print (3 files)' }));
+      await user.click(await screen.findByRole('radio', { name: /Separate jobs/ }));
+      // Gear before Cube: the queue order the run keeps.
+      await user.click((await screen.findAllByRole('button', { name: 'Move up' }))[2]);
+      await user.click(await screen.findByText('Test Printer'));
+      const print = () => screen.getAllByRole('button', { name: /^Print$/ }).at(-1) as HTMLElement;
+      await waitFor(() => expect(print()).toBeEnabled());
+      await user.click(print());
+      await screen.findByText(/Not queued: gear\.gcode\.3mf/);
+      await user.click(screen.getByRole('button', { name: 'Cancel' }));
+      await waitFor(() =>
+        expect(screen.queryByRole('radiogroup', { name: 'What the selected files become' })).not.toBeInTheDocument()
+      );
+
+      // Cube and Gear are still selected; Print from Nut's card menu.
+      const menuButtons = within(card('Nut')).getAllByRole('button');
+      await user.click(menuButtons[menuButtons.length - 1]);
+      await user.click(await screen.findByRole('button', { name: 'Print' }));
+
+      // The run's rest: no new choice of mode, and the run's printer is still
+      // picked, so Print goes straight on with the two files left.
+      await screen.findByText('gear.gcode.3mf');
+      expect(screen.queryByRole('radiogroup', { name: 'What the selected files become' })).not.toBeInTheDocument();
+      await waitFor(() => expect(print()).toBeEnabled());
+      await user.click(print());
+
+      await waitFor(() => expect(posted).toHaveLength(4));
+      // In the run's order, not the order the files were ticked in.
+      expect(posted.slice(2).map((b) => [b.library_file_id, b.printer_id])).toEqual([
+        [5, 1],
+        [3, 1],
+      ]);
+    });
+
+    it('shows a version group as alternatives only, without the choice (#3462)', async () => {
+      const user = userEvent.setup();
+      server.use(
+        http.get('/api/v1/library/files', ({ request }) =>
+          HttpResponse.json(
+            filesForRequest(request).map((f: { id: number }) =>
+              f.id === 1 || f.id === 3 ? { ...f, variant_group_id: 7 } : f
+            )
+          )
+        ),
+        http.get('/api/v1/library/variant-groups/7', () =>
+          HttpResponse.json({
+            id: 7,
+            name: 'Benchy',
+            members: [
+              { library_file_id: 1, filename: 'benchy.gcode.3mf', target_model: 'X1C', position: 0 },
+              { library_file_id: 3, filename: 'cube.gcode.3mf', target_model: 'P1S', position: 1 },
+            ],
+          })
+        )
+      );
+      render(<FileManagerPage />);
+      await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
+
+      await user.click(screen.getByText('Benchy').closest('div[class*="cursor-pointer"]') as HTMLElement);
+      await user.click(await screen.findByRole('button', { name: /^Print$/ }));
+
+      // The dialog is the multi-file one (the archive name lists both files) ...
+      expect(await screen.findByText(/benchy\.gcode\.3mf \+1 more/)).toBeInTheDocument();
+      // ... but a group has no "separate jobs" choice.
+      expect(screen.queryByRole('radiogroup', { name: 'What the selected files become' })).not.toBeInTheDocument();
+    });
   });
 
   describe('STL thumbnail generation', () => {

+ 66 - 0
frontend/src/components/PrintModal/MultiFileModeToggle.tsx

@@ -0,0 +1,66 @@
+import { useTranslation } from 'react-i18next';
+import type { MultiFileMode } from './multiFile';
+
+interface MultiFileModeToggleProps {
+  value: MultiFileMode;
+  onChange: (next: MultiFileMode) => void;
+  /** Set when one model has several files: "Alternatives" is then not offered. */
+  blockedModel: string | null;
+  /** Neither mode is offered, while the files are being queued: a switch
+   *  mid-run could leave the rest of a partly queued run as alternatives. */
+  disabled?: boolean;
+}
+
+const BUTTON_BASE = 'flex-1 px-3 py-2 rounded-lg border transition-colors text-sm';
+const SELECTED = 'border-bambu-green bg-bambu-green/10 text-white';
+const IDLE = 'border-bambu-dark-tertiary bg-bambu-dark text-bambu-gray hover:border-bambu-gray';
+
+const buttonClass = (selected: boolean, disabled: boolean) =>
+  `${BUTTON_BASE} ${selected ? SELECTED : IDLE} ${
+    // An idle choice that can't be made loses its hover too.
+    disabled ? `opacity-50 cursor-not-allowed${selected ? '' : ' hover:border-bambu-dark-tertiary'}` : ''
+  }`;
+
+/**
+ * Chooses what several selected sliced files become (#3462): one job with
+ * alternatives (#671) or one queue item per file. Same look as the
+ * printer/model toggle in PrinterSelector.
+ */
+export function MultiFileModeToggle({ value, onChange, blockedModel, disabled = false }: MultiFileModeToggleProps) {
+  const { t } = useTranslation();
+  const blocked = blockedModel != null;
+
+  return (
+    <div className="mb-4">
+      <div role="radiogroup" aria-label={t('printModal.multiFile.groupLabel')} className="flex gap-2">
+        <button
+          type="button"
+          role="radio"
+          aria-checked={value === 'alternatives'}
+          disabled={blocked || disabled}
+          onClick={() => onChange('alternatives')}
+          className={buttonClass(value === 'alternatives', blocked || disabled)}
+        >
+          {t('printModal.multiFile.alternatives')}
+        </button>
+        <button
+          type="button"
+          role="radio"
+          aria-checked={value === 'separate'}
+          disabled={disabled}
+          onClick={() => onChange('separate')}
+          className={buttonClass(value === 'separate', disabled)}
+        >
+          {t('printModal.multiFile.separate')}
+        </button>
+      </div>
+      <p className="text-xs text-bambu-gray mt-2">
+        {blocked
+          ? t('printModal.multiFile.alternativesBlocked', { model: blockedModel })
+          : value === 'alternatives'
+            ? t('printModal.multiFile.alternativesHint')
+            : t('printModal.multiFile.separateHint')}
+      </p>
+    </div>
+  );
+}

+ 45 - 16
frontend/src/components/PrintModal/VariantCandidates.tsx

@@ -4,7 +4,7 @@ import { ArrowDown, ArrowUp, Layers, Printer as PrinterIcon } from 'lucide-react
 import { useTranslation } from 'react-i18next';
 import { api } from '../../api/client';
 
-/** One sliced file offered as an alternative for a cross-model job (#671). */
+/** One sliced file of the list: an alternative of a cross-model job (#671), or one of the separate jobs (#3462). */
 export interface VariantCandidate {
   id: number;
   filename: string;
@@ -17,36 +17,53 @@ interface VariantCandidatesProps {
   /** file id -> chosen plate, for the multi-plate candidates only. */
   plateByFile: Record<number, number | null>;
   onPlateChange: (fileId: number, plateId: number | null) => void;
+  /** Told whether every file's plate list has answered and every multi-plate
+   *  file has a plate chosen; until then a submit would send no plate. */
+  onPlatesReadyChange?: (ready: boolean) => void;
   /** Show the set without offering to change it — the edit-queue-item case,
    *  where reordering would need a variant-level API that doesn't exist. */
   readOnly?: boolean;
   /** Replaces the help line under the heading when read-only. */
   readOnlyNote?: string;
+  /** Replaces the heading (default: the cross-model alternatives title). */
+  title?: string;
+  /** Replaces the help line (default: the cross-model alternatives help).
+   *  An empty string drops the line, for when something above the list
+   *  already explains it (the mode toggle, #3462). */
+  help?: string;
 }
 
 /**
- * The ordered candidate list for a cross-model print (#671).
+ * The ordered list of several sliced files, in one of two uses: the candidates
+ * of a cross-model print (#671), or the queue order of separate jobs (#3462).
  *
- * Only two things are configured per candidate: its order, and — when the file
+ * Only two things are configured per file: its order, and — when the file
  * holds more than one plate — which plate to run. Everything else on the modal
- * (filament overrides, print options, schedule) stays shared, because that is
- * already how model-based assignment works: the printer is unknown at queue
- * time, so there is nothing per-machine to configure. The AMS mapping in
- * particular is deliberately absent — the scheduler computes it against the
- * printer it actually picks, exactly as it does for a single-model job.
+ * (print options, schedule, and for alternatives the filament overrides) stays
+ * shared. For alternatives that is already how model-based assignment works:
+ * the printer is unknown at queue time, so there is nothing per-machine to
+ * configure. Separate jobs do know the printer, but the modal's filament panels
+ * describe the first file only, so they are hidden and no job carries them.
+ * The AMS mapping in particular is deliberately absent in both uses — the
+ * scheduler computes it against the printer and the file each item runs,
+ * exactly as it does for a single-model job.
  *
- * Order is the answer to "which would you rather have when both are free", so
- * it is explicit rather than left to whichever printer the matcher saw first.
- * Move buttons instead of drag: the list is two or three rows, and buttons work
- * from the keyboard without a drag-and-drop dependency.
+ * For alternatives, order is the answer to "which would you rather have when
+ * both are free", so it is explicit rather than left to whichever printer the
+ * matcher saw first. For separate jobs it is the order the items join the
+ * queue. Move buttons instead of drag: the list is two or three rows, and
+ * buttons work from the keyboard without a drag-and-drop dependency.
  */
 export function VariantCandidates({
   candidates,
   onReorder,
   plateByFile,
   onPlateChange,
+  onPlatesReadyChange,
   readOnly = false,
   readOnlyNote,
+  title,
+  help,
 }: VariantCandidatesProps) {
   const { t } = useTranslation();
 
@@ -84,6 +101,20 @@ export function VariantCandidates({
     }
   }, [platesByFile, plateByFile, onPlateChange, readOnly]);
 
+  // A list still loading has no plate to send yet (#3471 review): a quick
+  // Print would queue the file with plate_id null, the whole file's time and
+  // cost, and plate 1 printing. A list that failed to load counts as answered:
+  // there is no plate to offer, and the file goes without one, as before.
+  const platesPending = plateQueries.some((q) => q.isPending);
+  const platesChosen = Object.keys(platesByFile).every((fileId) => plateByFile[Number(fileId)] != null);
+  const platesReady = !readOnly && !platesPending && platesChosen;
+  useEffect(() => {
+    onPlatesReadyChange?.(platesReady);
+  }, [platesReady, onPlatesReadyChange]);
+
+  const defaultHelp = help ?? t('printModal.variants.help');
+  const helpText = readOnly ? (readOnlyNote ?? defaultHelp) : defaultHelp;
+
   const move = (from: number, to: number) => {
     if (to < 0 || to >= candidates.length) return;
     const next = [...candidates];
@@ -96,11 +127,9 @@ export function VariantCandidates({
     <div className="mb-4">
       <div className="flex items-center gap-2 mb-2">
         <PrinterIcon className="w-4 h-4 text-bambu-gray" />
-        <span className="text-sm text-bambu-gray">{t('printModal.variants.title')}</span>
+        <span className="text-sm text-bambu-gray">{title ?? t('printModal.variants.title')}</span>
       </div>
-      <p className="text-xs text-bambu-gray mb-2">
-        {readOnly ? (readOnlyNote ?? t('printModal.variants.help')) : t('printModal.variants.help')}
-      </p>
+      {helpText !== '' && <p className="text-xs text-bambu-gray mb-2">{helpText}</p>}
 
       <div className="space-y-2">
         {candidates.map((candidate, index) => {

+ 428 - 68
frontend/src/components/PrintModal/index.tsx

@@ -31,6 +31,16 @@ import { PrinterSelector } from './PrinterSelector';
 import { PrintOptionsPanel } from './PrintOptions';
 import { ScheduleOptionsPanel } from './ScheduleOptions';
 import { VariantCandidates, type VariantCandidate } from './VariantCandidates';
+import { MultiFileModeToggle } from './MultiFileModeToggle';
+import {
+  commonModel,
+  defaultMultiFileMode,
+  duplicateModel,
+  filesIncompatibleWith,
+  filesNotSlicedFor,
+  type MultiFileMode,
+} from './multiFile';
+import { fileHasQueuedPair, queueSeparateJobs, runAsapTail } from './separateJobs';
 import { CostCenterSelect } from './CostCenterSelect';
 import type {
   AssignmentMode,
@@ -98,6 +108,9 @@ export function PrintModal({
   projectId,
   cleanupLibraryAfterDispatch,
   variantFiles,
+  separateJobsAllowed,
+  resumeSeparateJobs,
+  onSeparateJobsProgress,
 }: PrintModalProps) {
   const { t } = useTranslation();
   const queryClient = useQueryClient();
@@ -111,9 +124,47 @@ export function PrintModal({
   const isLibraryFile = !!libraryFileId && !archiveId;
   const isEditing = mode === 'edit-queue-item';
 
-  // Cross-model alternatives (#671). One candidate is not a choice, so a
-  // single-entry list behaves exactly like an ordinary print.
-  const isCrossModel = mode === 'create' && (variantFiles?.length ?? 0) > 1;
+  // The rest of a separate-jobs run closed after a refusal (#3471 review).
+  const resumed = mode === 'create' && !!separateJobsAllowed && !!resumeSeparateJobs && (variantFiles?.length ?? 0) > 0;
+  // What that run was submitted with: the reopened dialog starts from it, so
+  // a quick Print finishes the run the way it began (#3471 review).
+  const resumedSettings = resumed ? resumeSeparateJobs!.settings : undefined;
+  // Several sliced files at once. One file is not a choice, so a single-entry
+  // list behaves exactly like an ordinary print, unless it is the last file of
+  // a resumed run: one printer may already have it, and only the separate-jobs
+  // path knows to skip that pair.
+  const isMultiFile = mode === 'create' && ((variantFiles?.length ?? 0) > 1 || resumed);
+  // What those files become (#3462): one job with a candidate per file (#671),
+  // or one queue item per file. Only a selection made in the File Manager may
+  // be split into separate jobs; a version group is the same job sliced for
+  // different printers, so without `separateJobsAllowed` it stays alternatives
+  // and the dialog behaves exactly as before.
+  const [multiFileMode, setMultiFileMode] = useState<MultiFileMode>(() =>
+    resumed ? 'separate' : separateJobsAllowed && isMultiFile ? defaultMultiFileMode(variantFiles!) : 'alternatives',
+  );
+  // Cross-model alternatives (#671).
+  const isCrossModel = isMultiFile && multiFileMode === 'alternatives';
+  const isSeparateJobs = isMultiFile && multiFileMode === 'separate';
+  // Set once separate jobs were partly queued and the rest is still listed.
+  // The rest is the remainder of those jobs, so the mode is no longer offered:
+  // as alternatives it could be a job of one candidate, or one whose filament
+  // panel describes a file that has already been queued and left the list.
+  const [partlyQueued, setPartlyQueued] = useState(resumed);
+  // File and printer pairs earlier submits of a partly queued run got into the
+  // queue. The next submit skips them, so a file one printer took and another
+  // refused is not queued on the first printer again.
+  const [queuedPairs, setQueuedPairs] = useState<ReadonlySet<string>>(
+    () => new Set(resumed ? resumeSeparateJobs!.queuedPairs : []),
+  );
+  // ASAP positions the run has taken so far. The backend moves everything at
+  // or below an inserted position down, so a second submit that restarted at 1
+  // would put the rest ahead of what is already queued.
+  const [asapPositionsTaken, setAsapPositionsTaken] = useState(resumed ? resumeSeparateJobs!.asapInserted : 0);
+  // The run's own queue items and batches, to find where its ASAP items end
+  // in the queue as it is at the next submit.
+  const [runItemIds, setRunItemIds] = useState<number[]>(() => (resumed ? resumeSeparateJobs!.queuedItemIds ?? [] : []));
+  const [runBatchIds, setRunBatchIds] = useState<number[]>(() => (resumed ? resumeSeparateJobs!.queuedBatchIds ?? [] : []));
+  const showModeToggle = isMultiFile && !!separateJobsAllowed && !partlyQueued;
   // Editing an already-queued cross-model item. The candidates are shown so the
   // dialog doesn't misrepresent the job as a plain "Any H2D" — which is what it
   // did before, offering a printer picker whose Save would have left a row with
@@ -130,7 +181,13 @@ export function PrintModal({
       : [];
   const hasEditingVariants = editingVariants.length > 0;
   const [candidates, setCandidates] = useState<VariantCandidate[]>(variantFiles ?? []);
-  const [candidatePlates, setCandidatePlates] = useState<Record<number, number | null>>({});
+  const [candidatePlates, setCandidatePlates] = useState<Record<number, number | null>>(
+    () => resumedSettings?.plateByFile ?? {},
+  );
+  // Whether every listed file's plates are known and chosen (#3471 review).
+  // Before that a quick Print would send plate_id null: the whole file's time
+  // and cost, and plate 1 printing.
+  const [candidatePlatesReady, setCandidatePlatesReady] = useState(false);
 
   type FilamentWarningItem = {
     printerId: number;
@@ -152,6 +209,7 @@ export function PrintModal({
     if (initialSelectedPrinterIds?.length) {
       return initialSelectedPrinterIds;
     }
+    if (resumedSettings) return resumedSettings.printerIds;
     return [];
   });
 
@@ -160,7 +218,8 @@ export function PrintModal({
     if (mode === 'edit-queue-item' && queueItem?.plate_id != null) {
       return new Set([queueItem.plate_id]);
     }
-    if (mode === 'create' && !isCrossModel && preselectPlateId != null) {
+    // Several files choose their plates in the candidate list, in either mode.
+    if (mode === 'create' && !isMultiFile && preselectPlateId != null) {
       return new Set([preselectPlateId]);
     }
     return new Set();
@@ -170,7 +229,7 @@ export function PrintModal({
   const selectedPlate = selectedPlates.size === 1 ? [...selectedPlates][0] : null;
 
   // Quantity — number of copies (creates a batch if > 1)
-  const [quantity, setQuantity] = useState(1);
+  const [quantity, setQuantity] = useState(() => resumedSettings?.quantity ?? 1);
 
   // Per-plate quantities for multi-plate files (#342). Keyed by plate index;
   // a plate with no entry means one run. Only used in create mode on a
@@ -191,7 +250,7 @@ export function PrintModal({
         preheat_chamber_target_override: queueItem.preheat_chamber_target_override ?? DEFAULT_PRINT_OPTIONS.preheat_chamber_target_override,
       };
     }
-    return DEFAULT_PRINT_OPTIONS;
+    return resumedSettings?.printOptions ?? DEFAULT_PRINT_OPTIONS;
   });
 
   const [scheduleOptions, setScheduleOptions] = useState<ScheduleOptions>(() => {
@@ -220,7 +279,7 @@ export function PrintModal({
         staggerIntervalMinutes: DEFAULT_SCHEDULE_OPTIONS.staggerIntervalMinutes,
       };
     }
-    return DEFAULT_SCHEDULE_OPTIONS;
+    return resumedSettings?.scheduleOptions ?? DEFAULT_SCHEDULE_OPTIONS;
   });
 
   // Manual slot overrides: slot_id (1-indexed) -> globalTrayId (default mapping for single printer or all printers)
@@ -259,7 +318,7 @@ export function PrintModal({
     if (mode === 'edit-queue-item' && queueItem?.target_model) {
       return 'model';
     }
-    return 'printer';
+    return resumedSettings?.assignmentMode ?? 'printer';
   });
 
   // Target model for model-based assignment
@@ -267,7 +326,7 @@ export function PrintModal({
     if (mode === 'edit-queue-item' && queueItem?.target_model) {
       return queueItem.target_model;
     }
-    return null;
+    return resumedSettings?.targetModel ?? null;
   });
 
   // Target location for model-based assignment (optional filter)
@@ -275,11 +334,11 @@ export function PrintModal({
     if (mode === 'edit-queue-item' && queueItem?.target_location) {
       return queueItem.target_location;
     }
-    return null;
+    return resumedSettings?.targetLocation ?? null;
   });
 
   const [selectedCostCenterId, setSelectedCostCenterId] = useState<number | null>(() =>
-    mode === 'edit-queue-item' ? queueItem?.cost_center_id ?? null : null
+    mode === 'edit-queue-item' ? queueItem?.cost_center_id ?? null : resumedSettings?.costCenterId ?? null
   );
   const [estimatedCost, setEstimatedCost] = useState<number | null>(queueItem?.estimated_cost ?? null);
   const [estimatedCostsByPlate, setEstimatedCostsByPlate] = useState<Record<number, number | null>>({});
@@ -333,7 +392,8 @@ export function PrintModal({
   // Sync print option defaults from settings once available
   const printDefaultsApplied = useRef(false);
   useEffect(() => {
-    if (!settings || printDefaultsApplied.current || mode === 'edit-queue-item') return;
+    // A resumed run keeps the options it was submitted with.
+    if (!settings || printDefaultsApplied.current || mode === 'edit-queue-item' || resumedSettings) return;
     printDefaultsApplied.current = true;
     setPrintOptions({
       bed_levelling: settings.default_bed_levelling ?? DEFAULT_PRINT_OPTIONS.bed_levelling,
@@ -346,19 +406,19 @@ export function PrintModal({
       preheat_override: DEFAULT_PRINT_OPTIONS.preheat_override,
       preheat_chamber_target_override: DEFAULT_PRINT_OPTIONS.preheat_chamber_target_override,
     });
-  }, [settings, mode]);
+  }, [settings, mode, resumedSettings]);
 
   // Sync stagger defaults from settings once available
   const staggerDefaultsApplied = useRef(false);
   useEffect(() => {
-    if (!settings || staggerDefaultsApplied.current || mode === 'edit-queue-item') return;
+    if (!settings || staggerDefaultsApplied.current || mode === 'edit-queue-item' || resumedSettings) return;
     staggerDefaultsApplied.current = true;
     setScheduleOptions((prev) => ({
       ...prev,
       staggerGroupSize: settings.stagger_group_size ?? prev.staggerGroupSize,
       staggerIntervalMinutes: settings.stagger_interval_minutes ?? prev.staggerIntervalMinutes,
     }));
-  }, [settings, mode]);
+  }, [settings, mode, resumedSettings]);
 
   const currencySymbol = getCurrencySymbol(settings?.currency || 'USD');
   const defaultCostPerKg = settings?.default_filament_cost ?? 0;
@@ -462,6 +522,39 @@ export function PrintModal({
   const sourceDetailsPending =
     (!!archiveId && !isLibraryFile && !archiveDetails && !archiveDetailsFailed) ||
     (isLibraryFile && !libraryFileDetails && !libraryFileDetailsFailed);
+  // The model the printer picker filters on, pre-selects and offers as "Any X".
+  // Separate jobs print every listed file, not just the first one the dialog
+  // was opened with (which may already be queued and gone from the list after
+  // a refusal), so they offer the model all of them share, or none.
+  const selectorModel = isSeparateJobs ? commonModel(candidates) : slicedForModel;
+  // The model gate (#2578) per file: each separate job runs its own file, and
+  // the backend refuses each one sliced for an incompatible model. A file that
+  // names no model passes, as a single file does.
+  const separateJobsIncompatible = useMemo(
+    () => (isSeparateJobs && assignmentMode === 'model' ? filesIncompatibleWith(candidates, targetModel) : []),
+    [isSeparateJobs, assignmentMode, candidates, targetModel],
+  );
+
+  // Each mode starts from the assignment it is built on, as when the dialog
+  // opens in it: the alternatives job is model-based with no printer chosen
+  // (#671), separate jobs start at the printer picker. Nothing picked for one
+  // mode rides along unseen into the other; the alternatives request always
+  // carries target_location, and no control in that mode shows it.
+  const handleMultiFileModeChange = (next: MultiFileMode) => {
+    if (next === multiFileMode) return;
+    // Not while the files are being queued: a refusal mid-run hides the
+    // toggle, and the rest would then be queued in a mode nobody chose for it.
+    // The toggle is disabled meanwhile; this keeps the mode in step with it.
+    if (isSubmitting) return;
+    // Two files for one model can't be alternatives; the toggle disables the
+    // choice, and this keeps the mode from ever disagreeing with it.
+    if (next === 'alternatives' && duplicateModel(candidates) != null) return;
+    setMultiFileMode(next);
+    setAssignmentMode(next === 'alternatives' ? 'model' : 'printer');
+    setTargetModel(null);
+    setTargetLocation(null);
+    if (next === 'alternatives') setSelectedPrinters([]);
+  };
 
   // The archive's own saved AMS-slot pick from the slicer (see the "Save AMS
   // mapping" virtual-printer setting) — undefined for library files or
@@ -605,6 +698,18 @@ export function PrintModal({
     }
   };
 
+  /** The success toast of a create submit that queued `count` items. */
+  const showQueuedToast = async (count: number) => {
+    const waitForIdleToast = await asapToastShouldPromiseLaterStart();
+    if (waitForIdleToast) {
+      showToast(t('queue.printQueuedWillStartWhenIdle'));
+    } else if (count === 1) {
+      showToast(assignmentMode === 'model' ? `Queued for any ${targetModel}` : t('queue.printQueued'));
+    } else {
+      showToast(t('queue.itemsQueued', { count }));
+    }
+  };
+
   // Get AMS mapping from hook (only when single printer selected)
   const { amsMapping } = useFilamentMapping(
     mappingFilamentReqs,
@@ -789,7 +894,7 @@ export function PrintModal({
     const defaultId = settings?.default_printer_id;
     const preferred =
       defaultId != null && !isCrossModel && assignmentMode === 'printer' && !sourceDetailsPending
-        ? activePrinters.find(p => p.id === defaultId && (!slicedForModel || p.model === slicedForModel))
+        ? activePrinters.find(p => p.id === defaultId && (!selectorModel || p.model === selectorModel))
         : undefined;
     if (preferred && !defaultPrinterOffered.current) {
       defaultPrinterOffered.current = true;
@@ -797,7 +902,7 @@ export function PrintModal({
     } else if (activePrinters.length === 1) {
       setSelectedPrinters([activePrinters[0].id]);
     }
-  }, [mode, printers, selectedPrinters.length, settings?.default_printer_id, isCrossModel, assignmentMode, slicedForModel, sourceDetailsPending]);
+  }, [mode, printers, selectedPrinters.length, settings?.default_printer_id, isCrossModel, assignmentMode, selectorModel, sourceDetailsPending]);
 
   // Clear manual mappings and per-printer configs when printer or plate changes.
   // The per-plate mappings go with them: a manual override holds a global tray id,
@@ -854,11 +959,11 @@ export function PrintModal({
   // to another model, #2578) — fill it with the sliced-for model once known,
   // provided an active printer of that model exists.
   useEffect(() => {
-    if (assignmentMode !== 'model' || targetModel || !slicedForModel) return;
-    if (printers?.some((p) => p.is_active && p.model === slicedForModel)) {
-      setTargetModel(slicedForModel);
+    if (assignmentMode !== 'model' || targetModel || !selectorModel) return;
+    if (printers?.some((p) => p.is_active && p.model === selectorModel)) {
+      setTargetModel(selectorModel);
     }
-  }, [assignmentMode, targetModel, slicedForModel, printers]);
+  }, [assignmentMode, targetModel, selectorModel, printers]);
 
   // Auto-expand per-printer mapping when setting is enabled and multiple printers selected
   // Only applies once per printer on initial selection, not when user unchecks
@@ -904,9 +1009,10 @@ export function PrintModal({
   const isMultiPlate = platesData?.is_multi_plate ?? false;
   const plates = platesData?.plates ?? [];
   // The plates that will print, beside the buttons: in a long plate list the
-  // selected card can be far out of view (#3395).
+  // selected card can be far out of view (#3395). Several files choose theirs
+  // in the candidate list, where they are in view.
   const selectedPlatesSummary =
-    !isCrossModel && isMultiPlate && plates.length > 1
+    !isMultiFile && isMultiPlate && plates.length > 1
       ? plates
           .filter((p) => selectedPlates.has(p.index))
           .map((p) => {
@@ -1064,10 +1170,14 @@ export function PrintModal({
       return;
     }
 
+    // Separate jobs skip it: it weighs the filaments of the dialog's one file,
+    // and each job prints its own. The scheduler's check at dispatch (#1496)
+    // still holds any of them back that is short of filament.
     if (
       !options?.skipFilamentCheck &&
       !settings?.disable_filament_warnings &&
       !isEditing &&
+      !isSeparateJobs &&
       assignmentMode === 'printer'
     ) {
       const warningItems: FilamentWarningItem[] = [];
@@ -1194,11 +1304,22 @@ export function PrintModal({
       return;
     }
     // Cross-model safety gate (#2578) — mirrors the backend's 400 so the user
-    // gets inline feedback instead of a failed request.
-    if (!isCrossModel && assignmentMode === 'model' && !isGcodeCompatible(slicedForModel, targetModel)) {
+    // gets inline feedback instead of a failed request. Separate jobs are
+    // gated per file below; the dialog's own file may no longer be listed.
+    if (!isMultiFile && assignmentMode === 'model' && !isGcodeCompatible(slicedForModel, targetModel)) {
       showToast(`File was sliced for ${slicedForModel} and cannot be dispatched to ${targetModel} printers`, 'error');
       return;
     }
+    if (separateJobsIncompatible.length > 0) {
+      showToast(
+        t('printModal.multiFile.incompatible', {
+          model: targetModel,
+          files: separateJobsIncompatible.map((f) => f.filename).join(', '),
+        }),
+        'error',
+      );
+      return;
+    }
 
 
     setIsSubmitting(true);
@@ -1377,6 +1498,170 @@ export function PrintModal({
       return;
     }
 
+    // Separate jobs (#3462): one ordinary queue item per listed file, files in
+    // list order and the selected printers inside each, through the same
+    // POST /queue/ the multi-plate fan-out below uses. Returns before that
+    // fan-out, which is built around the dialog's one file (its batch, its
+    // per-plate quantities and mappings).
+    //
+    // Each item carries only what is true of every file: printer or model,
+    // schedule, options, cost centre. No ams_mapping and no filament_overrides:
+    // the mapping and override panels describe the first file's filaments,
+    // which are not the others'. Without a mapping the scheduler resolves one at
+    // dispatch from the item's own 3MF and the printer's loaded trays
+    // (`_ensure_ams_mapping`), as it already does for model-based items. Also
+    // left out: estimated_cost (a display hint, the server prices each item
+    // itself), skip_filament_check (the spool warning was never shown),
+    // nozzle_rack_choice (picked against the first file's filament groups) and
+    // batch_id (a batch belongs to one source file; quantity > 1 still makes
+    // the server batch each file on its own).
+    //
+    // The first refused file stops the run: queueing the rest would put them
+    // ahead of it, and stopping lets a resubmit carry on from that file
+    // without queueing anything twice. What did go in leaves the list, so the
+    // list is always exactly what is still to be queued.
+    if (isSeparateJobs) {
+      const printerIds = assignmentMode === 'printer' ? selectedPrinters : [null];
+      // The stagger of the printer fan-out below, per file.
+      const useStagger = assignmentMode === 'printer' && scheduleOptions.staggerEnabled && selectedPrinters.length > 1;
+      const staggerBaseTime = useStagger
+        ? (scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
+          ? new Date(scheduleOptions.scheduledTime).getTime()
+          : Date.now())
+        : 0;
+      const asap = scheduleOptions.scheduleType === 'asap';
+      setSubmitProgress({ current: 0, total: candidates.length * printerIds.length });
+      try {
+        // The rest of a partly queued run goes in right behind the run's own
+        // ASAP items as the queue stands now: since the refusal, items may have
+        // been added, moved or started, and the count of positions taken would
+        // then point somewhere else (#3471 review). The count is the fallback
+        // when the queue can't be read.
+        let asapInsertedBefore = asapPositionsTaken;
+        if (asap && (runItemIds.length > 0 || runBatchIds.length > 0)) {
+          try {
+            const pending = await api.getQueue(undefined, 'pending');
+            asapInsertedBefore = runAsapTail(pending, runItemIds, runBatchIds);
+          } catch {
+            // Keep the count.
+          }
+        }
+        const outcome = await queueSeparateJobs({
+          files: candidates,
+          plateByFile: candidatePlates,
+          printerIds,
+          alreadyQueued: queuedPairs,
+          asapInsertedBefore,
+          quantity: effectiveQuantity,
+          asap,
+          buildItem: (file, plateId, printerId, printerIndex) => {
+            const item: PrintQueueItemCreate = {
+              library_file_id: file.id,
+              plate_id: plateId,
+              printer_id: assignmentMode === 'printer' ? printerId : null,
+              target_model: assignmentMode === 'model' ? targetModel : null,
+              target_location: assignmentMode === 'model' ? targetLocation : null,
+              require_previous_success: scheduleOptions.requirePreviousSuccess,
+              auto_off_after: scheduleOptions.autoOffAfter,
+              gcode_injection: scheduleOptions.gcodeInjection,
+              manual_start: needsReview || (scheduleOptions.scheduleType === 'queue' && scheduleOptions.requireManualStart),
+              scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
+                ? new Date(scheduleOptions.scheduledTime).toISOString()
+                : undefined,
+              ...printOptions,
+              project_id: projectId ?? undefined,
+              cost_center_id: billingEnabled ? selectedCostCenterId : undefined,
+              cleanup_library_after_dispatch: cleanupLibraryAfterDispatch,
+            };
+            if (useStagger) {
+              const groupIndex = Math.floor(printerIndex / scheduleOptions.staggerGroupSize);
+              if (groupIndex > 0) {
+                const offsetMs = groupIndex * scheduleOptions.staggerIntervalMinutes * 60_000;
+                item.scheduled_time = new Date(staggerBaseTime + offsetMs).toISOString();
+              }
+            }
+            return item;
+          },
+          addToQueue: (data) => addToQueueMutation.mutateAsync(data),
+          onProgress: (current, total) => setSubmitProgress({ current, total }),
+        });
+
+        setAsapPositionsTaken(outcome.asapInserted);
+        const allItemIds = [...runItemIds, ...outcome.queuedItemIds];
+        const allBatchIds = [...runBatchIds, ...outcome.queuedBatchIds];
+        setRunItemIds(allItemIds);
+        setRunBatchIds(allBatchIds);
+        if (outcome.failure) {
+          const allQueued = new Set([...queuedPairs, ...outcome.queuedPairs]);
+          setQueuedPairs(allQueued);
+          // What is in the queue: the files queued on every printer, and the
+          // refused file itself when a printer took it, in this submit or an
+          // earlier one, also on a printer that has been deselected since.
+          const refusedId = outcome.failure.file.id;
+          const refusedFileQueued = fileHasQueuedPair(allQueued, refusedId);
+          const queuedIds = refusedFileQueued
+            ? [...outcome.queuedFileIds, refusedId]
+            : outcome.queuedFileIds;
+          const queued = candidates.filter((c) => queuedIds.includes(c.id)).map((c) => c.filename);
+          showToast(
+            t('printModal.multiFile.failed', {
+              filename: outcome.failure.file.filename,
+              message: outcome.failure.message,
+              queued: queued.length > 0 ? queued.join(', ') : t('common.none'),
+            }),
+            'error',
+          );
+          // Only files queued on every printer leave the list; the refused one
+          // stays, even if a printer before the refusal took it.
+          const remaining = candidates.filter((c) => !outcome.queuedFileIds.includes(c.id));
+          setCandidates(remaining);
+          // Any item in the queue makes this run partly queued, also one of
+          // the refused file on an earlier printer.
+          if (outcome.itemsCreated > 0) setPartlyQueued(true);
+          // The opener keeps this if the dialog is closed now: without it the
+          // same selection would open empty-handed and queue them again.
+          if (allQueued.size > 0) {
+            const remainingIds = remaining.map((c) => c.id);
+            onSeparateJobsProgress?.({
+              queuedPairs: [...allQueued],
+              asapInserted: outcome.asapInserted,
+              queuedItemIds: allItemIds,
+              queuedBatchIds: allBatchIds,
+              settings: {
+                plateByFile: candidatePlates,
+                assignmentMode,
+                printerIds: selectedPrinters,
+                targetModel,
+                targetLocation,
+                quantity,
+                printOptions,
+                scheduleOptions,
+                costCenterId: selectedCostCenterId,
+              },
+              queuedFileIds: (variantFiles ?? []).map((f) => f.id).filter((id) => !remainingIds.includes(id)),
+              remainingFileIds: remainingIds,
+            });
+          }
+          queryClient.invalidateQueries({ queryKey: ['queue'] });
+          return;
+        }
+
+        // Nothing posted means every remaining pair was already queued (a
+        // refusing printer deselected since): a success, but "0 items queued"
+        // would say otherwise.
+        if (outcome.itemsCreated > 0) await showQueuedToast(outcome.itemsCreated);
+        queryClient.invalidateQueries({ queryKey: ['queue'] });
+        onSuccess?.();
+        onClose();
+      } catch (error) {
+        // queueSeparateJobs reports refusals itself; this is anything else.
+        showToast(error instanceof Error ? error.message : String(error), 'error');
+      } finally {
+        setIsSubmitting(false);
+      }
+      return;
+    }
+
     // Batch order (#342): a create submission that produces more than one run
     // from one source is pre-created as a batch carrying per-plate targets,
     // and its id is passed to each subsequent addToQueue call. The targets are
@@ -1628,22 +1913,8 @@ export function PrintModal({
         if (mode === 'edit-queue-item') {
           showToast('Queue item updated');
         }
-      } else if (results.success === 1) {
-        const waitForIdleToast = await asapToastShouldPromiseLaterStart();
-        showToast(
-          waitForIdleToast
-            ? t('queue.printQueuedWillStartWhenIdle')
-            : assignmentMode === 'model'
-              ? `Queued for any ${targetModel}`
-              : t('queue.printQueued'),
-        );
       } else {
-        const waitForIdleToast = await asapToastShouldPromiseLaterStart();
-        showToast(
-          waitForIdleToast
-            ? t('queue.printQueuedWillStartWhenIdle')
-            : t('queue.itemsQueued', { count: results.success }),
-        );
+        await showQueuedToast(results.success);
       }
       queryClient.invalidateQueries({ queryKey: ['queue'] });
       onSuccess?.();
@@ -1658,6 +1929,15 @@ export function PrintModal({
 
   const isPending = isSubmitting || updateQueueMutation.isPending;
 
+  // A single-printer AMS job must wait for the printer's live status before it
+  // can resolve the filament mapping. Submitting mid-load matched against zero
+  // known trays and serialized an all-[-1] mapping, which dispatched the print
+  // to the empty external feed (#2589). Separate jobs send no mapping at all
+  // (the scheduler maps each against its own file at dispatch), so they have
+  // nothing to wait for.
+  const waitingForAmsStatus =
+    !isSeparateJobs && assignmentMode === 'printer' && selectedPrinters.length === 1 && printerStatusLoading;
+
   const canSubmit = useMemo(() => {
     if (isPending) return false;
 
@@ -1671,11 +1951,15 @@ export function PrintModal({
     // Both are about the single-model case. A cross-model job has no one target
     // model, and each candidate is gated against its own by the backend (#671).
     if (!isCrossModel && assignmentMode === 'model' && !targetModel) return false;
-    // Cross-model mismatch cannot be queued (#2578)
-    if (!isCrossModel && assignmentMode === 'model' && !isGcodeCompatible(slicedForModel, targetModel)) return false;
+    // Cross-model mismatch cannot be queued (#2578); separate jobs per file.
+    if (!isMultiFile && assignmentMode === 'model' && !isGcodeCompatible(slicedForModel, targetModel)) return false;
+    if (separateJobsIncompatible.length > 0) return false;
+    // Each listed file's plate comes from the list, in either mode.
+    if (isMultiFile && !candidatePlatesReady) return false;
 
-    // For multi-plate files, need at least one plate selected
-    if (isMultiPlate && selectedPlates.size === 0) return false;
+    // For multi-plate files, need at least one plate selected. Separate jobs
+    // take each file's plate from the candidate list, not from this selection.
+    if (!isSeparateJobs && isMultiPlate && selectedPlates.size === 0) return false;
 
     // Every selected plate has to have answered before we can queue it: a plate
     // still in flight would be sent with no mapping and no overrides, and one that
@@ -1683,11 +1967,8 @@ export function PrintModal({
     // the rest — the banner above says which state we are in.
     if (perPlateReqsPending || perPlateReqsFailed) return false;
 
-    // A single-printer AMS job must wait for the printer's live status before it
-    // can resolve the filament mapping. Submitting mid-load matched against zero
-    // known trays and serialized an all-[-1] mapping, which dispatched the print
-    // to the empty external feed (#2589).
-    if (assignmentMode === 'printer' && selectedPrinters.length === 1 && printerStatusLoading) return false;
+    // The printer's live status first (#2589, see waitingForAmsStatus).
+    if (waitingForAmsStatus) return false;
 
     return true;
   }, [
@@ -1700,8 +1981,12 @@ export function PrintModal({
     isPending,
     perPlateReqsPending,
     perPlateReqsFailed,
-    printerStatusLoading,
+    waitingForAmsStatus,
     isCrossModel,
+    isMultiFile,
+    isSeparateJobs,
+    separateJobsIncompatible,
+    candidatePlatesReady,
     billingEnabled,
     loadingCostCenters,
     selectedCostCenter,
@@ -1714,11 +1999,12 @@ export function PrintModal({
   // global field is hidden (#342) — the reporter's case is "plate 1 once,
   // plate 2 twice", which one shared number cannot express. Single-plate
   // files, and edit mode, keep the single field exactly as before.
-  // Cross-model is excluded: its plate choice is per candidate and lives in
-  // VariantCandidates, so there are no per-plate steppers to own the number
-  // and the global Quantity field below is the only one there is (#3101).
+  // Several files are excluded, alternatives and separate jobs alike: their
+  // plate choice is per candidate and lives in VariantCandidates, so there are
+  // no per-plate steppers to own the number and the global Quantity field
+  // below is the only one there is (#3101).
   const usePerPlateQuantities =
-    mode === 'create' && !isCrossModel && isMultiPlate && plates.length > 1;
+    mode === 'create' && !isMultiFile && isMultiPlate && plates.length > 1;
 
   /** Runs to queue for one plate. `null` = the single-plate / whole-file case. */
   const quantityForPlate = (plateIndex: number | null): number => {
@@ -1776,7 +2062,9 @@ export function PrintModal({
   // - Single printer selected
   // - For archives: plate is selected (for multi-plate) or not required (single-plate)
   // - For library files: always show (no plate selection)
-  const showFilamentMapping = effectivePrinterId && selectedPlates.size <= 1 && (
+  // Never for separate jobs: the panel maps the first file's filaments, and
+  // each job is mapped against its own file at dispatch.
+  const showFilamentMapping = !isSeparateJobs && effectivePrinterId && selectedPlates.size <= 1 && (
     isLibraryFile || (isMultiPlate ? selectedPlate !== null : true)
   );
 
@@ -1791,7 +2079,7 @@ export function PrintModal({
   // plate *per printer*, so those items ship without a mapping and the scheduler
   // computes one per plate when it picks the printer.
   const showPerPlateFilamentMapping =
-    !!effectivePrinterId && isMultiPlateSelection && selectedPrinters.length === 1;
+    !isSeparateJobs && !!effectivePrinterId && isMultiPlateSelection && selectedPrinters.length === 1;
 
   // Model mode has no printer and so no trays to map onto; what it offers instead
   // is the filament each slot must be printed in, which the scheduler matches
@@ -1799,8 +2087,10 @@ export function PrintModal({
   // filaments to offer as alternatives.
   // Cross-model items have no targetModel by design — their candidates each
   // carry their own — so gate on having somewhere to source choices from.
+  // Separate jobs send no overrides: the slots listed are the first file's.
   const showFilamentOverride =
     assignmentMode === 'model'
+    && !isSeparateJobs
     && (isCrossModel || !!targetModel)
     && !!effectiveAvailableFilaments
     && effectiveAvailableFilaments.length > 0;
@@ -1860,9 +2150,10 @@ export function PrintModal({
                 early so the user knows which plate to mount before scheduling
                 (#1281). PlateSelector renders its own per-plate badges for
                 multi-plate files; this badge covers the single-plate case and
-                the multi-plate case where exactly one plate is selected. */}
+                the multi-plate case where exactly one plate is selected.
+                Not for separate jobs: it would name the first file's plate. */}
             {(() => {
-              if (!plates.length) return null;
+              if (isSeparateJobs || !plates.length) return null;
               const target = selectedPlate != null
                 ? plates.find(p => p.index === selectedPlate)
                 : plates[0];
@@ -1880,8 +2171,8 @@ export function PrintModal({
                 selecting printers. Cross-model has no use for it: the plate is
                 chosen per candidate in the list below, and this selector's own
                 choice never reached the request — it only decided which plate
-                the filament panel described (#3101). */}
-            {!isCrossModel && (
+                the filament panel described (#3101). Separate jobs likewise. */}
+            {!isMultiFile && (
               <PlateSelector
                 plates={plates}
                 isMultiPlate={isMultiPlate}
@@ -1914,10 +2205,26 @@ export function PrintModal({
               />
             )}
 
+            {/* What the marked files become (#3462). Only for a selection made
+                in the File Manager: a version group is one job sliced for
+                different printers, for which only alternatives make sense.
+                Its hint, or why Alternatives is blocked, is the one
+                explanation of the mode, so the list below shows none. */}
+            {showModeToggle && (
+              <MultiFileModeToggle
+                value={multiFileMode}
+                onChange={handleMultiFileModeChange}
+                blockedModel={duplicateModel(candidates)}
+                disabled={isPending}
+              />
+            )}
+
             {/* Cross-model alternatives (#671) replace the printer picker entirely:
                 the user already answered "which printer" by choosing these files,
-                and the remaining question is only which they'd rather have. */}
-            {isCrossModel && (
+                and the remaining question is only which they'd rather have.
+                Separate jobs use the same list for their queue order and keep
+                the printer picker below. */}
+            {isMultiFile && (
               <VariantCandidates
                 candidates={candidates}
                 onReorder={setCandidates}
@@ -1925,6 +2232,9 @@ export function PrintModal({
                 onPlateChange={(fileId, plateId) =>
                   setCandidatePlates((prev) => ({ ...prev, [fileId]: plateId }))
                 }
+                onPlatesReadyChange={setCandidatePlatesReady}
+                title={isSeparateJobs ? t('printModal.multiFile.orderTitle') : undefined}
+                help={showModeToggle ? '' : isSeparateJobs ? t('printModal.multiFile.separateHint') : undefined}
               />
             )}
 
@@ -1955,7 +2265,9 @@ export function PrintModal({
                 // fan-out across printers ships no mapping at all (the scheduler maps
                 // each plate against the printer it picks), so the editor would be
                 // collecting tray choices it then throws away. Withhold its input.
-                filamentReqs={isMultiPlateSelection ? undefined : mappingFilamentReqs}
+                // Separate jobs alike: those are the first file's filaments, and
+                // every job ships without a mapping.
+                filamentReqs={isMultiPlateSelection || isSeparateJobs ? undefined : mappingFilamentReqs}
                 onAutoConfigurePrinter={multiPrinterMapping.autoConfigurePrinter}
                 onUpdatePrinterConfig={multiPrinterMapping.updatePrinterConfig}
                 assignmentMode={assignmentMode}
@@ -1964,10 +2276,24 @@ export function PrintModal({
                 onTargetModelChange={setTargetModel}
                 targetLocation={targetLocation}
                 onTargetLocationChange={setTargetLocation}
-                slicedForModel={slicedForModel}
+                slicedForModel={selectorModel}
               />
             )}
 
+            {/* "Any X" for separate jobs: each file is gated on its own (#2578),
+                and the backend would refuse these one by one. */}
+            {separateJobsIncompatible.length > 0 && (
+              <div className="p-3 mb-2 bg-red-50 dark:bg-red-500/10 border border-red-300 dark:border-red-500/30 rounded-lg flex items-center gap-2">
+                <AlertCircle className="w-4 h-4 text-red-600 dark:text-red-400 flex-shrink-0" />
+                <span className="text-sm text-red-700 dark:text-red-400">
+                  {t('printModal.multiFile.incompatible', {
+                    model: targetModel,
+                    files: separateJobsIncompatible.map((f) => f.filename).join(', '),
+                  })}
+                </span>
+              </div>
+            )}
+
             {/* Filament override - shown in model mode when filament requirements are available */}
             {showFilamentOverride && !isMultiPlateSelection && effectiveFilamentReqs && (
               <FilamentOverride
@@ -2011,7 +2337,7 @@ export function PrintModal({
             })}
 
             {/* Compatibility warning when sliced model doesn't match selected printer */}
-            {slicedForModel && assignmentMode === 'printer' && selectedPrinters.length === 1 && (() => {
+            {!isSeparateJobs && slicedForModel && assignmentMode === 'printer' && selectedPrinters.length === 1 && (() => {
               const selectedPrinter = printers?.find(p => p.id === selectedPrinters[0]);
               if (selectedPrinter && selectedPrinter.model && slicedForModel !== selectedPrinter.model) {
                 return (
@@ -2026,6 +2352,37 @@ export function PrintModal({
               return null;
             })()}
 
+            {/* The same warning for separate jobs, naming each file that was
+                sliced for another model. Every selected printer gets every
+                file, so there is one line per model among them; with mixed
+                files the picker shows all printers unfiltered. A warning, not
+                a block, as for one file. */}
+            {isSeparateJobs && assignmentMode === 'printer' && (() => {
+              const printerModels = new Set(
+                selectedPrinters
+                  .map((id) => printers?.find((p) => p.id === id)?.model)
+                  .filter((model): model is string => !!model),
+              );
+              return [...printerModels].map((printerModel) => {
+                const mismatched = filesNotSlicedFor(candidates, printerModel);
+                if (mismatched.length === 0) return null;
+                return (
+                  <div
+                    key={printerModel}
+                    className="p-3 mb-2 bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-300 dark:border-yellow-500/30 rounded-lg flex items-center gap-2"
+                  >
+                    <AlertTriangle className="w-4 h-4 text-yellow-600 dark:text-yellow-400 flex-shrink-0" />
+                    <span className="text-sm text-yellow-700 dark:text-yellow-400">
+                      {t('printModal.multiFile.notSlicedFor', {
+                        model: printerModel,
+                        files: mismatched.map((f) => f.filename).join(', '),
+                      })}
+                    </span>
+                  </div>
+                );
+              });
+            })()}
+
             {/* Warning when archive data couldn't be loaded */}
             {archiveDataMissing && (
               <div className="flex items-start gap-2 p-3 mb-2 bg-orange-50 dark:bg-orange-500/10 border border-orange-300 dark:border-orange-500/30 rounded-lg text-sm">
@@ -2163,7 +2520,10 @@ export function PrintModal({
                 />
                 {quantity > 1 && (
                   <span className="text-xs text-bambu-gray">
-                    {t('queue.quantityHint', 'Creates {{count}} queue items', { count: quantity })}
+                    {/* Separate jobs post the quantity once per listed file. */}
+                    {t('queue.quantityHint', 'Creates {{count}} queue items', {
+                      count: isSeparateJobs ? quantity * candidates.length : quantity,
+                    })}
                   </span>
                 )}
               </div>
@@ -2209,7 +2569,7 @@ export function PrintModal({
 
             {/* Waiting for the printer's AMS status: submitting now would map
                 against zero known trays and dispatch to the empty external feed (#2589). */}
-            {assignmentMode === 'printer' && selectedPrinters.length === 1 && printerStatusLoading && (
+            {waitingForAmsStatus && (
               <div className="mb-4 p-3 bg-blue-100 dark:bg-blue-500/20 border border-blue-500/50 rounded-lg text-sm text-blue-700 dark:text-blue-400 flex items-center gap-2">
                 <Loader2 className="w-4 h-4 animate-spin" />
                 {t('printModal.waitingForAmsStatus', {

+ 55 - 0
frontend/src/components/PrintModal/multiFile.ts

@@ -0,0 +1,55 @@
+import { isGcodeCompatible } from '../../utils/printer';
+import type { VariantCandidate } from './VariantCandidates';
+
+/**
+ * Rules for choosing between the two ways of printing several selected sliced
+ * files (#3462): "alternatives" (one job, the first free printer of a matching
+ * model prints its file, #671) or "separate" (one queue item per file).
+ */
+export type MultiFileMode = 'alternatives' | 'separate';
+
+// Case and outer spaces only, not isGcodeCompatible's looser normalizer: a
+// duplicate blocks "Alternatives", so it has to be certain, and the backend's
+// normalizer has the final say on which models are the same anyway.
+const norm = (m: string) => m.trim().toLowerCase();
+
+/** The model that occurs more than once (spelled as in its first occurrence), or null. Unknown models never count. */
+export function duplicateModel(files: VariantCandidate[]): string | null {
+  const seen = new Set<string>();
+  for (const f of files) {
+    const m = f.sliced_for_model;
+    if (!m) continue;
+    const key = norm(m);
+    if (seen.has(key)) {
+      return files.find((x) => x.sliced_for_model && norm(x.sliced_for_model) === key)!.sliced_for_model;
+    }
+    seen.add(key);
+  }
+  return null;
+}
+
+/** Alternatives only make sense when every file has a distinct, known model; otherwise separate jobs. */
+export function defaultMultiFileMode(files: VariantCandidate[]): MultiFileMode {
+  if (files.some((f) => !f.sliced_for_model)) return 'separate';
+  return duplicateModel(files) ? 'separate' : 'alternatives';
+}
+
+/** The model all files share (first spelling), or null when they differ or one is unknown. */
+export function commonModel(files: VariantCandidate[]): string | null {
+  if (files.length === 0) return null;
+  const first = files[0].sliced_for_model;
+  if (!first) return null;
+  return files.every((f) => f.sliced_for_model && norm(f.sliced_for_model) === norm(first)) ? first : null;
+}
+
+/** Files that cannot run on the target model; files of unknown model count as compatible. */
+export function filesIncompatibleWith(files: VariantCandidate[], targetModel: string | null): VariantCandidate[] {
+  if (!targetModel) return [];
+  return files.filter((f) => !isGcodeCompatible(f.sliced_for_model, targetModel));
+}
+
+/** Files sliced for a different model than the printer's (exact comparison, like the single-file warning in index.tsx). */
+export function filesNotSlicedFor(files: VariantCandidate[], printerModel: string | null): VariantCandidate[] {
+  if (!printerModel) return [];
+  return files.filter((f) => f.sliced_for_model && f.sliced_for_model !== printerModel);
+}

+ 185 - 0
frontend/src/components/PrintModal/separateJobs.ts

@@ -0,0 +1,185 @@
+import type { PrintQueueItemCreate } from '../../api/client';
+import type { AssignmentMode, PrintOptions, ScheduleOptions } from './types';
+import type { VariantCandidate } from './VariantCandidates';
+
+/** What `queueSeparateJobs` got into the queue, and where it stopped. */
+export interface SeparateJobsResult {
+  /** Successful POSTs. */
+  itemsCreated: number;
+  /** ASAP items inserted so far, counting `asapInsertedBefore`. */
+  asapInserted: number;
+  /** Files now queued on every printer, counting pairs from `alreadyQueued`. */
+  queuedFileIds: number[];
+  /** `queuedPairKey`s of this call's successful POSTs. */
+  queuedPairs: string[];
+  /** Ids of the items this call created (the first of each POST). */
+  queuedItemIds: number[];
+  /** Batches this call's POSTs created (quantity > 1). */
+  queuedBatchIds: number[];
+  /** The refused file and the server's message, null when everything went through. */
+  failure: { file: VariantCandidate; message: string } | null;
+}
+
+/**
+ * What a partly queued run leaves for the next dialog on the same files
+ * (#3471 review): closing the dialog must not forget what went in.
+ */
+export interface SeparateJobsResume {
+  /** `queuedPairKey`s already in the queue. */
+  queuedPairs: string[];
+  /** ASAP positions taken so far, so the rest goes in behind them. Only the
+   *  fallback: the queue is read again before the rest goes in (see
+   *  `runAsapTail`), since it may have changed meanwhile. */
+  asapInserted: number;
+  /** The run's own queue items and batches, to find where it ends now. */
+  queuedItemIds?: number[];
+  queuedBatchIds?: number[];
+  /** What the run was submitted with, so the rest is queued the same way. */
+  settings?: SeparateJobsSettings;
+}
+
+/**
+ * The choices a separate-jobs run was submitted with (#3471 review). A
+ * reopened dialog starts from them instead of its defaults: otherwise a quick
+ * Print would finish the run on the default printer, with each file's first
+ * plate and default options.
+ */
+export interface SeparateJobsSettings {
+  /** Each listed file's plate. */
+  plateByFile: Record<number, number | null>;
+  assignmentMode: AssignmentMode;
+  printerIds: number[];
+  targetModel: string | null;
+  targetLocation: string | null;
+  quantity: number;
+  printOptions: PrintOptions;
+  scheduleOptions: ScheduleOptions;
+  costCenterId: number | null;
+}
+
+/** Reported after a refusal: the resume state, and which files are done. */
+export interface SeparateJobsProgress extends SeparateJobsResume {
+  /** Files queued on every printer; they leave the selection. */
+  queuedFileIds: number[];
+  /** Files still listed, in list order. */
+  remainingFileIds: number[];
+}
+
+/**
+ * Where a run's ASAP items end in the queue as it is now: the highest position
+ * among its own pending items (or their batch copies), 0 when none is pending
+ * any more. The rest of the run goes in right behind it, even after other
+ * items were added, moved or started since the run's first submit.
+ */
+export function runAsapTail(
+  queue: ReadonlyArray<{ id: number; position: number; batch_id?: number | null }>,
+  itemIds: ReadonlyArray<number>,
+  batchIds: ReadonlyArray<number>,
+): number {
+  let tail = 0;
+  for (const item of queue) {
+    const own = itemIds.includes(item.id) || (item.batch_id != null && batchIds.includes(item.batch_id));
+    if (own && item.position > tail) tail = item.position;
+  }
+  return tail;
+}
+
+/** One queue item: a file on a printer (`null` = any printer of the model). */
+export function queuedPairKey(fileId: number, printerId: number | null): string {
+  return `${fileId}:${printerId}`;
+}
+
+/** Whether any printer has `fileId` among the `queuedPairKey`s in `pairs`. */
+export function fileHasQueuedPair(pairs: Iterable<string>, fileId: number): boolean {
+  const prefix = `${fileId}:`;
+  for (const key of pairs) {
+    if (key.startsWith(prefix)) return true;
+  }
+  return false;
+}
+
+/**
+ * Queue loop for "Separate jobs" when several sliced files are selected
+ * (#3462): one queue item per file (per selected printer), in list order.
+ *
+ * It stops at the first refusal. That keeps the queue order intact and lets a
+ * second submit continue with the rest. The caller passes the pairs earlier
+ * submits queued as `alreadyQueued`, and they are skipped: a file one printer
+ * took and another refused stays in the list, and without the pairs a second
+ * submit would queue it on the first printer again.
+ */
+export async function queueSeparateJobs(args: {
+  files: VariantCandidate[];
+  plateByFile: Record<number, number | null>;
+  /** [null] for "any printer of the model". */
+  printerIds: (number | null)[];
+  /** `queuedPairKey`s queued by earlier submits of this dialog. */
+  alreadyQueued?: ReadonlySet<string>;
+  /** ASAP items earlier submits inserted; this call's positions follow them. */
+  asapInsertedBefore?: number;
+  quantity: number;
+  asap: boolean;
+  buildItem: (
+    file: VariantCandidate,
+    plateId: number | null,
+    printerId: number | null,
+    printerIndex: number,
+  ) => PrintQueueItemCreate;
+  addToQueue: (data: PrintQueueItemCreate) => Promise<unknown>;
+  onProgress?: (current: number, total: number) => void;
+}): Promise<SeparateJobsResult> {
+  const { files, plateByFile, printerIds, quantity, asap, buildItem, addToQueue, onProgress } = args;
+  const alreadyQueued = args.alreadyQueued ?? new Set<string>();
+  const isDone = (fileId: number, printerId: number | null) => alreadyQueued.has(queuedPairKey(fileId, printerId));
+  const total = files.reduce(
+    (sum, file) => sum + printerIds.filter((printerId) => !isDone(file.id, printerId)).length,
+    0,
+  );
+  const queuedFileIds: number[] = [];
+  const queuedPairs: string[] = [];
+  const queuedItemIds: number[] = [];
+  const queuedBatchIds: number[] = [];
+  let itemsCreated = 0;
+  // Same single counter as applyAsapInsertion in index.tsx: position = items
+  // inserted so far + 1, then advance by this call's quantity. It starts at
+  // what earlier submits inserted: the backend moves everything at or below a
+  // position down, so restarting at 1 would put the rest ahead of them.
+  let asapInserted = args.asapInsertedBefore ?? 0;
+
+  for (const file of files) {
+    const plateId = plateByFile[file.id] ?? null;
+    for (let printerIndex = 0; printerIndex < printerIds.length; printerIndex++) {
+      // printerIndex still counts every printer, so stagger groups stay put.
+      if (isDone(file.id, printerIds[printerIndex])) continue;
+      const data = buildItem(file, plateId, printerIds[printerIndex], printerIndex);
+      if (quantity > 1) data.quantity = quantity;
+      if (asap) {
+        data.insert_at_top = true;
+        data.insert_position = asapInserted + 1;
+      }
+      let created: unknown;
+      try {
+        created = await addToQueue(data);
+      } catch (error) {
+        return {
+          itemsCreated,
+          asapInserted,
+          queuedFileIds,
+          queuedPairs,
+          queuedItemIds,
+          queuedBatchIds,
+          failure: { file, message: error instanceof Error ? error.message : String(error) },
+        };
+      }
+      if (asap) asapInserted += quantity;
+      const item = created as { id?: unknown; batch_id?: unknown } | null | undefined;
+      if (typeof item?.id === 'number') queuedItemIds.push(item.id);
+      if (typeof item?.batch_id === 'number') queuedBatchIds.push(item.batch_id);
+      queuedPairs.push(queuedPairKey(file.id, printerIds[printerIndex]));
+      itemsCreated++;
+      onProgress?.(itemsCreated, total);
+    }
+    queuedFileIds.push(file.id);
+  }
+  return { itemsCreated, asapInserted, queuedFileIds, queuedPairs, queuedItemIds, queuedBatchIds, failure: null };
+}

+ 31 - 5
frontend/src/components/PrintModal/types.ts

@@ -1,5 +1,6 @@
 import type { PrintQueueItem, Printer, CalibrationMode } from '../../api/client';
 import type { VariantCandidate } from './VariantCandidates';
+import type { SeparateJobsProgress, SeparateJobsResume } from './separateJobs';
 
 export type { CalibrationMode };
 export type { VariantCandidate };
@@ -47,17 +48,42 @@ export interface PrintModalProps {
    *  so transient uploads don't linger in File Manager. Only applies to library-file prints. */
   cleanupLibraryAfterDispatch?: boolean;
   /**
-   * Cross-model alternatives (#671): the same job sliced for several printers,
-   * to be queued as ONE item that runs on whichever frees up first.
+   * Several sliced files to print at once, in the order given.
    *
    * Supplied by the File Manager when the user multi-selects sliced files, or
-   * when the clicked file belongs to a variant group. Two or more entries put
-   * the modal in cross-model mode: the printer picker is replaced by the ordered
-   * candidate list, and submit posts `variants` instead of a single file.
+   * when the clicked file belongs to a variant group. Two or more entries show
+   * the ordered candidate list, where each file's plate is chosen, and become
+   * one of two things:
+   * - Cross-model alternatives (#671): the same job sliced for several
+   *   printers, queued as ONE item that runs on whichever frees up first. The
+   *   printer picker is replaced by the list, and submit posts `variants`
+   *   instead of a single file. Without `separateJobsAllowed` this is the only
+   *   mode.
+   * - Separate jobs (#3462), only with `separateJobsAllowed`: one queue item
+   *   per file, in list order, and the printer picker stays.
    * `libraryFileId` must still be the first candidate — the shared filament and
    * plate preview reads from it.
    */
   variantFiles?: VariantCandidate[];
+  /**
+   * Let the user queue `variantFiles` as separate jobs instead (#3462): one
+   * queue item per file, in list order, on the printer or model picked as for
+   * a single file. Set only for files the user marked together in the File
+   * Manager, never for a version group (#2570): a group is by definition the
+   * same job sliced for different printers, which only alternatives express.
+   * Without it the modal offers the alternatives alone, as before.
+   */
+  separateJobsAllowed?: boolean;
+  /**
+   * The rest of a partly queued separate-jobs run, closed after a refusal and
+   * opened again on the files still to queue (#3471 review). Its pairs are
+   * skipped and its ASAP positions continued; the mode is not offered again,
+   * and a single remaining file still opens as that run's rest.
+   */
+  resumeSeparateJobs?: SeparateJobsResume;
+  /** Called after each refusal in separate jobs with what is in the queue so
+   *  far, so the opener can resume the run if the dialog is closed. */
+  onSeparateJobsProgress?: (progress: SeparateJobsProgress) => void;
 }
 
 /**

+ 13 - 1
frontend/src/i18n/locales/de.ts

@@ -4247,8 +4247,8 @@ export default {
       groupAction: 'Als Versionen gruppieren',
       groupTooltip: 'Diese Dateien als denselben Auftrag markieren, gesliced für verschiedene Drucker',
       grouped: '{{count}} Dateien als Versionen gruppiert',
-      printAlternatives: 'Drucken ({{count}} Alternativen)',
     },
+    printFiles: 'Drucken ({{count}} Dateien)',
     title: 'Dateimanager',
     subtitle: 'Organisieren und verwalten Sie Ihre Druckdateien',
     uploadFiles: 'Dateien hochladen',
@@ -5679,6 +5679,18 @@ export default {
 
   // Print modal
   printModal: {
+    multiFile: {
+      groupLabel: 'Was aus den gewählten Dateien wird',
+      alternatives: 'Alternativen',
+      separate: 'Einzelne Aufträge',
+      alternativesHint: 'Ein Auftrag. Der erste passende Drucker, der frei wird, druckt seine Datei.',
+      separateHint: 'Ein Warteschlangeneintrag je Datei, in der Reihenfolge unten angelegt. Die Optionen gelten für jeden Eintrag.',
+      alternativesBlocked: 'Mehr als eine Datei ist für {{model}} gesliced. Alternativen brauchen für jede Datei ein anderes Druckermodell.',
+      orderTitle: 'Reihenfolge in der Warteschlange',
+      incompatible: 'Kann nicht an {{model}}-Drucker gesendet werden: {{files}}',
+      notSlicedFor: 'Nicht für {{model}} gesliced: {{files}}',
+      failed: 'Nicht eingereiht: {{filename}} ({{message}}). Davor eingereiht: {{queued}}.',
+    },
     variants: {
       editNote: 'Diese Alternativen wurden beim Einreihen festgelegt. Zum Ändern abbrechen und neu einreihen.',
       title: 'Drucker-Alternativen',

+ 13 - 1
frontend/src/i18n/locales/en.ts

@@ -4279,8 +4279,8 @@ export default {
       groupAction: 'Group as versions',
       groupTooltip: 'Mark these files as the same job sliced for different printers',
       grouped: 'Grouped {{count}} files as versions',
-      printAlternatives: 'Print ({{count}} alternatives)',
     },
+    printFiles: 'Print ({{count}} files)',
     title: 'File Manager',
     subtitle: 'Organize and manage your print files',
     uploadFiles: 'Upload Files',
@@ -5726,6 +5726,18 @@ export default {
 
   // Print modal
   printModal: {
+    multiFile: {
+      groupLabel: 'What the selected files become',
+      alternatives: 'Alternatives',
+      separate: 'Separate jobs',
+      alternativesHint: 'One job. The first matching printer to free up prints its file.',
+      separateHint: 'One queue item per file, added in the order below. The options apply to every item.',
+      alternativesBlocked: 'More than one file is sliced for {{model}}. Alternatives need a different printer model for each file.',
+      orderTitle: 'Queue order',
+      incompatible: 'Cannot be dispatched to {{model}} printers: {{files}}',
+      notSlicedFor: 'Not sliced for {{model}}: {{files}}',
+      failed: 'Not queued: {{filename}} ({{message}}). Queued before it: {{queued}}.',
+    },
     variants: {
       editNote: 'These alternatives were set when the job was queued. Cancel and re-queue to change them.',
       title: 'Printer alternatives',

+ 13 - 1
frontend/src/i18n/locales/es.ts

@@ -4249,8 +4249,8 @@ export default {
       groupAction: 'Agrupar como versiones',
       groupTooltip: 'Marcar estos archivos como el mismo trabajo laminado para distintas impresoras',
       grouped: '{{count}} archivos agrupados como versiones',
-      printAlternatives: 'Imprimir ({{count}} alternativas)',
     },
+    printFiles: 'Imprimir ({{count}} archivos)',
     title: 'Gestor de archivos',
     subtitle: 'Organice y gestione sus archivos de impresión',
     uploadFiles: 'Subir archivos',
@@ -5686,6 +5686,18 @@ export default {
 
   // Print modal
   printModal: {
+    multiFile: {
+      groupLabel: 'En qué se convierten los archivos seleccionados',
+      alternatives: 'Alternativas',
+      separate: 'Trabajos separados',
+      alternativesHint: 'Un solo trabajo. La primera impresora compatible que quede libre imprime su archivo.',
+      separateHint: 'Un elemento de cola por archivo, añadidos en el orden de abajo. Las opciones se aplican a todos los elementos.',
+      alternativesBlocked: 'Hay más de un archivo laminado para {{model}}. Las alternativas necesitan un modelo de impresora distinto para cada archivo.',
+      orderTitle: 'Orden de la cola',
+      incompatible: 'No se puede enviar a impresoras {{model}}: {{files}}',
+      notSlicedFor: 'No laminado para {{model}}: {{files}}',
+      failed: 'No añadido a la cola: {{filename}} ({{message}}). Añadidos antes: {{queued}}.',
+    },
     variants: {
       editNote: 'Estas alternativas se fijaron al poner el trabajo en cola. Cancela y vuelve a encolar para cambiarlas.',
       title: 'Alternativas de impresora',

+ 13 - 1
frontend/src/i18n/locales/fr.ts

@@ -4235,8 +4235,8 @@ export default {
       groupAction: 'Grouper comme versions',
       groupTooltip: 'Marquer ces fichiers comme le même travail tranché pour différentes imprimantes',
       grouped: '{{count}} fichiers groupés comme versions',
-      printAlternatives: 'Imprimer ({{count}} alternatives)',
     },
+    printFiles: 'Imprimer ({{count}} fichiers)',
     title: 'Gestionnaire de fichiers',
     subtitle: 'Organisez vos fichiers d\'impression',
     uploadFiles: 'Téléverser fichiers',
@@ -5666,6 +5666,18 @@ export default {
 
   // Print modal
   printModal: {
+    multiFile: {
+      groupLabel: 'Ce que deviennent les fichiers sélectionnés',
+      alternatives: 'Alternatives',
+      separate: 'Tâches séparées',
+      alternativesHint: 'Une seule tâche. La première imprimante compatible qui se libère imprime son fichier.',
+      separateHint: 'Un élément de file par fichier, ajouté dans l\'ordre ci-dessous. Les options s\'appliquent à chaque élément.',
+      alternativesBlocked: 'Plusieurs fichiers sont tranchés pour {{model}}. Les alternatives exigent un modèle d\'imprimante différent pour chaque fichier.',
+      orderTitle: 'Ordre dans la file',
+      incompatible: 'Impossible d\'envoyer aux imprimantes {{model}} : {{files}}',
+      notSlicedFor: 'Non tranché pour {{model}} : {{files}}',
+      failed: 'Non mis en file : {{filename}} ({{message}}). Mis en file avant : {{queued}}.',
+    },
     variants: {
       editNote: 'Ces alternatives ont été définies lors de la mise en file. Annulez et remettez en file pour les modifier.',
       title: 'Alternatives d\'imprimante',

+ 13 - 1
frontend/src/i18n/locales/it.ts

@@ -4234,8 +4234,8 @@ export default {
       groupAction: 'Raggruppa come versioni',
       groupTooltip: 'Segna questi file come lo stesso lavoro elaborato per stampanti diverse',
       grouped: '{{count}} file raggruppati come versioni',
-      printAlternatives: 'Stampa ({{count}} alternative)',
     },
+    printFiles: 'Stampa ({{count}} file)',
     title: 'Gestore file',
     subtitle: 'Organizza e gestisci i tuoi file di stampa',
     uploadFiles: 'Carica file',
@@ -5665,6 +5665,18 @@ export default {
 
   // Print modal
   printModal: {
+    multiFile: {
+      groupLabel: 'Cosa diventano i file selezionati',
+      alternatives: 'Alternative',
+      separate: 'Lavori separati',
+      alternativesHint: 'Un solo lavoro. La prima stampante compatibile che si libera stampa il proprio file.',
+      separateHint: 'Un elemento in coda per file, aggiunto nell\'ordine sotto. Le opzioni valgono per ogni elemento.',
+      alternativesBlocked: 'Più di un file è stato elaborato per {{model}}. Le alternative richiedono un modello di stampante diverso per ogni file.',
+      orderTitle: 'Ordine in coda',
+      incompatible: 'Impossibile inviare alle stampanti {{model}}: {{files}}',
+      notSlicedFor: 'Non elaborato per {{model}}: {{files}}',
+      failed: 'Non messo in coda: {{filename}} ({{message}}). Messi in coda prima: {{queued}}.',
+    },
     variants: {
       editNote: 'Queste alternative sono state definite al momento dell\'accodamento. Annulla e riaccoda per modificarle.',
       title: 'Alternative di stampante',

+ 13 - 1
frontend/src/i18n/locales/ja.ts

@@ -4247,8 +4247,8 @@ export default {
       groupAction: 'バージョンとしてグループ化',
       groupTooltip: 'これらのファイルを、異なるプリンター向けにスライスした同一ジョブとして扱います',
       grouped: '{{count}}個のファイルをバージョンとしてグループ化しました',
-      printAlternatives: '印刷({{count}}件の候補)',
     },
+    printFiles: '印刷({{count}}個のファイル)',
     title: 'ファイル管理',
     subtitle: '印刷ファイルの整理と管理',
     uploadFiles: 'ファイルをアップロード',
@@ -5679,6 +5679,18 @@ export default {
 
   // Print modal
   printModal: {
+    multiFile: {
+      groupLabel: '選択したファイルの扱い',
+      alternatives: '候補',
+      separate: '個別のジョブ',
+      alternativesHint: '1つのジョブです。条件に合う最初に空いたプリンターがそのファイルを印刷します。',
+      separateHint: 'ファイルごとに1件のキュー項目を、下の順序で追加します。オプションはすべての項目に適用されます。',
+      alternativesBlocked: '{{model}} 向けにスライスされたファイルが複数あります。候補には、ファイルごとに異なるプリンターモデルが必要です。',
+      orderTitle: 'キューの順序',
+      incompatible: '{{model}} プリンターには送信できません:{{files}}',
+      notSlicedFor: '{{model}} 向けにスライスされていません:{{files}}',
+      failed: 'キューに追加されませんでした:{{filename}}({{message}})。これより前に追加済み:{{queued}}。',
+    },
     variants: {
       editNote: 'これらの候補はキュー追加時に決まります。変更するにはキャンセルして追加し直してください。',
       title: 'プリンターの候補',

+ 13 - 1
frontend/src/i18n/locales/ko.ts

@@ -4058,8 +4058,8 @@ export default {
       groupAction: '버전으로 그룹화',
       groupTooltip: '이 파일들을 서로 다른 프린터용으로 슬라이스한 동일 작업으로 표시합니다',
       grouped: '파일 {{count}}개를 버전으로 그룹화했습니다',
-      printAlternatives: '인쇄 (대안 {{count}}개)',
     },
+    printFiles: '인쇄 (파일 {{count}}개)',
     title: '파일 관리자',
     subtitle: '인쇄 파일 정리 및 관리',
     uploadFiles: '파일 업로드',
@@ -5449,6 +5449,18 @@ export default {
     assignedNotConfigured: 'Bambuddy에서 할당됨, 프린터에는 아직 설정되지 않음. 출력이 끝나 슬롯이 비면 프린터에 설정됩니다.'
   },
   printModal: {
+    multiFile: {
+      groupLabel: '선택한 파일의 처리 방식',
+      alternatives: '대안',
+      separate: '개별 작업',
+      alternativesHint: '작업 하나입니다. 가장 먼저 비는 호환 프린터가 자기 파일을 인쇄합니다.',
+      separateHint: '파일마다 대기열 항목 하나를 아래 순서대로 추가합니다. 옵션은 모든 항목에 적용됩니다.',
+      alternativesBlocked: '{{model}}용으로 슬라이스된 파일이 둘 이상입니다. 대안에는 파일마다 서로 다른 프린터 모델이 필요합니다.',
+      orderTitle: '대기열 순서',
+      incompatible: '{{model}} 프린터로 보낼 수 없음: {{files}}',
+      notSlicedFor: '{{model}}용으로 슬라이스되지 않음: {{files}}',
+      failed: '대기열에 추가되지 않음: {{filename}} ({{message}}). 먼저 추가된 항목: {{queued}}.',
+    },
     variants: {
       editNote: '이 대안은 대기열에 추가할 때 정해집니다. 변경하려면 취소 후 다시 추가하세요.',
       title: '프린터 대안',

+ 13 - 1
frontend/src/i18n/locales/nl.ts

@@ -4277,8 +4277,8 @@ export default {
       groupAction: 'Als versies groeperen',
       groupTooltip: 'Markeer deze bestanden als dezelfde taak, gesliced voor verschillende printers',
       grouped: '{{count}} bestanden als versies gegroepeerd',
-      printAlternatives: 'Afdrukken ({{count}} alternatieven)',
     },
+    printFiles: 'Afdrukken ({{count}} bestanden)',
     title: 'Bestandsbeheer',
     subtitle: 'Organiseer en beheer je afdrukbestanden',
     uploadFiles: 'Bestanden uploaden',
@@ -5724,6 +5724,18 @@ export default {
 
   // Print modal
   printModal: {
+    multiFile: {
+      groupLabel: 'Wat er van de geselecteerde bestanden wordt',
+      alternatives: 'Alternatieven',
+      separate: 'Aparte opdrachten',
+      alternativesHint: 'Eén opdracht. De eerste passende printer die vrijkomt print zijn bestand.',
+      separateHint: 'Eén wachtrij-item per bestand, toegevoegd in de volgorde hieronder. De opties gelden voor elk item.',
+      alternativesBlocked: 'Meer dan één bestand is gesliced voor {{model}}. Alternatieven vragen om een ander printermodel per bestand.',
+      orderTitle: 'Volgorde in de wachtrij',
+      incompatible: 'Kan niet worden verzonden naar {{model}}-printers: {{files}}',
+      notSlicedFor: 'Niet gesliced voor {{model}}: {{files}}',
+      failed: 'Niet in de wachtrij gezet: {{filename}} ({{message}}). Eerder in de wachtrij gezet: {{queued}}.',
+    },
     variants: {
       editNote: 'Deze alternatieven zijn ingesteld toen de taak in de wachtrij werd geplaatst. Annuleer en plaats opnieuw in de wachtrij om ze te wijzigen.',
       title: 'Printeralternatieven',

+ 13 - 1
frontend/src/i18n/locales/pl.ts

@@ -4279,8 +4279,8 @@ export default {
       groupAction: 'Grupuj jako wersje',
       groupTooltip: 'Oznacz te pliki jako to samo zadanie pocięte dla różnych drukarek',
       grouped: 'Zgrupowano pliki jako wersje: {{count}}',
-      printAlternatives: 'Drukuj (alternatywy: {{count}})',
     },
+    printFiles: 'Drukuj (pliki: {{count}})',
     title: 'Menedżer plików',
     subtitle: 'Organizuj pliki do druku i zarządzaj nimi',
     uploadFiles: 'Prześlij pliki',
@@ -5726,6 +5726,18 @@ export default {
 
   // Print modal
   printModal: {
+    multiFile: {
+      groupLabel: 'Czym stają się wybrane pliki',
+      alternatives: 'Alternatywy',
+      separate: 'Osobne zadania',
+      alternativesHint: 'Jedno zadanie. Pierwsza pasująca drukarka, która się zwolni, drukuje swój plik.',
+      separateHint: 'Jedna pozycja kolejki na plik, dodawana w kolejności poniżej. Opcje dotyczą każdej pozycji.',
+      alternativesBlocked: 'Więcej niż jeden plik jest pocięty dla {{model}}. Alternatywy wymagają innego modelu drukarki dla każdego pliku.',
+      orderTitle: 'Kolejność w kolejce',
+      incompatible: 'Nie można wysłać do drukarek {{model}}: {{files}}',
+      notSlicedFor: 'Nie pocięto dla {{model}}: {{files}}',
+      failed: 'Nie dodano do kolejki: {{filename}} ({{message}}). Dodano wcześniej: {{queued}}.',
+    },
     variants: {
       editNote: 'Te alternatywy ustawiono podczas dodawania zadania do kolejki. Aby je zmienić, anuluj zadanie i dodaj je ponownie.',
       title: 'Alternatywne drukarki',

+ 13 - 1
frontend/src/i18n/locales/pt-BR.ts

@@ -4234,8 +4234,8 @@ export default {
       groupAction: 'Agrupar como versões',
       groupTooltip: 'Marcar estes arquivos como o mesmo trabalho fatiado para impressoras diferentes',
       grouped: '{{count}} arquivos agrupados como versões',
-      printAlternatives: 'Imprimir ({{count}} alternativas)',
     },
+    printFiles: 'Imprimir ({{count}} arquivos)',
     title: 'Gerenciador de Arquivos',
     subtitle: 'Organize e gerencie seus arquivos de impressão',
     uploadFiles: 'Enviar Arquivos',
@@ -5665,6 +5665,18 @@ export default {
 
   // Print modal
   printModal: {
+    multiFile: {
+      groupLabel: 'O que os arquivos selecionados se tornam',
+      alternatives: 'Alternativas',
+      separate: 'Trabalhos separados',
+      alternativesHint: 'Um único trabalho. A primeira impressora compatível que ficar livre imprime o seu arquivo.',
+      separateHint: 'Um item de fila por arquivo, adicionado na ordem abaixo. As opções valem para todos os itens.',
+      alternativesBlocked: 'Mais de um arquivo está fatiado para {{model}}. As alternativas exigem um modelo de impressora diferente para cada arquivo.',
+      orderTitle: 'Ordem da fila',
+      incompatible: 'Não pode ser enviado para impressoras {{model}}: {{files}}',
+      notSlicedFor: 'Não fatiado para {{model}}: {{files}}',
+      failed: 'Não adicionado à fila: {{filename}} ({{message}}). Adicionados antes: {{queued}}.',
+    },
     variants: {
       editNote: 'Estas alternativas foram definidas ao enfileirar o trabalho. Cancele e enfileire de novo para alterá-las.',
       title: 'Alternativas de impressora',

+ 13 - 1
frontend/src/i18n/locales/ru.ts

@@ -4050,8 +4050,8 @@ export default {
       groupAction: 'Сгруппировать как версии',
       groupTooltip: 'Пометить эти файлы как одну задачу, нарезанную для разных принтеров',
       grouped: 'Файлов сгруппировано как версии: {{count}}',
-      printAlternatives: 'Печать (вариантов: {{count}})',
     },
+    printFiles: 'Печать (файлов: {{count}})',
     title: "Файловый менеджер",
     subtitle: "Организация и управление файлами для печати",
     uploadFiles: "Загрузить файлы",
@@ -5438,6 +5438,18 @@ export default {
     remainingUnit: "осталось",
   },
   printModal: {
+    multiFile: {
+      groupLabel: 'Во что превращаются выбранные файлы',
+      alternatives: 'Варианты',
+      separate: 'Отдельные задания',
+      alternativesHint: 'Одно задание. Первый подходящий принтер, который освободится, печатает свой файл.',
+      separateHint: 'Один элемент очереди на файл, в порядке, указанном ниже. Параметры применяются ко всем элементам.',
+      alternativesBlocked: 'Для {{model}} нарезано больше одного файла. Для вариантов каждому файлу нужна другая модель принтера.',
+      orderTitle: 'Порядок в очереди',
+      incompatible: 'Нельзя отправить на принтеры {{model}}: {{files}}',
+      notSlicedFor: 'Не нарезано для {{model}}: {{files}}',
+      failed: 'Не поставлено в очередь: {{filename}} ({{message}}). Поставлено ранее: {{queued}}.',
+    },
     variants: {
       editNote: 'Эти варианты заданы при добавлении в очередь. Чтобы изменить их, отмените и добавьте заново.',
       title: 'Варианты принтера',

+ 13 - 1
frontend/src/i18n/locales/sv.ts

@@ -4309,8 +4309,8 @@ errors: {
       groupAction: 'Gruppera som versioner',
       groupTooltip: 'Markera dessa filer som samma jobb slicade för olika skrivare',
       grouped: 'Grupperade {{count}} filer som versioner',
-      printAlternatives: 'Skriv ut ({{count}} alternativ)',
     },
+    printFiles: 'Skriv ut ({{count}} filer)',
     title: 'Filhanterare',
     subtitle: 'Organisera och hantera dina utskriftsfiler',
     uploadFiles: 'Ladda upp filer',
@@ -5755,6 +5755,18 @@ errors: {
 
   // Print modal
   printModal: {
+    multiFile: {
+      groupLabel: 'Vad de valda filerna blir',
+      alternatives: 'Alternativ',
+      separate: 'Separata jobb',
+      alternativesHint: 'Ett jobb. Den första matchande skrivaren som blir ledig skriver ut sin fil.',
+      separateHint: 'Ett köobjekt per fil, tillagda i ordningen nedan. Inställningarna gäller för varje objekt.',
+      alternativesBlocked: 'Mer än en fil är slicad för {{model}}. Alternativ kräver en annan skrivarmodell för varje fil.',
+      orderTitle: 'Köordning',
+      incompatible: 'Kan inte skickas till {{model}}-skrivare: {{files}}',
+      notSlicedFor: 'Inte slicad för {{model}}: {{files}}',
+      failed: 'Inte köad: {{filename}} ({{message}}). Köade före: {{queued}}.',
+    },
     variants: {
       editNote: 'Dessa alternativ sattes när jobbet köades. Avbryt och köa om för att ändra dem.',
       title: 'Skrivaralternativ',

+ 13 - 1
frontend/src/i18n/locales/tr.ts

@@ -4242,8 +4242,8 @@ export default {
       groupAction: 'Sürüm olarak grupla',
       groupTooltip: 'Bu dosyaları farklı yazıcılar için dilimlenmiş aynı iş olarak işaretle',
       grouped: '{{count}} dosya sürüm olarak gruplandı',
-      printAlternatives: 'Yazdır ({{count}} alternatif)',
     },
+    printFiles: 'Yazdır ({{count}} dosya)',
     title: 'Dosya Yöneticisi',
     subtitle: 'Baskı dosyalarınızı organize edin ve yönetin',
     uploadFiles: 'Dosya Yükle',
@@ -5655,6 +5655,18 @@ export default {
 
   // Baskı modali
   printModal: {
+    multiFile: {
+      groupLabel: 'Seçilen dosyalar neye dönüşür',
+      alternatives: 'Alternatifler',
+      separate: 'Ayrı işler',
+      alternativesHint: 'Tek iş. Boşa çıkan ilk uygun yazıcı kendi dosyasını yazdırır.',
+      separateHint: 'Dosya başına bir kuyruk öğesi, aşağıdaki sırayla eklenir. Seçenekler her öğeye uygulanır.',
+      alternativesBlocked: '{{model}} için birden fazla dosya dilimlenmiş. Alternatifler için her dosyada farklı bir yazıcı modeli gerekir.',
+      orderTitle: 'Kuyruk sırası',
+      incompatible: '{{model}} yazıcılarına gönderilemez: {{files}}',
+      notSlicedFor: '{{model}} için dilimlenmemiş: {{files}}',
+      failed: 'Kuyruğa eklenmedi: {{filename}} ({{message}}). Önce eklenenler: {{queued}}.',
+    },
     variants: {
       editNote: 'Bu alternatifler iş kuyruğa alınırken belirlendi. Değiştirmek için iptal edip yeniden kuyruğa alın.',
       title: 'Yazıcı alternatifleri',

+ 13 - 1
frontend/src/i18n/locales/uk.ts

@@ -4274,8 +4274,8 @@ export default {
       groupAction: 'Згрупувати як версії',
       groupTooltip: 'Позначити ці файли як одне завдання, нарізане для різних принтерів',
       grouped: 'Файлів згруповано як версії: {{count}}',
-      printAlternatives: 'Друк (варіантів: {{count}})',
     },
+    printFiles: 'Друк (файлів: {{count}})',
     title: "Менеджер файлів",
     subtitle: "Упорядковуйте файли друку та керуйте ними",
     uploadFiles: "Вивантажити файли",
@@ -5719,6 +5719,18 @@ export default {
 
   // Print modal
   printModal: {
+    multiFile: {
+      groupLabel: 'Чим стають вибрані файли',
+      alternatives: 'Варіанти',
+      separate: 'Окремі завдання',
+      alternativesHint: 'Одне завдання. Перший відповідний принтер, що звільниться, друкує свій файл.',
+      separateHint: 'Один елемент черги на файл, додані в порядку нижче. Параметри застосовуються до кожного елемента.',
+      alternativesBlocked: 'Для {{model}} нарізано більше одного файлу. Для варіантів кожному файлу потрібна інша модель принтера.',
+      orderTitle: 'Порядок у черзі',
+      incompatible: 'Не можна надіслати на принтери {{model}}: {{files}}',
+      notSlicedFor: 'Не нарізано для {{model}}: {{files}}',
+      failed: 'Не поставлено в чергу: {{filename}} ({{message}}). Поставлено раніше: {{queued}}.',
+    },
     variants: {
       editNote: 'Ці варіанти задано під час додавання в чергу. Щоб змінити, скасуйте та додайте знову.',
       title: 'Варіанти принтера',

+ 13 - 1
frontend/src/i18n/locales/zh-CN.ts

@@ -4234,8 +4234,8 @@ export default {
       groupAction: '归为版本组',
       groupTooltip: '将这些文件标记为针对不同打印机切片的同一任务',
       grouped: '已将 {{count}} 个文件归为版本组',
-      printAlternatives: '打印({{count}} 个备选)',
     },
+    printFiles: '打印({{count}} 个文件)',
     title: '文件管理器',
     subtitle: '组织和管理您的打印文件',
     uploadFiles: '上传文件',
@@ -5665,6 +5665,18 @@ export default {
 
   // Print modal
   printModal: {
+    multiFile: {
+      groupLabel: '所选文件的处理方式',
+      alternatives: '备选',
+      separate: '独立任务',
+      alternativesHint: '一个任务。第一台空闲且匹配的打印机会打印它对应的文件。',
+      separateHint: '每个文件一个队列项,按下方顺序添加。选项适用于每个队列项。',
+      alternativesBlocked: '有多个文件是为 {{model}} 切片的。备选要求每个文件对应不同的打印机型号。',
+      orderTitle: '队列顺序',
+      incompatible: '无法发送到 {{model}} 打印机:{{files}}',
+      notSlicedFor: '未为 {{model}} 切片:{{files}}',
+      failed: '未加入队列:{{filename}}({{message}})。此前已加入:{{queued}}。',
+    },
     variants: {
       editNote: '这些备选在任务加入队列时确定。如需更改,请取消后重新加入队列。',
       title: '打印机备选',

+ 13 - 1
frontend/src/i18n/locales/zh-TW.ts

@@ -4234,8 +4234,8 @@ export default {
       groupAction: '歸為版本群組',
       groupTooltip: '將這些檔案標記為針對不同印表機切片的同一工作',
       grouped: '已將 {{count}} 個檔案歸為版本群組',
-      printAlternatives: '列印({{count}} 個備選)',
     },
+    printFiles: '列印({{count}} 個檔案)',
     title: '檔案管理器',
     subtitle: '組織和管理您的列印檔案',
     uploadFiles: '上傳檔案',
@@ -5665,6 +5665,18 @@ export default {
 
   // Print modal
   printModal: {
+    multiFile: {
+      groupLabel: '所選檔案的處理方式',
+      alternatives: '備選',
+      separate: '獨立工作',
+      alternativesHint: '一項工作。第一台空閒且相符的印表機會列印它對應的檔案。',
+      separateHint: '每個檔案一個佇列項目,依下方順序加入。選項適用於每個項目。',
+      alternativesBlocked: '有多個檔案是為 {{model}} 切片的。備選要求每個檔案對應不同的印表機型號。',
+      orderTitle: '佇列順序',
+      incompatible: '無法傳送到 {{model}} 印表機:{{files}}',
+      notSlicedFor: '未為 {{model}} 切片:{{files}}',
+      failed: '未加入佇列:{{filename}}({{message}})。此前已加入:{{queued}}。',
+    },
     variants: {
       editNote: '這些備選在工作加入佇列時確定。如需變更,請取消後重新加入佇列。',
       title: '印表機備選',

+ 61 - 6
frontend/src/pages/FileManagerPage.tsx

@@ -73,6 +73,7 @@ import { ConfirmModal } from '../components/ConfirmModal';
 import { ContextMenu, type ContextMenuItem } from '../components/ContextMenu';
 import { LibraryTagFilterRail } from '../components/LibraryTagFilterRail';
 import { PrintModal } from '../components/PrintModal';
+import type { SeparateJobsProgress } from '../components/PrintModal/separateJobs';
 import { ModelViewerModal } from '../components/ModelViewerModal';
 import { SliceModal } from '../components/SliceModal';
 import { CombineFilesModal } from '../components/CombineFilesModal';
@@ -1848,6 +1849,10 @@ export function FileManagerPage() {
   const [folderDropTarget, setFolderDropTarget] = useState<string | null>(null);
   const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'file' | 'folder' | 'bulk'; id: number; count?: number } | null>(null);
   const [printFile, setPrintFile] = useState<LibraryFileListItem | null>(null);
+  // A separate-jobs run (#3462) that was partly queued: what is in the queue,
+  // so closing the dialog after a refusal doesn't queue it again on the next
+  // Print (#3471 review). Kept while the selection is exactly the files left.
+  const [separateJobsRun, setSeparateJobsRun] = useState<SeparateJobsProgress | null>(null);
   const [sliceFile, setSliceFile] = useState<LibraryFileListItem | null>(null);
   const [showCombineModal, setShowCombineModal] = useState(false);
   // A file just built by "Combine to 3MF", handed straight to the SliceModal.
@@ -2792,13 +2797,46 @@ export function FileManagerPage() {
     enabled: !!printFile?.variant_group_id,
   });
 
+  // The selection is the rest of a partly queued run: the files it left, and
+  // nothing else. Any other selection is a new print.
+  const selectionIsRunRest = useMemo(() => {
+    if (!separateJobsRun) return false;
+    const left = separateJobsRun.remainingFileIds;
+    return (
+      left.length > 0 &&
+      selectedSlicedFiles.length === left.length &&
+      selectedSlicedFiles.every((f) => left.includes(f.id))
+    );
+  }, [separateJobsRun, selectedSlicedFiles]);
+  // With several files selected the dialog shows the selection whichever
+  // file's Print was clicked, so it is the run's rest then too; otherwise a
+  // card's Print would start it as a new run and queue again what a printer
+  // already has (#3471 review). One file left resumes only from that file.
+  const resumeRun =
+    selectionIsRunRest &&
+    !!printFile &&
+    (selectedSlicedFiles.length > 1 || separateJobsRun!.remainingFileIds.includes(printFile.id));
+
+  // A run whose rest is no longer selected is over; a later selection of the
+  // same files is a new print.
+  useEffect(() => {
+    if (separateJobsRun && !printFile && !selectionIsRunRest) setSeparateJobsRun(null);
+  }, [separateJobsRun, printFile, selectionIsRunRest]);
+
   // Candidates for a cross-model print (#671), or undefined for an ordinary one.
   // An explicit multi-selection wins over the group: the user just said, in this
-  // action, which files they meant.
+  // action, which files they meant. The rest of a partly queued run stays a
+  // list even with one file left: a printer may already have it.
   const printVariantFiles = useMemo(() => {
     if (!printFile) return undefined;
-    if (selectedSlicedFiles.length > 1) {
-      return selectedSlicedFiles.map(f => ({
+    if (selectedSlicedFiles.length > 1 || resumeRun) {
+      // The rest of a run keeps the order the run had in its dialog, not the
+      // order the files were ticked in.
+      const order = resumeRun ? separateJobsRun!.remainingFileIds : null;
+      const files = order
+        ? [...selectedSlicedFiles].sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id))
+        : selectedSlicedFiles;
+      return files.map(f => ({
         id: f.id,
         filename: f.filename,
         sliced_for_model: f.sliced_for_model,
@@ -2812,7 +2850,7 @@ export function FileManagerPage() {
       }));
     }
     return undefined;
-  }, [printFile, selectedSlicedFiles, printFileGroup]);
+  }, [resumeRun, separateJobsRun, printFile, selectedSlicedFiles, printFileGroup]);
 
   // The list the arrow keys walk: the one that actually holds the focused
   // file, which may be an intermediate column rather than the selected
@@ -3632,7 +3670,7 @@ export function FileManagerPage() {
                         <Printer className="w-4 h-4 sm:mr-1" />
                         <span className="hidden sm:inline">
                           {selectedSlicedFiles.length > 1
-                            ? t('fileManager.variants.printAlternatives', { count: selectedSlicedFiles.length })
+                            ? t('fileManager.printFiles', { count: selectedSlicedFiles.length })
                             : t('common.print')}
                         </span>
                       </Button>
@@ -4287,6 +4325,12 @@ export function FileManagerPage() {
           mode="create"
           libraryFileId={printVariantFiles?.[0]?.id ?? printFile.id}
           variantFiles={printVariantFiles}
+          // A version group is by definition alternatives; only an explicit
+          // selection (the same one printVariantFiles prefers) may mean separate
+          // jobs (#3462).
+          separateJobsAllowed={selectedSlicedFiles.length > 1 || resumeRun}
+          resumeSeparateJobs={resumeRun ? separateJobsRun! : undefined}
+          onSeparateJobsProgress={setSeparateJobsRun}
           // Naming a cross-model job after one of its files reads as though the
           // others aren't part of it.
           archiveName={
@@ -4294,9 +4338,20 @@ export function FileManagerPage() {
               ? `${printVariantFiles[0].filename} ${t('common.plusNMore', { count: printVariantFiles.length - 1 })}`
               : printFile.print_name || printFile.filename
           }
-          onClose={() => setPrintFile(null)}
+          onClose={() => {
+            setPrintFile(null);
+            // Closed after a refusal: what went in leaves the selection, and the
+            // rest reopens as that run (see separateJobsRun).
+            if (separateJobsRun) {
+              const done = separateJobsRun.queuedFileIds;
+              setSelectedFiles((prev) => prev.filter((id) => !done.includes(id)));
+              queryClient.invalidateQueries({ queryKey: ['library-files'] });
+              queryClient.invalidateQueries({ queryKey: ['queue'] });
+            }
+          }}
           onSuccess={() => {
             setPrintFile(null);
+            setSeparateJobsRun(null);
             setSelectedFiles([]);
             queryClient.invalidateQueries({ queryKey: ['library-files'] });
             queryClient.invalidateQueries({ queryKey: ['queue'] });