/** * Send Feedback: a message, an optional screenshot, Send. Opened * from the status bar, the `FB` command and Help. The report carries scrubbed * debug logs and app details so a bug can be traced without a back-and-forth. * * `FeedbackDialogHost` is mounted by the shell for the life of the app and * owns the dialog; `requestFeedbackDialog` lets commands and chrome open it. */ import { useCallback, useEffect, useRef, useState } from "react"; import { apiClient } from "../api-client"; import { matchesKeybindingAction, useKeybindings, type KeyChordEventLike } from "../app/keybindings"; import { collectFeedbackDiagnostics, collectFeedbackLogs, feedbackSource } from "../feedback/diagnostics"; import { captureAppImageScreenshot, terminalFrameScreenshot } from "../feedback/screenshot"; import { FEEDBACK_MESSAGE_MAX, type FeedbackScreenshot, type FeedbackSubmitRequest, type FeedbackSubmitResponse, } from "../feedback/types"; import { t, tf } from "../i18n"; import { useAppLanguage } from "../i18n/react"; import { requestAuthDialog } from "../plugins/builtin/cloud/auth-dialog"; import { getSharedRegistry } from "../plugins/registry"; import { useViewport } from "../react/input"; import { useAppStateRef } from "../state/app/context"; import { useThemeColors } from "../theme/theme-context"; import { Box, Text, Textarea, useNativeRenderer, useUiCapabilities, type TextareaRenderable, } from "../ui"; import { useDialog, useDialogKeyboard, type PromptContext } from "../ui/dialog"; import { useToastHost } from "../ui/toast"; import { VERSION } from "../version"; import { Button } from "./ui/button"; import { Checkbox } from "./ui/checkbox"; import { DialogFrame } from "./ui/frame"; /** What the person typed, kept when the dialog closes without sending. */ const draft = { message: "" }; const CONTENT_WIDTH = 64; /** Border plus padding the terminal dialog host draws around the content. */ const TERMINAL_DIALOG_CHROME = 6; const MESSAGE_ROWS = 6; function errorMessage(error: unknown): string { return error instanceof Error && error.message ? error.message : t("Couldn't send. Try again."); } type KeyLike = KeyChordEventLike & { preventDefault?: () => void }; /** * Enter starts a new line; Shift+Enter sends. Terminals without the kitty * keyboard protocol report Shift+Enter as Enter, so Alt+Enter also sends there. */ const TERMINAL_MESSAGE_KEYS = [ { name: "return", action: "newline" }, { name: "linefeed", action: "newline" }, { name: "return", shift: true, action: "submit" }, { name: "linefeed", shift: true, action: "submit" }, { name: "return", meta: true, action: "submit" }, { name: "linefeed", meta: true, action: "submit" }, ]; export function FeedbackDialog({ dialogId, resolve, dismiss, terminalScreenshot, buildReport, onSentAfterClose, width, }: PromptContext & { width: number; /** The screen as it was when the dialog opened, on the terminal renderer. */ terminalScreenshot: FeedbackScreenshot | null; buildReport: (input: { message: string; screenshot: FeedbackScreenshot | null }) => FeedbackSubmitRequest; /** A send that finishes after Esc still counts: the host announces it instead of resolving. */ onSentAfterClose: (response: FeedbackSubmitResponse) => void; }) { useAppLanguage(); const colors = useThemeColors(); const { nativePaneChrome } = useUiCapabilities(); const keybindings = useKeybindings(); const messageRef = useRef(null); const messageValueRef = useRef(draft.message); const [screenshot, setScreenshot] = useState(null); const [capturing, setCapturing] = useState(false); const [sending, setSending] = useState(false); // State lags a render behind: two sends in one terminal read would both see `sending` false. const sendingRef = useRef(false); const closedRef = useRef(false); const [error, setError] = useState(null); const signedIn = apiClient.isSignedIn(); const canAttach = !!terminalScreenshot || nativePaneChrome; const focusMessage = useCallback(() => { messageRef.current?.focus?.(); }, []); useEffect(focusMessage, [focusMessage]); useEffect(() => () => { closedRef.current = true; }, []); const readMessage = () => { try { return messageRef.current?.editBuffer.getText() ?? messageValueRef.current; } catch { return messageValueRef.current; } }; // The terminal textarea reports edits through `onContentChange`, not // `onInput` (see markdown-editor); keep the draft current either way. useEffect(() => { const textarea = messageRef.current; if (!textarea) return; textarea.onContentChange = () => { const value = readMessage(); messageValueRef.current = value; draft.message = value; }; return () => { textarea.onContentChange = undefined; }; }, []); const send = useCallback(async () => { if (sendingRef.current || capturing) return; const message = readMessage().trim().slice(0, FEEDBACK_MESSAGE_MAX); if (!message) { setError(t("Write a message.")); return; } sendingRef.current = true; setSending(true); setError(null); try { const response = await apiClient.submitFeedback(buildReport({ message, screenshot })); // Leave a draft alone if it has moved on since (typed into a reopened dialog). if (draft.message.trim() === message) draft.message = ""; if (closedRef.current) onSentAfterClose(response); else resolve(response); } catch (errorValue) { sendingRef.current = false; if (closedRef.current) return; setError(errorMessage(errorValue)); setSending(false); } }, [buildReport, capturing, onSentAfterClose, resolve, screenshot]); // A click on the checkbox takes DOM focus; hand it back so typing continues. const setScreenshotAttached = useCallback(async (attach: boolean) => { if (!attach || terminalScreenshot) { setScreenshot(attach ? terminalScreenshot : null); focusMessage(); return; } setCapturing(true); setError(null); try { setScreenshot(await captureAppImageScreenshot()); } catch (errorValue) { setError(errorValue instanceof Error && errorValue.message ? errorValue.message : t("Couldn't take a screenshot.")); } finally { setCapturing(false); focusMessage(); } }, [focusMessage, terminalScreenshot]); const signIn = () => { dismiss(); // After the close settles: closing hands focus back to the pane behind, // which would otherwise land after the auth dialog focused its email field. setTimeout(() => { requestAuthDialog({ onSignedIn: () => { requestFeedbackDialog(); } }); }, 0); }; useDialogKeyboard((event) => { if (event.name === "escape") { event.stopPropagation?.(); dismiss(); return; } // The app's form submit key, for terminals that send neither Shift+Enter nor Alt+Enter. if (event.ctrl && event.name === "s") { event.preventDefault?.(); event.stopPropagation?.(); void send(); } }, { scope: dialogId, allowEditable: true }); // The DOM textarea submits on plain Enter whenever it has `onSubmit`, so on // desktop and web it gets none and Shift+Enter (or Alt/Cmd+Enter) sends here. const sendOnModifiedEnter = (event: KeyLike) => { if (event.name !== "return" || !(event.shift || event.alt || event.meta)) return; // Ctrl/Cmd+Shift+Enter and Alt+Enter act on the newest toast; that key // must not also send a half-written report. if (matchesKeybindingAction(keybindings, "notification-action", event)) return; event.preventDefault?.(); void send(); }; return (