Skip to content

Commit 8fb9e81

Browse files
committed
Implement thread management in Chat component and enhance CombinedAgentSelector
- Added thread management functionality in the Chat component, allowing users to load and select threads based on the selected agent. - Introduced a new ThreadsService for fetching thread data, improving modularity and separation of concerns. - Enhanced the CombinedAgentSelector component to include thread selection, enabling users to create and switch between threads seamlessly. - Updated state management to handle loading threads and resetting thread selection when changing agents, improving user experience. - Refactored related TypeScript types and API interactions to ensure consistency and reliability in thread handling. These changes significantly enhance the chat experience by integrating thread management, providing users with a more dynamic and organized interface.
1 parent 81076c7 commit 8fb9e81

File tree

8 files changed

+281
-92
lines changed

8 files changed

+281
-92
lines changed

src/api/stores.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,11 @@ def _deserialize_thread_item(self, item_data: dict[str, Any]) -> ThreadItem:
241241
)
242242
elif item_type == "chatkit.assistant_message":
243243
content = [
244-
AssistantMessageContent(type="output_text", text=part["text"])
244+
AssistantMessageContent(
245+
type="output_text",
246+
text=part.get("text", ""),
247+
annotations=part.get("annotations", []) if isinstance(part.get("annotations"), list) else []
248+
)
245249
for part in item_data.get("content", [])
246250
if part.get("type") == "output_text"
247251
]

src/components/CombinedAgentSelector.tsx

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,27 +8,43 @@ import {
88
} from '@ionic/react';
99
import type { BackendType } from '@/services/backendConfig';
1010
import type { AgentRecord } from '@/types/agent';
11+
import type { ThreadMetadata } from '@/types/thread';
1112

1213
interface CombinedAgentSelectorProps {
1314
backendType: BackendType;
1415
selectedAgent: AgentRecord | null;
1516
agents: AgentRecord[];
17+
threads: ThreadMetadata[];
18+
selectedThreadId: string | null;
19+
loadingThreads?: boolean;
1620
onBackendChange: (backend: BackendType) => void;
1721
onAgentChange: (agentId: string) => void;
22+
onThreadChange: (threadId: string | null) => void;
23+
onCreateNewThread: () => void;
1824
}
1925

2026
const CombinedAgentSelector = ({
2127
backendType,
2228
selectedAgent,
2329
agents,
30+
threads,
31+
selectedThreadId,
32+
loadingThreads = false,
2433
onBackendChange,
2534
onAgentChange,
35+
onThreadChange,
36+
onCreateNewThread,
2637
}: CombinedAgentSelectorProps) => {
2738
const [backendPopoverOpen, setBackendPopoverOpen] = useState(false);
2839
const [agentPopoverOpen, setAgentPopoverOpen] = useState(false);
40+
const [threadPopoverOpen, setThreadPopoverOpen] = useState(false);
2941

3042
const backendDisplay = backendType === 'typescript' ? 'TypeScript' : 'Python';
3143
const agentDisplay = selectedAgent?.name || 'Select Agent';
44+
const selectedThread = threads.find((t) => t.id === selectedThreadId);
45+
const threadDisplay = selectedThread
46+
? selectedThread.title || `Thread ${selectedThread.id.slice(0, 8)}`
47+
: 'New Thread';
3248

3349
const handleBackendClick = (backend: BackendType) => {
3450
onBackendChange(backend);
@@ -40,6 +56,16 @@ const CombinedAgentSelector = ({
4056
setAgentPopoverOpen(false);
4157
};
4258

59+
const handleThreadClick = (threadId: string | null) => {
60+
onThreadChange(threadId);
61+
setThreadPopoverOpen(false);
62+
};
63+
64+
const handleCreateNewThread = () => {
65+
onCreateNewThread();
66+
setThreadPopoverOpen(false);
67+
};
68+
4369
return (
4470
<>
4571
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
@@ -60,6 +86,16 @@ const CombinedAgentSelector = ({
6086
>
6187
{agentDisplay}
6288
</IonButton>
89+
<span>/</span>
90+
<IonButton
91+
fill="clear"
92+
onClick={() => setThreadPopoverOpen(true)}
93+
id="thread-selector-button"
94+
style={{ '--padding-start': '4px', '--padding-end': '4px' }}
95+
disabled={!selectedAgent}
96+
>
97+
{threadDisplay}
98+
</IonButton>
6399
</div>
64100

65101
<IonPopover
@@ -112,6 +148,46 @@ const CombinedAgentSelector = ({
112148
))}
113149
</IonList>
114150
</IonPopover>
151+
152+
<IonPopover
153+
isOpen={threadPopoverOpen}
154+
onDidDismiss={() => setThreadPopoverOpen(false)}
155+
trigger="thread-selector-button"
156+
showBackdrop={false}
157+
>
158+
<IonList>
159+
<IonItem button onClick={handleCreateNewThread}>
160+
<IonLabel>
161+
<span style={{ fontWeight: selectedThreadId === null ? 'bold' : 'normal' }}>
162+
+ New Thread
163+
</span>
164+
</IonLabel>
165+
</IonItem>
166+
{loadingThreads ? (
167+
<IonItem>
168+
<IonLabel>Loading threads...</IonLabel>
169+
</IonItem>
170+
) : (
171+
threads.map((thread) => (
172+
<IonItem
173+
key={thread.id}
174+
button
175+
onClick={() => handleThreadClick(thread.id)}
176+
>
177+
<IonLabel>
178+
<span
179+
style={{
180+
fontWeight: selectedThreadId === thread.id ? 'bold' : 'normal',
181+
}}
182+
>
183+
{thread.title || `Thread ${thread.id.slice(0, 8)}`}
184+
</span>
185+
</IonLabel>
186+
</IonItem>
187+
))
188+
)}
189+
</IonList>
190+
</IonPopover>
115191
</>
116192
);
117193
};

src/pages/Chat.tsx

Lines changed: 95 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ import { toast } from 'sonner';
33
import { supabase } from '@/integrations/supabase/client';
44
import { ChatKit, useChatKit } from '@openai/chatkit-react';
55
import { agentsService } from '@/services/agentsService';
6+
import { threadsService } from '@/services/threadsService';
67
import { getBackendType, setBackendType, getBackendBaseUrl, getChatKitUrl, type BackendType } from '@/services/backendConfig';
78
import type { AgentRecord } from '@/types/agent';
9+
import type { ThreadMetadata } from '@/types/thread';
810
import {
911
IonPage,
1012
IonHeader,
@@ -25,6 +27,8 @@ const Chat = () => {
2527
const [selectedAgent, setSelectedAgent] = useState<AgentRecord | null>(null);
2628
const [loadingAgents, setLoadingAgents] = useState(false);
2729
const [currentThreadId, setCurrentThreadId] = useState<string | null>(null);
30+
const [threads, setThreads] = useState<ThreadMetadata[]>([]);
31+
const [loadingThreads, setLoadingThreads] = useState(false);
2832

2933
// Settings state
3034
const [darkMode, setDarkMode] = useState(true);
@@ -74,29 +78,36 @@ const Chat = () => {
7478
}
7579
};
7680

77-
// Load the most recent thread for the current user
78-
const loadMostRecentThread = useCallback(async () => {
79-
try {
80-
const authHeaders = await getAuthHeaders();
81-
const response = await fetch(`${getServerBaseUrl()}/threads/list?limit=1&order=desc`, {
82-
method: 'GET',
83-
headers: authHeaders,
84-
});
81+
// Load threads for the current agent
82+
const loadThreads = useCallback(async (skipAutoSelect: boolean = false) => {
83+
if (!selectedAgent) {
84+
setThreads([]);
85+
return;
86+
}
8587

86-
if (response.ok) {
87-
const data = await response.json();
88-
if (data.data && data.data.length > 0) {
89-
const mostRecentThread = data.data[0];
90-
console.log('Loading most recent thread:', mostRecentThread.id);
91-
setCurrentThreadId(mostRecentThread.id);
92-
return mostRecentThread.id;
93-
}
88+
try {
89+
setLoadingThreads(true);
90+
const response = await threadsService.listThreads(backendType, 50);
91+
setThreads(response.data);
92+
93+
// Auto-select the most recent thread if available and no thread is selected
94+
// Skip auto-selection if explicitly requested (e.g., when creating a new thread)
95+
if (!skipAutoSelect) {
96+
setCurrentThreadId((prevThreadId) => {
97+
if (response.data.length > 0 && !prevThreadId) {
98+
return response.data[0].id;
99+
}
100+
return prevThreadId;
101+
});
94102
}
95103
} catch (error) {
96-
console.error('Error loading most recent thread:', error);
104+
console.error('Error loading threads:', error);
105+
toast.error('Failed to load threads');
106+
setThreads([]);
107+
} finally {
108+
setLoadingThreads(false);
97109
}
98-
return null;
99-
}, [backendType]);
110+
}, [selectedAgent, backendType]);
100111

101112
// Check for existing session or sign in anonymously on component mount
102113
useEffect(() => {
@@ -112,8 +123,6 @@ const Chat = () => {
112123
// We have a valid session, use it
113124
setIsAuthenticated(true);
114125
await loadAgents();
115-
// Load the most recent thread to continue the conversation
116-
await loadMostRecentThread();
117126
} else {
118127
// No valid session, sign in anonymously
119128
const { data, error } = await supabase.auth.signInAnonymously();
@@ -123,8 +132,6 @@ const Chat = () => {
123132
} else {
124133
setIsAuthenticated(true);
125134
await loadAgents();
126-
// Load the most recent thread to continue the conversation
127-
await loadMostRecentThread();
128135
}
129136
}
130137
} catch (error) {
@@ -151,14 +158,20 @@ const Chat = () => {
151158
return () => {
152159
subscription.unsubscribe();
153160
};
154-
}, [loadAgents, loadMostRecentThread]);
161+
}, [loadAgents]);
155162

156-
// Load agent details when selected agent changes
163+
// Load agent details and threads when selected agent changes
157164
useEffect(() => {
158165
if (selectedAgent?.id) {
159166
loadAgentDetails(selectedAgent.id);
167+
// Reset thread selection when agent changes
168+
setCurrentThreadId(null);
169+
loadThreads();
170+
} else {
171+
setThreads([]);
172+
setCurrentThreadId(null);
160173
}
161-
}, [selectedAgent?.id]);
174+
}, [selectedAgent?.id, loadThreads]);
162175

163176
// Handle backend type change
164177
const handleBackendChange = (newBackendType: BackendType) => {
@@ -195,15 +208,31 @@ const Chat = () => {
195208
const agent = agents.find((a) => a.id === agentId);
196209
if (agent) {
197210
setSelectedAgent(agent);
211+
// Reset thread when switching agents
212+
setCurrentThreadId(null);
198213
}
199214
};
200215

201216
// ChatKit configuration - use a generic agents endpoint and route dynamically
202217
const chatKitUrl = getChatKitUrl(backendType);
203218

204-
const { control } = useChatKit({
219+
const chatKitHook = useChatKit({
205220
onThreadChange: ({ threadId }) => {
206-
setCurrentThreadId(threadId);
221+
// Update thread ID when ChatKit changes it (e.g., when a new message creates a thread)
222+
setCurrentThreadId((prevThreadId) => {
223+
// Only update if threadId actually changed (avoid infinite loops)
224+
if (threadId !== prevThreadId) {
225+
// Reload threads when a new thread is created, but don't auto-select
226+
// since we're already on the new thread
227+
if (threadId) {
228+
setTimeout(() => {
229+
loadThreads(true); // Skip auto-selection since we're already on this thread
230+
}, 500);
231+
}
232+
return threadId;
233+
}
234+
return prevThreadId;
235+
});
207236
},
208237
onClientTool: async (invocation) => {
209238
if (invocation.name === "switch_theme") {
@@ -268,25 +297,11 @@ const Chat = () => {
268297
placeholder: `Message your ${selectedAgent?.name} AI agent...`,
269298
// tools: [{ id: "rate", label: "Rate", icon: "star", pinned: true }],
270299
},
300+
history: {
301+
enabled: false, // Disable built-in history management
302+
},
271303
header: {
272-
leftAction: {
273-
icon: 'sidebar-left',
274-
onClick: async () => {
275-
// Open the left settings menu
276-
if (leftMenuRef.current) {
277-
await leftMenuRef.current.open();
278-
}
279-
},
280-
},
281-
// rightAction: {
282-
// icon: 'sidebar-right',
283-
// onClick: async () => {
284-
// // Open the right menu
285-
// if (rightMenuRef.current) {
286-
// await rightMenuRef.current.open();
287-
// }
288-
// },
289-
// },
304+
enabled: false, // Disable built-in header to use our custom one
290305
},
291306
startScreen: {
292307
// greeting: selectedAgent ? `Welcome to Timestep AI! You're chatting with ${selectedAgent.name}` : "Welcome to Timestep AI!",
@@ -308,6 +323,35 @@ const Chat = () => {
308323
},
309324
});
310325

326+
// Extract control and setThreadId from the hook
327+
const { control, setThreadId } = chatKitHook;
328+
329+
// Handle thread switching
330+
const handleThreadChange = (threadId: string | null) => {
331+
// Just update state - the useEffect will handle calling setThreadId
332+
setCurrentThreadId(threadId);
333+
};
334+
335+
// Handle creating a new thread
336+
const handleCreateNewThread = () => {
337+
// Just update state - the useEffect will handle calling setThreadId
338+
setCurrentThreadId(null);
339+
// Reload threads to get the new one when it's created, but skip auto-selection
340+
// so we don't automatically switch away from the new thread
341+
setTimeout(() => {
342+
loadThreads(true); // Skip auto-selection
343+
}, 1000);
344+
};
345+
346+
// Set initial thread when component mounts or thread changes
347+
// Only call setThreadId when we have a valid thread ID (not null) or when explicitly setting to null for new thread
348+
useEffect(() => {
349+
if (setThreadId && currentThreadId !== undefined && selectedAgent) {
350+
// Only set thread ID if we have a valid thread ID or explicitly want to create a new thread
351+
setThreadId(currentThreadId);
352+
}
353+
}, [setThreadId, currentThreadId, selectedAgent]);
354+
311355
if (!isAuthenticated || loadingAgents) {
312356
return (
313357
<IonPage>
@@ -368,8 +412,13 @@ const Chat = () => {
368412
backendType={backendType}
369413
selectedAgent={selectedAgent}
370414
agents={agents}
415+
threads={threads}
416+
selectedThreadId={currentThreadId}
417+
loadingThreads={loadingThreads}
371418
onBackendChange={handleBackendChange}
372419
onAgentChange={handleAgentChange}
420+
onThreadChange={handleThreadChange}
421+
onCreateNewThread={handleCreateNewThread}
373422
/>
374423
</IonButtons>
375424
<IonTitle>Timestep AI</IonTitle>

0 commit comments

Comments
 (0)