Pete Dunn
Lock final workspace UI and guided flows
c2a37bc
Raw
History Blame Contribute Delete
5.38 kB
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>
);
}