From 89af5f992abbf58cbceb3af31b1f2d5d528a7a02 Mon Sep 17 00:00:00 2001 From: Ben Vinegar <2153+benvinegar@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:39:38 +0200 Subject: [PATCH] feat: add in-app feedback dialog --- .changeset/send-feedback-dialog.md | 5 + src/core/feedback.test.ts | 136 +++++++++++ src/core/feedback.ts | 132 ++++++++++ src/ui/App.tsx | 32 +++ src/ui/AppHost.interactions.test.tsx | 92 +++++++ src/ui/components/chrome/FeedbackDialog.tsx | 258 ++++++++++++++++++++ src/ui/hooks/useAppKeyboardShortcuts.ts | 44 ++++ src/ui/lib/appMenus.ts | 11 + src/ui/lib/ui-lib.test.ts | 2 + 9 files changed, 712 insertions(+) create mode 100644 .changeset/send-feedback-dialog.md create mode 100644 src/core/feedback.test.ts create mode 100644 src/core/feedback.ts create mode 100644 src/ui/components/chrome/FeedbackDialog.tsx diff --git a/.changeset/send-feedback-dialog.md b/.changeset/send-feedback-dialog.md new file mode 100644 index 00000000..d93a0305 --- /dev/null +++ b/.changeset/send-feedback-dialog.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Added an in-app feedback dialog for sending comments directly from Hunk. diff --git a/src/core/feedback.test.ts b/src/core/feedback.test.ts new file mode 100644 index 00000000..6dcd5235 --- /dev/null +++ b/src/core/feedback.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, test } from "bun:test"; +import packageJson from "../../package.json" with { type: "json" }; +import { buildFeedbackEventPayload, submitFeedback } from "./feedback"; + +const FIXED_DATE = new Date("2026-01-01T00:00:00.000Z"); +const FIXED_ISO = FIXED_DATE.toISOString(); + +describe("buildFeedbackEventPayload", () => { + test("builds a feedback.created envelope without an author block when no email is given", () => { + const payload = buildFeedbackEventPayload( + { description: "It crashed on startup." }, + () => FIXED_DATE, + ); + + expect(payload).toEqual({ + event_type: "feedback.created", + created_at: FIXED_ISO, + client: { name: "hunk/cli", version: packageJson.version }, + body: { + data: { + source_message_id: expect.any(String), + content: "It crashed on startup.", + type: "feedback", + source: "user", + metadata: { + platform: process.platform, + app_version: packageJson.version, + }, + }, + }, + }); + expect(payload.body.data).not.toHaveProperty("author"); + expect(payload.body.data).not.toHaveProperty("channel"); + expect(payload).not.toHaveProperty("source_name"); + }); + + test("includes an author block scoped to the submitter's email when provided", () => { + const payload = buildFeedbackEventPayload( + { description: "Love the split view!", email: "reviewer@example.com" }, + () => FIXED_DATE, + ); + + expect(payload.body.data.author).toEqual({ + source_author_id: "reviewer@example.com", + email: "reviewer@example.com", + is_bot: false, + }); + }); + + test("generates a fresh correlation id for every submission", () => { + const first = buildFeedbackEventPayload({ description: "a" }, () => FIXED_DATE); + const second = buildFeedbackEventPayload({ description: "b" }, () => FIXED_DATE); + + expect(first.body.data.source_message_id).not.toBe(second.body.data.source_message_id); + }); +}); + +describe("submitFeedback", () => { + test("fails with not-configured when the public key is still the shipped placeholder", async () => { + const result = await submitFeedback( + { description: "hello" }, + { env: {}, fetchImpl: async () => new Response(null, { status: 202 }) }, + ); + + expect(result).toEqual({ ok: false, reason: "not-configured" }); + }); + + test("fails with not-configured when the override env var is blank", async () => { + const result = await submitFeedback( + { description: "hello" }, + { + env: { HUNK_MODEM_FEEDBACK_KEY: " " }, + fetchImpl: async () => new Response(null, { status: 202 }), + }, + ); + + expect(result).toEqual({ ok: false, reason: "not-configured" }); + }); + + test("posts the envelope to the configured ingest URL with the public key header", async () => { + const requests: Array<{ url: string; init?: RequestInit }> = []; + const result = await submitFeedback( + { description: "Great tool!", email: "user@example.com" }, + { + env: { + HUNK_MODEM_FEEDBACK_KEY: "modem_test_key", + HUNK_MODEM_INGEST_URL: "https://ingest.example.test", + }, + fetchImpl: async (input, init) => { + requests.push({ url: String(input), init }); + return new Response(null, { status: 202 }); + }, + now: () => FIXED_DATE, + }, + ); + + expect(result).toEqual({ ok: true }); + expect(requests).toHaveLength(1); + expect(requests[0]?.url).toBe("https://ingest.example.test/ingest"); + expect(requests[0]?.init?.method).toBe("POST"); + + const headers = requests[0]?.init?.headers as Record; + expect(headers["x-modem-public-key"]).toBe("modem_test_key"); + expect(headers["content-type"]).toBe("application/json"); + + const body = JSON.parse(String(requests[0]?.init?.body)); + expect(body.body.data.content).toBe("Great tool!"); + expect(body.body.data.author.email).toBe("user@example.com"); + }); + + test("fails with http-error when the ingest endpoint responds with a non-2xx status", async () => { + const result = await submitFeedback( + { description: "hello" }, + { + env: { HUNK_MODEM_FEEDBACK_KEY: "modem_test_key" }, + fetchImpl: async () => new Response(null, { status: 500 }), + }, + ); + + expect(result).toEqual({ ok: false, reason: "http-error" }); + }); + + test("fails with network-error and never throws when fetch rejects", async () => { + const result = await submitFeedback( + { description: "hello" }, + { + env: { HUNK_MODEM_FEEDBACK_KEY: "modem_test_key" }, + fetchImpl: async () => { + throw new Error("boom"); + }, + }, + ); + + expect(result).toEqual({ ok: false, reason: "network-error" }); + }); +}); diff --git a/src/core/feedback.ts b/src/core/feedback.ts new file mode 100644 index 00000000..9c2fc17c --- /dev/null +++ b/src/core/feedback.ts @@ -0,0 +1,132 @@ +import { randomUUID } from "node:crypto"; +import packageJson from "../../package.json" with { type: "json" }; + +/** + * PLACEHOLDER — replace with the ingest URL for Modem's production API before shipping. + * Overridable via HUNK_MODEM_INGEST_URL for local/staging testing. + */ +const MODEM_INGEST_URL = "https://ingest.modem.dev"; + +/** + * PLACEHOLDER — Ben must fill this in with the dedicated "hunk" project's public key from + * Modem's org (Settings → Public Keys). Keys look like `modem_<32hex>` and are safe to embed + * in a distributed binary (Sentry-DSN model: public-by-design, revocable). Overridable via + * HUNK_MODEM_FEEDBACK_KEY for local/staging testing. + */ +const MODEM_FEEDBACK_PUBLIC_KEY = "modem_PLACEHOLDER_REPLACE_ME"; + +const FEEDBACK_SUBMIT_TIMEOUT_MS = 10_000; + +type FetchImpl = (input: RequestInfo | URL, init?: RequestInit) => Promise; + +/** Resolve the ingest URL feedback should be POSTed to, honoring the test/staging override. */ +function resolveIngestUrl(env: NodeJS.ProcessEnv = process.env) { + return env.HUNK_MODEM_INGEST_URL?.trim() || MODEM_INGEST_URL; +} + +/** Resolve the embedded public key used to authenticate feedback submissions. */ +function resolveFeedbackPublicKey(env: NodeJS.ProcessEnv = process.env) { + return env.HUNK_MODEM_FEEDBACK_KEY?.trim() || MODEM_FEEDBACK_PUBLIC_KEY; +} + +/** Return whether the resolved public key is still the shipped placeholder. */ +function isPlaceholderFeedbackKey(key: string) { + return key.length === 0 || key === MODEM_FEEDBACK_PUBLIC_KEY; +} + +export interface SubmitFeedbackInput { + description: string; + email?: string; +} + +export type SubmitFeedbackResult = + | { ok: true } + | { ok: false; reason: "not-configured" | "network-error" | "http-error" }; + +export interface SubmitFeedbackDeps { + env?: NodeJS.ProcessEnv; + fetchImpl?: FetchImpl; + now?: () => Date; +} + +/** Build the `feedback.created` APIEvent envelope for one feedback submission. */ +export function buildFeedbackEventPayload( + input: SubmitFeedbackInput, + now: () => Date = () => new Date(), +) { + const createdAt = now().toISOString(); + const email = input.email?.trim(); + + return { + event_type: "feedback.created" as const, + created_at: createdAt, + client: { + // Modem's APIEventClientSchema requires a two-segment `scope/name` slug + // (see CLIENT_NAME_PATTERN); a bare "hunk" is rejected at ingest. + name: "hunk/cli", + version: packageJson.version, + }, + body: { + data: { + source_message_id: randomUUID(), + ...(email + ? { + author: { + source_author_id: email, + email, + is_bot: false, + }, + } + : {}), + content: input.description, + type: "feedback" as const, + source: "user" as const, + metadata: { + platform: process.platform, + app_version: packageJson.version, + }, + }, + }, + }; +} + +/** + * Submit user feedback directly to Modem's ingest pipeline. Never throws — all failure modes + * (missing key, network error, non-2xx response) resolve to a tagged failure result so callers + * (the TUI dialog) can show a message without risking an unhandled rejection. + */ +export async function submitFeedback( + input: SubmitFeedbackInput, + deps: SubmitFeedbackDeps = {}, +): Promise { + const env = deps.env ?? process.env; + const fetchImpl: FetchImpl = deps.fetchImpl ?? fetch; + const publicKey = resolveFeedbackPublicKey(env); + + if (isPlaceholderFeedbackKey(publicKey)) { + return { ok: false, reason: "not-configured" }; + } + + const ingestUrl = resolveIngestUrl(env); + const payload = buildFeedbackEventPayload(input, deps.now); + + try { + const response = await fetchImpl(`${ingestUrl}/ingest`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-modem-public-key": publicKey, + }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(FEEDBACK_SUBMIT_TIMEOUT_MS), + }); + + if (!response.ok) { + return { ok: false, reason: "http-error" }; + } + + return { ok: true }; + } catch { + return { ok: false, reason: "network-error" }; + } +} diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 4acd36f7..e08a368e 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -47,6 +47,9 @@ const LazyAgentSkillDialog = lazy(async () => ({ const LazyHelpDialog = lazy(async () => ({ default: (await import("./components/chrome/HelpDialog")).HelpDialog, })); +const LazyFeedbackDialog = lazy(async () => ({ + default: (await import("./components/chrome/FeedbackDialog")).FeedbackDialog, +})); const LazyMenuDropdown = lazy(async () => ({ default: (await import("./components/chrome/MenuDropdown")).MenuDropdown, })); @@ -147,6 +150,7 @@ export function App({ const [forceSidebarOpen, setForceSidebarOpen] = useState(false); const [showHelp, setShowHelp] = useState(false); const [showAgentSkill, setShowAgentSkill] = useState(false); + const [showFeedback, setShowFeedback] = useState(false); const [focusArea, setFocusArea] = useState("files"); const [activeAddNoteTarget, setActiveAddNoteTarget] = useState(null); const [sidebarWidth, setSidebarWidth] = useState(34); @@ -697,6 +701,16 @@ export function App({ setShowHelp((current) => !current); }, []); + /** Close the feedback dialog. */ + const closeFeedback = useCallback(() => { + setShowFeedback(false); + }, []); + + /** Toggle the feedback dialog. */ + const toggleFeedback = useCallback(() => { + setShowFeedback((current) => !current); + }, []); + /** Focus the file list/sidebar navigation area. */ const focusFiles = useCallback(() => { setFocusArea("files"); @@ -766,6 +780,7 @@ export function App({ openThemeSelector, copyDecorations, showAgentNotes, + showFeedback, showHelp, showHunkHeaders, showLineNumbers, @@ -773,6 +788,7 @@ export function App({ renderSidebar, toggleCopyDecorations, toggleAgentNotes, + toggleFeedback, toggleFocusArea, openAgentSkill, toggleHelp, @@ -799,12 +815,14 @@ export function App({ triggerRefreshCurrentInput, toggleCopyDecorations, showAgentNotes, + showFeedback, showHelp, showHunkHeaders, showLineNumbers, showMenuBar, renderSidebar, toggleAgentNotes, + toggleFeedback, toggleFocusArea, toggleHelp, toggleHunkHeaders, @@ -838,6 +856,7 @@ export function App({ activateCurrentMenuItem, canRefreshCurrentInput, closeAgentSkill, + closeFeedback, closeHelp, closeMenu, acceptThemeSelector, @@ -859,11 +878,13 @@ export function App({ scrollDiff, selectLayoutMode, showAgentSkill, + showFeedback, showHelp, startUserNote: () => startUserNote(), switchMenu, themeSelectorOpen: themeSelectorState.open, toggleAgentNotes, + toggleFeedback, toggleFocusArea, toggleGapForSelectedHunk: review.toggleSelectedHunkGap, toggleHelp, @@ -1144,6 +1165,17 @@ export function App({ /> ) : null} + + {!pagerMode && showFeedback ? ( + + + + ) : null} ); } diff --git a/src/ui/AppHost.interactions.test.tsx b/src/ui/AppHost.interactions.test.tsx index 94daad81..c5f8d30c 100644 --- a/src/ui/AppHost.interactions.test.tsx +++ b/src/ui/AppHost.interactions.test.tsx @@ -3473,4 +3473,96 @@ describe("App interactions", () => { }); } }); + + test("F2 opens and Escape closes the feedback dialog", async () => { + const setup = await testRender(, { + width: 200, + height: 24, + }); + + try { + await flush(setup); + + let frame = setup.captureCharFrame(); + expect(frame).not.toContain("Send feedback"); + + await act(async () => { + await setup.mockInput.pressKey("F2"); + }); + frame = await waitForFrame(setup, (nextFrame) => nextFrame.includes("Send feedback")); + expect(frame).toContain("Send feedback"); + expect(frame).toContain("Description"); + + await act(async () => { + await setup.mockInput.pressEscape(); + }); + frame = await waitForFrame(setup, (nextFrame) => !nextFrame.includes("Send feedback")); + expect(frame).not.toContain("Send feedback"); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + + test("submitting feedback posts to the configured ingest endpoint and shows a success message", async () => { + const previousKey = process.env.HUNK_MODEM_FEEDBACK_KEY; + const previousIngestUrl = process.env.HUNK_MODEM_INGEST_URL; + const originalFetch = globalThis.fetch; + const requests: Array<{ url: string; init?: RequestInit }> = []; + + process.env.HUNK_MODEM_FEEDBACK_KEY = "modem_test_key"; + process.env.HUNK_MODEM_INGEST_URL = "https://ingest.example.test"; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + requests.push({ url: String(input), init }); + return new Response(null, { status: 202 }); + }) as typeof fetch; + + const setup = await testRender(, { + width: 200, + height: 24, + }); + + try { + await flush(setup); + + await act(async () => { + await setup.mockInput.pressKey("F2"); + }); + await waitForFrame(setup, (nextFrame) => nextFrame.includes("Send feedback")); + + await act(async () => { + await setup.mockInput.typeText("Hunk is great."); + }); + await act(async () => { + setup.mockInput.pressKey("s", { ctrl: true }); + }); + + const frame = await waitForFrame(setup, (nextFrame) => + nextFrame.includes("Thanks — feedback sent."), + ); + expect(frame).toContain("Thanks — feedback sent."); + expect(requests).toHaveLength(1); + expect(requests[0]?.url).toBe("https://ingest.example.test/ingest"); + + const body = JSON.parse(String(requests[0]?.init?.body)); + expect(body.body.data.content).toBe("Hunk is great."); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + + globalThis.fetch = originalFetch; + if (previousKey === undefined) { + delete process.env.HUNK_MODEM_FEEDBACK_KEY; + } else { + process.env.HUNK_MODEM_FEEDBACK_KEY = previousKey; + } + if (previousIngestUrl === undefined) { + delete process.env.HUNK_MODEM_INGEST_URL; + } else { + process.env.HUNK_MODEM_INGEST_URL = previousIngestUrl; + } + } + }); }); diff --git a/src/ui/components/chrome/FeedbackDialog.tsx b/src/ui/components/chrome/FeedbackDialog.tsx new file mode 100644 index 00000000..94414fb1 --- /dev/null +++ b/src/ui/components/chrome/FeedbackDialog.tsx @@ -0,0 +1,258 @@ +import type { KeyEvent, TextareaRenderable } from "@opentui/core"; +import { useEffect, useRef, useState } from "react"; +import { submitFeedback } from "../../../core/feedback"; +import { isEscapeKey, isSaveDraftNoteKey } from "../../lib/keyboard"; +import { fitText, padText } from "../../lib/text"; +import type { AppTheme } from "../../themes"; +import { ModalFrame } from "./ModalFrame"; + +type FeedbackDialogStatus = "editing" | "submitting" | "success" | "failure"; + +const DESCRIPTION_ROWS = 6; +const AUTO_DISMISS_MS = 2500; + +/** Resolve the failure copy shown for a submission outcome. */ +function failureMessage(reason: "not-configured" | "network-error" | "http-error" | undefined) { + if (reason === "not-configured") { + return "Feedback isn't configured in this build."; + } + + return "Couldn't send feedback — check your connection."; +} + +/** Render the feedback dialog: a multiline description, optional email, and submission states. */ +export function FeedbackDialog({ + terminalHeight, + terminalWidth, + theme, + onClose, +}: { + terminalHeight: number; + terminalWidth: number; + theme: AppTheme; + onClose: () => void; +}) { + const [status, setStatus] = useState("editing"); + const [description, setDescription] = useState(""); + const [email, setEmail] = useState(""); + const [focusedField, setFocusedField] = useState<"description" | "email">("description"); + const [failureReason, setFailureReason] = useState< + "not-configured" | "network-error" | "http-error" | undefined + >(undefined); + const descriptionRef = useRef(null); + const submissionTokenRef = useRef(0); + const autoDismissTimerRef = useRef | null>(null); + + useEffect(() => { + return () => { + submissionTokenRef.current += 1; + if (autoDismissTimerRef.current) { + clearTimeout(autoDismissTimerRef.current); + } + }; + }, []); + + useEffect(() => { + if (status !== "success") { + return; + } + + autoDismissTimerRef.current = setTimeout(() => { + onClose(); + }, AUTO_DISMISS_MS); + + return () => { + if (autoDismissTimerRef.current) { + clearTimeout(autoDismissTimerRef.current); + autoDismissTimerRef.current = null; + } + }; + }, [status, onClose]); + + const canSubmit = status === "editing" && description.trim().length > 0; + + const submit = () => { + if (!canSubmit) { + return; + } + + setStatus("submitting"); + const token = ++submissionTokenRef.current; + const trimmedEmail = email.trim(); + + void submitFeedback({ + description: description.trim(), + email: trimmedEmail.length > 0 ? trimmedEmail : undefined, + }).then((result) => { + if (submissionTokenRef.current !== token) { + // The dialog was closed or resubmitted before this request resolved. + return; + } + + if (result.ok) { + setStatus("success"); + return; + } + + setFailureReason(result.reason); + setStatus("failure"); + }); + }; + + const width = Math.min(70, Math.max(50, terminalWidth - 8)); + const bodyWidth = Math.max(1, width - 4); + const height = Math.min( + status === "editing" || status === "submitting" ? DESCRIPTION_ROWS + 9 : 8, + Math.max(8, terminalHeight - 2), + ); + + const handleDismissKey = (key: KeyEvent) => { + key.preventDefault(); + key.stopPropagation(); + onClose(); + }; + + return ( + + {status === "success" ? ( + + + Thanks — feedback sent. + + + {"Press any key to close."} + + + ) : status === "failure" ? ( + + + {failureMessage(failureReason)} + + + {"Press any key to close."} + + + ) : ( + + + + {padText(fitText("Description", bodyWidth), bodyWidth)} + + + +