File size: 5,384 Bytes
1187b53 f71b691 1187b53 f71b691 1187b53 f71b691 1187b53 c2a37bc 1187b53 f71b691 1187b53 be78213 1187b53 f71b691 1187b53 f71b691 1187b53 f71b691 1187b53 c2a37bc 1187b53 c2a37bc 1187b53 c2a37bc 1187b53 c2a37bc 1187b53 c2a37bc 1187b53 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 | import React from "react";
export type ChatTurnBase = {
role: "user" | "assistant";
created_at?: string;
};
type ChatTranscriptProps<TTurn extends ChatTurnBase> = {
turns: TTurn[];
busy: boolean;
bottomRef: React.RefObject<HTMLDivElement>;
getTimeLabel?: (turn: TTurn) => string;
renderMessage: (turn: TTurn, isUser: boolean) => React.ReactNode;
busyLabel?: string;
ariaLabel?: string;
showToolbar?: boolean;
initialVisibleCount?: number;
};
export default function ChatTranscript<TTurn extends ChatTurnBase>(props: ChatTranscriptProps<TTurn>) {
const {
turns,
busy,
bottomRef,
getTimeLabel,
renderMessage,
busyLabel = "Thinking…",
ariaLabel = "Conversation transcript",
showToolbar = true,
initialVisibleCount = 120,
} = props;
const minVisible = Math.max(24, Number(initialVisibleCount || 120));
const [visibleCount, setVisibleCount] = React.useState<number>(minVisible);
React.useEffect(() => {
setVisibleCount((prev) => {
if (prev < minVisible) return minVisible;
if (prev > turns.length) return Math.max(minVisible, turns.length);
return prev;
});
}, [turns.length, minVisible]);
const hiddenCount = Math.max(0, turns.length - visibleCount);
const visibleTurns = hiddenCount > 0 ? turns.slice(-visibleCount) : turns;
return (
<div
className="h-[54vh] overflow-y-auto rounded-[16px] border border-slate-200 bg-slate-50/70 p-2.5 pr-1.5 shadow-inner sm:h-[58vh] sm:p-3 sm:pr-2 lg:h-[60vh] xl:h-[62vh]"
role="log"
aria-live="polite"
aria-relevant="additions text"
aria-label={ariaLabel}
>
{showToolbar ? (
<div className="sticky top-0 z-10 mb-2 flex items-center justify-between rounded-xl border border-slate-200 bg-white/95 px-2.5 py-1.5 text-[11px] text-slate-600 backdrop-blur">
<div className="flex items-center gap-2">
<span>{turns.length} messages</span>
{hiddenCount > 0 ? (
<button
type="button"
className="mt-focus rounded-full bg-slate-100 px-2.5 py-1 font-semibold text-slate-700 hover:bg-slate-200"
onClick={() => setVisibleCount((prev) => Math.min(turns.length, prev + minVisible))}
>
Show {Math.min(hiddenCount, minVisible)} older
</button>
) : null}
</div>
<button
type="button"
className="mt-focus rounded-full bg-slate-100 px-2.5 py-1 font-semibold text-slate-700 hover:bg-slate-200"
onClick={() => bottomRef.current?.scrollIntoView({ behavior: "smooth" })}
>
Jump to latest
</button>
</div>
) : null}
{visibleTurns.map((turn, index) => {
const isUser = turn.role === "user";
const timeText = getTimeLabel ? getTimeLabel(turn) : "";
const globalIndex = hiddenCount + index;
return (
<div key={globalIndex} className={`mb-4 sm:mb-5 ${isUser ? "text-right" : "text-left"}`} role="article" aria-label={`${isUser ? "You" : "Assistant"} message`}>
<div
className={`mb-1.5 flex items-center gap-2 text-[11px] font-medium tracking-wide ${isUser ? "justify-end" : "justify-start"} text-slate-500`}
>
<span
className={
"rounded-full px-2.5 py-0.5 ring-1 " +
(isUser
? "text-white"
: "mt-text-primary")
}
style={
isUser
? { backgroundColor: "var(--mt-primary)", boxShadow: "inset 0 0 0 1px var(--mt-primary)" }
: { backgroundColor: "var(--mt-primary-soft)", boxShadow: "inset 0 0 0 1px var(--mt-primary-border)" }
}
>
{isUser ? "You" : "Assistant"}
</span>
{timeText ? <span>{timeText}</span> : null}
</div>
<div
className={
"inline-block max-w-[96%] overflow-x-auto rounded-[16px] px-3.5 py-3 leading-relaxed sm:max-w-[92%] sm:px-4 sm:py-3.5 " +
(isUser
? "text-white shadow-sm"
: "bg-white text-slate-900 shadow-sm ring-1 ring-slate-300")
}
style={isUser ? { backgroundColor: "var(--mt-primary)", boxShadow: "inset 0 0 0 1px var(--mt-primary)" } : undefined}
>
{renderMessage(turn, isUser)}
</div>
</div>
);
})}
{busy ? (
<div className="mb-4 text-left sm:mb-5" aria-live="polite" role="status" aria-label="Assistant is responding">
<div className="mb-1.5 flex items-center gap-2 text-[11px] font-medium tracking-wide text-slate-500">
<span
className="mt-text-primary rounded-full px-2.5 py-0.5"
style={{ backgroundColor: "var(--mt-primary-soft)", boxShadow: "inset 0 0 0 1px var(--mt-primary-border)" }}
>
Assistant
</span>
</div>
<div className="inline-block rounded-[16px] bg-white px-3.5 py-3 text-slate-900 shadow-sm ring-1 ring-slate-300 sm:px-4 sm:py-3.5">
{busyLabel}
</div>
</div>
) : null}
<div ref={bottomRef} />
</div>
);
}
|