Add replies and emoji reactions to chat messages

Every message has a row under it to reply or react. Replying quotes the
message above the composer, and the sent message stores
reply_to_message_id (a plain id, not a foreign key, so deleting the
quoted message only drops the quote). send-message accepts a quote from
any session of the same conversation, so a rollover or a thread that
moved on still sends; the conversation drops a picked quote when its
session changes. The bot reads the quote as "[Replying to ...]" before
the user's turn, file-only replies included, and relayed messages carry
it too.

Reactions are a fixed set (BotMessageReactions) in
bot_message_reactions, one per message, emoji and user or bot. Users
toggle their own through api/admin/bots/react-message, and every bot
gets react_to_message for the session it answers (the user's latest
message by default). Both push the fresh list as server:bot-reaction,
with mine worked out for the session's user.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-19 21:56:31 +01:00
co-authored by Claude Opus 5.5
parent bc499a7466
commit d1b5b6d68a
44 changed files with 1289 additions and 58 deletions
@@ -0,0 +1,17 @@
import React from "react";
import type { BotMessageReactionEmoji } from "@/src/dict/bot-dict";
import type { BotMessageWithAttachments } from "@/src/types";
/**
* The conversation's reply and reaction handlers, shared by every
* message's actions.
*/
export type BotMessageActions = {
onReply: (params: { message: BotMessageWithAttachments }) => void;
onReact: (params: { message_id: number; emoji: BotMessageReactionEmoji }) => void;
};
export const BotMessageActionsContext = React.createContext<BotMessageActions>({
onReply: () => {},
onReact: () => {},
});
@@ -0,0 +1,18 @@
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import type { ReactToBotMessageRequest } from "@/src/schemas/bot-schema";
import type { BotMessageReactionData } from "@/src/types";
/**
* Adds or takes back an emoji reaction on a message, returning the
* message's fresh reactions.
*/
export default async function reactToBotMessage({ message_id, emoji }: ReactToBotMessageRequest) {
return await fetchApi<
ReactToBotMessageRequest,
APIResponseObject<BotMessageReactionData>
>(`/api/admin/bots/react-message`, {
method: "POST",
body: { message_id, emoji },
});
}
@@ -8,12 +8,13 @@ export default async function sendBotMessage({
workspace_id,
text,
media_ids,
reply_to_message_id,
}: SendBotMessageRequest) {
return await fetchApi<
SendBotMessageRequest,
APIResponseObject<BotMessageWithAttachments>
>(`/api/admin/bots/send-message`, {
method: "POST",
body: { bot_id, workspace_id, text, media_ids },
body: { bot_id, workspace_id, text, media_ids, reply_to_message_id },
});
}
@@ -29,9 +29,8 @@ export default function BotMessageGroup({ bot, group }: Props) {
{group.messages.map((message, index) => (
<BotMessageLine
key={message.id}
message_id={message.id || null}
text={message.text || ""}
needs_you={Boolean(message.needs_you)}
message={message}
bot_name={bot.name || "the bot"}
at={index == group.messages.length - 1 ? group.at : undefined}
/>
))}
@@ -1,17 +1,22 @@
import { useContext } from "react";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import type { BotMessageWithAttachments } from "@/src/types";
import linkComputerRefs from "@/src/utils/link-computer-refs";
import { BotMessageActionsContext } from "../(data)/message-actions-context";
import { MessageMarkdownComponents } from "../(data)/message-markdown-components";
import GroupTimestamp from "./group-timestamp";
import MessageActions from "./message-actions";
import MessageReactions from "./message-reactions";
import MessageReplyQuote from "./message-reply-quote";
import NeedsYouChip from "./needs-you-chip";
import SpeakMessageButton from "./speak-message-button";
type Props = {
message_id?: number | null;
text: string;
needs_you?: boolean;
message: BotMessageWithAttachments;
bot_name: string;
/**
* The group's time, on the last message only: shown beside its play
* button.
@@ -22,32 +27,40 @@ type Props = {
/**
* One bot message, rendered from markdown (GFM; raw HTML is never
* rendered). Computers it names (`computerRef`) show as buttons that
* switch the bot to them. A play button under it reads it aloud,
* disabled while the bot has no speech model, with the group's time
* before it on the last message.
* switch the bot to them. A quoted reply sits above it, its reactions
* below, and a row under it replies, reacts, reads it aloud and shows
* why it needs you.
*/
export default function BotMessageLine({ message_id, text, needs_you, at }: Props) {
const can_speak = Boolean(message_id && text.trim());
export default function BotMessageLine({ message, bot_name, at }: Props) {
const { onReact } = useContext(BotMessageActionsContext);
const can_speak = Boolean(message.id && message.text?.trim());
return (
<Stack className="gap-1.5! items-start max-w-full">
<Stack className="w-full min-w-0 gap-1.5! items-start max-w-full">
{message.reply_to ? (
<MessageReplyQuote reply_to={message.reply_to} bot_name={bot_name} />
) : null}
<Stack className="w-full gap-2.5! items-start break-words">
<Markdown
remarkPlugins={[remarkGfm]}
components={MessageMarkdownComponents}
>
{linkComputerRefs({ text })}
{linkComputerRefs({ text: message.text || "" })}
</Markdown>
</Stack>
{needs_you || can_speak || at ? (
<Row className="gap-2! items-center">
{needs_you ? <NeedsYouChip /> : null}
{at ? <GroupTimestamp at={at} /> : null}
{can_speak ? (
<SpeakMessageButton message_id={message_id!} />
) : null}
</Row>
{message.id ? (
<MessageReactions
message_id={message.id}
reactions={message.reactions}
onReact={onReact}
/>
) : null}
<Row className="gap-2! items-center">
{message.needs_you ? <NeedsYouChip /> : null}
{at ? <GroupTimestamp at={at} /> : null}
{can_speak ? <SpeakMessageButton message_id={Number(message.id)} /> : null}
<MessageActions message={message} />
</Row>
</Stack>
);
}
@@ -0,0 +1,49 @@
import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import Span from "@moduletrace/twui/layout/Span";
import { Reply, X } from "lucide-react";
import type { BotMessageWithAttachments } from "@/src/types";
import messagePreview from "@/src/utils/message-preview";
type Props = {
message: BotMessageWithAttachments;
bot_name: string;
onCancel: () => void;
};
/**
* The message the next one will quote, above the composer, until it is
* sent or dropped.
*/
export default function ComposerReplyPreview({ message, bot_name, onCancel }: Props) {
const preview = messagePreview({ text: message.text || "" }) || "A file";
return (
<Row className="w-full max-w-container mx-auto gap-2! flex-nowrap items-center px-3 md:px-6 pt-4 pb-0">
<Row className="min-w-0 grow gap-2! flex-nowrap items-center overflow-hidden rounded-default-lg border border-solid border-hairline bg-surface px-3 py-1.5">
<Reply size={14} aria-hidden className="shrink-0 text-ink-secondary" />
<Stack className="min-w-0 grow gap-0.5!">
<Span className="block w-full truncate text-[11px] font-bold leading-4 text-ink">
Replying to {message.author == "user" ? "yourself" : bot_name}
</Span>
<Span className="block w-full truncate text-xs leading-4 text-ink-secondary">
{preview}
</Span>
</Stack>
</Row>
<Button
title="Cancel reply"
aria-label="Cancel reply"
type="button"
variant="ghost"
color="gray"
size="small"
className="shrink-0 p-1!"
onClick={onCancel}
>
<X size={14} aria-hidden />
</Button>
</Row>
);
}
@@ -1,4 +1,4 @@
import { useRef, useState, type KeyboardEvent } from "react";
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import Textarea from "@moduletrace/twui/form/Textarea";
@@ -18,6 +18,7 @@ import useVoiceRecorder from "../(hooks)/use-voice-recorder";
import useOnlineStatus from "@/src/hooks/use-online-status";
import ComposerAttachButton from "./composer-attach-button";
import ComposerAttachments from "./composer-attachments";
import ComposerReplyPreview from "./composer-reply-preview";
import ComposerSendButton from "./composer-send-button";
import ComposerTools from "./composer-tools";
import ComposerVoiceButton from "./composer-voice-button";
@@ -36,6 +37,11 @@ type Props = {
* The dictation mic is disabled while transcription has no model.
*/
voice?: BotVoicePayload | null;
/**
* The message the next one quotes, if the user picked one.
*/
reply_to?: BotMessageWithAttachments | null;
onCancelReply?: () => void;
onSent: (params: { message: BotMessageWithAttachments }) => void;
};
@@ -48,7 +54,15 @@ type Props = {
* are listed under the input. The mic dictates: what's said is written
* into the input at the cursor, to edit before sending.
*/
export default function Composer({ bot, workspace_id, extensions, voice, onSent }: Props) {
export default function Composer({
bot,
workspace_id,
extensions,
voice,
reply_to,
onCancelReply,
onSent,
}: Props) {
const { text, setText } = useComposerDraft({ bot_id: bot.id, workspace_id });
const [sending, setSending] = useState(false);
const [error, setError] = useState<string>();
@@ -58,6 +72,13 @@ export default function Composer({ bot, workspace_id, extensions, voice, onSent
useAutoGrowTextarea({ ref: input_ref, value: text });
/**
* Picking a message to reply to puts the cursor back in the input.
*/
useEffect(() => {
if (reply_to) input_ref.current?.focus();
}, [reply_to]);
const recorder = useVoiceRecorder({
onRecorded: async ({ audio }) => {
if (!bot.id) return;
@@ -115,6 +136,7 @@ export default function Composer({ bot, workspace_id, extensions, voice, onSent
workspace_id,
text,
media_ids: files.media_ids,
reply_to_message_id: reply_to?.id || null,
});
setSending(false);
@@ -156,7 +178,14 @@ export default function Composer({ bot, workspace_id, extensions, voice, onSent
onRemove={files.remove}
/>
<Stack className="shrink-0 gap-0! items-stretch border-0 border-t border-solid border-hairline">
<Row className="w-full max-w-container mx-auto min-h-[88px] py-7 gap-3! md:gap-4! flex-nowrap items-end px-3 md:px-6 cursor-text"
{reply_to && onCancelReply ? (
<ComposerReplyPreview
message={reply_to}
bot_name={bot.name || "the bot"}
onCancel={onCancelReply}
/>
) : null}
<Row className={`w-full max-w-container mx-auto min-h-[88px] ${reply_to ? "pt-3 pb-7" : "py-7"} gap-3! md:gap-4! flex-nowrap items-end px-3 md:px-6 cursor-text`}
onMouseDown={(e) => {
const target = e.target as HTMLElement;
@@ -0,0 +1,29 @@
import Button from "@moduletrace/twui/layout/Button";
import type { ReactNode } from "react";
type Props = {
title: string;
aria_label: string;
onClick: () => void;
children: ReactNode;
};
/**
* A small ghost icon button under a message, for its reply and reaction
* actions. Quiet until hovered, and readable in both themes.
*/
export default function MessageActionButton({ title, aria_label, onClick, children }: Props) {
return (
<Button
title={title}
aria-label={aria_label}
type="button"
variant="ghost"
color="text"
onClick={onClick}
className="shrink-0 p-1! -mx-1 text-ink-secondary dark:text-ink-secondary hover:text-ink rounded-default-xs focus-visible:ring-2 focus-visible:ring-ink"
>
{children}
</Button>
);
}
@@ -0,0 +1,33 @@
import { useContext } from "react";
import Row from "@moduletrace/twui/layout/Row";
import { Reply } from "lucide-react";
import type { BotMessageWithAttachments } from "@/src/types";
import { BotMessageActionsContext } from "../(data)/message-actions-context";
import MessageActionButton from "./message-action-button";
import MessageReactionPicker from "./message-reaction-picker";
type Props = {
message: BotMessageWithAttachments;
};
/**
* A message's reply and reaction actions, shown under it.
*/
export default function MessageActions({ message }: Props) {
const { onReply, onReact } = useContext(BotMessageActionsContext);
if (!message.id) return null;
return (
<Row className="gap-1.5! items-center">
<MessageActionButton
title="Reply"
aria_label="Reply to this message"
onClick={() => onReply({ message })}
>
<Reply size={14} aria-hidden />
</MessageActionButton>
<MessageReactionPicker message_id={message.id} onReact={onReact} />
</Row>
);
}
@@ -10,7 +10,7 @@ type Props = {
export default function MessageGroupBlock({ bot, group }: Props) {
if (group.author === "user") {
return <UserMessageGroup group={group} />;
return <UserMessageGroup bot={bot} group={group} />;
}
return <BotMessageGroup bot={bot} group={group} />;
@@ -0,0 +1,33 @@
import Button from "@moduletrace/twui/layout/Button";
import Span from "@moduletrace/twui/layout/Span";
import type { BotMessageReaction } from "@/src/types";
type Props = BotMessageReaction & {
onToggle: () => void;
};
/**
* One emoji reaction under a message: its count, highlighted when the
* user gave it. Clicking takes it back or adds it.
*/
export default function MessageReactionChip({ emoji, count, mine, onToggle }: Props) {
return (
<Button
title={mine ? "Remove your reaction" : "Add this reaction"}
aria-label={mine ? "Remove your reaction" : "Add this reaction"}
aria-pressed={mine}
type="button"
variant="ghost"
color="text"
onClick={onToggle}
className={`shrink-0 gap-1! px-2! py-0.5! rounded-default-lg border border-solid text-xs leading-4 ${
mine
? "border-hairline-strong bg-sunken text-ink"
: "border-hairline bg-surface text-ink-secondary hover:text-ink"
} focus-visible:ring-2 focus-visible:ring-ink`}
>
<Span className="text-sm leading-4">{emoji}</Span>
{count > 1 ? <Span className="tabular-nums">{count}</Span> : null}
</Button>
);
}
@@ -0,0 +1,27 @@
import Button from "@moduletrace/twui/layout/Button";
import Span from "@moduletrace/twui/layout/Span";
type Props = {
title: string;
value: string;
onPick: () => void;
};
/**
* One emoji in the reaction picker.
*/
export default function MessageReactionOption({ title, value, onPick }: Props) {
return (
<Button
title={title}
aria-label={`React with ${title}`}
type="button"
variant="ghost"
color="text"
onClick={onPick}
className="shrink-0 px-2! py-1! rounded-default-xs hover:bg-sunken focus-visible:ring-2 focus-visible:ring-ink"
>
<Span className="text-base leading-5">{value}</Span>
</Button>
);
}
@@ -0,0 +1,52 @@
import { useState } from "react";
import Dropdown from "@moduletrace/twui/elements/Dropdown";
import Row from "@moduletrace/twui/layout/Row";
import { SmilePlus } from "lucide-react";
import { BotMessageReactions, type BotMessageReactionEmoji } from "@/src/dict/bot-dict";
import MessageActionButton from "./message-action-button";
import MessageReactionOption from "./message-reaction-option";
type Props = {
message_id: number;
onReact: (params: { message_id: number; emoji: BotMessageReactionEmoji }) => void;
};
/**
* Opens the fixed set of reactions and adds one to the message. The
* picker closes as soon as one is picked.
*/
export default function MessageReactionPicker({ message_id, onReact }: Props) {
const [open, setOpen] = useState(false);
return (
<Dropdown
position="top-left"
externalOpen={open}
externalSetOpen={setOpen}
contentWrapperProps={{ className: "pb-1" }}
target={
<MessageActionButton
title="Add a reaction"
aria_label="Add a reaction"
onClick={() => {}}
>
<SmilePlus size={14} aria-hidden />
</MessageActionButton>
}
>
<Row className="gap-0! flex-nowrap items-center rounded-default-lg border border-solid border-hairline bg-surface p-1 shadow-popover">
{BotMessageReactions.map((reaction) => (
<MessageReactionOption
key={reaction.value}
title={reaction.title}
value={reaction.value}
onPick={() => {
setOpen(false);
onReact({ message_id, emoji: reaction.value });
}}
/>
))}
</Row>
</Dropdown>
);
}
@@ -0,0 +1,31 @@
import Row from "@moduletrace/twui/layout/Row";
import type { BotMessageReactionEmoji } from "@/src/dict/bot-dict";
import type { BotMessageReaction } from "@/src/types";
import MessageReactionChip from "./message-reaction-chip";
type Props = {
message_id: number;
reactions?: BotMessageReaction[];
onReact: (params: { message_id: number; emoji: BotMessageReactionEmoji }) => void;
};
/**
* The reactions a message has, one chip per emoji, each toggleable.
*/
export default function MessageReactions({ message_id, reactions, onReact }: Props) {
if (!reactions?.length) return null;
return (
<Row className="gap-1.5! flex-wrap items-center">
{reactions.map((reaction) => (
<MessageReactionChip
key={reaction.emoji}
emoji={reaction.emoji}
count={reaction.count}
mine={reaction.mine}
onToggle={() => onReact({ message_id, emoji: reaction.emoji })}
/>
))}
</Row>
);
}
@@ -0,0 +1,41 @@
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import Span from "@moduletrace/twui/layout/Span";
import { Reply } from "lucide-react";
import type { BotMessageReply } from "@/src/types";
import messagePreview from "@/src/utils/message-preview";
type Props = {
reply_to: BotMessageReply;
bot_name: string;
/**
* Right-align it, for a user message.
*/
align_end?: boolean;
};
/**
* The message a reply answers: who wrote it and a one-line preview. A
* file-only message says so instead of showing an empty line.
*/
export default function MessageReplyQuote({ reply_to, bot_name, align_end }: Props) {
const preview = messagePreview({ text: reply_to.text }) || "A file";
return (
<Stack
className={`w-full max-w-[80%] min-w-0 gap-0.5! overflow-hidden border-0 border-l-2 border-solid border-hairline-strong pl-2.5 ${
align_end ? "items-end text-right" : "items-start"
}`}
>
<Row className="max-w-full min-w-0 gap-1! items-center">
<Reply size={11} aria-hidden className="shrink-0 text-ink-secondary" />
<Span className="min-w-0 truncate text-[11px] font-bold leading-4 text-ink">
{reply_to.author == "user" ? "You" : bot_name}
</Span>
</Row>
<Span className="block w-full max-w-full break-words text-xs leading-4 text-ink-secondary line-clamp-2">
{preview}
</Span>
</Stack>
);
}
@@ -1,17 +1,23 @@
import Stack from "@moduletrace/twui/layout/Stack";
import type { BotWithLastMessage } from "@/src/types";
import type { MessageGroup } from "../(utils)/group-messages";
import UserMessage from "./user-message";
import GroupTimestamp from "./group-timestamp";
type Props = {
bot: BotWithLastMessage;
group: MessageGroup;
};
/**
* Right-aligned ink bubbles (each message's files above it), one
* timestamp under the group.
*/
export default function UserMessageGroup({ group }: { group: MessageGroup }) {
export default function UserMessageGroup({ bot, group }: Props) {
return (
<Stack className="w-full gap-1! items-end">
{group.messages.map((message) => (
<UserMessage key={message.id} {...message} />
<UserMessage key={message.id} message={message} bot_name={bot.name || "the bot"} />
))}
<GroupTimestamp at={group.at} />
</Stack>
@@ -1,16 +1,44 @@
import { useContext } from "react";
import Stack from "@moduletrace/twui/layout/Stack";
import type { BotMessageWithAttachments } from "@/src/types";
import { BotMessageActionsContext } from "../(data)/message-actions-context";
import MessageActions from "./message-actions";
import MessageAttachments from "./message-attachments";
import MessageReactions from "./message-reactions";
import MessageReplyQuote from "./message-reply-quote";
import UserBubble from "./user-bubble";
type Props = {
message: BotMessageWithAttachments;
bot_name: string;
};
/**
* One user message: its files, then its text (a file-only message has no
* bubble).
* One user message: the quoted message it answers, its files, then its
* text (a file-only message has no bubble), its reactions and actions.
*/
export default function UserMessage({ text, attachments }: BotMessageWithAttachments) {
export default function UserMessage({ message, bot_name }: Props) {
const { onReact } = useContext(BotMessageActionsContext);
return (
<>
<MessageAttachments attachments={attachments} />
{text ? <UserBubble text={text} /> : null}
</>
<Stack className="w-full min-w-0 max-w-full items-end gap-1.5!">
{message.reply_to ? (
<MessageReplyQuote
reply_to={message.reply_to}
bot_name={bot_name}
align_end
/>
) : null}
<MessageAttachments attachments={message.attachments} />
{message.text ? <UserBubble text={message.text} /> : null}
{message.id ? (
<MessageReactions
message_id={message.id}
reactions={message.reactions}
onReact={onReact}
/>
) : null}
<MessageActions message={message} />
</Stack>
);
}
@@ -1,12 +1,18 @@
import { useContext, useState } from "react";
import { useContext, useEffect, useState } from "react";
import Stack from "@moduletrace/twui/layout/Stack";
import type { BotMessageWithAttachments } from "@/src/types";
import type { BotMessageReactionEmoji } from "@/src/dict/bot-dict";
import type {
BotMessageReactionData,
BotMessageWithAttachments,
} from "@/src/types";
import { AppContext } from "@/src/pages/__root";
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import useLiveBots from "@/src/hooks/use-live-bots";
import useServerEvent from "@/src/hooks/use-server-event";
import { BotComputersContext } from "../(data)/bot-computers-context";
import { BotMessageActionsContext } from "../(data)/message-actions-context";
import { SpeakMessageContext } from "../(data)/speak-message-context";
import reactToBotMessage from "../(functions)/react-to-bot-message";
import useBotComputers from "../(hooks)/use-bot-computers";
import useTaskProgress from "../(hooks)/use-task-progress";
import useReplyProgress from "../(hooks)/use-reply-progress";
@@ -37,6 +43,14 @@ export default function Conversation() {
);
const [session_id, setSessionId] = useState<number | null>(pageProps?.bot_session?.id || null);
const [has_other_unread, setHasOtherUnread] = useState(false);
const [reply_to, setReplyTo] = useState<BotMessageWithAttachments | null>(null);
/**
* A quote picked in one session doesn't follow the thread to another.
*/
useEffect(() => {
setReplyTo(null);
}, [session_id]);
const bots = useLiveBots({
bots: pageProps?.bots || [],
@@ -113,12 +127,44 @@ export default function Conversation() {
},
});
/**
* A reaction toggled here or in another tab: the pushed list replaces
* the message's own.
*/
const applyReactions = ({ message_id, reactions }: BotMessageReactionData) => {
setMessages((prev) =>
prev.map((message) =>
Number(message.id) == message_id ? { ...message, reactions } : message,
),
);
};
useServerEvent({ event: "server:bot-reaction", onData: applyReactions });
const reactToMessage = async ({
message_id,
emoji,
}: {
message_id: number;
emoji: BotMessageReactionEmoji;
}) => {
const res = await reactToBotMessage({ message_id, emoji });
if (res.success && res.singleRes) applyReactions(res.singleRes);
};
if (!bot?.id || !live_bot) return null;
return (
<BotComputersContext.Provider value={bot_computers}>
<SpeakMessageContext.Provider value={speak}>
<Stack className="grow min-w-0 h-full gap-0! items-stretch">
<BotMessageActionsContext.Provider
value={{
onReply: ({ message }) => setReplyTo(message),
onReact: reactToMessage,
}}
>
<Stack className="grow min-w-0 h-full gap-0! items-stretch">
<ConversationHeader
bot={live_bot}
workspace={workspace}
@@ -137,7 +183,11 @@ export default function Conversation() {
workspace_id={workspace_id}
extensions={pageProps?.bot_extensions}
voice={pageProps?.bot_voice}
reply_to={reply_to}
onCancelReply={() => setReplyTo(null)}
onSent={({ message }) => {
setReplyTo(null);
if (startsNewSession(message) && message.session_id != session_id) {
setSessionId(Number(message.session_id));
setMessages([message]);
@@ -151,7 +201,8 @@ export default function Conversation() {
);
}}
/>
</Stack>
</Stack>
</BotMessageActionsContext.Provider>
<ComputerViewerModal bot={live_bot} />
</SpeakMessageContext.Provider>
</BotComputersContext.Provider>