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:
@@ -133,6 +133,8 @@ interface StatusRowProps {
|
|||||||
showAssistantStatus?: boolean;
|
showAssistantStatus?: boolean;
|
||||||
showTodos?: boolean;
|
showTodos?: boolean;
|
||||||
agentName?: string;
|
agentName?: string;
|
||||||
|
modelName?: string | null;
|
||||||
|
providerId?: string | null;
|
||||||
leftAccessory?: React.ReactNode;
|
leftAccessory?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,6 +152,8 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
|||||||
showAssistantStatus = true,
|
showAssistantStatus = true,
|
||||||
showTodos = true,
|
showTodos = true,
|
||||||
agentName,
|
agentName,
|
||||||
|
modelName,
|
||||||
|
providerId,
|
||||||
leftAccessory,
|
leftAccessory,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -313,6 +317,8 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
|||||||
isWaitingForPermission={isWaitingForPermission}
|
isWaitingForPermission={isWaitingForPermission}
|
||||||
retryInfo={retryInfo}
|
retryInfo={retryInfo}
|
||||||
agentName={agentName}
|
agentName={agentName}
|
||||||
|
modelName={modelName}
|
||||||
|
providerId={providerId}
|
||||||
/>
|
/>
|
||||||
) : leftAccessory ? (
|
) : leftAccessory ? (
|
||||||
leftAccessory
|
leftAccessory
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import React from 'react';
|
|||||||
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
|
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { getProviderModelDisplayName } from '@/lib/modelDisplay';
|
||||||
import { StatusRow } from './StatusRow';
|
import { StatusRow } from './StatusRow';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,6 +23,19 @@ export const StatusRowContainer: React.FC = React.memo(() => {
|
|||||||
);
|
);
|
||||||
const { working } = useAssistantStatus();
|
const { working } = useAssistantStatus();
|
||||||
const currentAgentName = useConfigStore((state) => state.currentAgentName);
|
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);
|
const wasAborted = Boolean(abortRecord && !abortRecord.acknowledged);
|
||||||
|
|
||||||
@@ -37,6 +51,8 @@ export const StatusRowContainer: React.FC = React.memo(() => {
|
|||||||
showAssistantStatus
|
showAssistantStatus
|
||||||
showTodos={false}
|
showTodos={false}
|
||||||
agentName={currentAgentName}
|
agentName={currentAgentName}
|
||||||
|
modelName={modelDisplayName}
|
||||||
|
providerId={currentProviderId ?? null}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { BusyDots } from './BusyDots';
|
import { BusyDots } from './BusyDots';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { useProviderLogo } from '@/hooks/useProviderLogo';
|
||||||
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
|
|
||||||
interface WorkingPlaceholderProps {
|
interface WorkingPlaceholderProps {
|
||||||
isWorking: boolean;
|
isWorking: boolean;
|
||||||
@@ -8,6 +11,8 @@ interface WorkingPlaceholderProps {
|
|||||||
isWaitingForPermission?: boolean;
|
isWaitingForPermission?: boolean;
|
||||||
retryInfo?: { attempt?: number; next?: number } | null;
|
retryInfo?: { attempt?: number; next?: number } | null;
|
||||||
agentName?: string;
|
agentName?: string;
|
||||||
|
modelName?: string | null;
|
||||||
|
providerId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_DISPLAY_TIME_MS = 1200;
|
const STATUS_DISPLAY_TIME_MS = 1200;
|
||||||
@@ -58,7 +63,13 @@ export function WorkingPlaceholder({
|
|||||||
isGenericStatus,
|
isGenericStatus,
|
||||||
isWaitingForPermission,
|
isWaitingForPermission,
|
||||||
retryInfo,
|
retryInfo,
|
||||||
|
modelName,
|
||||||
|
providerId,
|
||||||
}: WorkingPlaceholderProps) {
|
}: 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 [displayedText, setDisplayedText] = React.useState<string | null>(null);
|
||||||
const [displayedPermission, setDisplayedPermission] = React.useState<boolean>(false);
|
const [displayedPermission, setDisplayedPermission] = React.useState<boolean>(false);
|
||||||
const displayedTextRef = React.useRef(displayedText);
|
const displayedTextRef = React.useRef(displayedText);
|
||||||
@@ -211,7 +222,10 @@ export function WorkingPlaceholder({
|
|||||||
return null;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -224,6 +238,18 @@ export function WorkingPlaceholder({
|
|||||||
data-waiting={displayedPermission ? 'true' : undefined}
|
data-waiting={displayedPermission ? 'true' : undefined}
|
||||||
>
|
>
|
||||||
<span className="typography-ui-header">
|
<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}
|
{label}
|
||||||
<BusyDots />
|
<BusyDots />
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -346,13 +346,19 @@ export const useChatAutoFollow = ({
|
|||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
markAuto(el);
|
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') {
|
if (behavior === 'smooth') {
|
||||||
el.scrollTo({ top: el.scrollHeight, behavior });
|
el.scrollTo({ top: overshootTarget, behavior });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Direct `scrollTop` assignment bypasses any CSS `scroll-behavior: smooth`
|
// Direct `scrollTop` assignment bypasses any CSS `scroll-behavior: smooth`
|
||||||
// and lands in the same frame — no visible catch-up animation.
|
// and lands in the same frame — no visible catch-up animation.
|
||||||
el.scrollTop = el.scrollHeight;
|
el.scrollTop = overshootTarget;
|
||||||
}, [markAuto]);
|
}, [markAuto]);
|
||||||
|
|
||||||
// `force` true = user-intent jump (clears released and always scrolls).
|
// `force` true = user-intent jump (clears released and always scrolls).
|
||||||
@@ -370,15 +376,14 @@ export const useChatAutoFollow = ({
|
|||||||
if (!el) return;
|
if (!el) return;
|
||||||
if (!force && stateRef.current !== 'following') return;
|
if (!force && stateRef.current !== 'following') return;
|
||||||
|
|
||||||
const distance = distanceFromBottom(el);
|
// Always re-pin, even when already within tolerance of the bottom.
|
||||||
if (distance < AUTO_MATCH_TOLERANCE_PX) {
|
// Sub-tolerance growth (fractional line-height remainders) would
|
||||||
// Already at the bottom; just refresh the auto marker so the next
|
// otherwise leave the bottom drifting by up to ±AUTO_MATCH_TOLERANCE_PX
|
||||||
// scroll event is recognised as ours.
|
// between full re-pins, which reads as 1px vertical jitter on
|
||||||
markAuto(el);
|
// bottom-anchored rows during streaming. The write happens pre-paint
|
||||||
return;
|
// (ResizeObserver) and is a no-op when the position is unchanged.
|
||||||
}
|
|
||||||
scrollToBottomNow(force ? behavior : 'auto');
|
scrollToBottomNow(force ? behavior : 'auto');
|
||||||
}, [isActive, markAuto, scrollToBottomNow, setStateValue]);
|
}, [isActive, scrollToBottomNow, setStateValue]);
|
||||||
|
|
||||||
// User left the bottom — release auto-follow.
|
// User left the bottom — release auto-follow.
|
||||||
const stop = React.useCallback(() => {
|
const stop = React.useCallback(() => {
|
||||||
|
|||||||
@@ -138,6 +138,16 @@ textarea[data-chat-input="true"]:focus-visible {
|
|||||||
cursor: not-allowed;
|
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,
|
||||||
:root.light .message-content-text ::selection {
|
:root.light .message-content-text ::selection {
|
||||||
background: color-mix(in srgb, var(--interactive-border-focus) 18%, transparent);
|
background: color-mix(in srgb, var(--interactive-border-focus) 18%, transparent);
|
||||||
|
|||||||
@@ -1797,6 +1797,7 @@ export const dict = {
|
|||||||
'chat.statusRow.todo.priority.low': 'Low priority',
|
'chat.statusRow.todo.priority.low': 'Low priority',
|
||||||
'chat.statusRow.actions.stopGeneratingAria': 'Stop generating',
|
'chat.statusRow.actions.stopGeneratingAria': 'Stop generating',
|
||||||
'chat.statusRow.tasksTitle': 'Tasks',
|
'chat.statusRow.tasksTitle': 'Tasks',
|
||||||
|
'chat.statusRow.modelStatus': '{model} is {status}',
|
||||||
'chat.statusRow.summary.activeLeft': '{active} active · {left} left',
|
'chat.statusRow.summary.activeLeft': '{active} active · {left} left',
|
||||||
'chat.statusRow.aborted': 'Aborted',
|
'chat.statusRow.aborted': 'Aborted',
|
||||||
'chat.revertIndicator.redo': 'Redo',
|
'chat.revertIndicator.redo': 'Redo',
|
||||||
|
|||||||
@@ -1775,6 +1775,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"chat.statusRow.todo.priority.low": "Prioridad baja",
|
"chat.statusRow.todo.priority.low": "Prioridad baja",
|
||||||
"chat.statusRow.actions.stopGeneratingAria": "Detener la generación",
|
"chat.statusRow.actions.stopGeneratingAria": "Detener la generación",
|
||||||
"chat.statusRow.tasksTitle": "Tareas",
|
"chat.statusRow.tasksTitle": "Tareas",
|
||||||
|
"chat.statusRow.modelStatus": "{model} · {status}",
|
||||||
"chat.statusRow.summary.activeLeft": "{active} activas · {left} restantes",
|
"chat.statusRow.summary.activeLeft": "{active} activas · {left} restantes",
|
||||||
"chat.statusRow.aborted": "Interrumpido",
|
"chat.statusRow.aborted": "Interrumpido",
|
||||||
"chat.revertIndicator.redo": "Rehacer",
|
"chat.revertIndicator.redo": "Rehacer",
|
||||||
|
|||||||
@@ -1601,6 +1601,7 @@ export const dict = {
|
|||||||
'chat.statusRow.todo.priority.low': 'Faible priorité',
|
'chat.statusRow.todo.priority.low': 'Faible priorité',
|
||||||
'chat.statusRow.actions.stopGeneratingAria': 'Arrêter de générer',
|
'chat.statusRow.actions.stopGeneratingAria': 'Arrêter de générer',
|
||||||
'chat.statusRow.tasksTitle': 'Tâches',
|
'chat.statusRow.tasksTitle': 'Tâches',
|
||||||
|
'chat.statusRow.modelStatus': '{model} · {status}',
|
||||||
'chat.statusRow.summary.activeLeft': '{active} actif · {left} gauche',
|
'chat.statusRow.summary.activeLeft': '{active} actif · {left} gauche',
|
||||||
'chat.statusRow.aborted': 'Avorté',
|
'chat.statusRow.aborted': 'Avorté',
|
||||||
'chat.revertIndicator.redo': 'Refaire',
|
'chat.revertIndicator.redo': 'Refaire',
|
||||||
|
|||||||
@@ -1793,6 +1793,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'chat.statusRow.todo.priority.low': '低優先度',
|
'chat.statusRow.todo.priority.low': '低優先度',
|
||||||
'chat.statusRow.actions.stopGeneratingAria': '生成を停止',
|
'chat.statusRow.actions.stopGeneratingAria': '生成を停止',
|
||||||
'chat.statusRow.tasksTitle': 'タスク',
|
'chat.statusRow.tasksTitle': 'タスク',
|
||||||
|
'chat.statusRow.modelStatus': '{model} · {status}',
|
||||||
'chat.statusRow.summary.activeLeft': '{active}アクティブ · {left}残り',
|
'chat.statusRow.summary.activeLeft': '{active}アクティブ · {left}残り',
|
||||||
'chat.statusRow.aborted': '中止されました',
|
'chat.statusRow.aborted': '中止されました',
|
||||||
'chat.revertIndicator.redo': 'やり直し',
|
'chat.revertIndicator.redo': 'やり直し',
|
||||||
|
|||||||
@@ -1799,6 +1799,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'chat.statusRow.todo.priority.low': '낮은 우선순위',
|
'chat.statusRow.todo.priority.low': '낮은 우선순위',
|
||||||
'chat.statusRow.actions.stopGeneratingAria': '생성 중지',
|
'chat.statusRow.actions.stopGeneratingAria': '생성 중지',
|
||||||
'chat.statusRow.tasksTitle': '작업',
|
'chat.statusRow.tasksTitle': '작업',
|
||||||
|
'chat.statusRow.modelStatus': '{model} · {status}',
|
||||||
'chat.statusRow.summary.activeLeft': '{active}개 활성 · {left}개 남음',
|
'chat.statusRow.summary.activeLeft': '{active}개 활성 · {left}개 남음',
|
||||||
'chat.statusRow.aborted': '중단됨',
|
'chat.statusRow.aborted': '중단됨',
|
||||||
'chat.revertIndicator.redo': '다시 실행',
|
'chat.revertIndicator.redo': '다시 실행',
|
||||||
|
|||||||
@@ -687,6 +687,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'chat.statusRow.todo.priority.low': 'Niski priorytet',
|
'chat.statusRow.todo.priority.low': 'Niski priorytet',
|
||||||
'chat.statusRow.actions.stopGeneratingAria': 'Zatrzymaj generowanie',
|
'chat.statusRow.actions.stopGeneratingAria': 'Zatrzymaj generowanie',
|
||||||
'chat.statusRow.tasksTitle': 'Zadania',
|
'chat.statusRow.tasksTitle': 'Zadania',
|
||||||
|
'chat.statusRow.modelStatus': '{model} · {status}',
|
||||||
'chat.statusRow.summary.activeLeft': '{active} aktywne · {left} pozostało',
|
'chat.statusRow.summary.activeLeft': '{active} aktywne · {left} pozostało',
|
||||||
'chat.statusRow.aborted': 'Przerwane',
|
'chat.statusRow.aborted': 'Przerwane',
|
||||||
'chat.revertIndicator.redo': 'Ponów',
|
'chat.revertIndicator.redo': 'Ponów',
|
||||||
|
|||||||
@@ -1775,6 +1775,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"chat.statusRow.todo.priority.low": "Prioridade baixa",
|
"chat.statusRow.todo.priority.low": "Prioridade baixa",
|
||||||
"chat.statusRow.actions.stopGeneratingAria": "Parar a geração",
|
"chat.statusRow.actions.stopGeneratingAria": "Parar a geração",
|
||||||
"chat.statusRow.tasksTitle": "Tarefas",
|
"chat.statusRow.tasksTitle": "Tarefas",
|
||||||
|
"chat.statusRow.modelStatus": "{model} · {status}",
|
||||||
"chat.statusRow.summary.activeLeft": "{active} ativas · {left} restantes",
|
"chat.statusRow.summary.activeLeft": "{active} ativas · {left} restantes",
|
||||||
"chat.statusRow.aborted": "Interrompido",
|
"chat.statusRow.aborted": "Interrompido",
|
||||||
"chat.revertIndicator.redo": "Refazer",
|
"chat.revertIndicator.redo": "Refazer",
|
||||||
|
|||||||
@@ -1775,6 +1775,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"chat.statusRow.todo.priority.low": "Низький пріоритет",
|
"chat.statusRow.todo.priority.low": "Низький пріоритет",
|
||||||
"chat.statusRow.actions.stopGeneratingAria": "Припинити генерацію",
|
"chat.statusRow.actions.stopGeneratingAria": "Припинити генерацію",
|
||||||
"chat.statusRow.tasksTitle": "завдання",
|
"chat.statusRow.tasksTitle": "завдання",
|
||||||
|
"chat.statusRow.modelStatus": "{model} · {status}",
|
||||||
"chat.statusRow.summary.activeLeft": "Активних: {active} · залишилось: {left}",
|
"chat.statusRow.summary.activeLeft": "Активних: {active} · залишилось: {left}",
|
||||||
"chat.statusRow.aborted": "Перервано",
|
"chat.statusRow.aborted": "Перервано",
|
||||||
"chat.revertIndicator.redo": "Повторити",
|
"chat.revertIndicator.redo": "Повторити",
|
||||||
|
|||||||
@@ -1763,6 +1763,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'chat.statusRow.todo.priority.low': '低优先级',
|
'chat.statusRow.todo.priority.low': '低优先级',
|
||||||
'chat.statusRow.actions.stopGeneratingAria': '停止生成',
|
'chat.statusRow.actions.stopGeneratingAria': '停止生成',
|
||||||
'chat.statusRow.tasksTitle': '任务',
|
'chat.statusRow.tasksTitle': '任务',
|
||||||
|
'chat.statusRow.modelStatus': '{model} · {status}',
|
||||||
'chat.statusRow.summary.activeLeft': '{active} 个活跃 · 剩余 {left} 个',
|
'chat.statusRow.summary.activeLeft': '{active} 个活跃 · 剩余 {left} 个',
|
||||||
'chat.statusRow.aborted': '已中止',
|
'chat.statusRow.aborted': '已中止',
|
||||||
'chat.revertIndicator.redo': '重做',
|
'chat.revertIndicator.redo': '重做',
|
||||||
|
|||||||
@@ -1767,6 +1767,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'chat.statusRow.todo.priority.low': '低優先級',
|
'chat.statusRow.todo.priority.low': '低優先級',
|
||||||
'chat.statusRow.actions.stopGeneratingAria': '停止生成',
|
'chat.statusRow.actions.stopGeneratingAria': '停止生成',
|
||||||
'chat.statusRow.tasksTitle': '任務',
|
'chat.statusRow.tasksTitle': '任務',
|
||||||
|
'chat.statusRow.modelStatus': '{model} · {status}',
|
||||||
'chat.statusRow.summary.activeLeft': '{active} 個活躍 · 剩餘 {left} 個',
|
'chat.statusRow.summary.activeLeft': '{active} 個活躍 · 剩餘 {left} 個',
|
||||||
'chat.statusRow.aborted': '已中止',
|
'chat.statusRow.aborted': '已中止',
|
||||||
'chat.revertIndicator.redo': '重做',
|
'chat.revertIndicator.redo': '重做',
|
||||||
|
|||||||
Reference in New Issue
Block a user