diff --git a/packages/client/ui-chat/src/client/chat/StatsLine.tsx b/packages/client/ui-chat/src/client/chat/StatsLine.tsx index 25c9516b02..170ffec956 100644 --- a/packages/client/ui-chat/src/client/chat/StatsLine.tsx +++ b/packages/client/ui-chat/src/client/chat/StatsLine.tsx @@ -2,7 +2,7 @@ // Mounted on 'conversation.composer.dock' so it sticks with the composer in the // active conversation scrollport (see ConversationRoot data-conversation-scroll). -import { Fragment, memo, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { Fragment, memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react' import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { UseProjection } from '@deepseek-ai/dsh-api-session-controller/client' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' @@ -122,6 +122,44 @@ export interface StatsLineProps { t: ChatViewSlotProps['t'] } +/** Render and measure one non-empty statistics line. */ +const StatsLineContent = memo(function StatsLineContent({ + groups, + line, +}: { + readonly groups: readonly string[] + readonly line: string +}) { + const rootRef = useRef(null) + const [truncated, setTruncated] = useState(false) + const measure = useCallback(() => { + const el = rootRef.current + if (el === null) return + const next = el.scrollWidth > el.clientWidth + setTruncated(current => current === next ? current : next) + }, []) + useLayoutEffect(() => { + const el = rootRef.current + if (el === null || typeof ResizeObserver === 'undefined') return + const observer = new ResizeObserver(measure) + observer.observe(el) + return () => { observer.disconnect() } + }, [measure]) + useLayoutEffect(measure, [line, measure]) + return ( + +
+ {groups.map((group, i) => ( + + {i > 0 && <>|{' '}} + {group} + + ))} +
+
+ ) +}) + export const StatsLine = memo(function StatsLine({ useChat, useProjection, t }: StatsLineProps) { const settledNodes = useChat(s => s.legacy.nodes) const usage = useProjection('tokenUsage') @@ -166,31 +204,6 @@ export const StatsLine = memo(function StatsLine({ useChat, useProjection, t }: })) } const line = groups.join(' | ') - // The row elides with ellipsis when overlong; a delayed hover tooltip carries - // the full line, enabled only while content is actually clipped. - const rootRef = useRef(null) - const [truncated, setTruncated] = useState(false) - useLayoutEffect(() => { - const el = rootRef.current - if (el === null) return - const measure = () => { setTruncated(el.scrollWidth > el.clientWidth) } - measure() - if (typeof ResizeObserver === 'undefined') return - const observer = new ResizeObserver(measure) - observer.observe(el) - return () => { observer.disconnect() } - }, [line]) if (groups.length === 0) return null - return ( - -
- {groups.map((group, i) => ( - - {i > 0 && <>|{' '}} - {group} - - ))} -
-
- ) + return }) diff --git a/packages/client/ui-chat/tests/chat-stats.client.spec.tsx b/packages/client/ui-chat/tests/chat-stats.client.spec.tsx index 01e17ed71e..e35aa297dc 100644 --- a/packages/client/ui-chat/tests/chat-stats.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-stats.client.spec.tsx @@ -162,6 +162,25 @@ describe('StatsLine', () => { return { uncachedInputTokens, outputTokens: 1, cacheReadTokens, cacheWriteTokens: 0 } } + it('mounts one size observer only while a non-empty line exists', () => { + let observers = 0 + class CountingResizeObserver { + constructor(_callback: ResizeObserverCallback) { observers++ } + observe(): void {} + disconnect(): void {} + } + vi.stubGlobal('ResizeObserver', CountingResizeObserver) + const { set, source } = makeSource() + render() + expect(observers).toBe(0) + + act(() => { set({ nodes: [assistant(1, 1)] }) }) + expect(observers).toBe(1) + + act(() => { set({ nodes: [assistant(1, 1), assistant(2, 1)] }) }) + expect(observers).toBe(1) + }) + it('renders the grouped stats row and hides a brand-new empty session', () => { const { source } = makeSource({ nodes: [assistant(1, 1)] }) const view = render()