)
diff --git a/apps/desktop/src/app/chat/composer/help-hint.tsx b/apps/desktop/src/app/chat/composer/help-hint.tsx
index d981aca86f5..09084464e80 100644
--- a/apps/desktop/src/app/chat/composer/help-hint.tsx
+++ b/apps/desktop/src/app/chat/composer/help-hint.tsx
@@ -1,4 +1,6 @@
-import { COMPLETION_DRAWER_CLASS } from './constants'
+import type { ReactNode } from 'react'
+
+import { COMPLETION_DRAWER_CLASS } from './completion-drawer'
const COMMON_COMMANDS: [string, string][] = [
['/help', 'full list of commands + hotkeys'],
@@ -42,7 +44,7 @@ export function HelpHint() {
)
}
-function Section({ children, title }: { children: React.ReactNode; title: string }) {
+function Section({ children, title }: { children: ReactNode; title: string }) {
return (
diff --git a/apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts b/apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts
index fc9eab49554..fbeca7d59ee 100644
--- a/apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts
+++ b/apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts
@@ -12,16 +12,8 @@ export interface CompletionPayload {
query: string
}
-/**
- * Drives an assistant-ui `Unstable_TriggerAdapter` from an async RPC call.
- *
- * Mirrors the TUI's `useCompletion` flow: each query change schedules a
- * debounced fetch (default 60ms) and the adapter synchronously returns the
- * most recent items while the user keeps typing. When the fetch resolves we
- * store the new items + the query they belong to, which causes a re-render
- * with a fresh adapter instance — `Unstable_TriggerPopover` then re-runs its
- * `search()` and shows the latest results.
- */
+const EMPTY_QUERY = '\u0000'
+
export function useLiveCompletionAdapter(options: {
enabled: boolean
debounceMs?: number
@@ -31,7 +23,7 @@ export function useLiveCompletionAdapter(options: {
const { enabled, debounceMs = 60, fetcher, toItem } = options
const [state, setState] = useState<{ query: string; items: Unstable_TriggerItem[] }>({
- query: '\u0000',
+ query: EMPTY_QUERY,
items: []
})
@@ -50,6 +42,18 @@ export function useLiveCompletionAdapter(options: {
useEffect(() => () => cancelTimer(), [cancelTimer])
+ useEffect(() => {
+ if (enabled) {
+ return
+ }
+
+ cancelTimer()
+ pendingQueryRef.current = null
+ tokenRef.current += 1
+ setLoading(false)
+ setState({ query: EMPTY_QUERY, items: [] })
+ }, [cancelTimer, enabled])
+
const scheduleFetch = useCallback(
(query: string) => {
if (!enabled) {
diff --git a/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.ts b/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.ts
index 247d31f8671..3be83e96fbb 100644
--- a/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.ts
+++ b/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.ts
@@ -1,7 +1,5 @@
import { useEffect, useRef, useState } from 'react'
-import { VOICE_MIME_TYPES } from '../constants'
-
type BrowserAudioContext = typeof AudioContext
export interface MicRecorderOptions {
@@ -25,14 +23,6 @@ interface MicRecorderHandle {
cancel: () => void
}
-function preferredVoiceMimeType(): string {
- if (typeof MediaRecorder === 'undefined') {
- return ''
- }
-
- return VOICE_MIME_TYPES.find(type => MediaRecorder.isTypeSupported(type)) || ''
-}
-
function micError(error: unknown): Error {
const name = error instanceof DOMException ? error.name : ''
@@ -187,7 +177,10 @@ export function useMicRecorder(): { handle: MicRecorderHandle; level: number; re
throw micError(error)
}
- const mimeType = preferredVoiceMimeType()
+ const mimeType =
+ ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg;codecs=opus', 'audio/ogg', 'audio/wav'].find(
+ type => MediaRecorder.isTypeSupported(type)
+ ) ?? ''
let recorder: MediaRecorder
try {
diff --git a/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts
index c445246393c..dd2231cbecf 100644
--- a/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts
+++ b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts
@@ -3,13 +3,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
import { notify, notifyError } from '@/store/notifications'
-import {
- CONVERSATION_IDLE_SILENCE_MS,
- CONVERSATION_MAX_TURN_SECONDS,
- CONVERSATION_POST_SPEECH_SILENCE_MS,
- CONVERSATION_SPEECH_LEVEL
-} from '../constants'
-
import { useMicRecorder } from './use-mic-recorder'
export type ConversationStatus = 'idle' | 'listening' | 'transcribing' | 'thinking' | 'speaking'
@@ -195,10 +188,11 @@ export function useVoiceConversation({
}
try {
+ // VAD tuning mirrors `tools.voice_mode` defaults so the browser loop matches the CLI.
await handle.start({
- silenceLevel: CONVERSATION_SPEECH_LEVEL,
- silenceMs: CONVERSATION_POST_SPEECH_SILENCE_MS,
- idleSilenceMs: CONVERSATION_IDLE_SILENCE_MS,
+ silenceLevel: 0.075,
+ silenceMs: 1_250,
+ idleSilenceMs: 12_000,
onError: error => {
notifyError(error, 'Microphone failed')
pendingStartRef.current = false
@@ -207,10 +201,7 @@ export function useVoiceConversation({
onSilence: () => void handleTurn()
})
setStatus('listening')
- turnTimeoutRef.current = window.setTimeout(
- () => void handleTurn(),
- CONVERSATION_MAX_TURN_SECONDS * 1000
- )
+ turnTimeoutRef.current = window.setTimeout(() => void handleTurn(), 60_000)
} catch (error) {
notifyError(error, 'Could not start voice session')
pendingStartRef.current = false
diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx
index 21062ff0eb3..a239f3d52e8 100644
--- a/apps/desktop/src/app/chat/composer/index.tsx
+++ b/apps/desktop/src/app/chat/composer/index.tsx
@@ -4,6 +4,7 @@ import LiquidGlass from 'liquid-glass-react'
import { type ClipboardEvent, type CSSProperties, useEffect, useRef, useState } from 'react'
import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
+import { useMediaQuery } from '@/hooks/use-media-query'
import { chatMessageText } from '@/lib/chat-messages'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
@@ -12,16 +13,6 @@ import { $messages } from '@/store/session'
import { $threadScrolledUp } from '@/store/thread-scroll'
import { AttachmentList } from './attachments'
-import {
- ASK_PLACEHOLDERS,
- COMPOSER_BACKDROP_STYLE,
- DEFAULT_MAX_RECORDING_SECONDS,
- EDGE_NEWLINES_RE,
- EXPAND_HEIGHT_PX,
- NARROW_VIEWPORT,
- SHELL,
- STACK_AT
-} from './constants'
import { ContextMenu } from './context-menu'
import { ComposerControls } from './controls'
import { DirectivePopover } from './directive-popover'
@@ -36,9 +27,13 @@ import type { ChatBarProps } from './types'
import { UrlDialog } from './url-dialog'
import { VoiceActivity, VoicePlaybackActivity } from './voice-activity'
-function trimPastedEdgeNewlines(text: string): string {
- return text.replace(EDGE_NEWLINES_RE, '')
-}
+const SHELL =
+ 'absolute bottom-0 left-1/2 z-30 w-[min(calc(100%_-_1rem),clamp(26rem,61.8%,56rem))] max-w-full -translate-x-1/2'
+
+const COMPOSER_BACKDROP_STYLE = {
+ backdropFilter: 'blur(0.75rem) saturate(1.12)',
+ WebkitBackdropFilter: 'blur(0.75rem) saturate(1.12)'
+} satisfies CSSProperties
export function ChatBar({
busy,
@@ -46,7 +41,7 @@ export function ChatBar({
disabled,
focusKey,
gateway,
- maxRecordingSeconds = DEFAULT_MAX_RECORDING_SECONDS,
+ maxRecordingSeconds = 120,
sessionId,
state,
onCancel,
@@ -74,17 +69,29 @@ export function ChatBar({
const [urlValue, setUrlValue] = useState('')
const [expanded, setExpanded] = useState(false)
const [voiceConversationActive, setVoiceConversationActive] = useState(false)
- const [stack, setStack] = useState(false)
+ const [tight, setTight] = useState(false)
const lastSpokenIdRef = useRef(null)
- const [askPlaceholder] = useState(
- () => ASK_PLACEHOLDERS[Math.floor(Math.random() * ASK_PLACEHOLDERS.length)] || 'Ask anything'
- )
+ const narrow = useMediaQuery('(max-width: 680px)')
+
+ const [askPlaceholder] = useState(() => {
+ const lines = [
+ 'Hey friend, what can I help with?',
+ "What's on your mind? I'm here with you.",
+ 'Need a hand? We can take it one step at a time.',
+ 'Want to walk through this bug together?',
+ "Share what you're working on and we'll figure it out.",
+ "Tell me where you're stuck and I'll stay with you.",
+ 'Duck mode: gentle debugging, together.'
+ ]
+
+ return lines[Math.floor(Math.random() * lines.length)] ?? 'Ask anything'
+ })
const at = useAtCompletions({ gateway: gateway ?? null, sessionId: sessionId ?? null, cwd: cwd ?? null })
const slash = useSlashCompletions({ gateway: gateway ?? null })
- const stacked = expanded || stack
+ const stacked = expanded || narrow || tight
const hasComposerPayload = draft.trim().length > 0 || attachments.length > 0
const canSubmit = busy || hasComposerPayload
const showHelpHint = draft === '?'
@@ -120,7 +127,7 @@ export function ChatBar({
return
}
- const wraps = (textareaRef.current?.scrollHeight ?? 0) > EXPAND_HEIGHT_PX
+ const wraps = (textareaRef.current?.scrollHeight ?? 0) > 42
if (draft.includes('\n') || wraps) {
setExpanded(true)
@@ -128,26 +135,19 @@ export function ChatBar({
}, [draft, expanded])
useEffect(() => {
- const mq = window.matchMedia(NARROW_VIEWPORT)
+ const el = composerRef.current
- const update = () => {
- const w = composerRef.current?.getBoundingClientRect().width ?? window.innerWidth
-
- setStack(mq.matches || w < STACK_AT)
+ if (!el) {
+ return
}
+ const update = () => setTight(el.getBoundingClientRect().width < 500)
+
update()
- mq.addEventListener('change', update)
const ro = new ResizeObserver(update)
+ ro.observe(el)
- if (composerRef.current) {
- ro.observe(composerRef.current)
- }
-
- return () => {
- mq.removeEventListener('change', update)
- ro.disconnect()
- }
+ return () => ro.disconnect()
}, [])
const insertText = (text: string) => {
@@ -167,7 +167,7 @@ export function ChatBar({
return
}
- const trimmedText = trimPastedEdgeNewlines(pastedText)
+ const trimmedText = pastedText.replace(/^[\t ]*(?:\r\n|\r|\n)+|(?:\r\n|\r|\n)+[\t ]*$/g, '')
if (trimmedText === pastedText) {
return
@@ -344,7 +344,7 @@ export function ChatBar({
<>
-
-
- {attachments.length > 0 &&
}
- {stacked ? (
- <>
- {input}
-
+
+
+
+
+ {attachments.length > 0 && (
+
+ )}
+ {stacked ? (
+ <>
+ {input}
+
+ {contextMenu}
+ {controls}
+
+ >
+ ) : (
+
{contextMenu}
+ {input}
{controls}
- >
- ) : (
-
- {contextMenu}
- {input}
- {controls}
-
- )}
+ )}
+
@@ -456,10 +470,19 @@ export function ChatBar({
export function ChatBarFallback() {
return (
-
-
-
-
+
)
diff --git a/apps/desktop/src/app/chat/composer/voice-activity.tsx b/apps/desktop/src/app/chat/composer/voice-activity.tsx
index 59f090c91c0..842a4f35a53 100644
--- a/apps/desktop/src/app/chat/composer/voice-activity.tsx
+++ b/apps/desktop/src/app/chat/composer/voice-activity.tsx
@@ -1,7 +1,6 @@
-import { type AudioDataProvider, AudioWave, useCustomAudio } from '@audiowave/react'
import { useStore } from '@nanostores/react'
import { Loader2, Mic, Volume2, VolumeX } from 'lucide-react'
-import { useMemo } from 'react'
+import { useEffect, useRef } from 'react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
@@ -51,82 +50,101 @@ function VoiceLevelBars({ level, active }: { active: boolean; level: number }) {
)
}
+function getElementAnalyser(audioElement: HTMLAudioElement): ElementAnalyser | null {
+ const audioWindow = window as Window & { webkitAudioContext?: BrowserAudioContext }
+ const AudioContextCtor = window.AudioContext || audioWindow.webkitAudioContext
+
+ if (!AudioContextCtor) {
+ return null
+ }
+
+ let entry = elementAnalysers.get(audioElement)
+
+ if (!entry || entry.context.state === 'closed') {
+ const context = new AudioContextCtor()
+ const source = context.createMediaElementSource(audioElement)
+ const analyser = context.createAnalyser()
+
+ analyser.fftSize = 512
+ analyser.smoothingTimeConstant = 0.65
+ source.connect(analyser)
+ analyser.connect(context.destination)
+ entry = { analyser, context }
+ elementAnalysers.set(audioElement, entry)
+ }
+
+ void entry.context.resume()
+
+ return entry
+}
+
+const WAVE_W = 88
+const WAVE_H = 16
+const BAR_W = 2
+const BAR_GAP = 5
+const STEP = BAR_W + BAR_GAP
+const BARS = Math.floor((WAVE_W + BAR_GAP) / STEP)
+const X0 = Math.round((WAVE_W - (BARS * STEP - BAR_GAP)) / 2)
+
function PlaybackWaveform({ audioElement }: { audioElement: HTMLAudioElement | null }) {
- const provider = useMemo
(
- () => ({
- onAudioData(callback) {
- if (!audioElement) {
- return () => undefined
+ const canvasRef = useRef(null)
+
+ useEffect(() => {
+ const canvas = canvasRef.current
+
+ if (!canvas || !audioElement) {
+ return
+ }
+
+ const entry = getElementAnalyser(audioElement)
+ const ctx = canvas.getContext('2d')
+
+ if (!entry || !ctx) {
+ return
+ }
+
+ const dpr = Math.max(1, window.devicePixelRatio || 1)
+ const { analyser } = entry
+ const buf = new Uint8Array(analyser.frequencyBinCount)
+ const hi = Math.floor(buf.length * 0.9)
+
+ canvas.width = Math.round(WAVE_W * dpr)
+ canvas.height = Math.round(WAVE_H * dpr)
+ canvas.style.width = `${WAVE_W}px`
+ canvas.style.height = `${WAVE_H}px`
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
+ ctx.imageSmoothingEnabled = false
+ ctx.fillStyle = getComputedStyle(canvas).color
+
+ let raf = 0
+
+ const tick = () => {
+ analyser.getByteFrequencyData(buf)
+ ctx.clearRect(0, 0, WAVE_W, WAVE_H)
+
+ for (let i = 0; i < BARS; i++) {
+ const a = Math.floor((i / BARS) * hi)
+ const b = Math.floor(((i + 1) / BARS) * hi)
+ let peak = 0
+
+ for (let j = a; j < b; j++) {
+ peak = Math.max(peak, buf[j] ?? 0)
}
- const audioWindow = window as Window & { webkitAudioContext?: BrowserAudioContext }
- const AudioContextCtor = window.AudioContext || audioWindow.webkitAudioContext
-
- if (!AudioContextCtor) {
- return () => undefined
- }
-
- let entry = elementAnalysers.get(audioElement)
-
- if (!entry || entry.context.state === 'closed') {
- const context = new AudioContextCtor()
- const source = context.createMediaElementSource(audioElement)
- const analyser = context.createAnalyser()
-
- analyser.fftSize = 256
- analyser.smoothingTimeConstant = 0.72
- source.connect(analyser)
- analyser.connect(context.destination)
- entry = { analyser, context }
- elementAnalysers.set(audioElement, entry)
- }
-
- void entry.context.resume()
-
- const data = new Uint8Array(entry.analyser.fftSize)
- let raf = 0
-
- const tick = () => {
- entry.analyser.getByteTimeDomainData(data)
- callback(new Uint8Array(data))
- raf = window.requestAnimationFrame(tick)
- }
-
- tick()
-
- return () => window.cancelAnimationFrame(raf)
+ const amp = Math.sqrt(peak / 255)
+ const bh = Math.max(3, Math.round((0.18 + amp * 0.82) * WAVE_H))
+ ctx.fillRect(X0 + i * STEP, Math.round((WAVE_H - bh) / 2), BAR_W, bh)
}
- }),
- [audioElement]
- )
- const { source } = useCustomAudio({
- provider,
- status: audioElement ? 'active' : 'idle'
- })
+ raf = requestAnimationFrame(tick)
+ }
- return (
-
- {source ? (
-
- ) : null}
-
- )
+ tick()
+
+ return () => cancelAnimationFrame(raf)
+ }, [audioElement])
+
+ return
}
export function VoiceActivity({
diff --git a/apps/desktop/src/components/assistant-ui/thread.tsx b/apps/desktop/src/components/assistant-ui/thread.tsx
index 33632ef0f0f..b9a0b99a3e9 100644
--- a/apps/desktop/src/components/assistant-ui/thread.tsx
+++ b/apps/desktop/src/components/assistant-ui/thread.tsx
@@ -258,7 +258,7 @@ export const Thread: FC<{
{loading === 'response' && }
{loading === 'working' && }
-
+
{loading === 'session' &&
}
diff --git a/apps/desktop/src/hooks/use-media-query.ts b/apps/desktop/src/hooks/use-media-query.ts
new file mode 100644
index 00000000000..aa368dfb931
--- /dev/null
+++ b/apps/desktop/src/hooks/use-media-query.ts
@@ -0,0 +1,24 @@
+import { useEffect, useState } from 'react'
+
+export const matchesQuery = (query: string) =>
+ typeof window !== 'undefined' && !!window.matchMedia && window.matchMedia(query).matches
+
+export function useMediaQuery(query: string): boolean {
+ const [matches, setMatches] = useState(() => matchesQuery(query))
+
+ useEffect(() => {
+ if (typeof window === 'undefined' || !window.matchMedia) {
+ return
+ }
+
+ const mql = window.matchMedia(query)
+ const onChange = () => setMatches(mql.matches)
+
+ setMatches(mql.matches)
+ mql.addEventListener('change', onChange)
+
+ return () => mql.removeEventListener('change', onChange)
+ }, [query])
+
+ return matches
+}
diff --git a/apps/desktop/src/hooks/use-mobile.ts b/apps/desktop/src/hooks/use-mobile.ts
index 779648efb62..9beed4a9ab6 100644
--- a/apps/desktop/src/hooks/use-mobile.ts
+++ b/apps/desktop/src/hooks/use-mobile.ts
@@ -1,24 +1,3 @@
-import * as React from 'react'
+import { useMediaQuery } from './use-media-query'
-const MOBILE_BREAKPOINT = 768
-const MOBILE_BREAKPOINT_REM = MOBILE_BREAKPOINT / 16
-const ONE_PIXEL_IN_REM = 1 / 16
-
-export function useIsMobile() {
- const [isMobile, setIsMobile] = React.useState
(undefined)
-
- React.useEffect(() => {
- const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT_REM - ONE_PIXEL_IN_REM}rem)`)
-
- const onChange = () => {
- setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
- }
-
- mql.addEventListener('change', onChange)
- setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
-
- return () => mql.removeEventListener('change', onChange)
- }, [])
-
- return !!isMobile
-}
+export const useIsMobile = () => useMediaQuery(`(max-width: ${768 / 16 - 1 / 16}rem)`)
diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css
index f26156e949f..ce9f6ed45ae 100644
--- a/apps/desktop/src/styles.css
+++ b/apps/desktop/src/styles.css
@@ -68,13 +68,12 @@
0 1.25rem 2rem -0.875rem color-mix(in srgb, var(--dt-background) 82%, transparent),
0 2rem 3rem -1.5rem color-mix(in srgb, var(--dt-background) 55%, transparent);
--shadow-composer:
- 0 0 0 0.0625rem color-mix(in srgb, var(--shadow-ink) 6%, transparent),
- 0 0.25rem 0.5rem color-mix(in srgb, var(--shadow-ink) 5%, transparent),
- 0 0.75rem 2rem color-mix(in srgb, var(--shadow-ink) 8%, transparent);
+ 0 0 0 0.0625rem color-mix(in srgb, var(--shadow-ink) 4%, transparent),
+ 0 0.0625rem 0.25rem color-mix(in srgb, var(--shadow-ink) 3%, transparent);
--shadow-composer-focus:
- 0 0 0 0.1875rem color-mix(in srgb, var(--dt-ring) 18%, transparent),
- 0 0 0 0.0625rem color-mix(in srgb, var(--dt-ring) 35%, transparent),
- 0 0.5rem 1.5rem color-mix(in srgb, var(--shadow-ink) 6%, transparent);
+ 0 0 0 0.125rem color-mix(in srgb, var(--dt-ring) 14%, transparent),
+ 0 0 0 0.0625rem color-mix(in srgb, var(--dt-ring) 26%, transparent),
+ 0 0.1875rem 0.625rem color-mix(in srgb, var(--shadow-ink) 4%, transparent);
--shadow-user-message:
0 0.0625rem 0.125rem color-mix(in srgb, var(--shadow-ink) 6%, transparent),
0 0.25rem 0.75rem color-mix(in srgb, var(--shadow-ink) 4%, transparent);
@@ -117,6 +116,10 @@
--dt-spacing-mul: 1;
--radius: 0.75rem;
+ /* Thread ViewportFooter — gap from last msg → composer (scroll only) */
+ --thread-composer-clearance: 8rem;
+ /* Composer shell — gap under bar to chat pane bottom */
+ --composer-shell-pad-block-end: 2.5rem;
--vsq: min(0.5vh, 0.5vw);
--image-preview-max-width: 34rem;
--image-preview-height: clamp(16.25rem, calc(var(--vsq) * 100), 26.25rem);
@@ -177,7 +180,6 @@
textarea {
font: inherit;
}
-
}
button {
@@ -259,6 +261,43 @@ button {
box-shadow: none !important;
}
+.composer-completion-drawer {
+ position: absolute;
+ inset-inline: 0;
+ bottom: calc(100% - 0.5rem);
+ z-index: 50;
+ max-height: min(23rem, calc(100vh - 8rem));
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ border: 0.0625rem solid color-mix(in srgb, var(--dt-ring) 45%, transparent);
+ border-bottom: 0;
+ border-top-left-radius: var(--composer-active-radius);
+ border-top-right-radius: var(--composer-active-radius);
+ background: color-mix(in srgb, var(--dt-popover) 96%, transparent);
+ color: var(--dt-popover-foreground);
+ padding: 0.375rem 0.375rem 0.75rem;
+ backdrop-filter: blur(0.75rem) saturate(1.1);
+ -webkit-backdrop-filter: blur(0.75rem) saturate(1.1);
+}
+
+.composer-completion-row {
+ display: flex;
+ width: 100%;
+ min-width: 0;
+ align-items: baseline;
+ gap: 0.5rem;
+ border-radius: 0.375rem;
+ padding: 0.25rem 0.625rem;
+ text-align: left;
+ font-size: 0.75rem;
+ transition: background-color 120ms ease;
+}
+
+.composer-completion-row:hover,
+.composer-completion-row[data-highlighted] {
+ background: color-mix(in srgb, var(--dt-accent) 70%, transparent);
+}
+
[data-slot='composer-root']:has([data-slot='composer-completion-drawer'][data-state='open'])
[data-slot='composer-surface'] {
border-top-left-radius: 0 !important;
@@ -269,8 +308,7 @@ button {
0 0.5rem 1.5rem color-mix(in srgb, var(--shadow-ink) 6%, transparent);
}
-[data-slot='composer-root']:has([data-slot='composer-completion-drawer'][data-state='open'])
- [data-glass-frame='true'] {
+[data-slot='composer-root']:has([data-slot='composer-completion-drawer'][data-state='open']) [data-glass-frame='true'] {
border-top-left-radius: 0 !important;
border-top-right-radius: 0 !important;
}
diff --git a/apps/desktop/src/themes/context.tsx b/apps/desktop/src/themes/context.tsx
index 7b024569ac4..1bb56ada5ab 100644
--- a/apps/desktop/src/themes/context.tsx
+++ b/apps/desktop/src/themes/context.tsx
@@ -11,6 +11,8 @@
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react'
+import { matchesQuery, useMediaQuery } from '@/hooks/use-media-query'
+
import {
BUILTIN_THEME_LIST,
BUILTIN_THEMES,
@@ -36,15 +38,10 @@ const DENSITY_MULTIPLIERS: Record = {
const INJECTED_FONT_URLS = new Set()
const SKIN_THEME_LIST = BUILTIN_THEME_LIST.filter(t => t.name !== 'nous-light')
-function systemPrefersDark(): boolean {
- if (typeof window === 'undefined' || !window.matchMedia) {
- return false
- }
-
- return window.matchMedia('(prefers-color-scheme: dark)').matches
-}
-
-function effectiveMode(mode: ThemeMode, systemDark = systemPrefersDark()): 'light' | 'dark' {
+function effectiveMode(
+ mode: ThemeMode,
+ systemDark = matchesQuery('(prefers-color-scheme: dark)')
+): 'light' | 'dark' {
return mode === 'system' ? (systemDark ? 'dark' : 'light') : mode
}
@@ -313,20 +310,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
return (window.localStorage.getItem(MODE_KEY) as ThemeMode) ?? 'light'
})
- const [systemDark, setSystemDark] = useState(systemPrefersDark)
-
- useEffect(() => {
- if (typeof window === 'undefined' || !window.matchMedia) {
- return
- }
-
- const mql = window.matchMedia('(prefers-color-scheme: dark)')
- const listener = (e: MediaQueryListEvent) => setSystemDark(e.matches)
- mql.addEventListener('change', listener)
-
- return () => mql.removeEventListener('change', listener)
- }, [])
-
+ const systemDark = useMediaQuery('(prefers-color-scheme: dark)')
const resolvedMode = effectiveMode(mode, systemDark)
const activeTheme = useMemo(() => deriveTheme(themeName, resolvedMode), [themeName, resolvedMode])