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) + * + * - **