From f7e678bc8d85938bf92a57715ac1c58e9e554d0f Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 12 Jul 2026 18:47:08 -0700 Subject: [PATCH] FN-7894: fix conversation history rendering user's own-answer text as [object Object] Resolves planning interview history showing raw stringified objects instead of the user's typed "Other" answer text. - Add getResponseValue resolution that mirrors the server's `_other` reserved-key contract, extracting the free-text answer separately from `_comment` metadata. - Add formatResponse support for an `other` override across single_select, multi_select, and confirm question types, appending "(user's own answer)" suffix. - Add safeFormatScalar helper to safely stringify non-string scalar/object response values instead of relying on bare String()/JSON.stringify() branches. - Add regression tests covering text/single_select/multi_select/confirm question types with `_other` responses, plus the existing `_comment` metadata path. Files changed: .../app/components/ConversationHistory.tsx | 96 +++++++++++++---- .../__tests__/ConversationHistory.test.tsx | 117 +++++++++++++++++++++ 2 files changed, 191 insertions(+), 22 deletions(-) Fusion-Task-Id: FN-7894 Fusion-Task-Lineage: 06444248-23cd-4fac-9afc-b2e2c7366cbe Co-authored-by: Fusion (runfusion.ai) --- .../app/components/ConversationHistory.tsx | 96 ++++++++++---- .../__tests__/ConversationHistory.test.tsx | 117 ++++++++++++++++++ 2 files changed, 191 insertions(+), 22 deletions(-) diff --git a/packages/dashboard/app/components/ConversationHistory.tsx b/packages/dashboard/app/components/ConversationHistory.tsx index da95e8e0a4..6e6b8d9c4b 100644 --- a/packages/dashboard/app/components/ConversationHistory.tsx +++ b/packages/dashboard/app/components/ConversationHistory.tsx @@ -6,6 +6,9 @@ import { useState } from "react"; import type { ConversationHistoryEntry } from "../api"; const COMMENT_ICON = "💬"; +const PLANNING_OTHER_RESPONSE_KEY = "_other"; +const PLANNING_COMMENT_RESPONSE_KEY = "_comment"; +const USER_OWN_ANSWER_SUFFIX = " (user's own answer)"; interface ConversationHistoryProps { entries: ConversationHistoryEntry[]; @@ -16,54 +19,101 @@ interface NumberedEntry extends ConversationHistoryEntry { questionNumber: number | null; } -function getResponseValue(entry: ConversationHistoryEntry): unknown { +interface ResolvedResponseValue { + value: unknown; + other: string; +} + +function safeFormatScalar(value: unknown): string { + if (value == null) return ""; + if (typeof value === "object") return JSON.stringify(value); + return String(value); +} + +/* +FNXC:PlanningInterview 2026-07-12-18:36: +Planning interview history must mirror the server `_other` reserved-key contract so user-authored Other answers render as human text instead of falling through to object stringification. `_comment` remains metadata for the separate comment row and is never treated as an answer value. +*/ +function getResponseValue(entry: ConversationHistoryEntry): ResolvedResponseValue { const { question, response } = entry; - if (!question) return response; + if (!question) return { value: response, other: "" }; if (response && typeof response === "object" && !Array.isArray(response)) { const record = response as Record; + const other = typeof record[PLANNING_OTHER_RESPONSE_KEY] === "string" + ? record[PLANNING_OTHER_RESPONSE_KEY].trim() + : ""; + if (question.id in record) { - return record[question.id]; + return { value: record[question.id], other }; } + + if (other.length > 0) { + return { value: question.type === "text" ? other : undefined, other }; + } + + const hasAnswerKeys = Object.keys(record).some( + (key) => key !== PLANNING_OTHER_RESPONSE_KEY && key !== PLANNING_COMMENT_RESPONSE_KEY, + ); + + return { value: hasAnswerKeys ? response : undefined, other: "" }; } - return response; + return { value: response, other: "" }; } -function formatResponse(question: PlanningQuestion, responseValue: unknown, t: TFunction<"app">): string { +function formatOtherAnswer(other: string): string { + return other.length > 0 ? `${other}${USER_OWN_ANSWER_SUFFIX}` : ""; +} + +function formatResponse( + question: PlanningQuestion, + responseValue: unknown, + t: TFunction<"app">, + other = "", +): string { switch (question.type) { case "text": { if (typeof responseValue === "string") return responseValue; - return responseValue == null ? "" : String(responseValue); + return safeFormatScalar(responseValue); } case "single_select": { + if (other.length > 0) { + return formatOtherAnswer(other); + } if (typeof responseValue === "string") { const selected = question.options?.find((option) => option.id === responseValue); return selected?.label ?? responseValue; } - return responseValue == null ? "" : String(responseValue); + return safeFormatScalar(responseValue); } case "multi_select": { if (Array.isArray(responseValue)) { - return responseValue - .map((value) => { - if (typeof value !== "string") { - return String(value); - } - const selected = question.options?.find((option) => option.id === value); - return selected?.label ?? value; - }) - .join(", "); + const selected = responseValue.map((value) => { + if (typeof value !== "string") { + return safeFormatScalar(value); + } + const selectedOption = question.options?.find((option) => option.id === value); + return selectedOption?.label ?? value; + }); + if (other.length > 0) { + selected.push(formatOtherAnswer(other)); + } + return selected.join(", "); } - return responseValue == null ? "" : String(responseValue); + if (other.length > 0) { + return formatOtherAnswer(other); + } + return safeFormatScalar(responseValue); } case "confirm": { + if (other.length > 0) return formatOtherAnswer(other); if (responseValue === true) return t("conversation.confirm.yes", "Yes"); if (responseValue === false) return t("conversation.confirm.no", "No"); - return responseValue == null ? "" : String(responseValue); + return safeFormatScalar(responseValue); } default: - return responseValue == null ? "" : JSON.stringify(responseValue); + return safeFormatScalar(responseValue); } } @@ -104,15 +154,17 @@ export function ConversationHistory({ entries, defaultShowThinking = false }: Co const responseValue = hasQuestion ? getResponseValue(entry) : undefined; const formattedResponse = - entry.question && responseValue !== undefined - ? formatResponse(entry.question, responseValue, t) + entry.question && responseValue && (responseValue.value !== undefined || responseValue.other.length > 0) + ? formatResponse(entry.question, responseValue.value, t, responseValue.other) : ""; const responseRecord = entry.response && typeof entry.response === "object" && !Array.isArray(entry.response) ? (entry.response as Record) : undefined; const comment = - typeof responseRecord?._comment === "string" ? responseRecord._comment.trim() : ""; + typeof responseRecord?.[PLANNING_COMMENT_RESPONSE_KEY] === "string" + ? responseRecord[PLANNING_COMMENT_RESPONSE_KEY].trim() + : ""; return (
diff --git a/packages/dashboard/app/components/__tests__/ConversationHistory.test.tsx b/packages/dashboard/app/components/__tests__/ConversationHistory.test.tsx index de995c77d7..a640b1846d 100644 --- a/packages/dashboard/app/components/__tests__/ConversationHistory.test.tsx +++ b/packages/dashboard/app/components/__tests__/ConversationHistory.test.tsx @@ -13,6 +13,26 @@ const baseQuestion: PlanningQuestion = { ], }; +const multiSelectQuestion: PlanningQuestion = { + id: "q-tags", + type: "multi_select", + question: "Which tags apply?", + options: [ + { id: "frontend", label: "Frontend" }, + { id: "backend", label: "Backend" }, + ], +}; + +const confirmQuestion: PlanningQuestion = { + id: "q-confirm", + type: "confirm", + question: "Should Fusion continue?", +}; + +function expectNoObjectObject() { + expect(screen.queryByText(/\[object Object\]/)).toBeNull(); +} + describe("ConversationHistory", () => { it("renders question and formatted response pairs", () => { render( @@ -31,6 +51,103 @@ describe("ConversationHistory", () => { expect(screen.getByText("Medium")).toBeDefined(); }); + it("renders single-select Other responses as the user's own answer", () => { + render( + , + ); + + expect(screen.getByText("my own framing (user's own answer)")).toBeDefined(); + expectNoObjectObject(); + }); + + it("renders confirm Other responses as the user's own answer", () => { + render( + , + ); + + expect(screen.getByText("Continue only after review (user's own answer)")).toBeDefined(); + expectNoObjectObject(); + }); + + it("renders multi-select selected labels plus the user's Other answer", () => { + render( + , + ); + + expect(screen.getByText("Frontend, CLI polish (user's own answer)")).toBeDefined(); + expectNoObjectObject(); + }); + + it("renders multi-select Other-only responses as the user's own answer", () => { + render( + , + ); + + expect(screen.getByText("Docs only (user's own answer)")).toBeDefined(); + expectNoObjectObject(); + }); + + it("renders Other responses and comments together without treating comments as answers", () => { + render( + , + ); + + expect(screen.getByText("custom direction (user's own answer)")).toBeDefined(); + expect(screen.getByText("💬 Need this done by next sprint")).toBeDefined(); + expectNoObjectObject(); + }); + + it("renders arbitrary object response values as JSON defensively", () => { + render( + , + ); + + expect(screen.getByText('{"nested":"value"}')).toBeDefined(); + expectNoObjectObject(); + }); + it("shows thinking output when expanded", () => { render(