forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathChatTranscript.tsx
More file actions
233 lines (219 loc) · 7.75 KB
/
Copy pathChatTranscript.tsx
File metadata and controls
233 lines (219 loc) · 7.75 KB
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
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
'use client';
import { useRef, useEffect } from 'react';
import { motion } from 'framer-motion';
import Image from '@tschk/moonshine-next/image';
import { Brain } from 'lucide-react';
import type { ClientMessage } from '@/types/conversation';
import { cn } from '@/lib/utils';
import { parseChatEvidenceFromRecord } from '@/lib/chatEvidence';
import {
nearestVerticalScroller,
scrollEdgesOf,
shouldFollowLiveEdge,
} from '@/lib/scrollEdges';
import { ChatMarkdown } from './ChatMarkdown';
import { ChatEvidenceCard } from './ChatEvidenceCard';
/**
* The chat transcript, with no chrome of its own.
*
* Desktop renders the transcript as a bare column inside the Home stage
* (`ChatMessagesView`, `contentColumnWidth: 760`) rather than as a card, so it
* can sit directly on whichever surface hosts it.
*/
// Format timestamp for display (e.g., "12:32 AM")
function formatMessageTime(isoDate: string): string {
return new Date(isoDate).toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true,
});
}
function OmiAvatar() {
return (
<div className="flex-shrink-0 w-10 h-10">
<Image src="/logo.png" alt="Omi" width={40} height={40} className="rounded-full" />
</div>
);
}
function BouncingDots({ size = 'w-2 h-2' }: { size?: string }) {
return (
<div className="flex gap-1.5">
{[0, 150, 300].map((delay) => (
<div
key={delay}
className={cn(size, 'bg-text-quaternary rounded-full animate-bounce')}
style={{ animationDelay: `${delay}ms` }}
/>
))}
</div>
);
}
interface ChatTranscriptProps {
messages: ClientMessage[];
isLoading: boolean;
isStreaming: boolean;
streamingText: string;
currentThinking: string;
autoScroll?: boolean;
}
export function ChatTranscript({
messages,
isLoading,
isStreaming,
streamingText,
currentThinking,
autoScroll = true,
}: ChatTranscriptProps) {
const messagesEndRef = useRef<HTMLDivElement>(null);
const placedExchangeRef = useRef(false);
// Follow the live edge only while the reader is already there. Home's
// history sits in the same overflow ancestor, so a blanket scrollIntoView
// here is what yanks an upward history gesture back down.
useEffect(() => {
if (!autoScroll) {
placedExchangeRef.current = false;
return;
}
const end = messagesEndRef.current;
if (!end) return;
const scroller = nearestVerticalScroller(end.parentElement);
const pinnedToBottom = scroller
? scrollEdgesOf({
scrollTop: scroller.scrollTop,
scrollHeight: scroller.scrollHeight,
clientHeight: scroller.clientHeight,
}).atBottom
: true;
const force = !placedExchangeRef.current;
if (!shouldFollowLiveEdge({ pinnedToBottom, force })) return;
placedExchangeRef.current = true;
end.scrollIntoView({ behavior: force ? 'auto' : 'smooth' });
}, [autoScroll, messages, streamingText, isStreaming, currentThinking]);
if (isLoading && messages.length === 0) {
return (
<div className="flex justify-center py-12">
<BouncingDots size="w-2.5 h-2.5" />
</div>
);
}
return (
<div className="space-y-6">
{messages.map((message) => (
<motion.div
key={message.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
className={cn(
'flex',
message.sender === 'human' ? 'justify-end' : 'justify-start',
)}
>
{message.sender === 'ai' ? (
/* AI message with Omi icon */
<div className="flex gap-3 max-w-[85%] sm:max-w-[75%]">
<OmiAvatar />
<div className="flex-1 min-w-0">
<div className="rounded-2xl px-5 py-3 bg-bg-secondary border border-stroke text-text-primary">
{/* Show attached files if any */}
{message.files && message.files.length > 0 && (
<div className="flex flex-wrap gap-2 mb-2">
{message.files.map((file) => (
<div
key={file.id}
className="text-xs px-2 py-1 rounded bg-bg-tertiary"
>
{file.name}
</div>
))}
</div>
)}
<ChatMarkdown>{message.text}</ChatMarkdown>
</div>
<ChatEvidenceCard envelope={parseChatEvidenceFromRecord(message)} />
<span className="text-xs text-text-quaternary mt-1 block">
{formatMessageTime(message.created_at)}
</span>
</div>
</div>
) : (
/* Human message */
<div className="max-w-[85%] sm:max-w-[75%]">
{/* Desktop's user bubble is a neutral raised surface
(OmiColors.chatUserBubble #2C2C33), not a coloured fill. */}
<div className="rounded-2xl px-5 py-3 bg-[#2C2C33] text-text-primary">
{/* Show attached files if any */}
{message.files && message.files.length > 0 && (
<div className="flex flex-wrap gap-2 mb-2">
{message.files.map((file) => (
<div
key={file.id}
className="text-xs px-2 py-1 rounded bg-white/10"
>
{file.name}
</div>
))}
</div>
)}
<p className="text-sm whitespace-pre-wrap leading-relaxed">
{message.text}
</p>
</div>
<span className="text-xs text-text-quaternary mt-1 block text-right">
{formatMessageTime(message.created_at)}
</span>
</div>
)}
</motion.div>
))}
{/* Thinking indicator */}
{currentThinking && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="flex justify-start"
>
<div className="flex gap-3 max-w-[85%] sm:max-w-[75%]">
<OmiAvatar />
<div className="rounded-2xl px-5 py-3 bg-bg-secondary/50 border border-stroke">
<div className="flex items-center gap-2 text-text-secondary mb-2">
<Brain className="w-4 h-4" />
<span className="text-sm font-medium">Thinking...</span>
</div>
<p className="text-xs text-text-quaternary whitespace-pre-wrap line-clamp-4">
{currentThinking}
</p>
</div>
</div>
</motion.div>
)}
{/* Streaming text (AI response in progress) */}
{streamingText && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="flex justify-start"
>
<div className="flex gap-3 max-w-[85%] sm:max-w-[75%]">
<OmiAvatar />
<div className="rounded-2xl px-5 py-3 bg-bg-secondary border border-stroke text-text-primary">
<ChatMarkdown isStreaming>{streamingText}</ChatMarkdown>
</div>
</div>
</motion.div>
)}
{/* Loading indicator (before streaming starts) */}
{isStreaming && !streamingText && !currentThinking && (
<div className="flex justify-start">
<div className="flex gap-3">
<OmiAvatar />
<div className="bg-bg-secondary border border-stroke rounded-2xl px-5 py-4">
<BouncingDots />
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
);
}