PluginProbe
Code Snippets / 4.0.0-beta.2
Code Snippets v4.0.0-beta.2
4.0.0-beta.2 3.10.2 3.10.1 3.10.0 3.10.0-beta.2 3.10.0-beta.1 4.0.0-beta.1 3.9.6 trunk 2.10.0 2.10.1 2.12.0 2.12.1 2.13.0 2.13.1 2.13.2 2.13.3 2.14.0 2.14.1 2.14.2 2.14.3 2.14.4 2.14.5 2.14.6 3.0.0 All 65 releases
code-snippets / js / components / ManageMenu / AiAgentDemo / useAiAgentDemo.ts

useAiAgentDemo.ts in Code Snippets 4.0.0-beta.2, at js/components/ManageMenu/AiAgentDemo/useAiAgentDemo.ts

207 lines 5.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2 import {
3 DEMO_PROMPT,
4 getDraftSnippets,
5 getRefinedSnippets,
6 getRefinementPrompt,
7 getSiteName
8 } from './demoScript'
9 import type { DemoSnippet, DemoStage } from './types'
10
11 /** Milliseconds between characters while a prompt types itself in. */
12 const CHAR_INTERVAL = 45
13
14 /**
15 * How long each stage holds before the driver advances. Stages that type text
16 * derive their own duration from the length of that text instead.
17 *
18 * Several stages share one step of commentary, so these are paced against the
19 * step rather than the stage: every step is given long enough to read its
20 * callout and take in what changed on screen.
21 */
22 const STAGE_DURATIONS: Partial<Record<DemoStage, number>> = {
23 'prompt-sent': 1200,
24 'planning': 3600,
25 'plan-ready': 3800,
26 'plan-accepted': 1200,
27 'building': 3600,
28 'result-ready': 3000,
29 'refine-open': 1500,
30 'applying': 2800,
31 'saved': 2600
32 }
33
34 /** Uniform pause between stages when the visitor has asked for reduced motion. */
35 const REDUCED_MOTION_DURATION = 250
36
37 /** Pause after a prompt finishes typing, before it is sent. */
38 const TYPING_TAIL = 1600
39
40 /** Fallback hold for any stage without an explicit duration. */
41 const DEFAULT_DURATION = 1600
42
43 const prefersReducedMotion = (): boolean =>
44 window.matchMedia('(prefers-reduced-motion: reduce)').matches
45
46 export interface AiAgentDemoState {
47 stage: DemoStage
48 typedPrompt: string
49 typedRefinement: string
50 snippets: DemoSnippet[]
51 hasStarted: boolean
52 isFinished: boolean
53 reducedMotion: boolean
54 siteName: string
55 refinementPrompt: string
56 play: VoidFunction
57 skip: VoidFunction
58 replay: VoidFunction
59 }
60
61 // eslint-disable-next-line max-lines-per-function -- the timeline driver and its teardown belong together.
62 export const useAiAgentDemo = (): AiAgentDemoState => {
63 const reducedMotion = useMemo(prefersReducedMotion, [])
64 const siteName = useMemo(getSiteName, [])
65 const refinementPrompt = useMemo(() => getRefinementPrompt(siteName), [siteName])
66
67 const [stage, setStage] = useState<DemoStage>('idle')
68 const [typedPrompt, setTypedPrompt] = useState('')
69 const [typedRefinement, setTypedRefinement] = useState('')
70 const [snippets, setSnippets] = useState<DemoSnippet[]>([])
71
72 const timers = useRef<number[]>([])
73
74 const clearTimers = useCallback(() => {
75 timers.current.forEach(window.clearTimeout)
76 timers.current = []
77 }, [])
78
79 useEffect(() => () => {
80 timers.current.forEach(window.clearTimeout)
81 }, [])
82
83 const finishImmediately = useCallback(() => {
84 clearTimers()
85 setTypedPrompt(DEMO_PROMPT)
86 setTypedRefinement(refinementPrompt)
87 setSnippets(getRefinedSnippets(siteName))
88 setStage('finished')
89 }, [clearTimers, refinementPrompt, siteName])
90
91 // Nothing is written to the site: the walkthrough shows what the agent
92 // would produce, and the snippets it names exist only on screen.
93 const enter = useCallback((next: DemoStage) => {
94 setStage(next)
95
96 if ('result-ready' === next) {
97 setSnippets(getDraftSnippets())
98 }
99
100 if ('saved' === next) {
101 setSnippets(getRefinedSnippets(siteName))
102 }
103 }, [siteName])
104
105 const schedule = useCallback((next: DemoStage, delay: number, run: (stage: DemoStage) => void) => {
106 timers.current.push(window.setTimeout(() => run(next), reducedMotion ? REDUCED_MOTION_DURATION : delay))
107 }, [reducedMotion])
108
109 const advance = useCallback((next: DemoStage) => {
110 enter(next)
111
112 const followUp: Partial<Record<DemoStage, DemoStage>> = {
113 'typing-prompt': 'prompt-sent',
114 'prompt-sent': 'planning',
115 'planning': 'plan-ready',
116 'plan-ready': 'plan-accepted',
117 'plan-accepted': 'building',
118 'building': 'result-ready',
119 'result-ready': 'refine-open',
120 'refine-open': 'typing-refinement',
121 'typing-refinement': 'applying',
122 'applying': 'saved',
123 'saved': 'finished'
124 }
125
126 const upcoming = followUp[next]
127
128 if (!upcoming) {
129 return
130 }
131
132 const typingDuration = (text: string) =>
133 (reducedMotion ? 0 : text.length * CHAR_INTERVAL) + TYPING_TAIL
134
135 const delay = 'typing-prompt' === next
136 ? typingDuration(DEMO_PROMPT)
137 : 'typing-refinement' === next
138 ? typingDuration(refinementPrompt)
139 : STAGE_DURATIONS[next] ?? DEFAULT_DURATION
140
141 schedule(upcoming, delay, advance)
142 }, [enter, reducedMotion, refinementPrompt, schedule])
143
144 const reset = useCallback(() => {
145 clearTimers()
146 setTypedPrompt('')
147 setTypedRefinement('')
148 setSnippets([])
149 setStage('idle')
150 }, [clearTimers])
151
152 const play = useCallback(() => {
153 reset()
154 timers.current.push(window.setTimeout(() => advance('typing-prompt'), 0))
155 }, [advance, reset])
156
157 const replay = useCallback(() => {
158 reset()
159 timers.current.push(window.setTimeout(() => advance('typing-prompt'), REDUCED_MOTION_DURATION))
160 }, [advance, reset])
161
162 // Typewriter for whichever prompt the current stage is composing.
163 useEffect(() => {
164 const target = 'typing-prompt' === stage
165 ? { text: DEMO_PROMPT, set: setTypedPrompt }
166 : 'typing-refinement' === stage
167 ? { text: refinementPrompt, set: setTypedRefinement }
168 : undefined
169
170 if (!target) {
171 return
172 }
173
174 if (reducedMotion) {
175 target.set(target.text)
176 return
177 }
178
179 let index = 0
180 const interval = window.setInterval(() => {
181 index += 1
182 target.set(target.text.slice(0, index))
183
184 if (index >= target.text.length) {
185 window.clearInterval(interval)
186 }
187 }, CHAR_INTERVAL)
188
189 return () => window.clearInterval(interval)
190 }, [reducedMotion, refinementPrompt, stage])
191
192 return {
193 stage,
194 typedPrompt,
195 typedRefinement,
196 snippets,
197 hasStarted: 'idle' !== stage,
198 isFinished: 'finished' === stage,
199 reducedMotion,
200 siteName,
201 refinementPrompt,
202 play,
203 skip: finishImmediately,
204 replay
205 }
206 }
207