diff --git a/frontend/src/components/workspace/messages/message-list-item.tsx b/frontend/src/components/workspace/messages/message-list-item.tsx
index 22d8dc62d..6c3dd48f0 100644
--- a/frontend/src/components/workspace/messages/message-list-item.tsx
+++ b/frontend/src/components/workspace/messages/message-list-item.tsx
@@ -46,6 +46,12 @@ export function MessageListItem({
message: Message;
isLoading?: boolean;
threadId: string;
+ // ``feedback`` is ``undefined`` for messages that are not feedback-eligible
+ // (non-final AI messages, humans, tool results). It is ``null`` for the
+ // final ai_message of a run that has no rating yet, and a FeedbackData
+ // object once rated. The button renders whenever the field is present.
+ feedback?: FeedbackData | null;
+ runId?: string;
}) {
const isHuman = message.type === "human";
return (
@@ -74,11 +80,11 @@ export function MessageListItem({
""
}
/>
- {!isHuman && runId && threadId && (
+ {feedback !== undefined && runId && threadId && (
)}
diff --git a/frontend/src/components/workspace/messages/message-list.tsx b/frontend/src/components/workspace/messages/message-list.tsx
index 89a638470..9ef917e8d 100644
--- a/frontend/src/components/workspace/messages/message-list.tsx
+++ b/frontend/src/components/workspace/messages/message-list.tsx
@@ -4,7 +4,6 @@ import {
Conversation,
ConversationContent,
} from "@/components/ai-elements/conversation";
-import type { FeedbackData } from "@/core/api/feedback";
import { useI18n } from "@/core/i18n/hooks";
import {
extractContentFromMessage,
@@ -19,7 +18,7 @@ import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import type { Subtask } from "@/core/tasks";
import { useUpdateSubtask } from "@/core/tasks/context";
import type { AgentThreadState } from "@/core/threads";
-import { useThreadFeedback } from "@/core/threads/hooks";
+import { useThreadMessageEnrichment } from "@/core/threads/hooks";
import { cn } from "@/lib/utils";
import { ArtifactFileList } from "../artifacts/artifact-file-list";
@@ -48,11 +47,9 @@ export function MessageList({
const { t } = useI18n();
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const updateSubtask = useUpdateSubtask();
- const { data: feedbackData } = useThreadFeedback(threadId);
const messages = thread.messages;
+ const { data: enrichment } = useThreadMessageEnrichment(threadId);
- // Track AI message ordinal index for feedback mapping
- let aiMessageIndex = 0;
if (thread.isThreadLoading && messages.length === 0) {
return ;
}
@@ -64,24 +61,21 @@ export function MessageList({
{groupMessages(messages, (group) => {
if (group.type === "human" || group.type === "assistant") {
return group.messages.map((msg) => {
- let runId: string | undefined;
- let feedback: FeedbackData | null = null;
- if (msg.type !== "human" && feedbackData) {
- runId =
- feedbackData.runIdByAiIndex[aiMessageIndex] ?? undefined;
- feedback = runId
- ? (feedbackData.feedbackByRunId[runId] ?? null)
- : null;
- aiMessageIndex++;
- }
+ // Run id and feedback are sourced from the ``/history``
+ // enrichment query (see ``useThreadMessageEnrichment``). The
+ // map is keyed by ``message.id`` so tool_call interleavings
+ // and multi-run threads map cleanly without positional math.
+ // ``feedback`` is ``undefined`` for non-eligible messages,
+ // ``null`` for eligible-but-unrated, and an object once rated.
+ const entry = msg.id ? enrichment?.get(msg.id) : undefined;
return (
);
});
diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts
index 36ac28628..3b0aafda2 100644
--- a/frontend/src/core/threads/hooks.ts
+++ b/frontend/src/core/threads/hooks.ts
@@ -8,6 +8,7 @@ import { toast } from "sonner";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { getAPIClient } from "../api";
+import type { FeedbackData } from "../api/feedback";
import { fetchWithAuth } from "../api/fetcher";
import { getBackendBaseURL } from "../config";
import { useI18n } from "../i18n/hooks";
@@ -294,7 +295,9 @@ export function useThreadStream({
onFinish(state) {
listeners.current.onFinish?.(state.values);
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
- void queryClient.invalidateQueries({ queryKey: ["thread-feedback"] });
+ void queryClient.invalidateQueries({
+ queryKey: ["thread-message-enrichment"],
+ });
},
});
@@ -680,52 +683,62 @@ export function useRenameThread() {
});
}
-export interface ThreadFeedbackData {
- /** Maps AI message ordinal index (0-based, counting only AI messages) to run_id */
- runIdByAiIndex: string[];
- /** Maps run_id to feedback data */
- feedbackByRunId: Record<
- string,
- { feedback_id: string; rating: number; comment: string | null }
- >;
+/** Per-message enrichment data attached by the backend ``/history`` helper. */
+export interface MessageEnrichment {
+ run_id: string;
+ /** ``undefined`` = not feedback-eligible; ``null`` = eligible but unrated. */
+ feedback?: FeedbackData | null;
}
-export function useThreadFeedback(threadId: string | null | undefined) {
+/**
+ * Fetch ``/history`` once and index feedback + run_id by message id.
+ *
+ * Replaces the old ``useThreadFeedback`` hook which keyed by AI-message
+ * ordinal position — an inherently fragile mapping that broke whenever
+ * ``ai_tool_call`` messages were interleaved with ``ai_message`` messages.
+ * Keying by ``message.id`` is stable regardless of run count, tool-call
+ * chains, or summarization.
+ *
+ * The ``/history`` response is refreshed on every stream completion via
+ * ``invalidateQueries(["thread-message-enrichment"])`` in ``onFinish``.
+ */
+export function useThreadMessageEnrichment(
+ threadId: string | null | undefined,
+) {
return useQuery({
- queryKey: ["thread-feedback", threadId],
- queryFn: async (): Promise => {
- const empty: ThreadFeedbackData = {
- runIdByAiIndex: [],
- feedbackByRunId: {},
- };
+ queryKey: ["thread-message-enrichment", threadId],
+ queryFn: async (): Promise