From b7da8d3ab2ff0567d494452d65777a26e85eed42 Mon Sep 17 00:00:00 2001 From: KorenKrita Date: Sun, 2 Aug 2026 17:33:29 +0800 Subject: [PATCH] fix(web): show Pi reasoning effort (#1303) --- .../AssistantChat/HappyComposer.tsx | 1 + .../AssistantChat/StatusBar.popover.test.tsx | 100 ++++++++++++++++++ .../components/AssistantChat/StatusBar.tsx | 29 +++-- web/src/components/SessionHeader.test.tsx | 54 +++++++++- web/src/components/SessionHeader.tsx | 12 ++- web/src/lib/codexStatusLabels.test.ts | 33 +++++- web/src/lib/codexStatusLabels.ts | 40 ++++++- 7 files changed, 248 insertions(+), 21 deletions(-) diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index 4ae6b84c..ab08947a 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -1377,6 +1377,7 @@ export function HappyComposer(props: { contextModel={contextModel} model={model} modelReasoningEffort={modelReasoningEffort} + effort={effort} serviceTier={serviceTier} permissionMode={permissionMode} collaborationMode={collaborationMode} diff --git a/web/src/components/AssistantChat/StatusBar.popover.test.tsx b/web/src/components/AssistantChat/StatusBar.popover.test.tsx index ad7ca15b..4e0acea6 100644 --- a/web/src/components/AssistantChat/StatusBar.popover.test.tsx +++ b/web/src/components/AssistantChat/StatusBar.popover.test.tsx @@ -58,6 +58,7 @@ describe('StatusBar context details popover', () => { agentState={null} agentFlavor="codex" modelReasoningEffort="xhigh" + effort="max" /> ) @@ -68,6 +69,105 @@ describe('StatusBar context details popover', () => { expect(desktopLabel.className.split(' ')).toContain('sm:inline') }) + it('keeps the Codex default reasoning label when model effort is absent', () => { + render( + + + + ) + + expect(screen.getByText('default').className.split(' ')).toContain('sm:hidden') + expect(screen.getByText('reasoning default').className.split(' ')).toContain('sm:inline') + }) + + it('uses Pi ordinary effort instead of model reasoning effort', () => { + render( + + + + ) + + expect(screen.getByText('max').className.split(' ')).toContain('sm:hidden') + expect(screen.getByText('reasoning max').className.split(' ')).toContain('sm:inline') + expect(screen.queryByText('reasoning xhigh')).not.toBeInTheDocument() + }) + + it('hides Pi reasoning when effort is absent or blank', () => { + const { rerender } = render( + + + + ) + + expect(screen.queryByText('reasoning default')).not.toBeInTheDocument() + expect(screen.queryByText('default')).not.toBeInTheDocument() + + rerender( + + + + ) + + expect(screen.queryByText('reasoning default')).not.toBeInTheDocument() + expect(screen.queryByText('default')).not.toBeInTheDocument() + }) + + it('does not expose ordinary effort for Claude or unknown flavors', () => { + const { rerender } = render( + + + + ) + + expect(screen.queryByText('reasoning max')).not.toBeInTheDocument() + expect(screen.queryByText('max')).not.toBeInTheDocument() + + rerender( + + + + ) + + expect(screen.queryByText('reasoning max')).not.toBeInTheDocument() + expect(screen.queryByText('max')).not.toBeInTheDocument() + }) + it('opens from the mobile-accessible context trigger and keeps the requested detail order', async () => { localStorage.setItem('hapi-lang', 'zh-CN') render( diff --git a/web/src/components/AssistantChat/StatusBar.tsx b/web/src/components/AssistantChat/StatusBar.tsx index 86371610..8010e907 100644 --- a/web/src/components/AssistantChat/StatusBar.tsx +++ b/web/src/components/AssistantChat/StatusBar.tsx @@ -12,9 +12,10 @@ import type { ConversationStatus } from '@/realtime/types' import type { ThreadGoal } from '@/types/api' import { getContextBudgetTokens } from '@/chat/modelConfig' import { - formatCodexReasoningLabel, - formatCompactCodexReasoningLabel, - shouldShowCodexReasoningLabel + formatReasoningLabel, + formatCompactReasoningLabel, + getReasoningEffortForFlavor, + shouldShowReasoningStatusLabel } from '@/lib/codexStatusLabels' import { isFastServiceTier } from './codexFastMode' import { useTranslation } from '@/lib/use-translation' @@ -205,6 +206,7 @@ export function StatusBar(props: { contextModel?: string | null model?: string | null modelReasoningEffort?: string | null + effort?: string | null serviceTier?: string | null permissionMode?: PermissionMode collaborationMode?: CodexCollaborationMode @@ -262,12 +264,17 @@ export function StatusBar(props: { const collaborationModeLabel = displayCollaborationMode ? getCodexCollaborationModeLabel(displayCollaborationMode) : null - const displaysCodexReasoning = shouldShowCodexReasoningLabel(props.agentFlavor) - const codexReasoningLabel = displaysCodexReasoning - ? formatCodexReasoningLabel(props.modelReasoningEffort, headerMetadata.showLabels) + const reasoningEffort = getReasoningEffortForFlavor( + props.agentFlavor, + props.modelReasoningEffort, + props.effort + ) + const displaysReasoning = shouldShowReasoningStatusLabel(props.agentFlavor, reasoningEffort) + const reasoningLabel = displaysReasoning + ? formatReasoningLabel(reasoningEffort, headerMetadata.showLabels) : null - const compactCodexReasoningLabel = displaysCodexReasoning - ? formatCompactCodexReasoningLabel(props.modelReasoningEffort) + const compactReasoningLabel = displaysReasoning + ? formatCompactReasoningLabel(reasoningEffort) : null const codexFastMode = shouldShowCodexFastBadge(props.agentFlavor, props.serviceTier) const goalLabel = props.agentFlavor === 'codex' && props.threadGoal @@ -350,10 +357,10 @@ export function StatusBar(props: {
- {codexReasoningLabel ? ( + {reasoningLabel ? ( - {compactCodexReasoningLabel} - {codexReasoningLabel} + {compactReasoningLabel} + {reasoningLabel} ) : null} {codexFastMode ? ( diff --git a/web/src/components/SessionHeader.test.tsx b/web/src/components/SessionHeader.test.tsx index 4a57735d..5c4ad85c 100644 --- a/web/src/components/SessionHeader.test.tsx +++ b/web/src/components/SessionHeader.test.tsx @@ -6,7 +6,10 @@ import { I18nProvider } from '@/lib/i18n-context' import { ToastProvider } from '@/lib/toast-context' import { resolveSessionHeaderMachineLabel, SessionHeader } from './SessionHeader' -afterEach(() => cleanup()) +afterEach(() => { + cleanup() + localStorage.clear() +}) function baseSession(overrides: Partial = {}): Session { return { @@ -79,6 +82,55 @@ describe('SessionHeader', () => { expect(screen.queryByText('reasoning default')).not.toBeInTheDocument() }) + it('shows Pi ordinary effort as reasoning metadata', () => { + renderHeader(baseSession({ + metadata: { flavor: 'pi', path: '/repo', host: 'machine' }, + modelReasoningEffort: null, + effort: 'max' + })) + + expect(screen.getByTestId('session-header-reasoning')).toHaveTextContent('reasoning max') + }) + + it('keeps model reasoning effort for Codex and hides ordinary effort for non-Pi flavors', () => { + const { rerender } = renderHeader(baseSession({ + modelReasoningEffort: 'xhigh', + effort: 'max' + })) + + expect(screen.getByTestId('session-header-reasoning')).toHaveTextContent('reasoning xhigh') + + rerender( + + + + + + + + ) + + expect(screen.queryByTestId('session-header-reasoning')).not.toBeInTheDocument() + }) + + it('hides Pi reasoning metadata when the header reasoning setting is disabled', () => { + localStorage.setItem('hapi-session-header-metadata', JSON.stringify({ reasoning: false })) + renderHeader(baseSession({ + metadata: { flavor: 'pi', path: '/repo', host: 'machine' }, + effort: 'max' + })) + + expect(screen.queryByTestId('session-header-reasoning')).not.toBeInTheDocument() + }) + it('shows machine label and relative last-active age in the meta row', () => { const fiveMinutesAgo = Date.now() - 5 * 60_000 renderHeader(baseSession({ diff --git a/web/src/components/SessionHeader.tsx b/web/src/components/SessionHeader.tsx index 8584e20d..071c4703 100644 --- a/web/src/components/SessionHeader.tsx +++ b/web/src/components/SessionHeader.tsx @@ -10,7 +10,7 @@ import { RenameSessionDialog } from '@/components/RenameSessionDialog' import { ConfirmDialog } from '@/components/ui/ConfirmDialog' import { useScratchlistCount } from '@/lib/use-scratchlist-count' import { formatReopenError } from '@/lib/reopenError' -import { formatCodexReasoningLabel, shouldShowCodexReasoningLabel } from '@/lib/codexStatusLabels' +import { formatReasoningLabel, getReasoningEffortForFlavor } from '@/lib/codexStatusLabels' import { getSessionModelLabel } from '@/lib/sessionModelLabel' import { useTranslation } from '@/lib/use-translation' import { AgentFlavorIcon } from '@/components/AgentFlavorIcon' @@ -138,9 +138,13 @@ export function SessionHeader(props: { const modelLabel = getSessionModelLabel(session) const agentFlavor = session.metadata?.flavor ?? null const agentLabel = agentFlavor?.trim() || null - const reasoningEffort = session.modelReasoningEffort?.trim() || null - const reasoningLabel = reasoningEffort && shouldShowCodexReasoningLabel(agentFlavor) - ? formatCodexReasoningLabel(reasoningEffort, headerMetadata.showLabels) + const reasoningEffort = getReasoningEffortForFlavor( + agentFlavor, + session.modelReasoningEffort, + session.effort + ) + const reasoningLabel = reasoningEffort + ? formatReasoningLabel(reasoningEffort, headerMetadata.showLabels) : null // Match expected Fast badge semantics (#1004): only explicit service tier, no effort/model heuristics. const showFastBadge = agentFlavor === 'codex' && isFastServiceTier(props.serviceTier ?? session.serviceTier) diff --git a/web/src/lib/codexStatusLabels.test.ts b/web/src/lib/codexStatusLabels.test.ts index 4358bdcf..7b165d09 100644 --- a/web/src/lib/codexStatusLabels.test.ts +++ b/web/src/lib/codexStatusLabels.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it } from 'vitest' import { formatCodexReasoningLabel, formatCompactCodexReasoningLabel, - shouldShowCodexReasoningLabel + formatReasoningLabel, + formatCompactReasoningLabel, + getReasoningEffortForFlavor, + shouldShowCodexReasoningLabel, + shouldShowReasoningStatusLabel } from './codexStatusLabels' describe('codexStatusLabels', () => { @@ -37,3 +41,30 @@ describe('codexStatusLabels', () => { expect(shouldShowCodexReasoningLabel(null)).toBe(false) }) }) + +describe('reasoning status metadata', () => { + it('formats Pi reasoning labels with the shared formatter', () => { + expect(formatReasoningLabel('MAX')).toBe('reasoning max') + expect(formatCompactReasoningLabel(' MAX ')).toBe('max') + }) + + it('uses model reasoning effort for Codex/OpenCode and ordinary effort only for Pi', () => { + expect(getReasoningEffortForFlavor('codex', 'xhigh', 'max')).toBe('xhigh') + expect(getReasoningEffortForFlavor('opencode', 'high', 'max')).toBe('high') + expect(getReasoningEffortForFlavor('pi', 'xhigh', 'max')).toBe('max') + expect(getReasoningEffortForFlavor('claude', 'xhigh', 'max')).toBeNull() + expect(getReasoningEffortForFlavor('grok', 'xhigh', 'max')).toBeNull() + expect(getReasoningEffortForFlavor(null, 'xhigh', 'max')).toBeNull() + }) + + it('keeps unset defaults for Codex/OpenCode and requires a real Pi effort', () => { + expect(shouldShowReasoningStatusLabel('codex', null)).toBe(true) + expect(shouldShowReasoningStatusLabel('opencode', null)).toBe(true) + expect(shouldShowReasoningStatusLabel('pi', 'max')).toBe(true) + expect(shouldShowReasoningStatusLabel('pi', null)).toBe(false) + expect(shouldShowReasoningStatusLabel('pi', ' ')).toBe(false) + expect(shouldShowReasoningStatusLabel('claude', 'max')).toBe(false) + expect(shouldShowReasoningStatusLabel('grok', 'max')).toBe(false) + expect(shouldShowReasoningStatusLabel(null, 'max')).toBe(false) + }) +}) diff --git a/web/src/lib/codexStatusLabels.ts b/web/src/lib/codexStatusLabels.ts index 4702cc89..ae070cd3 100644 --- a/web/src/lib/codexStatusLabels.ts +++ b/web/src/lib/codexStatusLabels.ts @@ -1,16 +1,48 @@ -/** Labels shared by SessionHeader and composer StatusBar for Codex/OpenCode. */ +/** Labels shared by SessionHeader and composer StatusBar for supported reasoning providers. */ -export function formatCompactCodexReasoningLabel(effort?: string | null): string { +export function formatCompactReasoningLabel(effort?: string | null): string { const normalized = effort?.trim().toLowerCase() if (!normalized || normalized === 'default') return 'default' return normalized } -export function formatCodexReasoningLabel(effort?: string | null, showLabel = true): string { - const value = formatCompactCodexReasoningLabel(effort) +export function formatReasoningLabel(effort?: string | null, showLabel = true): string { + const value = formatCompactReasoningLabel(effort) return showLabel ? `reasoning ${value}` : value } +/** + * Status metadata comes from different session fields by agent protocol: + * Codex/OpenCode publish model reasoning effort, while Pi publishes effort. + */ +export function getReasoningEffortForFlavor( + agentFlavor: string | null | undefined, + modelReasoningEffort?: string | null, + effort?: string | null +): string | null { + if (agentFlavor === 'codex' || agentFlavor === 'opencode') { + return modelReasoningEffort?.trim() || null + } + if (agentFlavor === 'pi') { + return effort?.trim() || null + } + return null +} + +export function shouldShowReasoningStatusLabel( + agentFlavor: string | null | undefined, + reasoningEffort?: string | null +): boolean { + // Codex/OpenCode retain their existing explicit default label when unset. + if (agentFlavor === 'codex' || agentFlavor === 'opencode') return true + // Pi has no meaningful default for non-reasoning models; only show a real level. + return agentFlavor === 'pi' && Boolean(reasoningEffort?.trim()) +} + +/** Codex-only aliases retained for tool-card and thread call sites. */ +export const formatCompactCodexReasoningLabel = formatCompactReasoningLabel +export const formatCodexReasoningLabel = formatReasoningLabel + export function shouldShowCodexReasoningLabel(agentFlavor: string | null | undefined): boolean { return agentFlavor === 'codex' || agentFlavor === 'opencode' }