refactor: refresh chat indicators, settings pages, and shared UI primitives

This commit is contained in:
Bohdan Triapitsyn
2026-04-20 18:48:33 +03:00
parent 1a2738a2e2
commit ec38468e93
12 changed files with 83 additions and 325 deletions
@@ -0,0 +1,25 @@
import React from 'react';
import { cn } from '@/lib/utils';
interface BusyDotsProps {
className?: string;
}
const DOT_DELAYS_MS = [0, 200, 400] as const;
export const BusyDots: React.FC<BusyDotsProps> = ({ className }) => (
<>
{'\u00A0'}
<span className={cn('inline-flex', className)} aria-hidden="true">
{DOT_DELAYS_MS.map((delay) => (
<span
key={delay}
className="animate-busy-pulse"
style={{ animationDelay: `${delay}ms` }}
>
.
</span>
))}
</span>
</>
);
@@ -83,5 +83,5 @@ Why: in current pipeline Perplexity is static/grouped, so `StaticToolRow` is the
- Text: `AssistantTextPart.tsx`, `UserTextPart.tsx`
- Tools: `ToolPart.tsx`, `ProgressiveGroup.tsx`, `toolPresentation.tsx`, `toolRenderUtils.ts`, `ToolRevealOnMount.tsx`
- Reasoning/justification: `ReasoningPart.tsx`, `JustificationBlock.tsx`
- Status/placeholders: `WorkingPlaceholder.tsx`, `GenericStatusSpinner.tsx`, `SessionActiveSpinner.tsx`, `MigratingPart.tsx`
- Status/placeholders: `WorkingPlaceholder.tsx`, `SessionActiveSpinner.tsx`, `MigratingPart.tsx`, `BusyDots.tsx`
- Utility renderers: `VirtualizedCodeBlock.tsx`, `MinDurationShineText.tsx`
@@ -1,56 +0,0 @@
import React from 'react';
/**
* Starfield Twinkle — a 4×4 grid of tiny dots that flicker
* like stars in a night sky. Each dot has its own random phase
* and duration so the pattern never looks mechanical.
*
* Corners are hidden (same as original) to soften the grid shape.
*/
const COLS = 4;
const ROWS = 4;
const SPACING = 3.2; // viewBox units between centers
const OFFSET = 2.7; // center the grid in 15×15
const DOT_R = 0.7; // small dot radius — star-like
const cornerIndices = new Set([0, 3, 12, 15]);
const stars = Array.from({ length: COLS * ROWS }, (_, i) => ({
id: i,
cx: (i % COLS) * SPACING + OFFSET,
cy: Math.floor(i / COLS) * SPACING + OFFSET,
isCorner: cornerIndices.has(i),
// Each star gets its own rhythm — varying duration + delay
duration: 2.4 + Math.random() * 2.4,
delay: Math.random() * 3.5,
}));
export function GenericStatusSpinner({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 15 15"
data-component="opencode-spinner"
className={className}
fill="var(--foreground)"
aria-hidden="true"
>
{stars.map((star) => (
<circle
key={star.id}
cx={star.cx}
cy={star.cy}
r={DOT_R}
style={
star.isCorner
? { opacity: 0 }
: {
animation: `star-twinkle ${star.duration}s ease-in-out infinite`,
animationDelay: `${star.delay}s`,
}
}
/>
))}
</svg>
);
}
@@ -1,7 +1,7 @@
import React from 'react';
import { Text } from '@/components/ui/text';
import { BusyDots } from './BusyDots';
const MAX_SHINE_DURATION_MS = 5 * 60 * 1000; // 5 minutes cap
const MAX_BUSY_DURATION_MS = 5 * 60 * 1000; // 5 minutes cap
interface MinDurationShineTextProps {
active: boolean;
@@ -20,68 +20,54 @@ export const MinDurationShineText: React.FC<MinDurationShineTextProps> = ({
style,
title,
}) => {
// Once active, we latch shine on and only turn it off after active becomes
// false AND minDurationMs has elapsed since we first started shining.
// All bookkeeping lives in refs so intermediate re-renders (children
// changing, props updating) can never cause a flicker.
const shineStartRef = React.useRef<number | null>(active ? Date.now() : null);
const [isShining, setIsShining] = React.useState(active);
const busyStartRef = React.useRef<number | null>(active ? Date.now() : null);
const [isBusy, setIsBusy] = React.useState(active);
const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
// Latch on: if active becomes true, start shining immediately.
if (active && shineStartRef.current === null) {
shineStartRef.current = Date.now();
}
if (active && !isShining) {
// Synchronous state set during render is fine for a latch-on — React
// will coalesce it with the current render pass.
// But we can't call setState during render, so we use an effect below.
if (active && busyStartRef.current === null) {
busyStartRef.current = Date.now();
}
React.useEffect(() => {
if (active) {
// Cancel any pending off-timer.
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
if (shineStartRef.current === null) {
shineStartRef.current = Date.now();
if (busyStartRef.current === null) {
busyStartRef.current = Date.now();
}
// Cap shine duration at 5 minutes max to prevent infinite shine on stuck tools
const elapsed = Date.now() - shineStartRef.current;
if (elapsed >= MAX_SHINE_DURATION_MS) {
setIsShining(false);
shineStartRef.current = null;
const elapsed = Date.now() - busyStartRef.current;
if (elapsed >= MAX_BUSY_DURATION_MS) {
setIsBusy(false);
busyStartRef.current = null;
return;
}
setIsShining(true);
setIsBusy(true);
return;
}
if (!isShining) {
shineStartRef.current = null;
if (!isBusy) {
busyStartRef.current = null;
return;
}
// active went false — schedule turn-off respecting minDurationMs.
const startedAt = shineStartRef.current ?? Date.now();
const startedAt = busyStartRef.current ?? Date.now();
const elapsed = Date.now() - startedAt;
// Cap shine duration at 5 minutes max to prevent infinite shine on stuck tools
if (elapsed >= MAX_SHINE_DURATION_MS) {
setIsShining(false);
shineStartRef.current = null;
if (elapsed >= MAX_BUSY_DURATION_MS) {
setIsBusy(false);
busyStartRef.current = null;
return;
}
const remaining = Math.max(0, minDurationMs - elapsed);
timerRef.current = setTimeout(() => {
setIsShining(false);
shineStartRef.current = null;
setIsBusy(false);
busyStartRef.current = null;
timerRef.current = null;
}, remaining);
@@ -91,19 +77,12 @@ export const MinDurationShineText: React.FC<MinDurationShineTextProps> = ({
timerRef.current = null;
}
};
}, [active, minDurationMs, isShining]);
if (isShining) {
return (
<Text variant="shine" className={className} title={title}>
{children}
</Text>
);
}
}, [active, minDurationMs, isBusy]);
return (
<span className={className} style={style} title={title}>
{children}
{isBusy ? <BusyDots /> : null}
</span>
);
};
@@ -1,7 +1,5 @@
import React from 'react';
import { Text } from '@/components/ui/text';
// import { SessionActiveSpinner } from './SessionActiveSpinner';
import { GenericStatusSpinner } from './GenericStatusSpinner';
import { BusyDots } from './BusyDots';
interface WorkingPlaceholderProps {
isWorking: boolean;
@@ -190,20 +188,18 @@ export function WorkingPlaceholder({
const countdownLabel = retryCountdown !== null && retryCountdown > 0
? ` in ${formatRetryCountdown(retryCountdown)}`
: '';
const retryText = `Retrying${countdownLabel}${attemptLabel}...`;
const retryText = `Retrying${countdownLabel}${attemptLabel}`;
return (
<div
className="flex h-full items-center text-muted-foreground pl-0.5"
role="status"
aria-live="polite"
aria-label={retryText}
aria-label={`${retryText}...`}
>
<span className="flex items-center gap-1">
<GenericStatusSpinner className="size-[15px] shrink-0" />
<Text variant="shine" className="typography-ui-header">
{retryText}
</Text>
<span className="typography-ui-header">
{retryText}
<BusyDots />
</span>
</div>
);
@@ -214,7 +210,6 @@ export function WorkingPlaceholder({
}
const label = displayedText.charAt(0).toUpperCase() + displayedText.slice(1);
const displayText = `${label}...`;
return (
<div
@@ -226,11 +221,9 @@ export function WorkingPlaceholder({
aria-label={label}
data-waiting={displayedPermission ? 'true' : undefined}
>
<span className="flex items-center gap-1">
<GenericStatusSpinner className="size-[15px] shrink-0" />
<Text variant="shine" className="typography-ui-header">
{displayText}
</Text>
<span className="typography-ui-header">
{label}
<BusyDots />
</span>
</div>
);