From fbffce4fdfbd55702c9d1e2330e0b6f9d307b4fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Bert=C3=B3?= <463349+robertoberto@users.noreply.github.com> Date: Mon, 18 May 2026 11:54:19 -0300 Subject: [PATCH] feat: add copy-as-markdown and copy-as-json buttons to question card (#1305) * feat: add copy-as-markdown and copy-as-json buttons to question card Adds two small icon buttons in the QuestionCard header so users can copy the full question payload (text, options, descriptions) to the clipboard in either Markdown or JSON form. Useful for forwarding interactive questions to an external LLM or note. - Markdown serialization preserves question headers, multi-select hints and option descriptions. - JSON serialization mirrors the on-wire QuestionRequest shape for programmatic reuse. - Toast feedback on success/failure using the shared @/components/ui wrapper and the existing copyTextToClipboard helper (clipboard API with execCommand fallback). - New i18n keys added across all 7 locales. * test: extract question serializers and cover with 15 unit tests The two serializers backing the copy-as-markdown and copy-as-json buttons on QuestionCard were defined as top-level consts inside QuestionCard.tsx. Exposing them for unit tests would trip the react-refresh/only-export-components ESLint rule because that file also exports the QuestionCard component. Move serializeQuestionAsMarkdown and serializeQuestionAsJson into a React-free sibling module, questionSerializers.ts, and re-import them from QuestionCard.tsx. Behavior is byte-identical. Add 15 unit tests via bun:test covering header fallback, multi-select hint gating, blank-description elision, ordering across multiple questions, JSON canonical shape (no transient id/sessionID), boolean normalisation, and the empty-questions edge case. --- .../ui/src/components/chat/QuestionCard.tsx | 43 ++++ .../__tests__/questionSerializers.test.ts | 195 ++++++++++++++++++ .../components/chat/questionSerializers.ts | 72 +++++++ packages/ui/src/lib/i18n/messages/en.ts | 5 + packages/ui/src/lib/i18n/messages/es.ts | 5 + packages/ui/src/lib/i18n/messages/ko.ts | 5 + packages/ui/src/lib/i18n/messages/pl.ts | 5 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 5 + packages/ui/src/lib/i18n/messages/uk.ts | 5 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 5 + 10 files changed, 345 insertions(+) create mode 100644 packages/ui/src/components/chat/__tests__/questionSerializers.test.ts create mode 100644 packages/ui/src/components/chat/questionSerializers.ts diff --git a/packages/ui/src/components/chat/QuestionCard.tsx b/packages/ui/src/components/chat/QuestionCard.tsx index 7015e89c..38dbeab5 100644 --- a/packages/ui/src/components/chat/QuestionCard.tsx +++ b/packages/ui/src/components/chat/QuestionCard.tsx @@ -5,12 +5,15 @@ import { Icon } from "@/components/icon/Icon"; import { cn } from '@/lib/utils'; import { isIMECompositionEvent } from '@/lib/ime'; +import { copyTextToClipboard } from '@/lib/clipboard'; +import { toast } from '@/components/ui'; import type { QuestionRequest } from '@/types/question'; import { useUIStore } from '@/stores/useUIStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessions } from '@/sync/sync-context'; import * as sessionActions from '@/sync/session-actions'; import { useI18n } from '@/lib/i18n'; +import { serializeQuestionAsJson, serializeQuestionAsMarkdown } from './questionSerializers'; interface QuestionCardProps { question: QuestionRequest; @@ -206,6 +209,26 @@ export const QuestionCard: React.FC = ({ question }) => { } }, [question.id, question.sessionID, rejectQuestion]); + const handleCopyMarkdown = React.useCallback(async () => { + const text = serializeQuestionAsMarkdown(question); + const result = await copyTextToClipboard(text); + if (result.ok) { + toast.success(t('chat.questionCard.copiedMarkdown')); + return; + } + toast.error(t('chat.questionCard.copyFailed')); + }, [question, t]); + + const handleCopyJson = React.useCallback(async () => { + const text = serializeQuestionAsJson(question); + const result = await copyTextToClipboard(text); + if (result.ok) { + toast.success(t('chat.questionCard.copiedJson')); + return; + } + toast.error(t('chat.questionCard.copyFailed')); + }, [question, t]); + if (hasResponded || questions.length === 0) { return null; } @@ -229,6 +252,26 @@ export const QuestionCard: React.FC = ({ question }) => { {activeHeader} ) : null} +
+ + +
diff --git a/packages/ui/src/components/chat/__tests__/questionSerializers.test.ts b/packages/ui/src/components/chat/__tests__/questionSerializers.test.ts new file mode 100644 index 00000000..71602bf6 --- /dev/null +++ b/packages/ui/src/components/chat/__tests__/questionSerializers.test.ts @@ -0,0 +1,195 @@ +import { describe, test, expect } from 'bun:test'; +import { serializeQuestionAsJson, serializeQuestionAsMarkdown } from '../questionSerializers'; +import type { QuestionRequest, QuestionInfo, QuestionOption } from '@/types/question'; + +function makeOption(label: string, description = ''): QuestionOption { + return { label, description }; +} + +function makeQuestion(overrides: Partial & { question: string }): QuestionInfo { + return { + header: '', + options: [], + ...overrides, + }; +} + +function makeRequest(questions: QuestionInfo[]): QuestionRequest { + return { + id: 'req-test', + sessionID: 'sess-test', + questions, + }; +} + +describe('serializeQuestionAsMarkdown', () => { + test('renders header, body and labelled options', () => { + const md = serializeQuestionAsMarkdown( + makeRequest([ + makeQuestion({ + header: 'Pick mode', + question: 'Which mode should we use?', + options: [makeOption('safe', 'Default'), makeOption('aggressive')], + }), + ]) + ); + expect(md.startsWith('## Pick mode')).toBe(true); + expect(md.includes('Which mode should we use?')).toBe(true); + expect(md.includes('- **safe** — Default')).toBe(true); + expect(md.includes('- **aggressive**')).toBe(true); + expect(md.includes(' — ')).toBe(true); + }); + + test('falls back to "Question N" when header is empty or whitespace', () => { + const md = serializeQuestionAsMarkdown( + makeRequest([ + makeQuestion({ header: ' ', question: 'A?', options: [makeOption('yes')] }), + makeQuestion({ header: '', question: 'B?', options: [makeOption('yes')] }), + ]) + ); + expect(md.includes('## Question 1')).toBe(true); + expect(md.includes('## Question 2')).toBe(true); + }); + + test('emits multi-select hint only when q.multiple is true', () => { + const single = serializeQuestionAsMarkdown( + makeRequest([makeQuestion({ question: 'pick', options: [makeOption('a')] })]) + ); + const multi = serializeQuestionAsMarkdown( + makeRequest([makeQuestion({ question: 'pick', multiple: true, options: [makeOption('a')] })]) + ); + expect(single.includes('_Select all that apply._')).toBe(false); + expect(multi.includes('_Select all that apply._')).toBe(true); + }); + + test('elides description when it is blank or whitespace', () => { + const md = serializeQuestionAsMarkdown( + makeRequest([ + makeQuestion({ + question: 'q?', + options: [makeOption('x', ' '), makeOption('y', '')], + }), + ]) + ); + expect(md.includes('- **x**\n')).toBe(true); + expect(md.includes('- **y**')).toBe(true); + expect(md.includes(' — ')).toBe(false); + }); + + test('serializes multiple questions in order', () => { + const md = serializeQuestionAsMarkdown( + makeRequest([ + makeQuestion({ header: 'First', question: 'one?', options: [makeOption('a')] }), + makeQuestion({ header: 'Second', question: 'two?', options: [makeOption('b')] }), + ]) + ); + const firstIdx = md.indexOf('## First'); + const secondIdx = md.indexOf('## Second'); + expect(firstIdx >= 0).toBe(true); + expect(secondIdx > firstIdx).toBe(true); + }); + + test('returns trimmed output (no trailing blank line)', () => { + const md = serializeQuestionAsMarkdown( + makeRequest([makeQuestion({ question: 'q?', options: [makeOption('a')] })]) + ); + expect(md.endsWith('\n')).toBe(false); + expect(md.endsWith('- **a**')).toBe(true); + }); + + test('handles empty questions array', () => { + const md = serializeQuestionAsMarkdown(makeRequest([])); + expect(md).toBe(''); + }); + + test('handles question with zero options', () => { + const md = serializeQuestionAsMarkdown( + makeRequest([makeQuestion({ header: 'Empty', question: 'free?', options: [] })]) + ); + expect(md.includes('## Empty')).toBe(true); + expect(md.includes('free?')).toBe(true); + }); +}); + +describe('serializeQuestionAsJson', () => { + test('produces canonical envelope preserving description strings', () => { + const json = serializeQuestionAsJson( + makeRequest([ + makeQuestion({ + header: 'Pick', + question: 'pick?', + options: [makeOption('a', 'A desc'), makeOption('b')], + }), + ]) + ); + const parsed = JSON.parse(json); + expect(parsed).toEqual({ + questions: [ + { + header: 'Pick', + question: 'pick?', + multiple: false, + options: [ + { label: 'a', description: 'A desc' }, + { label: 'b', description: '' }, + ], + }, + ], + }); + }); + + test('preserves empty-string header as the literal empty string', () => { + // empty string is truthy enough to keep; only undefined/missing becomes null + const json = serializeQuestionAsJson( + makeRequest([makeQuestion({ header: '', question: 'q?', options: [makeOption('x')] })]) + ); + const parsed = JSON.parse(json); + expect(parsed.questions[0].header).toBe(''); + }); + + test('reflects q.multiple as Boolean true when set, false when absent', () => { + const json = serializeQuestionAsJson( + makeRequest([ + makeQuestion({ question: 'q1', multiple: true, options: [makeOption('a')] }), + makeQuestion({ question: 'q2', options: [makeOption('a')] }), + ]) + ); + const parsed = JSON.parse(json); + expect(parsed.questions[0].multiple).toBe(true); + expect(parsed.questions[1].multiple).toBe(false); + }); + + test('omits transient request id and sessionID', () => { + const json = serializeQuestionAsJson( + makeRequest([makeQuestion({ question: 'q?', options: [makeOption('a')] })]) + ); + expect(json.includes('req-test')).toBe(false); + expect(json.includes('sess-test')).toBe(false); + }); + + test('uses 2-space indentation (human-pasteable)', () => { + const json = serializeQuestionAsJson( + makeRequest([makeQuestion({ question: 'q?', options: [makeOption('a')] })]) + ); + expect(json.includes('\n "questions"')).toBe(true); + expect(json.includes('\n {')).toBe(true); + }); + + test('handles empty questions array', () => { + const json = serializeQuestionAsJson(makeRequest([])); + expect(JSON.parse(json)).toEqual({ questions: [] }); + }); + + test('handles undefined description option from runtime payload', () => { + const json = serializeQuestionAsJson( + makeRequest([ + makeQuestion({ + question: 'q?', + options: [{ label: 'x' } as unknown as QuestionOption], + }), + ]) + ); + const parsed = JSON.parse(json); + expect(parsed.questions[0].options[0]).toEqual({ label: 'x', description: null }); + }); +}); diff --git a/packages/ui/src/components/chat/questionSerializers.ts b/packages/ui/src/components/chat/questionSerializers.ts new file mode 100644 index 00000000..db01e43b --- /dev/null +++ b/packages/ui/src/components/chat/questionSerializers.ts @@ -0,0 +1,72 @@ +import type { QuestionRequest } from '@/types/question'; + +/** + * Pure serializers for QuestionRequest payloads. + * + * Extracted from QuestionCard.tsx so they can be unit-tested without + * pulling the component tree. Living in QuestionCard.tsx triggered the + * `react-refresh/only-export-components` rule when exposed for tests; + * the React-free home here avoids that constraint and keeps the + * QuestionCard import surface focused on rendering. + */ + +/** + * Render a QuestionRequest as Markdown the user can paste into another + * tool (chat with a companion model, issue tracker, doc, etc.). + * + * Layout per question: + * ##
+ * + * + * + * _Select all that apply._ (only when q.multiple) + * + * - **