LCOV - code coverage report
Current view: top level - dashql-app/src/agent - agent_greeting.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 93.8 % 64 60
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import * as React from 'react';
       2              : 
       3              : import { AgentFace, AgentFaceMorphEvent } from './agent_face.js';
       4              : import * as styles from './agent_greeting.module.css';
       5              : 
       6              : /// The questions the agent face asks on an empty session, in the order they rotate.
       7              : /// They speak of the user and the agent together, the analysis is not handed off to the agent.
       8            1 : export const AGENT_GREETINGS: readonly string[] = [
       9            1 :     'What are we exploring?',
      10            1 :     'What should we look into?',
      11            1 :     'What shall we find out?',
      12            1 :     'What should we analyze?',
      13            1 :     'Where do we start?'
      14            1 : ];
      15              : 
      16              : interface AgentGreetingProps {
      17              :     /// The props of the face
      18              :     face?: React.ComponentProps<typeof AgentFace> & { 'data-testid'?: string };
      19              :     /// The class of the question
      20              :     questionClassName?: string;
      21              :     /// The test id of the question
      22              :     questionTestId?: string;
      23              :     /// The element of the question
      24              :     questionElement?: 'h1' | 'p';
      25              :     /// The questions, the greetings of the agent by default
      26              :     greetings?: readonly string[];
      27              : }
      28              : 
      29              : /// The time to type a character, jittered so the typing feels like a hand
      30            1 : const TYPE_MS = 14;
      31            1 : const TYPE_JITTER_MS = 10;
      32              : /// The time to delete a character
      33            1 : const DELETE_MS = 8;
      34              : 
      35              : /// What the question is doing
      36            1 : enum Typing {
      37              :     /// The characters appear one by one
      38            1 :     Typing,
      39              :     /// The question is complete
      40            1 :     Done,
      41              :     /// The characters disappear one by one before the next question
      42            1 :     Deleting,
      43            1 : }
      44              : 
      45              : /// The agent face with its question right of it, the face and the question are siblings for the layout of the parent.
      46              : /// The question is typed character by character. When the face starts to melt into the brand mark, it is deleted
      47              : /// and the next question is typed.
      48            1 : export function AgentGreeting(props: AgentGreetingProps): React.ReactElement {
      49            1 :     const greetings = props.greetings ?? AGENT_GREETINGS;
      50            1 :     const reducedMotion = React.useMemo(() => globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false, []);
      51            1 :     const [index, setIndex] = React.useState(0);
      52            1 :     const [typing, setTyping] = React.useState(reducedMotion ? Typing.Done : Typing.Typing);
      53            1 :     const chars = React.useMemo(() => Array.from(greetings[index % greetings.length]), [greetings, index]);
      54            1 :     const [count, setCount] = React.useState(reducedMotion ? chars.length : 0);
      55              : 
      56            1 :     const onMorph = React.useCallback((event: AgentFaceMorphEvent) => {
      57            0 :         if (event !== AgentFaceMorphEvent.Leaving) return;
      58            0 :         if (reducedMotion) {
      59            0 :             setIndex(i => (i + 1) % greetings.length);
      60              :         } else {
      61            0 :             setTyping(Typing.Deleting);
      62              :         }
      63            1 :     }, [greetings.length, reducedMotion]);
      64              : 
      65              :     // Type or delete the next character
      66            1 :     React.useEffect(() => {
      67            1 :         if (reducedMotion) {
      68            1 :             setCount(chars.length);
      69            1 :             return;
      70            1 :         }
      71            1 :         let timer: ReturnType<typeof setTimeout> | undefined;
      72            1 :         if (typing === Typing.Typing) {
      73            1 :             if (count < chars.length) {
      74            1 :                 timer = setTimeout(() => setCount(c => c + 1), TYPE_MS + Math.random() * TYPE_JITTER_MS);
      75              :             } else {
      76            1 :                 setTyping(Typing.Done);
      77              :             }
      78            1 :         } else if (typing === Typing.Deleting) {
      79            1 :             if (count > 0) {
      80            1 :                 timer = setTimeout(() => setCount(c => Math.max(0, c - 1)), DELETE_MS);
      81            1 :             } else {
      82            1 :                 setIndex(i => (i + 1) % greetings.length);
      83            1 :                 setTyping(Typing.Typing);
      84            1 :             }
      85            1 :         }
      86            1 :         return () => clearTimeout(timer);
      87            1 :     }, [typing, count, chars, greetings.length, reducedMotion]);
      88              : 
      89            1 :     const Question = props.questionElement ?? 'h1';
      90            1 :     const questionClassName = props.questionClassName == null ? styles.question : `${styles.question} ${props.questionClassName}`;
      91            1 :     return (
      92            1 :         <>
      93            1 :             <AgentFace {...props.face} onMorph={onMorph} />
      94            1 :             <Question className={questionClassName} data-testid={props.questionTestId} aria-label={chars.join('')}>
      95              :                 {/* Every question takes the cell invisibly, the widest one sizes it so the face does not move */}
      96            1 :                 {greetings.map(text => (
      97            1 :                     <span key={text} className={styles.sizer} aria-hidden>{text}</span>
      98            1 :                 ))}
      99            1 :                 <span className={styles.typed} aria-hidden>
     100            1 :                     {chars.slice(0, count).join('')}
     101            1 :                     <span className={styles.caret} data-active={typing !== Typing.Done ? '' : undefined} />
     102            1 :                 </span>
     103            1 :             </Question>
     104            1 :         </>
     105              :     );
     106            1 : }
        

Generated by: LCOV version 2.0-1