From c8549cdfe338ad77cdcf585736b858f6ec2c9892 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 17 Mar 2026 01:32:52 +0000 Subject: [PATCH 01/10] Add multi-agent chat selection Co-authored-by: Kent C. Dodds --- client/agent-multi-select-combobox.tsx | 425 ++++++++++++++++++++++ client/routes/chat.tsx | 258 ++++++++++++- e2e/chat.spec.ts | 85 +++++ migrations/0005-chat-thread-agent-ids.sql | 8 + server/agents.ts | 37 ++ server/chat-threads.ts | 88 ++++- server/handlers/chat-agents.ts | 39 ++ server/handlers/chat-threads.ts | 91 ++++- server/router.ts | 8 + server/routes.ts | 2 + shared/chat.ts | 17 + worker/ai-runtime.ts | 52 +-- worker/chat-agent.ts | 403 ++++++++++++++------ worker/db.ts | 1 + 14 files changed, 1339 insertions(+), 175 deletions(-) create mode 100644 client/agent-multi-select-combobox.tsx create mode 100644 migrations/0005-chat-thread-agent-ids.sql create mode 100644 server/handlers/chat-agents.ts diff --git a/client/agent-multi-select-combobox.tsx b/client/agent-multi-select-combobox.tsx new file mode 100644 index 0000000..031c4a6 --- /dev/null +++ b/client/agent-multi-select-combobox.tsx @@ -0,0 +1,425 @@ +import { type Handle } from 'remix/component' +import { + colors, + radius, + shadows, + spacing, + transitions, + typography, +} from '#client/styles/tokens.ts' +import { type ManagedChatAgent } from '#shared/chat.ts' + +type AgentMultiSelectComboboxProps = { + id: string + agents: Array + selectedAgentIds: Array + disabled?: boolean + error?: string | null + isLoading?: boolean + onSelectionChange: (agentIds: Array) => void | Promise +} + +function normalizeSearchValue(value: string) { + return value.trim().toLowerCase() +} + +function buildSelectedAgentNames( + agents: Array, + selectedAgentIds: Array, +) { + const agentsById = new Map(agents.map((agent) => [agent.id, agent] as const)) + return selectedAgentIds + .map((agentId) => agentsById.get(agentId)?.name ?? null) + .filter((agentName): agentName is string => Boolean(agentName)) +} + +function resolveNextSelectedAgentIds(input: { + selectedAgentIds: Array + toggledAgentId: string +}) { + const selectedAgentIds = [...input.selectedAgentIds] + const selectedAgentIdSet = new Set(selectedAgentIds) + if (selectedAgentIdSet.has(input.toggledAgentId)) { + if (selectedAgentIds.length <= 1) { + return selectedAgentIds + } + return selectedAgentIds.filter((agentId) => agentId !== input.toggledAgentId) + } + return [...selectedAgentIds, input.toggledAgentId] +} + +export function AgentMultiSelectCombobox(handle: Handle) { + let isOpen = false + let search = '' + let highlightedAgentId: string | null = null + + function update() { + handle.update() + } + + function focusInput(inputId: string) { + void handle.queueTask(async () => { + const input = document.getElementById(inputId) + if (!(input instanceof HTMLInputElement)) return + input.focus() + input.select() + }) + } + + function focusButton(buttonId: string) { + void handle.queueTask(async () => { + const button = document.getElementById(buttonId) + if (!(button instanceof HTMLButtonElement)) return + button.focus() + }) + } + + return (props: AgentMultiSelectComboboxProps) => { + const buttonId = `${props.id}-button` + const inputId = `${props.id}-search` + const listboxId = `${props.id}-listbox` + const selectedAgentCount = props.selectedAgentIds.length + const normalizedSearch = normalizeSearchValue(search) + const filteredAgents = props.agents.filter((agent) => { + if (!normalizedSearch) return true + return normalizeSearchValue(agent.name).includes(normalizedSearch) + }) + const selectedAgentNameList = buildSelectedAgentNames( + props.agents, + props.selectedAgentIds, + ) + const selectedAgentSummary = + selectedAgentNameList.length > 0 + ? selectedAgentNameList.join(', ') + : 'No agents selected' + const resolvedHighlightedAgentId = + filteredAgents.some((agent) => agent.id === highlightedAgentId) + ? highlightedAgentId + : filteredAgents[0]?.id ?? null + + function openCombobox() { + if (props.disabled) return + isOpen = true + if ( + !filteredAgents.some((agent) => agent.id === highlightedAgentId) && + filteredAgents[0] + ) { + highlightedAgentId = filteredAgents[0].id + } + update() + focusInput(inputId) + } + + function closeCombobox() { + if (!isOpen) return + isOpen = false + search = '' + highlightedAgentId = null + update() + } + + function toggleCombobox() { + if (isOpen) { + closeCombobox() + return + } + openCombobox() + } + + function handleWrapperFocusOut(event: FocusEvent) { + if (!(event.currentTarget instanceof HTMLDivElement)) return + const nextTarget = + event.relatedTarget instanceof Node ? event.relatedTarget : null + if (nextTarget && event.currentTarget.contains(nextTarget)) return + closeCombobox() + } + + function handleButtonKeyDown(event: KeyboardEvent) { + if (props.disabled) return + if ( + event.key !== 'ArrowDown' && + event.key !== 'ArrowUp' && + event.key !== 'Enter' && + event.key !== ' ' + ) { + return + } + event.preventDefault() + openCombobox() + } + + function handleSearchInput(event: Event) { + if (!(event.currentTarget instanceof HTMLInputElement)) return + search = event.currentTarget.value + const nextFilteredAgents = props.agents.filter((agent) => + normalizeSearchValue(agent.name).includes(normalizeSearchValue(search)), + ) + highlightedAgentId = nextFilteredAgents[0]?.id ?? null + update() + } + + function moveHighlight(direction: 1 | -1) { + if (filteredAgents.length === 0) return + const currentIndex = filteredAgents.findIndex( + (agent) => agent.id === resolvedHighlightedAgentId, + ) + const startIndex = currentIndex === -1 ? 0 : currentIndex + const nextIndex = + (startIndex + direction + filteredAgents.length) % filteredAgents.length + highlightedAgentId = filteredAgents[nextIndex]?.id ?? null + update() + } + + function commitToggle(agentId: string) { + const nextSelectedAgentIds = resolveNextSelectedAgentIds({ + selectedAgentIds: props.selectedAgentIds, + toggledAgentId: agentId, + }) + highlightedAgentId = agentId + props.onSelectionChange(nextSelectedAgentIds) + update() + } + + function handleSearchKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') { + event.preventDefault() + closeCombobox() + focusButton(buttonId) + return + } + if (event.key === 'ArrowDown') { + event.preventDefault() + moveHighlight(1) + return + } + if (event.key === 'ArrowUp') { + event.preventDefault() + moveHighlight(-1) + return + } + if (event.key === 'Enter' || event.key === ' ') { + if (!resolvedHighlightedAgentId) return + event.preventDefault() + commitToggle(resolvedHighlightedAgentId) + } + } + + return ( +
+ + {isOpen ? ( +
+
+ + Included agents + +

+ {selectedAgentSummary} +

+
+ + {props.error ? ( +

+ {props.error} +

+ ) : null} +
+ {props.isLoading ? ( +

+ Loading agents... +

+ ) : filteredAgents.length === 0 ? ( +

+ No agents match your search. +

+ ) : ( + filteredAgents.map((agent) => { + const isSelected = props.selectedAgentIds.includes(agent.id) + const isHighlighted = agent.id === resolvedHighlightedAgentId + return ( + + ) + }) + )} +
+
+ ) : null} +
+ ) + } +} diff --git a/client/routes/chat.tsx b/client/routes/chat.tsx index a43b51a..b48ab08 100644 --- a/client/routes/chat.tsx +++ b/client/routes/chat.tsx @@ -1,4 +1,5 @@ import { type Handle } from 'remix/component' +import { AgentMultiSelectCombobox } from '#client/agent-multi-select-combobox.tsx' import { ChatClient, type ChatClientSnapshot } from '#client/chat-client.ts' import { navigate, routerEvents } from '#client/client-router.tsx' import { createDoubleCheck } from '#client/double-check.ts' @@ -24,13 +25,17 @@ import { typography, } from '#client/styles/tokens.ts' import { + type AvailableChatAgentListResponse, type ChatThreadLookupResponse, type ChatThreadListResponse, type ChatThreadSummary, + type ChatThreadAgentsUpdateResponse, type ChatThreadUpdateResponse, + type ManagedChatAgent, } from '#shared/chat.ts' type ThreadStatus = 'idle' | 'loading' | 'ready' | 'error' +type AvailableAgentsStatus = 'loading' | 'ready' | 'error' function getSelectedThreadIdFromLocation() { if (typeof window === 'undefined') return null @@ -40,10 +45,19 @@ function getSelectedThreadIdFromLocation() { return threadId || null } -function getAgentIdFromLocation() { +function getRequestedAgentIdsFromLocation() { if (typeof window === 'undefined') return null - const agentId = new URL(window.location.href).searchParams.get('agentId')?.trim() - return agentId || null + const url = new URL(window.location.href) + const requestedAgentIds = [ + ...url.searchParams + .getAll('agentIds') + .flatMap((value) => value.split(',')) + .map((value) => value.trim()) + .filter(Boolean), + url.searchParams.get('agentId')?.trim() ?? '', + ].filter(Boolean) + const uniqueAgentIds = new Set(requestedAgentIds) + return uniqueAgentIds.size > 0 ? [...uniqueAgentIds] : null } function buildThreadHref(threadId: string) { @@ -63,6 +77,16 @@ function truncatePreview(text: string) { return normalized.length > 120 ? `${normalized.slice(0, 117)}...` : normalized } +function normalizeSelectedAgentIds(agentIds?: ReadonlyArray | null) { + const uniqueAgentIds = new Set() + for (const agentId of agentIds ?? []) { + const normalizedAgentId = agentId.trim() + if (!normalizedAgentId) continue + uniqueAgentIds.add(normalizedAgentId) + } + return [...uniqueAgentIds] +} + function createInitialSnapshot(): ChatClientSnapshot { return { messages: [], @@ -97,6 +121,19 @@ function buildThreadPreviewFromMessages( return text ? truncatePreview(text) : null } +function getAssistantSpeakerName(message: ChatClientSnapshot['messages'][number]) { + if (message.role !== 'assistant') return 'You' + if (!message.metadata || typeof message.metadata !== 'object') { + return 'Assistant' + } + const agentName = + 'agentName' in message.metadata && + typeof message.metadata.agentName === 'string' + ? message.metadata.agentName.trim() + : '' + return agentName || 'Assistant' +} + async function fetchThreads(input?: { cursor?: string | null signal?: AbortSignal @@ -165,13 +202,13 @@ async function fetchThreadById(threadId: string, signal?: AbortSignal) { return payload.thread } -async function createThread(input?: { agentId?: string | null }) { +async function createThread(input?: { agentIds?: Array | null }) { const response = await fetch('/chat-threads', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - agentId: input?.agentId ?? null, + agentIds: input?.agentIds ?? [], }), }) const payload = (await response.json().catch(() => null)) as { @@ -185,6 +222,22 @@ async function createThread(input?: { agentId?: string | null }) { return payload.thread } +async function fetchAvailableAgents(signal?: AbortSignal) { + const response = await fetch('/chat-agents', { + credentials: 'include', + headers: { Accept: 'application/json' }, + signal, + }) + const payload = (await response.json().catch(() => null)) as + | (AvailableChatAgentListResponse & { error?: string }) + | { ok?: false; error?: string } + | null + if (!response.ok || !payload?.ok || !('agents' in payload)) { + throw new Error(payload?.error || 'Unable to load agents.') + } + return payload.agents +} + async function deleteThread(threadId: string) { const response = await fetch('/chat-threads/delete', { method: 'POST', @@ -223,6 +276,28 @@ async function updateThreadTitle(threadId: string, title: string) { return payload.thread } +async function updateThreadAgents(threadId: string, agentIds: Array) { + const response = await fetch('/chat-threads/agents', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ threadId, agentIds }), + }) + const payload = (await response.json().catch(() => null)) as + | (ChatThreadAgentsUpdateResponse & { error?: string }) + | { ok?: false; error?: string } + | null + if ( + !response.ok || + !payload?.ok || + !('thread' in payload) || + !payload.thread + ) { + throw new Error(payload?.error || 'Unable to update chat agents.') + } + return payload.thread +} + function renderMessageParts( parts: Array<{ type: string @@ -363,6 +438,10 @@ export function ChatRoute(handle: Handle) { let chatSnapshot = createInitialSnapshot() let activeClient: ChatClient | null = null let actionError: string | null = null + let availableAgents: Array = [] + let availableAgentsStatus: AvailableAgentsStatus = 'loading' + let availableAgentsError: string | null = null + let draftAgentIds = normalizeSelectedAgentIds(getRequestedAgentIdsFromLocation()) let syncInFlight = false let shouldAutoScrollMessages = true let showMessageScrollFadeTop = false @@ -370,6 +449,7 @@ export function ChatRoute(handle: Handle) { let showThreadListScrollFadeTop = false let showThreadListScrollFadeBottom = false const disconnectedIndicator = createSpinDelay(handle, { ssr: false }) + const agentMultiSelectCombobox = AgentMultiSelectCombobox(handle) const deleteThreadChecks = new Map< string, ReturnType @@ -422,6 +502,41 @@ export function ChatRoute(handle: Handle) { return threadListSnapshot.items } + function getActiveThreadSummary() { + if (!activeThreadId) return null + return getThreads().find((thread) => thread.id === activeThreadId) ?? null + } + + function getSelectedAgentIds() { + return getActiveThreadSummary()?.agentIds ?? draftAgentIds + } + + function syncDraftAgentIds() { + const selectedAgentIds = normalizeSelectedAgentIds(draftAgentIds) + const availableAgentIdSet = new Set(availableAgents.map((agent) => agent.id)) + const nextDraftAgentIds = selectedAgentIds.filter((agentId) => + availableAgentIdSet.has(agentId), + ) + if (nextDraftAgentIds.length > 0) { + draftAgentIds = nextDraftAgentIds + return + } + + const requestedAgentIds = normalizeSelectedAgentIds( + getRequestedAgentIdsFromLocation(), + ).filter((agentId) => availableAgentIdSet.has(agentId)) + if (requestedAgentIds.length > 0) { + draftAgentIds = requestedAgentIds + return + } + + const defaultAgentId = + availableAgents.find((agent) => agent.isDefault)?.id ?? + availableAgents[0]?.id ?? + null + draftAgentIds = defaultAgentId ? [defaultAgentId] : [] + } + function updateThreadListFromSnapshot( updater: (threads: Array) => Array, ) { @@ -658,9 +773,9 @@ export function ChatRoute(handle: Handle) { syncInFlight = true try { const locationThreadId = getSelectedThreadIdFromLocation() - const requestedAgentId = getAgentIdFromLocation() + const requestedAgentIds = getRequestedAgentIdsFromLocation() const threads = getThreads() - if (!locationThreadId && requestedAgentId) { + if (!locationThreadId && requestedAgentIds?.length) { activeClient?.close() activeClient = null activeThreadId = null @@ -767,6 +882,26 @@ export function ChatRoute(handle: Handle) { } } + async function loadAvailableAgents(signal?: AbortSignal) { + try { + const nextAvailableAgents = await fetchAvailableAgents(signal) + availableAgents = nextAvailableAgents + availableAgentsStatus = 'ready' + availableAgentsError = null + if (!getActiveThreadSummary()) { + syncDraftAgentIds() + } + update() + } catch (error) { + if (signal?.aborted) return + availableAgents = [] + availableAgentsStatus = 'error' + availableAgentsError = + error instanceof Error ? error.message : 'Unable to load agents.' + update() + } + } + handle.on(routerEvents, { navigate: () => { void handle.queueTask(async () => { @@ -775,9 +910,9 @@ export function ChatRoute(handle: Handle) { }, }) - async function createAndSelectThread() { + async function createAndSelectThread(input?: { agentIds?: Array }) { const thread = await createThread({ - agentId: getAgentIdFromLocation(), + agentIds: input?.agentIds ?? getSelectedAgentIds(), }) navigate(buildThreadHref(thread.id)) await refreshThreads() @@ -789,7 +924,7 @@ export function ChatRoute(handle: Handle) { actionError = null update() try { - await createAndSelectThread() + await createAndSelectThread({ agentIds: getSelectedAgentIds() }) await activeClient?.waitUntilConnected() } catch (error) { actionError = @@ -849,6 +984,55 @@ export function ChatRoute(handle: Handle) { } } + async function handleAgentSelectionChange(nextAgentIds: Array) { + const normalizedAgentIds = normalizeSelectedAgentIds(nextAgentIds) + const activeThread = getActiveThreadSummary() + if (!activeThread) { + draftAgentIds = normalizedAgentIds + update() + return + } + + const previousAgentIds = activeThread.agentIds + updateThreadListFromSnapshot((threads) => + threads.map((thread) => + thread.id === activeThread.id + ? { + ...thread, + agentId: normalizedAgentIds[0] ?? null, + agentIds: normalizedAgentIds, + } + : thread, + ), + ) + update() + + try { + const updatedThread = await updateThreadAgents(activeThread.id, normalizedAgentIds) + updateThreadListFromSnapshot((threads) => + threads.map((thread) => + thread.id === updatedThread.id ? updatedThread : thread, + ), + ) + update() + } catch (error) { + updateThreadListFromSnapshot((threads) => + threads.map((thread) => + thread.id === activeThread.id + ? { + ...thread, + agentId: previousAgentIds[0] ?? null, + agentIds: previousAgentIds, + } + : thread, + ), + ) + actionError = + error instanceof Error ? error.message : 'Unable to update chat agents.' + update() + } + } + async function handleSubmit(event: SubmitEvent) { event.preventDefault() actionError = null @@ -862,7 +1046,7 @@ export function ChatRoute(handle: Handle) { let client = activeClient if (!client) { - await createAndSelectThread() + await createAndSelectThread({ agentIds: getSelectedAgentIds() }) client = activeClient } @@ -888,15 +1072,26 @@ export function ChatRoute(handle: Handle) { if (threadStatus === 'loading') { handle.queueTask(refreshThreads) } + if (availableAgentsStatus === 'loading') { + handle.queueTask(loadAvailableAgents) + } const threads = getThreads() - const activeThread = activeThreadId - ? (threads.find((thread) => thread.id === activeThreadId) ?? null) - : null + const activeThread = getActiveThreadSummary() + const requestedAgentIds = getRequestedAgentIdsFromLocation() const showEmptyStateComposer = !activeThread && threadStatus !== 'error' && - (threads.length === 0 || Boolean(getAgentIdFromLocation())) + (threads.length === 0 || Boolean(requestedAgentIds?.length)) + const selectedAgentIds = getSelectedAgentIds() + const agentSelectionControl = agentMultiSelectCombobox({ + id: activeThread ? `thread-agents-${activeThread.id}` : 'draft-thread-agents', + agents: availableAgents, + selectedAgentIds, + error: availableAgentsError, + isLoading: availableAgentsStatus === 'loading', + onSelectionChange: handleAgentSelectionChange, + }) return (
+
+ {agentSelectionControl} +
- {message.role === 'user' ? 'You' : 'Assistant'} + {getAssistantSpeakerName(message)}
{renderMessageParts( @@ -1395,7 +1599,9 @@ export function ChatRoute(handle: Handle) { backgroundColor: colors.surface, }} > - Assistant + + Selected agents +

+

+ + Agents + + {agentSelectionControl} +
{ await page.goto('/chat') await expect(page).toHaveURL(/\/login/) @@ -74,3 +93,69 @@ test('responds to mock tool commands in chat', async ({ page, login }) => { .getByText('**Result**: `3`', { exact: false }), ).toBeVisible() }) + +test('supports selecting multiple agents before and during a chat', async ({ + page, + login, +}) => { + await login({ + email: 'me@kentcdodds.com', + password: 'password123', + }) + await createManagedAgent(page, { + name: 'alpha agent', + systemPrompt: 'You are alpha agent.', + }) + await createManagedAgent(page, { + name: 'beta agent', + systemPrompt: 'You are beta agent.', + }) + + await page.goto('/chat') + + const draftAgentButton = page.getByRole('button', { + name: '1 agents in this chat', + }) + await draftAgentButton.click() + const searchAgentsInput = page.getByRole('searchbox', { name: 'Search agents' }) + await searchAgentsInput.fill('alpha agent') + await page.keyboard.press('Enter') + await searchAgentsInput.fill('beta agent') + await page.keyboard.press('Enter') + await searchAgentsInput.fill('default agent') + await page.keyboard.press('Enter') + await page.keyboard.press('Escape') + await expect( + page.getByRole('button', { name: '2 agents in this chat' }), + ).toBeVisible() + + await page + .getByRole('textbox', { name: 'Message' }) + .fill('hello alpha agent and beta agent') + await page.getByRole('button', { name: 'Send message' }).click() + + await expect(page).toHaveURL(/\/chat\/.+/) + const messages = page.locator('#chat-messages-scroll-container') + await expect(messages.getByText('alpha agent', { exact: true })).toHaveCount(1) + await expect(messages.getByText('beta agent', { exact: true })).toHaveCount(1) + + const threadAgentButton = page.getByRole('button', { + name: '2 agents in this chat', + }) + await threadAgentButton.click() + const activeSearchAgentsInput = page.getByRole('searchbox', { + name: 'Search agents', + }) + await activeSearchAgentsInput.fill('alpha agent') + await page.keyboard.press('Enter') + await page.keyboard.press('Escape') + await expect( + page.getByRole('button', { name: '1 agents in this chat' }), + ).toBeVisible() + + await page.getByRole('textbox', { name: 'Message' }).fill('hello again') + await page.getByRole('button', { name: 'Send message' }).click() + + await expect(messages.getByText('alpha agent', { exact: true })).toHaveCount(1) + await expect(messages.getByText('beta agent', { exact: true })).toHaveCount(2) +}) diff --git a/migrations/0005-chat-thread-agent-ids.sql b/migrations/0005-chat-thread-agent-ids.sql new file mode 100644 index 0000000..b6c3779 --- /dev/null +++ b/migrations/0005-chat-thread-agent-ids.sql @@ -0,0 +1,8 @@ +ALTER TABLE chat_threads ADD COLUMN agent_ids_json TEXT NOT NULL DEFAULT '[]'; + +UPDATE chat_threads +SET agent_ids_json = CASE + WHEN agent_id IS NOT NULL AND trim(agent_id) != '' THEN json_array(agent_id) + ELSE '[]' +END +WHERE agent_ids_json = '[]'; diff --git a/server/agents.ts b/server/agents.ts index 2609f58..7a6fe30 100644 --- a/server/agents.ts +++ b/server/agents.ts @@ -2,6 +2,7 @@ import { agentsTable, createDb } from '#worker/db.ts' import { type ManagedChatAgent } from '#shared/chat.ts' export const defaultManagedChatAgentId = 'default-agent' +export const defaultManagedChatAgentName = 'Default agent' export const defaultManagedChatAgentSystemPrompt = [ 'You are a helpful assistant inside pea.', 'Use MCP tools when they provide a more reliable or interactive result than freeform text.', @@ -140,6 +141,42 @@ export function createAgentsStore(db: D1Database) { }> return rows.map(toManagedChatAgent) }, + async listAvailable() { + const result = await db + .prepare( + ` + SELECT + id, + name, + system_prompt, + model_preset, + custom_model, + is_active, + is_default, + created_at, + updated_at, + deleted_at + FROM agents + WHERE deleted_at IS NULL + AND is_active = 1 + ORDER BY is_default DESC, updated_at DESC, name COLLATE NOCASE ASC + `, + ) + .all() + const rows = result.results as Array<{ + id: string + name: string + system_prompt: string + model_preset: string + custom_model?: string | null + is_active: number + is_default: number + created_at: string + updated_at: string + deleted_at?: string | null + }> + return rows.map(toManagedChatAgent) + }, async getById(id: string) { const record = await getRecordById(id) return record ? toManagedChatAgent(record) : null diff --git a/server/chat-threads.ts b/server/chat-threads.ts index 221ee17..960ea50 100644 --- a/server/chat-threads.ts +++ b/server/chat-threads.ts @@ -15,9 +15,35 @@ function buildThreadTitleFallback(threadId: string) { return `Thread ${threadId.slice(0, 8)}` } +function parseThreadAgentIds(record: { + agent_id?: string | null + agent_ids_json?: string | null +}) { + const idsFromJson = (() => { + if (!record.agent_ids_json?.trim()) return [] + try { + const parsed = JSON.parse(record.agent_ids_json) as Array + if (!Array.isArray(parsed)) return [] + return parsed + .filter((value): value is string => typeof value === 'string') + .map((value) => value.trim()) + .filter(Boolean) + } catch { + return [] + } + })() + const fallbackAgentId = record.agent_id?.trim() ?? '' + const uniqueIds = new Set(idsFromJson) + if (fallbackAgentId) { + uniqueIds.add(fallbackAgentId) + } + return [...uniqueIds] +} + function toThreadSummary(record: { id: string agent_id?: string | null + agent_ids_json?: string | null title: string last_message_preview: string message_count: number @@ -26,9 +52,11 @@ function toThreadSummary(record: { deleted_at?: string | null }): ChatThreadSummary { const title = record.title.trim() + const agentIds = parseThreadAgentIds(record) return { id: record.id, - agentId: record.agent_id ?? null, + agentId: agentIds[0] ?? record.agent_id ?? null, + agentIds, title: title || buildThreadTitleFallback(record.id), lastMessagePreview: normalizePreview(record.last_message_preview) || null, messageCount: record.message_count, @@ -68,10 +96,37 @@ function normalizeCursor(cursor: string | null | undefined) { return parsedCursor } +function normalizeRequestedAgentIds(agentIds?: ReadonlyArray | null) { + const uniqueAgentIds = new Set() + for (const agentId of agentIds ?? []) { + const normalizedAgentId = agentId.trim() + if (!normalizedAgentId) continue + uniqueAgentIds.add(normalizedAgentId) + } + return [...uniqueAgentIds] +} + export function createChatThreadsStore(db: D1Database) { const database = createDb(db) const agentsStore = createAgentsStore(db) + async function resolveThreadAgentIds(agentIds?: ReadonlyArray | null) { + const requestedAgentIds = normalizeRequestedAgentIds(agentIds) + if (requestedAgentIds.length > 0) { + const availableAgents = await agentsStore.listAvailable() + const availableAgentIds = new Set(availableAgents.map((agent) => agent.id)) + const matchingAgentIds = requestedAgentIds.filter((agentId) => + availableAgentIds.has(agentId), + ) + if (matchingAgentIds.length > 0) { + return matchingAgentIds + } + } + + const fallbackAgent = await agentsStore.getDefault() + return [fallbackAgent?.id ?? defaultManagedChatAgentId] + } + return { async listForUser( userId: number, @@ -118,6 +173,7 @@ export function createChatThreadsStore(db: D1Database) { SELECT id, agent_id, + agent_ids_json, title, last_message_preview, message_count, @@ -138,6 +194,7 @@ export function createChatThreadsStore(db: D1Database) { result.results as Array<{ id: string agent_id?: string | null + agent_ids_json?: string | null title: string last_message_preview: string message_count: number @@ -156,14 +213,15 @@ export function createChatThreadsStore(db: D1Database) { totalCount, } }, - async createForUser(userId: number, agentId?: string | null) { - const fallbackAgent = await agentsStore.getDefault() + async createForUser(userId: number, agentIds?: ReadonlyArray | null) { + const resolvedAgentIds = await resolveThreadAgentIds(agentIds) const record = await database.create( chatThreadsTable, { id: crypto.randomUUID(), user_id: userId, - agent_id: agentId ?? fallbackAgent?.id ?? defaultManagedChatAgentId, + agent_id: resolvedAgentIds[0] ?? defaultManagedChatAgentId, + agent_ids_json: JSON.stringify(resolvedAgentIds), title: buildInitialTitle(), last_message_preview: '', message_count: 0, @@ -178,6 +236,28 @@ export function createChatThreadsStore(db: D1Database) { }) return record ? toThreadSummary(record) : null }, + async updateAgentsForUser( + userId: number, + threadId: string, + agentIds: ReadonlyArray, + ) { + const record = await database.findOne(chatThreadsTable, { + where: { id: threadId, user_id: userId, deleted_at: null }, + }) + if (!record) return null + + const resolvedAgentIds = await resolveThreadAgentIds(agentIds) + const updated = await database.update( + chatThreadsTable, + threadId, + { + agent_id: resolvedAgentIds[0] ?? defaultManagedChatAgentId, + agent_ids_json: JSON.stringify(resolvedAgentIds), + }, + { touch: true }, + ) + return toThreadSummary(updated) + }, async renameForUser(userId: number, threadId: string, title: string) { const record = await database.findOne(chatThreadsTable, { where: { id: threadId, user_id: userId, deleted_at: null }, diff --git a/server/handlers/chat-agents.ts b/server/handlers/chat-agents.ts new file mode 100644 index 0000000..cd5eca6 --- /dev/null +++ b/server/handlers/chat-agents.ts @@ -0,0 +1,39 @@ +import { type BuildAction } from 'remix/fetch-router' +import { readAuthenticatedAppUser } from '#server/authenticated-user.ts' +import { createAgentsStore } from '#server/agents.ts' +import { type routes } from '#server/routes.ts' +import { type AppEnv } from '#types/env-schema.ts' + +function jsonResponse(data: unknown, init?: ResponseInit) { + return new Response(JSON.stringify(data), { + ...init, + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + ...init?.headers, + }, + }) +} + +export function createChatAgentsHandler(appEnv: AppEnv) { + const store = createAgentsStore(appEnv.APP_DB) + + return { + middleware: [], + async action({ request }) { + const user = await readAuthenticatedAppUser(request, appEnv as Env) + if (!user) { + return jsonResponse( + { ok: false, error: 'Unauthorized' }, + { status: 401 }, + ) + } + + const agents = await store.listAvailable() + return jsonResponse({ ok: true, agents }) + }, + } satisfies BuildAction< + typeof routes.chatAgents.method, + typeof routes.chatAgents.pattern + > +} diff --git a/server/handlers/chat-threads.ts b/server/handlers/chat-threads.ts index 477c8c5..fa19924 100644 --- a/server/handlers/chat-threads.ts +++ b/server/handlers/chat-threads.ts @@ -57,23 +57,28 @@ export function createChatThreadsHandler(appEnv: AppEnv) { return jsonResponse({ ok: true, ...page }) } - let requestedAgentId: string | null = null + let requestedAgentIds: Array = [] const contentType = request.headers.get('Content-Type') ?? '' if (contentType.includes('application/json')) { const body = (await request.json().catch(() => null)) as - | { agentId?: unknown } + | { agentId?: unknown; agentIds?: unknown } | null - requestedAgentId = - typeof body?.agentId === 'string' ? body.agentId.trim() || null : null + if (Array.isArray(body?.agentIds)) { + requestedAgentIds = body.agentIds + .filter((value): value is string => typeof value === 'string') + .map((value) => value.trim()) + .filter(Boolean) + } else if (typeof body?.agentId === 'string') { + const requestedAgentId = body.agentId.trim() + requestedAgentIds = requestedAgentId ? [requestedAgentId] : [] + } } - - const agent = requestedAgentId - ? await agentsStore.getAvailableById(requestedAgentId) - : await agentsStore.getDefault() - const thread = await store.createForUser( - user.userId, - agent?.id ?? requestedAgentId, - ) + const filteredAgentIds = await Promise.all( + requestedAgentIds.map(async (agentId) => + (await agentsStore.getAvailableById(agentId))?.id ?? null, + ), + ).then((ids) => ids.filter((agentId): agentId is string => Boolean(agentId))) + const thread = await store.createForUser(user.userId, filteredAgentIds) return jsonResponse({ ok: true, thread }, { status: 201 }) }, } satisfies BuildAction< @@ -199,3 +204,65 @@ export function createUpdateChatThreadHandler(appEnv: AppEnv) { typeof routes.chatThreadsUpdate.pattern > } + +export function createUpdateChatThreadAgentsHandler(appEnv: AppEnv) { + const store = createChatThreadsStore(appEnv.APP_DB) + + return { + middleware: [], + async action({ request }) { + const user = await readAuthenticatedAppUser(request, appEnv as Env) + if (!user) { + return jsonResponse( + { ok: false, error: 'Unauthorized' }, + { status: 401 }, + ) + } + + let body: unknown + try { + body = await request.json() + } catch { + return jsonResponse( + { ok: false, error: 'Invalid JSON payload.' }, + { status: 400 }, + ) + } + + const threadId = + body && + typeof body === 'object' && + typeof (body as { threadId?: unknown }).threadId === 'string' + ? (body as { threadId: string }).threadId.trim() + : '' + const agentIds = + body && + typeof body === 'object' && + Array.isArray((body as { agentIds?: unknown }).agentIds) + ? (body as { agentIds: Array }).agentIds + .filter((value): value is string => typeof value === 'string') + .map((value) => value.trim()) + .filter(Boolean) + : [] + if (!threadId) { + return jsonResponse( + { ok: false, error: 'Thread ID is required.' }, + { status: 400 }, + ) + } + + const thread = await store.updateAgentsForUser(user.userId, threadId, agentIds) + if (!thread) { + return jsonResponse( + { ok: false, error: 'Thread not found.' }, + { status: 404 }, + ) + } + + return jsonResponse({ ok: true, thread }) + }, + } satisfies BuildAction< + typeof routes.chatThreadsAgentsUpdate.method, + typeof routes.chatThreadsAgentsUpdate.pattern + > +} diff --git a/server/router.ts b/server/router.ts index 7481afa..5085ef7 100644 --- a/server/router.ts +++ b/server/router.ts @@ -9,9 +9,11 @@ import { createUpdateAdminAgentHandler, } from './handlers/admin-agents.ts' import { createAuthHandler } from './handlers/auth.ts' +import { createChatAgentsHandler } from './handlers/chat-agents.ts' import { chat } from './handlers/chat.ts' import { createChatThreadsHandler, + createUpdateChatThreadAgentsHandler, createDeleteChatThreadHandler, createUpdateChatThreadHandler, } from './handlers/chat-threads.ts' @@ -38,10 +40,12 @@ export function createAppRouter(appEnv: AppEnv) { }) const chatThreadsHandler = createChatThreadsHandler(appEnv) const adminAgentsHandler = createAdminAgentsHandler(appEnv) + const chatAgentsHandler = createChatAgentsHandler(appEnv) router.map(routes.home, home) router.map(routes.chat, chat) router.map(routes.chatThread, chat) + router.map(routes.chatAgents, chatAgentsHandler) router.map(routes.adminAgents, adminAgentsPage) router.map(routes.adminAgentsData, adminAgentsHandler) router.map(routes.adminAgentsCreate, adminAgentsHandler) @@ -51,6 +55,10 @@ export function createAppRouter(appEnv: AppEnv) { router.map(routes.chatThreads, chatThreadsHandler) router.map(routes.chatThreadsCreate, chatThreadsHandler) router.map(routes.chatThreadsUpdate, createUpdateChatThreadHandler(appEnv)) + router.map( + routes.chatThreadsAgentsUpdate, + createUpdateChatThreadAgentsHandler(appEnv), + ) router.map(routes.chatThreadsDelete, createDeleteChatThreadHandler(appEnv)) router.map(routes.health, createHealthHandler(appEnv)) router.map(routes.login, login) diff --git a/server/routes.ts b/server/routes.ts index e61c40f..e99330f 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -4,6 +4,7 @@ export const routes = route({ home: '/', chat: '/chat', chatThread: '/chat/:threadId', + chatAgents: '/chat-agents', adminAgents: '/admin/agents', adminAgentsData: '/admin-agents', chatThreads: '/chat-threads', @@ -13,6 +14,7 @@ export const routes = route({ adminAgentsDefault: post('/admin-agents/default'), chatThreadsCreate: post('/chat-threads'), chatThreadsUpdate: post('/chat-threads/update'), + chatThreadsAgentsUpdate: post('/chat-threads/agents'), chatThreadsDelete: post('/chat-threads/delete'), health: '/health', login: '/login', diff --git a/shared/chat.ts b/shared/chat.ts index 95b32f2..1fc24a3 100644 --- a/shared/chat.ts +++ b/shared/chat.ts @@ -144,6 +144,7 @@ export const chatThreadRecordSchema = object({ id: string(), user_id: number(), agent_id: optional(nullable(string())), + agent_ids_json: optional(string()), title: string(), last_message_preview: string(), message_count: number(), @@ -157,6 +158,7 @@ export type ChatThreadRecord = InferOutput export type ChatThreadSummary = { id: string agentId: string | null + agentIds: Array title: string lastMessagePreview: string | null messageCount: number @@ -188,6 +190,11 @@ export type ChatThreadUpdateResponse = { thread: ChatThreadSummary } +export type ChatThreadAgentsUpdateResponse = { + ok: true + thread: ChatThreadSummary +} + export const managedChatAgentRecordSchema = object({ id: string(), name: string(), @@ -224,7 +231,17 @@ export type ManagedChatAgentListResponse = { environmentDefaultModel: string } +export type AvailableChatAgentListResponse = { + ok: true + agents: Array +} + export type ManagedChatAgentMutationResponse = { ok: true agent: ManagedChatAgent } + +export type ChatAssistantMessageMetadata = { + agentId: string | null + agentName: string | null +} diff --git a/worker/ai-runtime.ts b/worker/ai-runtime.ts index 05ff277..6a384c7 100644 --- a/worker/ai-runtime.ts +++ b/worker/ai-runtime.ts @@ -1,7 +1,6 @@ import { convertToModelMessages, - streamText, - type StreamTextOnFinishCallback, + generateText, type ToolSet, type UIMessage, } from 'ai' @@ -17,15 +16,12 @@ export type StreamChatReplyInput = { tools: ToolSet toolNames: Array abortSignal?: AbortSignal - onFinish?: StreamTextOnFinishCallback } -export type AiRuntimeResult = - | { kind: 'response'; response: Response } - | MockAiResponse +export type AiRuntimeResult = MockAiResponse export type AiRuntime = { - streamChatReply(input: StreamChatReplyInput): Promise + generateChatReply(input: StreamChatReplyInput): Promise } type AIEnabledEnv = Env & { @@ -81,22 +77,30 @@ function createWorkersAiProvider(env: WorkersAiCredentialsEnv) { function createRemoteAiRuntime(env: WorkersAiCredentialsEnv): AiRuntime { return { - async streamChatReply(input) { - const workersai = createWorkersAiProvider(env) - const model = input.model?.trim() || env.AI_MODEL || fallbackRemoteChatModel - - const result = streamText({ - model: workersai(model), - system: input.system, - messages: await convertToModelMessages(input.messages), - tools: input.tools, - abortSignal: input.abortSignal, - onFinish: input.onFinish, - }) - - return { - kind: 'response', - response: result.toUIMessageStreamResponse(), + async generateChatReply(input) { + try { + const workersai = createWorkersAiProvider(env) + const model = input.model?.trim() || env.AI_MODEL || fallbackRemoteChatModel + const result = await generateText({ + model: workersai(model), + system: input.system, + messages: await convertToModelMessages(input.messages), + tools: input.tools, + abortSignal: input.abortSignal, + maxSteps: 5, + }) + return { + kind: 'text', + text: result.text, + } + } catch (error) { + return { + kind: 'error', + message: + error instanceof Error + ? error.message + : 'Remote AI generation failed.', + } } }, } @@ -106,7 +110,7 @@ function createMockAiRuntime(env: Env): AiRuntime { const baseUrl = env.AI_MOCK_BASE_URL?.trim() return { - async streamChatReply(input) { + async generateChatReply(input) { if (!baseUrl) { const userMessages = input.messages.filter( (message) => message.role === 'user', diff --git a/worker/chat-agent.ts b/worker/chat-agent.ts index 53914bd..7b2748b 100644 --- a/worker/chat-agent.ts +++ b/worker/chat-agent.ts @@ -1,18 +1,20 @@ import { AIChatAgent } from '@cloudflare/ai-chat' import { - type StreamTextOnFinishCallback, type ToolSet, type UIMessage, + type StreamTextOnFinishCallback, } from 'ai' import { type Connection, type ConnectionContext } from 'agents' import { createMcpCallerContext } from '#mcp/context.ts' import { + type ChatAssistantMessageMetadata, envDefaultModelPreset, type ManagedChatAgent, } from '#shared/chat.ts' import { createAgentsStore, defaultManagedChatAgentId, + defaultManagedChatAgentName, defaultManagedChatAgentSystemPrompt, } from '#server/agents.ts' import { readAuthenticatedAppUser } from '#server/authenticated-user.ts' @@ -27,9 +29,28 @@ function resolveAgentModel(agent: ManagedChatAgent) { return null } +type ThreadAgentConfig = { + id: string + name: string + systemPrompt: string + model: string | null +} + +const noResponseToken = '[[NO_RESPONSE]]' + +function createManagedAgentConfig(agent: ManagedChatAgent): ThreadAgentConfig { + return { + id: agent.id, + name: agent.name, + systemPrompt: agent.systemPrompt, + model: resolveAgentModel(agent), + } +} + function createFallbackAgentConfig() { return { id: defaultManagedChatAgentId, + name: defaultManagedChatAgentName, systemPrompt: defaultManagedChatAgentSystemPrompt, model: null, } @@ -53,6 +74,79 @@ function getLatestUserMessageText(messages: Array) { return getTextParts(latestMessage).join('\n').trim() } +function getAssistantMessageMetadata(message: UIMessage) { + if (!message.metadata || typeof message.metadata !== 'object') { + return null + } + const metadata = message.metadata as { + agentId?: unknown + agentName?: unknown + } + return { + agentId: + typeof metadata.agentId === 'string' ? metadata.agentId.trim() || null : null, + agentName: + typeof metadata.agentName === 'string' + ? metadata.agentName.trim() || null + : null, + } satisfies ChatAssistantMessageMetadata +} + +function buildToolPartSummary( + part: { + type: string + state?: unknown + input?: unknown + output?: unknown + errorText?: unknown + }, +) { + if (!part.type.startsWith('tool-')) return '' + const lines = [part.type.replace(/^tool-/, '')] + if ('state' in part && typeof part.state === 'string') { + lines.push(`state: ${part.state}`) + } + if ('input' in part && part.input !== undefined) { + lines.push(`input: ${JSON.stringify(part.input)}`) + } + if ('output' in part && part.output !== undefined) { + lines.push(`output: ${JSON.stringify(part.output)}`) + } + if ('errorText' in part && typeof part.errorText === 'string') { + lines.push(`error: ${part.errorText}`) + } + return lines.join('\n') +} + +function buildMessageTextForAgentContext(message: UIMessage) { + const textParts = getTextParts(message) + const otherParts = message.parts + .map((part) => buildToolPartSummary(part as { type: string })) + .filter(Boolean) + const combinedText = [...textParts, ...otherParts].join('\n').trim() + if (message.role !== 'assistant') { + return combinedText + } + const metadata = getAssistantMessageMetadata(message) + const speakerName = metadata?.agentName ?? 'Assistant' + if (!combinedText) return `${speakerName}:` + return `${speakerName}: ${combinedText}` +} + +function buildConversationMessagesForAgent(messages: Array) { + return messages + .map((message) => { + const text = buildMessageTextForAgentContext(message) + if (!text) return null + return { + id: message.id, + role: message.role, + parts: [{ type: 'text', text }], + } satisfies UIMessage + }) + .filter((message): message is UIMessage => Boolean(message)) +} + function buildThreadTitle(messages: Array) { const firstUserMessage = messages.find((message) => message.role === 'user') if (!firstUserMessage) return '' @@ -67,13 +161,8 @@ function buildLastPreview(input: { userText: string; assistantText?: string }) { return input.userText.trim().slice(0, 160) } -function createTextResponse(text: string, chunks?: Array) { - const body = chunks && chunks.length > 0 ? chunks.join('') : text - return new Response(body, { - headers: { - 'Content-Type': 'text/plain; charset=utf-8', - }, - }) +function createEmptyResponse() { + return new Response(null, { status: 204 }) } function createErrorResponse(message: string) { @@ -188,16 +277,56 @@ function createKnownMockToolResult( return null } +function buildManagedAgentSystemPrompt(input: { + agent: ThreadAgentConfig + selectedAgents: Array +}) { + const otherSelectedAgents = input.selectedAgents + .filter((agent) => agent.id !== input.agent.id) + .map((agent) => agent.name) + const instructions = [ + input.agent.systemPrompt.trim(), + '', + `You are ${input.agent.name}, one participant in a shared chat with the user.`, + 'Only respond when the latest message is from the user and you can add distinct, useful value.', + 'Do not respond to messages from other agents. Do not critique or answer another agent unless the user explicitly asks you to.', + `If you should stay silent, reply with exactly ${noResponseToken} and nothing else.`, + ] + if (otherSelectedAgents.length > 0) { + instructions.push( + `Other selected agents in this chat: ${otherSelectedAgents.join(', ')}.`, + ) + } + return instructions.join('\n') +} + +function buildAssistantMessage( + agent: ThreadAgentConfig, + text: string, +): UIMessage { + return { + id: `assistant_${crypto.randomUUID()}`, + role: 'assistant', + metadata: { + agentId: agent.id, + agentName: agent.name, + }, + parts: [{ type: 'text', text }], + } +} + +function normalizeAgentMatchValue(value: string) { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim() +} + export class ChatAgent extends AIChatAgent { waitForMcpConnections = true private runtimeContext: { appUserId: number baseUrl: string - agent: { - id: string - systemPrompt: string - model: string | null - } user: ReturnType['user'] } | null = null @@ -257,6 +386,96 @@ export class ChatAgent extends AIChatAgent { return createAgentsStore(this.env.APP_DB) } + private async getSelectedAgentsForCurrentThread() { + const { appUserId } = this.getRuntimeContext() + const thread = await this.getThreadStore().getForUser(appUserId, this.name) + if (!thread) { + throw new Error('Thread not found.') + } + const availableAgents = await this.getAgentsStore().listAvailable() + const availableAgentsById = new Map( + availableAgents.map((agent) => [agent.id, agent] as const), + ) + const selectedAgents = thread.agentIds + .map((agentId) => availableAgentsById.get(agentId)) + .filter((agent): agent is ManagedChatAgent => Boolean(agent)) + .map(createManagedAgentConfig) + if (selectedAgents.length > 0) { + return selectedAgents + } + + const fallbackAgent = await this.getAgentsStore().getDefault() + return [fallbackAgent ? createManagedAgentConfig(fallbackAgent) : createFallbackAgentConfig()] + } + + private shouldMockAgentRespond(input: { + agent: ThreadAgentConfig + selectedAgents: Array + latestUserText: string + }) { + const normalizedUserText = normalizeAgentMatchValue(input.latestUserText) + if (!normalizedUserText) return true + const mentionedAgents = input.selectedAgents.filter((agent) => { + const normalizedName = normalizeAgentMatchValue(agent.name) + const normalizedId = normalizeAgentMatchValue(agent.id) + return ( + (normalizedName && normalizedUserText.includes(normalizedName)) || + (normalizedId && normalizedUserText.includes(normalizedId)) + ) + }) + if (mentionedAgents.length === 0) return true + return mentionedAgents.some((agent) => agent.id === input.agent.id) + } + + private async resolveMockToolCallAssistantText( + result: Extract, + ) { + const knownMockToolResult = createKnownMockToolResult(result) + if (knownMockToolResult) { + return knownMockToolResult.assistantText + } + + const tool = this.mcp + .listTools() + .find((entry) => entry.name === result.toolName) + if (!tool) { + return `Mock tool "${result.toolName}" is not available.` + } + + let toolResult: Awaited> + try { + toolResult = await this.mcp.callTool({ + serverId: tool.serverId, + name: result.toolName, + arguments: result.input, + }) + } catch (error) { + const message = + error instanceof Error ? error.message : 'Unknown mock tool error.' + return `Mock tool "${result.toolName}" failed to execute: ${message}` + } + const output = + 'structuredContent' in toolResult && toolResult.structuredContent + ? toolResult.structuredContent + : toolResult.content + const toolContents = Array.isArray(toolResult.content) + ? (toolResult.content as Array<{ type: string; text?: string }>) + : [] + + const assistantText = + result.text?.trim() || + toolContents + .filter((part) => part.type === 'text') + .map((part) => part.text) + .join('\n') + .trim() + + return ( + assistantText || + (typeof output === 'string' ? output : JSON.stringify(output, null, 2)) + ) + } + getMessagePage(input?: { before?: number | null limit?: number @@ -333,22 +552,10 @@ export class ChatAgent extends AIChatAgent { if (!thread) { throw new Error('Thread not found for authenticated user.') } - const configuredAgent = thread.agentId - ? await this.getAgentsStore().getById(thread.agentId) - : null - const fallbackAgent = configuredAgent ?? (await this.getAgentsStore().getDefault()) - const agentConfig = fallbackAgent - ? { - id: fallbackAgent.id, - systemPrompt: fallbackAgent.systemPrompt, - model: resolveAgentModel(fallbackAgent), - } - : createFallbackAgentConfig() const baseUrl = new URL(request.url).origin this.runtimeContext = { appUserId: user.userId, baseUrl, - agent: agentConfig, user: user.mcpUser, } this.persistRuntimeContext() @@ -387,115 +594,75 @@ export class ChatAgent extends AIChatAgent { return new Response('Not implemented', { status: 404 }) } - private async createMockToolCallResponse( - result: Extract, - ) { - const knownMockToolResult = createKnownMockToolResult(result) - if (knownMockToolResult) { - await this.syncThreadMetadata({ - assistantText: knownMockToolResult.assistantText, - messageCountOffset: 1, - }) - return createTextResponse(knownMockToolResult.assistantText) - } - - const tool = this.mcp - .listTools() - .find((entry) => entry.name === result.toolName) - if (!tool) { - return createErrorResponse( - `Mock tool "${result.toolName}" is not available.`, - ) - } - - let toolResult: Awaited> - try { - toolResult = await this.mcp.callTool({ - serverId: tool.serverId, - name: result.toolName, - arguments: result.input, - }) - } catch (error) { - const message = - error instanceof Error ? error.message : 'Unknown mock tool error.' - return createErrorResponse( - `Mock tool "${result.toolName}" failed to execute: ${message}`, - ) - } - const output = - 'structuredContent' in toolResult && toolResult.structuredContent - ? toolResult.structuredContent - : toolResult.content - const toolContents = Array.isArray(toolResult.content) - ? (toolResult.content as Array<{ type: string; text?: string }>) - : [] - - const assistantText = - result.text?.trim() || - toolContents - .filter((part) => part.type === 'text') - .map((part) => part.text) - .join('\n') - .trim() - - await this.syncThreadMetadata({ - assistantText, - messageCountOffset: 1, - }) - - const fallbackText = - assistantText || - (typeof output === 'string' ? output : JSON.stringify(output, null, 2)) - return createTextResponse(fallbackText) - } - async onChatMessage( onFinish: StreamTextOnFinishCallback, options?: { abortSignal?: AbortSignal }, ): Promise { + void onFinish const aiRuntime = createAiRuntime(this.env) const tools = this.mcp.getAITools() const toolNames = this.mcp.listTools().map((tool) => tool.name) - const { agent } = this.getRuntimeContext() - const wrappedOnFinish: StreamTextOnFinishCallback = async ( - event, - ) => { - await onFinish(event) - await this.syncThreadMetadata({ - assistantText: event.text, - messageCountOffset: 1, - }) - } + const selectedAgents = await this.getSelectedAgentsForCurrentThread() + const latestUserText = getLatestUserMessageText(this.messages) + const conversationMessages = buildConversationMessagesForAgent(this.messages) + const assistantMessages: Array> = [] + const generationErrors: Array = [] + + for (const agent of selectedAgents) { + if ( + this.env.AI_MODE !== 'remote' && + !this.shouldMockAgentRespond({ + agent, + selectedAgents, + latestUserText, + }) + ) { + continue + } - await this.syncThreadMetadata({}) + const runtimeResult = await aiRuntime.generateChatReply({ + messages: conversationMessages, + system: buildManagedAgentSystemPrompt({ + agent, + selectedAgents, + }), + model: agent.model, + tools, + toolNames, + abortSignal: options?.abortSignal, + }) - const runtimeResult = await aiRuntime.streamChatReply({ - messages: this.messages, - system: agent.systemPrompt, - model: agent.model, - tools, - toolNames, - abortSignal: options?.abortSignal, - onFinish: wrappedOnFinish, - }) + if (runtimeResult.kind === 'error') { + generationErrors.push(runtimeResult.message) + continue + } - if (runtimeResult.kind === 'response') { - return runtimeResult.response - } + const assistantText = + runtimeResult.kind === 'tool-call' + ? await this.resolveMockToolCallAssistantText(runtimeResult) + : runtimeResult.text.trim() + if (!assistantText || assistantText === noResponseToken) { + continue + } - if (runtimeResult.kind === 'error') { - return createErrorResponse(runtimeResult.message) + assistantMessages.push(buildAssistantMessage(agent, assistantText)) } - if (runtimeResult.kind === 'tool-call') { - return this.createMockToolCallResponse(runtimeResult) + if (assistantMessages.length > 0) { + await this.saveMessages([...this.messages, ...assistantMessages]) } - await this.syncThreadMetadata({ - assistantText: runtimeResult.text, - messageCountOffset: 1, + assistantText: assistantMessages.at(-1) + ? getTextParts(assistantMessages.at(-1)!).join('\n').trim() + : undefined, }) - return createTextResponse(runtimeResult.text, runtimeResult.chunks) + if (assistantMessages.length > 0) { + return createEmptyResponse() + } + if (generationErrors.length > 0) { + return createErrorResponse(generationErrors[0] ?? 'Chat generation failed.') + } + return createEmptyResponse() } async clearThread() { diff --git a/worker/db.ts b/worker/db.ts index cb1288c..89c4a91 100644 --- a/worker/db.ts +++ b/worker/db.ts @@ -48,6 +48,7 @@ export const chatThreadsTable = createTable({ id: string(), user_id: number(), agent_id: optional(nullable(string())), + agent_ids_json: string(), title: string(), last_message_preview: string(), message_count: number(), From c2fd42321078e5f1c1b73f79578efdb87faa8808 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 17 Mar 2026 01:35:42 +0000 Subject: [PATCH 02/10] Fix multi-agent type issues Co-authored-by: Kent C. Dodds --- worker/ai-runtime.ts | 1 - worker/chat-agent.ts | 20 ++++++++++---------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/worker/ai-runtime.ts b/worker/ai-runtime.ts index 6a384c7..933ef94 100644 --- a/worker/ai-runtime.ts +++ b/worker/ai-runtime.ts @@ -87,7 +87,6 @@ function createRemoteAiRuntime(env: WorkersAiCredentialsEnv): AiRuntime { messages: await convertToModelMessages(input.messages), tools: input.tools, abortSignal: input.abortSignal, - maxSteps: 5, }) return { kind: 'text', diff --git a/worker/chat-agent.ts b/worker/chat-agent.ts index 7b2748b..80e072f 100644 --- a/worker/chat-agent.ts +++ b/worker/chat-agent.ts @@ -134,17 +134,17 @@ function buildMessageTextForAgentContext(message: UIMessage) { } function buildConversationMessagesForAgent(messages: Array) { - return messages - .map((message) => { - const text = buildMessageTextForAgentContext(message) - if (!text) return null - return { - id: message.id, - role: message.role, - parts: [{ type: 'text', text }], - } satisfies UIMessage + const conversationMessages: Array = [] + for (const message of messages) { + const text = buildMessageTextForAgentContext(message) + if (!text) continue + conversationMessages.push({ + id: message.id, + role: message.role, + parts: [{ type: 'text', text }], }) - .filter((message): message is UIMessage => Boolean(message)) + } + return conversationMessages } function buildThreadTitle(messages: Array) { From 6b3e1f580f7f57615bdab1c9f50a769526ba424c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 17 Mar 2026 01:59:56 +0000 Subject: [PATCH 03/10] Use chat stream completion responses Co-authored-by: Kent C. Dodds --- worker/chat-agent.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/worker/chat-agent.ts b/worker/chat-agent.ts index 80e072f..92194f2 100644 --- a/worker/chat-agent.ts +++ b/worker/chat-agent.ts @@ -1,5 +1,7 @@ import { AIChatAgent } from '@cloudflare/ai-chat' import { + createUIMessageStream, + createUIMessageStreamResponse, type ToolSet, type UIMessage, type StreamTextOnFinishCallback, @@ -162,7 +164,11 @@ function buildLastPreview(input: { userText: string; assistantText?: string }) { } function createEmptyResponse() { - return new Response(null, { status: 204 }) + return createUIMessageStreamResponse({ + stream: createUIMessageStream({ + execute() {}, + }), + }) } function createErrorResponse(message: string) { From f00da27f492d4e12f1a60d32a41fef54d63afd11 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 17 Mar 2026 02:02:13 +0000 Subject: [PATCH 04/10] Stream combined multi-agent replies Co-authored-by: Kent C. Dodds --- e2e/chat.spec.ts | 8 +++--- worker/chat-agent.ts | 58 ++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/e2e/chat.spec.ts b/e2e/chat.spec.ts index 1d3d4ae..525ed75 100644 --- a/e2e/chat.spec.ts +++ b/e2e/chat.spec.ts @@ -136,8 +136,8 @@ test('supports selecting multiple agents before and during a chat', async ({ await expect(page).toHaveURL(/\/chat\/.+/) const messages = page.locator('#chat-messages-scroll-container') - await expect(messages.getByText('alpha agent', { exact: true })).toHaveCount(1) - await expect(messages.getByText('beta agent', { exact: true })).toHaveCount(1) + await expect(messages.getByText('alpha agent', { exact: false })).toHaveCount(1) + await expect(messages.getByText('beta agent', { exact: false })).toHaveCount(1) const threadAgentButton = page.getByRole('button', { name: '2 agents in this chat', @@ -156,6 +156,6 @@ test('supports selecting multiple agents before and during a chat', async ({ await page.getByRole('textbox', { name: 'Message' }).fill('hello again') await page.getByRole('button', { name: 'Send message' }).click() - await expect(messages.getByText('alpha agent', { exact: true })).toHaveCount(1) - await expect(messages.getByText('beta agent', { exact: true })).toHaveCount(2) + await expect(messages.getByText('alpha agent', { exact: false })).toHaveCount(1) + await expect(messages.getByText('beta agent', { exact: false })).toHaveCount(2) }) diff --git a/worker/chat-agent.ts b/worker/chat-agent.ts index 92194f2..e4b74bf 100644 --- a/worker/chat-agent.ts +++ b/worker/chat-agent.ts @@ -171,6 +171,41 @@ function createEmptyResponse() { }) } +function createAssistantTextResponse(text: string) { + return createUIMessageStreamResponse({ + stream: createUIMessageStream>({ + execute({ writer }) { + const textPartId = crypto.randomUUID() + const metadata = { + agentId: null, + agentName: 'Selected agents', + } satisfies ChatAssistantMessageMetadata + writer.write({ + type: 'start', + messageMetadata: metadata, + }) + writer.write({ + type: 'text-start', + id: textPartId, + }) + writer.write({ + type: 'text-delta', + id: textPartId, + delta: text, + }) + writer.write({ + type: 'text-end', + id: textPartId, + }) + writer.write({ + type: 'finish', + messageMetadata: metadata, + }) + }, + }), + }) +} + function createErrorResponse(message: string) { return new Response(message, { status: 500, @@ -328,6 +363,20 @@ function normalizeAgentMatchValue(value: string) { .trim() } +function buildCombinedAssistantText( + messages: Array>, +) { + return messages + .map((message) => { + const metadata = getAssistantMessageMetadata(message) + const agentName = metadata?.agentName ?? 'Assistant' + const text = getTextParts(message).join('\n').trim() + return text ? `${agentName}:\n${text}` : agentName + }) + .join('\n\n') + .trim() +} + export class ChatAgent extends AIChatAgent { waitForMcpConnections = true private runtimeContext: { @@ -654,16 +703,15 @@ export class ChatAgent extends AIChatAgent { assistantMessages.push(buildAssistantMessage(agent, assistantText)) } - if (assistantMessages.length > 0) { - await this.saveMessages([...this.messages, ...assistantMessages]) - } + const combinedAssistantText = buildCombinedAssistantText(assistantMessages) await this.syncThreadMetadata({ assistantText: assistantMessages.at(-1) ? getTextParts(assistantMessages.at(-1)!).join('\n').trim() : undefined, + messageCountOffset: combinedAssistantText ? 1 : 0, }) - if (assistantMessages.length > 0) { - return createEmptyResponse() + if (combinedAssistantText) { + return createAssistantTextResponse(combinedAssistantText) } if (generationErrors.length > 0) { return createErrorResponse(generationErrors[0] ?? 'Chat generation failed.') From 25f1879eab9a489a8cf727b726625ef76a038482 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 17 Mar 2026 02:02:58 +0000 Subject: [PATCH 05/10] Tighten multi-agent chat assertions Co-authored-by: Kent C. Dodds --- e2e/chat.spec.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/e2e/chat.spec.ts b/e2e/chat.spec.ts index 525ed75..190571f 100644 --- a/e2e/chat.spec.ts +++ b/e2e/chat.spec.ts @@ -136,8 +136,9 @@ test('supports selecting multiple agents before and during a chat', async ({ await expect(page).toHaveURL(/\/chat\/.+/) const messages = page.locator('#chat-messages-scroll-container') - await expect(messages.getByText('alpha agent', { exact: false })).toHaveCount(1) - await expect(messages.getByText('beta agent', { exact: false })).toHaveCount(1) + const firstAssistantMessage = messages.locator('article').nth(1) + await expect(firstAssistantMessage).toContainText('alpha agent:') + await expect(firstAssistantMessage).toContainText('beta agent:') const threadAgentButton = page.getByRole('button', { name: '2 agents in this chat', @@ -156,6 +157,8 @@ test('supports selecting multiple agents before and during a chat', async ({ await page.getByRole('textbox', { name: 'Message' }).fill('hello again') await page.getByRole('button', { name: 'Send message' }).click() - await expect(messages.getByText('alpha agent', { exact: false })).toHaveCount(1) - await expect(messages.getByText('beta agent', { exact: false })).toHaveCount(2) + await expect(messages.locator('article')).toHaveCount(4) + const secondAssistantMessage = messages.locator('article').nth(3) + await expect(secondAssistantMessage).not.toContainText('alpha agent:') + await expect(secondAssistantMessage).toContainText('beta agent:') }) From 6f7218fa65d354c95a0b2bb16498288b025f5239 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 17 Mar 2026 02:10:07 +0000 Subject: [PATCH 06/10] Allow chat agent menu to overflow Co-authored-by: Kent C. Dodds --- client/routes/chat.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/routes/chat.tsx b/client/routes/chat.tsx index b48ab08..464e29d 100644 --- a/client/routes/chat.tsx +++ b/client/routes/chat.tsx @@ -1430,7 +1430,7 @@ export function ChatRoute(handle: Handle) { backgroundColor: colors.surface, boxShadow: shadows.sm, height: CHAT_PANEL_HEIGHT, - overflow: 'hidden', + overflow: 'visible', }} > {activeThread ? ( From 4e4a7cd1691d3d98cc8ad1a9ad99cbf6e638d821 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 17 Mar 2026 20:59:29 +0000 Subject: [PATCH 07/10] Use popover API for agent combobox Co-authored-by: Kent C. Dodds --- client/agent-multi-select-combobox.tsx | 383 ++++++++++++------------- 1 file changed, 191 insertions(+), 192 deletions(-) diff --git a/client/agent-multi-select-combobox.tsx b/client/agent-multi-select-combobox.tsx index 031c4a6..fd0b9ed 100644 --- a/client/agent-multi-select-combobox.tsx +++ b/client/agent-multi-select-combobox.tsx @@ -74,10 +74,29 @@ export function AgentMultiSelectCombobox(handle: Handle) { }) } + function getPopover(popoverId: string) { + const popover = document.getElementById(popoverId) + return popover instanceof HTMLElement ? popover : null + } + + function openPopover(popoverId: string) { + const popover = getPopover(popoverId) + if (!popover || popover.matches(':popover-open')) return + popover.showPopover() + } + + function closePopover(popoverId: string) { + const popover = getPopover(popoverId) + if (!popover || !popover.matches(':popover-open')) return + popover.hidePopover() + } + return (props: AgentMultiSelectComboboxProps) => { const buttonId = `${props.id}-button` const inputId = `${props.id}-search` const listboxId = `${props.id}-listbox` + const popoverId = `${props.id}-popover` + const anchorName = `--${props.id}-anchor` const selectedAgentCount = props.selectedAgentIds.length const normalizedSearch = normalizeSearchValue(search) const filteredAgents = props.agents.filter((agent) => { @@ -97,43 +116,20 @@ export function AgentMultiSelectCombobox(handle: Handle) { ? highlightedAgentId : filteredAgents[0]?.id ?? null - function openCombobox() { - if (props.disabled) return - isOpen = true - if ( - !filteredAgents.some((agent) => agent.id === highlightedAgentId) && - filteredAgents[0] - ) { - highlightedAgentId = filteredAgents[0].id + function handlePopoverToggle(event: Event) { + if (!(event.currentTarget instanceof HTMLElement)) return + isOpen = event.currentTarget.matches(':popover-open') + if (isOpen) { + highlightedAgentId = filteredAgents[0]?.id ?? null + update() + focusInput(inputId) + return } - update() - focusInput(inputId) - } - - function closeCombobox() { - if (!isOpen) return - isOpen = false search = '' highlightedAgentId = null update() } - function toggleCombobox() { - if (isOpen) { - closeCombobox() - return - } - openCombobox() - } - - function handleWrapperFocusOut(event: FocusEvent) { - if (!(event.currentTarget instanceof HTMLDivElement)) return - const nextTarget = - event.relatedTarget instanceof Node ? event.relatedTarget : null - if (nextTarget && event.currentTarget.contains(nextTarget)) return - closeCombobox() - } - function handleButtonKeyDown(event: KeyboardEvent) { if (props.disabled) return if ( @@ -145,7 +141,7 @@ export function AgentMultiSelectCombobox(handle: Handle) { return } event.preventDefault() - openCombobox() + openPopover(popoverId) } function handleSearchInput(event: Event) { @@ -183,7 +179,7 @@ export function AgentMultiSelectCombobox(handle: Handle) { function handleSearchKeyDown(event: KeyboardEvent) { if (event.key === 'Escape') { event.preventDefault() - closeCombobox() + closePopover(popoverId) focusButton(buttonId) return } @@ -206,9 +202,7 @@ export function AgentMultiSelectCombobox(handle: Handle) { return (
{selectedAgentCount} agents in this chat - {isOpen ? ( +
+
+ + Included agents + +

+ {selectedAgentSummary} +

+
+ + {props.error ? ( +

+ {props.error} +

+ ) : null}
-
- - Included agents - + {props.isLoading ? (

- {selectedAgentSummary} + Loading agents...

-
- - {props.error ? ( -

- {props.error} + ) : filteredAgents.length === 0 ? ( +

+ No agents match your search.

- ) : null} -
- {props.isLoading ? ( -

- Loading agents... -

- ) : filteredAgents.length === 0 ? ( -

- No agents match your search. -

- ) : ( - filteredAgents.map((agent) => { - const isSelected = props.selectedAgentIds.includes(agent.id) - const isHighlighted = agent.id === resolvedHighlightedAgentId - return ( - - ) - }) - )} -
+ {agent.name} + {agent.isDefault ? ( + + Default agent + + ) : null} + + + ) + }) + )}
- ) : null} +
) } From ad381082f3f3b17f1acbdc4207653b73ceea979b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 17 Mar 2026 22:23:21 +0000 Subject: [PATCH 08/10] Fix popover combobox visibility Co-authored-by: Kent C. Dodds --- client/agent-multi-select-combobox.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/client/agent-multi-select-combobox.tsx b/client/agent-multi-select-combobox.tsx index fd0b9ed..75aa091 100644 --- a/client/agent-multi-select-combobox.tsx +++ b/client/agent-multi-select-combobox.tsx @@ -246,18 +246,27 @@ export function AgentMultiSelectCombobox(handle: Handle) { css={{ position: 'fixed', positionAnchor: anchorName, - top: `calc(anchor(bottom) + ${spacing.xs})`, - left: 'anchor(left)', + inset: 'auto', + positionArea: 'block-end span-inline-start', + positionTryFallbacks: + 'flip-block, flip-inline, flip-block flip-inline', minWidth: 'anchor-size(width)', maxWidth: 'min(24rem, calc(100vw - 2rem))', + maxHeight: 'calc(100dvh - 2rem)', margin: 0, - display: 'grid', gap: spacing.sm, padding: spacing.md, borderRadius: radius.lg, border: `1px solid ${colors.border}`, backgroundColor: colors.surface, boxShadow: shadows.md, + overflow: 'auto', + '&:not(:popover-open)': { + display: 'none', + }, + '&:popover-open': { + display: 'grid', + }, }} >
From 9c8386d1064bc4681f1b31e65ad5f50f4a1982a4 Mon Sep 17 00:00:00 2001 From: "Kent C. Dodds" Date: Tue, 17 Mar 2026 16:48:49 -0600 Subject: [PATCH 09/10] Enhance chat functionality with thread management improvements - Introduced session info fetching to improve chat initialization. - Updated thread ID extraction logic to handle trailing slashes. - Increased thread list pagination limit and added prefetching for better performance. - Implemented abort controllers for thread refresh and loading operations. - Enhanced error handling and state management during chat data loading. - Updated routing to maintain chat state across different URLs. Co-authored-by: Kent C. Dodds --- client/routes/chat.tsx | 177 ++++++++++++++++++++++++++++++++-------- client/routes/index.tsx | 7 +- docs/agents/setup.md | 10 +++ docs/getting-started.md | 4 +- 4 files changed, 158 insertions(+), 40 deletions(-) diff --git a/client/routes/chat.tsx b/client/routes/chat.tsx index 464e29d..0f3e343 100644 --- a/client/routes/chat.tsx +++ b/client/routes/chat.tsx @@ -15,6 +15,7 @@ import { restoreScrollAnchorAfterPrepend, scrollToEdge, } from '#client/scroll-container.ts' +import { fetchSessionInfo } from '#client/session.ts' import { createSpinDelay } from '#client/spin-delay.ts' import { colors, @@ -41,7 +42,8 @@ function getSelectedThreadIdFromLocation() { if (typeof window === 'undefined') return null const prefix = '/chat/' if (!window.location.pathname.startsWith(prefix)) return null - const threadId = window.location.pathname.slice(prefix.length).trim() + const rest = window.location.pathname.slice(prefix.length).replace(/\/+$/, '') + const threadId = rest.split('/')[0]?.trim() ?? '' return threadId || null } @@ -69,7 +71,8 @@ const THREAD_LIST_SCROLL_CONTAINER_ID = 'chat-thread-list-scroll-container' const MESSAGES_SCROLL_THRESHOLD_PX = 96 const THREAD_LIST_SCROLL_THRESHOLD_PX = 96 const MESSAGE_SCROLL_FADE_HEIGHT = '2.5rem' -const THREADS_PAGE_LIMIT = 40 +const THREADS_PAGE_LIMIT = 100 +const THREAD_LIST_PREFETCH_MAX_PAGES = 100 function truncatePreview(text: string) { const normalized = text.trim() @@ -443,6 +446,11 @@ export function ChatRoute(handle: Handle) { let availableAgentsError: string | null = null let draftAgentIds = normalizeSelectedAgentIds(getRequestedAgentIdsFromLocation()) let syncInFlight = false + /** False until loadInitial succeeds at least once (avoids stuck empty after didLoad false). */ + let threadsListHydrated = false + let chatDataBootstrapPromise: Promise | null = null + let refreshThreadsController: AbortController | null = null + let loadMoreThreadsController: AbortController | null = null let shouldAutoScrollMessages = true let showMessageScrollFadeTop = false let showMessageScrollFadeBottom = false @@ -485,6 +493,40 @@ export function ChatRoute(handle: Handle) { handle.update() } + function createAbortController() { + const controller = new AbortController() + if (handle.signal.aborted) { + controller.abort() + } else { + handle.signal.addEventListener('abort', () => controller.abort(), { + once: true, + }) + } + return controller + } + + function startRefreshThreads() { + refreshThreadsController?.abort() + const controller = createAbortController() + refreshThreadsController = controller + void refreshThreads(controller.signal).finally(() => { + if (refreshThreadsController === controller) { + refreshThreadsController = null + } + }) + } + + function startLoadMoreThreads() { + if (loadMoreThreadsController) return + const controller = createAbortController() + loadMoreThreadsController = controller + void loadMoreThreads(controller.signal).finally(() => { + if (loadMoreThreadsController === controller) { + loadMoreThreadsController = null + } + }) + } + function setThreadState( nextStatus: ThreadStatus, nextError: string | null = null, @@ -707,9 +749,7 @@ export function ChatRoute(handle: Handle) { thresholdPx: THREAD_LIST_SCROLL_THRESHOLD_PX, }) ) { - void handle.queueTask(async (signal) => { - await loadMoreThreads(signal) - }) + startLoadMoreThreads() } } @@ -717,9 +757,7 @@ export function ChatRoute(handle: Handle) { if (!(event.currentTarget instanceof HTMLInputElement)) return threadSearch = event.currentTarget.value update() - void handle.queueTask(async (signal) => { - await refreshThreads(signal) - }) + startRefreshThreads() } function handleComposerKeyDown(event: KeyboardEvent) { @@ -774,7 +812,6 @@ export function ChatRoute(handle: Handle) { try { const locationThreadId = getSelectedThreadIdFromLocation() const requestedAgentIds = getRequestedAgentIdsFromLocation() - const threads = getThreads() if (!locationThreadId && requestedAgentIds?.length) { activeClient?.close() activeClient = null @@ -785,6 +822,25 @@ export function ChatRoute(handle: Handle) { update() return } + + // Resolve deep links before treating an empty list as "no chats": the list + // can be empty (search, first page timing) while the URL thread still exists. + if ( + locationThreadId && + !getThreads().some((thread) => thread.id === locationThreadId) + ) { + try { + const selectedThread = await fetchThreadById(locationThreadId) + updateThreadListFromSnapshot((currentThreads) => [ + selectedThread, + ...currentThreads.filter((t) => t.id !== selectedThread.id), + ]) + } catch { + // Missing thread or error — fall through to empty state / first thread. + } + } + + const threads = getThreads() if (threads.length === 0) { activeClient?.close() activeClient = null @@ -799,27 +855,12 @@ export function ChatRoute(handle: Handle) { return } - if ( - locationThreadId && - !threads.some((thread) => thread.id === locationThreadId) - ) { - try { - const selectedThread = await fetchThreadById(locationThreadId) - updateThreadListFromSnapshot((currentThreads) => [ - selectedThread, - ...currentThreads, - ]) - } catch { - // Ignore missing selections and fall back to the first loaded thread. - } - } - const selectedThread = locationThreadId && - getThreads().find((thread) => thread.id === locationThreadId) + threads.some((thread) => thread.id === locationThreadId) ? locationThreadId : null - const resolvedThreadId = selectedThread ?? getThreads()[0]?.id ?? null + const resolvedThreadId = selectedThread ?? threads[0]?.id ?? null if (!resolvedThreadId) return if (locationThreadId !== resolvedThreadId) { @@ -855,6 +896,15 @@ export function ChatRoute(handle: Handle) { return didLoad } + async function prefetchRemainingThreadPages(signal?: AbortSignal) { + for (let i = 0; i < THREAD_LIST_PREFETCH_MAX_PAGES; i++) { + if (signal?.aborted) return + if (!threadList.getSnapshot().hasMore) return + const loaded = await loadMoreThreads(signal) + if (!loaded) return + } + } + async function refreshThreads(signal?: AbortSignal) { try { threadListCursor = null @@ -868,11 +918,17 @@ export function ChatRoute(handle: Handle) { totalCount: page.totalCount, } }, signal) - if (!didLoad) return + if (!didLoad) { + setThreadState('loading') + update() + return + } + threadsListHydrated = true threadListCursor = nextCursor setThreadState('ready') scheduleThreadListScrollFadeSync() await syncActiveThreadFromLocation() + await prefetchRemainingThreadPages(signal) } catch (error) { if (signal?.aborted) return setThreadState( @@ -882,6 +938,54 @@ export function ChatRoute(handle: Handle) { } } + async function bootstrapChatData(signal?: AbortSignal) { + let session = await fetchSessionInfo(signal) + if (signal?.aborted) return + if (!session?.email) { + await new Promise((r) => setTimeout(r, 120)) + if (signal?.aborted) return + session = await fetchSessionInfo(signal) + } + if (!session?.email) { + threadsListHydrated = true + setThreadState('ready') + availableAgentsStatus = 'ready' + availableAgents = [] + availableAgentsError = null + update() + return + } + + if ( + !threadsListHydrated && + (threadStatus === 'loading' || + threadStatus === 'error' || + (threadStatus === 'ready' && threadListSnapshot.items.length === 0)) + ) { + for (let attempt = 0; attempt < 12 && !threadsListHydrated; attempt++) { + if (signal?.aborted) return + if (attempt > 0) { + await new Promise((r) => setTimeout(r, Math.min(80 * attempt, 400))) + } + await refreshThreads(signal) + } + if (!threadsListHydrated && !signal?.aborted) { + setThreadState( + 'error', + 'Unable to load chats. Try refreshing the page.', + ) + update() + } + } + + if ( + availableAgentsStatus === 'loading' || + (availableAgentsStatus === 'error' && availableAgents.length === 0) + ) { + await loadAvailableAgents(signal) + } + } + async function loadAvailableAgents(signal?: AbortSignal) { try { const nextAvailableAgents = await fetchAvailableAgents(signal) @@ -904,12 +1008,17 @@ export function ChatRoute(handle: Handle) { handle.on(routerEvents, { navigate: () => { - void handle.queueTask(async () => { - await syncActiveThreadFromLocation() - }) + void syncActiveThreadFromLocation() }, }) + function ensureChatDataBootstrap() { + if (chatDataBootstrapPromise) return + chatDataBootstrapPromise = bootstrapChatData(handle.signal).finally(() => { + chatDataBootstrapPromise = null + }) + } + async function createAndSelectThread(input?: { agentIds?: Array }) { const thread = await createThread({ agentIds: input?.agentIds ?? getSelectedAgentIds(), @@ -1068,13 +1177,9 @@ export function ChatRoute(handle: Handle) { } } + ensureChatDataBootstrap() + return () => { - if (threadStatus === 'loading') { - handle.queueTask(refreshThreads) - } - if (availableAgentsStatus === 'loading') { - handle.queueTask(loadAvailableAgents) - } const threads = getThreads() const activeThread = getActiveThreadSummary() diff --git a/client/routes/index.tsx b/client/routes/index.tsx index 7109ee1..8c8b829 100644 --- a/client/routes/index.tsx +++ b/client/routes/index.tsx @@ -7,10 +7,13 @@ import { OAuthAuthorizeRoute } from './oauth-authorize.tsx' import { OAuthCallbackRoute } from './oauth-callback.tsx' import { ResetPasswordRoute } from './reset-password.tsx' +/** Single element so /chat ↔ /chat/:threadId does not remount (preserves thread list + sync). */ +const chatRoute = + export const clientRoutes = { '/': , - '/chat': , - '/chat/:threadId': , + '/chat': chatRoute, + '/chat/:threadId': chatRoute, '/admin/agents': , '/account': , '/login': , diff --git a/docs/agents/setup.md b/docs/agents/setup.md index ae44d7f..54c349c 100644 --- a/docs/agents/setup.md +++ b/docs/agents/setup.md @@ -13,6 +13,16 @@ Quick notes for getting a local pea environment running. ## Local development +### App URL and ports + +- **`bun run dev`** serves the full app at **`http://localhost:3742`** — use this + for manual testing (chat, auth, etc.). +- **Port 8788** is used by **Playwright** (`preview:e2e`) so tests don’t clash + with your dev server, and by **standalone mock workers** if you run Wrangler + on them directly. It is **not** the same origin as normal `bun run dev`. + +### Commands + - Copy `.env.example` to `.env` before starting any work, then update secrets as needed. - `bun run dev` (starts mock API servers automatically and sets diff --git a/docs/getting-started.md b/docs/getting-started.md index 7a052fc..7179ee4 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -42,8 +42,8 @@ Start the local development environment: bun run dev ``` -This starts the app and local mock services used by the current development -workflow. +Open **`http://localhost:3742`** — that is the main app. (Playwright uses a +separate port, often 8788, for isolated e2e runs; see `docs/agents/setup.md`.) ## Deploy From 3018e45aaec5538574546c2d8cba246dfe5935fd Mon Sep 17 00:00:00 2001 From: "Kent C. Dodds" Date: Tue, 17 Mar 2026 17:12:28 -0600 Subject: [PATCH 10/10] Improve mobile chat layout and navigation. Keep the chat list and thread views separate on small screens so the interface fits comfortably, and stack the header controls earlier to avoid overlap. Made-with: Cursor --- client/app.tsx | 17 +++- client/routes/chat.tsx | 176 +++++++++++++++++++++++++++++++++++------ e2e/chat.spec.ts | 40 ++++++++-- 3 files changed, 202 insertions(+), 31 deletions(-) diff --git a/client/app.tsx b/client/app.tsx index 44267ab..cbf41a2 100644 --- a/client/app.tsx +++ b/client/app.tsx @@ -1,13 +1,17 @@ import { type Handle } from 'remix/component' import { clientRoutes } from './routes/index.tsx' -import { getPathname, listenToRouterNavigation, Router } from './client-router.tsx' +import { + getPathname, + listenToRouterNavigation, + Router, +} from './client-router.tsx' import { fetchSessionInfo, type SessionInfo, type SessionStatus, } from './session.ts' import { buildAuthLink } from './auth-links.ts' -import { colors, spacing, typography } from './styles/tokens.ts' +import { colors, mq, spacing, typography } from './styles/tokens.ts' export function App(handle: Handle) { let session: SessionInfo | null = null @@ -97,6 +101,11 @@ export function App(handle: Handle) { minHeight: isChatLayout ? '100vh' : undefined, fontFamily: typography.fontFamily, boxSizing: 'border-box', + [mq.mobile]: { + padding: isChatLayout + ? `${spacing.md} ${spacing.md} ${spacing.sm}` + : spacing.lg, + }, }} >