Unibox
ReplySuggestions
The band between a Unibox thread and its composer: ask for a few replies, watch them arrive, pick one.
Guidelines#
A pick fills the composer and is never sent for you.
Use it for suggesting the next message in a conversation, aimed at the workspace's deal stages.
What you provide
- State:
suggestions,isLoading,errorandhasGenerated.- Each suggestion is
{ id, label, intent, deal_target, rationale, text }. intentis qualify, value, meeting, proposal, pricing, objection, close or nurture, and picks the icon.
- Each suggestion is
usedProspectCardanddealTypeCountset the source note, such as "From their contact card and your 2 deal types".- Callbacks:
onGenerate,onDismissandonSelect(text). - Optional:
composerValue(marks the card in play),disabledandclassName.
Anatomy
- The trigger pill is
rounded-full border px-3 py-1.5 text-xs. It is tinted primary before a run ("Suggest replies"), neutral after it ("Suggest again"), and reads "Writing options…" while loading. - Cards sit in 1, 2 (
sm) or 3 (lg) columns. Each isrounded-xl border-border/60 bg-background/70 p-3 shadow-sm, with an intent icon, atext-xs font-semiboldlabel, the deal target, three clamped lines and an italic rationale. - Hover gives
border-primary/40. The card in play getsborder-primary/60 bg-primary/[0.07]and a check. - While loading, three pulsing
h-24skeletons stand in.
Motion: panels swap on FADE_SWAP (0.16s). Cards rise 8px on SPRING_ENTER, staggered 0.05s, except under reduced motion.
Don't auto-send a suggestion or hide its source. The last word stays with the person whose name is on the account.
Facts#
| Group | Unibox |
|---|---|
| Product | Max |
| Global | window.DigitalCrew.ReplySuggestions |
| Source | max-agent: src/features/unibox/ui/components/reply-suggestions.tsx |
| Import in the app | import { ReplySuggestions } from "@/features/unibox/ui/components/reply-suggestions"; |
API#
TypeScript declarations emitted from src/features/unibox/ui/components/reply-suggestions.tsx.
interface ReplySuggestionsProps {
suggestions: ReplySuggestion[];
isLoading: boolean;
error: string | null;
/** A run finished for this conversation (even if it came back empty). */
hasGenerated: boolean;
/** The contact card fed the last run, not just the thread. */
usedProspectCard: boolean;
/** How many deal types the options could aim at. */
dealTypeCount: number;
onGenerate: () => void;
onDismiss: () => void;
/** Put this text in the composer, ready to edit and send. */
onSelect: (text: string) => void;
/**
* What the composer holds right now. A card matching it is marked as the
* one in play — and stops matching the moment the user edits a word, which
* is exactly when it stops being that suggestion.
*/
composerValue?: string;
/** Sending locks the panel: the reply is already on its way. */
disabled?: boolean;
className?: string;
}
export declare function ReplySuggestions({ suggestions, isLoading, error, hasGenerated, usedProspectCard, dealTypeCount, onGenerate, onDismiss, onSelect, composerValue, disabled, className, }: ReplySuggestionsProps): JSX.Element;Example#
The code of the preview above: plain React.createElement against window.DigitalCrew, with realistic data.
<div id="root" class="p-6"></div>
<script>
(function () {
var h = React.createElement, DC = window.DigitalCrew, I = DC.Icons;
var SUGGESTIONS = [
{ id: "s1", label: "Book the call", intent: "meeting", deal_target: "New business → Meeting booked",
rationale: "She named a day; lock it before the week fills.",
text: "Great, Thursday at 10:00 CET it is. I'll send the invite to you and Anouk now, with a 20-minute agenda: your current close process, where the two days go, and what a pilot would look like." },
{ id: "s2", label: "Share the ROI case", intent: "value", deal_target: "New business → Qualified",
rationale: "A CFO buys on numbers; give her one to bring.",
text: "Before Thursday, here is how Brightline Logistics cut month-end close from six days to three with us. One page, numbers included. Happy to walk through what the same model would mean for Northwind." },
{ id: "s3", label: "Qualify the buying group", intent: "qualify", deal_target: "New business → Discovery",
rationale: "A Q1 review means the budget is being set now.",
text: "Thanks Claire. So I bring the right material: is the Q1 tooling review owned by finance alone, or will IT and procurement join Thursday's call too?" }
];
function App() {
var sg = React.useState(SUGGESTIONS), list = sg[0], setList = sg[1];
var ld = React.useState(false), loading = ld[0], setLoading = ld[1];
var gen = React.useState(true), generated = gen[0], setGenerated = gen[1];
var cv = React.useState(SUGGESTIONS[0].text), value = cv[0], setValue = cv[1];
return h("div", { className: "mx-auto flex max-w-[900px] flex-col gap-3" },
h(DC.ReplySuggestions, {
suggestions: list, isLoading: loading, error: null, hasGenerated: generated,
usedProspectCard: true, dealTypeCount: 2, composerValue: value,
onGenerate: function () {
setLoading(true); setList([]);
setTimeout(function () { setLoading(false); setGenerated(true); setList(SUGGESTIONS); }, 1600);
},
onDismiss: function () { setList([]); setGenerated(false); },
onSelect: function (text) { setValue(text); }
}),
h(DC.MessageComposer, {
value: value, onChange: setValue, attachments: [], isSending: false,
onAddFiles: function () {}, onRemoveAttachment: function () {}, onSubmit: function () {}
}));
}
ReactDOM.createRoot(document.getElementById("root")).render(h(DC.TooltipProvider, { delayDuration: 80 }, h(App)));
})();
</script>