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>
);
@@ -1,7 +1,6 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { GridLoader } from '@/components/ui/grid-loader';
import { toast } from '@/components/ui';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { RiInformationLine } from '@remixicon/react';
@@ -236,7 +235,7 @@ export const MagicPromptsPage: React.FC = () => {
if (loading) {
return (
<div className="py-6 px-6 flex items-center gap-2 text-muted-foreground">
<GridLoader size="sm" />
<span className="inline-block h-1.5 w-1.5 rounded-full bg-current animate-busy-pulse" aria-label="Loading" />
<span className="typography-ui">Loading Magic Prompts...</span>
</div>
);
@@ -8,7 +8,6 @@ import { updateDesktopSettings } from '@/lib/persistence';
import { Checkbox } from '@/components/ui/checkbox';
import { toast } from '@/components/ui';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { GridLoader } from '@/components/ui/grid-loader';
import { Input } from '@/components/ui/input';
import { NumberInput } from '@/components/ui/number-input';
import { Button } from '@/components/ui/button';
@@ -908,7 +907,7 @@ export const NotificationSettings: React.FC = () => {
</div>
{pushBusy && (
<div className="pt-0.5 text-muted-foreground">
<GridLoader size="sm" />
<span className="inline-block h-1.5 w-1.5 rounded-full bg-current animate-busy-pulse" aria-label="Loading" />
</div>
)}
</div>
@@ -20,7 +20,6 @@ import {
import { toast } from '@/components/ui';
import { Button } from '@/components/ui/button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { GridLoader } from '@/components/ui/grid-loader';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
@@ -1058,7 +1057,7 @@ export const TunnelSettings: React.FC = () => {
if (state === 'checking') {
return (
<div className="flex items-center justify-center py-12">
<GridLoader size="sm" />
<span className="h-1.5 w-1.5 rounded-full bg-current animate-busy-pulse" aria-label="Loading" />
</div>
);
}
@@ -11,7 +11,6 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { GridLoader } from '@/components/ui/grid-loader';
import {
RiAddLine,
RiArrowDownSLine,
@@ -46,12 +45,6 @@ import { formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, r
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
import { useSessionUnseenCount } from '@/sync/notification-store';
const ATTENTION_DIAMOND_INDICES = new Set([1, 3, 4, 5, 7]);
const getAttentionDiamondDelay = (index: number): string => {
return index === 4 ? '0ms' : '130ms';
};
type Folder = { id: string; name: string; sessionIds: string[] };
type SecondaryMeta = {
@@ -387,17 +380,19 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
const showUnreadStatus = !isStreaming && needsAttention && !isActive;
const showStatusMarker = isStreaming || showUnreadStatus;
const statusMarkerContent = isStreaming
? <GridLoader size="xs" className="text-primary" />
? (
<span
className="h-1.5 w-1.5 rounded-full bg-primary animate-busy-pulse"
aria-label="Session active"
title="Session active"
/>
)
: (
<span className="grid grid-cols-3 gap-[1px] text-[var(--status-info)]" aria-label="Unread updates" title="Unread updates">
{Array.from({ length: 9 }, (_, i) => (
ATTENTION_DIAMOND_INDICES.has(i) ? (
<span key={i} className="h-[3px] w-[3px] rounded-full bg-current animate-attention-diamond-pulse" style={{ animationDelay: getAttentionDiamondDelay(i) }} />
) : (
<span key={i} className="h-[3px] w-[3px]" />
)
))}
</span>
<span
className="h-1.5 w-1.5 rounded-full bg-[var(--status-info)]"
aria-label="Unread updates"
title="Unread updates"
/>
);
const inlineStatusMarker = !isMinimalMode && showStatusMarker ? (
<span className="inline-flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center">
@@ -1,40 +0,0 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
interface GridLoaderProps {
className?: string;
size?: 'xs' | 'sm' | 'md' | 'lg';
}
const sizeConfig = {
xs: { container: 'gap-[1px]', dot: 'h-[3px] w-[3px]' },
sm: { container: 'gap-0.5', dot: 'h-1 w-1' },
md: { container: 'gap-1', dot: 'h-1.5 w-1.5' },
lg: { container: 'gap-1.5', dot: 'h-2 w-2' },
};
const getPulseDelayMs = (index: number): number => {
return ((index % 3) + Math.floor(index / 3)) * 150;
};
const GridLoader: React.FC<GridLoaderProps> = ({ className, size = 'md' }) => {
const config = sizeConfig[size];
return (
<span
className={cn('grid grid-cols-3 place-items-center', config.container, className)}
style={{ width: '11px', height: '11px' }}
aria-label="Loading"
>
{Array.from({ length: 9 }, (_, i) => (
<span
key={i}
className={cn('shrink-0 rounded-full bg-current animate-grid-pulse', config.dot)}
style={{ animationDelay: `${getPulseDelayMs(i)}ms` }}
/>
))}
</span>
);
};
export { GridLoader };
+2 -22
View File
@@ -16,24 +16,6 @@ const variants = [
</span>
),
},
{
variant: "shine",
component: ({ children, className, ...props }) => (
<span
{...props}
data-component="oc-text-shimmer"
data-active="true"
className={className}
>
<span data-slot="text-shimmer-char">
<span data-slot="text-shimmer-char-base">{children}</span>
<span data-slot="text-shimmer-char-shimmer" data-run="true" aria-hidden="true">
{children}
</span>
</span>
</span>
),
},
{
variant: "generate-effect",
component: ({ children, className, ...props }) => {
@@ -210,12 +192,10 @@ export type TextProps = {
} & React.ComponentProps<"span"> &
Partial<MotionProps>;
export function Text({ variant = "shine", className, ...props }: TextProps) {
const FALLBACK_INDEX = 1;
export function Text({ variant = "static", className, ...props }: TextProps) {
const variantComponent = variants.find((v) => v.variant === variant)?.component;
const Component = variantComponent || variants[FALLBACK_INDEX].component;
const Component = variantComponent || variants[0].component;
return <Component {...props} className={className} />;
}
+4 -119
View File
@@ -308,101 +308,6 @@ textarea[data-chat-input="true"]:focus-visible {
}
}
[data-component="oc-text-shimmer"] {
--oc-text-shimmer-step: 45ms;
--oc-text-shimmer-duration: 1200ms;
--oc-text-shimmer-angle: 90deg;
--oc-text-shimmer-spread: 5.2ch;
--oc-text-shimmer-size: 360%;
--oc-text-shimmer-base-color: color-mix(in srgb, var(--surface-muted-foreground) 75%, transparent);
--oc-text-shimmer-peak-color: var(--surface-foreground);
--oc-text-shimmer-sweep: linear-gradient(
var(--oc-text-shimmer-angle),
transparent calc(50% - var(--oc-text-shimmer-spread)),
var(--oc-text-shimmer-peak-color) 50%,
transparent calc(50% + var(--oc-text-shimmer-spread))
);
--oc-text-shimmer-base: linear-gradient(var(--oc-text-shimmer-base-color), var(--oc-text-shimmer-base-color));
}
[data-component="oc-text-shimmer"] [data-slot="text-shimmer-char"] {
display: inline-grid;
white-space: inherit;
font: inherit;
letter-spacing: inherit;
line-height: inherit;
}
[data-component="oc-text-shimmer"] [data-slot="text-shimmer-char-base"],
[data-component="oc-text-shimmer"] [data-slot="text-shimmer-char-shimmer"] {
grid-area: 1 / 1;
white-space: inherit;
font: inherit;
letter-spacing: inherit;
line-height: inherit;
}
[data-component="oc-text-shimmer"] [data-slot="text-shimmer-char-base"] {
color: inherit;
opacity: 0;
}
[data-component="oc-text-shimmer"] [data-slot="text-shimmer-char-shimmer"] {
color: var(--surface-muted-foreground);
opacity: 1;
}
[data-component="oc-text-shimmer"] [data-slot="text-shimmer-char-shimmer"][data-run="true"] {
animation-name: oc-text-shimmer-sweep;
animation-duration: var(--oc-text-shimmer-duration);
animation-iteration-count: infinite;
animation-timing-function: linear;
animation-fill-mode: both;
animation-delay: calc(var(--oc-text-shimmer-step) * -1);
will-change: background-position;
}
@keyframes oc-text-shimmer-sweep {
0% {
background-position:
100% 0,
0 0;
}
100% {
background-position:
0% 0,
0 0;
}
}
@supports ((-webkit-background-clip: text) or (background-clip: text)) {
[data-component="oc-text-shimmer"] [data-slot="text-shimmer-char-shimmer"] {
color: transparent;
-webkit-text-fill-color: transparent;
background-image: var(--oc-text-shimmer-sweep), var(--oc-text-shimmer-base);
background-size:
var(--oc-text-shimmer-size) 100%,
100% 100%;
background-position:
100% 0,
0 0;
background-repeat: no-repeat;
-webkit-background-clip: text;
background-clip: text;
}
}
@media (prefers-reduced-motion: reduce) {
[data-component="oc-text-shimmer"] [data-slot="text-shimmer-char-shimmer"] {
animation: none !important;
color: inherit;
-webkit-text-fill-color: currentColor;
background-image: none;
}
}
@keyframes gradient-shimmer {
0% {
stop-color: currentColor;
@@ -513,12 +418,6 @@ svg.animate-spin {
}
}
/* Starfield twinkle — dots fade through varying brightness like stars */
@keyframes star-twinkle {
0%, 100% { opacity: 0.15; }
50% { opacity: 0.7; }
}
@keyframes pulse-opacity {
0%,
100% {
@@ -1415,7 +1314,7 @@ textarea[data-terminal-hidden-input="true"]::placeholder {
font-family: "JetBrainsMono Nerd Font", "FiraCode Nerd Font", "Fira Code", "JetBrains Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", "Courier New", monospace;
}
@keyframes grid-pulse {
@keyframes oc-busy-pulse {
0%, 100% {
opacity: 0.2;
}
@@ -1424,21 +1323,8 @@ textarea[data-terminal-hidden-input="true"]::placeholder {
}
}
.animate-grid-pulse {
animation: grid-pulse 1.2s ease-in-out infinite;
}
@keyframes attention-diamond-pulse {
0%, 100% {
opacity: 0.52;
}
50% {
opacity: 0.92;
}
}
.animate-attention-diamond-pulse {
animation: attention-diamond-pulse 2.3s ease-in-out infinite;
.animate-busy-pulse {
animation: oc-busy-pulse 1.2s ease-in-out infinite;
}
@keyframes navrail-dot-wave {
@@ -1525,8 +1411,7 @@ textarea[data-terminal-hidden-input="true"]::placeholder {
animation: none;
}
.animate-grid-pulse,
.animate-attention-diamond-pulse {
.animate-busy-pulse {
animation: none;
}