Skip to content

Add chat screenshot export option#22

Merged
ScriptSmith merged 5 commits intomainfrom
screenshot
Mar 26, 2026
Merged

Add chat screenshot export option#22
ScriptSmith merged 5 commits intomainfrom
screenshot

Conversation

@ScriptSmith
Copy link
Owner

No description provided.

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 26, 2026

Greptile Summary

This PR adds a Screenshot (PNG) export option to the chat export dropdown. It introduces a hidden off-screen ScreenshotRenderer component that renders the full conversation and captures it via @zumer/snapdom, a useScreenshotExport hook that manages object-URL lifecycle and capture state, and a ScreenshotPreviewModal that lets users download or copy the resulting image to the clipboard. A forceStacked prop is added to MultiModelResponse so that the screenshot always renders in a single-column layout regardless of the user's view mode.\n\nKey changes:\n- ScreenshotRenderer: Renders all message groups off-screen via a React portal, waits one requestAnimationFrame + 500 ms for layout to settle, then calls captureElementAsBlob. Uses a ref-based callback pattern so the capture effect only depends on title and is stable against prop changes.\n- useScreenshotExport: Tracks isCapturing/screenshot state; correctly revokes the previous object URL in onCaptureComplete and dismissPreview.\n- exportScreenshot.ts: Thin wrapper around snapdom plus a downloadBlob helper.\n- P1 — Missing aria-hidden on off-screen container: The ScreenshotRenderer div is in the DOM and reachable by screen readers, which will read out the full conversation a second time.\n- P2 — Re-capture while preview is open: startCapture does not clear the existing screenshot state, so the old preview modal stays visible while the new capture is underway.\n- P2 — Uncleared setTimeout in ScreenshotPreviewModal.handleCopy: The 2-second reset timer for the "Copied" button is not cancelled if the modal unmounts in the interim.

Confidence Score: 4/5

Safe to merge after fixing the missing aria-hidden; remaining issues are minor UX/style concerns.

The core capture pipeline (snapdom integration, object-URL management, off-screen portal rendering, stable effect deps) is well-implemented and the prior thread concerns around URL leaking and effect instability have been addressed. One P1 accessibility gap remains (no aria-hidden on the hidden container), but it doesn't break functionality. The P2 items are polish/cleanup rather than correctness issues.

ScreenshotRenderer.tsx (missing aria-hidden) and useScreenshotExport.ts (re-capture UX) warrant a quick second look before merge.

Important Files Changed

Filename Overview
ui/src/components/ScreenshotRenderer/ScreenshotRenderer.tsx New component that renders the full conversation off-screen and captures it as a PNG blob; missing aria-hidden on the off-screen container causes screen readers to traverse duplicate content.
ui/src/hooks/useScreenshotExport.ts New hook that manages capture state and object-URL lifecycle; startCapture doesn't clear the previous screenshot, leaving the old preview modal open during a re-capture.
ui/src/components/ScreenshotRenderer/ScreenshotPreviewModal.tsx New modal for previewing and downloading/copying the captured PNG; the setTimeout in handleCopy is not cleaned up on unmount.
ui/src/utils/exportScreenshot.ts New utility wrapping @zumer/snapdom for DOM-to-PNG capture; filename generation and blob download helpers look correct.
ui/src/components/ChatHeader/ChatHeader.tsx Integrates screenshot capture into the export dropdown; screenshotGroups memo and instanceLabels computation are correct; ScreenshotRenderer and ScreenshotPreviewModal are conditionally rendered.
ui/src/components/MultiModelResponse/MultiModelResponse.tsx Adds forceStacked prop to force vertical layout during screenshot rendering; straightforward and well-guarded change.
ui/package.json Adds @zumer/snapdom ^2.6.0 as a new dependency for DOM-to-image capture.
ui/pnpm-lock.yaml Lockfile updated with @zumer/snapdom@2.6.0; no other unexpected changes.

Sequence Diagram

sequenceDiagram
    participant User
    participant ChatHeader
    participant useScreenshotExport
    participant ScreenshotRenderer
    participant snapdom
    participant ScreenshotPreviewModal

    User->>ChatHeader: Click "Screenshot (PNG)"
    ChatHeader->>useScreenshotExport: startCapture()
    useScreenshotExport-->>ChatHeader: isCapturing=true
    ChatHeader->>ScreenshotRenderer: mount (portal to document.body)
    Note over ScreenshotRenderer: rAF + 500ms delay
    ScreenshotRenderer->>snapdom: captureElementAsBlob(el)
    snapdom-->>ScreenshotRenderer: Blob (PNG)
    ScreenshotRenderer->>useScreenshotExport: onCaptureComplete(blob)
    useScreenshotExport->>useScreenshotExport: revoke old objectURL (if any)
    useScreenshotExport->>useScreenshotExport: URL.createObjectURL(blob)
    useScreenshotExport-->>ChatHeader: isCapturing=false, screenshot={blob,url}
    ChatHeader->>ScreenshotRenderer: unmount
    ChatHeader->>ScreenshotPreviewModal: mount with imageUrl + blob
    User->>ScreenshotPreviewModal: Click "Download"
    ScreenshotPreviewModal->>ScreenshotPreviewModal: downloadBlob(blob, filename)
    User->>ScreenshotPreviewModal: Click "Copy to clipboard"
    ScreenshotPreviewModal->>ScreenshotPreviewModal: navigator.clipboard.write(ClipboardItem)
    User->>ScreenshotPreviewModal: Close
    ScreenshotPreviewModal->>useScreenshotExport: dismissPreview()
    useScreenshotExport->>useScreenshotExport: revoke objectURL, screenshot=null
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: ui/src/components/ScreenshotRenderer/ScreenshotRenderer.tsx
Line: 91-101

Comment:
**Off-screen container is readable by screen readers**

The container is positioned off-screen with `left: -99999px` but is still present in the DOM and fully accessible to assistive technologies. Screen readers will traverse this element and read out every conversation message a second time, right after reading the real chat. This duplicates the entire conversation for any user on a screen reader.

Add `aria-hidden="true"` to suppress it from the accessibility tree:

```suggestion
    <div
      ref={containerRef}
      aria-hidden="true"
      className={`${themeClass} bg-background text-foreground`}
      style={{
        position: "fixed",
        left: "-99999px",
        top: 0,
        width: 800,
        padding: 32,
      }}
    >
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: ui/src/hooks/useScreenshotExport.ts
Line: 16-19

Comment:
**Re-capture while preview is open leaves the stale modal visible**

When the user clicks "Screenshot (PNG)" while an existing preview is already showing, `startCapture` sets `isCapturing = true` but does not clear `screenshot`. This means:

1. `ScreenshotPreviewModal` stays open showing the previous capture.
2. `ScreenshotRenderer` simultaneously mounts and begins a new capture.

The two components are rendered at the same time until `onCaptureComplete` fires. Clearing the previous result immediately on re-capture would give clearer feedback that a new capture is underway:

```suggestion
  const startCapture = useCallback(() => {
    if (objectUrlRef.current) {
      URL.revokeObjectURL(objectUrlRef.current);
      objectUrlRef.current = null;
    }
    setScreenshot(null);
    setIsCapturing(true);
    toast.info("Capturing screenshot…", "Rendering all messages");
  }, [toast]);
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: ui/src/components/ScreenshotRenderer/ScreenshotPreviewModal.tsx
Line: 38-46

Comment:
**`setTimeout` handle not cleared on unmount**

If the user copies the image and then immediately closes the modal (within 2 seconds), the `setTimeout(() => setCopied(false), 2000)` callback still fires on the unmounted component. In React 18 this is silently ignored, but it's better practice to store the timer ID and cancel it in a cleanup to avoid dangling callbacks.

```suggestion
  const handleCopy = useCallback(async () => {
    try {
      await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]);
      setCopied(true);
      const timer = setTimeout(() => setCopied(false), 2000);
      return () => clearTimeout(timer);
    } catch {
      toast.error("Copy failed", "Your browser may not support copying images");
    }
  }, [blob, toast]);
```

Alternatively, manage the timer with a `useRef` + `useEffect` cleanup if the callback needs to be reset on each invocation.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (2): Last reviewed commit: "Review fixes" | Re-trigger Greptile

@ScriptSmith
Copy link
Owner Author

@greptile-apps

Comment on lines +16 to +19
const startCapture = useCallback(() => {
setIsCapturing(true);
toast.info("Capturing screenshot…", "Rendering all messages");
}, [toast]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Re-capture while preview is open leaves the stale modal visible

When the user clicks "Screenshot (PNG)" while an existing preview is already showing, startCapture sets isCapturing = true but does not clear screenshot. This means:

  1. ScreenshotPreviewModal stays open showing the previous capture.
  2. ScreenshotRenderer simultaneously mounts and begins a new capture.

The two components are rendered at the same time until onCaptureComplete fires. Clearing the previous result immediately on re-capture would give clearer feedback that a new capture is underway:

Suggested change
const startCapture = useCallback(() => {
setIsCapturing(true);
toast.info("Capturing screenshot…", "Rendering all messages");
}, [toast]);
const startCapture = useCallback(() => {
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current);
objectUrlRef.current = null;
}
setScreenshot(null);
setIsCapturing(true);
toast.info("Capturing screenshot…", "Rendering all messages");
}, [toast]);
Prompt To Fix With AI
This is a comment left during a code review.
Path: ui/src/hooks/useScreenshotExport.ts
Line: 16-19

Comment:
**Re-capture while preview is open leaves the stale modal visible**

When the user clicks "Screenshot (PNG)" while an existing preview is already showing, `startCapture` sets `isCapturing = true` but does not clear `screenshot`. This means:

1. `ScreenshotPreviewModal` stays open showing the previous capture.
2. `ScreenshotRenderer` simultaneously mounts and begins a new capture.

The two components are rendered at the same time until `onCaptureComplete` fires. Clearing the previous result immediately on re-capture would give clearer feedback that a new capture is underway:

```suggestion
  const startCapture = useCallback(() => {
    if (objectUrlRef.current) {
      URL.revokeObjectURL(objectUrlRef.current);
      objectUrlRef.current = null;
    }
    setScreenshot(null);
    setIsCapturing(true);
    toast.info("Capturing screenshot…", "Rendering all messages");
  }, [toast]);
```

How can I resolve this? If you propose a fix, please make it concise.

@ScriptSmith ScriptSmith merged commit 4758698 into main Mar 26, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant