Files
openchamber/packages/ui/src/components/chat/message/parts/WorkingPlaceholder.tsx
T
Bohdan Triapitsyn 4be6854ba5 feat: show model in working indicator and fix streaming bottom jitter
- Working indicator shows provider icon and model name with the live
  status (e.g. 'Fable 5 is reading file'), localized via
  chat.statusRow.modelStatus
- Pin auto-follow to the exact fractional scroll maximum and re-pin on
  every passive follow instead of skipping within tolerance
- Round message text line-height to whole pixels so streamed content
  grows on the pixel grid; kills the 1px vertical jitter of
  bottom-anchored rows during streaming
2026-07-19 22:58:28 +03:00

259 lines
7.5 KiB
TypeScript

import React from 'react';
import { BusyDots } from './BusyDots';
import { useI18n } from '@/lib/i18n';
import { useProviderLogo } from '@/hooks/useProviderLogo';
import { useThemeSystem } from '@/contexts/useThemeSystem';
interface WorkingPlaceholderProps {
isWorking: boolean;
statusText: string | null;
isGenericStatus?: boolean;
isWaitingForPermission?: boolean;
retryInfo?: { attempt?: number; next?: number } | null;
agentName?: string;
modelName?: string | null;
providerId?: string | null;
}
const STATUS_DISPLAY_TIME_MS = 1200;
const EPOCH_SECONDS_THRESHOLD = 1_000_000_000;
const EPOCH_MILLISECONDS_THRESHOLD = 1_000_000_000_000;
const toRetryTargetTimestamp = (next: number): number => {
if (next >= EPOCH_MILLISECONDS_THRESHOLD) {
return next;
}
if (next >= EPOCH_SECONDS_THRESHOLD) {
return next * 1000;
}
return Date.now() + next;
};
const formatRetryCountdown = (seconds: number): string => {
if (seconds < 60) {
return `${seconds}s`;
}
if (seconds < 3600) {
const minutes = Math.floor(seconds / 60);
const remainderSeconds = seconds % 60;
return remainderSeconds > 0 ? `${minutes}m ${remainderSeconds}s` : `${minutes}m`;
}
if (seconds < 86400) {
const hours = Math.floor(seconds / 3600);
const remainderMinutes = Math.floor((seconds % 3600) / 60);
return remainderMinutes > 0 ? `${hours}h ${remainderMinutes}m` : `${hours}h`;
}
const days = Math.floor(seconds / 86400);
const remainderHours = Math.floor((seconds % 86400) / 3600);
if (remainderHours > 0) {
return `${days}d ${remainderHours}h`;
}
return `${days}d`;
};
export function WorkingPlaceholder({
isWorking,
statusText,
isGenericStatus,
isWaitingForPermission,
retryInfo,
modelName,
providerId,
}: WorkingPlaceholderProps) {
const { t } = useI18n();
const { src: providerLogoSrc, onError: handleProviderLogoError, hasLogo: hasProviderLogo } = useProviderLogo(providerId ?? null);
const { currentTheme } = useThemeSystem();
const isDarkTheme = currentTheme?.metadata.variant === 'dark';
const [displayedText, setDisplayedText] = React.useState<string | null>(null);
const [displayedPermission, setDisplayedPermission] = React.useState<boolean>(false);
const displayedTextRef = React.useRef(displayedText);
const displayedPermissionRef = React.useRef(displayedPermission);
displayedTextRef.current = displayedText;
displayedPermissionRef.current = displayedPermission;
const statusShownAtRef = React.useRef<number>(0);
const queuedStatusRef = React.useRef<{ text: string; permission: boolean } | null>(null);
const processQueueTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
// Countdown state for retry mode
const [retryCountdown, setRetryCountdown] = React.useState<number | null>(null);
React.useEffect(() => {
const rawNext = retryInfo?.next;
if (!rawNext || rawNext <= 0) {
setRetryCountdown(null);
return;
}
const retryTargetAt = toRetryTargetTimestamp(rawNext);
const update = () => {
const remaining = Math.max(0, retryTargetAt - Date.now());
setRetryCountdown(Math.ceil(remaining / 1000));
};
update();
const id = setInterval(update, 500);
return () => clearInterval(id);
}, [retryInfo?.next, retryInfo?.attempt]);
const clearTimers = React.useCallback(() => {
if (processQueueTimerRef.current) {
clearTimeout(processQueueTimerRef.current);
processQueueTimerRef.current = null;
}
}, []);
const showStatus = React.useCallback((text: string, permission: boolean) => {
clearTimers();
queuedStatusRef.current = null;
setDisplayedText(text);
setDisplayedPermission(permission);
statusShownAtRef.current = Date.now();
}, [clearTimers]);
const scheduleQueueProcess = React.useCallback(() => {
if (processQueueTimerRef.current) return;
const elapsed = Date.now() - statusShownAtRef.current;
const remaining = Math.max(0, STATUS_DISPLAY_TIME_MS - elapsed);
processQueueTimerRef.current = setTimeout(() => {
processQueueTimerRef.current = null;
const queued = queuedStatusRef.current;
if (queued) {
showStatus(queued.text, queued.permission);
}
}, remaining);
}, [showStatus]);
React.useEffect(() => {
if (!isWorking) {
clearTimers();
queuedStatusRef.current = null;
setDisplayedText(null);
setDisplayedPermission(false);
return;
}
// Retry state has its own display — skip the normal queue
if (retryInfo) {
clearTimers();
queuedStatusRef.current = null;
return;
}
const incomingText = isWaitingForPermission ? 'waiting for permission' : statusText;
const incomingPermission = Boolean(isWaitingForPermission);
const incomingGeneric = Boolean(isGenericStatus) && !incomingPermission;
if (!incomingText) {
return;
}
if (!displayedTextRef.current) {
showStatus(incomingText, incomingPermission);
return;
}
if (incomingText === displayedTextRef.current && incomingPermission === displayedPermissionRef.current) {
return;
}
// Ignore generic churn.
if (incomingGeneric) {
return;
}
const elapsed = Date.now() - statusShownAtRef.current;
if (elapsed >= STATUS_DISPLAY_TIME_MS) {
showStatus(incomingText, incomingPermission);
return;
}
queuedStatusRef.current = { text: incomingText, permission: incomingPermission };
scheduleQueueProcess();
}, [
isWorking,
statusText,
isGenericStatus,
isWaitingForPermission,
retryInfo,
clearTimers,
showStatus,
scheduleQueueProcess,
]);
React.useEffect(() => () => clearTimers(), [clearTimers]);
if (!isWorking) {
return null;
}
// Retry state: show countdown and attempt info
if (retryInfo) {
const attemptLabel = retryInfo.attempt && retryInfo.attempt > 1 ? ` (attempt ${retryInfo.attempt})` : '';
const countdownLabel = retryCountdown !== null && retryCountdown > 0
? ` in ${formatRetryCountdown(retryCountdown)}`
: '';
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}...`}
>
<span className="typography-ui-header">
{retryText}
<BusyDots />
</span>
</div>
);
}
if (!displayedText) {
return null;
}
const trimmedModelName = typeof modelName === 'string' ? modelName.trim() : '';
const label = trimmedModelName.length > 0
? t('chat.statusRow.modelStatus', { model: trimmedModelName, status: displayedText })
: displayedText.charAt(0).toUpperCase() + displayedText.slice(1);
return (
<div
className={
'flex h-full items-center text-muted-foreground pl-0.5'
}
role="status"
aria-live={displayedPermission ? 'assertive' : 'polite'}
aria-label={label}
data-waiting={displayedPermission ? 'true' : undefined}
>
<span className="typography-ui-header">
{hasProviderLogo && providerLogoSrc ? (
<img
src={providerLogoSrc}
alt=""
aria-hidden="true"
className="inline-block h-3.5 w-3.5 mr-1.5 align-[-2px]"
style={{
filter: isDarkTheme ? 'brightness(0.9) contrast(1.1) invert(1)' : 'brightness(0.9) contrast(1.1)',
}}
onError={handleProviderLogoError}
/>
) : null}
{label}
<BusyDots />
</span>
</div>
);
}