Browse Source

Let a bug-report capture outlive the panel that started it (#2847)

Step 2 asks the user to reproduce the problem, and the panel sits over
the part of the app they have to reach to do it. Closing it was the
obvious move and it was wrong in two different ways, picked by timing
alone. Reopen inside five minutes and the reset-on-open effect put you
on an empty step 1 while the server stayed at DEBUG, with nothing left
in the flow that could stop it -- only Stop & Submit ever did. Leave it
closed and the cap fired behind you: the panel is hidden but mounted, so
the timer kept running, stopped logging and filed the report with no
window open and no confirmation.

A capture is now written down -- description, email, was_debug and a
start timestamp -- and the reset skips a run in progress, so the panel
reopens on the step it left. The disc turns amber while a capture is
going and Layout marks the compact header's button and offers Resume
report on the debug-logging banner, since a run started there ends at
that panel's button and not at the System page's raw toggle. If the cap
fires while the panel is closed, it opens first, so the submission
happens in front of the user.

Elapsed is measured against the start time rather than counted in ticks,
which a background tab throttles hard enough that five minutes was not
five minutes. That timestamp also lets a run survive a reload, which
matters because reloading is an ordinary step in reproducing a bug: on
mount a stored run is reconciled against /support/debug-logging and
resumed. One that outlived the cap unattended is not resumed and not
filed -- an hour-old description is not a report anyone still expects --
but its log level is put back, which is what stayed wrong indefinitely
before.

The screenshot is deliberately not persisted: a 1920px JPEG runs to
hundreds of kilobytes against an origin-wide budget, and it survives a
close either way.
maziggy 3 weeks ago
parent
commit
713a85d114

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.6b1] - Unreleased
 
 ### Fixed
+- **Closing the bug-report panel no longer throws the capture away and leaves the logs running (#2847)** — Step 2 of the report flow asks you to reproduce the problem, and the panel sits over the part of the app you have to reach to do it. Closing it was the obvious move and it was the wrong one twice over. Reopening put you back on an empty step 1 — while the server was still logging at DEBUG, with nothing left in the flow that could stop it, because only **Stop & Submit** ever did. Leave it closed instead and the five-minute cap eventually fired behind your back: logging stopped and the report was filed with no window open and no confirmation that it had happened. Which of the two you got depended only on whether you reopened the panel inside five minutes. A capture is now a thing that outlives the panel. Closing keeps it running and says so — the bug button turns amber for as long as a capture is going, and clicking it returns you to step 2 with your description, your screenshot and the elapsed timer where you left them. If the cap does fire while the panel is closed, the panel reopens so the submission happens in front of you rather than behind you. The timer is measured against the capture's start time rather than counted in ticks, so a background tab, where browsers throttle timers hard, no longer stretches five minutes into something else. A capture also survives a page reload, which matters because reloading is a perfectly ordinary step in reproducing a bug: the report picks it back up where it was. One that outlived the cap while nobody was watching is not resumed and not filed — a description written an hour ago is not a report you are still expecting — but the log level is put back, which is the part that previously stayed wrong indefinitely. Translated in all locales; wiki updated. Covered by frontend tests.
 - **The File Manager's card menu no longer loses its top entry (#2846)** — In grid view a file card's action menu was drawn inside the card, and the card clipped anything its children painted outside it. A card is as tall as its square thumbnail plus whatever metadata the file has, so an STL — which has none beyond a name and a size — produced the shortest card in the library, about 270px against a seven-entry menu that needs closer to 310px. The difference was one row, and the row it took was the top one: **Slice**, since **Print** is only offered for a file that is already sliced. A 3MF carries a target model and a print count, two more rows, and its card was tall enough, which is why the button appeared there and looked like a file-type rule rather than a layout accident. Nothing about STL was special; the shortest card simply lost the first item, whichever it happened to be. The menu now opens against the viewport, the way the archive card's menu already did, so no card can crop it, and the card no longer clips its own children. List view was never affected — it has no menu, only inline buttons. Covered by a frontend test.
 
 ## [1.2.5.3] - 2026-08-15

+ 158 - 5
frontend/src/__tests__/components/BugReportBubble.test.tsx

@@ -2,7 +2,7 @@
  * Tests for the BugReportBubble component.
  */
 
-import { describe, it, expect } from 'vitest';
+import { describe, it, expect, afterEach, vi } from 'vitest';
 import { render, screen, waitFor } from '../utils';
 import userEvent from '@testing-library/user-event';
 import { http, HttpResponse } from 'msw';
@@ -135,8 +135,7 @@ describe('BugReportBubble', () => {
 
     // Should show step indicators and elapsed timer
     await waitFor(() => {
-      const reproduceText = screen.queryByText(/reproduce|Reproduce|reproduzieren|reproduire|riproduci|再現|reproduza|重现/i);
-      expect(reproduceText).toBeInTheDocument();
+      expect(screen.queryByTestId('bug-report-step-reproduce')).toBeInTheDocument();
     });
 
     // Should show elapsed timer (00:00 format)
@@ -171,7 +170,7 @@ describe('BugReportBubble', () => {
 
     // Wait for logging state, then click stop
     await waitFor(() => {
-      expect(screen.queryByText(/reproduce|Reproduce|reproduzieren|reproduire|riproduci|再現|reproduza|重现/i)).toBeInTheDocument();
+      expect(screen.queryByTestId('bug-report-step-reproduce')).toBeInTheDocument();
     });
 
     // Find and click the Stop & Submit button
@@ -213,7 +212,7 @@ describe('BugReportBubble', () => {
 
     // Wait for logging state, then click stop
     await waitFor(() => {
-      expect(screen.queryByText(/reproduce|Reproduce|reproduzieren|reproduire|riproduci|再現|reproduza|重现/i)).toBeInTheDocument();
+      expect(screen.queryByTestId('bug-report-step-reproduce')).toBeInTheDocument();
     });
 
     const stopBtn = screen.getAllByRole('button').find(
@@ -314,4 +313,158 @@ describe('BugReportBubble', () => {
     expect(await screen.findByText('Known issues found in your logs')).toBeInTheDocument();
     expect(screen.getByText('Printer rejected the access code')).toBeInTheDocument();
   });
+
+  // Step 2 asks the user to reproduce the problem, and the panel sits over the
+  // part of the app they have to reach to do it. Closing it used to be the only
+  // way through and it threw the run away: the reset-on-open effect put the
+  // panel back on step 1 while the server stayed at DEBUG, with nothing left
+  // that could stop it (#2847).
+  describe('a logging run that outlives the panel (#2847)', () => {
+    afterEach(() => {
+      vi.mocked(localStorage.getItem).mockReset();
+    });
+
+    /** The mock is shared with every other localStorage reader in the tree --
+     *  the theme context among them -- so only answer for our own key. */
+    const storeSession = (session: Record<string, unknown>) => {
+      vi.mocked(localStorage.getItem).mockImplementation((key: string) =>
+        key === 'bambuddy-bug-report-session' ? JSON.stringify(session) : null
+      );
+    };
+
+    /** Types a description and presses Start, landing on step 2. */
+    const startRun = async (user: ReturnType<typeof userEvent.setup>, description: string) => {
+      await user.click(screen.getByRole('button'));
+      await user.type(getDescriptionTextarea(), description);
+      const startBtn = getSubmitButton();
+      if (startBtn) await user.click(startBtn);
+      await waitFor(() => {
+        expect(screen.queryByTestId('bug-report-step-reproduce')).toBeInTheDocument();
+      });
+    };
+
+    const closePanel = async (user: ReturnType<typeof userEvent.setup>) => {
+      const closeButton = screen.getAllByRole('button').find((b) => b.querySelector('.lucide-x'));
+      if (closeButton) await user.click(closeButton);
+      await waitFor(() => {
+        expect(screen.queryByTestId('bug-report-step-reproduce')).not.toBeInTheDocument();
+      });
+    };
+
+    it('survives a close and reopens on the step it left, description intact', async () => {
+      const user = userEvent.setup();
+      let stopCalls = 0;
+      let submitted: { description?: string } | null = null;
+      server.use(
+        http.post('*/bug-report/start-logging', () => HttpResponse.json({ started: true, was_debug: false })),
+        http.post('*/bug-report/stop-logging', () => {
+          stopCalls += 1;
+          return HttpResponse.json({ logs: 'captured' });
+        }),
+        http.post('*/bug-report/submit', async ({ request }) => {
+          submitted = (await request.json()) as { description?: string };
+          return HttpResponse.json({ success: true, message: 'ok', issue_number: 7 });
+        }),
+      );
+
+      render(<BugReportBubble />);
+      await startRun(user, 'Queue page freezes');
+      await closePanel(user);
+
+      // Closing is not cancelling: the log level only comes back down when the
+      // user presses Stop & Submit.
+      expect(stopCalls).toBe(0);
+
+      // The disc carries the run's colour, so a closed panel still says a
+      // recording is live and that clicking gets back to it.
+      const disc = screen.getByRole('button');
+      expect(disc.className).toContain('bg-amber-500');
+
+      await user.click(disc);
+      expect(screen.getByTestId('bug-report-step-reproduce')).toBeInTheDocument();
+
+      const stopBtn = screen.getAllByRole('button').find(
+        (b) => b.className.includes('bg-red-500') && !b.className.includes('rounded-full')
+      );
+      if (stopBtn) await user.click(stopBtn);
+
+      await waitFor(() => expect(submitted).not.toBeNull());
+      expect(submitted!.description).toBe('Queue page freezes');
+      expect(stopCalls).toBe(1);
+    });
+
+    it('picks the run back up after a reload while the server is still logging', async () => {
+      const user = userEvent.setup();
+      const startedAt = Date.now() - 30_000;
+      storeSession({ description: 'Printer card goes blank', email: '', wasDebug: false, startedAt });
+      server.use(
+        http.get('*/support/debug-logging', () =>
+          HttpResponse.json({
+            enabled: true,
+            enabled_at: new Date(startedAt).toISOString(),
+            duration_seconds: 30,
+          })
+        ),
+      );
+
+      render(<BugReportBubble />);
+
+      await waitFor(() => {
+        expect(screen.getByRole('button').className).toContain('bg-amber-500');
+      });
+      await user.click(screen.getByRole('button'));
+      expect(screen.getByTestId('bug-report-step-reproduce')).toBeInTheDocument();
+      // Elapsed comes off the run's start time, so the reload does not reset it.
+      expect(screen.getByText('00:30')).toBeInTheDocument();
+    });
+
+    it('drops a stored run the server already stopped', async () => {
+      storeSession({ description: 'stale', email: '', wasDebug: false, startedAt: Date.now() - 30_000 });
+      let stopCalls = 0;
+      server.use(
+        http.get('*/support/debug-logging', () =>
+          HttpResponse.json({ enabled: false, enabled_at: null, duration_seconds: null })
+        ),
+        http.post('*/bug-report/stop-logging', () => {
+          stopCalls += 1;
+          return HttpResponse.json({ logs: '' });
+        }),
+      );
+
+      render(<BugReportBubble />);
+
+      await waitFor(() => expect(localStorage.removeItem).toHaveBeenCalled());
+      expect(screen.getByRole('button').className).toContain('bg-red-500');
+      // Logging is already off; there is nothing to put back.
+      expect(stopCalls).toBe(0);
+    });
+
+    it('restores the log level for a run that outlived the cap, without filing it', async () => {
+      let stopCalls = 0;
+      let submitCalls = 0;
+      storeSession({ description: 'from an hour ago', email: '', wasDebug: false, startedAt: Date.now() - 3_600_000 });
+      server.use(
+        http.get('*/support/debug-logging', () =>
+          HttpResponse.json({ enabled: true, enabled_at: new Date(Date.now() - 3_600_000).toISOString(), duration_seconds: 3600 })
+        ),
+        http.post('*/bug-report/stop-logging', () => {
+          stopCalls += 1;
+          return HttpResponse.json({ logs: '' });
+        }),
+        http.post('*/bug-report/submit', () => {
+          submitCalls += 1;
+          return HttpResponse.json({ success: true, message: 'ok' });
+        }),
+      );
+
+      render(<BugReportBubble />);
+
+      // The level comes back down, because nothing else was going to do it...
+      await waitFor(() => expect(stopCalls).toBe(1));
+      // ...but an hour-old description is not a report anyone is still waiting
+      // to be filed, and nobody is here to see it happen.
+      expect(submitCalls).toBe(0);
+      expect(screen.getByRole('button').className).toContain('bg-red-500');
+    });
+  });
 });

+ 186 - 15
frontend/src/components/BugReportBubble.tsx

@@ -2,7 +2,7 @@ import { useState, useRef, useCallback, useEffect } from 'react';
 import { Bug, X, Loader2, CheckCircle, AlertCircle, AlertTriangle, Trash2, Upload, Circle, CheckCircle2, Stethoscope } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useQuery } from '@tanstack/react-query';
-import { api, bugReportApi, type PrinterDiagnosticResult } from '../api/client';
+import { api, bugReportApi, supportApi, type PrinterDiagnosticResult } from '../api/client';
 import { DiagnosticChecklist } from './ConnectionDiagnostic';
 import { SystemHealthPanel } from './SystemHealthPanel';
 import { Collapsible } from './Collapsible';
@@ -18,6 +18,69 @@ const MAX_DIMENSION = 1920;
 const JPEG_QUALITY = 0.7;
 const MAX_LOG_SECONDS = 300; // 5 minutes
 
+/**
+ * A logging run outlives the panel that started it (#2847).
+ *
+ * Step 2 asks the user to reproduce the problem, and the panel sits over the
+ * part of the app they have to reach to do that. Closing it has to be allowed,
+ * so the run is written down rather than held only in component state: the
+ * panel reopens on the step it left, and a reload lands there too instead of
+ * leaving the server at DEBUG with nothing in the UI still tracking it.
+ *
+ * The screenshot is deliberately not persisted. A 1920px JPEG runs to hundreds
+ * of kilobytes against an origin-wide budget this app shares with everything
+ * else it stores, and it survives a close either way — only a reload loses it,
+ * and it is the one optional field on the form.
+ */
+const SESSION_KEY = 'bambuddy-bug-report-session';
+
+interface LoggingSession {
+  description: string;
+  email: string;
+  /** Debug logging was already on before this run, so stopping must leave it on. */
+  wasDebug: boolean;
+  /** Wall clock. Elapsed is derived from it rather than counted in ticks, which
+   *  a background tab throttles — the 5-minute cap has to mean five minutes. */
+  startedAt: number;
+}
+
+function readSession(): LoggingSession | null {
+  try {
+    const raw = window.localStorage.getItem(SESSION_KEY);
+    if (!raw) return null;
+    const parsed = JSON.parse(raw) as Partial<LoggingSession>;
+    if (typeof parsed?.startedAt !== 'number') return null;
+    return {
+      description: typeof parsed.description === 'string' ? parsed.description : '',
+      email: typeof parsed.email === 'string' ? parsed.email : '',
+      wasDebug: parsed.wasDebug === true,
+      startedAt: parsed.startedAt,
+    };
+  } catch {
+    // Unparseable or unreadable. Treat it as no session rather than trapping
+    // the user in a panel that cannot restore.
+    return null;
+  }
+}
+
+function writeSession(session: LoggingSession): void {
+  try {
+    window.localStorage.setItem(SESSION_KEY, JSON.stringify(session));
+  } catch {
+    // Quota, or storage refused outright in a locked-down browser. The run
+    // still works and still survives a close; it just will not survive a
+    // reload, which is no worse than before it was written down at all.
+  }
+}
+
+function clearSession(): void {
+  try {
+    window.localStorage.removeItem(SESSION_KEY);
+  } catch {
+    // See writeSession.
+  }
+}
+
 function compressImage(file: File): Promise<string> {
   return new Promise((resolve, reject) => {
     const img = new Image();
@@ -63,9 +126,16 @@ interface BugReportBubbleProps {
   /** Controlled open state. Falls back to internal state when omitted. */
   open?: boolean;
   onOpenChange?: (open: boolean) => void;
+  /**
+   * Fired when a logging run starts or ends. The floating disc shows a live run
+   * itself, but the compact layout replaces the disc with a header button and
+   * has no room for a timer, so Layout uses this to mark that button and to
+   * offer a way back into the run from the debug-logging banner (#2847).
+   */
+  onLoggingChange?: (active: boolean) => void;
 }
 
-export function BugReportBubble({ showTrigger = true, open, onOpenChange }: BugReportBubbleProps = {}) {
+export function BugReportBubble({ showTrigger = true, open, onOpenChange, onLoggingChange }: BugReportBubbleProps = {}) {
   const { t } = useTranslation();
   const isMobile = useIsMobile();
   const [internalOpen, setInternalOpen] = useState(false);
@@ -87,10 +157,19 @@ export function BugReportBubble({ showTrigger = true, open, onOpenChange }: BugR
   const [issueNumber, setIssueNumber] = useState<number | null>(null);
   const [errorMessage, setErrorMessage] = useState('');
   const [elapsedSeconds, setElapsedSeconds] = useState(0);
+  const [startedAt, setStartedAt] = useState<number | null>(null);
   const [wasDebug, setWasDebug] = useState(false);
   const modalRef = useRef<HTMLDivElement>(null);
   const fileInputRef = useRef<HTMLInputElement>(null);
   const handleStopLoggingRef = useRef<() => void>(() => {});
+  // Read inside effects that must not re-run when the view changes.
+  const viewStateRef = useRef(viewState);
+  viewStateRef.current = viewState;
+
+  const isLogging = viewState === 'logging';
+  useEffect(() => {
+    onLoggingChange?.(isLogging);
+  }, [isLogging, onLoggingChange]);
 
   // Before the user files a report, diagnose configured printers. Most bug
   // reports are setup issues — surfacing a connection problem inline lets the
@@ -126,23 +205,35 @@ export function BugReportBubble({ showTrigger = true, open, onOpenChange }: BugR
   });
   const logFindings = logHealthScan.data?.findings ?? [];
 
-  // Elapsed timer for logging phase — auto-stop at 5 minutes
+  // Elapsed timer for logging phase — auto-stop at 5 minutes. Measured against
+  // the run's start time rather than counted in ticks: the run continues while
+  // the panel is closed and while the tab is in the background, where timers
+  // are throttled hard enough that a tick count is not a clock.
   useEffect(() => {
-    if (viewState !== 'logging') return;
-    if (elapsedSeconds >= MAX_LOG_SECONDS) {
-      handleStopLoggingRef.current();
-      return;
-    }
-    const timer = setTimeout(() => setElapsedSeconds((s) => s + 1), 1000);
-    return () => clearTimeout(timer);
-  }, [viewState, elapsedSeconds]);
+    if (viewState !== 'logging' || startedAt === null) return;
+    const tick = () => {
+      const elapsed = Math.floor((Date.now() - startedAt) / 1000);
+      setElapsedSeconds(elapsed);
+      if (elapsed >= MAX_LOG_SECONDS) handleStopLoggingRef.current();
+    };
+    tick();
+    const timer = setInterval(tick, 1000);
+    return () => clearInterval(timer);
+  }, [viewState, startedAt]);
 
   // Reset on open rather than in the click handler: the panel now has two
   // possible triggers (the floating disc here, and the compact header's button
   // which only flips the controlled flag), and a stale half-filled form
   // reappearing for one of them would be a nasty little inconsistency.
+  //
+  // A run in progress is the exception (#2847). Step 2 asks the user to
+  // reproduce the problem, which usually means reaching a part of the app the
+  // panel is sitting on top of, so closing it has to be allowed — and the only
+  // thing that stops debug logging is the Stop & Submit button on the step this
+  // reset used to throw away.
   useEffect(() => {
     if (!isOpen) return;
+    if (viewStateRef.current === 'logging' || viewStateRef.current === 'stopping' || viewStateRef.current === 'submitting') return;
     setViewState('form');
     setDescription('');
     setEmail('');
@@ -151,9 +242,63 @@ export function BugReportBubble({ showTrigger = true, open, onOpenChange }: BugR
     setIssueNumber(null);
     setErrorMessage('');
     setElapsedSeconds(0);
+    setStartedAt(null);
     setWasDebug(false);
   }, [isOpen]);
 
+  // Pick a run back up after a reload. The panel's own state is gone by then,
+  // but the server still has the log level raised, so without this the app is
+  // left logging at DEBUG with nothing in the report flow still pointing at it.
+  useEffect(() => {
+    const session = readSession();
+    if (!session) return;
+    let cancelled = false;
+
+    (async () => {
+      let stillLogging: boolean;
+      try {
+        stillLogging = (await supportApi.getDebugLoggingState()).enabled;
+      } catch {
+        // Can't tell. Leave the session written down for the next load rather
+        // than dropping a run that may well still be going.
+        return;
+      }
+      if (cancelled || viewStateRef.current !== 'form') return;
+
+      if (!stillLogging) {
+        // Switched off from the System page, or the run was finished in another
+        // tab. Either way there is nothing left to resume.
+        clearSession();
+        return;
+      }
+
+      const elapsed = Math.floor((Date.now() - session.startedAt) / 1000);
+      if (elapsed >= MAX_LOG_SECONDS) {
+        // Past the cap with nobody watching — the browser was closed, or the
+        // tab sat elsewhere for an hour. Put the log level back, but do not
+        // submit: a description written that long ago is not a report anyone is
+        // still expecting to be filed, and no one is here to see it happen.
+        try {
+          await bugReportApi.stopLogging(session.wasDebug);
+        } catch {
+          // The banner in Layout still shows the raised level, and the System
+          // page can lower it.
+        }
+        clearSession();
+        return;
+      }
+
+      setDescription(session.description);
+      setEmail(session.email);
+      setWasDebug(session.wasDebug);
+      setStartedAt(session.startedAt);
+      setElapsedSeconds(elapsed);
+      setViewState('logging');
+    })();
+
+    return () => { cancelled = true; };
+  }, []);
+
   const handleOpen = () => setIsOpen(true);
 
   const handleClose = () => {
@@ -203,9 +348,17 @@ export function BugReportBubble({ showTrigger = true, open, onOpenChange }: BugR
     if (!description.trim()) return;
     try {
       const result = await bugReportApi.startLogging();
+      const runStartedAt = Date.now();
       setWasDebug(result.was_debug);
+      setStartedAt(runStartedAt);
       setElapsedSeconds(0);
       setViewState('logging');
+      writeSession({
+        description: description.trim(),
+        email: email.trim(),
+        wasDebug: result.was_debug,
+        startedAt: runStartedAt,
+      });
     } catch (err) {
       setErrorMessage(err instanceof Error ? err.message : t('bugReport.unexpectedError'));
       setViewState('error');
@@ -213,6 +366,14 @@ export function BugReportBubble({ showTrigger = true, open, onOpenChange }: BugR
   };
 
   const handleStopLogging = async () => {
+    // The cap can fire while the panel is closed, and stopping submits. Show
+    // the panel so that happens in front of the user instead of behind them.
+    setIsOpen(true);
+    // The run is over from here whichever way it goes, so there is nothing left
+    // to resume — including when stopping fails, where the banner in Layout is
+    // what surfaces a log level that did not come back down.
+    clearSession();
+    setStartedAt(null);
     setViewState('stopping');
     try {
       const stopResult = await bugReportApi.stopLogging(wasDebug);
@@ -255,10 +416,17 @@ export function BugReportBubble({ showTrigger = true, open, onOpenChange }: BugR
       {showTrigger && (
         <button
           onClick={handleOpen}
-          className="fixed bottom-4 right-4 z-40 w-12 h-12 rounded-full bg-red-500 hover:bg-red-600 text-white shadow-lg hover:shadow-xl transition-all duration-200 hover:scale-110 flex items-center justify-center"
-          title={t('bugReport.title')}
+          className={`fixed bottom-4 right-4 z-40 w-12 h-12 rounded-full text-white shadow-lg hover:shadow-xl transition-all duration-200 hover:scale-110 flex items-center justify-center ${
+            // Amber while a run is going, matching the debug-logging banner, so
+            // a closed panel still says the recording is live and clickable.
+            isLogging ? 'bg-amber-500 hover:bg-amber-600' : 'bg-red-500 hover:bg-red-600'
+          }`}
+          title={isLogging ? t('bugReport.resumeRecording', { elapsed: formatElapsed(elapsedSeconds) }) : t('bugReport.title')}
         >
-          <Bug className="w-5 h-5" />
+          {isLogging && (
+            <span className="absolute inset-0 rounded-full bg-amber-400 opacity-75 animate-ping" />
+          )}
+          <Bug className="w-5 h-5 relative" />
         </button>
       )}
 
@@ -515,7 +683,7 @@ export function BugReportBubble({ showTrigger = true, open, onOpenChange }: BugR
                         <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75"></span>
                         <span className="relative inline-flex rounded-full h-3 w-3 bg-blue-500"></span>
                       </span>
-                      <span className="text-sm font-medium text-blue-700 dark:text-blue-300">{t('bugReport.stepReproduce')}</span>
+                      <span data-testid="bug-report-step-reproduce" className="text-sm font-medium text-blue-700 dark:text-blue-300">{t('bugReport.stepReproduce')}</span>
                     </div>
                     {/* Step 3: Upcoming */}
                     <div className="flex items-center gap-3">
@@ -528,6 +696,9 @@ export function BugReportBubble({ showTrigger = true, open, onOpenChange }: BugR
                   <div className="text-center">
                     <p className="text-3xl font-mono text-blue-500">{formatElapsed(elapsedSeconds)}</p>
                     <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">{t('bugReport.maxDuration', { minutes: 5 })}</p>
+                    {/* The panel covers whatever has to be clicked to reproduce
+                        the problem, so say plainly that closing it is fine. */}
+                    <p className="text-xs text-gray-500 dark:text-gray-400 mt-2">{t('bugReport.closeKeepsRecording')}</p>
                   </div>
 
                   {/* Stop & Submit button */}

+ 23 - 4
frontend/src/components/Layout.tsx

@@ -86,6 +86,10 @@ export function Layout() {
   // whichever of them happens to be underneath. Moving out of the corner is
   // the only fix that covers in-flow content as well as fixed overlays.
   const [bugReportOpen, setBugReportOpen] = useState(false);
+  // A bug-report logging run survives the panel being closed (#2847). The
+  // floating disc shows that itself; the compact header's button and the
+  // debug-logging banner need telling.
+  const [bugReportLogging, setBugReportLogging] = useState(false);
 
   // Theme toggle: mode → icon and tooltip
   const ThemeIcon = { dark: Sun, light: Monitor, system: Moon }[mode];
@@ -517,11 +521,13 @@ export function Layout() {
           {/* Bug report — the compact-layout home of the floating bubble. */}
           <button
             onClick={() => setBugReportOpen(true)}
-            className="ml-auto p-2 -mr-2 rounded-lg text-red-500 hover:bg-bambu-dark-tertiary transition-colors"
-            title={t('bugReport.title')}
-            aria-label={t('bugReport.title')}
+            className={`ml-auto p-2 -mr-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${
+              bugReportLogging ? 'text-amber-500' : 'text-red-500'
+            }`}
+            title={bugReportLogging ? t('bugReport.resumeReport') : t('bugReport.title')}
+            aria-label={bugReportLogging ? t('bugReport.resumeReport') : t('bugReport.title')}
           >
-            <Bug className="w-5 h-5" />
+            <Bug className={`w-5 h-5 ${bugReportLogging ? 'animate-pulse' : ''}`} />
           </button>
         </header>
       )}
@@ -883,6 +889,18 @@ export function Layout() {
                   </span>
                 )}
               </span>
+              {/* A run started from the bug-report panel ends at that panel's
+                  Stop & Submit button, so send the user back there rather than
+                  to the System page's raw toggle, which would drop the logs
+                  and the description they already wrote (#2847). */}
+              {bugReportLogging && (
+                <button
+                  onClick={() => setBugReportOpen(true)}
+                  className="text-amber-700 dark:text-amber-400 hover:text-amber-900 dark:hover:text-amber-300 font-medium underline ml-2"
+                >
+                  {t('bugReport.resumeReport')}
+                </button>
+              )}
               <button
                 onClick={() => navigate('/system')}
                 className="text-amber-700 dark:text-amber-400 hover:text-amber-900 dark:hover:text-amber-300 font-medium underline ml-2"
@@ -1146,6 +1164,7 @@ export function Layout() {
         showTrigger={!isSidebarCompact}
         open={bugReportOpen}
         onOpenChange={setBugReportOpen}
+        onLoggingChange={setBugReportLogging}
       />
     </div>
   );

+ 3 - 0
frontend/src/i18n/locales/de.ts

@@ -6990,6 +6990,9 @@ export default {
     thankYou: 'Vielen Dank!',
     submitted: 'Ihr Fehlerbericht wurde eingereicht.',
     viewIssue: 'Issue ansehen',
+    closeKeepsRecording: 'Sie können dieses Fenster schließen, während Sie das Problem reproduzieren — die Aufzeichnung läuft weiter, und beim erneuten Öffnen sind Sie wieder hier.',
+    resumeRecording: 'Fehlerbericht zeichnet auf — {{elapsed}}. Zum Abschließen klicken.',
+    resumeReport: 'Bericht fortsetzen',
     unexpectedError: 'Ein unerwarteter Fehler ist aufgetreten',
   },
   failureDetection: {

+ 3 - 0
frontend/src/i18n/locales/en.ts

@@ -7040,6 +7040,9 @@ export default {
     thankYou: 'Thank you!',
     submitted: 'Your bug report has been submitted.',
     viewIssue: 'View Issue',
+    closeKeepsRecording: 'You can close this panel while you reproduce the problem — recording keeps running, and reopening brings you back here.',
+    resumeRecording: 'Bug report recording — {{elapsed}}. Click to finish.',
+    resumeReport: 'Resume report',
     unexpectedError: 'An unexpected error occurred',
   },
   failureDetection: {

+ 3 - 0
frontend/src/i18n/locales/es.ts

@@ -6998,6 +6998,9 @@ export default {
     thankYou: '¡Gracias!',
     submitted: 'Su informe de error se ha enviado.',
     viewIssue: 'Ver incidencia',
+    closeKeepsRecording: 'Puedes cerrar este panel mientras reproduces el problema: la grabación sigue en marcha y al volver a abrirlo regresarás aquí.',
+    resumeRecording: 'Informe de error grabando — {{elapsed}}. Haz clic para finalizar.',
+    resumeReport: 'Reanudar informe',
     unexpectedError: 'Se produjo un error inesperado',
   },
   failureDetection: {

+ 3 - 0
frontend/src/i18n/locales/fr.ts

@@ -6980,6 +6980,9 @@ export default {
     thankYou: 'Merci !',
     submitted: 'Votre rapport de bug a été soumis.',
     viewIssue: 'Voir l\'issue',
+    closeKeepsRecording: 'Vous pouvez fermer ce panneau pendant que vous reproduisez le problème : l\'enregistrement continue et sa réouverture vous ramènera ici.',
+    resumeRecording: 'Rapport de bogue en cours d\'enregistrement — {{elapsed}}. Cliquez pour terminer.',
+    resumeReport: 'Reprendre le rapport',
     unexpectedError: 'Une erreur inattendue est survenue',
   },
   failureDetection: {

+ 3 - 0
frontend/src/i18n/locales/it.ts

@@ -6979,6 +6979,9 @@ export default {
     thankYou: 'Grazie!',
     submitted: 'La tua segnalazione bug è stata inviata.',
     viewIssue: 'Vedi issue',
+    closeKeepsRecording: 'Puoi chiudere questo pannello mentre riproduci il problema: la registrazione continua e riaprendolo tornerai qui.',
+    resumeRecording: 'Segnalazione in registrazione — {{elapsed}}. Fai clic per completare.',
+    resumeReport: 'Riprendi segnalazione',
     unexpectedError: 'Si è verificato un errore imprevisto',
   },
   failureDetection: {

+ 3 - 0
frontend/src/i18n/locales/ja.ts

@@ -6991,6 +6991,9 @@ export default {
     thankYou: 'ありがとうございます!',
     submitted: 'バグレポートが送信されました。',
     viewIssue: 'Issueを表示',
+    closeKeepsRecording: '問題を再現している間、このパネルを閉じても構いません。記録は続行され、再度開くとここに戻ります。',
+    resumeRecording: 'バグレポートを記録中 — {{elapsed}}。クリックして完了します。',
+    resumeReport: 'レポートを再開',
     unexpectedError: '予期しないエラーが発生しました',
   },
   failureDetection: {

+ 3 - 0
frontend/src/i18n/locales/ko.ts

@@ -6435,6 +6435,9 @@ export default {
     thankYou: '감사합니다!',
     submitted: '버그 보고서가 제출되었습니다.',
     viewIssue: '이슈 보기',
+    closeKeepsRecording: '문제를 재현하는 동안 이 패널을 닫아도 됩니다. 기록은 계속되며 다시 열면 이 단계로 돌아옵니다.',
+    resumeRecording: '버그 리포트 기록 중 — {{elapsed}}. 클릭하여 완료하세요.',
+    resumeReport: '리포트 계속하기',
     unexpectedError: '예상치 못한 오류가 발생했습니다',
     submittingStepConnection: '프린터 연결 확인 실행 중',
     submittingStepVirtualPrinters: '가상 프린터 설정 확인 실행 중',

+ 3 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -6979,6 +6979,9 @@ export default {
     thankYou: 'Obrigado!',
     submitted: 'Seu relatório de bug foi enviado.',
     viewIssue: 'Ver issue',
+    closeKeepsRecording: 'Você pode fechar este painel enquanto reproduz o problema: a gravação continua e, ao reabrir, você volta para cá.',
+    resumeRecording: 'Relatório de bug gravando — {{elapsed}}. Clique para concluir.',
+    resumeReport: 'Retomar relatório',
     unexpectedError: 'Ocorreu um erro inesperado',
   },
   failureDetection: {

+ 3 - 0
frontend/src/i18n/locales/ru.ts

@@ -6616,6 +6616,9 @@ export default {
     thankYou: "Спасибо!",
     submitted: "Отчёт об ошибке отправлен.",
     viewIssue: "Открыть задачу",
+    closeKeepsRecording: 'Вы можете закрыть эту панель, пока воспроизводите проблему: запись продолжается, и при повторном открытии вы вернётесь сюда.',
+    resumeRecording: 'Идёт запись отчёта об ошибке — {{elapsed}}. Нажмите, чтобы завершить.',
+    resumeReport: 'Продолжить отчёт',
     unexpectedError: "Произошла непредвиденная ошибка",
   },
   failureDetection: {

+ 3 - 0
frontend/src/i18n/locales/tr.ts

@@ -6929,6 +6929,9 @@ export default {
     thankYou: 'Teşekkürler!',
     submitted: 'Hata raporunuz gönderildi.',
     viewIssue: 'Sorunu Görüntüle',
+    closeKeepsRecording: 'Sorunu yeniden oluştururken bu paneli kapatabilirsiniz; kayıt devam eder ve yeniden açtığınızda buraya dönersiniz.',
+    resumeRecording: 'Hata raporu kaydediyor — {{elapsed}}. Tamamlamak için tıklayın.',
+    resumeReport: 'Rapora devam et',
     unexpectedError: 'Beklenmedik bir hata oluştu',
   },
   failureDetection: {

+ 3 - 0
frontend/src/i18n/locales/uk.ts

@@ -7033,6 +7033,9 @@ export default {
     thankYou: "дякую!",
     submitted: "Ваш звіт про помилку надіслано.",
     viewIssue: "Переглянути випуск",
+    closeKeepsRecording: 'Ви можете закрити цю панель, поки відтворюєте проблему: запис триває, і після повторного відкриття ви повернетеся сюди.',
+    resumeRecording: 'Триває запис звіту про помилку — {{elapsed}}. Натисніть, щоб завершити.',
+    resumeReport: 'Продовжити звіт',
     unexpectedError: "Сталася неочікувана помилка",
   },
   failureDetection: {

+ 3 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -6978,6 +6978,9 @@ export default {
     thankYou: '谢谢!',
     submitted: '您的错误报告已提交。',
     viewIssue: '查看Issue',
+    closeKeepsRecording: '重现问题时可以关闭此面板——记录会继续进行,重新打开后会回到这一步。',
+    resumeRecording: '错误报告记录中 — {{elapsed}}。点击以完成。',
+    resumeReport: '继续报告',
     unexpectedError: '发生了意外错误',
   },
   failureDetection: {

+ 3 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -6978,6 +6978,9 @@ export default {
     thankYou: '謝謝!',
     submitted: '您的錯誤報告已提交。',
     viewIssue: '檢視 Issue',
+    closeKeepsRecording: '重現問題時可以關閉此面板——記錄會繼續進行,重新開啟後會回到這一步。',
+    resumeRecording: '錯誤報告記錄中 — {{elapsed}}。點擊以完成。',
+    resumeReport: '繼續報告',
     unexpectedError: '發生了意外錯誤',
   },
   failureDetection: {

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


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-Dbwdo7E_.js"></script>
+    <script type="module" crossorigin src="/assets/index-D5Du0dc3.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-VSpFxsmE.css">
   </head>
   <body>

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