mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-28 09:08:54 -04:00
feat(ui): add reasoning effort and reasoning off controls to chat (#12700)
The chat could not set reasoning_effort or turn reasoning traces off. Add an effort menu and a "No reasoning" toggle to the composer chips, and the matching /effort and /reasoning slash actions. Both are stored per chat. The effort menu defaults to the model default, which sends no reasoning_effort, so existing chats behave as before. Chosen effort goes out as the per-request reasoning_effort field. The toggle sends "none", which the server maps to enable_thinking=false, and also drops any reasoning the model still returns, so the thread shows only the answer. Assisted-by: Claude:claude-sonnet-5-5 eslint playwright Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
1 parent
6d185cc2f8
commit
fc6779d41e
8 files changed
+267
-11
No files matched your search
@@ -72,7 +72,7 @@ test.describe('Chat composer', () => {
|
||||
await expect(list).toBeVisible()
|
||||
await expect(list.locator('.dk-cmd-label')).toHaveText(['Chat', 'View'])
|
||||
const names = await list.locator('.dk-cmd-name').allTextContents()
|
||||
expect(names).toEqual(['/model', '/new', '/chats', '/assistant', '/canvas', '/find', '/settings', '/export', '/clear'])
|
||||
expect(names).toEqual(['/model', '/new', '/chats', '/assistant', '/reasoning', '/effort', '/canvas', '/find', '/settings', '/export', '/clear'])
|
||||
|
||||
await input(page).fill('/set')
|
||||
await expect(list.locator('.dk-cmd-name')).toHaveText(['/settings'])
|
||||
@@ -149,7 +149,7 @@ test.describe('Chat composer', () => {
|
||||
await openChat(page, [chatOf('c1', 'Empty', 'qwen3-8b', [])])
|
||||
await input(page).fill('/')
|
||||
const names = await page.getByRole('listbox', { name: 'Actions' }).locator('.dk-cmd-name').allTextContents()
|
||||
expect(names).toEqual(['/model', '/new', '/chats', '/assistant', '/canvas', '/settings'])
|
||||
expect(names).toEqual(['/model', '/new', '/chats', '/assistant', '/reasoning', '/effort', '/canvas', '/settings'])
|
||||
})
|
||||
|
||||
test('"/" from outside a text field starts a command', async ({ page }) => {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { test, expect } from './coverage-fixtures.js'
|
||||
import { chatOf, pair, mockChat, openChat, storedChats } from './chat-fixtures.js'
|
||||
|
||||
// The composer has two reasoning controls, stored per chat: an effort select
|
||||
// and a "No reasoning" toggle. Both are also slash actions.
|
||||
|
||||
const sendWith = async (page, answer) => {
|
||||
let body = null
|
||||
await page.route('**/v1/chat/completions', (route) => {
|
||||
body = route.request().postDataJSON()
|
||||
route.fulfill({ status: 200, contentType: 'text/event-stream', body: answer })
|
||||
})
|
||||
await page.getByTestId('chat-input').fill('hello')
|
||||
await page.getByTestId('chat-send').click()
|
||||
await expect.poll(() => body).not.toBeNull()
|
||||
return body
|
||||
}
|
||||
|
||||
const stream = (...deltas) => deltas.map(d => `data: ${JSON.stringify({ choices: [{ delta: d }] })}\n\n`).join('')
|
||||
+ `data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: 'stop' }] })}\n\ndata: [DONE]\n\n`
|
||||
|
||||
test.describe('Chat reasoning controls', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockChat(page)
|
||||
})
|
||||
|
||||
test('sends no reasoning_effort until a level is picked, then sends it', async ({ page }) => {
|
||||
await openChat(page, [chatOf('c1', 'R', 'qwen3-8b', pair('hi', 'hello'))])
|
||||
const chip = page.getByTestId('chat-effort-chip')
|
||||
await expect(chip).toContainText('Model default')
|
||||
let body = await sendWith(page, stream({ content: 'ok' }))
|
||||
expect(body.reasoning_effort).toBeUndefined()
|
||||
|
||||
await chip.click()
|
||||
await page.getByTestId('chat-effort-high').click()
|
||||
await expect(chip).toContainText('High')
|
||||
await expect.poll(async () => (await storedChats(page)).chats[0].reasoningEffort).toBe('high')
|
||||
await page.unroute('**/v1/chat/completions')
|
||||
body = await sendWith(page, stream({ content: 'ok' }))
|
||||
expect(body.reasoning_effort).toBe('high')
|
||||
})
|
||||
|
||||
test('the toggle asks for "none" and hides reasoning the model still sends', async ({ page }) => {
|
||||
await openChat(page, [chatOf('c1', 'R', 'qwen3-8b', pair('hi', 'hello'))])
|
||||
const toggle = page.getByTestId('chat-reasoning-off')
|
||||
await toggle.click()
|
||||
await expect(toggle).toHaveAttribute('aria-pressed', 'true')
|
||||
await expect(page.getByTestId('chat-effort-chip')).toBeDisabled()
|
||||
const body = await sendWith(page, stream({ reasoning: 'secret plan' }, { content: 'The answer.' }))
|
||||
expect(body.reasoning_effort).toBe('none')
|
||||
await expect(page.getByText('The answer.')).toBeVisible()
|
||||
await expect(page.getByText('secret plan')).toHaveCount(0)
|
||||
const history = (await storedChats(page)).chats[0].history
|
||||
expect(history.some(m => m.role === 'thinking')).toBe(false)
|
||||
})
|
||||
|
||||
test('the slash actions toggle reasoning and open the effort select', async ({ page }) => {
|
||||
await openChat(page, [chatOf('c1', 'R', 'qwen3-8b', pair('hi', 'hello'))])
|
||||
await page.getByTestId('chat-input').fill('/reasoning')
|
||||
await page.getByTestId('chat-input').press('Enter')
|
||||
await expect(page.getByTestId('chat-reasoning-off')).toHaveAttribute('aria-pressed', 'true')
|
||||
await page.getByTestId('chat-input').fill('/reasoning')
|
||||
await page.getByTestId('chat-input').press('Enter')
|
||||
await expect(page.getByTestId('chat-reasoning-off')).toHaveAttribute('aria-pressed', 'false')
|
||||
await page.getByTestId('chat-input').fill('/effort')
|
||||
await page.getByTestId('chat-input').press('Enter')
|
||||
await expect(page.getByTestId('chat-effort-menu')).toBeVisible()
|
||||
await expect(page.getByTestId('chat-effort-default')).toBeFocused()
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(page.getByTestId('chat-effort-menu')).toHaveCount(0)
|
||||
await expect(page.getByTestId('chat-effort-chip')).toBeFocused()
|
||||
})
|
||||
})
|
||||
@@ -132,6 +132,22 @@
|
||||
"selectModelFirst": "Select a model first",
|
||||
"placeholderPick": "Pick a model to start"
|
||||
},
|
||||
"reasoning": {
|
||||
"effortLabel": "Effort",
|
||||
"effortTitle": "Reasoning effort for this chat",
|
||||
"default": "Model default",
|
||||
"level": {
|
||||
"none": "None",
|
||||
"minimal": "Minimal",
|
||||
"low": "Low",
|
||||
"medium": "Medium",
|
||||
"high": "High"
|
||||
},
|
||||
"offLabel": "No reasoning",
|
||||
"offTitle": "Turn reasoning off for this chat: ask the model for no reasoning and hide any reasoning it sends",
|
||||
"offToast": "Reasoning is off for this chat",
|
||||
"onToast": "Reasoning is on for this chat"
|
||||
},
|
||||
"deleteAllDialog": {
|
||||
"title": "Delete All Chats",
|
||||
"message": "Delete all chats? This cannot be undone.",
|
||||
@@ -213,6 +229,14 @@
|
||||
"label": "Conversations",
|
||||
"desc": "Open the list of your chats"
|
||||
},
|
||||
"effort": {
|
||||
"label": "Reasoning effort",
|
||||
"desc": "Choose how hard the model thinks"
|
||||
},
|
||||
"reasoning": {
|
||||
"label": "Toggle reasoning",
|
||||
"desc": "Turn reasoning off or on for this chat"
|
||||
},
|
||||
"assistant": {
|
||||
"label": "Manage mode",
|
||||
"desc": "Let this chat manage LocalAI"
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Icon from '../Icon'
|
||||
import { REASONING_EFFORTS } from './chatActions'
|
||||
|
||||
// The two reasoning controls in the Chat composer, both stored per chat:
|
||||
// the effort chip opens a menu. "Model default" sends no reasoning_effort.
|
||||
// the traces chip a toggle. On, the chat asks for reasoning_effort "none" and
|
||||
// the thread drops any reasoning the model still sends.
|
||||
// The ref gives the page open() for the /effort action.
|
||||
const ReasoningChips = forwardRef(function ReasoningChips({ effort, off, onEffort, onOff }, ref) {
|
||||
const { t } = useTranslation('chat')
|
||||
const [open, setOpen] = useState(false)
|
||||
const rootRef = useRef(null)
|
||||
const chipRef = useRef(null)
|
||||
const menuRef = useRef(null)
|
||||
const choices = ['', ...REASONING_EFFORTS]
|
||||
const current = off ? 'none' : (effort || '')
|
||||
const nameOf = (v) => (v ? t(`reasoning.level.${v}`) : t('reasoning.default'))
|
||||
|
||||
const close = useCallback((restoreFocus = false) => {
|
||||
setOpen(false)
|
||||
if (restoreFocus) chipRef.current?.focus()
|
||||
}, [])
|
||||
const openMenu = useCallback(() => { if (!off) setOpen(true) }, [off])
|
||||
useImperativeHandle(ref, () => ({ open: openMenu }), [openMenu])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined
|
||||
const onDown = (e) => { if (rootRef.current && !rootRef.current.contains(e.target)) close() }
|
||||
document.addEventListener('mousedown', onDown)
|
||||
return () => document.removeEventListener('mousedown', onDown)
|
||||
}, [open, close])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const items = menuRef.current?.querySelectorAll('[role="menuitemradio"]')
|
||||
items?.[Math.max(0, choices.indexOf(current))]?.focus()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open])
|
||||
|
||||
const pick = (v) => { onEffort(v); close(true) }
|
||||
|
||||
const onMenuKey = (e) => {
|
||||
const items = Array.from(menuRef.current?.querySelectorAll('[role="menuitemradio"]') || [])
|
||||
const at = items.indexOf(document.activeElement)
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); items[(at + 1) % items.length]?.focus() }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); items[(at + items.length - 1) % items.length]?.focus() }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(true) }
|
||||
else if (e.key === 'Tab') close()
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="cx-chipset">
|
||||
<span className="home-pick" ref={rootRef}>
|
||||
<button
|
||||
ref={chipRef}
|
||||
type="button"
|
||||
className="home-chip"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
disabled={off}
|
||||
title={t('reasoning.effortTitle')}
|
||||
onClick={() => (open ? close() : openMenu())}
|
||||
data-testid="chat-effort-chip"
|
||||
>
|
||||
<Icon name="brain" />
|
||||
<span className="home-chip__text">{t('reasoning.effortLabel')}: {nameOf(current)}</span>
|
||||
<Icon name="chevron-down" className="home-chip__caret" />
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="home-menu cx-effort-menu"
|
||||
role="menu"
|
||||
aria-label={t('reasoning.effortTitle')}
|
||||
onKeyDown={onMenuKey}
|
||||
data-testid="chat-effort-menu"
|
||||
>
|
||||
{choices.map(v => (
|
||||
<button
|
||||
key={v || 'default'}
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={v === current}
|
||||
className="home-menu__item cx-effort-item"
|
||||
onClick={() => pick(v)}
|
||||
data-testid={`chat-effort-${v || 'default'}`}
|
||||
>
|
||||
<span>{nameOf(v)}</span>
|
||||
{v === current && <Icon name="check" className="home-menu__check" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="home-chip cx-chip-toggle"
|
||||
aria-pressed={!!off}
|
||||
onClick={() => { close(); onOff(!off) }}
|
||||
title={t('reasoning.offTitle')}
|
||||
data-testid="chat-reasoning-off"
|
||||
>
|
||||
<Icon name="eye-off" />
|
||||
<span className="home-chip__text">{t('reasoning.offLabel')}</span>
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
})
|
||||
|
||||
export default ReasoningChips
|
||||
@@ -4,11 +4,17 @@
|
||||
|
||||
export const CHAT_SLASH_GROUPS = ['chat', 'view']
|
||||
|
||||
// The reasoning_effort values the API accepts. An empty choice sends none.
|
||||
export const REASONING_EFFORTS = ['none', 'minimal', 'low', 'medium', 'high']
|
||||
|
||||
export const CHAT_SLASH_ACTIONS = [
|
||||
{ id: 'model', cmd: '/model', icon: 'cube', group: 'chat', needs: 'models' },
|
||||
{ id: 'new', cmd: '/new', icon: 'plus', group: 'chat' },
|
||||
{ id: 'chats', cmd: '/chats', icon: 'history', group: 'chat' },
|
||||
{ id: 'assistant', cmd: '/assistant', icon: 'user-shield', group: 'chat', needs: 'admin' },
|
||||
// /reasoning comes first so that Enter on "/reasoning" picks it, not "Reasoning effort".
|
||||
{ id: 'reasoning', cmd: '/reasoning', icon: 'eye-off', group: 'chat' },
|
||||
{ id: 'effort', cmd: '/effort', icon: 'brain', group: 'chat' },
|
||||
{ id: 'canvas', cmd: '/canvas', icon: 'columns', group: 'view' },
|
||||
{ id: 'find', cmd: '/find', icon: 'search', group: 'view', needs: 'thread' },
|
||||
{ id: 'settings', cmd: '/settings', icon: 'sliders', group: 'view' },
|
||||
|
||||
+18
-7
@@ -128,6 +128,8 @@ function saveChats(chats, activeChatId) {
|
||||
temperature: chat.temperature,
|
||||
topP: chat.topP,
|
||||
topK: chat.topK,
|
||||
reasoningEffort: chat.reasoningEffort,
|
||||
reasoningOff: chat.reasoningOff,
|
||||
tokenUsage: chat.tokenUsage,
|
||||
contextSize: chat.contextSize,
|
||||
createdAt: chat.createdAt,
|
||||
@@ -161,6 +163,10 @@ function createNewChat(model = '', systemPrompt = '', mcpMode = false) {
|
||||
temperature: null,
|
||||
topP: null,
|
||||
topK: null,
|
||||
// '' leaves reasoning_effort to the model config. reasoningOff asks for
|
||||
// 'none' and hides reasoning traces in the thread.
|
||||
reasoningEffort: '',
|
||||
reasoningOff: false,
|
||||
tokenUsage: { prompt: 0, completion: 0, total: 0 },
|
||||
contextSize: null,
|
||||
createdAt: Date.now(),
|
||||
@@ -282,6 +288,8 @@ export function useChat(initialModel = '') {
|
||||
const temperature = activeChat.temperature
|
||||
const topP = activeChat.topP
|
||||
const topK = activeChat.topK
|
||||
const hideReasoning = !!activeChat.reasoningOff
|
||||
const showReasoning = (text) => { if (!hideReasoning) setStreamingReasoning(text) }
|
||||
|
||||
// Build user message content
|
||||
let messageContent
|
||||
@@ -374,6 +382,9 @@ export function useChat(initialModel = '') {
|
||||
if (temperature !== null && temperature !== undefined) requestBody.temperature = temperature
|
||||
if (topP !== null && topP !== undefined) requestBody.top_p = topP
|
||||
if (topK !== null && topK !== undefined) requestBody.top_k = topK
|
||||
// reasoning_effort is honoured per request. 'none' also turns thinking off.
|
||||
const reasoningEffort = hideReasoning ? 'none' : activeChat.reasoningEffort
|
||||
if (reasoningEffort) requestBody.reasoning_effort = reasoningEffort
|
||||
// contextSize is the model's input+output window, not an
|
||||
// output cap. Backends bound generation at remaining context
|
||||
// automatically; Anthropic translate mode supplies its own
|
||||
@@ -473,7 +484,7 @@ export function useChat(initialModel = '') {
|
||||
if (eventData.content) {
|
||||
reasoningContent += eventData.content
|
||||
tokenCountRef.current += Math.ceil(eventData.content.length / 4)
|
||||
setStreamingReasoning(reasoningContent)
|
||||
showReasoning(reasoningContent)
|
||||
updateTps()
|
||||
}
|
||||
break
|
||||
@@ -518,7 +529,7 @@ export function useChat(initialModel = '') {
|
||||
const { regularContent, thinkingContent } = extractThinking(assistantContent)
|
||||
if (thinkingContent) {
|
||||
reasoningContent = thinkingContent
|
||||
setStreamingReasoning(reasoningContent)
|
||||
showReasoning(reasoningContent)
|
||||
}
|
||||
setStreamingContent(regularContent)
|
||||
} else {
|
||||
@@ -546,7 +557,7 @@ export function useChat(initialModel = '') {
|
||||
if (thinkingContent && !reasoningContent) reasoningContent = thinkingContent
|
||||
}
|
||||
|
||||
if (reasoningContent) {
|
||||
if (reasoningContent && !hideReasoning) {
|
||||
newMessages.unshift({ role: 'thinking', content: reasoningContent, expanded: true })
|
||||
}
|
||||
if (finalContent) {
|
||||
@@ -644,7 +655,7 @@ export function useChat(initialModel = '') {
|
||||
hasReasoningFromAPI = true
|
||||
reasoningContent += delta.reasoning
|
||||
tokenCountRef.current++
|
||||
setStreamingReasoning(reasoningContent)
|
||||
showReasoning(reasoningContent)
|
||||
updateTps()
|
||||
}
|
||||
|
||||
@@ -699,7 +710,7 @@ export function useChat(initialModel = '') {
|
||||
const lastOpen = Math.max(rawContent.lastIndexOf('<thinking>'), rawContent.lastIndexOf('<think>'), rawContent.lastIndexOf('<|channel>thought'))
|
||||
if (lastOpen >= 0) {
|
||||
const partial = rawContent.slice(lastOpen).replace(/<thinking>|<think>|<\|channel>thought/, '')
|
||||
setStreamingReasoning(partial)
|
||||
showReasoning(partial)
|
||||
const beforeThink = rawContent.slice(0, lastOpen)
|
||||
const { regularContent: contentBeforeThink } = extractThinking(beforeThink)
|
||||
setStreamingContent(contentBeforeThink)
|
||||
@@ -707,7 +718,7 @@ export function useChat(initialModel = '') {
|
||||
setStreamingContent(regularContent)
|
||||
}
|
||||
} else {
|
||||
setStreamingReasoning(reasoningContent)
|
||||
showReasoning(reasoningContent)
|
||||
setStreamingContent(regularContent)
|
||||
}
|
||||
} else {
|
||||
@@ -806,7 +817,7 @@ export function useChat(initialModel = '') {
|
||||
if (thinkingContent && !reasoningContent) reasoningContent = thinkingContent
|
||||
}
|
||||
|
||||
if (reasoningContent) {
|
||||
if (reasoningContent && !hideReasoning) {
|
||||
newMessages.push({ role: 'thinking', content: reasoningContent, expanded: true })
|
||||
}
|
||||
if (finalContent) {
|
||||
|
||||
@@ -29,6 +29,7 @@ import ChatHeader from '../components/chat/ChatHeader'
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import ShortcutsDialog from '../components/chat/ShortcutsDialog'
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import ReasoningChips from '../components/chat/ReasoningChips'
|
||||
import ChatSettingsSheet from '../components/chat/ChatSettingsSheet'
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import FindBar from '../components/chat/FindBar'
|
||||
@@ -190,6 +191,7 @@ export default function Chat() {
|
||||
const [scrolledUp, setScrolledUp] = useState(false)
|
||||
const chatsMenuRef = useRef(null)
|
||||
const pickerRef = useRef(null)
|
||||
const effortRef = useRef(null)
|
||||
const { models: chatModels, loading: chatModelsLoading } = useModels(CAP_CHAT)
|
||||
const [fitOpenCount, setFitOpenCount] = useState(0)
|
||||
const modelNames = useMemo(() => chatModels.map(m => m.id), [chatModels])
|
||||
@@ -1000,6 +1002,11 @@ export default function Chat() {
|
||||
/>
|
||||
)
|
||||
|
||||
const toggleReasoningOff = (reasoningOff) => {
|
||||
updateChatSettings(activeChat.id, { reasoningOff })
|
||||
addToast(t(reasoningOff ? 'reasoning.offToast' : 'reasoning.onToast'), 'info', 2000)
|
||||
}
|
||||
|
||||
const mcp = (
|
||||
<UnifiedMCPDropdown
|
||||
serverMCPAvailable={mcpAvailable}
|
||||
@@ -1039,7 +1046,15 @@ export default function Chat() {
|
||||
/>
|
||||
)
|
||||
|
||||
const canvasChip = (
|
||||
const composerChips = (
|
||||
<>
|
||||
<ReasoningChips
|
||||
ref={effortRef}
|
||||
effort={activeChat.reasoningEffort}
|
||||
off={activeChat.reasoningOff}
|
||||
onEffort={(reasoningEffort) => updateChatSettings(activeChat.id, { reasoningEffort })}
|
||||
onOff={toggleReasoningOff}
|
||||
/>
|
||||
<span className="cx-chipset">
|
||||
<button
|
||||
type="button"
|
||||
@@ -1064,6 +1079,7 @@ export default function Chat() {
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
|
||||
const moreItems = [
|
||||
@@ -1082,6 +1098,11 @@ export default function Chat() {
|
||||
case 'model': pickerRef.current?.open(); break
|
||||
case 'new': addChat(activeChat.model); break
|
||||
case 'chats': chatsMenuRef.current?.open(); break
|
||||
case 'effort': {
|
||||
effortRef.current?.open()
|
||||
break
|
||||
}
|
||||
case 'reasoning': toggleReasoningOff(!activeChat.reasoningOff); break
|
||||
case 'assistant': updateChatSettings(activeChat.id, { localaiAssistant: !activeChat.localaiAssistant }); break
|
||||
case 'canvas': toggleCanvasMode(); break
|
||||
case 'settings': setShowSettings(true); break
|
||||
@@ -1212,7 +1233,7 @@ export default function Chat() {
|
||||
textareaRef={textareaRef}
|
||||
picker={picker}
|
||||
mcp={mcp}
|
||||
chips={canvasChip}
|
||||
chips={composerChips}
|
||||
files={files}
|
||||
onRemoveFile={(f) => setFiles(prev => prev.filter(x => x !== f))}
|
||||
onAttach={attachFiles}
|
||||
|
||||
@@ -964,3 +964,12 @@
|
||||
.cx-dock .home-menu__list { max-height: min(60vh, 420px); }
|
||||
}
|
||||
.cx-thread[hidden] { display: none; }
|
||||
|
||||
/* Reasoning effort menu: a short list of buttons in the shared menu frame. */
|
||||
.cx-effort-menu { min-width: 180px; }
|
||||
.cx-effort-item { width: 100%; border: 0; background: none; font: inherit; text-align: left; cursor: pointer; }
|
||||
.cx-effort-item:hover,
|
||||
.cx-effort-item:focus-visible { background: var(--dk-hover); color: var(--dk-text); outline: none; }
|
||||
@media (max-width: 640px) {
|
||||
.cx-chipset .home-chip__text { max-width: 22ch; }
|
||||
}
|
||||
Reference in new issue
Block a user