| 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> |
| ); |
| } |
|
|