chat: render AskUserQuestion, instead of dropping it as raw JSON
The model has been asking questions in Officer all along. None of them ever rendered. MessageBubble dispatched the interactive question UI on `toolName === 'question'` — OpenCode's name for it. Claude Code's tool is `AskUserQuestion`, so every question from a Claude session fell through to the generic ToolActivity and printed its input as JSON in a bubble. Nothing threw, nothing logged, and the payload was arriving correctly the whole time, so it read as "the model never asks" rather than as a bug. Both harnesses send the same shape under different names; only the name was ever wrong. ── What this deliberately does NOT do ── It does not make the agent wait. Checked against the installed SDK rather than assumed: AskUserQuestion is implemented inside the bundled cli.js (with its own "No answer provided" fallback), appears nowhere in sdk.d.ts, and there is no control method or hook that can return a tool RESULT. canUseTool and PreToolUse can only allow or deny, and canUseTool is shadowed entirely under bypassPermissions, which is how Officer runs. So the tool self-resolves as unanswered the moment it is called, whatever the client does. The two ways to pretend otherwise — forging a tool result, or smuggling the choice through a denial reason — both lie to the model about what happened. Neither is here. The question is rendered, and the answer goes back as the next message, exactly what a person would have typed. The tool contract is untouched. The cost is real and stated in the file: the agent will usually have carried on and reads the answer a beat late. The day the SDK grows an answer channel, this component is the piece that already knows how to ask. ── Also fixed while in there ── State was single-valued and shared across questions, but the schema allows up to four. With more than one, the first click sent a partial answer and locked the rest. Now keyed by question index: nothing is sent until every question has an answer, and a multi-question reply is labelled by header so it cannot be ambiguous. Multi-select joins with commas, which is what the tool's own output schema documents. `multiSelect` (Claude Code) and `multiple` (OpenCode) are normalised in one place rather than at each read site. 6 tests pinning both harness shapes, the multi-question rule, the client-side "Other" escape hatch (both schemas tell the model not to offer one, because providing it is the client's job), and that an already-resolved question is read-only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,9 @@ import { errorText } from 'helpers/error-text';
|
||||
import type { ChatMessage } from '../types';
|
||||
import { ToolActivity } from './ToolActivity';
|
||||
import { QuestionActivity } from './QuestionActivity';
|
||||
|
||||
/** The tool each harness uses to ask the human something. Same payload, different name. */
|
||||
const QUESTION_TOOLS = new Set(['question', 'AskUserQuestion']);
|
||||
import { getRawUrl } from '../../FileViewer/file-types';
|
||||
import { useFilesAPI } from '../../../hooks/useFilesAPI';
|
||||
import { CopyButton } from './CopyButton';
|
||||
@@ -195,7 +198,12 @@ export const MessageBubble = ({ message, onAnswer, onRetry, onDecidePlan, defaul
|
||||
}
|
||||
|
||||
case 'tool':
|
||||
if (message.toolName === 'question' && onAnswer) {
|
||||
// Both harnesses ask the human the same way and call it something different: OpenCode's tool is
|
||||
// `question`, Claude Code's is `AskUserQuestion`. Only the first was matched, so every question from
|
||||
// a Claude session fell through to ToolActivity and rendered as raw JSON in a bubble — the payload
|
||||
// was arriving correctly and the UI simply did not recognise it. Nothing errored, which is why it
|
||||
// read as "the model never asks" rather than as a bug.
|
||||
if (QUESTION_TOOLS.has(message.toolName) && onAnswer) {
|
||||
return <QuestionActivity message={message} onAnswer={onAnswer} />;
|
||||
}
|
||||
return <ToolActivity message={message} defaultOpen={defaultOpen} />;
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { render, fireEvent, screen } from '@testing-library/react';
|
||||
import { QuestionActivity } from './QuestionActivity';
|
||||
import type { ChatMessage } from '../types';
|
||||
|
||||
// The bug this file exists for: a Claude session's questions rendered as raw JSON for months because the
|
||||
// dispatch matched only OpenCode's tool name. Nothing threw — it read as "the model never asks". So what
|
||||
// is pinned here is the SHAPE both harnesses send, not the styling.
|
||||
|
||||
const ask = (questions: unknown): Extract<ChatMessage, { role: 'tool' }> =>
|
||||
({ role: 'tool', toolName: 'AskUserQuestion', toolCallId: 't1', toolInput: { questions } }) as never;
|
||||
|
||||
const OPTIONS = [
|
||||
{ label: 'Postgres', description: 'the one already running' },
|
||||
{ label: 'SQLite', description: 'no server to run' },
|
||||
];
|
||||
|
||||
describe('the two harnesses disagree on one field name', () => {
|
||||
test("Claude Code's `multiSelect` is honoured — several options, one submit", () => {
|
||||
const answers: string[] = [];
|
||||
render(
|
||||
<QuestionActivity
|
||||
message={ask([{ question: 'Which?', header: 'Store', multiSelect: true, options: OPTIONS }])}
|
||||
onAnswer={(t) => answers.push(t)}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByText('Postgres'));
|
||||
fireEvent.click(screen.getByText('SQLite'));
|
||||
// Nothing sent until submit: that is what multi-select means.
|
||||
expect(answers).toEqual([]);
|
||||
fireEvent.click(screen.getByText(/Submit \(2 selected\)/));
|
||||
// Comma-separated, as the tool's own output schema documents.
|
||||
expect(answers).toEqual(['Postgres, SQLite']);
|
||||
});
|
||||
|
||||
test("OpenCode's `multiple` means the same thing", () => {
|
||||
const answers: string[] = [];
|
||||
render(
|
||||
<QuestionActivity
|
||||
message={ask([{ question: 'Which?', header: 'Store', multiple: true, options: OPTIONS }])}
|
||||
onAnswer={(t) => answers.push(t)}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByText('Postgres'));
|
||||
fireEvent.click(screen.getByText(/Submit \(1 selected\)/));
|
||||
expect(answers).toEqual(['Postgres']);
|
||||
});
|
||||
|
||||
test('neither field set means single-select — one click answers', () => {
|
||||
const answers: string[] = [];
|
||||
render(
|
||||
<QuestionActivity
|
||||
message={ask([{ question: 'Which?', header: 'Store', options: OPTIONS }])}
|
||||
onAnswer={(t) => answers.push(t)}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByText('SQLite'));
|
||||
expect(answers).toEqual(['SQLite']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('more than one question', () => {
|
||||
// The schema allows up to four. State used to be shared across them, so the first click sent a partial
|
||||
// answer and locked the rest — invisible while only one was ever asked.
|
||||
test('nothing is sent until every question has an answer', () => {
|
||||
const answers: string[] = [];
|
||||
render(
|
||||
<QuestionActivity
|
||||
message={ask([
|
||||
{ question: 'Which store?', header: 'Store', options: OPTIONS },
|
||||
{
|
||||
question: 'Which runtime?',
|
||||
header: 'Runtime',
|
||||
options: [
|
||||
{ label: 'Bun', description: 'what the repo uses' },
|
||||
{ label: 'Node', description: 'the other one' },
|
||||
],
|
||||
},
|
||||
])}
|
||||
onAnswer={(t) => answers.push(t)}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Postgres'));
|
||||
expect(answers).toEqual([]); // one of two — still waiting
|
||||
|
||||
fireEvent.click(screen.getByText('Bun'));
|
||||
// Labelled by header, so a two-part reply cannot be ambiguous.
|
||||
expect(answers).toEqual(['Store: Postgres\nRuntime: Bun']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the escape hatch', () => {
|
||||
// Both schemas tell the model NOT to offer an "Other" option, because providing one is the client's job.
|
||||
test('free text answers the question', () => {
|
||||
const answers: string[] = [];
|
||||
render(
|
||||
<QuestionActivity
|
||||
message={ask([{ question: 'Which?', header: 'Store', options: OPTIONS }])}
|
||||
onAnswer={(t) => answers.push(t)}
|
||||
/>,
|
||||
);
|
||||
fireEvent.change(screen.getByPlaceholderText('Other...'), { target: { value: 'DuckDB' } });
|
||||
fireEvent.click(screen.getByText('Send'));
|
||||
expect(answers).toEqual(['DuckDB']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a question the harness already resolved', () => {
|
||||
test('is read-only — its answer cannot be changed', () => {
|
||||
const answers: string[] = [];
|
||||
const resolved = {
|
||||
...ask([{ question: 'Which?', header: 'Store', options: OPTIONS }]),
|
||||
output: 'The user did not answer the questions.',
|
||||
} as never;
|
||||
render(<QuestionActivity message={resolved} onAnswer={(t) => answers.push(t)} />);
|
||||
fireEvent.click(screen.getByText('Postgres'));
|
||||
expect(answers).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -6,13 +6,23 @@ type ToolMessage = Extract<ChatMessage, { role: 'tool' }>;
|
||||
|
||||
type QuestionOption = {
|
||||
label: string;
|
||||
description: string;
|
||||
/** Why you would pick this. Optional in practice, though both harnesses document it as required. */
|
||||
description?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* One question, as it arrives on the wire.
|
||||
*
|
||||
* Two harnesses send this, and they disagree on exactly one field name: Claude Code's
|
||||
* `AskUserQuestion` says `multiSelect`, OpenCode's `question` says `multiple`. Both are accepted and
|
||||
* normalised at the top of the component rather than anywhere a renderer has to remember it — see
|
||||
* `isMulti`. Everything else (`question`, `header`, `options[{label, description}]`) is identical.
|
||||
*/
|
||||
type Question = {
|
||||
question: string;
|
||||
header: string;
|
||||
multiple: boolean;
|
||||
multiple?: boolean;
|
||||
multiSelect?: boolean;
|
||||
options: QuestionOption[];
|
||||
};
|
||||
|
||||
@@ -21,146 +31,178 @@ type QuestionActivityProps = {
|
||||
onAnswer: (text: string) => void;
|
||||
};
|
||||
|
||||
const isMulti = (q: Question): boolean => q.multiSelect ?? q.multiple ?? false;
|
||||
|
||||
/**
|
||||
* The interactive rendering of a harness asking the human something.
|
||||
*
|
||||
* ── Why the answer is a chat message rather than a tool result ──
|
||||
*
|
||||
* The honest version of this would resolve the tool call itself: the model asks, the turn blocks, the
|
||||
* human picks, the tool returns. That is what the terminal does, and it is not available to us. Checked
|
||||
* against the installed SDK rather than assumed: `AskUserQuestion` is implemented inside the bundled CLI
|
||||
* (`cli.js`, with its own "No answer provided" fallback), it appears nowhere in `sdk.d.ts`, and there is
|
||||
* no control method or hook that can return a tool RESULT — `canUseTool` and `PreToolUse` can only allow
|
||||
* or deny, and `canUseTool` is shadowed entirely under `bypassPermissions`, which is how Officer runs.
|
||||
*
|
||||
* So the tool self-resolves as "no answer" the moment it is called, whatever we do. The two ways to
|
||||
* pretend otherwise — forging a result, or smuggling the choice through a denial reason — both lie to the
|
||||
* model about what happened. This does neither. It renders what was asked and sends the human's choice as
|
||||
* the next message, which is exactly what a person would have typed. The tool contract is untouched.
|
||||
*
|
||||
* The cost is real and worth stating: the agent does not WAIT. It will usually have carried on, and reads
|
||||
* the answer a beat later. That is a limitation of the SDK, not of this component, and the day the SDK
|
||||
* grows an answer channel this is the piece that already knows how to ask.
|
||||
*/
|
||||
export const QuestionActivity = ({ message, onAnswer }: QuestionActivityProps) => {
|
||||
const [selectedOptions, setSelectedOptions] = useState<Set<string>>(new Set());
|
||||
const [otherText, setOtherText] = useState('');
|
||||
const [answered, setAnswered] = useState(false);
|
||||
const [answeredText, setAnsweredText] = useState('');
|
||||
// Keyed by question INDEX, because a payload may carry up to four and they are answered independently.
|
||||
// This was single-valued state shared across every question, so with more than one the first click sent
|
||||
// a partial answer and locked the rest — invisible while only one question was ever asked.
|
||||
const [answers, setAnswers] = useState<Record<number, string>>({});
|
||||
const [selected, setSelected] = useState<Record<number, Set<string>>>({});
|
||||
const [otherText, setOtherText] = useState<Record<number, string>>({});
|
||||
const [sent, setSent] = useState(false);
|
||||
|
||||
const input = message.toolInput as { questions?: Question[] };
|
||||
const questions = input.questions;
|
||||
if (!questions || questions.length === 0) return null;
|
||||
|
||||
// A tool call with an output has already been resolved by the harness; nothing here can change it.
|
||||
const pending = message.output === undefined;
|
||||
const isDisabled = sent || !pending;
|
||||
|
||||
const handleSelect = (question: Question, label: string) => {
|
||||
if (answered || !pending) return;
|
||||
/**
|
||||
* Record one answer, and send once EVERY question has one.
|
||||
*
|
||||
* The format follows the tool's own output schema, which maps question text to answer and joins a
|
||||
* multi-select with commas. With a single question the bare answer reads like something a person
|
||||
* typed; with several, each is labelled by its header so the reply cannot be ambiguous.
|
||||
*/
|
||||
const record = (qi: number, text: string) => {
|
||||
if (isDisabled) return;
|
||||
const next = { ...answers, [qi]: text };
|
||||
setAnswers(next);
|
||||
if (Object.keys(next).length < questions.length) return;
|
||||
|
||||
if (question.multiple) {
|
||||
setSelectedOptions((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(label)) next.delete(label);
|
||||
else next.add(label);
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
const text = label;
|
||||
setAnswered(true);
|
||||
setAnsweredText(text);
|
||||
onAnswer(text);
|
||||
}
|
||||
setSent(true);
|
||||
onAnswer(questions.length === 1 ? next[0]! : questions.map((q, i) => `${q.header}: ${next[i]}`).join('\n'));
|
||||
};
|
||||
|
||||
const handleSubmitMultiple = () => {
|
||||
if (selectedOptions.size === 0 || answered || !pending) return;
|
||||
const text = Array.from(selectedOptions).join(', ');
|
||||
setAnswered(true);
|
||||
setAnsweredText(text);
|
||||
onAnswer(text);
|
||||
const toggle = (qi: number, label: string) => {
|
||||
if (isDisabled) return;
|
||||
setSelected((prev) => {
|
||||
const set = new Set(prev[qi] ?? []);
|
||||
if (set.has(label)) set.delete(label);
|
||||
else set.add(label);
|
||||
return { ...prev, [qi]: set };
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmitOther = () => {
|
||||
const text = otherText.trim();
|
||||
if (!text || answered || !pending) return;
|
||||
setAnswered(true);
|
||||
setAnsweredText(text);
|
||||
onAnswer(text);
|
||||
};
|
||||
|
||||
const isDisabled = answered || !pending;
|
||||
|
||||
return (
|
||||
<div className="my-1 space-y-3">
|
||||
{questions.map((q, qi) => (
|
||||
<div key={qi} className="rounded-xl border border-duck-teal/20 bg-background/90 overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 bg-duck-teal/5 border-b border-duck-teal/10">
|
||||
<MessageCircleQuestion className="h-4 w-4 text-duck-teal shrink-0" />
|
||||
<span className="text-xs font-medium text-duck-teal uppercase tracking-wider">{q.header}</span>
|
||||
</div>
|
||||
{questions.map((q, qi) => {
|
||||
const multi = isMulti(q);
|
||||
const answer = answers[qi];
|
||||
const picked = selected[qi] ?? new Set<string>();
|
||||
const done = answer !== undefined || isDisabled;
|
||||
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
<p className="text-sm text-foreground font-medium">{q.question}</p>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{q.options.map((opt) => {
|
||||
const isSelected = answered
|
||||
? answeredText === opt.label || answeredText.split(', ').includes(opt.label)
|
||||
: selectedOptions.has(opt.label);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={opt.label}
|
||||
onClick={() => handleSelect(q, opt.label)}
|
||||
disabled={isDisabled}
|
||||
className={`w-full text-left px-3 py-2 rounded-lg border text-sm transition-colors ${
|
||||
isSelected
|
||||
? 'border-duck-teal bg-duck-teal/10 text-foreground'
|
||||
: isDisabled
|
||||
? 'border-border bg-muted text-muted-foreground cursor-not-allowed'
|
||||
: 'border-border hover:border-duck-teal/40 hover:bg-duck-teal/5 text-foreground cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isSelected && <Check className="h-3.5 w-3.5 text-duck-teal shrink-0" />}
|
||||
<div>
|
||||
<span className="font-medium">{opt.label}</span>
|
||||
{opt.description && (
|
||||
<span className="text-muted-foreground ml-1.5">— {opt.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<div key={qi} className="rounded-xl border border-duck-teal/20 bg-background/90 overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 bg-duck-teal/5 border-b border-duck-teal/10">
|
||||
<MessageCircleQuestion className="h-4 w-4 text-duck-teal shrink-0" />
|
||||
<span className="text-xs font-medium text-duck-teal uppercase tracking-wider">{q.header}</span>
|
||||
{questions.length > 1 && (
|
||||
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
||||
{qi + 1}/{questions.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* "Other" free-text option */}
|
||||
{!isDisabled && (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={otherText}
|
||||
onChange={(ev) => setOtherText(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter') {
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
<p className="text-sm text-foreground font-medium">{q.question}</p>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{q.options.map((opt) => {
|
||||
const isSelected =
|
||||
answer !== undefined ? answer.split(', ').includes(opt.label) : picked.has(opt.label);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={opt.label}
|
||||
onClick={() => (multi ? toggle(qi, opt.label) : record(qi, opt.label))}
|
||||
disabled={done}
|
||||
className={`w-full text-left px-3 py-2 rounded-lg border text-sm transition-colors ${
|
||||
isSelected
|
||||
? 'border-duck-teal bg-duck-teal/10 text-foreground'
|
||||
: done
|
||||
? 'border-border bg-muted text-muted-foreground cursor-not-allowed'
|
||||
: 'border-border hover:border-duck-teal/40 hover:bg-duck-teal/5 text-foreground cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isSelected && <Check className="h-3.5 w-3.5 text-duck-teal shrink-0" />}
|
||||
<div>
|
||||
<span className="font-medium">{opt.label}</span>
|
||||
{opt.description && (
|
||||
<span className="text-muted-foreground ml-1.5">— {opt.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* "Other" — provided by the client on purpose. Both schemas say the model must NOT offer one
|
||||
as an option, precisely because the escape hatch is the UI's job. */}
|
||||
{!done && (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={otherText[qi] ?? ''}
|
||||
onChange={(ev) => setOtherText((prev) => ({ ...prev, [qi]: ev.target.value }))}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key !== 'Enter') return;
|
||||
ev.preventDefault();
|
||||
handleSubmitOther();
|
||||
}
|
||||
}}
|
||||
placeholder="Other..."
|
||||
className="flex-1 px-3 py-1.5 rounded-lg border border-border text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-duck-teal/40"
|
||||
/>
|
||||
const text = (otherText[qi] ?? '').trim();
|
||||
if (text) record(qi, text);
|
||||
}}
|
||||
placeholder="Other..."
|
||||
className="flex-1 px-3 py-1.5 rounded-lg border border-border text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-duck-teal/40"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
const text = (otherText[qi] ?? '').trim();
|
||||
if (text) record(qi, text);
|
||||
}}
|
||||
disabled={!(otherText[qi] ?? '').trim()}
|
||||
className="px-3 py-1.5 rounded-lg bg-duck-teal text-white text-sm font-medium disabled:opacity-40 disabled:cursor-not-allowed hover:bg-duck-teal/90 transition-colors cursor-pointer"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{multi && !done && (
|
||||
<button
|
||||
onClick={handleSubmitOther}
|
||||
disabled={!otherText.trim()}
|
||||
className="px-3 py-1.5 rounded-lg bg-duck-teal text-white text-sm font-medium disabled:opacity-40 disabled:cursor-not-allowed hover:bg-duck-teal/90 transition-colors cursor-pointer"
|
||||
onClick={() => picked.size > 0 && record(qi, Array.from(picked).join(', '))}
|
||||
disabled={picked.size === 0}
|
||||
className="px-4 py-1.5 rounded-lg bg-duck-teal text-white text-sm font-medium disabled:opacity-40 disabled:cursor-not-allowed hover:bg-duck-teal/90 transition-colors cursor-pointer"
|
||||
>
|
||||
Send
|
||||
Submit ({picked.size} selected)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Submit button for multi-select */}
|
||||
{q.multiple && !isDisabled && (
|
||||
<button
|
||||
onClick={handleSubmitMultiple}
|
||||
disabled={selectedOptions.size === 0}
|
||||
className="px-4 py-1.5 rounded-lg bg-duck-teal text-white text-sm font-medium disabled:opacity-40 disabled:cursor-not-allowed hover:bg-duck-teal/90 transition-colors cursor-pointer"
|
||||
>
|
||||
Submit ({selectedOptions.size} selected)
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Answered indicator */}
|
||||
{isDisabled && answeredText && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-duck-teal">
|
||||
<Check className="h-3 w-3" />
|
||||
<span>Answered: {answeredText}</span>
|
||||
</div>
|
||||
)}
|
||||
{answer !== undefined && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-duck-teal">
|
||||
<Check className="h-3 w-3" />
|
||||
<span>Answered: {answer}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user