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
This commit is contained in:
Bohdan Triapitsyn
2026-07-19 22:58:28 +03:00
parent 8e718ee58c
commit 4be6854ba5
15 changed files with 84 additions and 11 deletions
@@ -133,6 +133,8 @@ interface StatusRowProps {
showAssistantStatus?: boolean;
showTodos?: boolean;
agentName?: string;
modelName?: string | null;
providerId?: string | null;
leftAccessory?: React.ReactNode;
}
@@ -150,6 +152,8 @@ export const StatusRow: React.FC<StatusRowProps> = ({
showAssistantStatus = true,
showTodos = true,
agentName,
modelName,
providerId,
leftAccessory,
}) => {
const { t } = useI18n();
@@ -313,6 +317,8 @@ export const StatusRow: React.FC<StatusRowProps> = ({
isWaitingForPermission={isWaitingForPermission}
retryInfo={retryInfo}
agentName={agentName}
modelName={modelName}
providerId={providerId}
/>
) : leftAccessory ? (
leftAccessory
@@ -3,6 +3,7 @@ import React from 'react';
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
import { useConfigStore } from '@/stores/useConfigStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { getProviderModelDisplayName } from '@/lib/modelDisplay';
import { StatusRow } from './StatusRow';
/**
@@ -22,6 +23,19 @@ export const StatusRowContainer: React.FC = React.memo(() => {
);
const { working } = useAssistantStatus();
const currentAgentName = useConfigStore((state) => state.currentAgentName);
const currentProviderId = useConfigStore((state) => state.currentProviderId);
const currentModelId = useConfigStore((state) => state.currentModelId);
const providers = useConfigStore((state) => state.providers);
const modelDisplayName = React.useMemo(() => {
if (!currentModelId) {
return null;
}
const provider = currentProviderId && providers.length > 0
? providers.find((candidate) => candidate.id === currentProviderId)
: undefined;
return getProviderModelDisplayName(provider, currentModelId) || null;
}, [currentProviderId, currentModelId, providers]);
const wasAborted = Boolean(abortRecord && !abortRecord.acknowledged);
@@ -37,6 +51,8 @@ export const StatusRowContainer: React.FC = React.memo(() => {
showAssistantStatus
showTodos={false}
agentName={currentAgentName}
modelName={modelDisplayName}
providerId={currentProviderId ?? null}
/>
);
});
@@ -1,5 +1,8 @@
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;
@@ -8,6 +11,8 @@ interface WorkingPlaceholderProps {
isWaitingForPermission?: boolean;
retryInfo?: { attempt?: number; next?: number } | null;
agentName?: string;
modelName?: string | null;
providerId?: string | null;
}
const STATUS_DISPLAY_TIME_MS = 1200;
@@ -58,7 +63,13 @@ export function WorkingPlaceholder({
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);
@@ -211,7 +222,10 @@ export function WorkingPlaceholder({
return null;
}
const label = displayedText.charAt(0).toUpperCase() + displayedText.slice(1);
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
@@ -224,6 +238,18 @@ export function WorkingPlaceholder({
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>
+15 -10
View File
@@ -346,13 +346,19 @@ export const useChatAutoFollow = ({
const el = scrollRef.current;
if (!el) return;
markAuto(el);
// `scrollHeight` is rounded to an integer while the real content height
// is fractional (prose line-heights), so `scrollTop = scrollHeight`
// leaves a 01px remainder that oscillates per streamed token and makes
// bottom-anchored rows jitter vertically. An over-large target clamps to
// the exact fractional maximum instead, pinning content to the bottom.
const overshootTarget = el.scrollHeight + 4096;
if (behavior === 'smooth') {
el.scrollTo({ top: el.scrollHeight, behavior });
el.scrollTo({ top: overshootTarget, behavior });
return;
}
// Direct `scrollTop` assignment bypasses any CSS `scroll-behavior: smooth`
// and lands in the same frame — no visible catch-up animation.
el.scrollTop = el.scrollHeight;
el.scrollTop = overshootTarget;
}, [markAuto]);
// `force` true = user-intent jump (clears released and always scrolls).
@@ -370,15 +376,14 @@ export const useChatAutoFollow = ({
if (!el) return;
if (!force && stateRef.current !== 'following') return;
const distance = distanceFromBottom(el);
if (distance < AUTO_MATCH_TOLERANCE_PX) {
// Already at the bottom; just refresh the auto marker so the next
// scroll event is recognised as ours.
markAuto(el);
return;
}
// Always re-pin, even when already within tolerance of the bottom.
// Sub-tolerance growth (fractional line-height remainders) would
// otherwise leave the bottom drifting by up to ±AUTO_MATCH_TOLERANCE_PX
// between full re-pins, which reads as 1px vertical jitter on
// bottom-anchored rows during streaming. The write happens pre-paint
// (ResizeObserver) and is a no-op when the position is unchanged.
scrollToBottomNow(force ? behavior : 'auto');
}, [isActive, markAuto, scrollToBottomNow, setStateValue]);
}, [isActive, scrollToBottomNow, setStateValue]);
// User left the bottom — release auto-follow.
const stop = React.useCallback(() => {
+10
View File
@@ -138,6 +138,16 @@ textarea[data-chat-input="true"]:focus-visible {
cursor: not-allowed;
}
/* Fractional line heights (1.625 × font-size) make streamed content grow by
sub-pixel amounts while scroll positions quantize to the device-pixel grid.
The residue accumulates and bottom-anchored rows (status indicator) creep
and snap by 1px per few lines during streaming. Rounding the line height to
whole pixels keeps content growth on the pixel grid. */
.message-content-text.leading-relaxed,
.message-content-text .leading-relaxed {
line-height: round(1.625em, 1px);
}
:root.light .message-content-text::selection,
:root.light .message-content-text ::selection {
background: color-mix(in srgb, var(--interactive-border-focus) 18%, transparent);
+1
View File
@@ -1797,6 +1797,7 @@ export const dict = {
'chat.statusRow.todo.priority.low': 'Low priority',
'chat.statusRow.actions.stopGeneratingAria': 'Stop generating',
'chat.statusRow.tasksTitle': 'Tasks',
'chat.statusRow.modelStatus': '{model} is {status}',
'chat.statusRow.summary.activeLeft': '{active} active · {left} left',
'chat.statusRow.aborted': 'Aborted',
'chat.revertIndicator.redo': 'Redo',
+1
View File
@@ -1775,6 +1775,7 @@ export const dict: Record<I18nKey, string> = {
"chat.statusRow.todo.priority.low": "Prioridad baja",
"chat.statusRow.actions.stopGeneratingAria": "Detener la generación",
"chat.statusRow.tasksTitle": "Tareas",
"chat.statusRow.modelStatus": "{model} · {status}",
"chat.statusRow.summary.activeLeft": "{active} activas · {left} restantes",
"chat.statusRow.aborted": "Interrumpido",
"chat.revertIndicator.redo": "Rehacer",
+1
View File
@@ -1601,6 +1601,7 @@ export const dict = {
'chat.statusRow.todo.priority.low': 'Faible priorité',
'chat.statusRow.actions.stopGeneratingAria': 'Arrêter de générer',
'chat.statusRow.tasksTitle': 'Tâches',
'chat.statusRow.modelStatus': '{model} · {status}',
'chat.statusRow.summary.activeLeft': '{active} actif · {left} gauche',
'chat.statusRow.aborted': 'Avorté',
'chat.revertIndicator.redo': 'Refaire',
+1
View File
@@ -1793,6 +1793,7 @@ export const dict: Record<I18nKey, string> = {
'chat.statusRow.todo.priority.low': '低優先度',
'chat.statusRow.actions.stopGeneratingAria': '生成を停止',
'chat.statusRow.tasksTitle': 'タスク',
'chat.statusRow.modelStatus': '{model} · {status}',
'chat.statusRow.summary.activeLeft': '{active}アクティブ · {left}残り',
'chat.statusRow.aborted': '中止されました',
'chat.revertIndicator.redo': 'やり直し',
+1
View File
@@ -1799,6 +1799,7 @@ export const dict: Record<I18nKey, string> = {
'chat.statusRow.todo.priority.low': '낮은 우선순위',
'chat.statusRow.actions.stopGeneratingAria': '생성 중지',
'chat.statusRow.tasksTitle': '작업',
'chat.statusRow.modelStatus': '{model} · {status}',
'chat.statusRow.summary.activeLeft': '{active}개 활성 · {left}개 남음',
'chat.statusRow.aborted': '중단됨',
'chat.revertIndicator.redo': '다시 실행',
+1
View File
@@ -687,6 +687,7 @@ export const dict: Record<I18nKey, string> = {
'chat.statusRow.todo.priority.low': 'Niski priorytet',
'chat.statusRow.actions.stopGeneratingAria': 'Zatrzymaj generowanie',
'chat.statusRow.tasksTitle': 'Zadania',
'chat.statusRow.modelStatus': '{model} · {status}',
'chat.statusRow.summary.activeLeft': '{active} aktywne · {left} pozostało',
'chat.statusRow.aborted': 'Przerwane',
'chat.revertIndicator.redo': 'Ponów',
@@ -1775,6 +1775,7 @@ export const dict: Record<I18nKey, string> = {
"chat.statusRow.todo.priority.low": "Prioridade baixa",
"chat.statusRow.actions.stopGeneratingAria": "Parar a geração",
"chat.statusRow.tasksTitle": "Tarefas",
"chat.statusRow.modelStatus": "{model} · {status}",
"chat.statusRow.summary.activeLeft": "{active} ativas · {left} restantes",
"chat.statusRow.aborted": "Interrompido",
"chat.revertIndicator.redo": "Refazer",
+1
View File
@@ -1775,6 +1775,7 @@ export const dict: Record<I18nKey, string> = {
"chat.statusRow.todo.priority.low": "Низький пріоритет",
"chat.statusRow.actions.stopGeneratingAria": "Припинити генерацію",
"chat.statusRow.tasksTitle": "завдання",
"chat.statusRow.modelStatus": "{model} · {status}",
"chat.statusRow.summary.activeLeft": "Активних: {active} · залишилось: {left}",
"chat.statusRow.aborted": "Перервано",
"chat.revertIndicator.redo": "Повторити",
@@ -1763,6 +1763,7 @@ export const dict: Record<I18nKey, string> = {
'chat.statusRow.todo.priority.low': '低优先级',
'chat.statusRow.actions.stopGeneratingAria': '停止生成',
'chat.statusRow.tasksTitle': '任务',
'chat.statusRow.modelStatus': '{model} · {status}',
'chat.statusRow.summary.activeLeft': '{active} 个活跃 · 剩余 {left} 个',
'chat.statusRow.aborted': '已中止',
'chat.revertIndicator.redo': '重做',
@@ -1767,6 +1767,7 @@ export const dict: Record<I18nKey, string> = {
'chat.statusRow.todo.priority.low': '低優先級',
'chat.statusRow.actions.stopGeneratingAria': '停止生成',
'chat.statusRow.tasksTitle': '任務',
'chat.statusRow.modelStatus': '{model} · {status}',
'chat.statusRow.summary.activeLeft': '{active} 個活躍 · 剩餘 {left} 個',
'chat.statusRow.aborted': '已中止',
'chat.revertIndicator.redo': '重做',