From fd20e584d6da4b1c4df84597f3c648fafc4f446a Mon Sep 17 00:00:00 2001 From: Ananovo Date: Sat, 1 Aug 2026 17:09:49 +0800 Subject: [PATCH] feat(web): refine composer status bar (#1281) --- .../AssistantChat/StatusBar.popover.test.tsx | 143 ++++++++++++++ .../AssistantChat/StatusBar.test.ts | 36 +++- .../components/AssistantChat/StatusBar.tsx | 177 ++++++++++++++---- web/src/lib/codexStatusLabels.test.ts | 13 +- web/src/lib/codexStatusLabels.ts | 10 +- web/src/lib/locales/en.ts | 6 +- web/src/lib/locales/zh-CN.ts | 10 +- 7 files changed, 349 insertions(+), 46 deletions(-) create mode 100644 web/src/components/AssistantChat/StatusBar.popover.test.tsx diff --git a/web/src/components/AssistantChat/StatusBar.popover.test.tsx b/web/src/components/AssistantChat/StatusBar.popover.test.tsx new file mode 100644 index 00000000..ad7ca15b --- /dev/null +++ b/web/src/components/AssistantChat/StatusBar.popover.test.tsx @@ -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( + + + + ) + + 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( + + + + ) + + 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( + + + + ) + + 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( + + + + ) + + 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( + + + + ) + + 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使用:90k(35%)剩余:168k(65%)') + expect(screen.queryByText('上下文详情')).toBeNull() + }) + + it('localizes the popover content without localizing the external left label', async () => { + localStorage.setItem('hapi-lang', 'en') + render( + + + + ) + + 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%)') + }) +}) diff --git a/web/src/components/AssistantChat/StatusBar.test.ts b/web/src/components/AssistantChat/StatusBar.test.ts index d64d8ecb..98956fb8 100644 --- a/web/src/components/AssistantChat/StatusBar.test.ts +++ b/web/src/components/AssistantChat/StatusBar.test.ts @@ -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', () => { diff --git a/web/src/components/AssistantChat/StatusBar.tsx b/web/src/components/AssistantChat/StatusBar.tsx index 1b636d79..ebd51e1b 100644 --- a/web/src/components/AssistantChat/StatusBar.tsx +++ b/web/src/components/AssistantChat/StatusBar.tsx @@ -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): { 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 ( -
-
-
+
+
+
@@ -221,26 +278,72 @@ export function StatusBar(props: {
{contextUsageLabel ? ( - - - {compactContextUsageLabel} - - - {contextUsageLabel}{contextWarning ? ` · ${contextWarning.text}` : ''} - - - ) : null} - {cacheHitLabel ? ( - - {cacheHitLabel} - + + + + + + +
+ {contextUsageDetails?.cacheRead ? ( + + {t('misc.contextCache', { value: contextUsageDetails.cacheRead })} + + ) : null} + + {contextUsageDetails?.usedPercentage === null + ? t('misc.contextUsedTokens', { value: contextUsageDetails.used }) + : t('misc.contextUsed', { + value: contextUsageDetails?.used ?? '', + percent: contextUsageDetails?.usedPercentage ?? 0 + })} + + {contextUsageDetails?.remaining && contextUsageDetails.remainingPercentage !== null ? ( + + {t('misc.contextRemaining', { + value: contextUsageDetails.remaining, + percent: contextUsageDetails.remainingPercentage + })} + + ) : null} +
+
+
+
) : null}
-
+
{codexReasoningLabel ? ( - {codexReasoningLabel} + {compactCodexReasoningLabel} + {codexReasoningLabel} ) : null} {codexFastMode ? ( diff --git a/web/src/lib/codexStatusLabels.test.ts b/web/src/lib/codexStatusLabels.test.ts index 779f5a07..c6d69f62 100644 --- a/web/src/lib/codexStatusLabels.test.ts +++ b/web/src/lib/codexStatusLabels.test.ts @@ -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) diff --git a/web/src/lib/codexStatusLabels.ts b/web/src/lib/codexStatusLabels.ts index d4960c6b..02324289 100644 --- a/web/src/lib/codexStatusLabels.ts +++ b/web/src/lib/codexStatusLabels.ts @@ -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 { diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 83801eec..03b99c23 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -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', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index b83f551c..94a598b3 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -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',