diff --git a/src/components/ChatBox/MessageItem/AgentMessageCard.tsx b/src/components/ChatBox/MessageItem/AgentMessageCard.tsx index 34a4fa3cf..ee8eface4 100644 --- a/src/components/ChatBox/MessageItem/AgentMessageCard.tsx +++ b/src/components/ChatBox/MessageItem/AgentMessageCard.tsx @@ -12,6 +12,10 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import { + recordMessageFeedback, + type MessageFeedbackRating, +} from '@/lib/events/appEvents'; import { fileInfoFromPath } from '@/lib/fileInfo'; import { usePageTabStore } from '@/store/pageTabStore'; import { Check, Copy, FileText, ThumbsDown, ThumbsUp } from 'lucide-react'; @@ -23,7 +27,21 @@ import { MarkDown } from './MarkDown'; const COPIED_RESET_MS = 2000; -type MessageFeedback = 'up' | 'down' | null; +type MessageFeedback = MessageFeedbackRating | null; + +type MessageFeedbackState = { + key: string; + rating: MessageFeedbackRating; +}; + +// Keep accepted feedback stable while a message card is remounted (for +// example, after switching Timeline modes). The Run scope prevents otherwise +// identical message ids from colliding across separate Runs. +const messageFeedbackByKey = new Map(); + +function getMessageFeedbackKey(runId: string | undefined, messageId: string) { + return JSON.stringify([runId ?? null, messageId]); +} interface AgentMessageCardProps { id: string; @@ -31,6 +49,12 @@ interface AgentMessageCardProps { className?: string; typewriter?: boolean; attaches?: File[]; + /** Stable logical message identity used by feedback analytics. */ + feedbackMessageId?: string; + /** Run scope for feedback correlation and deduplication. */ + feedbackRunId?: string; + /** Lifecycle step of the rated message; forwarded with the feedback event. */ + messageStep?: string; /** Shown only after markdown (and typewriter, if enabled) has finished rendering — e.g. generated file chips. */ deferredFooter?: ReactNode; onTyping?: () => void; @@ -48,6 +72,9 @@ export function AgentMessageCard({ onMarkdownRenderComplete, className, attaches, + feedbackMessageId, + feedbackRunId, + messageStep, deferredFooter, }: AgentMessageCardProps) { const openFilePreview = usePageTabStore((s) => s.openFilePreview); @@ -63,13 +90,24 @@ export function AgentMessageCard({ const enableTypewriter = !isCompleted; const [copied, setCopied] = useState(false); - const [feedback, setFeedback] = useState(null); + const resolvedFeedbackMessageId = feedbackMessageId?.trim() + ? feedbackMessageId + : id; + const resolvedFeedbackRunId = feedbackRunId?.trim() + ? feedbackRunId + : undefined; + const messageFeedbackKey = getMessageFeedbackKey( + resolvedFeedbackRunId, + resolvedFeedbackMessageId + ); + const [feedbackState, setFeedbackState] = + useState(null); + const feedback: MessageFeedback = + feedbackState?.key === messageFeedbackKey + ? feedbackState.rating + : (messageFeedbackByKey.get(messageFeedbackKey) ?? null); const { t } = useTranslation(); - useEffect(() => { - setFeedback(null); - }, [id]); - const handleTypingComplete = () => { if (!completedTypewriterByMessageId.has(id)) { completedTypewriterByMessageId.set(id, true); @@ -99,21 +137,50 @@ export function AgentMessageCard({ onMarkdownRenderComplete?.(); }, [onMarkdownRenderComplete]); - const handleThumbUp = useCallback(() => { - if (feedback !== null) return; - setFeedback('up'); - toast.success( - t('chat.feedback-thanks', { defaultValue: 'Thanks for your feedback' }) - ); - }, [feedback, t]); - - const handleThumbDown = useCallback(() => { - if (feedback !== null) return; - setFeedback('down'); - toast.success( - t('chat.feedback-thanks', { defaultValue: 'Thanks for your feedback' }) - ); - }, [feedback, t]); + // Feedback is recorded once per run-scoped message identity for this app + // session. The rating goes to the app event bus so edition adapters can + // report it; rated content and agent names are not included in the event. + const submitFeedback = useCallback( + (rating: MessageFeedbackRating) => { + const recordedRating = messageFeedbackByKey.get(messageFeedbackKey); + if (recordedRating) { + setFeedbackState({ + key: messageFeedbackKey, + rating: recordedRating, + }); + return; + } + + messageFeedbackByKey.set(messageFeedbackKey, rating); + setFeedbackState({ key: messageFeedbackKey, rating }); + recordMessageFeedback({ + rating, + message_id: resolvedFeedbackMessageId, + run_id: resolvedFeedbackRunId, + message_step: messageStep, + }); + toast.success( + t('chat.feedback-thanks', { defaultValue: 'Thanks for your feedback' }) + ); + }, + [ + messageFeedbackKey, + messageStep, + resolvedFeedbackMessageId, + resolvedFeedbackRunId, + t, + ] + ); + + const handleThumbUp = useCallback( + () => submitFeedback('up'), + [submitFeedback] + ); + + const handleThumbDown = useCallback( + () => submitFeedback('down'), + [submitFeedback] + ); const showDeferredFileUi = markdownAndTypingComplete && diff --git a/src/components/ChatBox/TimelineModes/NarrativeTimeline.tsx b/src/components/ChatBox/TimelineModes/NarrativeTimeline.tsx index 4cfbd3b10..faec3f866 100644 --- a/src/components/ChatBox/TimelineModes/NarrativeTimeline.tsx +++ b/src/components/ChatBox/TimelineModes/NarrativeTimeline.tsx @@ -31,7 +31,11 @@ import { type TimelineSegment, } from '@/lib/projector/chat/presentation'; import { cn } from '@/lib/utils'; -import { SessionMode, type SessionModeType } from '@/types/constants'; +import { + AgentStep, + SessionMode, + type SessionModeType, +} from '@/types/constants'; import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'; import { ChevronDown, ChevronRight } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; @@ -1042,7 +1046,13 @@ export function NarrativeTimeline({ /> ) : undefined } + feedbackMessageId={ + run.finalAssistantResponse.messageId ?? + run.finalAssistantResponse.id + } + feedbackRunId={run.runId} id={run.finalAssistantResponse.id} + messageStep={AgentStep.END} typewriter={isActiveRunStatus(run.status)} /> ) : null} diff --git a/src/components/ChatBox/UserQueryGroup.tsx b/src/components/ChatBox/UserQueryGroup.tsx index 33b1a4053..d10451f89 100644 --- a/src/components/ChatBox/UserQueryGroup.tsx +++ b/src/components/ChatBox/UserQueryGroup.tsx @@ -52,8 +52,18 @@ const AgentResultCard: React.FC<{ agentName?: string; content: string; attaches?: any[]; + feedbackRunId?: string; + messageStep?: string; defaultOpen?: boolean; -}> = ({ id, agentName, content, attaches, defaultOpen = false }) => { +}> = ({ + id, + agentName, + content, + attaches, + feedbackRunId, + messageStep, + defaultOpen = false, +}) => { const [isOpen, setIsOpen] = useState(defaultOpen); const label = agentName || 'Agent'; @@ -84,6 +94,8 @@ const AgentResultCard: React.FC<{ typewriter={false} onTyping={() => {}} attaches={attaches} + feedbackRunId={feedbackRunId} + messageStep={messageStep} /> @@ -523,6 +535,8 @@ export const UserQueryGroup: React.FC = ({ id={message.id} content={message.content} onTyping={() => {}} + feedbackRunId={activeTaskId ?? undefined} + messageStep={message.step} deferredFooter={ message.fileList?.length || task?.artifactManifestTruncated || @@ -558,6 +572,8 @@ export const UserQueryGroup: React.FC = ({ defaultValue: 'No reply received; the task continues…', })} onTyping={() => {}} + feedbackRunId={activeTaskId ?? undefined} + messageStep={message.step} /> ); @@ -575,6 +591,8 @@ export const UserQueryGroup: React.FC = ({ agentName={message.agent_name} content={message.content} attaches={message.attaches} + feedbackRunId={activeTaskId ?? undefined} + messageStep={message.step} defaultOpen /> @@ -595,6 +613,8 @@ export const UserQueryGroup: React.FC = ({ content={message.content} onTyping={() => {}} attaches={message.attaches} + feedbackRunId={activeTaskId ?? undefined} + messageStep={message.step} /> ); diff --git a/src/lib/events/appEvents.ts b/src/lib/events/appEvents.ts index 1eb3644fe..c7fbc7e37 100644 --- a/src/lib/events/appEvents.ts +++ b/src/lib/events/appEvents.ts @@ -30,6 +30,8 @@ export interface TaskOutcomeEventProperties extends Record { task_category?: string; } +export type MessageFeedbackRating = 'up' | 'down'; + export interface AppEventMap { app_launch_failed: { reason: string }; onboarding_step_completed: { @@ -73,6 +75,15 @@ export interface AppEventMap { task_stopped: TaskOutcomeEventProperties & { stop_reason: string }; task_completed: TaskOutcomeEventProperties; file_generated: { count: number }; + message_feedback: { + rating: MessageFeedbackRating; + message_id: string; + // Message ids are only assumed to be stable inside one Run. + run_id?: string; + // Low-cardinality lifecycle step of the rated message (see AgentStep). + // The rated content and the agent name stay on-device. + message_step?: string; + }; user_identity_available: UserIdentity; user_session_cleared: Record; } @@ -243,3 +254,9 @@ export function recordTaskCompleted( export function recordFileGenerated(count: number): void { emitAppEvent('file_generated', { count }); } + +export function recordMessageFeedback( + properties: AppEventMap['message_feedback'] +): void { + emitAppEvent('message_feedback', properties); +} diff --git a/test/unit/components/ChatBox/MessageItem/AgentMessageCardFeedback.test.tsx b/test/unit/components/ChatBox/MessageItem/AgentMessageCardFeedback.test.tsx new file mode 100644 index 000000000..951a33b20 --- /dev/null +++ b/test/unit/components/ChatBox/MessageItem/AgentMessageCardFeedback.test.tsx @@ -0,0 +1,183 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import { fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +// The rated content itself is irrelevant here: the action row only appears once +// the message reports that typing and markdown rendering finished. +vi.mock('@/components/ChatBox/MessageItem/MarkDown', async () => { + const { useEffect } = await import('react'); + return { + MarkDown: ({ + onTyping, + onMarkdownRenderComplete, + }: { + onTyping?: () => void; + onMarkdownRenderComplete?: () => void; + }) => { + useEffect(() => { + onTyping?.(); + onMarkdownRenderComplete?.(); + }, [onTyping, onMarkdownRenderComplete]); + return null; + }, + }; +}); + +import { AgentMessageCard } from '@/components/ChatBox/MessageItem/AgentMessageCard'; +import { subscribeAppEvents, type AppEvent } from '@/lib/events/appEvents'; + +describe('AgentMessageCard feedback', () => { + let events: AppEvent[] = []; + let unsubscribe: () => void = () => {}; + + beforeEach(() => { + events = []; + unsubscribe = subscribeAppEvents((event) => { + events.push(event); + }); + }); + + afterEach(() => { + unsubscribe(); + }); + + it('emits a rated app event for a thumb up', () => { + render( + + ); + + fireEvent.click(screen.getByLabelText('Thumb up')); + + expect(events).toEqual([ + expect.objectContaining({ + name: 'message_feedback', + properties: { + rating: 'up', + message_id: 'message-up', + message_step: 'end', + }, + }), + ]); + }); + + it('emits the logical message and Run identities instead of the render id', () => { + render( + + ); + + fireEvent.click(screen.getByLabelText('Thumb up')); + + expect(events).toEqual([ + expect.objectContaining({ + name: 'message_feedback', + properties: { + rating: 'up', + message_id: 'logical-message-up', + run_id: 'run-up', + message_step: 'end', + }, + }), + ]); + }); + + it('emits a rated app event for a thumb down', () => { + render( + + ); + + fireEvent.click(screen.getByLabelText('Thumb down')); + + expect(events).toEqual([ + expect.objectContaining({ + name: 'message_feedback', + properties: { + rating: 'down', + message_id: 'message-down', + message_step: 'agent_end', + }, + }), + ]); + }); + + it('records one rating per message', () => { + render( + + ); + + fireEvent.click(screen.getByLabelText('Thumb up')); + fireEvent.click(screen.getByLabelText('Thumb up')); + + expect(events).toHaveLength(1); + }); + + it('does not record the same logical message again after a remount', () => { + const card = ( + + ); + const firstRender = render(card); + + fireEvent.click(screen.getByLabelText('Thumb up')); + firstRender.unmount(); + render(card); + + expect(screen.getByLabelText('Thumb up')).toHaveAttribute( + 'aria-pressed', + 'true' + ); + expect(screen.getByLabelText('Thumb down')).toBeDisabled(); + fireEvent.click(screen.getByLabelText('Thumb up')); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + name: 'message_feedback', + properties: { + message_id: 'logical-message-once', + run_id: 'run-once', + }, + }); + }); +}); diff --git a/test/unit/components/ChatBox/TimelineModes.test.tsx b/test/unit/components/ChatBox/TimelineModes.test.tsx index 94859b9da..6eb52fab7 100644 --- a/test/unit/components/ChatBox/TimelineModes.test.tsx +++ b/test/unit/components/ChatBox/TimelineModes.test.tsx @@ -13,6 +13,7 @@ // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import { TimelineModeRenderer } from '@/components/ChatBox/TimelineModes'; +import { subscribeAppEvents, type AppEvent } from '@/lib/events/appEvents'; import { composeTimelineRuns, reconcileTimelineRun, @@ -300,6 +301,50 @@ describe('ChatBox timeline modes', () => { }); }); + it('records final-message feedback with logical message and Run identities', async () => { + const finalMessage: ChatProjectionNode = { + ...base, + kind: 'message', + id: 'final-event', + eventId: 'final-event', + eventType: 'assistant.final', + runSequence: 1, + createdAt: '2026-08-19T00:00:00Z', + role: 'assistant', + purpose: 'final', + status: 'complete', + content: 'Final answer', + messageId: 'logical-final-message', + }; + const events: AppEvent[] = []; + const unsubscribe = subscribeAppEvents((event) => events.push(event)); + + try { + render( + + ); + + fireEvent.click(await screen.findByLabelText('Thumb up')); + + expect(events).toContainEqual( + expect.objectContaining({ + name: 'message_feedback', + properties: { + rating: 'up', + message_id: 'logical-final-message', + run_id: 'run-1', + message_step: 'end', + }, + }) + ); + } finally { + unsubscribe(); + } + }); + it('renders Detailed as labelled rows with vertical Input then Output', () => { const runs = composeTimelineRuns(nodes('completed')); const { container } = render( diff --git a/test/unit/components/ChatBox/UserQueryGroup.test.tsx b/test/unit/components/ChatBox/UserQueryGroup.test.tsx index df398aa7b..8a2915be2 100644 --- a/test/unit/components/ChatBox/UserQueryGroup.test.tsx +++ b/test/unit/components/ChatBox/UserQueryGroup.test.tsx @@ -37,7 +37,25 @@ vi.mock('@/components/ChatBox/MessageItem/UserMessageCard', () => ({ })); vi.mock('@/components/ChatBox/MessageItem/AgentMessageCard', () => ({ - AgentMessageCard: ({ content }: { content: string }) =>
{content}
, + AgentMessageCard: ({ + id, + content, + feedbackRunId, + messageStep, + }: { + id: string; + content: string; + feedbackRunId?: string; + messageStep?: string; + }) => ( +
+ {content} +
+ ), })); vi.mock('@/components/ChatBox/MessageItem/PreparingToExecuteTasks', () => ({ @@ -142,6 +160,50 @@ describe('UserQueryGroup Run work-log ownership', () => { expect(screen.queryByText('Europe')).not.toBeInTheDocument(); }); + it('forwards message lifecycle and Run identity to every feedback card path', () => { + const messages = [ + { id: 'user-1', role: 'user', content: 'Build a report' }, + { + id: 'end-1', + role: 'agent', + step: AgentStep.END, + content: 'Final response', + }, + { + id: 'agent-end-1', + role: 'agent', + step: AgentStep.AGENT_END, + content: 'Delegated result', + }, + { + id: 'generic-1', + role: 'agent', + step: AgentStep.ACTIVATE_AGENT, + content: 'Working update', + }, + { + id: 'skip-1', + role: 'agent', + step: AgentStep.AGENT_END, + content: 'skip', + }, + ]; + + renderGroups(messages); + + const expectedSteps = { + 'end-1': AgentStep.END, + 'agent-end-1': AgentStep.AGENT_END, + 'generic-1': AgentStep.ACTIVATE_AGENT, + 'skip-1': AgentStep.AGENT_END, + }; + for (const [messageId, messageStep] of Object.entries(expectedSteps)) { + const card = screen.getByTestId(`agent-message-card-${messageId}`); + expect(card).toHaveAttribute('data-message-step', messageStep); + expect(card).toHaveAttribute('data-feedback-run-id', 'run-1'); + } + }); + it('does not give a transient ordinary follow-up a second copy of the old Run log', () => { const messages = [ { id: 'user-1', role: 'user', content: 'Build a report' },