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