From ec38468e934d4ac7f1cde45546edd2c50dc35617 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 20 Apr 2026 18:48:33 +0300 Subject: [PATCH] refactor: refresh chat indicators, settings pages, and shared UI primitives --- .../chat/message/parts/BusyDots.tsx | 25 ++++ .../chat/message/parts/DOCUMENTATION.md | 2 +- .../message/parts/GenericStatusSpinner.tsx | 56 -------- .../message/parts/MinDurationShineText.tsx | 75 ++++------- .../chat/message/parts/WorkingPlaceholder.tsx | 25 ++-- .../magic-prompts/MagicPromptsPage.tsx | 3 +- .../openchamber/NotificationSettings.tsx | 3 +- .../sections/openchamber/TunnelSettings.tsx | 3 +- .../session/sidebar/SessionNodeItem.tsx | 29 ++--- packages/ui/src/components/ui/grid-loader.tsx | 40 ------ packages/ui/src/components/ui/text.tsx | 24 +--- packages/ui/src/index.css | 123 +----------------- 12 files changed, 83 insertions(+), 325 deletions(-) create mode 100644 packages/ui/src/components/chat/message/parts/BusyDots.tsx delete mode 100644 packages/ui/src/components/chat/message/parts/GenericStatusSpinner.tsx delete mode 100644 packages/ui/src/components/ui/grid-loader.tsx diff --git a/packages/ui/src/components/chat/message/parts/BusyDots.tsx b/packages/ui/src/components/chat/message/parts/BusyDots.tsx new file mode 100644 index 00000000..b9946421 --- /dev/null +++ b/packages/ui/src/components/chat/message/parts/BusyDots.tsx @@ -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 = ({ className }) => ( + <> + {'\u00A0'} + + +); diff --git a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md index 9bda2915..91f26d5d 100644 --- a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md @@ -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` diff --git a/packages/ui/src/components/chat/message/parts/GenericStatusSpinner.tsx b/packages/ui/src/components/chat/message/parts/GenericStatusSpinner.tsx deleted file mode 100644 index 50178c7f..00000000 --- a/packages/ui/src/components/chat/message/parts/GenericStatusSpinner.tsx +++ /dev/null @@ -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 ( - - ); -} diff --git a/packages/ui/src/components/chat/message/parts/MinDurationShineText.tsx b/packages/ui/src/components/chat/message/parts/MinDurationShineText.tsx index 122e4c2d..2d31c514 100644 --- a/packages/ui/src/components/chat/message/parts/MinDurationShineText.tsx +++ b/packages/ui/src/components/chat/message/parts/MinDurationShineText.tsx @@ -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 = ({ 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(active ? Date.now() : null); - const [isShining, setIsShining] = React.useState(active); + const busyStartRef = React.useRef(active ? Date.now() : null); + const [isBusy, setIsBusy] = React.useState(active); const timerRef = React.useRef | 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 = ({ timerRef.current = null; } }; - }, [active, minDurationMs, isShining]); - - if (isShining) { - return ( - - {children} - - ); - } + }, [active, minDurationMs, isBusy]); return ( {children} + {isBusy ? : null} ); }; diff --git a/packages/ui/src/components/chat/message/parts/WorkingPlaceholder.tsx b/packages/ui/src/components/chat/message/parts/WorkingPlaceholder.tsx index f6fc444e..a0013aab 100644 --- a/packages/ui/src/components/chat/message/parts/WorkingPlaceholder.tsx +++ b/packages/ui/src/components/chat/message/parts/WorkingPlaceholder.tsx @@ -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 (
- - - - {retryText} - + + {retryText} +
); @@ -214,7 +210,6 @@ export function WorkingPlaceholder({ } const label = displayedText.charAt(0).toUpperCase() + displayedText.slice(1); - const displayText = `${label}...`; return (
- - - - {displayText} - + + {label} +
); diff --git a/packages/ui/src/components/sections/magic-prompts/MagicPromptsPage.tsx b/packages/ui/src/components/sections/magic-prompts/MagicPromptsPage.tsx index 5e97dc71..4830c26d 100644 --- a/packages/ui/src/components/sections/magic-prompts/MagicPromptsPage.tsx +++ b/packages/ui/src/components/sections/magic-prompts/MagicPromptsPage.tsx @@ -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 (
- + Loading Magic Prompts...
); diff --git a/packages/ui/src/components/sections/openchamber/NotificationSettings.tsx b/packages/ui/src/components/sections/openchamber/NotificationSettings.tsx index 933a5413..ee5fad69 100644 --- a/packages/ui/src/components/sections/openchamber/NotificationSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/NotificationSettings.tsx @@ -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 = () => { {pushBusy && (
- +
)} diff --git a/packages/ui/src/components/sections/openchamber/TunnelSettings.tsx b/packages/ui/src/components/sections/openchamber/TunnelSettings.tsx index 250053b7..ccc1c968 100644 --- a/packages/ui/src/components/sections/openchamber/TunnelSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/TunnelSettings.tsx @@ -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 (
- +
); } diff --git a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx index 377c37e5..2f038165 100644 --- a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx +++ b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx @@ -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 - ? + ? ( + + ) : ( - - {Array.from({ length: 9 }, (_, i) => ( - ATTENTION_DIAMOND_INDICES.has(i) ? ( - - ) : ( - - ) - ))} - + ); const inlineStatusMarker = !isMinimalMode && showStatusMarker ? ( diff --git a/packages/ui/src/components/ui/grid-loader.tsx b/packages/ui/src/components/ui/grid-loader.tsx deleted file mode 100644 index 75e51a3d..00000000 --- a/packages/ui/src/components/ui/grid-loader.tsx +++ /dev/null @@ -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 = ({ className, size = 'md' }) => { - const config = sizeConfig[size]; - - return ( - - {Array.from({ length: 9 }, (_, i) => ( - - ))} - - ); -}; - -export { GridLoader }; diff --git a/packages/ui/src/components/ui/text.tsx b/packages/ui/src/components/ui/text.tsx index b110d648..48f70e5c 100644 --- a/packages/ui/src/components/ui/text.tsx +++ b/packages/ui/src/components/ui/text.tsx @@ -16,24 +16,6 @@ const variants = [ ), }, - { - variant: "shine", - component: ({ children, className, ...props }) => ( - - - {children} - - - - ), - }, { variant: "generate-effect", component: ({ children, className, ...props }) => { @@ -210,12 +192,10 @@ export type TextProps = { } & React.ComponentProps<"span"> & Partial; -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 ; } diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index f8cda817..470d7249 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -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; }