11<script setup lang="ts">
2- import { ref , nextTick , onBeforeUnmount , computed } from " vue" ;
2+ import { ref , nextTick , onBeforeUnmount , watch } from " vue" ;
33import { marked } from " marked" ;
44import type { DemoQuestion } from " @/data/demos" ;
55import QuestionButton from " @/components/QuestionButton.vue" ;
@@ -16,13 +16,27 @@ const props = defineProps<{
1616
1717const messages = ref <ChatMessage []>([]);
1818const isTyping = ref (false );
19- const usedQuestionIds = ref (new Set <string >());
2019const chatContainer = ref <HTMLDivElement >();
20+ const currentQuestions = ref <DemoQuestion []>([]);
21+ const hasStarted = ref (false );
22+ const activeQuestion = ref <DemoQuestion | null >(null );
2123let typeTimer: ReturnType <typeof setInterval > | null = null ;
2224
23- const availableQuestions = computed (() =>
24- props .questions .filter ((q ) => ! usedQuestionIds .value .has (q .id ))
25- );
25+ watch (() => props .questions , () => {
26+ resetChat ();
27+ }, { immediate: true });
28+
29+ function resetChat() {
30+ messages .value = [];
31+ isTyping .value = false ;
32+ hasStarted .value = false ;
33+ activeQuestion .value = null ;
34+ currentQuestions .value = [... props .questions ];
35+ if (typeTimer ) {
36+ clearInterval (typeTimer );
37+ typeTimer = null ;
38+ }
39+ }
2640
2741function scrollToBottom() {
2842 nextTick (() => {
@@ -39,7 +53,10 @@ function renderMarkdown(text: string): string {
3953function askQuestion(question : DemoQuestion ) {
4054 if (isTyping .value ) return ;
4155
42- usedQuestionIds .value .add (question .id );
56+ hasStarted .value = true ;
57+ activeQuestion .value = question ;
58+ // Hide current questions immediately
59+ currentQuestions .value = [];
4360
4461 messages .value .push ({
4562 role: " user" ,
@@ -48,36 +65,57 @@ function askQuestion(question: DemoQuestion) {
4865
4966 scrollToBottom ();
5067
51- const assistantMsg : ChatMessage = {
68+ messages . value . push ( {
5269 role: " assistant" ,
5370 content: " " ,
5471 isTyping: true ,
55- };
56- messages .value .push (assistantMsg );
72+ });
73+ // Get the reactive proxy reference — mutating this triggers Vue re-renders
74+ const reactiveMsg = messages .value [messages .value .length - 1 ];
5775 isTyping .value = true ;
5876
5977 let charIndex = 0 ;
6078 const answer = question .answer ;
61- const CHAR_DELAY_MS = 25 ; // slightly slower typing for realistic effect
79+ const CHAR_DELAY_MS = 25 ;
6280
6381 typeTimer = setInterval (() => {
6482 if (charIndex < answer .length ) {
6583 let step = 1 ;
6684 if (answer [charIndex ] === " \n " ) step = 2 ;
67- assistantMsg .content = answer .slice (0 , charIndex + step );
85+ reactiveMsg .content = answer .slice (0 , charIndex + step );
6886 charIndex += step ;
6987 scrollToBottom ();
7088 } else {
71- assistantMsg .isTyping = false ;
72- isTyping .value = false ;
73- if (typeTimer ) {
74- clearInterval (typeTimer );
75- typeTimer = null ;
76- }
89+ finishTyping (reactiveMsg , question );
7790 }
7891 }, CHAR_DELAY_MS );
7992}
8093
94+ function stopTyping() {
95+ if (typeTimer ) {
96+ clearInterval (typeTimer );
97+ typeTimer = null ;
98+ }
99+ const lastMsg = messages .value [messages .value .length - 1 ];
100+ if (lastMsg && lastMsg .isTyping && activeQuestion .value ) {
101+ lastMsg .content = activeQuestion .value .answer ;
102+ finishTyping (lastMsg , activeQuestion .value );
103+ scrollToBottom ();
104+ }
105+ }
106+
107+ function finishTyping(msg : ChatMessage , question : DemoQuestion ) {
108+ msg .isTyping = false ;
109+ isTyping .value = false ;
110+ if (typeTimer ) {
111+ clearInterval (typeTimer );
112+ typeTimer = null ;
113+ }
114+ if (question .followUps && question .followUps .length > 0 ) {
115+ currentQuestions .value = [... question .followUps ];
116+ }
117+ }
118+
81119onBeforeUnmount (() => {
82120 if (typeTimer ) clearInterval (typeTimer );
83121});
@@ -161,27 +199,80 @@ defineExpose({ askQuestion });
161199 </div >
162200 </div >
163201
164- <!-- Question buttons floating at the bottom -->
165- <div class =" border-t border-emerald-100 bg-white/70 backdrop-blur-md px-5 py-4 shadow-[0_-4px_10px_rgba(0,0,0,0.02)]" >
166- <p class =" mb-3 text-xs font-bold tracking-widest uppercase text-emerald-800/60" >
167- Suggested questions
168- </p >
169- <div class =" flex flex-wrap gap-2.5" >
170- <QuestionButton
171- v-for =" q in questions"
172- :key =" q.id"
173- :text =" q.text"
174- :disabled =" isTyping"
175- :used =" usedQuestionIds.has(q.id)"
176- @click =" askQuestion(q)"
177- />
202+ <!-- Action Area (Questions & Controls) floating at the bottom -->
203+ <div class =" border-t border-emerald-100 bg-white/70 backdrop-blur-md px-5 py-4 shadow-[0_-4px_10px_rgba(0,0,0,0.02)] min-h-[96px] flex flex-col justify-center" >
204+ <div class =" flex flex-col sm:flex-row sm:items-center justify-between gap-4" >
205+
206+ <!-- Left: Questions or Status -->
207+ <div class =" flex-1" >
208+ <div v-if =" !hasStarted" >
209+ <p class =" mb-3 text-xs font-bold tracking-widest uppercase text-emerald-800/60" >
210+ Suggested questions
211+ </p >
212+ <div class =" flex flex-wrap gap-2.5" >
213+ <QuestionButton
214+ v-for =" q in currentQuestions"
215+ :key =" q.id"
216+ :text =" q.text"
217+ :disabled =" false"
218+ :used =" false"
219+ @click =" askQuestion(q)"
220+ />
221+ </div >
222+ </div >
223+
224+ <div v-else >
225+ <div v-if =" isTyping" class =" flex items-center gap-2 text-sm font-medium text-emerald-600" >
226+ <span class =" relative flex h-3 w-3" >
227+ <span class =" animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" ></span >
228+ <span class =" relative inline-flex rounded-full h-3 w-3 bg-emerald-500" ></span >
229+ </span >
230+ Model is typing...
231+ </div >
232+
233+ <div v-else-if =" currentQuestions.length > 0" >
234+ <p class =" mb-3 text-xs font-bold tracking-widest uppercase text-emerald-800/60" >
235+ Follow-up questions
236+ </p >
237+ <div class =" flex flex-wrap gap-2.5" >
238+ <QuestionButton
239+ v-for =" q in currentQuestions"
240+ :key =" q.id"
241+ :text =" q.text"
242+ :disabled =" false"
243+ :used =" false"
244+ @click =" askQuestion(q)"
245+ />
246+ </div >
247+ </div >
248+
249+ <div v-else class =" text-sm font-medium text-gray-500" >
250+ Dialogue complete. Return to the gallery or clear chat to try again.
251+ </div >
252+ </div >
253+ </div >
254+
255+ <!-- Right: Stop/Clear Buttons -->
256+ <div v-if =" hasStarted" class =" flex shrink-0 gap-2 items-end justify-end mt-2 sm:mt-0" >
257+ <button
258+ v-if =" isTyping"
259+ @click =" stopTyping"
260+ class =" flex items-center gap-1.5 rounded-2xl border border-red-200 bg-red-50 px-4 py-2.5 text-sm font-bold text-red-600 shadow-sm transition-all hover:bg-red-100 hover:shadow"
261+ >
262+ <svg class =" h-4 w-4" fill =" currentColor" viewBox =" 0 0 20 20" ><rect x =" 5" y =" 5" width =" 10" height =" 10" rx =" 2" /></svg >
263+ Stop
264+ </button >
265+ <button
266+ v-if =" !isTyping"
267+ @click =" resetChat"
268+ class =" flex items-center gap-1.5 rounded-2xl border border-gray-200 bg-white px-4 py-2.5 text-sm font-bold text-gray-600 shadow-sm transition-all hover:bg-gray-50 hover:shadow"
269+ >
270+ <svg class =" h-4 w-4" fill =" none" stroke =" currentColor" stroke-width =" 2" viewBox =" 0 0 24 24" ><path stroke-linecap =" round" stroke-linejoin =" round" d =" M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg >
271+ Clear
272+ </button >
273+ </div >
274+
178275 </div >
179- <p
180- v-if =" availableQuestions.length === 0 && !isTyping"
181- class =" mt-3 text-sm font-medium text-gray-500"
182- >
183- All questions explored. Return to the gallery to try another video.
184- </p >
185276 </div >
186277 </div >
187278</template >
0 commit comments