Skip to content

feat(platform): chat image gallery and zoom-pan viewer#754

Merged
Israeltheminer merged 2 commits into
mainfrom
feat/chat-image-gallery
Mar 11, 2026
Merged

feat(platform): chat image gallery and zoom-pan viewer#754
Israeltheminer merged 2 commits into
mainfrom
feat/chat-image-gallery

Conversation

@Israeltheminer
Copy link
Copy Markdown
Collaborator

@Israeltheminer Israeltheminer commented Mar 11, 2026

Summary

  • Add multi-image gallery navigation to chat message bubbles with prev/next buttons and arrow key support
  • Extract zoom/pan logic into a reusable ZoomPanViewer component with mouse wheel zoom, pinch-to-zoom, and drag panning
  • Consolidate image thumbnail sizes to size-9 with consistent ring borders across chat input and message bubbles
  • Move zoom/pan translation keys from chat-scoped imageViewer to shared imagePreview namespace

Test plan

  • Verify image thumbnails in chat messages open gallery dialog on click
  • Test arrow key and button navigation between images in gallery
  • Test zoom in/out via mouse wheel and pinch gestures in ZoomPanViewer
  • Verify drag panning works when zoomed in
  • Check that single-image preview still works (no gallery controls shown)
  • Run ZoomPanViewer unit tests and Storybook stories

Summary by CodeRabbit

  • New Features

    • Added image zoom and pan viewer with configurable toolbar positioning.
    • Added image gallery navigation in chat—browse multiple images with previous/next controls and arrow key shortcuts.
    • Added image counter display when viewing image galleries.
  • UI/UX Improvements

    • Updated styling for image attachments and markdown image previews with refined visual design.

…onent

Refactor image preview dialog to support multi-image gallery with
prev/next navigation via arrow keys and buttons. Extract zoom/pan
logic into a reusable ZoomPanViewer component with pinch-to-zoom
and mouse wheel support. Consolidate image thumbnails to consistent
size-9 with ring borders.
Copy link
Copy Markdown

@greptile-apps greptile-apps Bot left a comment

Choose a reason for hiding this comment

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

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 11, 2026

📝 Walkthrough

Walkthrough

This PR introduces a new ZoomPanViewer component and useZoomPan hook for image zooming and panning functionality. The component supports three toolbar layouts (overlay, bottom, inline) and is integrated into the chat image preview system, replacing the previous zoom/pan implementation. Integration includes adding an image gallery mechanism to message bubbles with multi-image navigation, updating file display components with image click handlers, and adding comprehensive Storybook stories and test coverage. Minor styling adjustments are applied to image attachments and preview buttons across the chat UI, with corresponding i18n message updates.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~30 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and concisely summarizes the main changes: a new chat image gallery feature and the extracted zoom-pan viewer component.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/chat-image-gallery

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 7

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@services/platform/app/components/ui/data-display/zoom-pan-viewer.stories.tsx`:
- Around line 6-7: The SAMPLE_IMAGE constant currently points to an external
Unsplash URL which makes stories flaky; replace it with a bundled fixture or
inline data URI and update references to SAMPLE_IMAGE in this file. Add a local
image asset (e.g., put a small PNG/JPG in the same folder or a fixtures/assets
folder), import it at the top (e.g., import sampleImg from
'./fixtures/sample-image.png') or replace the string with a base64 data URI,
then assign SAMPLE_IMAGE = sampleImg so the ZoomPanViewer stories use the
local/bundled asset instead of the external URL.

In `@services/platform/app/components/ui/data-display/zoom-pan-viewer.test.tsx`:
- Around line 83-98: Tests for toolbar positioning use fragile CSS class
selectors; update the ZoomPanViewer component to add stable data-testid
attributes (e.g., data-testid="toolbar-overlay" and
data-testid="toolbar-inline") on the respective toolbar container elements, then
update the test file zoom-pan-viewer.test.tsx to queryByTestId / getByTestId for
"toolbar-overlay" and "toolbar-inline" instead of using
container.querySelector('.absolute.top-4') and '.justify-end.mb-2'; ensure the
test assertions still call toBeInTheDocument() on the elements returned from
render.

In `@services/platform/app/components/ui/data-display/zoom-pan-viewer.tsx`:
- Around line 82-110: The three raw button elements in the ZoomPanViewer UI (the
buttons wired to the zoomOut, zoomIn, and reset handlers that use buttonClass
and aria-labels like t('imagePreview.zoomOut')/zoomIn/resetZoom') default to
type="submit" and can accidentally submit enclosing forms; update each of those
<button> elements to explicitly include type="button" to prevent form submission
while preserving their onClick, disabled, className and aria-label props.

In
`@services/platform/app/features/chat/components/message-bubble/image-preview-dialog.tsx`:
- Around line 40-41: The dialog uses activeIndex directly even when images
changes, causing invalid counters; clamp activeIndex to the valid range before
using it in currentSrc/currentAlt and the counter. In image-preview-dialog
(variables currentSrc/currentAlt and the counter render), compute a safeIndex =
Math.max(0, Math.min(activeIndex, (images?.length ?? 1) - 1)) (or equivalent)
and use safeIndex everywhere you currently use activeIndex so the viewer and
"activeIndex + 1 / images.length" stay consistent when images updates. Ensure
the same clamped index is used in the viewer logic and the block that renders
the counter.
- Around line 22-25: The gallery mode should only be enabled when navigation is
actually wired; update the logic that computes isGallery (used with images,
activeIndex, onActiveIndexChange) so it returns true only when images?.length >
1 AND onActiveIndexChange is provided (and activeIndex is not undefined), and
hide/disable Prev/Next controls and keyboard ArrowLeft/ArrowRight handlers when
onActiveIndexChange is absent; ensure functions like the prev/next click
handlers and the keyboard handler early-returning behavior is matched by the
isGallery guard so controls are never shown or active unless onActiveIndexChange
(and a valid activeIndex) exist.

In `@services/platform/app/hooks/use-zoom-pan.ts`:
- Around line 94-102: handlePointerMove allows unrestricted panning so the image
can be dragged completely off-screen; clamp pan before calling setPan by
computing visible bounds from the container size and scaled image size and
limiting pan.x/pan.y to min/max values. In the handlePointerMove callback
(referencing dragStart, panStart, isDragging, setPan) calculate imageWidth =
naturalWidth * scale and imageHeight = naturalHeight * scale (or read computed
sizes), compute maxPanX/minPanX and maxPanY/minPanY based on container
dimensions and image dimensions, then clamp the computed newX/newY into those
ranges and pass the clamped values to setPan; keep existing isDragging guard and
dependency list.

In `@services/platform/messages/en.json`:
- Around line 408-411: Move the three zoom translation keys from the
dialogs.imagePreview section into the common.imagePreview namespace so
ZoomPanViewer can find them via t('imagePreview.zoomIn') etc.; specifically
relocate "zoomIn", "zoomOut", and "resetZoom" entries into the
common.imagePreview object in en.json (remove them from dialogs.imagePreview) so
the component using common namespace resolves correctly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 3549b330-250f-4108-9664-f3e9d3086949

📥 Commits

Reviewing files that changed from the base of the PR and between 1b37de0 and 0bcbc5c.

📒 Files selected for processing (11)
  • services/platform/app/components/ui/data-display/file-preview-card.tsx
  • services/platform/app/components/ui/data-display/zoom-pan-viewer.stories.tsx
  • services/platform/app/components/ui/data-display/zoom-pan-viewer.test.tsx
  • services/platform/app/components/ui/data-display/zoom-pan-viewer.tsx
  • services/platform/app/features/chat/components/chat-input.tsx
  • services/platform/app/features/chat/components/message-bubble.tsx
  • services/platform/app/features/chat/components/message-bubble/file-displays.tsx
  • services/platform/app/features/chat/components/message-bubble/image-preview-dialog.tsx
  • services/platform/app/features/chat/components/message-bubble/markdown-renderer.tsx
  • services/platform/app/hooks/use-zoom-pan.ts
  • services/platform/messages/en.json

Comment on lines +6 to +7
const SAMPLE_IMAGE =
'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800&h=600&fit=crop';
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Use a local fixture for the story image.

Relying on a third-party URL here makes Storybook and visual-regression runs flaky/offline-hostile. Please switch this to a bundled asset or data URI so the stories stay deterministic.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@services/platform/app/components/ui/data-display/zoom-pan-viewer.stories.tsx`
around lines 6 - 7, The SAMPLE_IMAGE constant currently points to an external
Unsplash URL which makes stories flaky; replace it with a bundled fixture or
inline data URI and update references to SAMPLE_IMAGE in this file. Add a local
image asset (e.g., put a small PNG/JPG in the same folder or a fixtures/assets
folder), import it at the top (e.g., import sampleImg from
'./fixtures/sample-image.png') or replace the string with a base64 data URI,
then assign SAMPLE_IMAGE = sampleImg so the ZoomPanViewer stories use the
local/bundled asset instead of the external URL.

Comment on lines +83 to +98
describe('toolbar positions', () => {
it('renders overlay toolbar by default', () => {
const { container } = render(<ZoomPanViewer {...defaultProps} />);

const toolbarWrapper = container.querySelector('.absolute.top-4');
expect(toolbarWrapper).toBeInTheDocument();
});

it('renders inline toolbar when toolbarPosition is inline', () => {
const { container } = render(
<ZoomPanViewer {...defaultProps} toolbarPosition="inline" />,
);

const inlineWrapper = container.querySelector('.justify-end.mb-2');
expect(inlineWrapper).toBeInTheDocument();
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Consider using more stable test selectors.

The toolbar position tests rely on CSS class selectors like .absolute.top-4 and .justify-end.mb-2, which are implementation details that could break if styling changes. Consider adding data-testid attributes to the toolbar containers for more resilient tests.

💡 Example with test IDs

In the component, add:

<div data-testid="toolbar-overlay" className="absolute top-4 ...">

Then in tests:

-const toolbarWrapper = container.querySelector('.absolute.top-4');
+const toolbarWrapper = screen.getByTestId('toolbar-overlay');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@services/platform/app/components/ui/data-display/zoom-pan-viewer.test.tsx`
around lines 83 - 98, Tests for toolbar positioning use fragile CSS class
selectors; update the ZoomPanViewer component to add stable data-testid
attributes (e.g., data-testid="toolbar-overlay" and
data-testid="toolbar-inline") on the respective toolbar container elements, then
update the test file zoom-pan-viewer.test.tsx to queryByTestId / getByTestId for
"toolbar-overlay" and "toolbar-inline" instead of using
container.querySelector('.absolute.top-4') and '.justify-end.mb-2'; ensure the
test assertions still call toBeInTheDocument() on the elements returned from
render.

Comment on lines +82 to +110
<button
onClick={zoomOut}
disabled={!canZoomOut}
className={buttonClass}
aria-label={t('imagePreview.zoomOut')}
>
<ZoomOut className="size-4" />
</button>
<Text
as="span"
align="center"
className="min-w-[3rem] text-sm tabular-nums"
>
{Math.round(zoom * 100)}%
</Text>
<button
onClick={zoomIn}
disabled={!canZoomIn}
className={buttonClass}
aria-label={t('imagePreview.zoomIn')}
>
<ZoomIn className="size-4" />
</button>
<button
onClick={reset}
disabled={!isZoomed}
className={buttonClass}
aria-label={t('imagePreview.resetZoom')}
>
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Add type="button" to the zoom controls.

These raw <button> elements default to submit, so embedding ZoomPanViewer inside a form will submit it when users click zoom/reset.

Proposed fix
       <button
+        type="button"
         onClick={zoomOut}
         disabled={!canZoomOut}
         className={buttonClass}
         aria-label={t('imagePreview.zoomOut')}
       >
@@
       <button
+        type="button"
         onClick={zoomIn}
         disabled={!canZoomIn}
         className={buttonClass}
         aria-label={t('imagePreview.zoomIn')}
       >
@@
       <button
+        type="button"
         onClick={reset}
         disabled={!isZoomed}
         className={buttonClass}
         aria-label={t('imagePreview.resetZoom')}
       >
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<button
onClick={zoomOut}
disabled={!canZoomOut}
className={buttonClass}
aria-label={t('imagePreview.zoomOut')}
>
<ZoomOut className="size-4" />
</button>
<Text
as="span"
align="center"
className="min-w-[3rem] text-sm tabular-nums"
>
{Math.round(zoom * 100)}%
</Text>
<button
onClick={zoomIn}
disabled={!canZoomIn}
className={buttonClass}
aria-label={t('imagePreview.zoomIn')}
>
<ZoomIn className="size-4" />
</button>
<button
onClick={reset}
disabled={!isZoomed}
className={buttonClass}
aria-label={t('imagePreview.resetZoom')}
>
<button
type="button"
onClick={zoomOut}
disabled={!canZoomOut}
className={buttonClass}
aria-label={t('imagePreview.zoomOut')}
>
<ZoomOut className="size-4" />
</button>
<Text
as="span"
align="center"
className="min-w-[3rem] text-sm tabular-nums"
>
{Math.round(zoom * 100)}%
</Text>
<button
type="button"
onClick={zoomIn}
disabled={!canZoomIn}
className={buttonClass}
aria-label={t('imagePreview.zoomIn')}
>
<ZoomIn className="size-4" />
</button>
<button
type="button"
onClick={reset}
disabled={!isZoomed}
className={buttonClass}
aria-label={t('imagePreview.resetZoom')}
>
🧰 Tools
🪛 Biome (2.4.6)

[error] 82-87: Provide an explicit type prop for the button element.

(lint/a11y/useButtonType)


[error] 97-102: Provide an explicit type prop for the button element.

(lint/a11y/useButtonType)


[error] 105-110: Provide an explicit type prop for the button element.

(lint/a11y/useButtonType)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@services/platform/app/components/ui/data-display/zoom-pan-viewer.tsx` around
lines 82 - 110, The three raw button elements in the ZoomPanViewer UI (the
buttons wired to the zoomOut, zoomIn, and reset handlers that use buttonClass
and aria-labels like t('imagePreview.zoomOut')/zoomIn/resetZoom') default to
type="submit" and can accidentally submit enclosing forms; update each of those
<button> elements to explicitly include type="button" to prevent form submission
while preserving their onClick, disabled, className and aria-label props.

Comment on lines +22 to +25
/** Gallery images for prev/next navigation. When provided, src/alt are ignored in favor of images[activeIndex]. */
images?: GalleryImage[];
activeIndex?: number;
onActiveIndexChange?: (index: number) => void;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don’t enter gallery mode unless navigation is actually wired.

Right now isGallery only checks images.length > 1, but both navigation callbacks return early when onActiveIndexChange is missing. That leaves visible prev/next controls and ArrowLeft/ArrowRight handlers that never change the image.

Proposed fix
-  const isGallery = images && images.length > 1;
+  const isGallery = Boolean(images && images.length > 1 && onActiveIndexChange);

Also applies to: 39-68, 102-123

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@services/platform/app/features/chat/components/message-bubble/image-preview-dialog.tsx`
around lines 22 - 25, The gallery mode should only be enabled when navigation is
actually wired; update the logic that computes isGallery (used with images,
activeIndex, onActiveIndexChange) so it returns true only when images?.length >
1 AND onActiveIndexChange is provided (and activeIndex is not undefined), and
hide/disable Prev/Next controls and keyboard ArrowLeft/ArrowRight handlers when
onActiveIndexChange is absent; ensure functions like the prev/next click
handlers and the keyboard handler early-returning behavior is matched by the
isGallery guard so controls are never shown or active unless onActiveIndexChange
(and a valid activeIndex) exist.

Comment on lines +40 to +41
const currentSrc = images ? (images[activeIndex]?.src ?? src) : src;
const currentAlt = images ? (images[activeIndex]?.alt ?? alt) : alt;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Clamp activeIndex before using it in the viewer and counter.

If the parent keeps a stale index after images changes, the dialog falls back to src/alt for rendering but still shows activeIndex + 1 in the counter, so you can end up with invalid states like “4 / 3”.

Proposed fix
-  const currentSrc = images ? (images[activeIndex]?.src ?? src) : src;
-  const currentAlt = images ? (images[activeIndex]?.alt ?? alt) : alt;
+  const normalizedIndex = images?.length
+    ? Math.min(Math.max(activeIndex, 0), images.length - 1)
+    : 0;
+  const currentSrc = images ? (images[normalizedIndex]?.src ?? src) : src;
+  const currentAlt = images ? (images[normalizedIndex]?.alt ?? alt) : alt;
@@
-                  current: activeIndex + 1,
+                  current: normalizedIndex + 1,

Also applies to: 91-94

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@services/platform/app/features/chat/components/message-bubble/image-preview-dialog.tsx`
around lines 40 - 41, The dialog uses activeIndex directly even when images
changes, causing invalid counters; clamp activeIndex to the valid range before
using it in currentSrc/currentAlt and the counter. In image-preview-dialog
(variables currentSrc/currentAlt and the counter render), compute a safeIndex =
Math.max(0, Math.min(activeIndex, (images?.length ?? 1) - 1)) (or equivalent)
and use safeIndex everywhere you currently use activeIndex so the viewer and
"activeIndex + 1 / images.length" stay consistent when images updates. Ensure
the same clamped index is used in the viewer logic and the block that renders
the counter.

Comment on lines +94 to +102
const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
if (!isDragging) return;
const dx = e.clientX - dragStart.current.x;
const dy = e.clientY - dragStart.current.y;
setPan({ x: panStart.current.x + dx, y: panStart.current.y + dy });
},
[isDragging],
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Consider adding pan bounds (optional enhancement).

Currently, users can pan the image completely off-screen when zoomed in. If this becomes a UX concern, consider clamping pan values based on the container and image dimensions. This could be a future enhancement if user feedback warrants it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@services/platform/app/hooks/use-zoom-pan.ts` around lines 94 - 102,
handlePointerMove allows unrestricted panning so the image can be dragged
completely off-screen; clamp pan before calling setPan by computing visible
bounds from the container size and scaled image size and limiting pan.x/pan.y to
min/max values. In the handlePointerMove callback (referencing dragStart,
panStart, isDragging, setPan) calculate imageWidth = naturalWidth * scale and
imageHeight = naturalHeight * scale (or read computed sizes), compute
maxPanX/minPanX and maxPanY/minPanY based on container dimensions and image
dimensions, then clamp the computed newX/newY into those ranges and pass the
clamped values to setPan; keep existing isDragging guard and dependency list.

Comment thread services/platform/messages/en.json Outdated
Comment on lines +408 to +411
"title": "Image preview",
"zoomIn": "Zoom in",
"zoomOut": "Zoom out",
"resetZoom": "Reset zoom"
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check how zoom translation keys are accessed in components
rg -n "imagePreview\.(zoomIn|zoomOut|resetZoom)" services/platform/app/

Repository: tale-project/tale

Length of output: 785


🏁 Script executed:

#!/bin/bash
# Check the ZoomPanViewer component's useT hook and namespace
sed -n '1,60p' services/platform/app/components/ui/data-display/zoom-pan-viewer.tsx

Repository: tale-project/tale

Length of output: 1770


🏁 Script executed:

#!/bin/bash
# Also check if there are other dialogs.imagePreview references in the codebase
rg "dialogs\.imagePreview" services/platform/

Repository: tale-project/tale

Length of output: 43


🏁 Script executed:

#!/bin/bash
# Check how the test mocks the translations - what namespace is used
sed -n '1,30p' services/platform/app/components/ui/data-display/zoom-pan-viewer.test.tsx

Repository: tale-project/tale

Length of output: 902


Move zoom control translations to the correct namespace.

The ZoomPanViewer component uses the common namespace and accesses keys like t('imagePreview.zoomIn'), but the new translations are placed under dialogs.imagePreview in en.json. These should be under common.imagePreview to match the component's usage.

Translation keys to relocate in en.json

Move lines 408-411 from the dialogs.imagePreview section to the common.imagePreview section:

      "zoomIn": "Zoom in",
      "zoomOut": "Zoom out",
      "resetZoom": "Reset zoom"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@services/platform/messages/en.json` around lines 408 - 411, Move the three
zoom translation keys from the dialogs.imagePreview section into the
common.imagePreview namespace so ZoomPanViewer can find them via
t('imagePreview.zoomIn') etc.; specifically relocate "zoomIn", "zoomOut", and
"resetZoom" entries into the common.imagePreview object in en.json (remove them
from dialogs.imagePreview) so the component using common namespace resolves
correctly.

- Add type="button" to zoom control buttons in ZoomPanViewer
- Only enable gallery mode when onActiveIndexChange is provided
- Clamp activeIndex to valid range to prevent stale index issues
- Move zoom translation keys to common.imagePreview namespace
@Israeltheminer Israeltheminer merged commit 006d940 into main Mar 11, 2026
16 checks passed
@Israeltheminer Israeltheminer deleted the feat/chat-image-gallery branch March 11, 2026 13:38
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