From a1e9a31e8312eae29e1a96100d6ccfcf26c5eb35 Mon Sep 17 00:00:00 2001 From: Fridemn <702625325@qq.com> Date: Fri, 31 Jul 2026 10:13:17 +0800 Subject: [PATCH 01/10] fix(dashboard): preserve chat streams across route navigation --- dashboard/src/routes/chat/ChatPage.test.tsx | 50 +++++++++++++++++++ dashboard/src/routes/chat/ChatPage.tsx | 34 +++++++++---- .../src/routes/chat/chatStreamRegistry.ts | 34 +++++++++++++ 3 files changed, 107 insertions(+), 11 deletions(-) create mode 100644 dashboard/src/routes/chat/chatStreamRegistry.ts diff --git a/dashboard/src/routes/chat/ChatPage.test.tsx b/dashboard/src/routes/chat/ChatPage.test.tsx index db176d04..06d3a237 100644 --- a/dashboard/src/routes/chat/ChatPage.test.tsx +++ b/dashboard/src/routes/chat/ChatPage.test.tsx @@ -21,6 +21,7 @@ import { import { deferred } from '@/test/async'; import { mockApiResponse, renderRoute } from '@/test/render'; import { runChatStream } from './chatTransport'; +import { chatStreamRegistry, resetChatStreamRegistry } from './chatStreamRegistry'; import ChatPage from './ChatPage'; vi.mock('@/api/openapi'); @@ -33,6 +34,7 @@ function CurrentPath() { describe('ChatPage', () => { beforeEach(() => { + resetChatStreamRegistry(); vi.resetAllMocks(); vi.mocked(listChatSessions).mockResolvedValue(mockApiResponse({ sessions: [] })); vi.mocked(listChatProjects).mockResolvedValue(mockApiResponse({ projects: [] })); @@ -134,4 +136,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..9f4de388 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(''); @@ -554,10 +555,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 +973,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 +985,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( { @@ -1012,12 +1020,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); @@ -1046,11 +1054,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 +1091,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 +1121,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( @@ -1145,6 +1155,7 @@ export default function ChatPage({ chatbox = false }: ChatPageProps) { scheduler.schedule(); scheduler.flush(); activeStreamsRef.current.delete(conversationId); + chatStreamRegistry.notify(conversationId); markSessionRunning(conversationId, false); } }; @@ -1353,6 +1364,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); diff --git a/dashboard/src/routes/chat/chatStreamRegistry.ts b/dashboard/src/routes/chat/chatStreamRegistry.ts new file mode 100644 index 00000000..32992cf7 --- /dev/null +++ b/dashboard/src/routes/chat/chatStreamRegistry.ts @@ -0,0 +1,34 @@ +import type { ChatRecord } from './model'; + +type StreamListener = () => void; + +const activeStreams = new Map(); +const messageCache: Record = {}; +const listeners = new Map>(); + +export const chatStreamRegistry = { + activeStreams, + messageCache, + + notify(sessionId: string) { + listeners.get(sessionId)?.forEach((listener) => listener()); + }, + + subscribe(sessionId: string, listener: StreamListener) { + const sessionListeners = listeners.get(sessionId) ?? new Set(); + sessionListeners.add(listener); + listeners.set(sessionId, sessionListeners); + listener(); + return () => { + sessionListeners.delete(listener); + if (!sessionListeners.size) listeners.delete(sessionId); + }; + }, +}; + +export function resetChatStreamRegistry() { + activeStreams.forEach((controller) => controller.abort()); + activeStreams.clear(); + Object.keys(messageCache).forEach((sessionId) => delete messageCache[sessionId]); + listeners.clear(); +} From 51b5f16e5f2095b03c145ac1f0882d92b7706981 Mon Sep 17 00:00:00 2001 From: Fridemn <702625325@qq.com> Date: Fri, 31 Jul 2026 10:27:25 +0800 Subject: [PATCH 02/10] fix(dashboard): validate persisted provider before sending chat messages --- dashboard/src/routes/chat/ChatPage.test.tsx | 33 +++++++++++++++++++++ dashboard/src/routes/chat/ChatPage.tsx | 27 ++++++++++------- 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/dashboard/src/routes/chat/ChatPage.test.tsx b/dashboard/src/routes/chat/ChatPage.test.tsx index 06d3a237..eab994bb 100644 --- a/dashboard/src/routes/chat/ChatPage.test.tsx +++ b/dashboard/src/routes/chat/ChatPage.test.tsx @@ -6,6 +6,7 @@ import { Link, Route, Routes, useLocation } from 'react-router-dom'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ApiError } from '@/api/http'; +import { selectedModelPreference, selectedProviderPreference } from '@/config/preferences'; import { createChatSession, getChatSession, @@ -36,6 +37,7 @@ describe('ChatPage', () => { 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: [] })); @@ -105,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>>>(); diff --git a/dashboard/src/routes/chat/ChatPage.tsx b/dashboard/src/routes/chat/ChatPage.tsx index 9f4de388..c0542258 100644 --- a/dashboard/src/routes/chat/ChatPage.tsx +++ b/dashboard/src/routes/chat/ChatPage.tsx @@ -429,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; @@ -994,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, }, @@ -1034,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); @@ -1133,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, @@ -1284,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, From 99ecf73e6c4495a7735b3f2dea86d373ac7d3f44 Mon Sep 17 00:00:00 2001 From: Fridemn <702625325@qq.com> Date: Fri, 31 Jul 2026 10:39:51 +0800 Subject: [PATCH 03/10] fix(dashboard): enlarge welcome onboarding dialogs --- dashboard/src/styles/features/_knowledge.scss | 2 -- dashboard/src/styles/features/_welcome.scss | 34 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/dashboard/src/styles/features/_knowledge.scss b/dashboard/src/styles/features/_knowledge.scss index 0af35360..cd7fdb2e 100644 --- a/dashboard/src/styles/features/_knowledge.scss +++ b/dashboard/src/styles/features/_knowledge.scss @@ -190,8 +190,6 @@ .knowledge-tavily.is-configured { color: var(--astrbot-success); } .knowledge-tavily > span { flex: 1; } .knowledge-tavily > button { border: 0; background: transparent; color: var(--astrbot-primary); cursor: pointer; font-weight: 650; } -.welcome-onboarding-dialog { width: min(1120px, 86vw); max-height: 74vh; overflow: auto; } -.welcome-onboarding-dialog .route-page__heading { display: none; } .knowledge-upload__settings h3 { width: 100%; margin: 0; font-size: 13px; } .knowledge-upload__settings span { font-size: 11px; } diff --git a/dashboard/src/styles/features/_welcome.scss b/dashboard/src/styles/features/_welcome.scss index e3adaa55..6f23ae00 100644 --- a/dashboard/src/styles/features/_welcome.scss +++ b/dashboard/src/styles/features/_welcome.scss @@ -142,3 +142,37 @@ min-height: 44px; } +.headless-dialog__content:has(.welcome-onboarding-dialog) { + display: flex; + width: min(1440px, calc(100vw - 48px)); + height: min(900px, calc(100vh - 48px)); + max-height: calc(100vh - 48px); + flex-direction: column; + overflow: hidden; +} + +.welcome-onboarding-dialog { + min-width: 0; + min-height: 0; + flex: 1; + overflow: auto; +} + +.welcome-onboarding-dialog > .provider-page, +.welcome-onboarding-dialog > .platform-page-react { + min-height: 100%; +} + +.welcome-onboarding-dialog .route-page__heading { + display: none; +} + +@media (max-width: 700px) { + .headless-dialog__content:has(.welcome-onboarding-dialog) { + width: calc(100vw - 16px); + height: calc(100vh - 16px); + max-height: calc(100vh - 16px); + padding: 16px; + } +} + From b8e56546d07fe4e913fd1e1445312913a6282d47 Mon Sep 17 00:00:00 2001 From: Fridemn <702625325@qq.com> Date: Fri, 31 Jul 2026 11:42:26 +0800 Subject: [PATCH 04/10] fix(dashboard): unify dropdown controls across pages --- .../config/ConfigSpecialEditors.tsx | 5 +- .../components/config/DynamicConfigForm.tsx | 5 +- .../config/ObjectConfigControl.test.tsx | 3 +- .../components/config/ObjectConfigControl.tsx | 5 +- dashboard/src/components/ui/Pagination.tsx | 5 +- .../src/components/ui/SelectControl.test.tsx | 30 ++ dashboard/src/components/ui/SelectControl.tsx | 93 ++++++ dashboard/src/components/ui/SelectMenu.tsx | 89 ++++++ .../src/components/ui/primitives.test.tsx | 56 ++++ .../src/routes/chat/ChatComposer.test.tsx | 4 +- dashboard/src/routes/chat/ChatComposer.tsx | 5 +- .../src/routes/chat/ChatProjectDialog.tsx | 5 +- .../configuration/ApiKeySettingsSection.tsx | 5 +- .../src/routes/configuration/ConfigPage.tsx | 5 +- .../src/routes/configuration/CronPage.tsx | 20 +- .../src/routes/configuration/PlatformPage.tsx | 85 +---- .../src/routes/extensions/ExtensionPage.tsx | 9 +- .../routes/extensions/ExtensionSections.tsx | 107 ++++--- .../routes/knowledge/DocumentDetailPage.tsx | 5 +- .../knowledge/KnowledgeBaseDetailPage.tsx | 13 +- .../routes/monitoring/ConversationPage.tsx | 5 +- .../monitoring/SessionManagementControls.tsx | 5 +- .../monitoring/SessionManagementPage.test.tsx | 6 +- .../monitoring/SessionManagementPage.tsx | 33 +- dashboard/src/routes/welcome/WelcomePage.tsx | 16 +- .../src/styles/components/_primitives.scss | 300 +++++++++++++++++- dashboard/src/styles/features/_chat.scss | 7 +- .../src/styles/features/_config-editor.scss | 11 +- .../src/styles/features/_conversations.scss | 9 +- dashboard/src/styles/features/_cron.scss | 7 +- .../src/styles/features/_extensions.scss | 19 +- dashboard/src/styles/features/_knowledge.scss | 7 +- .../styles/features/_monitoring-pages.scss | 6 +- dashboard/src/styles/features/_platforms.scss | 17 +- dashboard/src/styles/features/_sessions.scss | 13 +- dashboard/src/styles/features/_settings.scss | 7 +- dashboard/src/styles/features/_welcome.scss | 3 +- 37 files changed, 781 insertions(+), 244 deletions(-) create mode 100644 dashboard/src/components/ui/SelectControl.test.tsx create mode 100644 dashboard/src/components/ui/SelectControl.tsx create mode 100644 dashboard/src/components/ui/SelectMenu.tsx 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} /> ) : ( - + )} + {open && ( +
+ {options.map((option) => { + const image = option.image || imageForValue?.(option.id); + return ( + + ); + })} +
+ )} + + ); +} 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/ChatProjectDialog.tsx b/dashboard/src/routes/chat/ChatProjectDialog.tsx index 8782bd4b..a254209e 100644 --- a/dashboard/src/routes/chat/ChatProjectDialog.tsx +++ b/dashboard/src/routes/chat/ChatProjectDialog.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import { Dialog } from '@/components/headless/Dialog'; import { Button } from '@/components/ui/Button'; import { DialogActions } from '@/components/ui/DialogActions'; +import { SelectControl } from '@/components/ui/SelectControl'; export type ChatProjectForm = { description: string; @@ -109,7 +110,7 @@ export function ChatProjectDialog({
{form.workspace_type === 'custom' && (
); @@ -705,13 +706,16 @@ function ScheduleFields({ form, k, updateForm }: ScheduleFieldsProps) {