From a3f092d30223e7c0e60ea0af50f4678757882acc Mon Sep 17 00:00:00 2001 From: Deodate-Lawson Date: Sun, 25 Oct 2026 20:43:19 -0400 Subject: [PATCH 1/7] feat(feedback): wire contract and browser-side capture The shared contract for in-app feedback (bugs private to reporter and triagers, feature requests on a shared board), plus what the browser collects for a report: page, browser, window, theme, language and the last ten client errors, a viewport screenshot via modern-screenshot (it keeps each pane's scroll position, which html-to-image does not), the typed API client, the Feedback tab's paths and 'Copy as GitHub issue' markdown that leaves out who reported it. Co-Authored-By: Claude Opus 5.5 --- apps/web/__tests__/feedback/context.test.ts | 106 +++++++++ .../feedback/github-markdown.test.ts | 79 +++++++ apps/web/__tests__/feedback/screens.test.ts | 50 ++++ apps/web/package.json | 1 + .../documents/_workspace/feedback/screens.ts | 42 ++++ apps/web/src/lib/feedback/api.ts | 137 +++++++++++ apps/web/src/lib/feedback/context.ts | 72 ++++++ apps/web/src/lib/feedback/contract.ts | 216 ++++++++++++++++++ apps/web/src/lib/feedback/github-markdown.ts | 81 +++++++ apps/web/src/lib/feedback/recent-errors.ts | 67 ++++++ apps/web/src/lib/feedback/screenshot.ts | 54 +++++ pnpm-lock.yaml | 13 +- 12 files changed, 915 insertions(+), 3 deletions(-) create mode 100644 apps/web/__tests__/feedback/context.test.ts create mode 100644 apps/web/__tests__/feedback/github-markdown.test.ts create mode 100644 apps/web/__tests__/feedback/screens.test.ts create mode 100644 apps/web/src/app/employer/documents/_workspace/feedback/screens.ts create mode 100644 apps/web/src/lib/feedback/api.ts create mode 100644 apps/web/src/lib/feedback/context.ts create mode 100644 apps/web/src/lib/feedback/contract.ts create mode 100644 apps/web/src/lib/feedback/github-markdown.ts create mode 100644 apps/web/src/lib/feedback/recent-errors.ts create mode 100644 apps/web/src/lib/feedback/screenshot.ts diff --git a/apps/web/__tests__/feedback/context.test.ts b/apps/web/__tests__/feedback/context.test.ts new file mode 100644 index 000000000..241d56916 --- /dev/null +++ b/apps/web/__tests__/feedback/context.test.ts @@ -0,0 +1,106 @@ +/** @jest-environment jsdom */ + +import { collectFeedbackContext, describeBrowser } from "~/lib/feedback/context"; +import { feedbackContextSchema } from "~/lib/feedback/contract"; +import { clearRecentErrors, installRecentErrors, recentErrors } from "~/lib/feedback/recent-errors"; + +// Silence what the tests log on purpose: the buffer wraps whatever +// console.error is when it installs, so this is what it passes through to. +jest.spyOn(console, "error").mockImplementation(() => undefined); + +describe("describeBrowser", () => { + it.each([ + [ + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36", + "Chrome 141 on macOS", + ], + [ + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0", + "Edge 140 on Windows", + ], + [ + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Safari 18 on iOS", + ], + ["Mozilla/5.0 (X11; Linux x86_64; rv:131.0) Gecko/20100101 Firefox/131.0", "Firefox 131 on Linux"], + ["curl/8.0", undefined], + ])("reads %s", (ua, expected) => { + expect(describeBrowser(ua)).toBe(expected); + }); +}); + +describe("collectFeedbackContext", () => { + beforeEach(() => { + clearRecentErrors(); + window.history.replaceState({}, "", "/employer/documents?feature=knowledge"); + document.documentElement.dataset.theme = "dark"; + document.documentElement.lang = "zh-CN"; + }); + + it("sends only the page when technical details are off", () => { + expect(collectFeedbackContext({ technical: false })).toEqual({ + path: "/employer/documents?feature=knowledge", + }); + expect(collectFeedbackContext({ technical: false, origin: { jobId: "j1" } })).toEqual({ + path: "/employer/documents?feature=knowledge", + origin: { jobId: "j1" }, + }); + }); + + it("describes the page, window and theme, in a shape the server accepts", () => { + const context = collectFeedbackContext({ technical: true }); + expect(context).toMatchObject({ + path: "/employer/documents?feature=knowledge", + theme: "dark", + locale: "zh-CN", + viewport: { width: window.innerWidth, height: window.innerHeight }, + }); + expect(context.url).toContain("/employer/documents?feature=knowledge"); + expect(feedbackContextSchema.safeParse(context).success).toBe(true); + }); + + it("includes recent errors only when there were some", () => { + expect(collectFeedbackContext({ technical: true }).recentErrors).toBeUndefined(); + installRecentErrors(); + // eslint-disable-next-line no-console -- the thing under test + console.error("Something failed:", new Error("boom")); + expect(collectFeedbackContext({ technical: true }).recentErrors).toEqual([ + expect.objectContaining({ message: "Something failed: Error: boom", source: "console" }), + ]); + }); +}); + +describe("recent errors", () => { + beforeAll(() => installRecentErrors()); + beforeEach(() => clearRecentErrors()); + + it("keeps the last ten, newest last, and drops repeats", () => { + for (let i = 0; i < 14; i += 1) { + // eslint-disable-next-line no-console -- the thing under test + console.error(`failure ${i}`); + // eslint-disable-next-line no-console -- a render loop repeating itself + console.error(`failure ${i}`); + } + const kept = recentErrors().map(e => e.message); + expect(kept).toHaveLength(10); + expect(kept[0]).toBe("failure 4"); + expect(kept.at(-1)).toBe("failure 13"); + }); + + it("hears uncaught errors and rejections", () => { + window.dispatchEvent(new ErrorEvent("error", { message: "x is not a function", error: new TypeError("x is not a function") })); + const rejection = new Event("unhandledrejection") as Event & { reason?: unknown }; + rejection.reason = new Error("fetch failed"); + window.dispatchEvent(rejection); + expect(recentErrors()).toEqual([ + expect.objectContaining({ message: "TypeError: x is not a function", source: "error" }), + expect.objectContaining({ message: "Error: fetch failed", source: "unhandledrejection" }), + ]); + }); + + it("trims long messages", () => { + // eslint-disable-next-line no-console -- the thing under test + console.error("y".repeat(2000)); + expect(recentErrors()[0]!.message).toHaveLength(500); + }); +}); diff --git a/apps/web/__tests__/feedback/github-markdown.test.ts b/apps/web/__tests__/feedback/github-markdown.test.ts new file mode 100644 index 000000000..9e70a58be --- /dev/null +++ b/apps/web/__tests__/feedback/github-markdown.test.ts @@ -0,0 +1,79 @@ +import type { FeedbackDetail } from "~/lib/feedback/contract"; +import { feedbackIssueMarkdown, feedbackIssueTitle } from "~/lib/feedback/github-markdown"; + +const REPORT: FeedbackDetail = { + id: 42, + kind: "bug", + title: "Upload stops at 90%", + body: "Dropped a 40 MB PDF.\nIt never finished.", + status: "new", + severity: "blocking", + votes: 0, + voted: false, + mine: false, + replies: 0, + attachments: 1, + createdAt: "2026-10-25T09:30:00.000Z", + updatedAt: "2026-10-25T09:30:00.000Z", + reporter: { name: "Ada Lovelace", email: "ada@example.com" }, + workspaceName: "Acme | Secret", + context: { + path: "/employer/documents?feature=knowledge", + appVersion: "abc1234", + browser: "Chrome 141 on macOS", + viewport: { width: 1440, height: 900 }, + theme: "dark", + locale: "en", + recentErrors: [{ message: "TypeError: x | y", source: "error", at: "2026-10-25T09:29:00Z" }], + origin: { jobId: "job-77" }, + }, + attachmentList: [ + { id: 1, url: "/api/feedback/42/attachments/1", contentType: "image/webp", width: 10, height: 10 }, + ], + thread: [], + canReply: true, + canTriage: true, +}; + +describe("Copy as GitHub issue", () => { + it("prefixes the title the way the repository's templates do", () => { + expect(feedbackIssueTitle(REPORT)).toBe("[bug]: Upload stops at 90%"); + expect(feedbackIssueTitle({ kind: "idea", title: "Export chats" })).toBe( + "[feat]: Export chats" + ); + }); + + it("carries the report and its context", () => { + const md = feedbackIssueMarkdown(REPORT); + expect(md).toContain("## Summary"); + expect(md).toContain("Dropped a 40 MB PDF.\nIt never finished."); + expect(md).toContain("**Severity:** Blocking"); + expect(md).toContain("in-app feedback #42"); + expect(md).toContain("| Page | `/employer/documents?feature=knowledge` |"); + expect(md).toContain("| App version | `abc1234` |"); + expect(md).toContain("| jobId | `job-77` |"); + expect(md).toContain("Recent errors in the page (1)"); + expect(md).toContain("1 screenshot attached"); + }); + + it("leaves out who reported it and where — a public tracker is the wrong place", () => { + const md = feedbackIssueMarkdown(REPORT); + expect(md).not.toContain("Ada"); + expect(md).not.toContain("ada@example.com"); + expect(md).not.toContain("Acme"); + }); + + it("keeps table cells intact when a value has a pipe", () => { + const md = feedbackIssueMarkdown({ + ...REPORT, + context: { browser: "Weird | Browser" }, + }); + expect(md).toContain("| Browser | Weird \\| Browser |"); + }); + + it("says so when there are no details", () => { + expect(feedbackIssueMarkdown({ ...REPORT, body: "", context: undefined })).toContain( + "_No details given._" + ); + }); +}); diff --git a/apps/web/__tests__/feedback/screens.test.ts b/apps/web/__tests__/feedback/screens.test.ts new file mode 100644 index 000000000..ee6e7c826 --- /dev/null +++ b/apps/web/__tests__/feedback/screens.test.ts @@ -0,0 +1,50 @@ +import { feedbackReportHref } from "~/lib/feedback/contract"; +import { toolTargetFromHref } from "~/lib/tool-app/locations"; +import { + FEEDBACK_HOME, + feedbackPaths, + feedbackScreenFor, +} from "~/app/employer/documents/_workspace/feedback/screens"; + +/** + * The Feedback tab's paths are what the dialog's "View report", the bell and + * the webhook's triage link point at. Pinned here so a rename can't strand them. + */ +describe("feedback screens", () => { + it("opens on My reports", () => { + expect(FEEDBACK_HOME).toBe("/mine"); + expect(feedbackScreenFor("/")).toEqual({ kind: "list", screen: "mine" }); + }); + + it.each([ + ["/mine", { kind: "list", screen: "mine" }], + ["/ideas", { kind: "list", screen: "ideas" }], + ["/triage", { kind: "list", screen: "triage" }], + ["/mine/12", { kind: "report", screen: "mine", id: 12 }], + ["/ideas/3", { kind: "report", screen: "ideas", id: 3 }], + ["/triage/40", { kind: "report", screen: "triage", id: 40 }], + ])("reads %s", (path, expected) => { + expect(feedbackScreenFor(path)).toEqual(expected); + }); + + it.each(["/inbox", "/mine/abc", "/ideas/3/edit", "/triage/-1"])( + "has no screen for %s", + path => { + expect(feedbackScreenFor(path)).toEqual({ kind: "not-found" }); + } + ); + + it("round-trips its own paths", () => { + expect(feedbackScreenFor(feedbackPaths.report("ideas", 9))).toEqual({ + kind: "report", + screen: "ideas", + id: 9, + }); + }); + + it("links to a report as the workspace's Feedback tab at that screen", () => { + const href = feedbackReportHref(7, "triage"); + expect(href.startsWith("/employer/documents?feature=feedback&at=")).toBe(true); + expect(toolTargetFromHref(href)).toEqual({ toolId: "feedback", at: "/triage/7" }); + }); +}); diff --git a/apps/web/package.json b/apps/web/package.json index 18379e874..fc2383845 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -125,6 +125,7 @@ "mammoth": "^1.11.0", "marked": "^17.0.3", "mermaid": "^11.12.2", + "modern-screenshot": "^4.7.0", "neo4j-driver": "^6.0.1", "next": "^15.5.7", "next-intl": "^4.14.9", diff --git a/apps/web/src/app/employer/documents/_workspace/feedback/screens.ts b/apps/web/src/app/employer/documents/_workspace/feedback/screens.ts new file mode 100644 index 000000000..49311fdb0 --- /dev/null +++ b/apps/web/src/app/employer/documents/_workspace/feedback/screens.ts @@ -0,0 +1,42 @@ +/** + * The Feedback tab's screens and their paths. Plain data, so deep links — + * the dialog's "View report", the bell, the webhook's triage link — are + * pinned by tests. + * + * /mine what I reported, newest activity first (the tab's home) + * /mine/ one of mine: its status and the team's replies + * /ideas the shared board of feature requests, by votes + * /ideas/ one request: vote, read the team's answer + * /triage every report, for the instance's operators + * /triage/ one report with everything that came with it + */ + +import type { FeedbackScreen } from "~/lib/feedback/contract"; + +export const FEEDBACK_ROOTS = ["mine", "ideas", "triage"] as const; +export const FEEDBACK_HOME = "/mine"; + +export type FeedbackScreenState = + | { kind: "list"; screen: FeedbackScreen } + | { kind: "report"; screen: FeedbackScreen; id: number } + | { kind: "not-found" }; + +export const feedbackPaths = { + list: (screen: FeedbackScreen) => `/${screen}`, + report: (screen: FeedbackScreen, id: number) => `/${screen}/${id}`, +}; + +function isScreen(segment: string | undefined): segment is FeedbackScreen { + return segment === "mine" || segment === "ideas" || segment === "triage"; +} + +export function feedbackScreenFor(path: string): FeedbackScreenState { + const [root, rest, extra] = path.split("/").filter(Boolean); + if (extra !== undefined) return { kind: "not-found" }; + if (root === undefined) return { kind: "list", screen: "mine" }; + if (!isScreen(root)) return { kind: "not-found" }; + if (rest === undefined) return { kind: "list", screen: root }; + return /^\d+$/.test(rest) + ? { kind: "report", screen: root, id: Number(rest) } + : { kind: "not-found" }; +} diff --git a/apps/web/src/lib/feedback/api.ts b/apps/web/src/lib/feedback/api.ts new file mode 100644 index 000000000..942d0352e --- /dev/null +++ b/apps/web/src/lib/feedback/api.ts @@ -0,0 +1,137 @@ +/** + * The Feedback routes, from the browser. Every call rejects with a + * `FeedbackApiError` carrying the server's own sentence (already in the + * reader's language) when there is one, so screens can show it as is. + */ + +import type { + CreateFeedbackInput, + FeedbackAccess, + FeedbackDetail, + FeedbackListItem, + FeedbackReply, + FeedbackScope, + FeedbackStatus, + BugSeverity, + FeedbackKind, +} from "./contract"; + +export class FeedbackApiError extends Error { + constructor( + message: string, + readonly status: number + ) { + super(message); + this.name = "FeedbackApiError"; + } +} + +async function read(res: Response): Promise { + if (res.ok) return (await res.json()) as T; + let message = ""; + try { + const body = (await res.json()) as { error?: unknown; message?: unknown }; + message = + typeof body.error === "string" + ? body.error + : typeof body.message === "string" + ? body.message + : ""; + } catch { + // Not JSON — a proxy's error page. The status is all there is. + } + throw new FeedbackApiError(message, res.status); +} + +const CHANGED = "launchstack:feedback-changed"; + +/** Something was sent, voted on or answered: lists on screen should reload. */ +export function notifyFeedbackChanged(): void { + if (typeof window !== "undefined") window.dispatchEvent(new Event(CHANGED)); +} + +export function onFeedbackChanged(listener: () => void): () => void { + window.addEventListener(CHANGED, listener); + return () => window.removeEventListener(CHANGED, listener); +} + +const json = (body: unknown): RequestInit => ({ + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), +}); + +export function fetchFeedbackAccess(): Promise { + return fetch("/api/feedback/me", { cache: "no-store" }).then(r => read(r)); +} + +export async function createFeedback( + input: CreateFeedbackInput, + images: Blob[] +): Promise { + const form = new FormData(); + form.set("payload", JSON.stringify(input)); + images.forEach((image, i) => { + const type = image.type || "image/png"; + const ext = type.split("/")[1] ?? "png"; + form.append("files", image, `screenshot-${i + 1}.${ext}`); + }); + const res = await fetch("/api/feedback", { method: "POST", body: form }); + const { report } = await read<{ report: FeedbackDetail }>(res); + notifyFeedbackChanged(); + return report; +} + +export interface FeedbackListFilter { + scope: FeedbackScope; + kind?: FeedbackKind; + status?: FeedbackStatus; + q?: string; + sort?: "votes" | "recent"; + limit?: number; +} + +export async function listFeedback( + filter: FeedbackListFilter, + signal?: AbortSignal +): Promise { + const params = new URLSearchParams({ scope: filter.scope }); + if (filter.kind) params.set("kind", filter.kind); + if (filter.status) params.set("status", filter.status); + if (filter.q?.trim()) params.set("q", filter.q.trim()); + if (filter.sort) params.set("sort", filter.sort); + if (filter.limit) params.set("limit", String(filter.limit)); + const res = await fetch(`/api/feedback?${params.toString()}`, { cache: "no-store", signal }); + return (await read<{ items: FeedbackListItem[] }>(res)).items; +} + +export async function getFeedback(id: number, signal?: AbortSignal): Promise { + const res = await fetch(`/api/feedback/${id}`, { cache: "no-store", signal }); + return (await read<{ report: FeedbackDetail }>(res)).report; +} + +export async function updateFeedback( + id: number, + patch: { status?: FeedbackStatus; severity?: BugSeverity | null } +): Promise { + const res = await fetch(`/api/feedback/${id}`, { method: "PATCH", ...json(patch) }); + const { report } = await read<{ report: FeedbackDetail }>(res); + notifyFeedbackChanged(); + return report; +} + +export async function replyToFeedback(id: number, body: string): Promise { + const res = await fetch(`/api/feedback/${id}/replies`, { method: "POST", ...json({ body }) }); + const { reply } = await read<{ reply: FeedbackReply }>(res); + notifyFeedbackChanged(); + return reply; +} + +export async function voteForFeedback( + id: number, + vote: boolean +): Promise<{ votes: number; voted: boolean }> { + const res = await fetch(`/api/feedback/${id}/vote`, { method: vote ? "PUT" : "DELETE" }); + const result = await read<{ votes: number; voted: boolean }>(res); + notifyFeedbackChanged(); + return result; +} diff --git a/apps/web/src/lib/feedback/context.ts b/apps/web/src/lib/feedback/context.ts new file mode 100644 index 000000000..aafee14a7 --- /dev/null +++ b/apps/web/src/lib/feedback/context.ts @@ -0,0 +1,72 @@ +/** + * What the browser can say about where a report was made. Collected when the + * dialog opens — not when it is sent, by which time the page under it may + * have changed — and shown to the reporter in full before anything is sent. + */ + +import type { FeedbackContext } from "./contract"; +import { recentErrors } from "./recent-errors"; + +/** "Chrome 141 on macOS". Coarse on purpose: triage reads it at a glance. */ +export function describeBrowser(userAgent: string): string | undefined { + const ua = userAgent; + const browser = + match(ua, /Edg\/(\d+)/, "Edge") ?? + match(ua, /OPR\/(\d+)/, "Opera") ?? + match(ua, /Firefox\/(\d+)/, "Firefox") ?? + match(ua, /Chrome\/(\d+)/, "Chrome") ?? + (ua.includes("Safari/") ? match(ua, /Version\/(\d+)/, "Safari") : undefined); + const os = /iPhone|iPad|iPod/.test(ua) + ? "iOS" + : ua.includes("Android") + ? "Android" + : /Mac OS X|Macintosh/.test(ua) + ? "macOS" + : ua.includes("Windows") + ? "Windows" + : /Linux|X11/.test(ua) + ? "Linux" + : undefined; + if (!browser && !os) return undefined; + if (!os) return browser; + if (!browser) return os; + return `${browser} on ${os}`; +} + +function match(ua: string, pattern: RegExp, name: string): string | undefined { + const m = pattern.exec(ua); + return m ? `${name} ${m[1]}` : undefined; +} + +export interface CollectOptions { + /** Off sends the page's path and nothing else. */ + technical: boolean; + origin?: Record; +} + +export function collectFeedbackContext({ technical, origin }: CollectOptions): FeedbackContext { + if (typeof window === "undefined") return {}; + const { location, navigator, document, screen } = window; + const path = `${location.pathname}${location.search}`; + if (!technical) return origin ? { path, origin } : { path }; + const root = document.documentElement; + const errors = recentErrors(); + return { + url: location.href, + path, + pageTitle: document.title || undefined, + userAgent: navigator.userAgent, + browser: describeBrowser(navigator.userAgent), + viewport: { width: window.innerWidth, height: window.innerHeight }, + screen: { + width: screen.width, + height: screen.height, + pixelRatio: Math.round((window.devicePixelRatio || 1) * 100) / 100, + }, + theme: root.dataset.theme === "dark" ? "dark" : "light", + locale: root.lang || navigator.language, + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + recentErrors: errors.length > 0 ? errors : undefined, + origin, + }; +} diff --git a/apps/web/src/lib/feedback/contract.ts b/apps/web/src/lib/feedback/contract.ts new file mode 100644 index 000000000..5670c14c6 --- /dev/null +++ b/apps/web/src/lib/feedback/contract.ts @@ -0,0 +1,216 @@ +/** + * The feedback wire contract: what the reporter dialog sends and what the + * Feedback tab reads back. Client-safe — the dialog validates with the same + * schema the route parses with, so a report that passes here is accepted. + * + * Two kinds share one table: a `bug` is private to the person who filed it and + * the instance's triagers; an `idea` (feature request) is on the shared board, + * where everyone signed in can read it and vote for it, without its author's + * name. Triagers are the instance's operators (`FEEDBACK_ADMIN_EMAILS`), not a + * workspace role: a bug in the product is the operator's to fix, whichever + * workspace it happened in. + */ + +import { z } from "zod"; + +import { toolTabHref } from "~/lib/tool-app/locations"; + +export const FEEDBACK_KINDS = ["bug", "idea"] as const; +export type FeedbackKind = (typeof FEEDBACK_KINDS)[number]; + +export const FEEDBACK_STATUSES = ["new", "planned", "in_progress", "done", "wont_do"] as const; +export type FeedbackStatus = (typeof FEEDBACK_STATUSES)[number]; + +/** How much a bug is in the reporter's way. Ideas have none. */ +export const BUG_SEVERITIES = ["blocking", "annoying", "cosmetic"] as const; +export type BugSeverity = (typeof BUG_SEVERITIES)[number]; + +export const FEEDBACK_LIMITS = { + titleMax: 140, + bodyMax: 8000, + replyMax: 4000, + attachmentsMax: 3, + /** Per image, before it is re-encoded. */ + attachmentBytesMax: 5 * 1024 * 1024, + attachmentTypes: ["image/png", "image/jpeg", "image/webp", "image/gif"] as const, + recentErrorsMax: 10, + errorMessageMax: 500, +} as const; + +/** One thing that went wrong in the page before the report was opened. */ +export const recentErrorSchema = z.object({ + message: z.string().max(FEEDBACK_LIMITS.errorMessageMax), + /** `error` | `unhandledrejection` | `console` */ + source: z.string().max(32).optional(), + at: z.string().max(40), +}); +export type RecentError = z.infer; + +/** + * What the browser knew when the report was made. Every field is optional: + * the reporter can switch technical details off, which sends only `path`. + * The server adds `appVersion` and the workspace itself — neither is the + * client's to claim. + */ +export const feedbackContextSchema = z.object({ + url: z.string().max(2000).optional(), + path: z.string().max(1000).optional(), + pageTitle: z.string().max(300).optional(), + userAgent: z.string().max(500).optional(), + /** "Chrome 141 on macOS" — parsed in the browser so triage reads at a glance. */ + browser: z.string().max(120).optional(), + viewport: z + .object({ width: z.number().int().nonnegative(), height: z.number().int().nonnegative() }) + .optional(), + screen: z + .object({ + width: z.number().int().nonnegative(), + height: z.number().int().nonnegative(), + pixelRatio: z.number().positive().max(10), + }) + .optional(), + theme: z.enum(["light", "dark"]).optional(), + locale: z.string().max(20).optional(), + timeZone: z.string().max(64).optional(), + recentErrors: z.array(recentErrorSchema).max(FEEDBACK_LIMITS.recentErrorsMax).optional(), + /** + * Where the report came from when something prefilled it: an error + * screen's digest, a failed source's job id and document. + */ + origin: z.record(z.string().max(40), z.string().max(300)).optional(), +}); +export type FeedbackContext = z.infer; + +/** The server's additions, stored beside what the browser sent. */ +export interface StoredFeedbackContext extends FeedbackContext { + appVersion?: string; + workspaceName?: string; +} + +/** + * `POST /api/feedback` is multipart: this object as JSON in the `payload` + * field, and up to `attachmentsMax` images in `files`. + */ +export const createFeedbackSchema = z.object({ + kind: z.enum(FEEDBACK_KINDS), + title: z.string().trim().min(1).max(FEEDBACK_LIMITS.titleMax), + body: z.string().trim().max(FEEDBACK_LIMITS.bodyMax).default(""), + severity: z.enum(BUG_SEVERITIES).optional(), + context: feedbackContextSchema.default({}), +}); +export type CreateFeedbackInput = z.input; + +export const updateFeedbackSchema = z + .object({ + status: z.enum(FEEDBACK_STATUSES).optional(), + severity: z.enum(BUG_SEVERITIES).nullable().optional(), + }) + .refine(v => v.status !== undefined || v.severity !== undefined); + +export const replyFeedbackSchema = z.object({ + body: z.string().trim().min(1).max(FEEDBACK_LIMITS.replyMax), +}); + +/** `GET /api/feedback?scope=…` */ +export const FEEDBACK_SCOPES = ["mine", "ideas", "triage"] as const; +export type FeedbackScope = (typeof FEEDBACK_SCOPES)[number]; + +export const listFeedbackQuerySchema = z.object({ + scope: z.enum(FEEDBACK_SCOPES), + kind: z.enum(FEEDBACK_KINDS).optional(), + status: z.enum(FEEDBACK_STATUSES).optional(), + /** Matched against title and body. Also how the dialog finds similar ideas. */ + q: z.string().trim().max(200).optional(), + sort: z.enum(["votes", "recent"]).default("recent"), + limit: z.coerce.number().int().min(1).max(100).default(50), +}); + +/** Who someone is, as the Feedback tab shows them. */ +export interface FeedbackPerson { + name: string; + /** Only triagers are sent the email. */ + email?: string; +} + +export interface FeedbackListItem { + id: number; + kind: FeedbackKind; + title: string; + status: FeedbackStatus; + severity: BugSeverity | null; + votes: number; + /** Whether the person asking has voted for it. */ + voted: boolean; + /** Whether the person asking filed it. */ + mine: boolean; + replies: number; + attachments: number; + createdAt: string; + /** The newest reply or status change, else `createdAt`. */ + updatedAt: string; + /** Triage only: who filed it and from which workspace. */ + reporter?: FeedbackPerson; + workspaceName?: string | null; + /** Triage only: the last word in the thread is the reporter's. */ + awaitingReply?: boolean; +} + +export interface FeedbackReply { + id: number; + body: string; + /** Written by a triager — the tab labels it as the team's. */ + fromTeam: boolean; + mine: boolean; + author: FeedbackPerson; + createdAt: string; +} + +export interface FeedbackAttachment { + id: number; + /** Same-origin, auth-gated: `/api/feedback//attachments/`. */ + url: string; + contentType: string; + width: number | null; + height: number | null; +} + +export interface FeedbackDetail extends FeedbackListItem { + body: string; + /** + * The reporter and triagers see these. Someone reading another person's + * idea on the board does not: a page URL or a screenshot can show their + * workspace. + */ + context?: StoredFeedbackContext; + attachmentList?: FeedbackAttachment[]; + thread: FeedbackReply[]; + /** The person asking may reply (the reporter, or a triager). */ + canReply: boolean; + /** The person asking may change status and severity. */ + canTriage: boolean; +} + +/** `GET /api/feedback/me` — whether to show the reporter at all, and Triage. */ +export interface FeedbackAccess { + /** False while the instance has named no triager: nobody would read it. */ + enabled: boolean; + triager: boolean; +} + +/** Where the Feedback tab shows a report: `/employer/documents?feature=feedback&at=`. */ +export const FEEDBACK_TOOL_ID = "feedback"; +export type FeedbackScreen = "mine" | "ideas" | "triage"; +export function feedbackReportPath(id: number, from: FeedbackScreen = "mine"): string { + return `/${from}/${id}`; +} +export function feedbackReportHref(id: number, from: FeedbackScreen = "mine"): string { + return toolTabHref(FEEDBACK_TOOL_ID, feedbackReportPath(id, from)); +} + +/** `workspace_notifications` rows about feedback use these. */ +export const FEEDBACK_NOTIFICATION_KIND = "feedback"; +export const FEEDBACK_NOTIFICATION_TARGET = "feedback"; +export interface FeedbackNotificationDetail { + event: "reply" | "status"; + status?: FeedbackStatus; +} diff --git a/apps/web/src/lib/feedback/github-markdown.ts b/apps/web/src/lib/feedback/github-markdown.ts new file mode 100644 index 000000000..2e4453077 --- /dev/null +++ b/apps/web/src/lib/feedback/github-markdown.ts @@ -0,0 +1,81 @@ +/** + * A report as a GitHub issue, for Triage's "Copy as GitHub issue". Shaped + * like the repository's own issue templates (`.github/ISSUE_TEMPLATE`), so a + * pasted report reads like one filed there. + * + * In English whatever the triager's language: it is written for the issue + * tracker, not for the screen. It leaves out who reported it and which + * workspace they were in — a public tracker is the wrong place for either — + * and the screenshots, which stay in the instance. + */ + +import type { FeedbackDetail } from "./contract"; + +const SEVERITY: Record = { + blocking: "Blocking", + annoying: "Annoying", + cosmetic: "Cosmetic", +}; + +function cell(value: string): string { + return value.replace(/\|/g, "\\|").replace(/\n/g, " "); +} + +export function feedbackIssueTitle(report: Pick): string { + return `${report.kind === "bug" ? "[bug]" : "[feat]"}: ${report.title}`; +} + +export function feedbackIssueMarkdown(report: FeedbackDetail): string { + const lines: string[] = []; + lines.push(report.kind === "bug" ? "## Summary" : "## Problem", ""); + lines.push(report.body.trim() || "_No details given._", ""); + const facts: string[] = []; + if (report.kind === "bug" && report.severity) + facts.push(`**Severity:** ${SEVERITY[report.severity] ?? report.severity}`); + if (report.kind === "idea") facts.push(`**Votes:** ${report.votes}`); + facts.push(`**Reported:** ${report.createdAt.slice(0, 10)} (in-app feedback #${report.id})`); + lines.push(facts.join(" \n"), ""); + + const context = report.context; + if (context) { + const rows: [string, string][] = []; + if (context.path) rows.push(["Page", `\`${context.path}\``]); + if (context.appVersion) rows.push(["App version", `\`${context.appVersion}\``]); + if (context.browser) rows.push(["Browser", context.browser]); + if (context.viewport) + rows.push(["Window", `${context.viewport.width} × ${context.viewport.height}`]); + if (context.screen) + rows.push([ + "Screen", + `${context.screen.width} × ${context.screen.height} @${context.screen.pixelRatio}x`, + ]); + if (context.theme) rows.push(["Theme", context.theme]); + if (context.locale) rows.push(["Language", context.locale]); + for (const [key, value] of Object.entries(context.origin ?? {})) + rows.push([key, `\`${value}\``]); + if (rows.length > 0) { + lines.push("## Context", "", "| | |", "|---|---|"); + for (const [label, value] of rows) lines.push(`| ${cell(label)} | ${cell(value)} |`); + lines.push(""); + } + const errors = context.recentErrors ?? []; + if (errors.length > 0) { + lines.push( + `
Recent errors in the page (${errors.length})`, + "", + "```", + ...errors.map(e => `${e.at} ${e.source ?? ""} ${e.message}`.replace(/\s+/g, " ")), + "```", + "", + "
", + "" + ); + } + } + const shots = report.attachmentList?.length ?? report.attachments; + if (shots > 0) + lines.push( + `_${shots} screenshot${shots === 1 ? "" : "s"} attached to the report in LaunchStack (not copied here)._` + ); + return lines.join("\n").trim() + "\n"; +} diff --git a/apps/web/src/lib/feedback/recent-errors.ts b/apps/web/src/lib/feedback/recent-errors.ts new file mode 100644 index 000000000..21a52340e --- /dev/null +++ b/apps/web/src/lib/feedback/recent-errors.ts @@ -0,0 +1,67 @@ +/** + * The last few things that went wrong in this tab, kept so a bug report can + * say what the page was doing before someone opened it — "it just broke" is + * hard to fix; "it broke, and the console said `TypeError: … reading 'id'`" + * often isn't. + * + * Three sources: uncaught errors, unhandled promise rejections, and + * `console.error` (which is where React and our own catch blocks report). + * Only the message is kept, trimmed; nothing leaves the browser unless the + * reporter sends it, and the dialog shows the list before they do. + */ + +import { FEEDBACK_LIMITS, type RecentError } from "./contract"; + +const buffer: RecentError[] = []; +let installed = false; + +function push(source: string, message: string) { + const text = message.trim().slice(0, FEEDBACK_LIMITS.errorMessageMax); + if (!text) return; + // A render loop logs the same line hundreds of times; one is enough. + if (buffer.at(-1)?.message === text) return; + buffer.push({ message: text, source, at: new Date().toISOString() }); + if (buffer.length > FEEDBACK_LIMITS.recentErrorsMax) buffer.shift(); +} + +function describe(value: unknown): string { + if (value instanceof Error) return `${value.name}: ${value.message}`; + if (typeof value === "string") return value; + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +/** Start listening. Idempotent; a no-op on the server. */ +export function installRecentErrors(): void { + if (installed || typeof window === "undefined") return; + installed = true; + window.addEventListener("error", event => { + push("error", event.error ? describe(event.error) : event.message); + }); + window.addEventListener("unhandledrejection", event => { + push("unhandledrejection", describe(event.reason)); + }); + const original = console.error.bind(console); + console.error = (...args: unknown[]) => { + try { + // React passes a format string first: "%s … %s", then its parts. + push("console", args.map(describe).join(" ")); + } catch { + // Recording must never be the thing that breaks logging. + } + original(...args); + }; +} + +/** Newest last. A copy, so the dialog can drop entries without touching the record. */ +export function recentErrors(): RecentError[] { + return buffer.slice(); +} + +/** For tests. */ +export function clearRecentErrors(): void { + buffer.length = 0; +} diff --git a/apps/web/src/lib/feedback/screenshot.ts b/apps/web/src/lib/feedback/screenshot.ts new file mode 100644 index 000000000..6829b7787 --- /dev/null +++ b/apps/web/src/lib/feedback/screenshot.ts @@ -0,0 +1,54 @@ +/** + * A picture of the page as the reporter sees it, without asking the browser + * for screen-share permission: the DOM is redrawn into an image + * (modern-screenshot, SVG foreignObject). It restores each pane's scroll + * position — a chat scrolled to its last message is captured there, not at + * the top — which is why it is used rather than html-to-image. + * + * What it cannot draw: the inside of iframes, and images from another + * origin that refuse CORS (those come out blank). The dialog says so when a + * capture fails, and paste/attach is always there instead. + * + * Elements marked `data-feedback-capture="skip"` (the dialog, its overlay, + * the launcher, toasts) are left out, so the report shows the page and not + * the form about the page. + */ + +import { domToBlob } from "modern-screenshot"; + +export const CAPTURE_SKIP_ATTRIBUTE = "data-feedback-capture"; + +function skipped(node: Node): boolean { + return ( + node instanceof Element && + (node.getAttribute(CAPTURE_SKIP_ATTRIBUTE) === "skip" || + // The dialog's dimmed backdrop: the picture is of the page under it. + node.getAttribute("data-slot") === "dialog-overlay" || + // Sonner's toaster: "Report sent" must not be in the next report's picture. + node.hasAttribute("data-sonner-toaster")) + ); +} + +/** The visible viewport as a PNG, at up to 2× for legibility. */ +export async function capturePage(): Promise { + const body = document.body; + const width = window.innerWidth; + const height = window.innerHeight; + const background = getComputedStyle(body).backgroundColor; + return domToBlob(body, { + width, + height, + scale: Math.min(window.devicePixelRatio || 1, 2), + backgroundColor: background, + type: "image/png", + timeout: 15_000, + filter: node => !skipped(node), + features: { restoreScrollPosition: true }, + // A window-level scroll (Settings is a long page) is not an element's: + // move the copy so the viewport's part of it is what gets drawn. + style: + window.scrollX || window.scrollY + ? { transform: `translate(${-window.scrollX}px, ${-window.scrollY}px)` } + : undefined, + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8699f5295..ed4891216 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -396,6 +396,9 @@ importers: mermaid: specifier: ^11.12.2 version: 11.13.0 + modern-screenshot: + specifier: ^4.7.0 + version: 4.7.0 neo4j-driver: specifier: ^6.0.1 version: 6.0.1 @@ -2150,7 +2153,6 @@ packages: '@browserbasehq/stagehand@1.9.0': resolution: {integrity: sha512-0wIFnwOVnUEgVkPKW0RX7NoOt98qaRJ8+l1m9ppk1f5E03GtefDQTMiQwwT9WQn163bpZT5cOhyA1I3jZNfFeA==} - deprecated: This version is no longer maintained. Please upgrade to the latest version of @browserbasehq/stagehand. peerDependencies: '@playwright/test': ^1.42.1 deepmerge: ^4.3.1 @@ -9872,6 +9874,9 @@ packages: mlly@1.8.1: resolution: {integrity: sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==} + modern-screenshot@4.7.0: + resolution: {integrity: sha512-9YxN+ddPSMMlhylOv25VHzXrl9u67QRxoh7+SEewGtgUw7t6hHTrjptSDJUSne9oG4Xk/h2cwG15nIt4Hc9ujg==} + module-details-from-path@1.0.4: resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} @@ -20128,7 +20133,7 @@ snapshots: isstream: 0.1.2 jsonwebtoken: 9.0.3 mime-types: 2.1.35 - retry-axios: 2.6.0(axios@1.7.4(debug@4.4.3)) + retry-axios: 2.6.0(axios@1.7.4) tough-cookie: 4.1.4 transitivePeerDependencies: - supports-color @@ -21638,6 +21643,8 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.3 + modern-screenshot@4.7.0: {} + module-details-from-path@1.0.4: {} motion-dom@12.29.2: @@ -22807,7 +22814,7 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - retry-axios@2.6.0(axios@1.7.4(debug@4.4.3)): + retry-axios@2.6.0(axios@1.7.4): dependencies: axios: 1.7.4(debug@4.4.3) From 5d4032a97d5e5a41f76e2fe9a6b203b8067eabb5 Mon Sep 17 00:00:00 2001 From: Deodate-Lawson Date: Sun, 25 Oct 2026 20:43:35 -0400 Subject: [PATCH 2/7] feat(feedback): reporter dialog, Feedback button and entry points One dialog for every signed-in page (employer pages and the workspace picker), shown only when the instance names a triager. A bug needs one line; a feature request a title, with matching requests on the board offered to vote for instead. Screenshots: capture the page, paste or attach, then box or black out parts before sending. What goes with the report is listed in the dialog and technical details can be switched off. Ways in: a floating Feedback button (hideable per person), the account menu, Report a bug / Request a feature in Cmd+K and the right-click menu, Mod+Alt+B, the error screen, a failed source's card and the contact page. English and Simplified Chinese. Co-Authored-By: Claude Opus 5.5 --- .../feedback/FeedbackDialog.test.tsx | 294 ++++++ .../app/employer/contact/ContactClient.tsx | 48 +- .../documents/_workspace/AccountMenu.tsx | 14 +- .../documents/_workspace/SourceStatus.tsx | 46 +- apps/web/src/app/employer/error.tsx | 23 + apps/web/src/app/employer/layout.tsx | 12 +- apps/web/src/app/workspaces/layout.tsx | 10 +- .../components/feedback/FeedbackDialog.tsx | 864 ++++++++++++++++++ .../components/feedback/FeedbackLauncher.tsx | 140 +++ .../components/feedback/FeedbackProvider.tsx | 166 ++++ .../components/feedback/ScreenshotEditor.tsx | 248 +++++ .../src/components/feedback/VoteButton.tsx | 80 ++ apps/web/src/components/feedback/draft.ts | 33 + apps/web/src/components/feedback/keys.ts | 16 + apps/web/src/components/feedback/labels.tsx | 54 ++ .../src/components/ui/action-menu/icons.tsx | 4 + apps/web/src/i18n/messages/en/chrome.json | 4 +- apps/web/src/i18n/messages/en/feedback.json | 231 +++++ apps/web/src/i18n/messages/en/index.ts | 2 + apps/web/src/i18n/messages/en/shortcuts.json | 4 + apps/web/src/i18n/messages/en/sources.json | 3 +- apps/web/src/i18n/messages/en/workspace.json | 7 +- apps/web/src/i18n/messages/zh-CN/chrome.json | 4 +- .../web/src/i18n/messages/zh-CN/feedback.json | 231 +++++ apps/web/src/i18n/messages/zh-CN/index.ts | 2 + .../src/i18n/messages/zh-CN/shortcuts.json | 4 + apps/web/src/i18n/messages/zh-CN/sources.json | 3 +- .../src/i18n/messages/zh-CN/workspace.json | 7 +- apps/web/src/lib/shortcuts/commands.ts | 9 + 29 files changed, 2546 insertions(+), 17 deletions(-) create mode 100644 apps/web/__tests__/feedback/FeedbackDialog.test.tsx create mode 100644 apps/web/src/components/feedback/FeedbackDialog.tsx create mode 100644 apps/web/src/components/feedback/FeedbackLauncher.tsx create mode 100644 apps/web/src/components/feedback/FeedbackProvider.tsx create mode 100644 apps/web/src/components/feedback/ScreenshotEditor.tsx create mode 100644 apps/web/src/components/feedback/VoteButton.tsx create mode 100644 apps/web/src/components/feedback/draft.ts create mode 100644 apps/web/src/components/feedback/keys.ts create mode 100644 apps/web/src/components/feedback/labels.tsx create mode 100644 apps/web/src/i18n/messages/en/feedback.json create mode 100644 apps/web/src/i18n/messages/zh-CN/feedback.json diff --git a/apps/web/__tests__/feedback/FeedbackDialog.test.tsx b/apps/web/__tests__/feedback/FeedbackDialog.test.tsx new file mode 100644 index 000000000..28eb6c4ab --- /dev/null +++ b/apps/web/__tests__/feedback/FeedbackDialog.test.tsx @@ -0,0 +1,294 @@ +/** @jest-environment jsdom */ + +import React from "react"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import "@testing-library/jest-dom"; + +import { FeedbackProvider, useFeedback } from "~/components/feedback/FeedbackProvider"; +import type { CreateFeedbackInput } from "~/lib/feedback/contract"; + +/** + * The reporter dialog end to end against a mocked API: whether it shows at + * all, what a one-line bug sends, what switching technical details off + * leaves out, the duplicate hint on feature requests, and that a closed + * dialog keeps its draft. + */ + +const mockPush = jest.fn(); +jest.mock("next/navigation", () => ({ + useRouter: () => ({ push: mockPush, replace: jest.fn() }), +})); + +// The page capture draws the DOM into an image; jsdom has no canvas. +jest.mock("~/lib/feedback/screenshot", () => ({ + CAPTURE_SKIP_ATTRIBUTE: "data-feedback-capture", + capturePage: jest.fn(() => Promise.resolve(new Blob(["png"], { type: "image/png" }))), +})); + +// jsdom has no ResizeObserver; the kit's Switch measures itself with one. +global.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} +} as unknown as typeof ResizeObserver; + +const fetchMock = jest.fn(); +let access = { enabled: true, triager: false }; +let similar: unknown[] = []; + +function urlOf(input: RequestInfo | URL): string { + return typeof input === "string" ? input : input instanceof URL ? input.href : input.url; +} + +function json(body: unknown, status = 200) { + return Promise.resolve({ + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(body), + }); +} + +function sentPayload(): { payload: CreateFeedbackInput; files: File[] } { + const call = fetchMock.mock.calls.find( + ([input, init]) => + urlOf(input as RequestInfo) === "/api/feedback" && + (init as RequestInit | undefined)?.method === "POST" + ); + if (!call) throw new Error("nothing was sent"); + const form = (call[1] as RequestInit).body as FormData; + return { + payload: JSON.parse(form.get("payload") as string) as CreateFeedbackInput, + files: form.getAll("files") as File[], + }; +} + +beforeEach(() => { + access = { enabled: true, triager: false }; + similar = []; + mockPush.mockReset(); + fetchMock.mockReset(); + fetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => { + const url = urlOf(input); + if (url === "/api/feedback/me") return json(access); + if (url === "/api/feedback" && init?.method === "POST") + return json({ report: { id: 7, kind: "bug", title: "x" } }, 201); + if (url.startsWith("/api/feedback?scope=ideas")) return json({ items: similar }); + if (/\/api\/feedback\/\d+\/vote$/.test(url)) return json({ votes: 4, voted: true }); + return json({}, 404); + }); + global.fetch = fetchMock as unknown as typeof fetch; + window.localStorage.clear(); + let n = 0; + URL.createObjectURL = jest.fn(() => `blob:test/${(n += 1)}`); + URL.revokeObjectURL = jest.fn(); +}); + +/** Something on the page that opens the dialog, as the account menu does. */ +function Opener() { + const feedback = useFeedback(); + return ( + <> + + + {String(feedback.enabled)} + + ); +} + +function renderReporter() { + return render( + + + + ); +} + +async function openDialog(kind: "bug" | "idea" = "bug") { + await waitFor(() => expect(screen.getByTestId("enabled")).toHaveTextContent("true")); + fireEvent.click(screen.getByText(kind === "bug" ? "open bug" : "open idea")); + return screen.findByTestId("feedback-dialog"); +} + +describe("FeedbackProvider", () => { + it("shows nothing while the instance has named no triager", async () => { + access = { enabled: false, triager: false }; + renderReporter(); + await waitFor(() => + expect(fetchMock).toHaveBeenCalledWith("/api/feedback/me", expect.anything()) + ); + await act(async () => undefined); + expect(screen.getByTestId("enabled")).toHaveTextContent("false"); + expect(screen.queryByTestId("feedback-launcher")).not.toBeInTheDocument(); + fireEvent.click(screen.getByText("open bug")); + expect(screen.queryByTestId("feedback-dialog")).not.toBeInTheDocument(); + }); + + it("shows the Feedback button when reports are taken, unless it was hidden", async () => { + const { unmount } = renderReporter(); + expect(await screen.findByTestId("feedback-launcher")).toHaveTextContent("Feedback"); + unmount(); + window.localStorage.setItem("feedback.launcher.hidden.v1", "1"); + renderReporter(); + await waitFor(() => expect(screen.getByTestId("enabled")).toHaveTextContent("true")); + expect(screen.queryByTestId("feedback-launcher")).not.toBeInTheDocument(); + }); +}); + +describe("FeedbackDialog", () => { + it("sends a one-line bug with the page and its technical details", async () => { + renderReporter(); + const dialog = await openDialog(); + const send = within(dialog).getByTestId("feedback-send"); + expect(send).toBeDisabled(); + + fireEvent.change(within(dialog).getByTestId("feedback-title"), { + target: { value: " Upload stops at 90% " }, + }); + fireEvent.click(within(dialog).getByTestId("feedback-severity-blocking")); + expect(send).toBeEnabled(); + fireEvent.click(send); + + expect(await screen.findByTestId("feedback-sent")).toHaveTextContent("bug #7"); + const { payload, files } = sentPayload(); + expect(payload).toMatchObject({ + kind: "bug", + title: "Upload stops at 90%", + body: "", + severity: "blocking", + }); + expect(payload.context?.path).toBe(window.location.pathname + window.location.search); + expect(payload.context?.browser ?? payload.context?.userAgent).toBeTruthy(); + expect(payload.context?.viewport).toEqual({ + width: window.innerWidth, + height: window.innerHeight, + }); + expect(files).toHaveLength(0); + + fireEvent.click(screen.getByTestId("feedback-sent-view")); + expect(mockPush).toHaveBeenCalledWith( + "/employer/documents?feature=feedback&at=%2Fmine%2F7" + ); + }); + + it("sends only the page when technical details are switched off", async () => { + renderReporter(); + const dialog = await openDialog(); + fireEvent.change(within(dialog).getByTestId("feedback-title"), { + target: { value: "Theme flickers" }, + }); + fireEvent.click(within(dialog).getByTestId("feedback-technical")); + fireEvent.click(within(dialog).getByTestId("feedback-send")); + await screen.findByTestId("feedback-sent"); + expect(sentPayload().payload.context).toEqual({ + path: window.location.pathname + window.location.search, + }); + }); + + it("attaches a captured screenshot", async () => { + renderReporter(); + const dialog = await openDialog(); + fireEvent.click(within(dialog).getByTestId("feedback-capture")); + const images = await within(dialog).findByTestId("feedback-images"); + expect(within(images).getAllByRole("img")).toHaveLength(1); + fireEvent.change(within(dialog).getByTestId("feedback-title"), { + target: { value: "Chart overlaps" }, + }); + fireEvent.click(within(dialog).getByTestId("feedback-send")); + await screen.findByTestId("feedback-sent"); + const { files } = sentPayload(); + expect(files).toHaveLength(1); + expect(files[0]!.type).toBe("image/png"); + }); + + it("refuses files that aren't images", async () => { + renderReporter(); + const dialog = await openDialog(); + const input = within(dialog).getByTestId("feedback-file-input"); + fireEvent.change(input, { + target: { files: [new File(["x"], "notes.txt", { type: "text/plain" })] }, + }); + expect(await within(dialog).findByRole("status")).toHaveTextContent( + "Only PNG, JPEG, WebP or GIF images can be attached." + ); + expect(within(dialog).queryByTestId("feedback-images")).not.toBeInTheDocument(); + }); + + it("keeps the draft when the dialog is closed by accident", async () => { + renderReporter(); + let dialog = await openDialog(); + fireEvent.change(within(dialog).getByTestId("feedback-title"), { + target: { value: "Half-typed thought" }, + }); + fireEvent.keyDown(dialog, { key: "Escape" }); + await waitFor(() => expect(screen.queryByTestId("feedback-dialog")).not.toBeInTheDocument()); + dialog = await openDialog(); + expect(within(dialog).getByTestId("feedback-title")).toHaveValue("Half-typed thought"); + }); + + it("offers matching feature requests to vote for instead", async () => { + similar = [ + { + id: 3, + kind: "idea", + title: "Export a chat as Word", + status: "planned", + severity: null, + votes: 3, + voted: false, + mine: false, + replies: 1, + attachments: 0, + createdAt: "2026-10-20T00:00:00Z", + updatedAt: "2026-10-21T00:00:00Z", + }, + ]; + renderReporter(); + const dialog = await openDialog("idea"); + fireEvent.change(within(dialog).getByTestId("feedback-title"), { + target: { value: "Export chats" }, + }); + const box = await within(dialog).findByTestId("feedback-similar", undefined, { + timeout: 2000, + }); + expect(box).toHaveTextContent("Export a chat as Word"); + expect( + fetchMock.mock.calls.some(([input]) => + urlOf(input as RequestInfo).includes("q=Export+chats") + ) + ).toBe(true); + + fireEvent.click(within(box).getByTestId("feedback-similar-vote")); + await waitFor(() => + expect(fetchMock).toHaveBeenCalledWith( + "/api/feedback/3/vote", + expect.objectContaining({ method: "PUT" }) + ) + ); + await waitFor(() => expect(within(box).getByTestId("feedback-similar-vote")).toHaveTextContent("4")); + }); + + it("says so when sending too often", async () => { + fetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => { + const url = urlOf(input); + if (url === "/api/feedback/me") return json(access); + if (url === "/api/feedback" && init?.method === "POST") + return json({ error: "Too many requests" }, 429); + return json({}, 404); + }); + renderReporter(); + const dialog = await openDialog(); + fireEvent.change(within(dialog).getByTestId("feedback-title"), { + target: { value: "Again" }, + }); + fireEvent.click(within(dialog).getByTestId("feedback-send")); + expect(await within(dialog).findByTestId("feedback-error")).toHaveTextContent( + "You've sent several reports in a short time." + ); + // Nothing typed is lost. + expect(within(dialog).getByTestId("feedback-title")).toHaveValue("Again"); + }); +}); diff --git a/apps/web/src/app/employer/contact/ContactClient.tsx b/apps/web/src/app/employer/contact/ContactClient.tsx index 9d691597d..6bf9af302 100644 --- a/apps/web/src/app/employer/contact/ContactClient.tsx +++ b/apps/web/src/app/employer/contact/ContactClient.tsx @@ -3,13 +3,24 @@ import React, { useEffect, useState } from "react"; import { useAuth } from "~/lib/auth-client"; import { useRouter } from "next/navigation"; -import { Bug, CircleAlert, Copy, Mail, MessageSquare, Phone, Send } from "lucide-react"; +import { + Bug, + CircleAlert, + Copy, + Lightbulb, + Mail, + MessageSquare, + MessageSquareText, + Phone, + Send, +} from "lucide-react"; import { useTranslations } from "next-intl"; import type { SupportChannels } from "~/server/support"; import { useSourceFailureCopy, type SourceFailureReport } from "~/lib/source-failure"; import LoadingPage from "~/app/_components/loading"; +import { useFeedback } from "~/components/feedback/FeedbackProvider"; import { EmployerChrome } from "~/app/employer/_components/EmployerChrome"; import { Button } from "~/components/ui/button"; import { Card, PageHeader, PageShell, Section } from "~/components/layout/page-shell"; @@ -26,6 +37,7 @@ export function ContactClient({ const t = useTranslations("onboarding.contact"); const tc = useTranslations("common"); const failureCopy = useSourceFailureCopy(); + const feedback = useFeedback(); const router = useRouter(); const { isLoaded, userId } = useAuth(); const [loading, setLoading] = useState(true); @@ -87,6 +99,8 @@ export function ContactClient({ description={support.email ? t("descriptionMail") : t("description")} /> + {feedback.enabled && feedback.open({ kind })} />} + {report && reportText && ( void }) { + const t = useTranslations("feedback.contact"); + const tEntry = useTranslations("feedback.entry"); + return ( +
+ +
+
{t("title")}
+

{t("body")}

+
+
+ + +
+
+ ); +} + /** * What the failed source's link carried, shown above the form so the person * can see what will be sent — and copy it, when this instance has no diff --git a/apps/web/src/app/employer/documents/_workspace/AccountMenu.tsx b/apps/web/src/app/employer/documents/_workspace/AccountMenu.tsx index f9252268e..a30f58414 100644 --- a/apps/web/src/app/employer/documents/_workspace/AccountMenu.tsx +++ b/apps/web/src/app/employer/documents/_workspace/AccountMenu.tsx @@ -8,11 +8,13 @@ import { BookOpen, ChevronsUpDown, LogOut, + MessageSquareText, Moon, Settings, Sun, } from "lucide-react"; +import { useFeedback } from "~/components/feedback/FeedbackProvider"; import { LanguageSubmenu } from "~/components/i18n/LanguageMenu"; import { ProfileAvatar } from "~/components/ProfileAvatar"; import { @@ -48,7 +50,7 @@ export interface AccountMenuProps { /** * Who is signed in, and what they can do about it: theme, language, - * settings, switch workspace, sign out. + * settings, switch workspace, send feedback, sign out. * * It lives at the foot of the sidebar, where Claude, ChatGPT, Slack and VS * Code all keep the account. It used to sit at the end of the first column's @@ -70,6 +72,7 @@ export function AccountMenu({ const { resolvedTheme, setTheme } = useTheme(); const isDark = resolvedTheme === "dark"; const workspace = useEmployerWorkspaceSwitcher(); + const feedback = useFeedback(); const name = userName ?? t("fallbackName"); const subtitle = userTitle ?? userEmail; @@ -168,6 +171,15 @@ export function AccountMenu({ ↗ + {feedback.enabled && ( + feedback.open()} + data-testid="account-menu-feedback" + > + + {t("feedback")} + + )} {onSignOut && ( <> diff --git a/apps/web/src/app/employer/documents/_workspace/SourceStatus.tsx b/apps/web/src/app/employer/documents/_workspace/SourceStatus.tsx index 08a49d1e8..37a6bc3ff 100644 --- a/apps/web/src/app/employer/documents/_workspace/SourceStatus.tsx +++ b/apps/web/src/app/employer/documents/_workspace/SourceStatus.tsx @@ -3,7 +3,9 @@ import { useState } from "react"; import Link from "next/link"; import { useTranslations } from "next-intl"; -import { ChevronRight, CircleAlert, LifeBuoy, Loader2, RotateCw } from "lucide-react"; +import { Bug, ChevronRight, CircleAlert, LifeBuoy, Loader2, RotateCw } from "lucide-react"; + +import { useFeedback } from "~/components/feedback/FeedbackProvider"; import { Button } from "~/components/ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "~/components/ui/collapsible"; @@ -101,7 +103,9 @@ interface SourceFailureStatusProps { export function SourceFailureStatus({ source, onRetry }: SourceFailureStatusProps) { const t = useTranslations("sources.sourceStatus"); const tc = useTranslations("common"); - const failure = useSourceFailureCopy().describe(source.errorMessage); + const failureCopy = useSourceFailureCopy(); + const failure = failureCopy.describe(source.errorMessage); + const feedback = useFeedback(); const [retrying, setRetrying] = useState(false); const [open, setOpen] = useState(false); const retryCount = source.processing?.retryCount ?? 0; @@ -110,6 +114,31 @@ export function SourceFailureStatus({ source, onRetry }: SourceFailureStatusProp // Support is the way forward when a retry can't help, or one already didn't. const offerContact = !failure.retryable || retryCount > 0; + /** + * Where the instance takes reports, the failure goes into one — stored, + * answered in the Feedback tab — rather than a mail the person has to + * send. Same ids either way: the document, the category, the job id. + */ + const reportFailure = () => { + const text = failureCopy.reportText({ + document: source.title, + documentId: source.documentId, + category: failure.category, + jobId, + }); + setOpen(false); + feedback.open({ + kind: "bug", + title: text.subject, + body: text.details, + origin: { + document: source.title.slice(0, 300), + problem: failure.category, + ...(jobId ? { jobId: jobId.slice(0, 300) } : {}), + }, + }); + }; + const retry = async () => { if (!onRetry || retrying) return; setRetrying(true); @@ -172,7 +201,18 @@ export function SourceFailureStatus({ source, onRetry }: SourceFailureStatusProp {tc("actions.retry")} )} - {offerContact && ( + {offerContact && feedback.enabled && ( + + )} + {offerContact && !feedback.enabled && ( + )} ); diff --git a/apps/web/src/app/employer/layout.tsx b/apps/web/src/app/employer/layout.tsx index a6dd64c0a..3d9836d38 100644 --- a/apps/web/src/app/employer/layout.tsx +++ b/apps/web/src/app/employer/layout.tsx @@ -5,6 +5,7 @@ import { EmployerWorkspaceSwitcherProvider } from "./_chrome/EmployerWorkspaceSw import { getWorkspaceSwitcherPayload } from "./_chrome/getWorkspaceSwitcherPayload"; import { JoinWelcome } from "./_chrome/JoinWelcome"; import { SignInNotice } from "~/components/auth/SignInNotice"; +import { FeedbackProvider } from "~/components/feedback/FeedbackProvider"; import { Toaster } from "~/components/ui/sonner"; export default async function EmployerLayout({ children }: { children: ReactNode }) { @@ -18,10 +19,13 @@ export default async function EmployerLayout({ children }: { children: ReactNode }} > - - {children} - - + {/* Every signed-in page can report a bug or ask for a feature. */} + + + {children} + + + diff --git a/apps/web/src/app/workspaces/layout.tsx b/apps/web/src/app/workspaces/layout.tsx index 9a47c3922..44ad70488 100644 --- a/apps/web/src/app/workspaces/layout.tsx +++ b/apps/web/src/app/workspaces/layout.tsx @@ -1,6 +1,8 @@ import type { ReactNode } from "react"; import { SignInNotice } from "~/components/auth/SignInNotice"; +import { FeedbackProvider } from "~/components/feedback/FeedbackProvider"; +import { Toaster } from "~/components/ui/sonner"; export default function WorkspacesLayout({ children }: { children: ReactNode }) { return ( @@ -10,8 +12,12 @@ export default function WorkspacesLayout({ children }: { children: ReactNode }) width: "100%", }} > - {children} - + {/* The picker is signed in too: a bug in joining or switching is reportable here. */} + + {children} + + + ); } diff --git a/apps/web/src/components/feedback/FeedbackDialog.tsx b/apps/web/src/components/feedback/FeedbackDialog.tsx new file mode 100644 index 000000000..95f5fb594 --- /dev/null +++ b/apps/web/src/components/feedback/FeedbackDialog.tsx @@ -0,0 +1,864 @@ +"use client"; + +/** + * The reporter: one dialog, two kinds. + * + * - **Bug** — one line is enough to send. Details, how much it is in the + * way, and up to three screenshots are optional. The page, browser, window + * and the last few errors go with it unless the reporter switches that off; + * the panel at the bottom lists exactly what will be sent. + * - **Feature request** — a title is enough. While it is typed, requests + * already on the board that match are offered, with a vote button: a +1 + * on an existing request counts for more than a near-duplicate. + * + * Sending shows the report's number and a way to it in the Feedback tab, + * where replies from the team arrive. + */ + +import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; +import { + Bug, + Camera, + Check, + ChevronDown, + ChevronUp, + ImagePlus, + Lightbulb, + Loader2, + PenLine, + X, +} from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; + +import { Button } from "~/components/ui/button"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "~/components/ui/collapsible"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "~/components/ui/dialog"; +import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; +import { Switch } from "~/components/ui/switch"; +import { Textarea } from "~/components/ui/textarea"; +import { ToggleGroup, ToggleGroupItem } from "~/components/ui/toggle-group"; +import { + createFeedback, + FeedbackApiError, + listFeedback, + voteForFeedback, +} from "~/lib/feedback/api"; +import { collectFeedbackContext } from "~/lib/feedback/context"; +import { + BUG_SEVERITIES, + FEEDBACK_LIMITS, + feedbackReportHref, + type BugSeverity, + type FeedbackContext, + type FeedbackDetail, + type FeedbackKind, + type FeedbackListItem, +} from "~/lib/feedback/contract"; +import { capturePage } from "~/lib/feedback/screenshot"; +import { cn } from "~/lib/utils"; + +import { feedbackKeys } from "./keys"; +import { emptyDraft, isOriginKey, type FeedbackDraft, type FeedbackImage } from "./draft"; +import { StatusBadge } from "./labels"; +import { ScreenshotEditor } from "./ScreenshotEditor"; + +let imageSeq = 0; +function toImage(blob: Blob): FeedbackImage { + imageSeq += 1; + return { id: `img-${imageSeq}`, blob, url: URL.createObjectURL(blob) }; +} + +const ACCEPTED = FEEDBACK_LIMITS.attachmentTypes as readonly string[]; + +export function FeedbackDialog({ + open, + onOpenChange, + draft, + onDraftChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + draft: FeedbackDraft; + onDraftChange: React.Dispatch>; +}) { + const t = useTranslations("feedback.dialog"); + const router = useRouter(); + const [sent, setSent] = useState(null); + const [busy, setBusy] = useState(false); + const [capturing, setCapturing] = useState(false); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [editing, setEditing] = useState(null); + // What the page looked like when the dialog opened — not when it is + // sent, by which time the dialog itself has changed what's on screen. + const [snapshot, setSnapshot] = useState({}); + const fileInput = useRef(null); + const titleInput = useRef(null); + + useEffect(() => { + if (!open) return; + setSnapshot(collectFeedbackContext({ technical: true, origin: draft.origin })); + setError(null); + setNotice(null); + // Opening again after a send starts on the form, not the thank-you. + setSent(null); + // eslint-disable-next-line react-hooks/exhaustive-deps -- only on open + }, [open]); + + const patch = useCallback( + (next: Partial) => onDraftChange(current => ({ ...current, ...next })), + [onDraftChange] + ); + + const addImages = useCallback( + (blobs: Blob[]) => { + setNotice(null); + const room = FEEDBACK_LIMITS.attachmentsMax - draft.images.length; + const usable = blobs.filter(b => ACCEPTED.includes(b.type)); + const small = usable.filter(b => b.size <= FEEDBACK_LIMITS.attachmentBytesMax); + if (usable.length < blobs.length) setNotice(t("images.wrongType")); + else if (small.length < usable.length) + setNotice(t("images.tooBig", { mb: FEEDBACK_LIMITS.attachmentBytesMax >> 20 })); + else if (small.length > room) + setNotice(t("images.tooMany", { max: FEEDBACK_LIMITS.attachmentsMax })); + const taken = small.slice(0, Math.max(0, room)).map(toImage); + if (taken.length > 0) + onDraftChange(current => ({ ...current, images: [...current.images, ...taken] })); + }, + [draft.images.length, onDraftChange, t] + ); + + const removeImage = (id: string) => + onDraftChange(current => { + const gone = current.images.find(i => i.id === id); + if (gone) URL.revokeObjectURL(gone.url); + return { ...current, images: current.images.filter(i => i.id !== id) }; + }); + + const replaceImage = (id: string, blob: Blob) => + onDraftChange(current => ({ + ...current, + images: current.images.map(image => { + if (image.id !== id) return image; + URL.revokeObjectURL(image.url); + return { ...image, blob, url: URL.createObjectURL(blob) }; + }), + })); + + const capture = async () => { + setCapturing(true); + setNotice(null); + try { + // Let the button's spinner paint before the main thread is busy. + await new Promise(resolve => requestAnimationFrame(() => resolve(null))); + addImages([await capturePage()]); + } catch { + setNotice(t("images.captureFailed")); + } finally { + setCapturing(false); + } + }; + + const onPaste = (event: React.ClipboardEvent) => { + const files = Array.from(event.clipboardData.files).filter(f => + f.type.startsWith("image/") + ); + if (files.length === 0) return; + event.preventDefault(); + addImages(files); + }; + + const canSend = draft.title.trim().length > 0 && !busy; + + const send = async () => { + if (!canSend) return; + setBusy(true); + setError(null); + try { + const context = draft.technical + ? { ...snapshot, origin: draft.origin } + : collectFeedbackContext({ technical: false, origin: draft.origin }); + const report = await createFeedback( + { + kind: draft.kind, + title: draft.title.trim(), + body: draft.body.trim(), + severity: draft.kind === "bug" ? (draft.severity ?? undefined) : undefined, + context, + }, + draft.images.map(i => i.blob) + ); + draft.images.forEach(i => URL.revokeObjectURL(i.url)); + onDraftChange(emptyDraft(draft.kind)); + setSent(report); + } catch (err) { + setError( + err instanceof FeedbackApiError && err.status === 429 + ? t("errors.rateLimited") + : err instanceof FeedbackApiError && err.message + ? err.message + : t("errors.generic") + ); + } finally { + setBusy(false); + } + }; + + const editingImage = editing ? draft.images.find(i => i.id === editing) : undefined; + + return ( + + { + // Escape in the editor goes back to the form, not out of it. + if (editingImage) { + event.preventDefault(); + setEditing(null); + } + }} + onKeyDown={event => { + if ( + !sent && + !editingImage && + event.key === "Enter" && + (event.metaKey || event.ctrlKey) + ) { + event.preventDefault(); + void send(); + } + }} + onOpenAutoFocus={event => { + event.preventDefault(); + titleInput.current?.focus(); + }} + > + {sent ? ( + { + onOpenChange(false); + router.push(feedbackReportHref(sent.id, "mine")); + }} + onAnother={() => setSent(null)} + onDone={() => onOpenChange(false)} + /> + ) : editingImage ? ( + <> + + {t("editor.title")} + {t("editor.description")} + + setEditing(null)} + onDone={blob => { + replaceImage(editingImage.id, blob); + setEditing(null); + }} + /> + + ) : ( + <> + + {t("title")} + {t("description")} + + + { + if (!value || value === draft.kind) return; + patch({ kind: value as FeedbackKind, technical: value === "bug" }); + }} + className="w-full" + aria-label={t("kindLabel")} + > + + + {t("kinds.bug")} + + + + {t("kinds.idea")} + + + +
{ + event.preventDefault(); + void send(); + }} + > + patch({ title })} + /> + {draft.kind === "idea" && ( + { + onOpenChange(false); + router.push(feedbackReportHref(id, "ideas")); + }} + /> + )} + patch({ body })} + /> + {draft.kind === "bug" && ( + patch({ severity })} + /> + )} + void capture()} + onAttach={() => fileInput.current?.click()} + onEdit={setEditing} + onRemove={removeImage} + /> + { + addImages(Array.from(event.target.files ?? [])); + event.target.value = ""; + }} + /> + patch({ technical })} + snapshot={snapshot} + origin={draft.origin} + /> + {error && ( +

+ {error} +

+ )} + + + {t("sendHint", { send: feedbackKeys().send })} + + + + + + + )} +
+
+ ); +} + +const TitleField = React.forwardRef< + HTMLInputElement, + { kind: FeedbackKind; value: string; onChange: (value: string) => void } +>(function TitleField({ kind, value, onChange }, ref) { + const t = useTranslations("feedback.dialog"); + const id = useId(); + const left = FEEDBACK_LIMITS.titleMax - value.length; + return ( +
+ + onChange(event.target.value)} + placeholder={kind === "bug" ? t("bugTitlePlaceholder") : t("ideaTitlePlaceholder")} + data-testid="feedback-title" + autoComplete="off" + /> + {left < 20 && ( + {t("charsLeft", { count: left })} + )} +
+ ); +}); + +function BodyField({ + kind, + value, + onChange, +}: { + kind: FeedbackKind; + value: string; + onChange: (value: string) => void; +}) { + const t = useTranslations("feedback.dialog"); + const id = useId(); + return ( +
+ +