fix(web): localize the context panel headline order and drop empty bar parts
The panel header concatenated a `45%` span with a `context.used` fragment, so
Chinese rendered "45% 上下文已用" against the ring's own "上下文已用 45%". The
header now renders the one localized `context.aria` sentence split around its
`{percent}` slot: each locale owns the reading's position while the reading
keeps its primary tone, and the side a locale leaves empty collapses through
`.headline:empty` instead of spending a header gap.
The bar mapped every composition row to a segment unconditionally, and
`.segment`'s 2px min-width kept each one visible, so a 0% occupancy panel
painted an ~8px filled bar over an empty context. Parts are now computed with
their widths and zero-width parts are filtered out, which also collapses the
plain and segmented branches into one map.
This commit is contained in:
@@ -23,7 +23,7 @@ export const zh = {
|
||||
'input.stop': '停止生成',
|
||||
'input.send': '发送消息',
|
||||
'input.accessMode': '访问模式,当前:{name}',
|
||||
'context.aria': '上下文已用 {percent}%',
|
||||
'context.aria': '上下文已用 {percent}',
|
||||
'context.used': '上下文已用',
|
||||
'context.system': '系统提示词',
|
||||
'context.tools': '工具',
|
||||
@@ -143,7 +143,7 @@ export const en = {
|
||||
'input.stop': 'Stop generating',
|
||||
'input.send': 'Send message',
|
||||
'input.accessMode': 'Access mode, current: {name}',
|
||||
'context.aria': '{percent}% of context used',
|
||||
'context.aria': '{percent} of context used',
|
||||
'context.used': 'of context used',
|
||||
'context.system': 'System prompt',
|
||||
'context.tools': 'Tools',
|
||||
|
||||
@@ -77,6 +77,12 @@
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* The headline brackets the reading, so the side a locale leaves empty must
|
||||
drop out of the flex row rather than spend a gap. */
|
||||
.headline:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
|
||||
@@ -17,6 +17,13 @@ import css from './ContextMeter.module.css'
|
||||
const RADIUS = 5.5
|
||||
const CIRCUMFERENCE = 2 * Math.PI * RADIUS
|
||||
|
||||
/**
|
||||
* Marker the localized occupancy sentence is split on, so the panel headline
|
||||
* keeps the reading in its own tone while each locale still owns the word
|
||||
* order (`45% of context used` / `上下文已用 45%`).
|
||||
*/
|
||||
const READING_SLOT = '\u0000'
|
||||
|
||||
/** Panel legend rows, in bar-segment order; each color class carries the shared swatch/segment tint. */
|
||||
const ROWS = [
|
||||
{ key: 'systemTokens', label: 'context.system', color: css.colorSystem },
|
||||
@@ -57,23 +64,30 @@ export function ContextMeter({ useProjection, t }: ContextMeterProps) {
|
||||
const context = contextOccupancy(pressure)
|
||||
if (context === null) return null
|
||||
const percent = context.percent
|
||||
const reading = `${percent}%`
|
||||
const [headBefore = '', headAfter = ''] = t('context.aria', { percent: READING_SLOT })
|
||||
.split(READING_SLOT)
|
||||
.map(part => part.trim())
|
||||
|
||||
// The bar's overall length stays the provider-exact percent; the heuristic
|
||||
// breakdown only proportions its colored segments.
|
||||
// breakdown only proportions its colored parts. A zero-width part is dropped
|
||||
// instead of rendered: `.segment`'s min-width keeps a hairline part visible,
|
||||
// which at 0% occupancy would draw a filled bar over an empty context.
|
||||
const breakdownTotal = breakdown === undefined
|
||||
? 0
|
||||
: breakdown.systemTokens + breakdown.toolsTokens + breakdown.messageTokens
|
||||
const segments = breakdown === undefined || breakdownTotal === 0
|
||||
? null
|
||||
: ROWS.map(row => ({ key: row.key, color: row.color, share: breakdown[row.key] / breakdownTotal }))
|
||||
const parts = breakdown === undefined || breakdownTotal === 0
|
||||
? [{ key: 'total', color: undefined, width: percent }]
|
||||
: ROWS.map(row => ({ key: row.key, color: row.color, width: percent * breakdown[row.key] / breakdownTotal }))
|
||||
const segments = parts.filter(part => part.width > 0)
|
||||
|
||||
return (
|
||||
<span ref={rootRef} className={css.root}>
|
||||
<Tooltip label={t('context.aria', { percent })} side="top" delayMs={200} disabled={open}>
|
||||
<Tooltip label={t('context.aria', { percent: reading })} side="top" delayMs={200} disabled={open}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.trigger}
|
||||
aria-label={t('context.aria', { percent })}
|
||||
aria-label={t('context.aria', { percent: reading })}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
onClick={() => { setOpen(!open) }}
|
||||
@@ -94,22 +108,23 @@ export function ContextMeter({ useProjection, t }: ContextMeterProps) {
|
||||
{open && (
|
||||
<div className={css.panel} role="dialog" aria-label={t('context.used')}>
|
||||
<div className={css.header}>
|
||||
<span className={css.percent}>{`${percent}%`}</span>
|
||||
<span className={css.headline}>{t('context.used')}</span>
|
||||
{/* Empty sides collapse through `.headline:empty` so the locale that
|
||||
needs no leading (or trailing) text spends no header gap. */}
|
||||
<span className={css.headline}>{headBefore}</span>
|
||||
<span className={css.percent}>{reading}</span>
|
||||
<span className={css.headline}>{headAfter}</span>
|
||||
<span className={css.figures}>
|
||||
{`~${formatTokens(context.pressureTokens)} / ${formatTokens(context.contextWindow)}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className={css.bar}>
|
||||
{segments === null
|
||||
? <div className={css.segment} style={{ width: `${percent}%` }} />
|
||||
: segments.map(segment => (
|
||||
<div
|
||||
key={segment.key}
|
||||
className={`${css.segment} ${segment.color}`}
|
||||
style={{ width: `${percent * segment.share}%` }}
|
||||
/>
|
||||
))}
|
||||
{segments.map(segment => (
|
||||
<div
|
||||
key={segment.key}
|
||||
className={segment.color === undefined ? css.segment : `${css.segment} ${segment.color}`}
|
||||
style={{ width: `${segment.width}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{breakdown !== undefined && (
|
||||
<dl className={css.rows}>
|
||||
|
||||
@@ -5,15 +5,16 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { en as commonEn, zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/index.ts'
|
||||
import { ContextMeter, type ContextMeterProps } from '../src/client/skeleton/ContextMeter.tsx'
|
||||
import css from '../src/client/skeleton/ContextMeter.module.css'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import { en, zh } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
const t = makeTranslate(zh, commonZh) as ContextMeterProps['t']
|
||||
const tEn = makeTranslate(en, commonEn) as ContextMeterProps['t']
|
||||
|
||||
const BREAKDOWN = { systemTokens: 120, toolsTokens: 21_500, messageTokens: 477_000 }
|
||||
|
||||
@@ -25,8 +26,8 @@ function projections(values: Record<string, unknown>): ContextMeterProps['usePro
|
||||
return (key: string) => values[key]
|
||||
}
|
||||
|
||||
function meter(values: Record<string, unknown>) {
|
||||
return render(<ContextMeter useProjection={projections(values)} t={t} />)
|
||||
function meter(values: Record<string, unknown>, translate: ContextMeterProps['t'] = t) {
|
||||
return render(<ContextMeter useProjection={projections(values)} t={translate} />)
|
||||
}
|
||||
|
||||
describe('ContextMeter', () => {
|
||||
@@ -58,6 +59,36 @@ describe('ContextMeter', () => {
|
||||
expect(view.container.querySelector('[role="dialog"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('lets each locale own the headline word order around the reading', () => {
|
||||
const values = {
|
||||
contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 },
|
||||
contextBreakdown: BREAKDOWN,
|
||||
}
|
||||
const zhView = meter(values)
|
||||
fireEvent.click(zhView.getByRole('button', { name: '上下文已用 25%' }))
|
||||
// The reading follows the label in Chinese and leads it in English; both
|
||||
// headers read as one sentence rather than a concatenated fragment.
|
||||
expect(zhView.container.querySelector('[role="dialog"]')!.textContent)
|
||||
.toMatch(/^上下文已用25%/)
|
||||
const enView = meter(values, tEn)
|
||||
fireEvent.click(enView.getByRole('button', { name: '25% of context used' }))
|
||||
expect(enView.container.querySelector('[role="dialog"]')!.textContent)
|
||||
.toMatch(/^25%of context used/)
|
||||
})
|
||||
|
||||
it('draws no bar segment at zero occupancy', () => {
|
||||
const view = meter({
|
||||
contextPressure: { pressureTokens: 0, contextWindow: 128_000 },
|
||||
contextBreakdown: BREAKDOWN,
|
||||
})
|
||||
fireEvent.click(view.getByRole('button', { name: '上下文已用 0%' }))
|
||||
const panel = view.container.querySelector('[role="dialog"]')!
|
||||
// `.segment` carries a min-width, so a zero-width part would still paint a
|
||||
// filled sliver over an empty context.
|
||||
expect(panel.getElementsByClassName(segmentClass)).toHaveLength(0)
|
||||
expect(panel.textContent).toContain('~0 / 128K')
|
||||
})
|
||||
|
||||
it('omits the composition rows while the contextBreakdown projection is absent', () => {
|
||||
const view = meter({ contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 } })
|
||||
fireEvent.click(view.getByRole('button', { name: '上下文已用 25%' }))
|
||||
|
||||
Reference in New Issue
Block a user