Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 88 additions & 21 deletions src/components/ChatBox/MessageItem/AgentMessageCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -23,14 +27,34 @@ 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<string, MessageFeedbackRating>();

function getMessageFeedbackKey(runId: string | undefined, messageId: string) {
return JSON.stringify([runId ?? null, messageId]);
}

interface AgentMessageCardProps {
id: string;
content: string;
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;
Expand All @@ -48,6 +72,9 @@ export function AgentMessageCard({
onMarkdownRenderComplete,
className,
attaches,
feedbackMessageId,
feedbackRunId,
messageStep,
deferredFooter,
}: AgentMessageCardProps) {
const openFilePreview = usePageTabStore((s) => s.openFilePreview);
Expand All @@ -63,13 +90,24 @@ export function AgentMessageCard({
const enableTypewriter = !isCompleted;

const [copied, setCopied] = useState(false);
const [feedback, setFeedback] = useState<MessageFeedback>(null);
const resolvedFeedbackMessageId = feedbackMessageId?.trim()
? feedbackMessageId
: id;
const resolvedFeedbackRunId = feedbackRunId?.trim()
? feedbackRunId
: undefined;
const messageFeedbackKey = getMessageFeedbackKey(
resolvedFeedbackRunId,
resolvedFeedbackMessageId
);
const [feedbackState, setFeedbackState] =
useState<MessageFeedbackState | null>(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);
Expand Down Expand Up @@ -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 &&
Expand Down
12 changes: 11 additions & 1 deletion src/components/ChatBox/TimelineModes/NarrativeTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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}
Expand Down
22 changes: 21 additions & 1 deletion src/components/ChatBox/UserQueryGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -84,6 +94,8 @@ const AgentResultCard: React.FC<{
typewriter={false}
onTyping={() => {}}
attaches={attaches}
feedbackRunId={feedbackRunId}
messageStep={messageStep}
/>
</div>
</div>
Expand Down Expand Up @@ -523,6 +535,8 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
id={message.id}
content={message.content}
onTyping={() => {}}
feedbackRunId={activeTaskId ?? undefined}
messageStep={message.step}
deferredFooter={
message.fileList?.length ||
task?.artifactManifestTruncated ||
Expand Down Expand Up @@ -558,6 +572,8 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
defaultValue: 'No reply received; the task continues…',
})}
onTyping={() => {}}
feedbackRunId={activeTaskId ?? undefined}
messageStep={message.step}
/>
</motion.div>
);
Expand All @@ -575,6 +591,8 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
agentName={message.agent_name}
content={message.content}
attaches={message.attaches}
feedbackRunId={activeTaskId ?? undefined}
messageStep={message.step}
defaultOpen
/>
</motion.div>
Expand All @@ -595,6 +613,8 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
content={message.content}
onTyping={() => {}}
attaches={message.attaches}
feedbackRunId={activeTaskId ?? undefined}
messageStep={message.step}
/>
</motion.div>
);
Expand Down
17 changes: 17 additions & 0 deletions src/lib/events/appEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export interface TaskOutcomeEventProperties extends Record<string, unknown> {
task_category?: string;
}

export type MessageFeedbackRating = 'up' | 'down';

export interface AppEventMap {
app_launch_failed: { reason: string };
onboarding_step_completed: {
Expand Down Expand Up @@ -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<string, never>;
}
Expand Down Expand Up @@ -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);
}
Loading
Loading