Files
openswarm/frontend/src/app/components/feedback/Animated.tsx
T
AidanandGitHub 08526b6149 [aidan] bug/feat: sidebar reordering, title improvements, openai gen bug (#76)
* [aidan] ui: sidebar reorder

* [aidan] ux: sidebar naming bug fix

* [aidan] ui: title changing improvements

* [aidan] ui/ux: aux title gen streams for cx/ route + agent writes meta.json first

- All aux LLM calls (chat title, turn label, group meta, dashboard name) now stream
  instead of using messages.create; 9router's cx/ subscription response translator
  drops content for GPT-5-family models on the non-streaming path but works per-event.
- aux_max_tokens_for floors GPT-5 budget at 4K so reasoning still leaves headroom for
  a label; non-reasoning models get the base 100.
- App Builder skill: write meta.json FIRST (step 1 of Quick start) so the app's name
  surfaces in the sidebar + ViewEditor header on the agent's first tool call instead
  of waiting until end-of-turn.
- Session-end sync_output_from_meta_json takes a fallback_name (= session.name) so an
  agent that never writes meta.json still leaves the app with the aux-LLM chat title
  rather than "Untitled App".
- Title display: truncateForTitle caps at 4 words / 30 chars; displayChatTitle picks
  the right Phase 1 placeholder by session.mode; normalizeSessionName strips legacy
  Agent-XXXX names at slice intake.
- Typewriter component (char-by-char delete-then-type) drives the chat header,
  dashboard card title, sidebar Apps entry, and ViewEditor TextField + description
  field; honors useReducedMotion.

* [aidan] tune: reduce gpt-5 aux token floor
2026-06-12 23:33:32 -07:00

173 lines
5.4 KiB
TypeScript

import React, { useEffect, useRef, useState } from 'react';
import Box from '@mui/material/Box';
import { DURATION_MS, EASE } from '@/shared/styles/motionTokens';
import { useReducedMotion } from '@/shared/hooks/useReducedMotion';
/**
* Smooth visual transitions for status pills + counters that currently snap.
*
* <CrossFadeOnChange value={x}>{(v) => <span>{v}</span>}</CrossFadeOnChange>
* Old value fades to 30% while new value fades in. Cancels on rapid changes.
*
* <TweeningNumber value={1234} format={(n) => `$${n.toFixed(4)}`} />
* RAF-tweens from previous to new value. Caps duration on big jumps.
*/
interface CrossFadeProps<T> {
value: T;
children: (currentValue: T) => React.ReactNode;
/** Defaults to DURATION_MS.quick (140ms). */
durationMs?: number;
}
export function CrossFadeOnChange<T>({ value, children, durationMs }: CrossFadeProps<T>) {
const reduced = useReducedMotion();
const dur = reduced ? 0 : (durationMs ?? DURATION_MS.quick);
const [displayed, setDisplayed] = useState(value);
const [opacity, setOpacity] = useState(1);
useEffect(() => {
if (Object.is(displayed, value)) return;
if (dur === 0) {
setDisplayed(value);
return;
}
// Fade old to ~0, then swap and fade new in.
setOpacity(0);
const t = setTimeout(() => {
setDisplayed(value);
setOpacity(1);
}, dur / 2);
return () => clearTimeout(t);
}, [value, dur, displayed]);
return (
<Box
component="span"
sx={{
display: 'inline-block',
opacity,
transition: `opacity ${dur / 2}ms ${EASE.out}`,
}}
>
{children(displayed)}
</Box>
);
}
interface TypewriterProps {
value: string;
children: (current: string) => React.ReactNode;
charDelayMs?: number;
enabled?: boolean;
snapOnFirstTransition?: boolean;
}
export function Typewriter({ value, children, charDelayMs = 14, enabled = true, snapOnFirstTransition = false }: TypewriterProps) {
const reduced = useReducedMotion();
const [displayed, setDisplayed] = useState(value);
const targetRef = useRef(value);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hasTransitionedRef = useRef(false);
useEffect(() => {
targetRef.current = value;
if (!enabled || reduced) {
if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; }
setDisplayed(value);
if (value !== displayed) hasTransitionedRef.current = true;
return;
}
if (value === displayed) return;
if (snapOnFirstTransition && !hasTransitionedRef.current) {
hasTransitionedRef.current = true;
setDisplayed(value);
return;
}
hasTransitionedRef.current = true;
if (timerRef.current) clearTimeout(timerRef.current);
const tick = () => {
setDisplayed((prev) => {
const target = targetRef.current;
if (prev === target) return prev;
let commonLen = 0;
while (commonLen < prev.length && commonLen < target.length && prev[commonLen] === target[commonLen]) {
commonLen++;
}
const next = prev.length > commonLen
? prev.substring(0, prev.length - 1)
: target.substring(0, prev.length + 1);
if (next !== target) {
timerRef.current = setTimeout(tick, charDelayMs);
}
return next;
});
};
timerRef.current = setTimeout(tick, charDelayMs);
return () => {
if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; }
};
}, [value, enabled, reduced, charDelayMs, snapOnFirstTransition, displayed]);
return <>{children(displayed)}</>;
}
interface TweeningNumberProps {
value: number;
/** How to render the tweened number. Default: `n.toString()`. */
format?: (n: number) => string;
/** Cap on tween duration regardless of delta. Default 500ms. */
maxDurationMs?: number;
}
export const TweeningNumber: React.FC<TweeningNumberProps> = ({
value,
format = (n) => String(Math.round(n)),
maxDurationMs = 500,
}) => {
const reduced = useReducedMotion();
const [displayed, setDisplayed] = useState(value);
const startedAtRef = useRef<number | null>(null);
const fromRef = useRef<number>(value);
const toRef = useRef<number>(value);
const rafRef = useRef<number | null>(null);
useEffect(() => {
if (reduced) {
setDisplayed(value);
return;
}
if (Object.is(toRef.current, value)) return;
fromRef.current = displayed;
toRef.current = value;
startedAtRef.current = performance.now();
// Duration scales with delta but caps. ~1ms per unit, capped.
const delta = Math.abs(value - fromRef.current);
const dur = Math.min(maxDurationMs, Math.max(120, delta * 1.2));
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
const step = (now: number) => {
const t = Math.min(1, (now - (startedAtRef.current as number)) / dur);
// ease-out cubic
const eased = 1 - Math.pow(1 - t, 3);
const current = fromRef.current + (toRef.current - fromRef.current) * eased;
setDisplayed(current);
if (t < 1) {
rafRef.current = requestAnimationFrame(step);
} else {
rafRef.current = null;
}
};
rafRef.current = requestAnimationFrame(step);
return () => {
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
};
}, [value, reduced, maxDurationMs]); // eslint-disable-line react-hooks/exhaustive-deps
return <>{format(displayed)}</>;
};