Files
hermes-agent/apps/desktop/src/components/chat/image-generation-placeholder.tsx
T
Brooklyn Nicholson 4dd9732a94 feat(desktop): hoisted todo widget, JSON tool summaries, history grouping & timer fixes
- Hoist todo to first-class widget (shadcn checkboxes, brand colors, no
  tool-accordion). Header derives label from active task; non-active rows fade.
- Replace raw JSON dumps with structured key/value summaries via
  formatToolResultSummary; nested error extraction for clearer failures.
- Fix loaded-session grouping: stitch interleaved assistant/tool iterations
  into one bubble instead of orphaned synthetic messages.
- Stable tool/thinking timers via keyed registry so unmount/scroll doesn't
  reset elapsed counts; gate "running" on real live thread state.
- Reorganize chat-only assistant-ui components under components/chat/.
2026-05-11 16:34:25 -04:00

277 lines
8.9 KiB
TypeScript

import { type FC, useCallback, useEffect, useRef } from 'react'
import { useResizeObserver } from '@/hooks/use-resize-observer'
type Rgb = { r: number; g: number; b: number }
const RAMP = ' .,:;-=+*#%@'
const FALLBACKS = {
card: { r: 255, g: 255, b: 255 },
muted: { r: 240, g: 240, b: 239 },
foreground: { r: 36, g: 36, b: 36 },
primary: { r: 207, g: 128, b: 109 },
ring: { r: 185, g: 121, b: 105 }
} satisfies Record<string, Rgb>
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))
const smoothstep = (edge0: number, edge1: number, value: number) => {
const t = clamp((value - edge0) / (edge1 - edge0), 0, 1)
return t * t * (3 - 2 * t)
}
const parseColor = (value: string, fallback: Rgb): Rgb => {
const hex = value.trim().match(/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i)
if (hex) {
return {
r: Number.parseInt(hex[1], 16),
g: Number.parseInt(hex[2], 16),
b: Number.parseInt(hex[3], 16)
}
}
const rgb = value.trim().match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i)
return rgb ? { r: Number(rgb[1]), g: Number(rgb[2]), b: Number(rgb[3]) } : fallback
}
const mix = (a: Rgb, b: Rgb, amount: number): Rgb => ({
r: Math.round(a.r + (b.r - a.r) * amount),
g: Math.round(a.g + (b.g - a.g) * amount),
b: Math.round(a.b + (b.b - a.b) * amount)
})
const rgba = ({ r, g, b }: Rgb, alpha: number) => `rgba(${r}, ${g}, ${b}, ${alpha})`
const hash2 = (x: number, y: number) => {
const n = Math.sin(x * 127.1 + y * 311.7) * 43758.5453
return n - Math.floor(n)
}
const noise2 = (x: number, y: number) => {
const xi = Math.floor(x)
const yi = Math.floor(y)
const xf = x - xi
const yf = y - yi
const u = xf * xf * (3 - 2 * xf)
const v = yf * yf * (3 - 2 * yf)
const a = hash2(xi, yi)
const b = hash2(xi + 1, yi)
const c = hash2(xi, yi + 1)
const d = hash2(xi + 1, yi + 1)
return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v
}
const fbm = (x: number, y: number) => {
let value = 0
let amplitude = 0.5
let frequency = 1
for (let i = 0; i < 4; i += 1) {
value += amplitude * noise2(x * frequency, y * frequency)
frequency *= 2.04
amplitude *= 0.52
}
return value
}
const readTheme = () => {
const styles = getComputedStyle(document.documentElement)
return {
card: parseColor(styles.getPropertyValue('--dt-card'), FALLBACKS.card),
muted: parseColor(styles.getPropertyValue('--dt-muted'), FALLBACKS.muted),
foreground: parseColor(styles.getPropertyValue('--dt-foreground'), FALLBACKS.foreground),
primary: parseColor(styles.getPropertyValue('--dt-primary'), FALLBACKS.primary),
ring: parseColor(styles.getPropertyValue('--dt-ring'), FALLBACKS.ring)
}
}
const fitCanvas = (canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D) => {
const rect = canvas.getBoundingClientRect()
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const width = Math.max(1, rect.width)
const height = Math.max(1, rect.height)
canvas.width = Math.round(width * dpr)
canvas.height = Math.round(height * dpr)
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
return { width, height }
}
const drawAsciiDiffusion = (ctx: CanvasRenderingContext2D, width: number, height: number, time: number) => {
const theme = readTheme()
const bg = ctx.createLinearGradient(0, 0, width, height)
bg.addColorStop(0, rgba(mix(theme.card, theme.primary, 0.08), 1))
bg.addColorStop(0.54, rgba(mix(theme.card, theme.muted, 0.68), 1))
bg.addColorStop(1, rgba(mix(theme.muted, theme.ring, 0.12), 1))
ctx.fillStyle = bg
ctx.fillRect(0, 0, width, height)
const cycle = (time * 0.028) % 1
const denoise = cycle < 0.82 ? smoothstep(0.02, 0.82, cycle) : 1 - smoothstep(0.82, 1, cycle)
const fontSize = clamp(width / 58, 8, 13)
const cellWidth = fontSize * 0.78
const cellHeight = fontSize * 1.28
const cols = Math.ceil(width / cellWidth)
const rows = Math.ceil(height / cellHeight)
const centerX = 0.53 + Math.sin(time * 0.055) * 0.02
const centerY = 0.5 + Math.cos(time * 0.048) * 0.02
const timestep = Math.floor(time * 1.15)
const timestepBlend = smoothstep(0, 1, time * 1.15 - timestep)
ctx.font = `${fontSize}px "SF Mono", "Cascadia Code", Menlo, Consolas, monospace`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
for (let row = -1; row <= rows + 1; row += 1) {
for (let col = -1; col <= cols + 1; col += 1) {
const x = col * cellWidth + cellWidth * 0.5
const y = row * cellHeight + cellHeight * 0.5
const nx = x / width
const ny = y / height
const dx = (nx - centerX) * 1.2
const dy = (ny - centerY) * 0.95
const radius = Math.hypot(dx, dy)
const angle = Math.atan2(dy, dx)
const bloom =
Math.exp(-(radius * radius) / 0.075) * 0.72 +
Math.exp(-((radius - (0.28 + Math.sin(angle * 5 + time * 0.16) * 0.035)) ** 2) / 0.0028) * 0.8
const contour =
Math.exp(-((Math.sin(angle * 3 + radius * 17 - time * 0.17) * 0.5 + 0.5 - radius) ** 2) / 0.016) * 0.38
const stem = Math.exp(-((nx - centerX + 0.05) ** 2 / 0.004 + (ny - centerY - 0.25) ** 2 / 0.08)) * 0.46
const latent = clamp(bloom + contour + stem, 0, 1)
const staticA = hash2(col + timestep * 19, row - timestep * 11)
const staticB = hash2(col + (timestep + 1) * 19, row - (timestep + 1) * 11)
const staticNoise = staticA + (staticB - staticA) * timestepBlend
const livingNoise = fbm(col * 0.12 + time * 0.024, row * 0.12 - time * 0.018)
const denoiseWave = Math.exp(-((radius - denoise * 0.62) ** 2) / 0.006)
const signal = clamp(
staticNoise * (1 - denoise) +
latent * denoise +
(livingNoise - 0.45) * (0.45 - denoise * 0.26) +
denoiseWave * 0.3,
0,
1
)
const dropoutA = hash2(col - timestep * 7, row + timestep * 13)
const dropoutB = hash2(col - (timestep + 1) * 7, row + (timestep + 1) * 13)
const dropout = dropoutA + (dropoutB - dropoutA) * timestepBlend
if (dropout > 0.35 + signal * 0.68) {
continue
}
const glyph = RAMP[clamp(Math.floor(signal * (RAMP.length - 1)), 0, RAMP.length - 1)]
if (glyph === ' ') {
continue
}
const jitter = (1 - denoise) * 1.35 + (1 - latent) * 0.45
const jx = (noise2(col * 0.31, row * 0.31 + time * 0.09) - 0.5) * jitter
const jy = (noise2(col * 0.27 - time * 0.085, row * 0.27) - 0.5) * jitter
const tintAmount = clamp(latent * 0.7 + denoiseWave * 0.4, 0, 1)
const warm = mix(theme.primary, theme.ring, hash2(col, row))
const tint = mix(theme.foreground, warm, tintAmount)
const alpha = clamp(0.12 + signal * 0.68 + denoiseWave * 0.16, 0, 0.86)
if (signal > 0.58 && denoise > 0.34) {
ctx.fillStyle = rgba(theme.ring, alpha * 0.2)
ctx.fillText(glyph, x + jx + 0.75, y + jy - 0.45)
ctx.fillStyle = rgba(theme.primary, alpha * 0.18)
ctx.fillText(glyph, x + jx - 0.75, y + jy + 0.45)
}
ctx.fillStyle = rgba(tint, alpha)
ctx.fillText(glyph, x + jx, y + jy)
}
}
const veil = ctx.createRadialGradient(
width * centerX,
height * centerY,
0,
width * centerX,
height * centerY,
Math.min(width, height) * (0.35 + denoise * 0.3)
)
veil.addColorStop(0, rgba(theme.card, 0.08 + denoise * 0.12))
veil.addColorStop(0.52, rgba(theme.card, 0.05))
veil.addColorStop(1, rgba(theme.card, 0))
ctx.fillStyle = veil
ctx.fillRect(0, 0, width, height)
}
const DiffusionCanvas: FC = () => {
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const sizeRef = useRef({ width: 0, height: 0 })
const fitToContainer = useCallback(() => {
const canvas = canvasRef.current
const ctx = canvas?.getContext('2d')
if (!canvas || !ctx) {
return
}
sizeRef.current = fitCanvas(canvas, ctx)
}, [])
useResizeObserver(fitToContainer, canvasRef)
useEffect(() => {
const canvas = canvasRef.current
const ctx = canvas?.getContext('2d')
if (!canvas || !ctx) {
return
}
sizeRef.current = fitCanvas(canvas, ctx)
let frame = requestAnimationFrame(function draw(now) {
const { width, height } = sizeRef.current
ctx.clearRect(0, 0, width, height)
drawAsciiDiffusion(ctx, width, height, now / 1000)
frame = requestAnimationFrame(draw)
})
return () => {
cancelAnimationFrame(frame)
}
}, [])
return <canvas className="absolute inset-0 h-full w-full" ref={canvasRef} />
}
export const ImageGenerationPlaceholder: FC = () => {
return (
<div aria-label="Rendering image" aria-live="polite" className="w-full max-w-136 self-start" role="status">
<div className="relative h-(--image-preview-height) overflow-hidden rounded-4xl border border-border/55 shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_45%,transparent),inset_0_0_0_0.0625rem_color-mix(in_srgb,var(--dt-border)_34%,transparent),inset_0_-0.75rem_1.75rem_color-mix(in_srgb,var(--dt-primary)_5%,transparent)]">
<DiffusionCanvas />
</div>
</div>
)
}