Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/send-feedback-dialog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": minor
---

Added an in-app feedback dialog for sending comments directly from Hunk.
136 changes: 136 additions & 0 deletions src/core/feedback.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
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" });
});
});
132 changes: 132 additions & 0 deletions src/core/feedback.ts
Original file line number Diff line number Diff line change
@@ -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<Response>;

/** 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<SubmitFeedbackResult> {
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" };
}
}
32 changes: 32 additions & 0 deletions src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}));
Expand Down Expand Up @@ -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<FocusArea>("files");
const [activeAddNoteTarget, setActiveAddNoteTarget] = useState<ActiveAddNoteTarget | null>(null);
const [sidebarWidth, setSidebarWidth] = useState(34);
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -766,13 +780,15 @@ export function App({
openThemeSelector,
copyDecorations,
showAgentNotes,
showFeedback,
showHelp,
showHunkHeaders,
showLineNumbers,
showMenuBar,
renderSidebar,
toggleCopyDecorations,
toggleAgentNotes,
toggleFeedback,
toggleFocusArea,
openAgentSkill,
toggleHelp,
Expand All @@ -799,12 +815,14 @@ export function App({
triggerRefreshCurrentInput,
toggleCopyDecorations,
showAgentNotes,
showFeedback,
showHelp,
showHunkHeaders,
showLineNumbers,
showMenuBar,
renderSidebar,
toggleAgentNotes,
toggleFeedback,
toggleFocusArea,
toggleHelp,
toggleHunkHeaders,
Expand Down Expand Up @@ -838,6 +856,7 @@ export function App({
activateCurrentMenuItem,
canRefreshCurrentInput,
closeAgentSkill,
closeFeedback,
closeHelp,
closeMenu,
acceptThemeSelector,
Expand All @@ -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,
Expand Down Expand Up @@ -1144,6 +1165,17 @@ export function App({
/>
</Suspense>
) : null}

{!pagerMode && showFeedback ? (
<Suspense fallback={null}>
<LazyFeedbackDialog
terminalHeight={terminal.height}
terminalWidth={terminal.width}
theme={activeTheme}
onClose={closeFeedback}
/>
</Suspense>
) : null}
</box>
);
}
Loading