Feedback

What a report can carry, how screenshots are uploaded, and what happens when a workspace reaches its monthly allowance.

PanelUI components are native-only for now

Everything on this page works on the web through the SDK. The PanelUI components the App version uses — Feedback, Rating and Checkbox — are React Native components and don't run in a browser yet, so on the web you build the form with your own components. A form on the web is a complete one.

A report is one call. Everything but message is optional.

const { feedback, warnings } = await studio.feedback.submit({
  message: 'Checkout spins forever on the last step.',
  rating: 2,
  tags: ['Bug', 'Checkout'],
  screen: '/checkout/confirm',
  appVersion: '2.4.1',
  build: '241',
  metadata: { cartSize: 3, plan: 'gold' },
});
FieldWhat it's for
messageWhat the user wrote. 1–5,000 characters.
rating1–5, when you ask for one. Ratings of 2 or lower are flagged in the inbox.
tagsUp to 5. Unknown tags are created, up to 100 per project.
screenWhere the user was. A route works well.
appVersion, buildFilled from the device when left out.
metadataYour own context, as a JSON object. Pro and Max.
fieldsAnswers to the project's custom form. Pro and Max.
attachmentIdsScreenshots, from uploads.screenshot().
countryISO-3166 alpha-2, when your app knows it better than the device locale.

The device context — platform, OS, model, locale — comes from the device and doesn't need passing.

The device context — platform, OS and its version, browser, locale — comes from the browser and doesn't need passing. A browser has no app version, so pass appVersion yourself, here or once as device.appVersion when you create the client.

Warnings

A report that is accepted with something left out resolves normally and says so in warnings. Each entry starts with a code:

WarningMeaning
metadata_ignoredThe workspace is on Free, so metadata was dropped.
fields_ignoredThe workspace is on Free, or some answers aren't on the form.
tags_droppedThe project already has 100 tags, so the new names weren't created.
over_quotaThe workspace is past this month's allowance. The report was still saved.

Warnings are for you, not your users. Log them; don't show them.

Screenshots

Uploading is its own step, so a slow upload never holds up the report:

const attachmentId = await studio.uploads.screenshot({ uri: capture.uri });

await studio.feedback.submit({
  message: 'The chart overlaps the legend.',
  attachmentIds: [attachmentId],
});

uri is a local file — a file://… URI or, from panelui-studio 0.2.1, a bare path, which is what react-native-view-shot returns on iOS — or anything fetch can read.

const file = input.files?.[0]; // from <input type="file">
const attachmentId = await studio.uploads.screenshot({ file });

await studio.feedback.submit({
  message: 'The chart overlaps the legend.',
  attachmentIds: [attachmentId],
});

file is the image itself: a File from a file input, a pasted or dropped image, or any Blob. Its own type is used, and anything other than an image is refused before an upload starts. A uri works too, for an image fetch can read.

PNG, JPEG or WebP, up to 5 MB. The bytes go straight to private storage; Studio only ever shows them to your team through links that expire within minutes.

Each plan allows a number of screenshots per report — 1 on Free, 3 on Pro and Max — and a total amount of storage across the workspace. Once storage is full, uploads.screenshot() fails with storage_full and reports still go through without images.

Monthly allowance

Reports count against the workspace's allowance for the calendar month (UTC). Going over doesn't lose anything your users send:

  • Free: reports past the allowance are saved but held out of the inbox until the workspace upgrades.
  • Pro and Max: reports keep arriving for another 20%, and the workspace's owners get an email.

Past that, submit() fails with quota_exceeded until the next month or an upgrade.

Rate limits are separate and the same on every plan: 5 reports a minute and 50 a day per install. A limited call fails with rate_limited and carries retryAfter in seconds.

Handling errors

Every failure is a StudioError with a stable code. Branch on the code, never on the message:

import { isStudioError } from 'panelui-studio';

try {
  await studio.feedback.submit({ message });
} catch (error) {
  if (isStudioError(error) && error.code === 'rate_limited') {
    showToast(`Try again in ${error.retryAfter ?? 60} seconds.`);
  } else {
    showToast("Couldn't send that. Check your connection and try again.");
  }
}

The full list is in the API reference.

A form on the web

A message, an optional screenshot and the states in between, in plain React. screen is the page the reader was on:

components/feedback-form.tsx
'use client';

import { useState } from 'react';
import { isStudioError } from 'panelui-studio';

import { studio } from '@/lib/studio';

export function FeedbackForm({ onSent }: { onSent?: () => void }) {
  const [status, setStatus] = useState<'idle' | 'sending' | 'sent'>('idle');
  const [error, setError] = useState<string | null>(null);

  async function send(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const form = new FormData(event.currentTarget);
    const shot = form.get('screenshot');
    setStatus('sending');
    setError(null);
    try {
      const attachmentIds =
        shot instanceof File && shot.size > 0 ? [await studio.uploads.screenshot({ file: shot })] : [];
      await studio.feedback.submit({
        message: String(form.get('message')),
        screen: window.location.pathname,
        attachmentIds,
      });
      setStatus('sent');
      onSent?.();
    } catch (caught) {
      setStatus('idle');
      setError(isStudioError(caught) ? caught.message : "Couldn't send that. Check your connection.");
    }
  }

  if (status === 'sent') return <p>Thanks — we read every one.</p>;

  return (
    <form onSubmit={send}>
      <label htmlFor="feedback-message">What should we fix first?</label>
      <textarea id="feedback-message" name="message" required maxLength={5000} />
      <label htmlFor="feedback-screenshot">Screenshot (optional)</label>
      <input id="feedback-screenshot" name="screenshot" type="file" accept="image/png,image/jpeg,image/webp" />
      {error ? <p role="alert">{error}</p> : null}
      <button type="submit" disabled={status === 'sending'}>
        {status === 'sending' ? 'Sending…' : 'Send feedback'}
      </button>
    </form>
  );
}

Style it with your own components; the SDK only needs the message and the file.

Usage with PanelUI

submit works from any UI. If your app uses PanelUI, its Feedback dialog collects the message; a Rating and a Checkbox sit in its panel for the rest. On submit, capture the screen behind the dialog, upload it, then send the report:

components/feedback-button.tsx
import { useState, type RefObject } from 'react';
import { View } from 'react-native';
import { captureRef } from 'react-native-view-shot';
import { Button, Checkbox, Feedback, Rating, Text, useToast } from 'panelui-native';
import { isStudioError } from 'panelui-studio';

import { studio } from '@/lib/studio';

export function FeedbackButton({ screenRef }: { screenRef: RefObject<View | null> }) {
  const { toast } = useToast();
  const [open, setOpen] = useState(false);
  const [rating, setRating] = useState(0);
  const [attach, setAttach] = useState(true);
  const [error, setError] = useState<string | null>(null);

  async function send(message: string) {
    setError(null);
    try {
      const attachmentIds: string[] = [];
      if (attach) {
        const uri = await captureRef(screenRef, { format: 'png', result: 'tmpfile' });
        attachmentIds.push(await studio.uploads.screenshot({ uri }));
      }

      const { feedback } = await studio.feedback.submit({
        message,
        rating: rating || undefined,
        screen: '/workouts',
        attachmentIds,
      });

      setOpen(false);
      setRating(0);
      toast.show({ variant: 'success', label: `Sent as #${feedback.number}` });
    } catch (caught) {
      // Shown in the dialog: a toast would sit underneath it.
      setError(isStudioError(caught) ? caught.message : "Couldn't send that. Check your connection.");
    }
  }

  return (
    <>
      <Button onPress={() => setOpen(true)}>Send feedback</Button>
      <Feedback open={open} onOpenChange={setOpen}>
        <Feedback.Content>
          <Feedback.Panel>
            <Feedback.Title>What should we fix first?</Feedback.Title>
            <Feedback.Close />
            <Feedback.Field placeholder="Tell us what got in your way" />
            <View className="gap-4 pt-2">
              <Rating label="How was it?" value={rating} onValueChange={setRating} />
              <Checkbox label="Attach a screenshot" checked={attach} onCheckedChange={setAttach} />
              {error ? (
                <Text size="sm" className="text-destructive">
                  {error}
                </Text>
              ) : null}
            </View>
          </Feedback.Panel>
          <Feedback.Footer>
            <Feedback.Cancel />
            <Feedback.Submit onSubmit={send} />
          </Feedback.Footer>
        </Feedback.Content>
      </Feedback>
    </>
  );
}

screenRef is the screen being reported on — pass a ref to its root View, with collapsable={false} so Android keeps it to capture. The dialog renders in PanelUI's overlay layer, outside that view, so it isn't in the screenshot.

The PanelUI Feedback dialog on an iPhone: a message, a two-star rating, the project's form questions and a checked Attach a screenshot box.
What the user sees. The Plan and How easy questions come from the project's custom form.

On this page