diff --git a/dashboard/src/components/config/ConfigSpecialEditors.tsx b/dashboard/src/components/config/ConfigSpecialEditors.tsx index 9876d0d4..c04f1c49 100644 --- a/dashboard/src/components/config/ConfigSpecialEditors.tsx +++ b/dashboard/src/components/config/ConfigSpecialEditors.tsx @@ -19,6 +19,7 @@ import { Dialog } from '@/components/headless/Dialog'; import { MdiIcon } from '@/components/icons/MdiIcon'; import { Button } from '@/components/ui/Button'; import { DialogActions } from '@/components/ui/DialogActions'; +import { SelectControl } from '@/components/ui/SelectControl'; import { confirmAction, toast } from '@/stores/feedback'; import { normalizeT2iPreview } from './configSpecialEditorsModel'; import { isConfigRecord, setConfigValue, type ConfigRecord } from './configFormModel'; @@ -229,14 +230,14 @@ export function T2ITemplateEditor() { value={name} /> ) : ( - + )} + ); + }, +); diff --git a/dashboard/src/components/ui/Pagination.tsx b/dashboard/src/components/ui/Pagination.tsx index 3bf00f90..37cd951a 100644 --- a/dashboard/src/components/ui/Pagination.tsx +++ b/dashboard/src/components/ui/Pagination.tsx @@ -3,6 +3,7 @@ import { type ReactNode } from 'react'; import { MdiIcon } from '@/components/icons/MdiIcon'; import { paginationDefaults } from '@/config/defaults'; import { IconButton } from './IconButton'; +import { SelectControl } from './SelectControl'; export type PaginationLabels = { navigation: string; @@ -45,13 +46,13 @@ export function Pagination({ {onPageSizeChange ? ( ) : null} {labels.range ? {labels.range} : null} diff --git a/dashboard/src/components/ui/SelectControl.test.tsx b/dashboard/src/components/ui/SelectControl.test.tsx new file mode 100644 index 00000000..56a47d64 --- /dev/null +++ b/dashboard/src/components/ui/SelectControl.test.tsx @@ -0,0 +1,60 @@ +// @vitest-environment jsdom + +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { ChangeEvent } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { SelectControl } from './SelectControl'; + +describe('SelectControl', () => { + it('preserves native change handlers while using the shared menu', async () => { + const user = userEvent.setup(); + let changedValue = ''; + const onChange = vi.fn((event: ChangeEvent) => { + changedValue = event.target.value; + }); + const view = render( + + + + , + ); + + await user.click(screen.getByRole('button', { name: 'Page size' })); + expect(view.container.querySelector('[role="listbox"]')).toBeNull(); + expect(document.body.querySelector('[role="listbox"]')).not.toBeNull(); + await user.click(screen.getByRole('option', { name: '20' })); + + expect(onChange).toHaveBeenCalledOnce(); + expect(changedValue).toBe('20'); + }); + + it('opens above the trigger when the viewport has no room below', async () => { + const user = userEvent.setup(); + render( + undefined} value="10"> + + + , + ); + const trigger = screen.getByRole('button', { name: 'Page size' }); + vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({ + bottom: 764, + height: 44, + left: 100, + right: 220, + top: 720, + width: 120, + x: 100, + y: 720, + toJSON: () => ({}), + }); + + await user.click(trigger); + + const listbox = screen.getByRole('listbox', { name: 'Page size' }); + await waitFor(() => expect(listbox.style.bottom).not.toBe('')); + expect(listbox.style.top).toBe(''); + }); +}); diff --git a/dashboard/src/components/ui/SelectControl.tsx b/dashboard/src/components/ui/SelectControl.tsx new file mode 100644 index 00000000..8339388c --- /dev/null +++ b/dashboard/src/components/ui/SelectControl.tsx @@ -0,0 +1,93 @@ +import { + Children, + Fragment, + isValidElement, + type ChangeEventHandler, + type OptionHTMLAttributes, + type ReactNode, + type SelectHTMLAttributes, + useMemo, + useRef, +} from 'react'; + +import { SelectMenu, type SelectMenuOption } from './SelectMenu'; + +type SelectControlProps = Omit, 'multiple' | 'size'> & { + children: ReactNode; +}; + +function optionLabel(children: ReactNode) { + return Children.toArray(children) + .map((child) => (typeof child === 'string' || typeof child === 'number' ? String(child) : '')) + .join(''); +} + +function collectOptions(children: ReactNode, options: SelectMenuOption[] = []) { + Children.forEach(children, (child) => { + if (!isValidElement(child)) return; + if (child.type === Fragment || child.type === 'optgroup') { + collectOptions((child.props as { children?: ReactNode }).children, options); + return; + } + if (child.type !== 'option') return; + const props = child.props as OptionHTMLAttributes; + const name = optionLabel(props.children); + options.push({ + disabled: props.disabled, + id: String(props.value ?? name), + name, + }); + }); + return options; +} + +export function SelectControl({ + 'aria-label': ariaLabel, + children, + className = '', + defaultValue, + disabled, + onChange, + value, + ...props +}: SelectControlProps) { + const nativeRef = useRef(null); + const options = useMemo(() => collectOptions(children), [children]); + const selectedValue = String(value ?? defaultValue ?? options[0]?.id ?? ''); + const selectedName = options.find((option) => option.id === selectedValue)?.name || ''; + + const selectValue = (nextValue: string) => { + const select = nativeRef.current; + if (!select) return; + const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')?.set; + setter?.call(select, nextValue); + select.dispatchEvent(new Event('change', { bubbles: true })); + }; + + return ( + <> + + + + ); +} diff --git a/dashboard/src/components/ui/SelectMenu.tsx b/dashboard/src/components/ui/SelectMenu.tsx new file mode 100644 index 00000000..f604b288 --- /dev/null +++ b/dashboard/src/components/ui/SelectMenu.tsx @@ -0,0 +1,144 @@ +import { createPortal } from 'react-dom'; +import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react'; + +import { MdiIcon } from '@/components/icons/MdiIcon'; + +export type SelectMenuOption = { + disabled?: boolean; + id: string; + image?: string; + name: string; +}; + +export function SelectMenu({ + ariaLabel, + className = '', + disabled = false, + imageForValue, + onChange, + options, + placeholder, + value, +}: { + ariaLabel: string; + className?: string; + disabled?: boolean; + imageForValue?: (value: string) => string | undefined; + onChange: (value: string) => void; + options: SelectMenuOption[]; + placeholder: string; + value: string; +}) { + const [open, setOpen] = useState(false); + const [menuStyle, setMenuStyle] = useState({ visibility: 'hidden' }); + const menu = useRef(null); + const root = useRef(null); + const selected = options.find((option) => option.id === value); + + useEffect(() => { + if (!open) return undefined; + const close = (event: PointerEvent) => { + const target = event.target as Node; + if (!root.current?.contains(target) && !menu.current?.contains(target)) setOpen(false); + }; + document.addEventListener('pointerdown', close); + return () => document.removeEventListener('pointerdown', close); + }, [open]); + + useLayoutEffect(() => { + if (!open) return undefined; + const positionMenu = () => { + const trigger = root.current?.querySelector(':scope > button'); + if (!(trigger instanceof HTMLElement)) return; + const rect = trigger.getBoundingClientRect(); + const viewportWidth = document.documentElement.clientWidth || window.innerWidth; + const viewportHeight = document.documentElement.clientHeight || window.innerHeight; + const gap = 5; + const margin = 8; + const width = Math.min(Math.max(rect.width, 96), viewportWidth - margin * 2); + const left = Math.min(Math.max(rect.left, margin), viewportWidth - width - margin); + const measuredHeight = menu.current?.scrollHeight || Math.min(options.length * 42 + 10, 360); + const spaceBelow = viewportHeight - rect.bottom - margin; + const spaceAbove = rect.top - margin; + const openAbove = spaceBelow < Math.min(measuredHeight, 180) && spaceAbove > spaceBelow; + const availableHeight = Math.max(80, (openAbove ? spaceAbove : spaceBelow) - gap); + + setMenuStyle({ + bottom: openAbove ? viewportHeight - rect.top + gap : undefined, + left, + maxHeight: Math.min(360, availableHeight), + pointerEvents: 'auto', + top: openAbove ? undefined : rect.bottom + gap, + visibility: 'visible', + width, + }); + }; + + positionMenu(); + window.addEventListener('resize', positionMenu); + document.addEventListener('scroll', positionMenu, true); + return () => { + window.removeEventListener('resize', positionMenu); + document.removeEventListener('scroll', positionMenu, true); + }; + }, [open, options.length]); + + useEffect(() => { + if (disabled) setOpen(false); + }, [disabled]); + + return ( +
+ + {open && + typeof document !== 'undefined' && + createPortal( +
event.stopPropagation()} + > + {options.map((option) => { + const image = option.image || imageForValue?.(option.id); + return ( + + ); + })} +
, + document.body, + )} +
+ ); +} diff --git a/dashboard/src/components/ui/primitives.test.tsx b/dashboard/src/components/ui/primitives.test.tsx index 45c41e9f..1e392a2f 100644 --- a/dashboard/src/components/ui/primitives.test.tsx +++ b/dashboard/src/components/ui/primitives.test.tsx @@ -9,6 +9,8 @@ import { DisclosureButton } from './DisclosureButton'; import { DialogActions } from './DialogActions'; import { Pagination } from './Pagination'; import { SearchField } from './SearchField'; +import { SelectMenu } from './SelectMenu'; +import { SelectControl } from './SelectControl'; import { StatusChip } from './StatusChip'; const primitiveStyles = readFileSync(new URL('../../styles/components/_primitives.scss', import.meta.url), 'utf8'); @@ -46,6 +48,60 @@ describe('shared UI primitives', () => { expect(primitiveStyles).toContain('.button--warning'); }); + it('gives native form controls a theme-aware baseline', () => { + expect(primitiveStyles).toContain("input[type='text']"); + expect(primitiveStyles).toContain("input[type='checkbox']"); + expect(primitiveStyles).toContain('select,'); + expect(primitiveStyles).toContain('textarea'); + expect(primitiveStyles).toContain('var(--astrbot-radius-control)'); + expect(primitiveStyles).toContain(':focus'); + }); + + it('styles expanded native select pickers with a themed fallback', () => { + expect(primitiveStyles).toContain(':where(select option:checked)'); + expect(primitiveStyles).toContain('@supports (appearance: base-select)'); + expect(primitiveStyles).toContain('::picker(select)'); + expect(primitiveStyles).toContain('::picker-icon'); + expect(primitiveStyles).toContain('::checkmark'); + expect(primitiveStyles).toContain(':where(select:not(:disabled))'); + expect(primitiveStyles).toContain(':where(select:active, select:focus, select:focus-visible, select:open)'); + expect(primitiveStyles).toContain('border-color: transparent'); + }); + + it('shares the custom select menu used by feature pages', () => { + const markup = renderToStaticMarkup( + undefined} + options={[ + { id: 'local', name: 'Allow' }, + { id: 'none', name: 'Deny' }, + ]} + placeholder="Choose access" + value="none" + />, + ); + + expect(markup).toContain('ui-select-menu'); + expect(markup).toContain('aria-haspopup="listbox"'); + expect(markup).toContain('Deny'); + expect(markup).not.toContain('Choose access'); + }); + + it('adapts native option markup to the shared select menu', () => { + const markup = renderToStaticMarkup( + undefined}> + + + , + ); + + expect(markup).toContain('ui-select-control__native'); + expect(markup).toContain('aria-label="Page size"'); + expect(markup).toContain('aria-haspopup="listbox"'); + expect(markup).toContain('>20<'); + }); + it('gives search and status controls consistent accessible markup', () => { const markup = renderToStaticMarkup( <> diff --git a/dashboard/src/routes/chat/ChatComposer.test.tsx b/dashboard/src/routes/chat/ChatComposer.test.tsx index 66732752..ce858604 100644 --- a/dashboard/src/routes/chat/ChatComposer.test.tsx +++ b/dashboard/src/routes/chat/ChatComposer.test.tsx @@ -32,7 +32,9 @@ describe('ChatComposer', () => { />, ); - expect(markup).toContain(' onConfigChange(event.target.value)} @@ -456,7 +457,7 @@ export const ChatComposer = forwardRef(fu {config.name} ))} - + {configs.find((config) => config.id === configId)?.description && ( {configs.find((config) => config.id === configId)?.description} )} diff --git a/dashboard/src/routes/chat/ChatMessageList.test.tsx b/dashboard/src/routes/chat/ChatMessageList.test.tsx new file mode 100644 index 00000000..c890ff9c --- /dev/null +++ b/dashboard/src/routes/chat/ChatMessageList.test.tsx @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; + +import { renderStatic } from '@/test/render'; +import { ChatMessageList } from './ChatMessageList'; + +describe('ChatMessageList', () => { + it('renders one lightweight thinking indicator while a reply is starting', () => { + const markup = renderStatic( + , + ); + + expect(markup.match(/ab-chat-message-spinner/g)).toHaveLength(1); + expect(markup).toContain('Thinking...'); + expect(markup).not.toContain('>Running<'); + }); +}); diff --git a/dashboard/src/routes/chat/ChatMessageList.tsx b/dashboard/src/routes/chat/ChatMessageList.tsx index b73fcfbe..02afd65c 100644 --- a/dashboard/src/routes/chat/ChatMessageList.tsx +++ b/dashboard/src/routes/chat/ChatMessageList.tsx @@ -44,6 +44,7 @@ export type ChatMessageLabels = { replyTo: string; cachedTokens: string; inputTokens: string; + loading: string; outputTokens: string; ttft: string; duration: string; @@ -127,6 +128,7 @@ export function ChatMessageList({ duration: t('features.chat.stats.duration'), edit: t('core.common.edit'), inputTokens: t('features.chat.stats.inputTokens'), + loading: t('features.chat.message.loading'), outputTokens: t('features.chat.stats.outputTokens'), reasoning: t('features.chat.reasoning.thinking'), references: t('features.chat.refs.title'), @@ -251,9 +253,7 @@ export function ChatMessageList({ ) : message.content.isLoading && bubbleParts.length === 0 ? ( - - {labels.running} - + {labels.loading} ) : ( bubbleParts.map((part, partIndex) => ( { beforeEach(() => { + resetChatStreamRegistry(); vi.resetAllMocks(); + localStorage.clear(); vi.mocked(listChatSessions).mockResolvedValue(mockApiResponse({ sessions: [] })); vi.mocked(listChatProjects).mockResolvedValue(mockApiResponse({ projects: [] })); vi.mocked(listProviders).mockResolvedValue(mockApiResponse({ model_metadata: {}, providers: [] })); @@ -103,6 +107,37 @@ describe('ChatPage', () => { ); }); + it('replaces a removed persisted provider before sending the first message', async () => { + const user = userEvent.setup(); + localStorage.setItem('selectedProvider', 'removed-provider'); + localStorage.setItem('selectedProviderModel', 'removed-model'); + vi.mocked(listProviders).mockResolvedValue( + mockApiResponse({ + model_metadata: {}, + providers: [{ enable: true, id: 'available-provider', model: 'available-model' }], + }), + ); + vi.mocked(createChatSession).mockResolvedValue(mockApiResponse({ session_id: 'session-new' })); + + renderRoute(, { route: '/chat' }); + + const composer = await screen.findByPlaceholderText('features.chat.input.placeholder'); + await waitFor(() => expect(selectedProviderPreference.read()).toBe('available-provider')); + expect(selectedModelPreference.read()).toBe('available-model'); + await user.type(composer, 'Use the available provider'); + await user.click(screen.getByRole('button', { name: 'features.chat.input.send' })); + + await waitFor(() => expect(runChatStream).toHaveBeenCalled()); + expect(runChatStream).toHaveBeenCalledWith( + expect.objectContaining({ + selectedModel: 'available-model', + selectedProvider: 'available-provider', + }), + expect.any(AbortSignal), + expect.any(Object), + ); + }); + it('keeps the newest conversation when requests resolve out of order', async () => { const user = userEvent.setup(); const firstRequest = deferred>>>(); @@ -134,4 +169,52 @@ describe('ChatPage', () => { await waitFor(() => expect(screen.queryByText('stale response')).not.toBeInTheDocument()); expect(screen.getByText('newest response')).toBeInTheDocument(); }); + + it('keeps an active chat stream alive while navigating to another dashboard page', async () => { + const user = userEvent.setup(); + const stream = deferred(); + let streamSignal: AbortSignal | undefined; + let deliverPayload: ((payload: unknown) => void) | undefined; + vi.mocked(getChatSession).mockResolvedValue(mockApiResponse({ history: [] })); + vi.mocked(runChatStream).mockImplementation(async (_action, signal, callbacks) => { + streamSignal = signal; + deliverPayload = callbacks.onPayload; + await stream.promise; + }); + + renderRoute( + <> + Open logs + Return to chat + + } path="/chat/:conversationId" /> + Logs page} path="/logs" /> + + , + { route: '/chat/session-1' }, + ); + + const composer = await screen.findByPlaceholderText('features.chat.input.placeholder'); + await user.type(composer, 'Keep generating'); + await user.click(screen.getByRole('button', { name: 'features.chat.input.send' })); + await waitFor(() => expect(runChatStream).toHaveBeenCalled()); + expect(deliverPayload).toEqual(expect.any(Function)); + expect(chatStreamRegistry.messageCache['session-1']).toHaveLength(2); + + await user.click(screen.getByRole('link', { name: 'Open logs' })); + expect(await screen.findByText('Logs page')).toBeInTheDocument(); + expect(streamSignal?.aborted).toBe(false); + + await user.click(screen.getByRole('link', { name: 'Return to chat' })); + await screen.findByPlaceholderText('features.chat.input.placeholder'); + expect(chatStreamRegistry.messageCache['session-1']).toHaveLength(2); + expect(await screen.findByText('Keep generating')).toBeInTheDocument(); + deliverPayload?.({ type: 'plain', data: 'Still connected', streaming: true }); + expect(chatStreamRegistry.messageCache['session-1'][1].content.message).toContainEqual({ + text: 'Still connected', + type: 'plain', + }); + + stream.resolve(); + }); }); diff --git a/dashboard/src/routes/chat/ChatPage.tsx b/dashboard/src/routes/chat/ChatPage.tsx index 83439afa..58303f8c 100644 --- a/dashboard/src/routes/chat/ChatPage.tsx +++ b/dashboard/src/routes/chat/ChatPage.tsx @@ -67,6 +67,7 @@ import { } from './configBinding'; import { createStreamRenderScheduler } from './streamRenderScheduler'; import { runChatStream } from './chatTransport'; +import { chatStreamRegistry } from './chatStreamRegistry'; import { useChatPreferences } from './useChatPreferences'; import { agentRunnerTypeFromProfile, @@ -187,8 +188,8 @@ export default function ChatPage({ chatbox = false }: ChatPageProps) { const agentRunnerCacheRef = useRef(new Map()); const agentRunnerRequestsRef = useRef(new Map>()); const agentRunnerRequestIdRef = useRef(0); - const activeStreamsRef = useRef>(new Map()); - const messageCacheRef = useRef>({}); + const activeStreamsRef = useRef(chatStreamRegistry.activeStreams); + const messageCacheRef = useRef(chatStreamRegistry.messageCache); const activeConversationRef = useRef(conversationId); const audioRecorderRef = useRef(new AudioRecorder()); const activeSessionRef = useRef(''); @@ -428,14 +429,17 @@ export default function ChatPage({ chatbox = false }: ChatPageProps) { setProviderMetadata( isObject(envelope.model_metadata) ? (envelope.model_metadata as Record) : {}, ); - const selected = items.find((item) => item.id === provider); - if (selected?.model) setModel(selected.model); + const selected = items.find((item) => item.id === provider) || items[0]; + const selectedProvider = selected?.id || ''; + const selectedModel = selected?.model || ''; + if (provider !== selectedProvider) setProvider(selectedProvider); + setModel(selectedModel); } catch (cause) { toast.error(errorMessage(cause, 'Failed to load models.')); } finally { setProvidersLoading(false); } - }, [provider]); + }, [provider, setModel, setProvider]); const loadMessages = useCallback(async () => { const requestId = ++messageLoadRequestRef.current; @@ -554,10 +558,17 @@ export default function ChatPage({ chatbox = false }: ChatPageProps) { void loadMessages(); }, [conversationId, loadMessages]); + useEffect(() => { + if (!conversationId) return; + return chatStreamRegistry.subscribe(conversationId, () => { + const cached = messageCacheRef.current[conversationId]; + if (cached) setMessages([...cached]); + markSessionRunning(conversationId, activeStreamsRef.current.has(conversationId)); + }); + }, [conversationId, markSessionRunning]); + useEffect( () => () => { - activeStreamsRef.current.forEach((controller) => controller.abort()); - activeStreamsRef.current.clear(); audioRecorderRef.current.cancel(); if (settingsSubmenuTimer.current != null) window.clearTimeout(settingsSubmenuTimer.current); if (messageScrollFrame.current != null) window.cancelAnimationFrame(messageScrollFrame.current); @@ -965,6 +976,7 @@ export default function ChatPage({ chatbox = false }: ChatPageProps) { abort = new AbortController(); abortRef.current = abort; activeStreamsRef.current.set(sessionId, abort); + chatStreamRegistry.notify(sessionId); activeSessionRef.current = sessionId; const messagePayload = serializeChatParts(outgoing); const applyPayloads = (payloads: unknown[]) => { @@ -976,8 +988,7 @@ export default function ChatPage({ chatbox = false }: ChatPageProps) { if (changed) streamRender?.schedule(); }; streamRender = createStreamRenderScheduler(() => { - const cached = messageCacheRef.current[sessionId]; - if (activeConversationRef.current === sessionId && cached?.includes(bot!)) setMessages([...cached]); + chatStreamRegistry.notify(sessionId); }); await runChatStream( { @@ -986,8 +997,8 @@ export default function ChatPage({ chatbox = false }: ChatPageProps) { enableStreaming: streaming, message: messagePayload, messageId, - selectedModel: providerOverrideEnabled ? model : '', - selectedProvider: providerOverrideEnabled ? provider : '', + selectedModel: providerOverrideEnabled ? currentProvider?.model || '' : '', + selectedProvider: providerOverrideEnabled ? currentProvider?.id || '' : '', sessionId, transport: transportMode, }, @@ -1012,12 +1023,12 @@ export default function ChatPage({ chatbox = false }: ChatPageProps) { streamRender.schedule(); streamRender.flush(); } else { - const cached = messageCacheRef.current[sessionId]; - if (activeConversationRef.current === sessionId && cached?.includes(bot)) setMessages([...cached]); + chatStreamRegistry.notify(sessionId); } } if (!abort || abortRef.current === abort) abortRef.current = null; if (sessionId) activeStreamsRef.current.delete(sessionId); + if (sessionId) chatStreamRegistry.notify(sessionId); if (!sessionId || activeSessionRef.current === sessionId) activeSessionRef.current = ''; if (sessionId) markSessionRunning(sessionId, false); setPendingSessionSending(false); @@ -1026,7 +1037,11 @@ export default function ChatPage({ chatbox = false }: ChatPageProps) { } }; - const regenerate = async (target: ChatRecord, selectedProvider = provider, selectedModel = model) => { + const regenerate = async ( + target: ChatRecord, + selectedProvider = currentProvider?.id || '', + selectedModel = currentProvider?.model || '', + ) => { if (!conversationId || target.id == null || sending) return; const targetId = String(target.id); const index = messages.findIndex((item) => item === target || String(item.id) === targetId); @@ -1046,11 +1061,11 @@ export default function ChatPage({ chatbox = false }: ChatPageProps) { setError(''); const abort = new AbortController(); const streamRender = createStreamRenderScheduler(() => { - const cached = messageCacheRef.current[conversationId]; - if (activeConversationRef.current === conversationId && cached?.includes(regenerated)) setMessages([...cached]); + chatStreamRegistry.notify(conversationId); }); abortRef.current = abort; activeStreamsRef.current.set(conversationId, abort); + chatStreamRegistry.notify(conversationId); activeSessionRef.current = conversationId; try { await runChatStream( @@ -1083,6 +1098,7 @@ export default function ChatPage({ chatbox = false }: ChatPageProps) { streamRender.flush(); if (abortRef.current === abort) abortRef.current = null; activeStreamsRef.current.delete(conversationId); + chatStreamRegistry.notify(conversationId); if (activeSessionRef.current === conversationId) activeSessionRef.current = ''; markSessionRunning(conversationId, false); } @@ -1112,8 +1128,9 @@ export default function ChatPage({ chatbox = false }: ChatPageProps) { markSessionRunning(conversationId, true); const abort = new AbortController(); activeStreamsRef.current.set(conversationId, abort); + chatStreamRegistry.notify(conversationId); const scheduler = createStreamRenderScheduler(() => { - if (activeConversationRef.current === conversationId) setMessages([...messageCacheRef.current[conversationId]]); + chatStreamRegistry.notify(conversationId); }); try { await runChatStream( @@ -1123,8 +1140,8 @@ export default function ChatPage({ chatbox = false }: ChatPageProps) { enableStreaming: streaming, llmCheckpointId: String(source.llm_checkpoint_id || ''), message: serializeChatParts(source.content.message), - selectedModel: providerOverrideEnabled ? model : '', - selectedProvider: providerOverrideEnabled ? provider : '', + selectedModel: providerOverrideEnabled ? currentProvider?.model || '' : '', + selectedProvider: providerOverrideEnabled ? currentProvider?.id || '' : '', sessionId: conversationId, }, abort.signal, @@ -1145,6 +1162,7 @@ export default function ChatPage({ chatbox = false }: ChatPageProps) { scheduler.schedule(); scheduler.flush(); activeStreamsRef.current.delete(conversationId); + chatStreamRegistry.notify(conversationId); markSessionRunning(conversationId, false); } }; @@ -1273,8 +1291,8 @@ export default function ChatPage({ chatbox = false }: ChatPageProps) { kind: 'thread', enableStreaming: streaming, message: [{ type: 'plain', text }], - selectedModel: providerOverrideEnabled ? model : '', - selectedProvider: providerOverrideEnabled ? provider : '', + selectedModel: providerOverrideEnabled ? currentProvider?.model || '' : '', + selectedProvider: providerOverrideEnabled ? currentProvider?.id || '' : '', threadId, }, abort.signal, @@ -1353,6 +1371,7 @@ export default function ChatPage({ chatbox = false }: ChatPageProps) { if (sessionId) await stopChatSession({ path: { session_id: sessionId } }).catch(() => undefined); if (sessionId) { activeStreamsRef.current.delete(sessionId); + chatStreamRegistry.notify(sessionId); markSessionRunning(sessionId, false); } setPendingSessionSending(false); @@ -1590,7 +1609,7 @@ export default function ChatPage({ chatbox = false }: ChatPageProps) { {runningSessionIds.has(session.session_id) && ( - +