Skip to content

Commit 7279172

Browse files
feat(table): typewriter reveal for SSE-driven workflow cell values
Workflow-output cells now reveal their text character-by-character when an SSE update lands, while page reloads and virtualization remounts still paint the value instantly. A first-render guard inside the new useTypewriter hook distinguishes hydration from live updates with no plumbing through the cell tree. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e57380d commit 7279172

1 file changed

Lines changed: 47 additions & 1 deletion

File tree

  • apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
'use client'
22

3+
import { useEffect, useRef, useState } from 'react'
34
import type React from 'react'
45
import { parse } from 'tldts'
56
import { Badge, Checkbox, Tooltip } from '@/components/emcn'
@@ -125,6 +126,9 @@ interface CellRenderProps {
125126
}
126127

127128
export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactElement | null {
129+
const valueText = kind.kind === 'value' ? kind.text : null
130+
const revealedValueText = useTypewriter(valueText)
131+
128132
switch (kind.kind) {
129133
case 'value':
130134
return (
@@ -134,7 +138,7 @@ export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactEle
134138
isEditing && 'invisible'
135139
)}
136140
>
137-
{kind.text}
141+
{revealedValueText ?? kind.text}
138142
</span>
139143
)
140144

@@ -281,3 +285,45 @@ function Wrap({ isEditing, children }: { isEditing: boolean; children: React.Rea
281285
if (!isEditing) return <>{children}</>
282286
return <div className='invisible'>{children}</div>
283287
}
288+
289+
const TYPEWRITER_MS_PER_CHAR = 15
290+
291+
/**
292+
* Reveals `text` character-by-character whenever it changes after the first
293+
* render. Initial render (page hydration or virtualization remount) shows the
294+
* value statically — animation fires only for subsequent updates, which in
295+
* practice means SSE-driven workflow completions arriving via
296+
* `useTableEventStream → applyCell()`.
297+
*/
298+
function useTypewriter(text: string | null): string | null {
299+
const [revealed, setRevealed] = useState<string | null>(text)
300+
const isFirstRunRef = useRef(true)
301+
const prevTextRef = useRef<string | null>(text)
302+
303+
useEffect(() => {
304+
if (isFirstRunRef.current) {
305+
isFirstRunRef.current = false
306+
prevTextRef.current = text
307+
setRevealed(text)
308+
return
309+
}
310+
if (prevTextRef.current === text) return
311+
prevTextRef.current = text
312+
313+
if (text === null || text.length === 0) {
314+
setRevealed(text)
315+
return
316+
}
317+
318+
setRevealed('')
319+
let i = 0
320+
const id = window.setInterval(() => {
321+
i++
322+
setRevealed(text.slice(0, i))
323+
if (i >= text.length) window.clearInterval(id)
324+
}, TYPEWRITER_MS_PER_CHAR)
325+
return () => window.clearInterval(id)
326+
}, [text])
327+
328+
return revealed
329+
}

0 commit comments

Comments
 (0)