Browse Source

feat(slice-modal): Bundle tier for picking presets from imported .bbscfg

  Closes the loop on the bundle work: users who imported a Printer
  Preset Bundle via Settings → Slicer Bundles can now pick it in the
  SliceModal and slice through the bundle dispatch path the backend
  already supports.

  UX:
  - New "Slicer bundle" picker at the top of the modal, rendered only
    when at least one bundle is imported (GET /slicer/bundles non-empty)
  - Selecting a bundle replaces cloud/local/standard preset dropdowns
    with bundle-scoped pickers (process + per-slot filament names from
    the bundle). Printer is implicit (each .bbscfg has exactly one).
  - Submit routes through SliceRequest.bundle so the backend skips
    PresetRef resolution and asks the sidecar to materialise the JSON
    triplet from the stored bundle by name.
  - "None" leaves the modal on the original preset triplet path.

  Frontend types: SliceBundleSpec + bundle?: SliceBundleSpec on SliceRequest.
maziggy 3 weeks ago
parent
commit
a50958e426

+ 173 - 0
frontend/src/__tests__/components/SliceModal.test.tsx

@@ -26,6 +26,7 @@ vi.mock('../../api/client', () => ({
     getArchivePlates: vi.fn(),
     getLibraryFileFilamentRequirements: vi.fn(),
     getArchiveFilamentRequirements: vi.fn(),
+    listSlicerBundles: vi.fn(),
     getSettings: vi.fn().mockResolvedValue({}),
     updateSettings: vi.fn().mockResolvedValue({}),
   },
@@ -40,6 +41,7 @@ const mockApi = api as unknown as {
   getArchivePlates: ReturnType<typeof vi.fn>;
   getLibraryFileFilamentRequirements: ReturnType<typeof vi.fn>;
   getArchiveFilamentRequirements: ReturnType<typeof vi.fn>;
+  listSlicerBundles: ReturnType<typeof vi.fn>;
 };
 
 function makeUnified(overrides: Partial<UnifiedPresetsResponse> = {}): UnifiedPresetsResponse {
@@ -119,6 +121,10 @@ describe('SliceModal', () => {
       plate_id: 1,
       filaments: [],
     });
+    // Default: no bundles imported. Bundle-tier tests override this with a
+    // populated array; everything else inherits the empty default so the
+    // modal renders the original (preset-only) layout.
+    mockApi.listSlicerBundles.mockResolvedValue([]);
   });
 
   it('auto-selects the highest-priority tier per slot on first load', async () => {
@@ -957,4 +963,171 @@ describe('SliceModal', () => {
       expect(body.filament_presets[1]).toEqual({ source: 'cloud', id: 'F-GREY' });
     });
   });
+
+  // -------------------------------------------------------------------------
+  // Bundle tier — picking an imported .bbscfg replaces the cloud/local/standard
+  // dropdown set with bundle-scoped pickers and routes the slice through the
+  // backend's bundle dispatch shape (no PresetRefs in the body).
+  // -------------------------------------------------------------------------
+
+  describe('Bundle tier', () => {
+    const sampleBundle = {
+      id: 'abc123def456abcd',
+      printer_preset_name: '# Bambu Lab H2D 0.4 nozzle',
+      printer: ['# Bambu Lab H2D 0.4 nozzle'],
+      process: [
+        '# 0.20mm Standard @BBL H2D',
+        '# 0.16mm Standard @BBL H2D',
+      ],
+      filament: [
+        '# Bambu PLA Basic @BBL H2D',
+        '# Bambu PETG HF @BBL H2D 0.4 nozzle',
+      ],
+      version: '02.06.00.50',
+    };
+
+    it('hides the bundle picker when no bundles are imported', async () => {
+      // Default beforeEach already returns []; assert the picker isn't
+      // rendered so users without bundles see the original layout.
+      renderWithTracker({
+        source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
+        onClose: vi.fn(),
+      });
+      await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
+      expect(screen.queryByText(/slicer bundle/i)).toBeNull();
+    });
+
+    it('renders the bundle picker when at least one bundle is imported', async () => {
+      mockApi.listSlicerBundles.mockResolvedValue([sampleBundle]);
+      renderWithTracker({
+        source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
+        onClose: vi.fn(),
+      });
+      await waitFor(() =>
+        expect(screen.getByText(/slicer bundle/i)).toBeDefined(),
+      );
+      // The bundle option is in the dropdown.
+      const bundleSelect = screen.getAllByRole('combobox')[0] as HTMLSelectElement;
+      expect(
+        Array.from(bundleSelect.options).map((o) => o.textContent),
+      ).toContain('# Bambu Lab H2D 0.4 nozzle');
+    });
+
+    it('replaces preset dropdowns with bundle-scoped pickers when a bundle is selected', async () => {
+      mockApi.listSlicerBundles.mockResolvedValue([sampleBundle]);
+      renderWithTracker({
+        source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
+        onClose: vi.fn(),
+      });
+      await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
+
+      const user = userEvent.setup();
+      const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
+      // First select is the bundle picker (new top-of-modal dropdown).
+      await user.selectOptions(selects[0], sampleBundle.id);
+
+      // Wait for the bundle-mode UI to take over: process options should
+      // now reflect the bundle's process names.
+      await waitFor(() => {
+        expect(
+          screen.getByText('# 0.20mm Standard @BBL H2D'),
+        ).toBeDefined();
+      });
+
+      // The static printer label shows the bundle's printer. Both the
+      // <option> in the bundle picker and the read-only <div> below
+      // contain this text, so use getAllByText.
+      const printerNameMatches = screen.getAllByText('# Bambu Lab H2D 0.4 nozzle');
+      expect(printerNameMatches.length).toBeGreaterThanOrEqual(2);
+
+      // Cloud/local/standard preset names from the original tier no longer
+      // appear in the visible dropdowns (the bundle replaced them).
+      const visibleSelects = screen.getAllByRole('combobox') as HTMLSelectElement[];
+      const allOptionTexts = visibleSelects.flatMap((sel) =>
+        Array.from(sel.options).map((o) => o.textContent ?? ''),
+      );
+      // Cloud printer name shouldn't be in any visible dropdown anymore.
+      expect(allOptionTexts).not.toContain('My Custom X1C');
+    });
+
+    it('submits bundle dispatch shape (no PresetRefs) when a bundle is selected', async () => {
+      mockApi.listSlicerBundles.mockResolvedValue([sampleBundle]);
+      mockApi.sliceLibraryFile.mockResolvedValue({
+        job_id: 99,
+        status: 'pending',
+        status_url: '/api/v1/slice-jobs/99',
+      });
+
+      renderWithTracker({
+        source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
+        onClose: vi.fn(),
+      });
+      await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
+
+      const user = userEvent.setup();
+      const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
+      await user.selectOptions(selects[0], sampleBundle.id);
+
+      // Wait for bundle-mode dropdowns to render.
+      await waitFor(() =>
+        expect(screen.getByText('# 0.20mm Standard @BBL H2D')).toBeDefined(),
+      );
+      await user.click(screen.getByRole('button', { name: /^Slice$/ }));
+
+      await waitFor(() => {
+        const [fileId, body] = mockApi.sliceLibraryFile.mock.calls[0];
+        expect(fileId).toBe(100);
+        expect(body.bundle).toEqual({
+          bundle_id: sampleBundle.id,
+          printer_name: '# Bambu Lab H2D 0.4 nozzle',
+          process_name: '# 0.20mm Standard @BBL H2D',
+          filament_names: ['# Bambu PLA Basic @BBL H2D'],
+        });
+        // The preset triplet must NOT be in the body — bundle dispatch
+        // skips PresetRef resolution entirely on the backend.
+        expect(body.printer_preset).toBeUndefined();
+        expect(body.process_preset).toBeUndefined();
+        expect(body.filament_presets).toBeUndefined();
+      });
+    });
+
+    it('switching back to "None" restores the preset triplet path', async () => {
+      mockApi.listSlicerBundles.mockResolvedValue([sampleBundle]);
+      mockApi.sliceLibraryFile.mockResolvedValue({
+        job_id: 100,
+        status: 'pending',
+        status_url: '/api/v1/slice-jobs/100',
+      });
+
+      renderWithTracker({
+        source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
+        onClose: vi.fn(),
+      });
+      await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
+
+      const user = userEvent.setup();
+      const bundleSelect = screen.getAllByRole('combobox')[0] as HTMLSelectElement;
+      await user.selectOptions(bundleSelect, sampleBundle.id);
+      await waitFor(() =>
+        expect(screen.getByText('# 0.20mm Standard @BBL H2D')).toBeDefined(),
+      );
+
+      // Flip back to None.
+      await user.selectOptions(bundleSelect, '');
+      await waitFor(() => {
+        const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
+        // After de-selecting bundle, the printer dropdown's first option
+        // should be one of the original cloud/local/standard names.
+        const printerOptions = Array.from(selects[1].options).map((o) => o.textContent);
+        expect(printerOptions).toContain('My Custom X1C');
+      });
+
+      await user.click(screen.getByRole('button', { name: /^Slice$/ }));
+      await waitFor(() => {
+        const [, body] = mockApi.sliceLibraryFile.mock.calls[0];
+        expect(body.bundle).toBeUndefined();
+        expect(body.printer_preset).toBeDefined();
+      });
+    });
+  });
 });

+ 12 - 0
frontend/src/api/client.ts

@@ -1155,6 +1155,13 @@ export interface PresetRef {
   source: PresetSource;
   id: string;
 }
+export interface SliceBundleSpec {
+  bundle_id: string;
+  printer_name: string;
+  process_name: string;
+  // Per-slot filament names in plate order. Index 0 = slot 1, etc.
+  filament_names: string[];
+}
 export interface SliceRequest {
   printer_preset_id?: number;
   process_preset_id?: number;
@@ -1167,6 +1174,11 @@ export interface SliceRequest {
   // backend validator promotes a singular into a one-element list when this
   // is omitted, so legacy single-color clients keep working unchanged.
   filament_presets?: PresetRef[];
+  // Bundle dispatch: when set, the backend skips PresetRef resolution and
+  // picks the JSON triplet from a sidecar-stored .bbscfg by name. Mutually
+  // exclusive with the preset fields above (validator accepts both, but
+  // dispatch ignores the preset side when bundle is set).
+  bundle?: SliceBundleSpec;
   plate?: number;
   export_3mf?: boolean;
 }

+ 308 - 47
frontend/src/components/SliceModal.tsx

@@ -1,4 +1,4 @@
-import { Cloud, CloudOff, Cog, Loader2, X } from 'lucide-react';
+import { Cloud, CloudOff, Cog, Loader2, Package, X } from 'lucide-react';
 import { useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useMutation, useQuery } from '@tanstack/react-query';
@@ -6,7 +6,10 @@ import {
   api,
   type PresetRef,
   type PresetSource,
+  type SliceBundleSpec,
   type SliceJobProgress,
+  type SliceRequest,
+  type SlicerBundle,
   type SlicerCloudStatus,
   type UnifiedPreset,
   type UnifiedPresetsBySlot,
@@ -245,6 +248,14 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
   // entry per AMS slot the plate uses. Pre-pick (effect below) initialises
   // each slot from the source plate's required (type, colour).
   const [filamentPresets, setFilamentPresets] = useState<(PresetRef | null)[]>([]);
+  // Bundle dispatch (alternative to the preset triplet). When non-null, the
+  // SliceModal hides the cloud/local/standard preset dropdowns and shows
+  // bundle-scoped pickers (process + per-slot filament from the chosen
+  // bundle's contents). Submit routes through the backend's bundle dispatch
+  // (`SliceRequest.bundle`) which skips PresetRef resolution.
+  const [selectedBundleId, setSelectedBundleId] = useState<string | null>(null);
+  const [bundleProcessName, setBundleProcessName] = useState<string | null>(null);
+  const [bundleFilamentNames, setBundleFilamentNames] = useState<(string | null)[]>([]);
   const [errorMessage, setErrorMessage] = useState<string | null>(null);
   // null = plate not yet picked (or single-plate / non-3MF — picker is skipped
   // and we'll backfill 1 at submit time). Set to a 1-indexed plate number once
@@ -321,6 +332,24 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
     enabled: !platesQuery.isLoading && !needsPlatePicker,
   });
 
+  // Imported Printer Preset Bundles (.bbscfg). Empty list when no sidecar
+  // configured / no bundles imported yet; the bundle picker hides itself
+  // in that case so users without bundles see the original modal layout.
+  const bundlesQuery = useQuery({
+    queryKey: ['slicerBundles'],
+    queryFn: api.listSlicerBundles,
+    staleTime: 60_000,
+    enabled: !platesQuery.isLoading && !needsPlatePicker,
+    // Bundle listing is a hard 503 when the sidecar is offline; don't
+    // retry tight loops in that case.
+    retry: false,
+  });
+  const selectedBundle: SlicerBundle | null = useMemo(() => {
+    if (!selectedBundleId || !bundlesQuery.data) return null;
+    return bundlesQuery.data.find((b) => b.id === selectedBundleId) ?? null;
+  }, [selectedBundleId, bundlesQuery.data]);
+  const isBundleMode = selectedBundle != null;
+
   // Printer / process pre-pick: see SLICE_MODAL_TIER_ORDER. Runs once when
   // presets first arrive; subsequent re-renders preserve any manual choice.
   useEffect(() => {
@@ -348,29 +377,82 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
     });
   }, [presetsQuery.data, filamentSlots]);
 
+  // Bundle-mode auto-pick: when the user picks a bundle (or the slot count
+  // changes after the picker is open), default the process to the bundle's
+  // first listed process and every filament slot to the bundle's first
+  // listed filament. Plain string match — bundles store delta files keyed
+  // by user preset name, no scoring needed since the user picks per-slot
+  // afterwards if the default is wrong.
+  useEffect(() => {
+    if (!selectedBundle) {
+      // Reset bundle picks when bundle is cleared so re-selection
+      // re-defaults rather than carrying stale values.
+      setBundleProcessName(null);
+      setBundleFilamentNames([]);
+      return;
+    }
+    setBundleProcessName((current) => {
+      // Preserve a manual pick if it still exists in the bundle; otherwise
+      // re-default. Same shape as the preset auto-pick effect above.
+      if (current && selectedBundle.process.includes(current)) return current;
+      return selectedBundle.process[0] ?? null;
+    });
+    setBundleFilamentNames((current) => {
+      if (current.length === filamentSlots.length && current.every((n) => n != null)) {
+        return current;
+      }
+      const fallback = selectedBundle.filament[0] ?? null;
+      return filamentSlots.map((_, i) => current[i] ?? fallback);
+    });
+  }, [selectedBundle, filamentSlots]);
+
   const enqueueMutation = useMutation({
     mutationFn: async () => {
-      if (
-        !printerPreset ||
-        !processPreset ||
-        filamentPresets.length === 0 ||
-        filamentPresets.some((r) => r == null)
-      ) {
-        throw new Error(t('slice.allPresetsRequired', 'All presets must be selected'));
+      let body: SliceRequest;
+      if (isBundleMode) {
+        // Bundle dispatch path. The selected bundle's first printer is the
+        // implicit printer choice (every .bbscfg carries exactly one).
+        if (
+          !selectedBundle ||
+          !bundleProcessName ||
+          bundleFilamentNames.length === 0 ||
+          bundleFilamentNames.some((n) => n == null)
+        ) {
+          throw new Error(t('slice.bundleAllRequired', 'Bundle process and every filament slot must be picked'));
+        }
+        const bundleSpec: SliceBundleSpec = {
+          bundle_id: selectedBundle.id,
+          printer_name: selectedBundle.printer[0] ?? selectedBundle.printer_preset_name,
+          process_name: bundleProcessName,
+          filament_names: bundleFilamentNames as string[],
+        };
+        body = {
+          bundle: bundleSpec,
+          ...(selectedPlate != null ? { plate: selectedPlate } : {}),
+        };
+      } else {
+        if (
+          !printerPreset ||
+          !processPreset ||
+          filamentPresets.length === 0 ||
+          filamentPresets.some((r) => r == null)
+        ) {
+          throw new Error(t('slice.allPresetsRequired', 'All presets must be selected'));
+        }
+        body = {
+          printer_preset: printerPreset,
+          process_preset: processPreset,
+          // The first slot also goes into the legacy singular field so the
+          // backend's older callers / clients keep behaving the same — the
+          // backend validator prefers `filament_presets` when both are set.
+          filament_preset: filamentPresets[0] as PresetRef,
+          filament_presets: filamentPresets as PresetRef[],
+          // Always send a concrete plate number when the source is multi-plate;
+          // omit otherwise so the backend default applies for STL / single-plate
+          // 3MF sources where the concept doesn't apply.
+          ...(selectedPlate != null ? { plate: selectedPlate } : {}),
+        };
       }
-      const body = {
-        printer_preset: printerPreset,
-        process_preset: processPreset,
-        // The first slot also goes into the legacy singular field so the
-        // backend's older callers / clients keep behaving the same — the
-        // backend validator prefers `filament_presets` when both are set.
-        filament_preset: filamentPresets[0] as PresetRef,
-        filament_presets: filamentPresets as PresetRef[],
-        // Always send a concrete plate number when the source is multi-plate;
-        // omit otherwise so the backend default applies for STL / single-plate
-        // 3MF sources where the concept doesn't apply.
-        ...(selectedPlate != null ? { plate: selectedPlate } : {}),
-      };
       if (source.kind === 'libraryFile') {
         return api.sliceLibraryFile(source.id, body);
       }
@@ -392,10 +474,15 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
   // is desktop-Studio only. If we can match the source's printer model to a
   // SliceModal-known model and the user's chosen printer profile names a
   // different model, surface a warning before they click Slice.
+  //
+  // For bundle mode, the bundle's printer_preset_name plays the same role
+  // as the picked PresetRef's resolved name in preset mode.
   const sourcePrinterModel = platesQuery.data?.source_printer_model ?? null;
-  const printerProfileName = printerPreset
-    ? presetsQuery.data?.[printerPreset.source].printer.find((p) => p.id === printerPreset.id)?.name
-    : null;
+  const printerProfileName = isBundleMode
+    ? selectedBundle?.printer_preset_name.replace(/^# /, '') ?? null
+    : printerPreset
+      ? presetsQuery.data?.[printerPreset.source].printer.find((p) => p.id === printerPreset.id)?.name
+      : null;
   // Profile names follow `<model> <nozzle> nozzle` (e.g. "Bambu Lab H2D 0.4
   // nozzle"). The CLI compat check uses the model prefix; substring match
   // catches both standard and locally-imported user-named profiles that
@@ -417,13 +504,19 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
   //     auto-pick fills these once filamentSlots arrives)
   //   - no printer-mismatch warning is up (clicking would silently fall
   //     back to embedded settings and produce a wrong-printer file)
-  const isReady =
-    printerPreset != null &&
-    processPreset != null &&
-    filamentReqsQuery.isSuccess &&
-    filamentPresets.length > 0 &&
-    filamentPresets.every((r) => r != null) &&
-    !printerMismatch;
+  const isReady = isBundleMode
+    ? selectedBundle != null &&
+      bundleProcessName != null &&
+      filamentReqsQuery.isSuccess &&
+      bundleFilamentNames.length > 0 &&
+      bundleFilamentNames.every((n) => n != null) &&
+      !printerMismatch
+    : printerPreset != null &&
+      processPreset != null &&
+      filamentReqsQuery.isSuccess &&
+      filamentPresets.length > 0 &&
+      filamentPresets.every((r) => r != null) &&
+      !printerMismatch;
   const isEnqueuing = enqueueMutation.isPending;
 
   // Step 1: plate picker for multi-plate 3MF sources. Cancelling closes the
@@ -501,22 +594,63 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
           {presetsQuery.data && (
             <>
               <CloudStatusBanner status={presetsQuery.data.cloud_status} />
-              <PresetDropdown
-                label={t('slice.printer', 'Printer profile')}
-                slot="printer"
-                data={presetsQuery.data}
-                value={printerPreset}
-                onChange={setPrinterPreset}
-                disabled={isEnqueuing}
-              />
-              <PresetDropdown
-                label={t('slice.process', 'Process profile')}
-                slot="process"
-                data={presetsQuery.data}
-                value={processPreset}
-                onChange={setProcessPreset}
-                disabled={isEnqueuing}
-              />
+              {/* Bundle picker — only renders when at least one .bbscfg has
+                  been imported via Settings → Slicer Bundles. Lets the user
+                  trade the cloud/local/standard tier for a single curated
+                  triplet from a previously-uploaded BambuStudio bundle. */}
+              {bundlesQuery.data && bundlesQuery.data.length > 0 && (
+                <BundlePicker
+                  bundles={bundlesQuery.data}
+                  selectedId={selectedBundleId}
+                  onChange={setSelectedBundleId}
+                  disabled={isEnqueuing}
+                />
+              )}
+              {/* Preset triplet — hidden when a bundle is selected so the
+                  user only sees one tier at a time. The bundle's process +
+                  filament dropdowns render below in their stead. */}
+              {!isBundleMode && (
+                <>
+                  <PresetDropdown
+                    label={t('slice.printer', 'Printer profile')}
+                    slot="printer"
+                    data={presetsQuery.data}
+                    value={printerPreset}
+                    onChange={setPrinterPreset}
+                    disabled={isEnqueuing}
+                  />
+                  <PresetDropdown
+                    label={t('slice.process', 'Process profile')}
+                    slot="process"
+                    data={presetsQuery.data}
+                    value={processPreset}
+                    onChange={setProcessPreset}
+                    disabled={isEnqueuing}
+                  />
+                </>
+              )}
+              {isBundleMode && selectedBundle && (
+                <>
+                  {/* Bundle's printer is implicit (each .bbscfg has exactly
+                      one). Show it as a read-only label so the user can
+                      verify the printer they're slicing for. */}
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1">
+                      {t('slice.printer', 'Printer profile')}
+                    </label>
+                    <div className="px-3 py-2 rounded-md bg-bambu-dark/40 border border-bambu-dark-tertiary text-white text-sm">
+                      {selectedBundle.printer_preset_name}
+                    </div>
+                  </div>
+                  <BundleStringDropdown
+                    label={t('slice.process', 'Process profile')}
+                    options={selectedBundle.process}
+                    value={bundleProcessName}
+                    onChange={setBundleProcessName}
+                    disabled={isEnqueuing}
+                  />
+                </>
+              )}
               {/* Filament reqs may need a server-side preview-slice for
                   unsliced project files (single-pass, then cached). Show a
                   scoped spinner so the user sees the printer/process
@@ -526,6 +660,40 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
                   requestId={previewRequestId}
                   sourceName={source.filename}
                 />
+              ) : isBundleMode && selectedBundle ? (
+                filamentSlots.map((slot, idx) => {
+                  const isUsed = slot.used_in_plate !== false;
+                  const baseLabel =
+                    filamentSlots.length > 1
+                      ? t('slice.filamentSlot', {
+                          index: idx + 1,
+                          type: slot.type,
+                          defaultValue: `Filament ${idx + 1} (${slot.type || ''})`,
+                        })
+                      : t('slice.filament', 'Filament profile');
+                  const label = isUsed
+                    ? baseLabel
+                    : `${baseLabel} ${t('slice.notUsedByPlate', '— not used by this plate')}`;
+                  return (
+                    <BundleStringDropdown
+                      key={`bundle-filament-${idx}`}
+                      label={label}
+                      options={selectedBundle.filament}
+                      value={bundleFilamentNames[idx] ?? null}
+                      onChange={(name) =>
+                        setBundleFilamentNames((current) => {
+                          const next = current.length === filamentSlots.length
+                            ? [...current]
+                            : filamentSlots.map((_, i) => current[i] ?? null);
+                          next[idx] = name;
+                          return next;
+                        })
+                      }
+                      disabled={isEnqueuing || !isUsed}
+                      swatchColor={filamentSlots.length > 1 ? slot.color : undefined}
+                    />
+                  );
+                })
               ) : (
                 filamentSlots.map((slot, idx) => {
                   // Slots flagged by the backend as not used by the
@@ -734,3 +902,96 @@ function PresetDropdown({ label, slot, data, value, onChange, disabled, swatchCo
     </label>
   );
 }
+
+// Top-of-modal bundle picker. The "None" option leaves the user on the
+// cloud/local/standard tier path; selecting a bundle id flips the modal
+// into bundle dispatch mode (see SliceModal state above).
+interface BundlePickerProps {
+  bundles: SlicerBundle[];
+  selectedId: string | null;
+  onChange: (id: string | null) => void;
+  disabled?: boolean;
+}
+
+function BundlePicker({ bundles, selectedId, onChange, disabled }: BundlePickerProps) {
+  const { t } = useTranslation();
+  return (
+    <label className="block">
+      <span className="block text-sm text-bambu-gray mb-1 inline-flex items-center gap-1.5">
+        <Package className="w-3.5 h-3.5" />
+        {t('slice.bundle', 'Slicer bundle')}
+      </span>
+      <select
+        value={selectedId ?? ''}
+        onChange={(e) => onChange(e.target.value || null)}
+        disabled={disabled}
+        className="w-full px-3 py-2 rounded-md bg-bambu-dark border border-bambu-dark-tertiary text-white text-sm focus:outline-none focus:border-bambu-gray disabled:opacity-50"
+      >
+        <option value="">
+          {t('slice.bundleNone', '— None (pick presets individually) —')}
+        </option>
+        {bundles.map((b) => (
+          <option key={b.id} value={b.id}>
+            {b.printer_preset_name}
+          </option>
+        ))}
+      </select>
+    </label>
+  );
+}
+
+// Plain-string dropdown used for bundle-mode process / filament selectors.
+// Bundles store presets as a flat list of names within their printer-tied
+// directory, so a `<select>` of strings is enough — no source tier, no
+// optgroups. Same swatch / disabled affordances as the cloud/local/standard
+// PresetDropdown above so the visual rhythm of the form stays consistent.
+interface BundleStringDropdownProps {
+  label: string;
+  options: string[];
+  value: string | null;
+  onChange: (next: string | null) => void;
+  disabled?: boolean;
+  swatchColor?: string;
+}
+
+function BundleStringDropdown({
+  label,
+  options,
+  value,
+  onChange,
+  disabled,
+  swatchColor,
+}: BundleStringDropdownProps) {
+  const { t } = useTranslation();
+  return (
+    <label className="block">
+      <span className="block text-sm text-bambu-gray mb-1 inline-flex items-center gap-1.5">
+        {swatchColor && (
+          <span
+            className="inline-block w-3 h-3 rounded-sm border border-black/20"
+            style={{ backgroundColor: swatchColor || 'transparent' }}
+            aria-hidden
+          />
+        )}
+        <span>{label}</span>
+      </span>
+      <select
+        value={value ?? ''}
+        onChange={(e) => onChange(e.target.value || null)}
+        disabled={disabled || options.length === 0}
+        className="w-full px-3 py-2 rounded-md bg-bambu-dark border border-bambu-dark-tertiary text-white text-sm focus:outline-none focus:border-bambu-gray disabled:opacity-50"
+      >
+        <option value="">
+          {options.length === 0
+            ? t('slice.noPresetsForSlot', 'No presets available')
+            : t('slice.selectPreset', '— Select a preset —')}
+        </option>
+        {options.map((name) => (
+          <option key={name} value={name}>
+            {name}
+          </option>
+        ))}
+      </select>
+    </label>
+  );
+}

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CzFVl3Rw.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-D-Dvx81t.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DmnG_wF0.css


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="./img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="./assets/index-B--K0Euf.js"></script>
-    <link rel="stylesheet" crossorigin href="./assets/index-CzFVl3Rw.css">
+    <script type="module" crossorigin src="./assets/index-D-Dvx81t.js"></script>
+    <link rel="stylesheet" crossorigin href="./assets/index-DmnG_wF0.css">
   </head>
   <body>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff