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
152 changes: 103 additions & 49 deletions examples/kitchen-sink/src/app/(console)/support-agent/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
'use client';

import { Button } from '@/components/button';
import { ConversationList } from '@/components/conversation-list';
import { Text } from '@/components/text';
import { useConversations } from '@/lib/conversations';
import { Field } from '@base-ui-components/react/field';
import { Form } from '@base-ui-components/react/form';
import { createFeedbackClient, Feedback } from 'axiom/ai/feedback';
Expand All @@ -17,70 +19,122 @@ const { sendFeedback } = createFeedbackClient({
});

export default function SupportAgent() {
const { input, setInput, messages, result, error, isLoading, handleSubmit } = useSupportChat();
const {
conversations,
currentId,
isLoaded,
createConversation,
selectConversation,
deleteConversation,
getMessages,
setMessages,
updateConversationTitle,
} = useConversations();

const { input, setInput, messages, result, error, isLoading, handleSubmit } = useSupportChat({
conversationId: currentId,
getMessages,
setMessages,
updateTitle: updateConversationTitle,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feedback state not reset on conversation switch

Medium Severity

The feedbackGiven state (keyed by message index) is never cleared when switching conversations. After giving feedback on, say, message index 2 in one conversation, switching to another conversation will incorrectly show that same feedback indicator on its message index 2. The state needs to be reset when currentId changes.

Fix in Cursor Fix in Web

const [feedbackGiven, setFeedbackGiven] = useState<Record<number, 'up' | 'down'>>({});

const handleFeedback = async (messageIndex: number, value: 'up' | 'down', message: string) => {
if (!result?.links) {
console.warn('Cannot send feedback: no links available', { result });
const handleFeedback = async (messageIndex: number, value: 'up' | 'down', feedbackMessage: string) => {
const msg = messages[messageIndex];
if (!msg?.links) {
console.warn('Cannot send feedback: no links available for message', { messageIndex, msg });
return;
}
setFeedbackGiven((prev) => ({ ...prev, [messageIndex]: value }));
await sendFeedback(
result.links,
Feedback.thumb({ name: 'response-quality', value, message: message || undefined }),
msg.links,
Feedback.thumb({ name: 'response-quality', value, message: feedbackMessage || undefined }),
);
};

if (!isLoaded) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">Loading...</div>
</div>
);
}

return (
<>
<Text variant="h1">
<span className="font-mono">Pets.ai</span> support agent
</Text>
<Text variant="subtitle">Respond to Pets.ai customer support requests.</Text>
<div className="flex gap-8">
<div className="w-64 shrink-0">
<h3 className="text-sm font-semibold text-gray-900 mb-4 uppercase">Conversations</h3>
<ConversationList
conversations={conversations}
currentId={currentId}
onSelect={selectConversation}
onCreate={() => createConversation()}
onDelete={deleteConversation}
/>
</div>

{/* Chat History */}
<div className="flex flex-col gap-4 mb-6 w-full max-w-xl bg-gray-50 p-4 rounded-lg border border-gray-200 max-h-[500px] overflow-y-auto">
{messages.length === 0 && (
<div className="text-sm text-gray-500 text-center italic">No messages yet.</div>
<div className="flex-1 min-w-0">
<Text variant="h1">
<span className="font-mono">Pets.ai</span> support agent
</Text>
<Text variant="subtitle">Respond to Pets.ai customer support requests.</Text>

{currentId && (
<div className="text-xs text-gray-400 mb-4 font-mono">Conversation: {currentId}</div>
)}
{messages.map((msg, idx) => (
<ChatMessage
key={idx}
message={msg}
feedback={feedbackGiven[idx]}
onFeedback={(value, message) => handleFeedback(idx, value, message)}
/>
))}
{isLoading && (
<div className="flex items-center gap-2 text-sm text-gray-500">
<div className="animate-pulse">Thinking...</div>

{!currentId ? (
<div className="flex flex-col items-center justify-center py-16 text-gray-500">
<p className="mb-4">No conversation selected</p>
<Button onClick={() => createConversation()}>Start a new conversation</Button>
</div>
)}
</div>
) : (
<>
<div className="flex flex-col gap-4 mb-6 w-full max-w-xl bg-gray-50 p-4 rounded-lg border border-gray-200 max-h-[500px] overflow-y-auto">
{messages.length === 0 && (
<div className="text-sm text-gray-500 text-center italic">No messages yet.</div>
)}
{messages.map((msg, idx) => (
<ChatMessage
key={idx}
message={msg}
feedback={feedbackGiven[idx]}
onFeedback={(value, message) => handleFeedback(idx, value, message)}
/>
))}
{isLoading && (
<div className="flex items-center gap-2 text-sm text-gray-500">
<div className="animate-pulse">Thinking...</div>
</div>
)}
</div>

<Form className="flex w-full max-w-xl flex-col gap-4 mb-8" onSubmit={handleSubmit}>
<Field.Root name="prompt" className="flex flex-col items-start gap-1">
<Field.Control
placeholder="Type your message..."
value={input}
onChange={(event) => setInput(event.target.value)}
disabled={isLoading}
className={`w-full rounded-md border border-gray-200 p-3 text-base text-gray-900 focus:outline-2 focus:-outline-offset-1 focus:outline-blue-800 ${
isLoading ? 'bg-gray-100 cursor-not-allowed' : ''
}`}
/>
</Field.Root>
<Button disabled={isLoading || !input.trim()}>Send</Button>
</Form>
<Form className="flex w-full max-w-xl flex-col gap-4 mb-8" onSubmit={handleSubmit}>
<Field.Root name="prompt" className="flex flex-col items-start gap-1">
<Field.Control
placeholder="Type your message..."
value={input}
onChange={(event) => setInput(event.target.value)}
disabled={isLoading}
className={`w-full rounded-md border border-gray-200 p-3 text-base text-gray-900 focus:outline-2 focus:-outline-offset-1 focus:outline-blue-800 ${
isLoading ? 'bg-gray-100 cursor-not-allowed' : ''
}`}
/>
</Field.Root>
<Button disabled={isLoading || !input.trim()}>Send</Button>
</Form>

{error && (
<div className="p-4 bg-red-50 text-red-800 rounded-md border border-red-200 mb-4 max-w-xl">
{error}
</div>
)}
{error && (
<div className="p-4 bg-red-50 text-red-800 rounded-md border border-red-200 mb-4 max-w-xl">
{error}
</div>
)}

{result && <AgentInternals result={result} />}
</>
{result && <AgentInternals result={result} />}
</>
)}
</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -1,54 +1,97 @@
'use client';

import { useState } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { apiClient } from '@/lib/api/api-client';
import type { SupportAgentResult } from '@/lib/capabilities/support-agent/support-agent';
import type { StoredMessage } from '@/lib/conversations';

export type ModelMessage = { role: 'user' | 'assistant' | 'system'; content: string };
export type ModelMessage = StoredMessage;

export function useSupportChat() {
type UseSupportChatOptions = {
conversationId: string | null;
getMessages: (id: string) => ModelMessage[];
setMessages: (id: string, messages: ModelMessage[]) => void;
updateTitle?: (id: string, title: string) => void;
};

export function useSupportChat({
conversationId,
getMessages,
setMessages,
updateTitle,
}: UseSupportChatOptions) {
const [input, setInput] = useState('');
const [messages, setMessages] = useState<ModelMessage[]>([]);
const [messages, setLocalMessages] = useState<ModelMessage[]>([]);
const [result, setResult] = useState<SupportAgentResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();

if (!input.trim() || isLoading) return;
useEffect(() => {
if (conversationId) {
const storedMessages = getMessages(conversationId);
setLocalMessages(storedMessages);
setResult(null);
setError(null);
} else {
setLocalMessages([]);
setResult(null);
setError(null);
}
}, [conversationId, getMessages]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Conversation switch doesn't reset loading state, corrupts UI

Medium Severity

When switching conversations, the useEffect resets result and error but not isLoading. If a request is in-flight for conversation A and the user switches to conversation B, the "Thinking..." indicator shows on conversation B and the input is disabled — even though B has no pending request. Worse, when A's response arrives, the stale handleSubmit closure calls setLocalMessages and setResult with A's data, overwriting B's view with the wrong conversation's messages.

Additional Locations (1)

Fix in Cursor Fix in Web


setIsLoading(true);
setError(null);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();

const newMessages = [...messages, { role: 'user', content: input } as ModelMessage];
setMessages(newMessages);
setInput('');
if (!input.trim() || isLoading || !conversationId) return;

try {
const clientResponse = await apiClient.api['support-response'].$post({
json: { messages: newMessages },
});
setIsLoading(true);
setError(null);

const json = await clientResponse.json();
const userMessage: ModelMessage = { role: 'user', content: input };
const newMessages = [...messages, userMessage];
setLocalMessages(newMessages);
setMessages(conversationId, newMessages);
setInput('');

if ('error' in json) {
throw new Error(json.error);
if (messages.length === 0 && updateTitle) {
const title = input.slice(0, 50) + (input.length > 50 ? '...' : '');
updateTitle(conversationId, title);
}

const agentResult = json.data as unknown as SupportAgentResult;
setResult(agentResult);
try {
const clientResponse = await apiClient.api['support-response'].$post({
json: { messages: newMessages, conversationId },
});

const json = await clientResponse.json();

if (agentResult.answer) {
setMessages((prev) => [...prev, agentResult.answer as ModelMessage]);
if ('error' in json) {
throw new Error(json.error);
}

const agentResult = json.data as unknown as SupportAgentResult;
setResult(agentResult);

if (agentResult.answer) {
const assistantMessage: ModelMessage = {
role: 'assistant',
content: agentResult.answer.content as string,
links: agentResult.links,
};
const updatedMessages = [...newMessages, assistantMessage];
setLocalMessages(updatedMessages);
setMessages(conversationId, updatedMessages);
}
} catch (err) {
console.error(err);
setError('❌ Error generating response. Please try again.');
} finally {
setIsLoading(false);
}
} catch (err) {
console.error(err);
setError('❌ Error generating response. Please try again.');
} finally {
setIsLoading(false);
}
};
},
[input, isLoading, conversationId, messages, setMessages, updateTitle]
);

return {
input,
Expand Down
72 changes: 72 additions & 0 deletions examples/kitchen-sink/src/components/conversation-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
'use client';

import { Conversation } from '@/lib/conversations';

type ConversationListProps = {
conversations: Conversation[];
currentId: string | null;
onSelect: (id: string) => void;
onCreate: () => void;
onDelete: (id: string) => void;
};

export function ConversationList({
conversations,
currentId,
onSelect,
onCreate,
onDelete,
}: ConversationListProps) {
return (
<div className="flex flex-col gap-1">
<button
onClick={onCreate}
className="w-full py-2 px-3 text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100 rounded-md border border-dashed border-gray-300 mb-2"
>
+ New conversation
</button>
{conversations.length === 0 && (
<div className="text-xs text-gray-400 px-3 py-2">No conversations yet</div>
)}
{conversations.map((conv) => (
<div
key={conv.id}
className={`group flex items-center justify-between rounded-md cursor-pointer ${
currentId === conv.id
? 'bg-gray-50 outline outline-1 outline-gray-200 outline-offset-[-1px]'
: 'hover:bg-gray-50'
}`}
>
<button
onClick={() => onSelect(conv.id)}
className="flex-1 py-[0.3125rem] px-3 text-left text-sm truncate"
title={conv.title}
>
{conv.title}
</button>
<button
onClick={(e) => {
e.stopPropagation();
onDelete(conv.id);
}}
className="opacity-0 group-hover:opacity-100 p-1 mr-1 text-gray-400 hover:text-red-500 transition-opacity"
title="Delete conversation"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="w-4 h-4"
>
<path
fillRule="evenodd"
d="M8.75 1A2.75 2.75 0 006 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 10.23 1.482l.149-.022.841 10.518A2.75 2.75 0 007.596 19h4.807a2.75 2.75 0 002.742-2.53l.841-10.519.149.023a.75.75 0 00.23-1.482A41.03 41.03 0 0014 4.193V3.75A2.75 2.75 0 0011.25 1h-2.5zM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4zM8.58 7.72a.75.75 0 00-1.5.06l.3 7.5a.75.75 0 101.5-.06l-.3-7.5zm4.34.06a.75.75 0 10-1.5-.06l-.3 7.5a.75.75 0 101.5.06l.3-7.5z"
clipRule="evenodd"
/>
</svg>
</button>
</div>
))}
</div>
);
}
Loading
Loading