feat(web): refine composer status bar (#1281)

This commit is contained in:
Ananovo
2026-08-01 17:09:49 +08:00
committed by GitHub
parent 51d8c04e0a
commit fd20e584d6
7 changed files with 349 additions and 46 deletions
@@ -0,0 +1,143 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it } from 'vitest'
import { I18nProvider } from '@/lib/i18n-context'
import { StatusBar } from './StatusBar'
describe('StatusBar context details popover', () => {
beforeEach(() => {
localStorage.clear()
})
it('keeps stable connection labels in English and offsets the whole left status', () => {
localStorage.setItem('hapi-lang', 'zh-CN')
const { rerender } = render(
<I18nProvider>
<StatusBar active thinking={false} agentState={null} />
</I18nProvider>
)
const onlineLabel = screen.getByText('online')
expect(onlineLabel.className.split(' ')).not.toContain('top-px')
expect(onlineLabel.previousElementSibling?.className.split(' ')).not.toContain('top-px')
expect(onlineLabel.parentElement?.className.split(' ')).toContain('top-px')
expect(onlineLabel.parentElement?.className.split(' ')).toContain('sm:top-0.5')
rerender(
<I18nProvider>
<StatusBar active={false} thinking={false} agentState={null} />
</I18nProvider>
)
const offlineLabel = screen.getByText('offline')
expect(offlineLabel.className.split(' ')).toContain('text-[#999]')
expect(offlineLabel.className.split(' ')).not.toContain('top-px')
expect(offlineLabel.previousElementSibling?.className.split(' ')).toContain('bg-[#999]')
expect(offlineLabel.previousElementSibling?.className.split(' ')).not.toContain('top-px')
expect(offlineLabel.parentElement?.className.split(' ')).toContain('top-px')
expect(offlineLabel.parentElement?.className.split(' ')).toContain('sm:top-0.5')
rerender(
<I18nProvider>
<StatusBar active thinking agentState={null} />
</I18nProvider>
)
const thinkingLabel = screen.getByText(/…$/)
expect(thinkingLabel.className.split(' ')).not.toContain('top-px')
expect(thinkingLabel.previousElementSibling?.className.split(' ')).not.toContain('top-px')
expect(thinkingLabel.parentElement?.className.split(' ')).toContain('top-px')
expect(thinkingLabel.parentElement?.className.split(' ')).toContain('sm:top-0.5')
})
it('uses an effort-only reasoning label on mobile and the full label on desktop', () => {
render(
<I18nProvider>
<StatusBar
active
thinking={false}
agentState={null}
agentFlavor="codex"
modelReasoningEffort="xhigh"
/>
</I18nProvider>
)
expect(screen.getByText('xhigh').className.split(' ')).toContain('sm:hidden')
const desktopLabel = screen.getByText('reasoning xhigh')
expect(desktopLabel.className.split(' ')).toContain('hidden')
expect(desktopLabel.className.split(' ')).toContain('sm:inline')
})
it('opens from the mobile-accessible context trigger and keeps the requested detail order', async () => {
localStorage.setItem('hapi-lang', 'zh-CN')
render(
<I18nProvider>
<StatusBar
active
thinking={false}
agentState={null}
contextSize={90_000}
contextCacheRead={86_000}
contextWindow={258_000}
/>
</I18nProvider>
)
const connectionLabel = screen.getByText('online')
const leftStatusGroup = connectionLabel.parentElement?.parentElement
const statusBar = leftStatusGroup?.parentElement
const rightStatusGroup = statusBar?.lastElementChild
expect(statusBar?.className.split(' ')).toContain('items-baseline')
expect(leftStatusGroup?.className.split(' ')).toContain('items-baseline')
expect(rightStatusGroup?.className.split(' ')).toContain('items-baseline')
expect(connectionLabel.className.split(' ')).not.toContain('top-px')
expect(connectionLabel.previousElementSibling?.className.split(' ')).not.toContain('top-px')
expect(connectionLabel.parentElement?.className.split(' ')).toContain('top-px')
expect(connectionLabel.parentElement?.className.split(' ')).toContain('sm:top-0.5')
expect(leftStatusGroup?.className.split(' ')).toContain('gap-2')
expect(leftStatusGroup?.className.split(' ')).not.toContain('sm:gap-3')
expect(rightStatusGroup?.className.split(' ')).toContain('gap-2')
const trigger = screen.getByRole('button', { name: '上下文详情' })
expect(trigger.className.split(' ')).not.toContain('relative')
expect(trigger.className.split(' ')).not.toContain('-top-px')
expect(trigger.className.split(' ')).toContain('text-[10px]')
expect(trigger.className.split(' ')).toContain('leading-4')
expect(trigger.className.split(' ')).toContain('text-[var(--app-hint)]')
expect(trigger.textContent).toBe('ctx 258k (65% left)35% · 90k / 258k')
expect(trigger.className.split(' ')).not.toContain('hidden')
const progressTrack = trigger.querySelector('[aria-hidden="true"]')
expect((progressTrack?.firstElementChild as HTMLElement | null)?.style.width).toBe('35%')
fireEvent.click(trigger)
const cacheLine = await screen.findByText('缓存:86k')
const details = cacheLine.parentElement
expect(details?.textContent).toBe('缓存:86k使用:90k35%)剩余:168k65%')
expect(screen.queryByText('上下文详情')).toBeNull()
})
it('localizes the popover content without localizing the external left label', async () => {
localStorage.setItem('hapi-lang', 'en')
render(
<I18nProvider>
<StatusBar
active
thinking={false}
agentState={null}
contextSize={90_000}
contextCacheRead={86_000}
contextWindow={258_000}
/>
</I18nProvider>
)
const trigger = screen.getByRole('button', { name: 'Context details' })
expect(trigger.textContent).toContain('ctx 258k (65% left)')
fireEvent.click(trigger)
const cacheLine = await screen.findByText('Cache: 86k')
expect(cacheLine.parentElement?.textContent).toBe('Cache: 86kUsed: 90k (35%)Remaining: 168k (65%)')
})
})
@@ -1,5 +1,39 @@
import { describe, expect, it } from 'vitest'
import { shouldShowCodexFastBadge } from './StatusBar'
import {
formatCompactContextUsageLabel,
formatContextUsageLabel,
getContextUsageDetails,
shouldShowCodexFastBadge
} from './StatusBar'
describe('context usage labels', () => {
it('keeps the desktop label compact and expresses used capacity', () => {
expect(formatContextUsageLabel(90_000, 258_000)).toBe('35% · 90k / 258k')
})
it('uses the compact parenthesized mobile label with a fixed English suffix', () => {
expect(formatCompactContextUsageLabel(186_000, 262_000)).toBe('ctx 262k (29% left)')
})
it('orders cache, used, and remaining metrics for the desktop details', () => {
expect(getContextUsageDetails(90_000, 258_000, 86_000)).toEqual({
cacheRead: '86k',
used: '90k',
usedPercentage: 35,
remaining: '168k',
remainingPercentage: 65
})
})
it('keeps external and detailed percentages complementary at rounding midpoints', () => {
expect(formatContextUsageLabel(69, 200)).toBe('35% · 69 / 200')
expect(formatCompactContextUsageLabel(69, 200)).toBe('ctx 200 (65% left)')
expect(getContextUsageDetails(69, 200, 0)).toMatchObject({
usedPercentage: 35,
remainingPercentage: 65
})
})
})
describe('shouldShowCodexFastBadge', () => {
it('uses only the effective service tier', () => {
+140 -37
View File
@@ -5,12 +5,17 @@ import {
isPermissionModeAllowedForFlavor
} from '@hapi/protocol'
import type { PermissionModeTone } from '@hapi/protocol'
import * as Popover from '@radix-ui/react-popover'
import { useMemo } from 'react'
import type { AgentState, CodexCollaborationMode, PermissionMode } from '@/types/api'
import type { ConversationStatus } from '@/realtime/types'
import type { ThreadGoal } from '@/types/api'
import { getContextBudgetTokens } from '@/chat/modelConfig'
import { formatCodexReasoningLabel, shouldShowCodexReasoningLabel } from '@/lib/codexStatusLabels'
import {
formatCodexReasoningLabel,
formatCompactCodexReasoningLabel,
shouldShowCodexReasoningLabel
} from '@/lib/codexStatusLabels'
import { isFastServiceTier } from './codexFastMode'
import { useTranslation } from '@/lib/use-translation'
@@ -105,17 +110,16 @@ function getConnectionStatus(
}
}
function getContextWarning(contextSize: number, maxContextSize: number, t: (key: string, params?: Record<string, string | number>) => string): { text: string; color: string } | null {
function getContextWarning(contextSize: number, maxContextSize: number): { color: string } | null {
const percentageUsed = (contextSize / maxContextSize) * 100
const percentageRemaining = Math.max(0, 100 - percentageUsed)
const percent = Math.round(percentageRemaining)
if (percentageRemaining <= 5) {
return { text: t('misc.percentLeft', { percent }), color: 'text-red-500' }
return { color: 'text-red-500' }
} else if (percentageRemaining <= 10) {
return { text: t('misc.percentLeft', { percent }), color: 'text-amber-500' }
return { color: 'text-amber-500' }
} else {
return { text: t('misc.percentLeft', { percent }), color: 'text-[var(--app-hint)]' }
return { color: 'text-[var(--app-hint)]' }
}
}
@@ -125,6 +129,57 @@ function formatTokenCount(value: number): string {
return String(value)
}
function getContextPercentages(contextSize: number, maxContextSize: number): {
usedPercentage: number
remainingPercentage: number
} {
const usedPercentage = Math.min(100, Math.max(0, Math.round((contextSize / maxContextSize) * 100)))
return { usedPercentage, remainingPercentage: 100 - usedPercentage }
}
export function formatContextUsageLabel(contextSize: number, maxContextSize: number | null | undefined): string {
if (!maxContextSize) return `${formatTokenCount(contextSize)} used`
const { usedPercentage } = getContextPercentages(contextSize, maxContextSize)
return `${usedPercentage}% · ${formatTokenCount(contextSize)} / ${formatTokenCount(maxContextSize)}`
}
export function formatCompactContextUsageLabel(contextSize: number, maxContextSize: number | null | undefined): string {
if (!maxContextSize) return `ctx ${formatTokenCount(contextSize)}`
const { remainingPercentage } = getContextPercentages(contextSize, maxContextSize)
return `ctx ${formatTokenCount(maxContextSize)} (${remainingPercentage}% left)`
}
export function getContextUsageDetails(
contextSize: number,
maxContextSize: number | null | undefined,
contextCacheRead: number | null | undefined
): {
cacheRead: string | null
used: string
usedPercentage: number | null
remaining: string | null
remainingPercentage: number | null
} {
if (!maxContextSize) {
return {
cacheRead: contextCacheRead && contextCacheRead > 0 ? formatTokenCount(contextCacheRead) : null,
used: formatTokenCount(contextSize),
usedPercentage: null,
remaining: null,
remainingPercentage: null
}
}
const { usedPercentage, remainingPercentage } = getContextPercentages(contextSize, maxContextSize)
return {
cacheRead: contextCacheRead && contextCacheRead > 0 ? formatTokenCount(contextCacheRead) : null,
used: formatTokenCount(contextSize),
usedPercentage,
remaining: formatTokenCount(Math.max(0, maxContextSize - contextSize)),
remainingPercentage
}
}
export function shouldShowCodexFastBadge(
agentFlavor: string | null | undefined,
serviceTier: string | null | undefined
@@ -160,28 +215,26 @@ export function StatusBar(props: {
if (props.contextSize === undefined) return null
const maxContextSize = props.contextWindow ?? getContextBudgetTokens(props.model, props.agentFlavor)
if (!maxContextSize) return null
return getContextWarning(props.contextSize, maxContextSize, t)
return getContextWarning(props.contextSize, maxContextSize)
},
[props.contextSize, props.contextWindow, props.model, props.agentFlavor, t]
[props.contextSize, props.contextWindow, props.model, props.agentFlavor]
)
const contextUsageLabel = useMemo(() => {
if (props.contextSize === undefined) return null
const maxContextSize = props.contextWindow ?? getContextBudgetTokens(props.model, props.agentFlavor)
if (!maxContextSize) return `ctx ${formatTokenCount(props.contextSize)}`
const percentageUsed = Math.min(100, Math.round((props.contextSize / maxContextSize) * 100))
return `ctx ${formatTokenCount(props.contextSize)}/${formatTokenCount(maxContextSize)} (${percentageUsed}%)`
return formatContextUsageLabel(props.contextSize, maxContextSize)
}, [props.contextSize, props.contextWindow, props.model, props.agentFlavor])
const compactContextUsageLabel = useMemo(() => {
if (props.contextSize === undefined) return null
const maxContextSize = props.contextWindow ?? getContextBudgetTokens(props.model, props.agentFlavor)
if (!maxContextSize) return `ctx ${formatTokenCount(props.contextSize)}`
const percentageLeft = Math.max(0, Math.round(100 - (props.contextSize / maxContextSize) * 100))
return `ctx ${formatTokenCount(maxContextSize).toUpperCase()}, ${percentageLeft}% left`
return formatCompactContextUsageLabel(props.contextSize, maxContextSize)
}, [props.contextSize, props.contextWindow, props.model, props.agentFlavor])
const cacheHitLabel = useMemo(() => {
if (!props.contextCacheRead || props.contextCacheRead <= 0) return null
return `cache ${formatTokenCount(props.contextCacheRead)}`
}, [props.contextCacheRead])
const contextUsageDetails = useMemo(() => {
if (props.contextSize === undefined) return null
const maxContextSize = props.contextWindow ?? getContextBudgetTokens(props.model, props.agentFlavor)
return getContextUsageDetails(props.contextSize, maxContextSize, props.contextCacheRead)
}, [props.contextSize, props.contextCacheRead, props.contextWindow, props.model, props.agentFlavor])
const contextUsedPercentage = contextUsageDetails?.usedPercentage ?? null
const permissionMode = props.permissionMode
const displayPermissionMode = permissionMode
@@ -199,9 +252,13 @@ export function StatusBar(props: {
const collaborationModeLabel = displayCollaborationMode
? getCodexCollaborationModeLabel(displayCollaborationMode)
: null
const codexReasoningLabel = shouldShowCodexReasoningLabel(props.agentFlavor)
const displaysCodexReasoning = shouldShowCodexReasoningLabel(props.agentFlavor)
const codexReasoningLabel = displaysCodexReasoning
? formatCodexReasoningLabel(props.modelReasoningEffort)
: null
const compactCodexReasoningLabel = displaysCodexReasoning
? formatCompactCodexReasoningLabel(props.modelReasoningEffort)
: null
const codexFastMode = shouldShowCodexFastBadge(props.agentFlavor, props.serviceTier)
const goalLabel = props.agentFlavor === 'codex' && props.threadGoal
? props.threadGoal.status === 'active'
@@ -210,9 +267,9 @@ export function StatusBar(props: {
: null
return (
<div className="flex min-w-0 items-center justify-between gap-2 px-2 pb-1">
<div className="flex min-w-0 items-baseline gap-2 sm:gap-3">
<div className="flex shrink-0 items-center gap-1.5">
<div className="flex min-w-0 items-baseline justify-between gap-2 px-2 pb-1">
<div className="flex min-w-0 items-baseline gap-2">
<div className="relative top-px sm:top-0.5 flex shrink-0 items-center gap-1.5">
<span
className={`h-2 w-2 rounded-full ${connectionStatus.dotColor} ${connectionStatus.isPulsing ? 'animate-pulse' : ''}`}
/>
@@ -221,26 +278,72 @@ export function StatusBar(props: {
</span>
</div>
{contextUsageLabel ? (
<span className={`min-w-0 whitespace-nowrap text-[10px] ${contextWarning?.color ?? 'text-[var(--app-hint)]'}`}>
<span className="sm:hidden">
{compactContextUsageLabel}
</span>
<span className="hidden sm:inline">
{contextUsageLabel}{contextWarning ? ` · ${contextWarning.text}` : ''}
</span>
</span>
) : null}
{cacheHitLabel ? (
<span className="hidden whitespace-nowrap text-[10px] text-[var(--app-hint)] sm:inline">
{cacheHitLabel}
</span>
<Popover.Root>
<Popover.Trigger asChild>
<button
type="button"
aria-label={t('misc.contextDetails')}
className={`min-w-0 cursor-pointer whitespace-nowrap rounded-sm bg-transparent p-0 text-[10px] leading-4 outline-none focus-visible:ring-1 focus-visible:ring-[var(--app-link)] ${contextWarning?.color ?? 'text-[var(--app-hint)]'}`}
>
<span className="sm:hidden">{compactContextUsageLabel}</span>
<span className="hidden items-center gap-2 sm:inline-flex">
{contextUsedPercentage !== null ? (
<span
aria-hidden="true"
className="h-1 w-12 shrink-0 overflow-hidden rounded-full bg-[var(--app-link-muted)]"
>
<span
className="block h-full rounded-full bg-current"
style={{ width: `${contextUsedPercentage}%` }}
/>
</span>
) : null}
<span>{contextUsageLabel}</span>
</span>
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
side="top"
align="start"
sideOffset={6}
collisionPadding={8}
className="z-50 rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] px-3 py-2 shadow-lg"
>
<div className="flex max-w-[min(22rem,calc(100vw-1rem))] flex-col gap-1 text-xs leading-tight text-[var(--app-fg)]">
{contextUsageDetails?.cacheRead ? (
<span className="break-words">
{t('misc.contextCache', { value: contextUsageDetails.cacheRead })}
</span>
) : null}
<span className="break-words">
{contextUsageDetails?.usedPercentage === null
? t('misc.contextUsedTokens', { value: contextUsageDetails.used })
: t('misc.contextUsed', {
value: contextUsageDetails?.used ?? '',
percent: contextUsageDetails?.usedPercentage ?? 0
})}
</span>
{contextUsageDetails?.remaining && contextUsageDetails.remainingPercentage !== null ? (
<span className="break-words">
{t('misc.contextRemaining', {
value: contextUsageDetails.remaining,
percent: contextUsageDetails.remainingPercentage
})}
</span>
) : null}
</div>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
) : null}
</div>
<div className="flex min-w-0 shrink-0 items-center gap-2">
<div className="flex min-w-0 shrink-0 items-baseline gap-2">
{codexReasoningLabel ? (
<span className="whitespace-nowrap text-xs text-[var(--app-hint)]">
{codexReasoningLabel}
<span className="sm:hidden">{compactCodexReasoningLabel}</span>
<span className="hidden sm:inline">{codexReasoningLabel}</span>
</span>
) : null}
{codexFastMode ? (
+12 -1
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest'
import { formatCodexReasoningLabel, shouldShowCodexReasoningLabel } from './codexStatusLabels'
import {
formatCodexReasoningLabel,
formatCompactCodexReasoningLabel,
shouldShowCodexReasoningLabel
} from './codexStatusLabels'
describe('codexStatusLabels', () => {
it('formats unset and default effort as reasoning default', () => {
@@ -14,6 +18,13 @@ describe('codexStatusLabels', () => {
expect(formatCodexReasoningLabel('Ultra')).toBe('reasoning ultra')
})
it('formats compact effort-only labels', () => {
expect(formatCompactCodexReasoningLabel(null)).toBe('default')
expect(formatCompactCodexReasoningLabel('default')).toBe('default')
expect(formatCompactCodexReasoningLabel('xhigh')).toBe('xhigh')
expect(formatCompactCodexReasoningLabel(' Ultra ')).toBe('ultra')
})
it('only shows the label for codex and opencode', () => {
expect(shouldShowCodexReasoningLabel('codex')).toBe(true)
expect(shouldShowCodexReasoningLabel('opencode')).toBe(true)
+7 -3
View File
@@ -1,9 +1,13 @@
/** Labels shared by SessionHeader and composer StatusBar for Codex/OpenCode. */
export function formatCodexReasoningLabel(effort?: string | null): string {
export function formatCompactCodexReasoningLabel(effort?: string | null): string {
const normalized = effort?.trim().toLowerCase()
if (!normalized || normalized === 'default') return 'reasoning default'
return `reasoning ${normalized}`
if (!normalized || normalized === 'default') return 'default'
return normalized
}
export function formatCodexReasoningLabel(effort?: string | null): string {
return `reasoning ${formatCompactCodexReasoningLabel(effort)}`
}
export function shouldShowCodexReasoningLabel(agentFlavor: string | null | undefined): boolean {
+5 -1
View File
@@ -879,8 +879,12 @@ export default {
'misc.typeAMessage': 'Type a message...',
'misc.offline': 'offline',
'misc.permissionRequired': 'permission required',
'misc.percentLeft': '{percent}% left',
'misc.online': 'online',
'misc.contextDetails': 'Context details',
'misc.contextCache': 'Cache: {value}',
'misc.contextUsed': 'Used: {value} ({percent}%)',
'misc.contextUsedTokens': 'Used: {value}',
'misc.contextRemaining': 'Remaining: {value} ({percent}%)',
// Web Share Target picker
'share.title': 'Share to HAPI',
+7 -3
View File
@@ -881,10 +881,14 @@ export default {
'misc.releaseToLoadOlder': '松开加载',
'misc.typeMessage': "输入 'continue' 继续...",
'misc.typeAMessage': '输入消息...',
'misc.offline': '离线',
'misc.offline': 'offline',
'misc.permissionRequired': '需要权限',
'misc.percentLeft': '剩余 {percent}%',
'misc.online': '在线',
'misc.online': 'online',
'misc.contextDetails': '上下文详情',
'misc.contextCache': '缓存:{value}',
'misc.contextUsed': '使用:{value}{percent}%',
'misc.contextUsedTokens': '使用:{value}',
'misc.contextRemaining': '剩余:{value}{percent}%',
// Web Share Target 分享面板
'share.title': '分享到 HAPI',