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.
This commit is contained in:
Roberto Bertó
2026-05-18 17:54:19 +03:00
committed by GitHub
parent ff35f40b43
commit fbffce4fdf
10 changed files with 345 additions and 0 deletions
@@ -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<QuestionCardProps> = ({ 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<QuestionCardProps> = ({ question }) => {
{activeHeader}
</span>
) : null}
<div className={cn('flex items-center gap-0.5', activeHeader ? null : 'ml-auto')}>
<button
type="button"
onClick={handleCopyMarkdown}
title={t('chat.questionCard.copyMarkdown')}
aria-label={t('chat.questionCard.copyMarkdown')}
className="flex items-center justify-center h-5 w-5 rounded text-muted-foreground hover:text-foreground hover:bg-interactive-hover/30 transition-colors"
>
<Icon name="file-text" className="h-3 w-3" />
</button>
<button
type="button"
onClick={handleCopyJson}
title={t('chat.questionCard.copyJson')}
aria-label={t('chat.questionCard.copyJson')}
className="flex items-center justify-center h-5 w-5 rounded text-muted-foreground hover:text-foreground hover:bg-interactive-hover/30 transition-colors"
>
<Icon name="code-box" className="h-3 w-3" />
</button>
</div>
</div>
</div>
@@ -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<QuestionInfo> & { 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 });
});
});
@@ -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:
* ## <header or fallback>
*
* <question body>
*
* _Select all that apply._ (only when q.multiple)
*
* - **<label>** — <description> (description elided when blank)
*/
export function serializeQuestionAsMarkdown(question: QuestionRequest): string {
const lines: string[] = [];
const questions = question.questions ?? [];
questions.forEach((q, index) => {
const header = q.header?.trim();
const title = header && header.length > 0 ? header : `Question ${index + 1}`;
lines.push(`## ${title}`);
lines.push('');
lines.push(q.question);
lines.push('');
if (q.multiple) {
lines.push('_Select all that apply._');
lines.push('');
}
q.options.forEach((option) => {
const label = option.label;
const description = option.description?.trim();
lines.push(description ? `- **${label}** — ${description}` : `- **${label}**`);
});
lines.push('');
});
return lines.join('\n').trimEnd();
}
/**
* Render a QuestionRequest as a stable JSON envelope.
*
* Mirrors the on-wire `QuestionRequest` shape minus the transient `id`
* and `sessionID` (which are local routing concerns, not part of the
* question content). `header` and `description` are normalised to
* `null` when absent so consumers do not have to distinguish `undefined`
* from `missing key`.
*/
export function serializeQuestionAsJson(question: QuestionRequest): string {
const payload = {
questions: (question.questions ?? []).map((q) => ({
header: q.header ?? null,
question: q.question,
multiple: Boolean(q.multiple),
options: q.options.map((option) => ({
label: option.label,
description: option.description ?? null,
})),
})),
};
return JSON.stringify(payload, null, 2);
}
+5
View File
@@ -1474,6 +1474,11 @@ export const dict = {
'chat.questionCard.submit': 'Submit',
'chat.questionCard.next': 'Next',
'chat.questionCard.dismiss': 'Dismiss',
'chat.questionCard.copyMarkdown': 'Copy as Markdown',
'chat.questionCard.copyJson': 'Copy as JSON',
'chat.questionCard.copiedMarkdown': 'Question copied as Markdown',
'chat.questionCard.copiedJson': 'Question copied as JSON',
'chat.questionCard.copyFailed': 'Failed to copy question',
'chat.textSelection.toast.noProject': 'No project found for this session',
'chat.textSelection.toast.addToNotesFailed': 'Failed to add to notes',
'chat.textSelection.toast.addToNotesSuccess': 'Added distilled insight to notes',
+5
View File
@@ -1440,6 +1440,11 @@ export const dict: Record<I18nKey, string> = {
"chat.questionCard.submit": "Enviar",
"chat.questionCard.next": "Siguiente",
"chat.questionCard.dismiss": "Descartar",
"chat.questionCard.copyMarkdown": "Copiar como Markdown",
"chat.questionCard.copyJson": "Copiar como JSON",
"chat.questionCard.copiedMarkdown": "Pregunta copiada como Markdown",
"chat.questionCard.copiedJson": "Pregunta copiada como JSON",
"chat.questionCard.copyFailed": "No se pudo copiar la pregunta",
"chat.textSelection.toast.noProject": "No se encontró proyecto para esta sesión",
"chat.textSelection.toast.addToNotesFailed": "No se pudo añadir a las notas",
"chat.textSelection.toast.addToNotesSuccess": "Se añadió la información destilada a notas",
+5
View File
@@ -1476,6 +1476,11 @@ export const dict: Record<I18nKey, string> = {
'chat.questionCard.submit': '제출',
'chat.questionCard.next': '다음',
'chat.questionCard.dismiss': '닫기',
'chat.questionCard.copyMarkdown': 'Markdown으로 복사',
'chat.questionCard.copyJson': 'JSON으로 복사',
'chat.questionCard.copiedMarkdown': '질문을 Markdown으로 복사했습니다',
'chat.questionCard.copiedJson': '질문을 JSON으로 복사했습니다',
'chat.questionCard.copyFailed': '질문 복사에 실패했습니다',
'chat.textSelection.toast.noProject': '이 세션의 프로젝트를 찾을 수 없음',
'chat.textSelection.toast.addToNotesFailed': '메모 추가 실패',
'chat.textSelection.toast.addToNotesSuccess': '정리된 인사이트를 메모에 추가함',
+5
View File
@@ -521,6 +521,11 @@ export const dict: Record<I18nKey, string> = {
'chat.questionCard.submit': 'Wyślij',
'chat.questionCard.next': 'Następne',
'chat.questionCard.dismiss': 'Odrzuć',
'chat.questionCard.copyMarkdown': 'Kopiuj jako Markdown',
'chat.questionCard.copyJson': 'Kopiuj jako JSON',
'chat.questionCard.copiedMarkdown': 'Pytanie skopiowane jako Markdown',
'chat.questionCard.copiedJson': 'Pytanie skopiowane jako JSON',
'chat.questionCard.copyFailed': 'Nie udało się skopiować pytania',
'chat.textSelection.toast.noProject': 'Nie znaleziono projektu dla tej sesji',
'chat.textSelection.toast.addToNotesFailed': 'Nie udało się dodać do notatek',
'chat.textSelection.toast.addToNotesSuccess': 'Dodano destylowaną wiedzę do notatek',
@@ -1440,6 +1440,11 @@ export const dict: Record<I18nKey, string> = {
"chat.questionCard.submit": "Enviar",
"chat.questionCard.next": "Próximo",
"chat.questionCard.dismiss": "Descartar",
"chat.questionCard.copyMarkdown": "Copiar como Markdown",
"chat.questionCard.copyJson": "Copiar como JSON",
"chat.questionCard.copiedMarkdown": "Pergunta copiada como Markdown",
"chat.questionCard.copiedJson": "Pergunta copiada como JSON",
"chat.questionCard.copyFailed": "Falha ao copiar a pergunta",
"chat.textSelection.toast.noProject": "Não foi encontrado projeto para esta sessão",
"chat.textSelection.toast.addToNotesFailed": "Não foi possível adicionar às notas",
"chat.textSelection.toast.addToNotesSuccess": "Informação destilada adicionada às notas",
+5
View File
@@ -1440,6 +1440,11 @@ export const dict: Record<I18nKey, string> = {
"chat.questionCard.submit": "Надіслати",
"chat.questionCard.next": "Далі",
"chat.questionCard.dismiss": "Відхилити",
"chat.questionCard.copyMarkdown": "Скопіювати як Markdown",
"chat.questionCard.copyJson": "Скопіювати як JSON",
"chat.questionCard.copiedMarkdown": "Питання скопійовано як Markdown",
"chat.questionCard.copiedJson": "Питання скопійовано як JSON",
"chat.questionCard.copyFailed": "Не вдалося скопіювати питання",
"chat.textSelection.toast.noProject": "Для цієї сесії не знайдено жодного проєкту",
"chat.textSelection.toast.addToNotesFailed": "Не вдалося додати до нотаток",
"chat.textSelection.toast.addToNotesSuccess": "Інсайт додано до нотаток",
@@ -1440,6 +1440,11 @@ export const dict: Record<I18nKey, string> = {
'chat.questionCard.submit': '提交',
'chat.questionCard.next': '下一个',
'chat.questionCard.dismiss': '忽略',
'chat.questionCard.copyMarkdown': '复制为 Markdown',
'chat.questionCard.copyJson': '复制为 JSON',
'chat.questionCard.copiedMarkdown': '问题已复制为 Markdown',
'chat.questionCard.copiedJson': '问题已复制为 JSON',
'chat.questionCard.copyFailed': '复制问题失败',
'chat.textSelection.toast.noProject': '未找到此会话对应的项目',
'chat.textSelection.toast.addToNotesFailed': '添加到笔记失败',
'chat.textSelection.toast.addToNotesSuccess': '已将洞察添加到笔记',