mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
Render Codex review messages
This commit is contained in:
@@ -403,6 +403,102 @@ describe('normalizeDecryptedMessage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes Codex review JSON messages as structured review content', () => {
|
||||
const message = makeMessage({
|
||||
role: 'agent',
|
||||
content: {
|
||||
type: 'codex',
|
||||
data: {
|
||||
type: 'message',
|
||||
message: JSON.stringify({
|
||||
findings: [{
|
||||
title: '[P2] Remove retained sessions when sockets disconnect',
|
||||
body: 'Retained sockets survive disconnects.',
|
||||
confidence_score: 0.82,
|
||||
priority: 2,
|
||||
code_location: {
|
||||
absolute_file_path: '/data/dz/wapair-ts/src/pairing/manager.ts',
|
||||
line_range: { start: 1614, end: 1619 }
|
||||
}
|
||||
}],
|
||||
overall_correctness: 'patch is incorrect',
|
||||
overall_explanation: 'The message-sending feature retains long-lived sockets but does not fully manage their lifecycle.',
|
||||
overall_confidence_score: 0.8
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const normalized = normalizeDecryptedMessage(message)
|
||||
|
||||
expect(normalized).toMatchObject({
|
||||
role: 'agent',
|
||||
content: [{
|
||||
type: 'codex-review',
|
||||
review: {
|
||||
overallCorrectness: 'patch is incorrect',
|
||||
overallExplanation: 'The message-sending feature retains long-lived sockets but does not fully manage their lifecycle.',
|
||||
overallConfidenceScore: 0.8,
|
||||
findings: [{
|
||||
title: '[P2] Remove retained sessions when sockets disconnect',
|
||||
body: 'Retained sockets survive disconnects.',
|
||||
priority: 2,
|
||||
confidenceScore: 0.82,
|
||||
filePath: '/data/dz/wapair-ts/src/pairing/manager.ts',
|
||||
lineStart: 1614,
|
||||
lineEnd: 1619
|
||||
}]
|
||||
}
|
||||
}]
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps non-review Codex JSON messages as text', () => {
|
||||
const message = makeMessage({
|
||||
role: 'agent',
|
||||
content: {
|
||||
type: 'codex',
|
||||
data: {
|
||||
type: 'message',
|
||||
message: JSON.stringify({ status: 'ok', message: 'plain JSON' })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const normalized = normalizeDecryptedMessage(message)
|
||||
|
||||
expect(normalized).toMatchObject({
|
||||
role: 'agent',
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: '{"status":"ok","message":"plain JSON"}'
|
||||
}]
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps malformed Codex review-looking messages as text', () => {
|
||||
const message = makeMessage({
|
||||
role: 'agent',
|
||||
content: {
|
||||
type: 'codex',
|
||||
data: {
|
||||
type: 'message',
|
||||
message: '{"findings": ['
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const normalized = normalizeDecryptedMessage(message)
|
||||
|
||||
expect(normalized).toMatchObject({
|
||||
role: 'agent',
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: '{"findings": ['
|
||||
}]
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes Codex plan updates as completed update_plan snapshots', () => {
|
||||
const message = makeMessage({
|
||||
role: 'agent',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AgentEvent, NormalizedAgentContent, NormalizedMessage, ToolResultPermission } from '@/chat/types'
|
||||
import type { AgentEvent, CodexReview, CodexReviewFinding, NormalizedAgentContent, NormalizedMessage, ToolResultPermission } from '@/chat/types'
|
||||
import { AGENT_MESSAGE_PAYLOAD_TYPE, asNumber, asString, isObject } from '@hapi/protocol'
|
||||
import { isClaudeChatVisibleMessage } from '@hapi/protocol/messages'
|
||||
|
||||
@@ -142,6 +142,75 @@ function normalizePlanEntries(value: unknown): Array<{ step: string; status: 'pe
|
||||
return plan
|
||||
}
|
||||
|
||||
function normalizeCodexReviewFinding(value: unknown): CodexReviewFinding | null {
|
||||
if (!isObject(value)) return null
|
||||
const title = asString(value.title)
|
||||
const body = asString(value.body)
|
||||
if (!title || !body) return null
|
||||
|
||||
const codeLocation = isObject(value.code_location)
|
||||
? value.code_location
|
||||
: isObject(value.codeLocation)
|
||||
? value.codeLocation
|
||||
: null
|
||||
const lineRange = codeLocation && isObject(codeLocation.line_range)
|
||||
? codeLocation.line_range
|
||||
: codeLocation && isObject(codeLocation.lineRange)
|
||||
? codeLocation.lineRange
|
||||
: null
|
||||
|
||||
return {
|
||||
title,
|
||||
body,
|
||||
priority: asNumber(value.priority),
|
||||
confidenceScore: asNumber(value.confidence_score ?? value.confidenceScore),
|
||||
filePath: codeLocation ? asString(codeLocation.absolute_file_path ?? codeLocation.absoluteFilePath ?? codeLocation.path) : null,
|
||||
lineStart: lineRange ? asNumber(lineRange.start) : null,
|
||||
lineEnd: lineRange ? asNumber(lineRange.end) : null
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCodexReviewJson(value: unknown): CodexReview | null {
|
||||
if (!isObject(value)) return null
|
||||
const hasReviewMarker = Array.isArray(value.findings)
|
||||
|| 'overall_correctness' in value
|
||||
|| 'overallCorrectness' in value
|
||||
|| 'overall_explanation' in value
|
||||
|| 'overallExplanation' in value
|
||||
if (!hasReviewMarker) return null
|
||||
|
||||
const findings = Array.isArray(value.findings)
|
||||
? value.findings
|
||||
.map(normalizeCodexReviewFinding)
|
||||
.filter((finding): finding is CodexReviewFinding => finding !== null)
|
||||
: []
|
||||
|
||||
const overallCorrectness = asString(value.overall_correctness ?? value.overallCorrectness)
|
||||
const overallExplanation = asString(value.overall_explanation ?? value.overallExplanation)
|
||||
const overallConfidenceScore = asNumber(value.overall_confidence_score ?? value.overallConfidenceScore)
|
||||
|
||||
if (findings.length === 0 && !overallCorrectness && !overallExplanation && overallConfidenceScore === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
findings,
|
||||
overallCorrectness,
|
||||
overallExplanation,
|
||||
overallConfidenceScore
|
||||
}
|
||||
}
|
||||
|
||||
function parseCodexReviewMessage(message: string): CodexReview | null {
|
||||
const trimmed = message.trim()
|
||||
if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return null
|
||||
try {
|
||||
return normalizeCodexReviewJson(JSON.parse(trimmed) as unknown)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAssistantOutput(
|
||||
messageId: string,
|
||||
localId: string | null,
|
||||
@@ -503,6 +572,18 @@ export function normalizeAgentRecord(
|
||||
}
|
||||
|
||||
if (data.type === 'message' && typeof data.message === 'string') {
|
||||
const review = parseCodexReviewMessage(data.message)
|
||||
if (review) {
|
||||
return {
|
||||
id: messageId,
|
||||
localId,
|
||||
createdAt,
|
||||
role: 'agent',
|
||||
isSidechain: false,
|
||||
content: [{ type: 'codex-review', review, uuid: messageId, parentUUID: null }],
|
||||
meta
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: messageId,
|
||||
localId,
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
ChatBlock,
|
||||
GeneratedImageBlock,
|
||||
CliOutputBlock,
|
||||
CodexReviewBlock,
|
||||
ToolCallBlock,
|
||||
ToolPermission,
|
||||
UserTextBlock,
|
||||
@@ -145,6 +146,13 @@ function areGeneratedImageBlocksEqual(left: GeneratedImageBlock, right: Generate
|
||||
&& left.meta === right.meta
|
||||
}
|
||||
|
||||
function areCodexReviewBlocksEqual(left: CodexReviewBlock, right: CodexReviewBlock): boolean {
|
||||
return left.review === right.review
|
||||
&& left.localId === right.localId
|
||||
&& left.createdAt === right.createdAt
|
||||
&& left.meta === right.meta
|
||||
}
|
||||
|
||||
function areAgentEventBlocksEqual(left: AgentEventBlock, right: AgentEventBlock): boolean {
|
||||
return left.createdAt === right.createdAt
|
||||
&& left.meta === right.meta
|
||||
@@ -228,6 +236,11 @@ function reconcileBlock(block: ChatBlock, prevById: ChatBlocksById): ChatBlock {
|
||||
return areGeneratedImageBlocksEqual(prevBlock, block) ? prevBlock : block
|
||||
}
|
||||
|
||||
if (block.kind === 'codex-review') {
|
||||
const prevBlock = prev as CodexReviewBlock
|
||||
return areCodexReviewBlocksEqual(prevBlock, block) ? prevBlock : block
|
||||
}
|
||||
|
||||
const prevBlock = prev as AgentEventBlock
|
||||
return areAgentEventBlocksEqual(prevBlock, block) ? prevBlock : block
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AgentReasoningBlock, AgentTextBlock, ChatBlock, CliOutputBlock, ToolCallBlock, ToolPermission } from '@/chat/types'
|
||||
import type { AgentReasoningBlock, AgentTextBlock, ChatBlock, CliOutputBlock, CodexReviewBlock, ToolCallBlock, ToolPermission } from '@/chat/types'
|
||||
import type { TracedMessage } from '@/chat/tracer'
|
||||
import { createCliOutputBlock, isCliOutputText, mergeCliOutputBlocks } from '@/chat/reducerCliOutput'
|
||||
import { parseMessageAsEvent } from '@/chat/reducerEvents'
|
||||
@@ -437,8 +437,8 @@ export function reduceTimeline(
|
||||
const targetId = msg.content.targetMessageId
|
||||
const durationMs = msg.content.durationMs as number
|
||||
type DurationBearingBlock = AgentTextBlock | AgentReasoningBlock | CliOutputBlock | ToolCallBlock
|
||||
const isDurationTarget = (b: ChatBlock): b is DurationBearingBlock =>
|
||||
b.kind === 'agent-text' || b.kind === 'agent-reasoning' || b.kind === 'cli-output' || b.kind === 'tool-call'
|
||||
const isDurationTarget = (b: ChatBlock): b is DurationBearingBlock | CodexReviewBlock =>
|
||||
b.kind === 'agent-text' || b.kind === 'agent-reasoning' || b.kind === 'codex-review' || b.kind === 'cli-output' || b.kind === 'tool-call'
|
||||
let foundIndex = -1
|
||||
|
||||
if (targetId) {
|
||||
@@ -760,6 +760,21 @@ export function reduceTimeline(
|
||||
continue
|
||||
}
|
||||
|
||||
if (c.type === 'codex-review') {
|
||||
blocks.push({
|
||||
kind: 'codex-review',
|
||||
id: `${msg.id}:${idx}`,
|
||||
localId: msg.localId,
|
||||
createdAt: msg.createdAt,
|
||||
invokedAt: msg.invokedAt,
|
||||
usage: msg.usage,
|
||||
model: msg.model,
|
||||
review: c.review,
|
||||
meta: msg.meta
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (c.type === 'summary') {
|
||||
blocks.push({
|
||||
kind: 'agent-event',
|
||||
|
||||
+37
-1
@@ -65,6 +65,23 @@ export type GeneratedImageContent = {
|
||||
parentUUID: string | null
|
||||
}
|
||||
|
||||
export type CodexReviewFinding = {
|
||||
title: string
|
||||
body: string
|
||||
priority: number | null
|
||||
confidenceScore: number | null
|
||||
filePath: string | null
|
||||
lineStart: number | null
|
||||
lineEnd: number | null
|
||||
}
|
||||
|
||||
export type CodexReview = {
|
||||
findings: CodexReviewFinding[]
|
||||
overallCorrectness: string | null
|
||||
overallExplanation: string | null
|
||||
overallConfidenceScore: number | null
|
||||
}
|
||||
|
||||
export type NormalizedAgentContent =
|
||||
| {
|
||||
type: 'text'
|
||||
@@ -81,6 +98,12 @@ export type NormalizedAgentContent =
|
||||
| ToolUse
|
||||
| ToolResult
|
||||
| GeneratedImageContent
|
||||
| {
|
||||
type: 'codex-review'
|
||||
review: CodexReview
|
||||
uuid: string
|
||||
parentUUID: string | null
|
||||
}
|
||||
| { type: 'summary'; summary: string }
|
||||
| { type: 'sidechain'; uuid: string; parentUUID: string | null; prompt: string }
|
||||
|
||||
@@ -171,6 +194,19 @@ export type AgentReasoningBlock = {
|
||||
meta?: unknown
|
||||
}
|
||||
|
||||
export type CodexReviewBlock = {
|
||||
kind: 'codex-review'
|
||||
id: string
|
||||
localId: string | null
|
||||
createdAt: number
|
||||
invokedAt?: number | null
|
||||
durationMs?: number
|
||||
usage?: UsageData
|
||||
model?: string | null
|
||||
review: CodexReview
|
||||
meta?: unknown
|
||||
}
|
||||
|
||||
export type CliOutputBlock = {
|
||||
kind: 'cli-output'
|
||||
id: string
|
||||
@@ -221,4 +257,4 @@ export type ToolCallBlock = {
|
||||
meta?: unknown
|
||||
}
|
||||
|
||||
export type ChatBlock = UserTextBlock | AgentTextBlock | AgentReasoningBlock | CliOutputBlock | ToolCallBlock | GeneratedImageBlock | AgentEventBlock
|
||||
export type ChatBlock = UserTextBlock | AgentTextBlock | AgentReasoningBlock | CodexReviewBlock | CliOutputBlock | ToolCallBlock | GeneratedImageBlock | AgentEventBlock
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getAssistantCopyText } from '@/components/AssistantChat/messages/assist
|
||||
import { getConversationMessageAnchorId } from '@/chat/outline'
|
||||
import { MessageMetadata } from '@/components/AssistantChat/messages/MessageMetadata'
|
||||
import { isNestedInteractiveEvent } from '@/components/AssistantChat/messages/metadataToggle'
|
||||
import { CodexReviewCard } from '@/components/AssistantChat/messages/CodexReviewCard'
|
||||
|
||||
const TOOL_COMPONENTS = {
|
||||
Fallback: HappyToolMessage
|
||||
@@ -35,6 +36,10 @@ export function HappyAssistantMessage() {
|
||||
const custom = message.metadata.custom as Partial<HappyChatMessageMetadata> | undefined
|
||||
return custom?.kind === 'cli-output'
|
||||
})
|
||||
const codexReview = useAssistantState(({ message }) => {
|
||||
const custom = message.metadata.custom as Partial<HappyChatMessageMetadata> | undefined
|
||||
return custom?.kind === 'codex-review' ? custom.review : undefined
|
||||
})
|
||||
const cliText = useAssistantState(({ message }) => {
|
||||
const custom = message.metadata.custom as Partial<HappyChatMessageMetadata> | undefined
|
||||
if (custom?.kind !== 'cli-output') return ''
|
||||
@@ -102,6 +107,51 @@ export function HappyAssistantMessage() {
|
||||
)
|
||||
}
|
||||
|
||||
if (codexReview) {
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
id={getConversationMessageAnchorId(messageId)}
|
||||
className={`${rootClass} ${copyText ? 'group/msg' : ''} scroll-mt-4`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div
|
||||
className={hasMetadata ? 'min-w-0 flex-1 cursor-pointer' : 'min-w-0 flex-1'}
|
||||
onClick={hasMetadata ? toggleMetadata : undefined}
|
||||
onKeyDown={hasMetadata ? onMetadataKeyDown : undefined}
|
||||
role={hasMetadata ? 'button' : undefined}
|
||||
tabIndex={hasMetadata ? 0 : undefined}
|
||||
aria-expanded={hasMetadata ? showMetadata : undefined}
|
||||
>
|
||||
<CodexReviewCard review={codexReview} />
|
||||
{showMetadata && (
|
||||
<MessageMetadata
|
||||
invokedAt={invokedAt}
|
||||
durationMs={durationMs}
|
||||
usage={usage}
|
||||
model={messageModel ?? null}
|
||||
className="mt-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{copyText ? (
|
||||
<div className="happy-message-actions-first-line hidden sm:flex shrink-0 opacity-0 group-hover/msg:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
title="Copy"
|
||||
className="p-0.5 rounded hover:bg-[var(--app-subtle-bg)] transition-colors"
|
||||
onClick={() => copy(copyText)}
|
||||
>
|
||||
{copied
|
||||
? <CheckIcon className="h-3.5 w-3.5 text-green-500" />
|
||||
: <CopyIcon className="h-3.5 w-3.5 text-[var(--app-hint)]" />}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</MessagePrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
if (toolOnly) {
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { I18nProvider } from '@/lib/i18n-context'
|
||||
import { CodexReviewCard } from '@/components/AssistantChat/messages/CodexReviewCard'
|
||||
import type { CodexReview } from '@/chat/types'
|
||||
|
||||
function renderCard(review: CodexReview) {
|
||||
return render(
|
||||
<I18nProvider>
|
||||
<CodexReviewCard review={review} />
|
||||
</I18nProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('CodexReviewCard', () => {
|
||||
it('renders overall review details and finding locations', () => {
|
||||
renderCard({
|
||||
overallCorrectness: 'patch is incorrect',
|
||||
overallExplanation: 'Retained sessions can survive socket disconnects.',
|
||||
overallConfidenceScore: 0.8,
|
||||
findings: [{
|
||||
title: '[P2] Remove retained sessions when sockets disconnect',
|
||||
body: 'This entry remains in onlineMessageSessions.',
|
||||
priority: 2,
|
||||
confidenceScore: 0.82,
|
||||
filePath: '/data/dz/wapair-ts/src/pairing/manager.ts',
|
||||
lineStart: 1614,
|
||||
lineEnd: 1619
|
||||
}]
|
||||
})
|
||||
|
||||
expect(screen.getByText('Codex review')).toBeInTheDocument()
|
||||
expect(screen.getByText('patch is incorrect')).toBeInTheDocument()
|
||||
expect(screen.getByText('80%')).toBeInTheDocument()
|
||||
expect(screen.getByText('Retained sessions can survive socket disconnects.')).toBeInTheDocument()
|
||||
expect(screen.getByText('1 findings')).toBeInTheDocument()
|
||||
expect(screen.getByText('P2')).toBeInTheDocument()
|
||||
expect(screen.getByText('[P2] Remove retained sessions when sockets disconnect')).toBeInTheDocument()
|
||||
expect(screen.getByText('This entry remains in onlineMessageSessions.')).toBeInTheDocument()
|
||||
expect(screen.getByText('/data/dz/wapair-ts/src/pairing/manager.ts:1614-1619')).toBeInTheDocument()
|
||||
expect(screen.getByText('Confidence 82%')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('omits optional values without rendering nullish text', () => {
|
||||
renderCard({
|
||||
overallCorrectness: null,
|
||||
overallExplanation: null,
|
||||
overallConfidenceScore: null,
|
||||
findings: [{
|
||||
title: 'Finding without metadata',
|
||||
body: 'Body only.',
|
||||
priority: null,
|
||||
confidenceScore: null,
|
||||
filePath: null,
|
||||
lineStart: null,
|
||||
lineEnd: null
|
||||
}]
|
||||
})
|
||||
|
||||
expect(screen.getByText('Finding without metadata')).toBeInTheDocument()
|
||||
expect(screen.getByText('Body only.')).toBeInTheDocument()
|
||||
expect(screen.getByText('No location')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/undefined|null/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { CodexReview, CodexReviewFinding } from '@/chat/types'
|
||||
import { useTranslation } from '@/lib/use-translation'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function formatPercent(value: number | null): string | null {
|
||||
if (value === null || !Number.isFinite(value)) return null
|
||||
return `${Math.round(value * 100)}%`
|
||||
}
|
||||
|
||||
function formatLocation(finding: CodexReviewFinding): string | null {
|
||||
if (!finding.filePath) return null
|
||||
if (finding.lineStart === null) return finding.filePath
|
||||
if (finding.lineEnd !== null && finding.lineEnd !== finding.lineStart) {
|
||||
return `${finding.filePath}:${finding.lineStart}-${finding.lineEnd}`
|
||||
}
|
||||
return `${finding.filePath}:${finding.lineStart}`
|
||||
}
|
||||
|
||||
function getPriorityClassName(priority: number | null): string {
|
||||
if (priority === 0 || priority === 1) {
|
||||
return 'border-[var(--app-badge-error-border)] bg-[var(--app-badge-error-bg)] text-[var(--app-badge-error-text)]'
|
||||
}
|
||||
if (priority === 2) {
|
||||
return 'border-[var(--app-badge-warning-border)] bg-[var(--app-badge-warning-bg)] text-[var(--app-badge-warning-text)]'
|
||||
}
|
||||
return 'border-[var(--app-border)] bg-[var(--app-subtle-bg)] text-[var(--app-hint)]'
|
||||
}
|
||||
|
||||
function ReviewBadge(props: { children: string; className?: string }) {
|
||||
return (
|
||||
<span className={cn('inline-flex shrink-0 items-center rounded-md border px-1.5 py-0.5 text-[11px] font-medium leading-4', props.className)}>
|
||||
{props.children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function FindingItem(props: { finding: CodexReviewFinding }) {
|
||||
const { t } = useTranslation()
|
||||
const confidence = formatPercent(props.finding.confidenceScore)
|
||||
const location = formatLocation(props.finding)
|
||||
|
||||
return (
|
||||
<li className="rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] p-3">
|
||||
<div className="flex min-w-0 flex-wrap items-start gap-2">
|
||||
{props.finding.priority !== null ? (
|
||||
<ReviewBadge className={getPriorityClassName(props.finding.priority)}>
|
||||
{`P${props.finding.priority}`}
|
||||
</ReviewBadge>
|
||||
) : null}
|
||||
<div className="min-w-0 flex-1 text-sm font-semibold leading-6 text-[var(--app-fg)]">
|
||||
{props.finding.title}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 whitespace-pre-wrap break-words text-sm leading-6 text-[var(--app-fg)]">
|
||||
{props.finding.body}
|
||||
</p>
|
||||
<div className="mt-2 flex min-w-0 flex-wrap gap-x-3 gap-y-1 text-xs leading-5 text-[var(--app-hint)]">
|
||||
{location ? (
|
||||
<span className="min-w-0 break-all font-mono">{location}</span>
|
||||
) : (
|
||||
<span>{t('codexReview.location.missing')}</span>
|
||||
)}
|
||||
{confidence ? (
|
||||
<span>{t('codexReview.confidence', { value: confidence })}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export function CodexReviewCard(props: { review: CodexReview }) {
|
||||
const { t } = useTranslation()
|
||||
const confidence = formatPercent(props.review.overallConfidenceScore)
|
||||
const findingCount = props.review.findings.length
|
||||
|
||||
return (
|
||||
<section className="my-1 max-w-full overflow-hidden rounded-lg border border-[var(--app-border)] bg-[var(--app-subtle-bg)]">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2 border-b border-[var(--app-divider)] px-3 py-2">
|
||||
<div className="min-w-0 flex-1 text-sm font-semibold text-[var(--app-fg)]">
|
||||
{t('codexReview.title')}
|
||||
</div>
|
||||
{props.review.overallCorrectness ? (
|
||||
<ReviewBadge className="border-[var(--app-border)] bg-[var(--app-bg)] text-[var(--app-fg)]">
|
||||
{props.review.overallCorrectness}
|
||||
</ReviewBadge>
|
||||
) : null}
|
||||
{confidence ? (
|
||||
<ReviewBadge className="border-[var(--app-border)] bg-[var(--app-bg)] text-[var(--app-hint)]">
|
||||
{confidence}
|
||||
</ReviewBadge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="px-3 py-3">
|
||||
{props.review.overallExplanation ? (
|
||||
<p className="whitespace-pre-wrap break-words text-sm leading-6 text-[var(--app-fg)]">
|
||||
{props.review.overallExplanation}
|
||||
</p>
|
||||
) : null}
|
||||
<div className={cn('text-xs font-medium text-[var(--app-hint)]', props.review.overallExplanation ? 'mt-3' : '')}>
|
||||
{t('codexReview.findings', { count: findingCount })}
|
||||
</div>
|
||||
{findingCount > 0 ? (
|
||||
<ol className="mt-2 space-y-2">
|
||||
{props.review.findings.map((finding, index) => (
|
||||
<FindingItem key={`${finding.title}:${index}`} finding={finding} />
|
||||
))}
|
||||
</ol>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -3,13 +3,13 @@ import type { AppendMessage, AttachmentAdapter, ThreadMessageLike } from '@assis
|
||||
import { useExternalMessageConverter, useExternalStoreRuntime } from '@assistant-ui/react'
|
||||
import { safeStringify } from '@hapi/protocol'
|
||||
import { renderEventLabel } from '@/chat/presentation'
|
||||
import type { ChatBlock, CliOutputBlock, UsageData } from '@/chat/types'
|
||||
import type { ChatBlock, CliOutputBlock, CodexReview, UsageData } from '@/chat/types'
|
||||
import type { AgentEvent, ToolCallBlock } from '@/chat/types'
|
||||
import type { ToolGroupBlock, VisibleChatBlock } from '@/chat/toolGroups'
|
||||
import type { AttachmentMetadata, MessageStatus as HappyMessageStatus, Session } from '@/types/api'
|
||||
|
||||
export type HappyChatMessageMetadata = {
|
||||
kind: 'user' | 'assistant' | 'tool' | 'event' | 'cli-output'
|
||||
kind: 'user' | 'assistant' | 'tool' | 'event' | 'cli-output' | 'codex-review'
|
||||
status?: HappyMessageStatus
|
||||
localId?: string | null
|
||||
originalText?: string
|
||||
@@ -21,6 +21,29 @@ export type HappyChatMessageMetadata = {
|
||||
durationMs?: number
|
||||
usage?: UsageData
|
||||
model?: string | null
|
||||
review?: CodexReview
|
||||
}
|
||||
|
||||
function formatCodexReviewText(review: CodexReview): string {
|
||||
const lines = ['Codex review']
|
||||
if (review.overallCorrectness) {
|
||||
lines.push(`Overall: ${review.overallCorrectness}`)
|
||||
}
|
||||
if (review.overallExplanation) {
|
||||
lines.push('', review.overallExplanation)
|
||||
}
|
||||
if (review.findings.length > 0) {
|
||||
lines.push('', 'Findings:')
|
||||
for (const finding of review.findings) {
|
||||
const priority = finding.priority === null ? '' : `[P${finding.priority}] `
|
||||
const location = finding.filePath
|
||||
? ` (${finding.filePath}${finding.lineStart === null ? '' : `:${finding.lineStart}${finding.lineEnd !== null && finding.lineEnd !== finding.lineStart ? `-${finding.lineEnd}` : ''}`})`
|
||||
: ''
|
||||
lines.push(`- ${priority}${finding.title}${location}`)
|
||||
lines.push(` ${finding.body}`)
|
||||
}
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function toThreadMessageLike(block: VisibleChatBlock): ThreadMessageLike {
|
||||
@@ -104,6 +127,26 @@ function toThreadMessageLike(block: VisibleChatBlock): ThreadMessageLike {
|
||||
}
|
||||
}
|
||||
|
||||
if (block.kind === 'codex-review') {
|
||||
const messageId = `review:${block.id}`
|
||||
return {
|
||||
role: 'assistant',
|
||||
id: messageId,
|
||||
createdAt: new Date(block.createdAt),
|
||||
content: [{ type: 'text', text: formatCodexReviewText(block.review) }],
|
||||
metadata: {
|
||||
custom: {
|
||||
kind: 'codex-review',
|
||||
invokedAt: block.invokedAt,
|
||||
durationMs: block.durationMs,
|
||||
usage: block.usage,
|
||||
model: block.model,
|
||||
review: block.review
|
||||
} satisfies HappyChatMessageMetadata
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (block.kind === 'agent-event') {
|
||||
const messageId = `event:${block.id}`
|
||||
return {
|
||||
|
||||
@@ -163,6 +163,12 @@ export default {
|
||||
'chat.terminal': 'Terminal',
|
||||
'chat.switchRemote': 'Switch to remote mode',
|
||||
|
||||
// Codex review
|
||||
'codexReview.title': 'Codex review',
|
||||
'codexReview.findings': '{count} findings',
|
||||
'codexReview.confidence': 'Confidence {value}',
|
||||
'codexReview.location.missing': 'No location',
|
||||
|
||||
// Terminal
|
||||
'terminal.commandName': 'Command',
|
||||
'terminal.commandMessage': 'Command message',
|
||||
|
||||
@@ -165,6 +165,12 @@ export default {
|
||||
'chat.terminal': '终端',
|
||||
'chat.switchRemote': '切换到远程模式',
|
||||
|
||||
// Codex review
|
||||
'codexReview.title': 'Codex review',
|
||||
'codexReview.findings': '{count} 条发现',
|
||||
'codexReview.confidence': '置信度 {value}',
|
||||
'codexReview.location.missing': '无位置',
|
||||
|
||||
// Terminal
|
||||
'terminal.commandName': '命令',
|
||||
'terminal.commandMessage': '命令消息',
|
||||
|
||||
Reference in New Issue
Block a user