From 95642fb220d1912418c4fdfc7b3d8c5424c683e8 Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Wed, 26 Aug 2026 11:34:40 +0200 Subject: [PATCH] feat(ui): add Lighthouse request outcome feedback (#12419) Co-authored-by: alejandrobailo --- .../_actions/lighthouse-v2.adapter.test.ts | 21 + .../lighthouse/_actions/lighthouse-v2.ts | 3 +- .../lighthouse/_components/chat/composer.tsx | 32 +- .../chat/lighthouse-feedback-survey.test.ts | 509 ++++++++++++++++++ .../chat/lighthouse-feedback-survey.ts | 265 +++++++++ .../chat/lighthouse-v2-chat-page.test.tsx | 238 ++++++++ .../chat/lighthouse-v2-chat-view.tsx | 44 +- .../_components/chat/message-bubble.test.tsx | 401 +++++++++++++- .../_components/chat/message-bubble.tsx | 212 +++++++- .../lighthouse/_lib/chat-store.test.ts | 91 ++++ .../(prowler)/lighthouse/_lib/chat-store.ts | 126 ++++- ...ghthouse-request-outcome-feedback.added.md | 1 + ui/components/survey/feedback-form.tsx | 128 +++++ ui/components/survey/feedback-survey.test.tsx | 1 + .../survey/runtime-feedback-survey.tsx | 43 +- 15 files changed, 2041 insertions(+), 74 deletions(-) create mode 100644 ui/app/(prowler)/lighthouse/_components/chat/lighthouse-feedback-survey.test.ts create mode 100644 ui/app/(prowler)/lighthouse/_components/chat/lighthouse-feedback-survey.ts create mode 100644 ui/changelog.d/lighthouse-request-outcome-feedback.added.md create mode 100644 ui/components/survey/feedback-form.tsx diff --git a/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.adapter.test.ts b/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.adapter.test.ts index 31060984e9..2cb1827a62 100644 --- a/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.adapter.test.ts +++ b/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.adapter.test.ts @@ -122,6 +122,27 @@ describe("lighthouse-v2.adapter", () => { }); }); + it("should not expose removed feedback state on mapped messages", () => { + // Given + const resource: Parameters[0] = { + id: "message-1", + type: "lighthouse-messages", + attributes: { + role: "user", + model: null, + token_usage: null, + inserted_at: "2026-06-24T10:01:00Z", + parts: [], + }, + }; + + // When + const message = mapLighthouseV2Message(resource); + + // Then + expect(message).not.toHaveProperty("feedback"); + }); + it("should give id-less parts stable fallback keys instead of empty strings", () => { // Given const resource: Parameters[0] = { diff --git a/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.ts b/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.ts index e2248db15c..9b666b07e7 100644 --- a/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.ts +++ b/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.ts @@ -345,11 +345,12 @@ async function mutateEmpty( path: string, init: RequestInit, pathToRevalidate: string, + includeContentType = false, ): Promise> { try { const response = await fetch(buildApiUrl(path), { ...init, - headers: await getAuthHeaders({ contentType: false }), + headers: await getAuthHeaders({ contentType: includeContentType }), }); const document = await handleApiResponse(response, pathToRevalidate); if (isErrorDocument(document)) { diff --git a/ui/app/(prowler)/lighthouse/_components/chat/composer.tsx b/ui/app/(prowler)/lighthouse/_components/chat/composer.tsx index 8932f978ec..78b8f09d4d 100644 --- a/ui/app/(prowler)/lighthouse/_components/chat/composer.tsx +++ b/ui/app/(prowler)/lighthouse/_components/chat/composer.tsx @@ -4,6 +4,7 @@ import { CornerDownLeft, Settings, TriangleAlert } from "lucide-react"; import Link from "next/link"; import { type ReactNode, type SubmitEvent, useRef } from "react"; +import type { LighthouseV2Message } from "@/app/(prowler)/lighthouse/_types"; import { Alert, AlertDescription } from "@/components/shadcn/alert"; import { Button } from "@/components/shadcn/button/button"; import { Spinner } from "@/components/shadcn/spinner/spinner"; @@ -16,8 +17,13 @@ import { import { useMountEffect } from "@/hooks/use-mount-effect"; import { LIGHTHOUSE_ROUTE } from "@/lib/lighthouse-routes"; +import type { LighthouseFeedbackSurvey } from "./lighthouse-feedback-survey"; +import { LighthouseOutcomeFeedbackControls } from "./message-bubble"; + interface ChatComposerPanelProps { feedback: string | null; + feedbackTarget?: LighthouseV2Message; + feedbackSurvey?: LighthouseFeedbackSurvey | null; canRetry: boolean; onRetry: () => void; onDismissFeedback: () => void; @@ -36,6 +42,8 @@ interface ChatComposerPanelProps { // two branches can't drift apart. export function ChatComposerPanel({ feedback, + feedbackTarget, + feedbackSurvey, canRetry, onRetry, onDismissFeedback, @@ -45,6 +53,8 @@ export function ChatComposerPanel({ <> void; onDismiss: () => void; @@ -72,11 +86,19 @@ function ChatFeedbackBar({ {feedback} - {canRetry && ( - - )} +
+ {feedbackTarget && feedbackSurvey && ( + + )} + {canRetry && ( + + )} +
); diff --git a/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-feedback-survey.test.ts b/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-feedback-survey.test.ts new file mode 100644 index 0000000000..0b55d49682 --- /dev/null +++ b/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-feedback-survey.test.ts @@ -0,0 +1,509 @@ +import type { Survey } from "posthog-js"; +import { describe, expect, it } from "vitest"; + +import { + buildLighthouseFeedbackSurveyEvents, + LIGHTHOUSE_FEEDBACK_DETAILS_MAX_LENGTH, + type LighthouseFeedbackReason, + LIGHTHOUSE_FEEDBACK_REASONS, + resolveLighthouseFeedbackSurvey, +} from "./lighthouse-feedback-survey"; + +const SURVEY = { + id: "survey-123", + name: "Lighthouse Request Outcome Feedback", + type: "api", + questions: [ + { + id: "rating-question-id", + type: "rating", + display: "emoji", + scale: 2, + question: "How was this outcome?", + lowerBoundLabel: "Not helpful", + upperBoundLabel: "Helpful", + }, + { + id: "reasons-question-id", + type: "multiple_choice", + optional: true, + choices: [ + "Don't like the style", + "Didn't fully follow instructions", + "Low quality", + "Biased", + "Safety or legal concern", + "Other", + ], + question: "What could be improved?", + }, + { + id: "details-question-id", + type: "open", + optional: true, + question: "Additional feedback", + }, + ], +} as unknown as Survey; + +describe("Lighthouse outcome feedback survey", () => { + it("should build one completed rating snapshot for a helpful outcome", () => { + // Given + const survey = resolveLighthouseFeedbackSurvey([SURVEY]); + expect(survey).not.toBeNull(); + + // When + const events = buildLighthouseFeedbackSurveyEvents(survey!, { + targetMessageId: "message-user-1", + rating: "up", + submissionId: "submission-1", + }); + + // Then + expect(events).toEqual([ + { + $survey_id: "survey-123", + $survey_name: "Lighthouse Request Outcome Feedback", + $survey_submission_id: "submission-1", + $survey_completed: true, + $ai_trace_id: "message-user-1", + $survey_questions: [ + { + id: "rating-question-id", + question: "How was this outcome?", + response: 1, + }, + ], + "$survey_response_rating-question-id": 1, + $survey_response: 1, + }, + ]); + }); + + it("should emit cumulative snapshots for a negative outcome with optional answers", () => { + // Given + const survey = resolveLighthouseFeedbackSurvey([SURVEY]); + expect(survey).not.toBeNull(); + + // When + const events = buildLighthouseFeedbackSurveyEvents(survey!, { + targetMessageId: "message-user-1", + rating: "down", + reasons: ["Low quality", "Other"], + details: " Missing evidence. ", + submissionId: "submission-1", + }); + + // Then + expect(events).toEqual([ + { + $survey_id: "survey-123", + $survey_name: "Lighthouse Request Outcome Feedback", + $survey_submission_id: "submission-1", + $survey_completed: false, + $ai_trace_id: "message-user-1", + $survey_questions: [ + { + id: "rating-question-id", + question: "How was this outcome?", + response: 2, + }, + ], + "$survey_response_rating-question-id": 2, + $survey_response: 2, + }, + { + $survey_id: "survey-123", + $survey_name: "Lighthouse Request Outcome Feedback", + $survey_submission_id: "submission-1", + $survey_completed: false, + $ai_trace_id: "message-user-1", + $survey_questions: [ + { + id: "rating-question-id", + question: "How was this outcome?", + response: 2, + }, + { + id: "reasons-question-id", + question: "What could be improved?", + response: ["Low quality", "Other"], + }, + ], + "$survey_response_rating-question-id": 2, + "$survey_response_reasons-question-id": ["Low quality", "Other"], + $survey_response: ["Low quality", "Other"], + }, + { + $survey_id: "survey-123", + $survey_name: "Lighthouse Request Outcome Feedback", + $survey_submission_id: "submission-1", + $survey_completed: true, + $ai_trace_id: "message-user-1", + $survey_questions: [ + { + id: "rating-question-id", + question: "How was this outcome?", + response: 2, + }, + { + id: "reasons-question-id", + question: "What could be improved?", + response: ["Low quality", "Other"], + }, + { + id: "details-question-id", + question: "Additional feedback", + response: "Missing evidence.", + }, + ], + "$survey_response_rating-question-id": 2, + "$survey_response_reasons-question-id": ["Low quality", "Other"], + "$survey_response_details-question-id": "Missing evidence.", + $survey_response: "Missing evidence.", + }, + ]); + }); + + it("should reject an API survey whose reason choices are not allowlisted", () => { + // Given + const invalidSurvey = { + ...SURVEY, + questions: [ + SURVEY.questions[0], + { + ...SURVEY.questions[1], + choices: ["Not an allowlisted reason"], + }, + SURVEY.questions[2], + ], + } as unknown as Survey; + + // When + const survey = resolveLighthouseFeedbackSurvey([invalidSurvey]); + + // Then + expect(survey).toBeNull(); + }); + + it("should reject an API survey whose reasons question has no choices", () => { + // Given + const invalidSurvey = { + ...SURVEY, + questions: [ + SURVEY.questions[0], + { ...SURVEY.questions[1], choices: undefined }, + SURVEY.questions[2], + ], + } as unknown as Survey; + + // When + const survey = resolveLighthouseFeedbackSurvey([invalidSurvey]); + + // Then + expect(survey).toBeNull(); + }); + + it("should reject an API survey whose reason choices are not an array", () => { + // Given + const invalidSurvey = { + ...SURVEY, + questions: [ + SURVEY.questions[0], + { ...SURVEY.questions[1], choices: "Low quality" }, + SURVEY.questions[2], + ], + } as unknown as Survey; + + // When + const survey = resolveLighthouseFeedbackSurvey([invalidSurvey]); + + // Then + expect(survey).toBeNull(); + }); + + it("should reject an API survey with an optional rating question", () => { + // Given + const invalidSurvey = { + ...SURVEY, + questions: [ + { ...SURVEY.questions[0], optional: true }, + SURVEY.questions[1], + SURVEY.questions[2], + ], + } as unknown as Survey; + + // When + const survey = resolveLighthouseFeedbackSurvey([invalidSurvey]); + + // Then + expect(survey).toBeNull(); + }); + + it("should reject an API survey with duplicate question identifiers", () => { + // Given + const invalidSurvey = { + ...SURVEY, + questions: [ + SURVEY.questions[0], + { ...SURVEY.questions[1], id: SURVEY.questions[0].id }, + SURVEY.questions[2], + ], + } as unknown as Survey; + + // When + const survey = resolveLighthouseFeedbackSurvey([invalidSurvey]); + + // Then + expect(survey).toBeNull(); + }); + + it.each([ + ["a non-rating type", { ...SURVEY.questions[0], type: "open" }], + ["a non-emoji display", { ...SURVEY.questions[0], display: "number" }], + ["a scale other than two", { ...SURVEY.questions[0], scale: 3 }], + ])("should reject an API survey with %s", (_description, ratingQuestion) => { + // Given + const invalidSurvey = { + ...SURVEY, + questions: [ratingQuestion, SURVEY.questions[1], SURVEY.questions[2]], + } as unknown as Survey; + + // When + const survey = resolveLighthouseFeedbackSurvey([invalidSurvey]); + + // Then + expect(survey).toBeNull(); + }); + + it("should accept a required rating question that explicitly declares optional false", () => { + // Given + const surveyWithRequiredRating = { + ...SURVEY, + questions: [ + { ...SURVEY.questions[0], optional: false }, + SURVEY.questions[1], + SURVEY.questions[2], + ], + } as unknown as Survey; + + // When + const survey = resolveLighthouseFeedbackSurvey([surveyWithRequiredRating]); + + // Then + expect(survey).not.toBeNull(); + }); + + it.each([ + ["reasons", 1], + ["details", 2], + ])( + "should reject an API survey with a non-optional %s question", + (_name, index) => { + // Given + const invalidSurvey = { + ...SURVEY, + questions: [ + SURVEY.questions[0], + index === 1 + ? { ...SURVEY.questions[1], optional: false } + : SURVEY.questions[1], + index === 2 + ? { ...SURVEY.questions[2], optional: false } + : SURVEY.questions[2], + ], + } as unknown as Survey; + + // When + const survey = resolveLighthouseFeedbackSurvey([invalidSurvey]); + + // Then + expect(survey).toBeNull(); + }, + ); + + it.each([ + ["missing", undefined], + ["empty", ""], + ["blank", " "], + ])( + "should reject an API survey with a %s question identifier", + (_name, id) => { + // Given + const invalidSurvey = surveyWithQuestionIds([ + SURVEY.questions[0].id, + id, + SURVEY.questions[2].id, + ]); + + // When + const survey = resolveLighthouseFeedbackSurvey([invalidSurvey]); + + // Then + expect(survey).toBeNull(); + }, + ); + + it.each([ + [ + "rating and reasons", + "duplicate-rating-question-id", + "duplicate-rating-question-id", + SURVEY.questions[2].id, + ], + [ + "rating and details", + "duplicate-rating-question-id", + SURVEY.questions[1].id, + "duplicate-rating-question-id", + ], + [ + "reasons and details", + SURVEY.questions[0].id, + "duplicate-reasons-question-id", + "duplicate-reasons-question-id", + ], + ])( + "should reject an API survey with duplicate %s identifiers", + (_name, ratingId, reasonsId, detailsId) => { + // Given + const invalidSurvey = surveyWithQuestionIds([ + ratingId, + reasonsId, + detailsId, + ]); + + // When + const survey = resolveLighthouseFeedbackSurvey([invalidSurvey]); + + // Then + expect(survey).toBeNull(); + }, + ); + + it("should reject an API survey with reordered reason choices", () => { + // Given + const invalidSurvey = { + ...SURVEY, + questions: [ + SURVEY.questions[0], + { + ...SURVEY.questions[1], + choices: [...LIGHTHOUSE_FEEDBACK_REASONS].reverse(), + }, + SURVEY.questions[2], + ], + } as unknown as Survey; + + // When + const survey = resolveLighthouseFeedbackSurvey([invalidSurvey]); + + // Then + expect(survey).toBeNull(); + }); + + it("should deduplicate and allowlist reason responses in their selected order", () => { + // Given + const survey = resolveLighthouseFeedbackSurvey([SURVEY]); + expect(survey).not.toBeNull(); + + // When + const events = buildLighthouseFeedbackSurveyEvents(survey!, { + targetMessageId: "message-user-1", + rating: "down", + reasons: [ + "Other", + "Low quality", + "Other", + "Unrecognized", + ] as unknown as LighthouseFeedbackReason[], + submissionId: "submission-1", + }); + + // Then + expect(events).toHaveLength(2); + expect(events.at(-1)).toMatchObject({ + $survey_completed: true, + "$survey_response_reasons-question-id": ["Other", "Low quality"], + }); + }); + + it("should trim and defensively bound detail responses", () => { + // Given + const survey = resolveLighthouseFeedbackSurvey([SURVEY]); + expect(survey).not.toBeNull(); + const details = ` ${"x".repeat(LIGHTHOUSE_FEEDBACK_DETAILS_MAX_LENGTH + 1)} `; + + // When + const events = buildLighthouseFeedbackSurveyEvents(survey!, { + targetMessageId: "message-user-1", + rating: "down", + details, + submissionId: "submission-1", + }); + + // Then + expect(events).toHaveLength(2); + expect(events.at(-1)).toMatchObject({ + $survey_completed: true, + "$survey_response_details-question-id": "x".repeat( + LIGHTHOUSE_FEEDBACK_DETAILS_MAX_LENGTH, + ), + }); + }); + + it("should complete a negative submission with reasons only", () => { + // Given + const survey = resolveLighthouseFeedbackSurvey([SURVEY]); + expect(survey).not.toBeNull(); + + // When + const events = buildLighthouseFeedbackSurveyEvents(survey!, { + targetMessageId: "message-user-1", + rating: "down", + reasons: ["Low quality"], + submissionId: "submission-1", + }); + + // Then + expect(events.map((event) => event.$survey_completed)).toEqual([ + false, + true, + ]); + expect(events.at(-1)?.$survey_questions).toHaveLength(2); + expect(events.at(-1)?.$survey_response).toEqual(["Low quality"]); + }); + + it("should complete a negative submission with details only", () => { + // Given + const survey = resolveLighthouseFeedbackSurvey([SURVEY]); + expect(survey).not.toBeNull(); + + // When + const events = buildLighthouseFeedbackSurveyEvents(survey!, { + targetMessageId: "message-user-1", + rating: "down", + details: "Missing evidence", + submissionId: "submission-1", + }); + + // Then + expect(events.map((event) => event.$survey_completed)).toEqual([ + false, + true, + ]); + expect(events.at(-1)?.$survey_questions).toHaveLength(2); + expect(events.at(-1)?.$survey_response).toBe("Missing evidence"); + }); +}); + +function surveyWithQuestionIds(ids: [unknown, unknown, unknown]): Survey { + return { + ...SURVEY, + questions: [ + { ...SURVEY.questions[0], id: ids[0] }, + { ...SURVEY.questions[1], id: ids[1] }, + { ...SURVEY.questions[2], id: ids[2] }, + ], + } as unknown as Survey; +} diff --git a/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-feedback-survey.ts b/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-feedback-survey.ts new file mode 100644 index 0000000000..f8907289e1 --- /dev/null +++ b/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-feedback-survey.ts @@ -0,0 +1,265 @@ +import type { Survey, SurveyQuestion } from "posthog-js"; + +export const LIGHTHOUSE_FEEDBACK_SURVEY_NAME = + "Lighthouse Request Outcome Feedback"; + +export const LIGHTHOUSE_FEEDBACK_RATING = { + UP: "up", + DOWN: "down", +} as const; + +export type LighthouseFeedbackRating = + (typeof LIGHTHOUSE_FEEDBACK_RATING)[keyof typeof LIGHTHOUSE_FEEDBACK_RATING]; + +export const LIGHTHOUSE_FEEDBACK_REASON = { + STYLE: "Don't like the style", + INSTRUCTIONS: "Didn't fully follow instructions", + QUALITY: "Low quality", + BIAS: "Biased", + SAFETY_OR_LEGAL: "Safety or legal concern", + OTHER: "Other", +} as const; + +export type LighthouseFeedbackReason = + (typeof LIGHTHOUSE_FEEDBACK_REASON)[keyof typeof LIGHTHOUSE_FEEDBACK_REASON]; + +export const LIGHTHOUSE_FEEDBACK_REASONS = Object.values( + LIGHTHOUSE_FEEDBACK_REASON, +); + +export const LIGHTHOUSE_FEEDBACK_DETAILS_MAX_LENGTH = 2000; + +interface LighthouseFeedbackQuestion { + id: string; + question: string; +} + +export interface LighthouseFeedbackSurvey { + id: string; + name: string; + ratingQuestion: LighthouseFeedbackQuestion; + reasonsQuestion: LighthouseFeedbackQuestion; + detailsQuestion: LighthouseFeedbackQuestion; +} + +interface LighthouseFeedbackSubmission { + targetMessageId: string; + rating: LighthouseFeedbackRating; + submissionId: string; + reasons?: LighthouseFeedbackReason[]; + details?: string; +} + +type LighthouseFeedbackResponse = number | string | LighthouseFeedbackReason[]; + +interface LighthouseFeedbackQuestionResponse { + id: string; + question: string; + response: LighthouseFeedbackResponse; +} + +export interface LighthouseFeedbackSurveyEvent { + $survey_id: string; + $survey_name: string; + $survey_submission_id: string; + $survey_completed: boolean; + $ai_trace_id: string; + $survey_questions: LighthouseFeedbackQuestionResponse[]; + $survey_response: LighthouseFeedbackResponse; + [responseProperty: `$survey_response_${string}`]: LighthouseFeedbackResponse; +} + +export function resolveLighthouseFeedbackSurvey( + surveys: Survey[], +): LighthouseFeedbackSurvey | null { + const survey = surveys.find( + (item) => + item.name === LIGHTHOUSE_FEEDBACK_SURVEY_NAME && item.type === "api", + ); + if ( + !survey || + !Array.isArray(survey.questions) || + survey.questions.length !== 3 + ) { + return null; + } + + const [ratingQuestion, reasonsQuestion, detailsQuestion] = survey.questions; + if ( + !isRatingQuestion(ratingQuestion) || + !isReasonsQuestion(reasonsQuestion) || + !isDetailsQuestion(detailsQuestion) + ) { + return null; + } + if ( + new Set([ratingQuestion.id, reasonsQuestion.id, detailsQuestion.id]) + .size !== 3 + ) { + return null; + } + + return { + id: survey.id, + name: survey.name, + ratingQuestion: toFeedbackQuestion(ratingQuestion), + reasonsQuestion: toFeedbackQuestion(reasonsQuestion), + detailsQuestion: toFeedbackQuestion(detailsQuestion), + }; +} + +export function buildLighthouseFeedbackSurveyEvents( + survey: LighthouseFeedbackSurvey, + submission: LighthouseFeedbackSubmission, +): LighthouseFeedbackSurveyEvent[] { + const ratingResponse = + submission.rating === LIGHTHOUSE_FEEDBACK_RATING.UP ? 1 : 2; + const reasons = normalizeReasons(submission.reasons); + const details = normalizeDetails(submission.details); + const ratingQuestion = toQuestionResponse( + survey.ratingQuestion, + ratingResponse, + ); + const isNegative = submission.rating === LIGHTHOUSE_FEEDBACK_RATING.DOWN; + const events = [ + buildSurveyEvent( + survey, + submission, + [ratingQuestion], + !isNegative || (reasons.length === 0 && !details), + ), + ]; + + if (isNegative && reasons.length > 0) { + events.push( + buildSurveyEvent( + survey, + submission, + [ratingQuestion, toQuestionResponse(survey.reasonsQuestion, reasons)], + !details, + ), + ); + } + + if (isNegative && details) { + events.push( + buildSurveyEvent( + survey, + submission, + [ + ratingQuestion, + ...(reasons.length > 0 + ? [toQuestionResponse(survey.reasonsQuestion, reasons)] + : []), + toQuestionResponse(survey.detailsQuestion, details), + ], + true, + ), + ); + } + + return events; +} + +function buildSurveyEvent( + survey: LighthouseFeedbackSurvey, + submission: LighthouseFeedbackSubmission, + questions: LighthouseFeedbackQuestionResponse[], + completed: boolean, +): LighthouseFeedbackSurveyEvent { + const latestResponse = questions.at(-1)!; + const responseProperties: Record< + `$survey_response_${string}`, + LighthouseFeedbackResponse + > = {}; + for (const question of questions) { + responseProperties[`$survey_response_${question.id}`] = question.response; + } + + return { + $survey_id: survey.id, + $survey_name: survey.name, + $survey_submission_id: submission.submissionId, + $survey_completed: completed, + $ai_trace_id: submission.targetMessageId, + $survey_questions: questions, + ...responseProperties, + $survey_response: latestResponse.response, + }; +} + +function toQuestionResponse( + question: LighthouseFeedbackQuestion, + response: LighthouseFeedbackResponse, +): LighthouseFeedbackQuestionResponse { + return { ...question, response }; +} + +function normalizeReasons( + reasons: LighthouseFeedbackReason[] | undefined, +): LighthouseFeedbackReason[] { + const selected = new Set(); + for (const reason of reasons ?? []) { + if (LIGHTHOUSE_FEEDBACK_REASONS.includes(reason)) selected.add(reason); + } + return Array.from(selected); +} + +function normalizeDetails(details: string | undefined): string | undefined { + const trimmed = details + ?.trim() + .slice(0, LIGHTHOUSE_FEEDBACK_DETAILS_MAX_LENGTH); + return trimmed || undefined; +} + +function isRatingQuestion( + question: SurveyQuestion | undefined, +): question is SurveyQuestion & { id: string } { + return ( + hasQuestionIdentity(question) && + question.type === "rating" && + question.display === "emoji" && + question.scale === 2 && + question.optional !== true + ); +} + +function isReasonsQuestion( + question: SurveyQuestion | undefined, +): question is SurveyQuestion & { id: string } { + return ( + hasQuestionIdentity(question) && + question.type === "multiple_choice" && + question.optional === true && + Array.isArray(question.choices) && + question.choices.length === LIGHTHOUSE_FEEDBACK_REASONS.length && + question.choices.every( + (choice, index) => choice === LIGHTHOUSE_FEEDBACK_REASONS[index], + ) + ); +} + +function isDetailsQuestion( + question: SurveyQuestion | undefined, +): question is SurveyQuestion & { id: string } { + return ( + hasQuestionIdentity(question) && + question.type === "open" && + question.optional === true + ); +} + +function hasQuestionIdentity( + question: SurveyQuestion | undefined, +): question is SurveyQuestion & { id: string } { + return ( + typeof question?.id === "string" && + question.id.trim().length > 0 && + typeof question.question === "string" && + question.question.length > 0 + ); +} + +function toFeedbackQuestion(question: SurveyQuestion & { id: string }) { + return { id: question.id, question: question.question }; +} diff --git a/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.test.tsx b/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.test.tsx index ba7fd098a6..047445f01d 100644 --- a/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.test.tsx +++ b/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.test.tsx @@ -1,5 +1,6 @@ import { act, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import type { Survey } from "posthog-js"; import { type ReactNode } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -34,11 +35,15 @@ const { getMessagesMock, sendMessageMock, updateConfigurationMock, + captureMock, + onSurveysLoadedMock, } = vi.hoisted(() => ({ createSessionMock: vi.fn(), getMessagesMock: vi.fn(), sendMessageMock: vi.fn(), updateConfigurationMock: vi.fn(), + captureMock: vi.fn(), + onSurveysLoadedMock: vi.fn(), })); vi.mock("@/app/(prowler)/lighthouse/_actions", () => ({ @@ -48,6 +53,13 @@ vi.mock("@/app/(prowler)/lighthouse/_actions", () => ({ updateLighthouseV2Configuration: updateConfigurationMock, })); +vi.mock("posthog-js", () => ({ + default: { + capture: captureMock, + onSurveysLoaded: onSurveysLoadedMock, + }, +})); + vi.mock("next/navigation", () => ({ usePathname: () => window.location.pathname, useSearchParams: () => new URLSearchParams(window.location.search), @@ -97,6 +109,43 @@ const supportedProviders: LighthouseV2SupportedProvider[] = [ { id: "openai-compatible", name: "OpenAI Compatible" }, ]; +const OUTCOME_FEEDBACK_SURVEY = { + id: "survey-123", + name: "Lighthouse Request Outcome Feedback", + type: "api", + questions: [ + { + id: "rating-question-id", + type: "rating", + display: "emoji", + scale: 2, + question: "How was this outcome?", + lowerBoundLabel: "Not helpful", + upperBoundLabel: "Helpful", + }, + { + id: "reasons-question-id", + type: "multiple_choice", + optional: true, + choices: [ + "Don't like the style", + "Didn't fully follow instructions", + "Low quality", + "Biased", + "Safety or legal concern", + "Other", + ], + question: "What could be improved?", + }, + { + id: "details-question-id", + type: "open", + optional: true, + question: "Additional feedback", + }, + ], +} as unknown as Survey; + describe("LighthouseV2ChatPage", () => { beforeEach(() => { vi.stubGlobal( @@ -115,6 +164,12 @@ describe("LighthouseV2ChatPage", () => { getMessagesMock.mockReset(); sendMessageMock.mockReset(); updateConfigurationMock.mockReset(); + captureMock.mockReset(); + onSurveysLoadedMock.mockReset(); + onSurveysLoadedMock.mockImplementation((callback) => { + callback([OUTCOME_FEEDBACK_SURVEY]); + return () => {}; + }); resetPanelChatStoreForTests(); eventSources = stubEventSource(); window.history.replaceState(null, "", "/lighthouse"); @@ -545,6 +600,156 @@ describe("LighthouseV2ChatPage", () => { ); }); + it("renders each outcome control under its paired assistant answer and submits against the initiating user message", async () => { + // Given + const user = userEvent.setup(); + const firstUserMessage = message("message-user-1", "user", "First prompt"); + const secondUserMessage = message( + "message-user-2", + "user", + "Second prompt", + ); + renderPage({ + initialSessionId: "session-1", + initialMessages: [ + firstUserMessage, + message("message-assistant-1", "assistant", "First answer"), + secondUserMessage, + message("message-assistant-2", "assistant", "Second answer"), + ], + }); + const firstPrompt = screen.getByText("First prompt").closest("article"); + const firstAnswer = screen.getByText("First answer").closest("article"); + const secondPrompt = screen.getByText("Second prompt").closest("article"); + const secondAnswer = screen.getByText("Second answer").closest("article"); + if (!firstPrompt || !firstAnswer || !secondPrompt || !secondAnswer) { + throw new Error("Expected every chat message to render"); + } + + // When + await user.click( + within(firstAnswer).getByRole("button", { + name: "Mark outcome as not helpful", + }), + ); + await user.click(screen.getByRole("button", { name: "Submit" })); + await user.click( + within(secondAnswer).getByRole("button", { + name: "Mark outcome as not helpful", + }), + ); + await user.click(screen.getByRole("button", { name: "Submit" })); + + // Then + expect( + within(firstPrompt).queryByRole("button", { name: /Mark outcome/ }), + ).not.toBeInTheDocument(); + expect( + within(secondPrompt).queryByRole("button", { name: /Mark outcome/ }), + ).not.toBeInTheDocument(); + expect( + within(firstAnswer).getByRole("button", { + name: "Mark outcome as helpful", + }), + ).toBeInTheDocument(); + expect( + within(secondAnswer).getByRole("button", { + name: "Mark outcome as helpful", + }), + ).toBeInTheDocument(); + expect(captureMock).toHaveBeenCalledWith( + "survey sent", + expect.objectContaining({ + $ai_trace_id: "message-user-1", + "$survey_response_rating-question-id": 2, + }), + ); + expect(captureMock).toHaveBeenCalledWith( + "survey sent", + expect.objectContaining({ + $ai_trace_id: "message-user-2", + "$survey_response_rating-question-id": 2, + }), + ); + expect(captureMock).toHaveBeenCalledTimes(2); + expect(onSurveysLoadedMock).toHaveBeenCalledOnce(); + }); + + it.each([ + ["unavailable", []], + [ + "invalid", + [ + { + ...OUTCOME_FEEDBACK_SURVEY, + questions: [ + { ...OUTCOME_FEEDBACK_SURVEY.questions[0], optional: true }, + OUTCOME_FEEDBACK_SURVEY.questions[1], + OUTCOME_FEEDBACK_SURVEY.questions[2], + ], + } as unknown as Survey, + ], + ], + ])( + "does not render outcome feedback controls when the API survey is %s", + (_state, surveys) => { + // Given + onSurveysLoadedMock.mockImplementation((callback) => { + callback(surveys); + return () => {}; + }); + + // When + renderPage({ + initialSessionId: "session-1", + initialMessages: [ + message("message-user-1", "user", "Prompt"), + message("message-assistant-1", "assistant", "Answer"), + ], + }); + + // Then + expect( + screen.queryByRole("button", { name: "Mark outcome as helpful" }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Mark outcome as not helpful" }), + ).not.toBeInTheDocument(); + }, + ); + + it("cleans up the outcome feedback survey subscription when the chat unmounts", () => { + // Given + const unsubscribe = vi.fn(); + onSurveysLoadedMock.mockImplementation((callback) => { + callback([OUTCOME_FEEDBACK_SURVEY]); + return unsubscribe; + }); + const { unmount } = renderPage(); + + // When + unmount(); + + // Then + expect(unsubscribe).toHaveBeenCalledOnce(); + }); + + it("does not attach outcome feedback to an assistant after an optimistic user prompt", () => { + // Given / When + renderPage({ + initialSessionId: "session-1", + initialMessages: [ + message("optimistic-user-1", "user", "Pending prompt"), + message("message-assistant-1", "assistant", "Unpaired answer"), + ], + }); + + // Then + expect( + screen.queryByRole("button", { name: /Mark outcome/ }), + ).not.toBeInTheDocument(); + }); + it("hands the suggested follow-up skill off to a fresh session", async () => { // Given: a persisted triage run in the currently open session const user = userEvent.setup(); @@ -698,6 +903,39 @@ describe("LighthouseV2ChatPage", () => { expect(sendMessageMock).toHaveBeenCalledTimes(1); }); + it("keeps outcome feedback available after a terminal run error", async () => { + // Given + const user = userEvent.setup(); + getMessagesMock.mockResolvedValue({ + data: [message("task-1", "user", "Run this check")], + }); + renderPage(); + await user.type( + screen.getByRole("textbox", { name: "Message" }), + ["Run this check", "{Enter}"].join(""), + ); + await waitFor(() => expect(eventSources).toHaveLength(1)); + + // When + act(() => eventSources[0].emit("error", { detail: "Agent run failed." })); + + // Then + expect(await screen.findByText("Agent run failed.")).toBeInTheDocument(); + await user.click( + await screen.findByRole("button", { + name: "Mark outcome as not helpful", + }), + ); + await user.click(screen.getByRole("button", { name: "Submit" })); + expect(captureMock).toHaveBeenCalledWith( + "survey sent", + expect.objectContaining({ + $ai_trace_id: "task-1", + "$survey_response_rating-question-id": 2, + }), + ); + }); + it("surfaces a connection error when the stream closes without retrying", async () => { // Given const user = userEvent.setup(); diff --git a/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-view.tsx b/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-view.tsx index 87246de043..839949fa81 100644 --- a/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-view.tsx +++ b/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-view.tsx @@ -1,6 +1,7 @@ "use client"; -import { type ReactNode, type SubmitEvent } from "react"; +import posthogClient from "posthog-js"; +import { type ReactNode, type SubmitEvent, useState } from "react"; import { Conversation, @@ -19,6 +20,7 @@ import { parseLighthouseV2ModelSelectionValue, } from "@/app/(prowler)/lighthouse/_lib/model-selection"; import { + LIGHTHOUSE_V2_MESSAGE_ROLE, LIGHTHOUSE_V2_PROVIDER_TYPE, type LighthouseV2Configuration, type LighthouseV2ProviderType, @@ -33,12 +35,17 @@ import { } from "@/components/shadcn/combobox/combobox"; import { Skeleton } from "@/components/shadcn/skeleton/skeleton"; import { useLighthouseCurrentContext } from "@/hooks/use-lighthouse-context"; +import { useMountEffect } from "@/hooks/use-mount-effect"; import { ProviderIcon } from "../config/provider-icon"; import { ChatComposerPanel } from "./composer"; import { ChatEmptyState } from "./empty-state"; import { useLighthouseChatStore } from "./lighthouse-chat-store-provider"; +import { + resolveLighthouseFeedbackSurvey, + type LighthouseFeedbackSurvey, +} from "./lighthouse-feedback-survey"; import { MessageBubble } from "./message-bubble"; import { SkillComposerPill } from "./skill-composer-pill"; import { SkillRunProgress } from "./skill-run-progress"; @@ -62,6 +69,7 @@ export function LighthouseV2ChatView({ emptyStateFooter, }: LighthouseV2ChatViewProps) { const currentContext = useLighthouseCurrentContext(); + const feedbackSurvey = useLighthouseOutcomeFeedbackSurvey(); // Whole-store subscription is intentional: the view renders most of the state and selectLighthouseChatCanSend takes full state. const state = useLighthouseChatStore((current) => current); const { @@ -72,6 +80,7 @@ export function LighthouseV2ChatView({ feedback, isLoadingSession, lastSubmission, + failedOutcomeMessageId, selectedModelSelection, modelPreferenceSaving, setInput, @@ -142,9 +151,14 @@ export function LighthouseV2ChatView({ Boolean(streamState.assistantText) || streamState.toolCalls.length > 0; const hasConversation = messages.length > 0 || hasLiveAssistantActivity; + const failedOutcomeFeedbackTarget = failedOutcomeMessageId + ? messages.find((message) => message.id === failedOutcomeMessageId) + : undefined; const composerPanelProps = { feedback, + feedbackTarget: failedOutcomeFeedbackTarget, + feedbackSurvey, canRetry: streamState.status === LIGHTHOUSE_V2_STREAM_STATUS.DISCONNECTED && lastSubmission !== null, @@ -200,14 +214,22 @@ export function LighthouseV2ChatView({ scrollClassName="minimal-scrollbar overflow-x-hidden overflow-y-auto" > {messages.map((message, index) => { - const skillRun = getSkillRunFromLaunch( - message, - messages[index - 1], - ); + const previousMessage = messages[index - 1]; + const skillRun = getSkillRunFromLaunch(message, previousMessage); + // The assistant owns the controls visually; its adjacent persisted + // user turn remains the API task/trace feedback target. + const feedbackTarget = + message.role === LIGHTHOUSE_V2_MESSAGE_ROLE.ASSISTANT && + previousMessage?.role === LIGHTHOUSE_V2_MESSAGE_ROLE.USER && + !previousMessage.id.startsWith("optimistic-") + ? previousMessage + : undefined; return ( { // The DyR prompts hand follow-up skills off to a separate @@ -275,6 +297,18 @@ export function LighthouseV2ChatView({ ); } +function useLighthouseOutcomeFeedbackSurvey(): LighthouseFeedbackSurvey | null { + const [survey, setSurvey] = useState(null); + + useMountEffect(() => { + return posthogClient.onSurveysLoaded((surveys) => { + setSurvey(resolveLighthouseFeedbackSurvey(surveys)); + }); + }); + + return survey; +} + function SessionLoadingState() { return (
({ + captureMock: vi.fn(), +})); + +vi.mock("posthog-js", () => ({ + default: { capture: captureMock }, +})); + +const FEEDBACK_SURVEY = { + id: "survey-123", + name: "Lighthouse Request Outcome Feedback", + ratingQuestion: { + id: "rating-question-id", + question: "How was this outcome?", + }, + reasonsQuestion: { + id: "reasons-question-id", + question: "What could be improved?", + }, + detailsQuestion: { + id: "details-question-id", + question: "Additional feedback", + }, +} satisfies LighthouseFeedbackSurvey; + vi.mock("streamdown", () => ({ Streamdown: ({ children }: { children: ReactNode }) => { const text = String(children); @@ -48,6 +77,14 @@ vi.mock("streamdown", () => ({ })); describe("MessageBubble", () => { + beforeEach(() => { + captureMock.mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + it("should never render the agent-facing context block for user messages", () => { // Given const userMessage: LighthouseV2Message = { @@ -341,6 +378,345 @@ describe("MessageBubble", () => { expect(isBefore(firstText, toolCall)).toBe(true); expect(isBefore(toolCall, secondText)).toBe(true); }); + + describe("when rendering feedback controls", () => { + it("should show controls for an assistant answer with a persisted user feedback target", () => { + // Given / When + renderFeedbackBubble(); + + // Then + expect( + screen.getByRole("button", { name: "Mark outcome as helpful" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Mark outcome as not helpful" }), + ).toBeInTheDocument(); + }); + + it("should not show controls under a user prompt", () => { + // Given + const message = buildUserMessage(); + + // When + render(); + + // Then + expect( + screen.queryByRole("button", { name: "Mark outcome as helpful" }), + ).not.toBeInTheDocument(); + }); + + it("should not show controls for an assistant without a feedback target", () => { + // Given + const message = buildAssistantMessage([textPart("part-1", "Done")]); + + // When + render(); + + // Then + expect( + screen.queryByRole("button", { name: "Mark outcome as helpful" }), + ).not.toBeInTheDocument(); + }); + + it("should not show controls for an optimistic user feedback target", () => { + // Given + const message = buildAssistantMessage([textPart("part-1", "Done")]); + + // When + render( + , + ); + + // Then + expect( + screen.queryByRole("button", { name: "Mark outcome as helpful" }), + ).not.toBeInTheDocument(); + }); + }); + + describe("when rating an assistant answer", () => { + it("should submit thumbs up immediately without opening the feedback form", async () => { + // Given + const user = userEvent.setup(); + renderFeedbackBubble(); + + // When + await user.click( + screen.getByRole("button", { name: "Mark outcome as helpful" }), + ); + + // Then + expect(captureMock).toHaveBeenCalledWith( + "survey sent", + expect.objectContaining({ + $ai_trace_id: "message-user-1", + $survey_id: "survey-123", + $survey_name: "Lighthouse Request Outcome Feedback", + "$survey_response_rating-question-id": 1, + $survey_completed: true, + }), + ); + expect( + screen.queryByRole("heading", { name: "Share feedback" }), + ).not.toBeInTheDocument(); + }); + + it("should keep controls usable and assign a fresh submission identifier for each capture", async () => { + // Given + const user = userEvent.setup(); + vi.spyOn(globalThis.crypto, "randomUUID") + .mockReturnValueOnce("submission-1") + .mockReturnValueOnce("submission-2"); + renderFeedbackBubble(); + const helpfulButton = screen.getByRole("button", { + name: "Mark outcome as helpful", + }); + + // When + await user.click(helpfulButton); + + // Then + expect(helpfulButton).toBeEnabled(); + expect(captureMock).toHaveBeenNthCalledWith( + 1, + "survey sent", + expect.objectContaining({ + $survey_submission_id: "submission-1", + }), + ); + + // When + await user.click(helpfulButton); + + // Then + expect(captureMock).toHaveBeenNthCalledWith( + 2, + "survey sent", + expect.objectContaining({ + $survey_submission_id: "submission-2", + }), + ); + }); + + it("should open the feedback form with the chosen rating without submitting", async () => { + // Given + const user = userEvent.setup(); + renderFeedbackBubble(); + + // When + await user.click( + screen.getByRole("button", { name: "Mark outcome as not helpful" }), + ); + + // Then + expect( + screen.getByRole("heading", { name: "Share feedback" }), + ).toBeVisible(); + expect( + screen.getByRole("button", { name: "Mark outcome as not helpful" }), + ).toHaveAttribute("aria-pressed", "true"); + expect( + screen.getByLabelText("Additional feedback (optional)"), + ).toHaveAttribute( + "maxlength", + String(LIGHTHOUSE_FEEDBACK_DETAILS_MAX_LENGTH), + ); + expect(captureMock).not.toHaveBeenCalled(); + }); + + it("should submit selected feedback reasons with trimmed optional details", async () => { + // Given + const user = userEvent.setup(); + renderFeedbackBubble(); + + // When + await user.click( + screen.getByRole("button", { name: "Mark outcome as not helpful" }), + ); + const styleReason = screen.getByRole("button", { + name: "Don't like the style", + }); + expect( + within(screen.getByRole("group", { name: "Reasons (optional)" })) + .getAllByRole("button") + .map((button) => button.textContent), + ).toEqual([ + "Don't like the style", + "Didn't fully follow instructions", + "Low quality", + "Biased", + "Safety or legal concern", + "Other", + ]); + styleReason.focus(); + await user.keyboard("{Enter}"); + await user.click(screen.getByRole("button", { name: "Low quality" })); + + // Then - keyboard and pointer interactions retain a multi-select pressed state. + expect(styleReason).toHaveAttribute("aria-pressed", "true"); + expect( + screen.getByRole("button", { name: "Low quality" }), + ).toHaveAttribute("aria-pressed", "true"); + + // When + await user.type( + screen.getByLabelText("Additional feedback (optional)"), + " Missing evidence ", + ); + await user.click(screen.getByRole("button", { name: "Submit" })); + + // Then + expect(captureMock).toHaveBeenCalledTimes(3); + expect(captureMock).toHaveBeenLastCalledWith( + "survey sent", + expect.objectContaining({ + $ai_trace_id: "message-user-1", + "$survey_response_rating-question-id": 2, + "$survey_response_reasons-question-id": [ + "Don't like the style", + "Low quality", + ], + "$survey_response_details-question-id": "Missing evidence", + $survey_completed: true, + }), + ); + }); + + it("should submit thumbs down without optional reasons or details", async () => { + // Given + const user = userEvent.setup(); + renderFeedbackBubble(); + + // When + await user.click( + screen.getByRole("button", { name: "Mark outcome as not helpful" }), + ); + await user.click(screen.getByRole("button", { name: "Submit" })); + + // Then + expect(captureMock).toHaveBeenCalledWith( + "survey sent", + expect.objectContaining({ + $ai_trace_id: "message-user-1", + "$survey_response_rating-question-id": 2, + $survey_completed: true, + }), + ); + }); + + it("should cancel without submitting and clear the draft", async () => { + // Given + const user = userEvent.setup(); + renderFeedbackBubble(); + await user.click( + screen.getByRole("button", { name: "Mark outcome as not helpful" }), + ); + await user.click( + screen.getByRole("button", { name: "Don't like the style" }), + ); + await user.type( + screen.getByLabelText("Additional feedback (optional)"), + "Unsaved draft", + ); + + // When + await user.click(screen.getByRole("button", { name: "Cancel" })); + + // Then + expect(captureMock).not.toHaveBeenCalled(); + expect( + screen.queryByRole("heading", { name: "Share feedback" }), + ).not.toBeInTheDocument(); + await user.click( + screen.getByRole("button", { name: "Mark outcome as not helpful" }), + ); + expect( + screen.getByLabelText("Additional feedback (optional)"), + ).toHaveValue(""); + expect( + screen.getByRole("button", { name: "Don't like the style" }), + ).toHaveAttribute("aria-pressed", "false"); + }); + + it("should clear selected feedback reasons after a successful submission", async () => { + // Given + const user = userEvent.setup(); + renderFeedbackBubble(); + await user.click( + screen.getByRole("button", { name: "Mark outcome as not helpful" }), + ); + await user.click( + screen.getByRole("button", { name: "Don't like the style" }), + ); + + // When + await user.click(screen.getByRole("button", { name: "Submit" })); + expect( + screen.queryByRole("heading", { name: "Share feedback" }), + ).not.toBeInTheDocument(); + await user.click( + screen.getByRole("button", { name: "Mark outcome as not helpful" }), + ); + + // Then + expect( + screen.getByRole("button", { name: "Don't like the style" }), + ).toHaveAttribute("aria-pressed", "false"); + }); + + it("should close the feedback popup without submitting", async () => { + // Given + const user = userEvent.setup(); + renderFeedbackBubble(); + await user.click( + screen.getByRole("button", { name: "Mark outcome as not helpful" }), + ); + + // When + await user.keyboard("{Escape}"); + + // Then + expect(captureMock).not.toHaveBeenCalled(); + expect( + screen.queryByRole("heading", { name: "Share feedback" }), + ).not.toBeInTheDocument(); + }); + + it("should discard the feedback draft when the popup closes with Escape", async () => { + // Given + const user = userEvent.setup(); + renderFeedbackBubble(); + await user.click( + screen.getByRole("button", { name: "Mark outcome as not helpful" }), + ); + await user.click( + screen.getByRole("button", { name: "Don't like the style" }), + ); + await user.type( + screen.getByLabelText("Additional feedback (optional)"), + "Unsaved draft", + ); + + // When + await user.keyboard("{Escape}"); + await user.click( + screen.getByRole("button", { name: "Mark outcome as not helpful" }), + ); + + // Then + expect( + screen.getByLabelText("Additional feedback (optional)"), + ).toHaveValue(""); + expect( + screen.getByRole("button", { name: "Don't like the style" }), + ).toHaveAttribute("aria-pressed", "false"); + }); + }); }); function isBefore(first: HTMLElement, second: HTMLElement): boolean { @@ -362,6 +738,27 @@ function buildAssistantMessage( }; } +function buildUserMessage(id = "message-user-1"): LighthouseV2Message { + return { + id, + role: LIGHTHOUSE_V2_MESSAGE_ROLE.USER, + model: null, + tokenUsage: null, + insertedAt: "2026-06-25T10:00:00Z", + parts: [textPart("part-user-1", "Run this check")], + }; +} + +function renderFeedbackBubble() { + return render( + , + ); +} + function textPart( id: string, text: string, diff --git a/ui/app/(prowler)/lighthouse/_components/chat/message-bubble.tsx b/ui/app/(prowler)/lighthouse/_components/chat/message-bubble.tsx index b82974b652..f5ed96b339 100644 --- a/ui/app/(prowler)/lighthouse/_components/chat/message-bubble.tsx +++ b/ui/app/(prowler)/lighthouse/_components/chat/message-bubble.tsx @@ -1,6 +1,14 @@ "use client"; -import { Bot, Check, Copy, UserRound } from "lucide-react"; +import { + Bot, + Check, + Copy, + ThumbsDown, + ThumbsUp, + UserRound, +} from "lucide-react"; +import posthogClient from "posthog-js"; import { useState } from "react"; import { formatMessageTimestamp } from "@/app/(prowler)/lighthouse/_lib/format"; @@ -18,9 +26,24 @@ import { } from "@/app/(prowler)/lighthouse/_types"; import { LighthouseContextBadge } from "@/components/lighthouse/context-chip"; import { Button } from "@/components/shadcn/button/button"; +import { + Popover, + PopoverAnchor, + PopoverContent, +} from "@/components/shadcn/popover"; +import { FeedbackForm } from "@/components/survey/feedback-form"; import { cn } from "@/lib/utils"; import type { LighthouseSkillDefinition } from "@/types/lighthouse-skills"; +import { + buildLighthouseFeedbackSurveyEvents, + LIGHTHOUSE_FEEDBACK_DETAILS_MAX_LENGTH, + LIGHTHOUSE_FEEDBACK_RATING, + LIGHTHOUSE_FEEDBACK_REASON, + type LighthouseFeedbackRating, + type LighthouseFeedbackReason, + type LighthouseFeedbackSurvey, +} from "./lighthouse-feedback-survey"; import { MessageMarkdown } from "./message-markdown"; import { SkillActionsRow, SkillRunReceipt } from "./skill-completed"; import { SkillMessageCard } from "./skill-message-card"; @@ -34,22 +57,32 @@ const ASSISTANT_PART_GROUP_TYPE = { type AssistantPartGroupType = (typeof ASSISTANT_PART_GROUP_TYPE)[keyof typeof ASSISTANT_PART_GROUP_TYPE]; +const LIGHTHOUSE_FEEDBACK_REASON_OPTIONS = Object.values( + LIGHTHOUSE_FEEDBACK_REASON, +).map((reason) => ({ value: reason, label: reason })); + interface AssistantPartGroup { id: string; type: AssistantPartGroupType; parts: LighthouseV2Part[]; } -export function MessageBubble({ - message, - skillRun, - onLaunchSkill, -}: { +interface MessageBubbleProps { message: LighthouseV2Message; + feedbackTarget?: LighthouseV2Message; + feedbackSurvey?: LighthouseFeedbackSurvey | null; // Present when this assistant message answered a skill launch (design 1j). skillRun?: SkillRunInfo; onLaunchSkill?: (skill: LighthouseSkillDefinition) => void; -}) { +} + +export function MessageBubble({ + message, + feedbackTarget, + feedbackSurvey, + skillRun, + onLaunchSkill, +}: MessageBubbleProps) { const isUser = message.role === LIGHTHOUSE_V2_MESSAGE_ROLE.USER; const isSkillResponse = !isUser && skillRun !== undefined; // Text-only join feeds the copy button; tool calls are rendered separately. @@ -125,6 +158,8 @@ export function MessageBubble({ isUser={isUser} text={messageText} insertedAt={message.insertedAt} + feedbackTarget={feedbackTarget} + feedbackSurvey={feedbackSurvey} />
{isUser && ( @@ -204,10 +239,14 @@ function MessageMeta({ isUser, text, insertedAt, + feedbackTarget, + feedbackSurvey, }: { isUser: boolean; text: string; insertedAt: string; + feedbackTarget?: LighthouseV2Message; + feedbackSurvey?: LighthouseFeedbackSurvey | null; }) { // Copy is always shown; the timestamp only reveals on hover over the message. // Agent footer reads left-to-right ([copy] [time]); user footer mirrors it. @@ -219,6 +258,13 @@ function MessageMeta({ )} > + {feedbackTarget && feedbackSurvey && ( + + )}