From 4be6854ba541db67e4aa2d91b5b031834a9a8796 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 19 Jul 2026 22:58:28 +0300 Subject: [PATCH] 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 --- packages/ui/src/components/chat/StatusRow.tsx | 6 ++++ .../components/chat/StatusRowContainer.tsx | 16 +++++++++++ .../chat/message/parts/WorkingPlaceholder.tsx | 28 ++++++++++++++++++- packages/ui/src/hooks/useChatAutoFollow.ts | 25 ++++++++++------- packages/ui/src/index.css | 10 +++++++ packages/ui/src/lib/i18n/messages/en.ts | 1 + packages/ui/src/lib/i18n/messages/es.ts | 1 + packages/ui/src/lib/i18n/messages/fr.ts | 1 + packages/ui/src/lib/i18n/messages/ja.ts | 1 + packages/ui/src/lib/i18n/messages/ko.ts | 1 + packages/ui/src/lib/i18n/messages/pl.ts | 1 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 1 + packages/ui/src/lib/i18n/messages/uk.ts | 1 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 1 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 1 + 15 files changed, 84 insertions(+), 11 deletions(-) diff --git a/packages/ui/src/components/chat/StatusRow.tsx b/packages/ui/src/components/chat/StatusRow.tsx index 168ca453..ede1c1c1 100644 --- a/packages/ui/src/components/chat/StatusRow.tsx +++ b/packages/ui/src/components/chat/StatusRow.tsx @@ -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 = ({ showAssistantStatus = true, showTodos = true, agentName, + modelName, + providerId, leftAccessory, }) => { const { t } = useI18n(); @@ -313,6 +317,8 @@ export const StatusRow: React.FC = ({ isWaitingForPermission={isWaitingForPermission} retryInfo={retryInfo} agentName={agentName} + modelName={modelName} + providerId={providerId} /> ) : leftAccessory ? ( leftAccessory diff --git a/packages/ui/src/components/chat/StatusRowContainer.tsx b/packages/ui/src/components/chat/StatusRowContainer.tsx index 7f33a78b..03f95dd0 100644 --- a/packages/ui/src/components/chat/StatusRowContainer.tsx +++ b/packages/ui/src/components/chat/StatusRowContainer.tsx @@ -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} /> ); }); diff --git a/packages/ui/src/components/chat/message/parts/WorkingPlaceholder.tsx b/packages/ui/src/components/chat/message/parts/WorkingPlaceholder.tsx index 95c4fc8a..2a980448 100644 --- a/packages/ui/src/components/chat/message/parts/WorkingPlaceholder.tsx +++ b/packages/ui/src/components/chat/message/parts/WorkingPlaceholder.tsx @@ -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(null); const [displayedPermission, setDisplayedPermission] = React.useState(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 (
+ {hasProviderLogo && providerLogoSrc ? ( + + ) : null} {label} diff --git a/packages/ui/src/hooks/useChatAutoFollow.ts b/packages/ui/src/hooks/useChatAutoFollow.ts index a12d634f..194f6ede 100644 --- a/packages/ui/src/hooks/useChatAutoFollow.ts +++ b/packages/ui/src/hooks/useChatAutoFollow.ts @@ -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 0–1px 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(() => { diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index a432b303..f7bafb2b 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -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); diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 539088ac..f0bac895 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 2368e444..799a7b08 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1775,6 +1775,7 @@ export const dict: Record = { "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", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index c8c04def..a2b2f893 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 6a5b7c97..3243bac1 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -1793,6 +1793,7 @@ export const dict: Record = { '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': 'やり直し', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index b99f498b..8600b022 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1799,6 +1799,7 @@ export const dict: Record = { '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': '다시 실행', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 938da259..e36af240 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -687,6 +687,7 @@ export const dict: Record = { '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', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 591574ab..cf9d78cc 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1775,6 +1775,7 @@ export const dict: Record = { "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", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index e102b76d..32e452bf 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1775,6 +1775,7 @@ export const dict: Record = { "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": "Повторити", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 6f8ba4fa..b85d3e77 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1763,6 +1763,7 @@ export const dict: Record = { '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': '重做', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index c779e396..a614462e 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1767,6 +1767,7 @@ export const dict: Record = { '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': '重做',