From cd381d6ba5648c4b5b45fd96d6c456edea3116ff Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 1 May 2026 20:15:00 -0500 Subject: [PATCH] chore: uptick --- .../src/app/chat/composer/attachments.tsx | 6 +- .../app/chat/composer/completion-drawer.tsx | 6 +- .../src/app/chat/composer/constants.ts | 75 -------- .../src/app/chat/composer/context-menu.tsx | 8 +- .../src/app/chat/composer/controls.tsx | 4 +- .../app/chat/composer/directive-popover.tsx | 6 +- .../src/app/chat/composer/help-hint.tsx | 6 +- .../hooks/use-live-completion-adapter.ts | 26 +-- .../chat/composer/hooks/use-mic-recorder.ts | 15 +- .../composer/hooks/use-voice-conversation.ts | 19 +- apps/desktop/src/app/chat/composer/index.tsx | 147 +++++++++------- .../src/app/chat/composer/voice-activity.tsx | 162 ++++++++++-------- .../src/components/assistant-ui/thread.tsx | 2 +- apps/desktop/src/hooks/use-media-query.ts | 24 +++ apps/desktop/src/hooks/use-mobile.ts | 25 +-- apps/desktop/src/styles.css | 56 +++++- apps/desktop/src/themes/context.tsx | 30 +--- 17 files changed, 300 insertions(+), 317 deletions(-) delete mode 100644 apps/desktop/src/app/chat/composer/constants.ts create mode 100644 apps/desktop/src/hooks/use-media-query.ts diff --git a/apps/desktop/src/app/chat/composer/attachments.tsx b/apps/desktop/src/app/chat/composer/attachments.tsx index a1cae432269..101268a9f7a 100644 --- a/apps/desktop/src/app/chat/composer/attachments.tsx +++ b/apps/desktop/src/app/chat/composer/attachments.tsx @@ -1,9 +1,7 @@ -import { X } from 'lucide-react' +import { FileText, FolderOpen, ImageIcon, Link, X } from 'lucide-react' import type { ComposerAttachment } from '@/store/composer' -import { ATTACHMENT_ICON } from './constants' - export function AttachmentList({ attachments, onRemove @@ -21,7 +19,7 @@ export function AttachmentList({ } function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachment; onRemove?: (id: string) => void }) { - const Icon = ATTACHMENT_ICON[attachment.kind] + const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText }[attachment.kind] return (
diff --git a/apps/desktop/src/app/chat/composer/completion-drawer.tsx b/apps/desktop/src/app/chat/composer/completion-drawer.tsx index 7ca303a1987..215cdc00664 100644 --- a/apps/desktop/src/app/chat/composer/completion-drawer.tsx +++ b/apps/desktop/src/app/chat/composer/completion-drawer.tsx @@ -2,10 +2,8 @@ import type { Unstable_TriggerAdapter } from '@assistant-ui/core' import { ComposerPrimitive } from '@assistant-ui/react' import type { ReactNode } from 'react' -import { COMPLETION_DRAWER_CLASS } from './constants' - -export const COMPLETION_DRAWER_ROW_CLASS = - 'flex w-full min-w-0 items-baseline gap-2 rounded-md px-2.5 py-1 text-left text-xs transition-colors hover:bg-accent/70 data-highlighted:bg-accent' +export const COMPLETION_DRAWER_CLASS = 'composer-completion-drawer' +export const COMPLETION_DRAWER_ROW_CLASS = 'composer-completion-row' export function ComposerCompletionDrawer({ adapter, diff --git a/apps/desktop/src/app/chat/composer/constants.ts b/apps/desktop/src/app/chat/composer/constants.ts deleted file mode 100644 index aaae461b82d..00000000000 --- a/apps/desktop/src/app/chat/composer/constants.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { FileText, FolderOpen, ImageIcon, Link, type LucideIcon } from 'lucide-react' -import type { CSSProperties } from 'react' - -import { cn } from '@/lib/utils' -import type { ComposerAttachment } from '@/store/composer' - -export const STACK_AT = 500 -export const NARROW_VIEWPORT = '(max-width: 680px)' -export const EXPAND_HEIGHT_PX = 42 - -export 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' - -export const ICON_BTN = 'h-8 w-8 shrink-0 rounded-full' - -export const GHOST_ICON_BTN = cn(ICON_BTN, 'text-muted-foreground hover:bg-accent hover:text-foreground') - -export const COMPOSER_BACKDROP_STYLE = { - backdropFilter: 'blur(.5rem) saturate(1.18)', - WebkitBackdropFilter: 'blur(.5rem) saturate(1.18)' -} satisfies CSSProperties - -export const ATTACHMENT_ICON: Record = { - folder: FolderOpen, - url: Link, - image: ImageIcon, - file: FileText -} - -export const COMPLETION_DRAWER_CLASS = - 'absolute inset-x-0 bottom-[calc(100%-0.0rem)] z-50 max-h-[min(23rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain rounded-t-(--composer-active-radius) rounded-b-none border-x border-t border-b-0 border-ring/45 bg-popover/96 p-1.5 pb-3 text-popover-foreground shadow-[0_-1rem_2.25rem_-1.75rem_color-mix(in_srgb,var(--dt-foreground)_34%,transparent),0_-0.3125rem_0.875rem_-0.6875rem_color-mix(in_srgb,var(--dt-foreground)_22%,transparent)] backdrop-blur-md' - -export const PROMPT_SNIPPETS = [ - { - label: 'Code review', - text: 'Please review this for bugs, regressions, and missing tests.' - }, - { - label: 'Implementation plan', - text: 'Please make a concise implementation plan before changing code.' - }, - { - label: 'Explain this', - text: 'Please explain how this works and point me to the key files.' - } -] - -export const ASK_PLACEHOLDERS = [ - '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.' -] - -export const EDGE_NEWLINES_RE = /^[\t ]*(?:\r\n|\r|\n)+|(?:\r\n|\r|\n)+[\t ]*$/g -export const DEFAULT_MAX_RECORDING_SECONDS = 120 - -// Conversation-mode VAD tuning — mirrors `tools.voice_mode` defaults so the -// browser pipeline feels like the CLI continuous loop. -export const CONVERSATION_SPEECH_LEVEL = 0.075 -export const CONVERSATION_POST_SPEECH_SILENCE_MS = 1_250 -export const CONVERSATION_IDLE_SILENCE_MS = 12_000 -export const CONVERSATION_MAX_TURN_SECONDS = 60 - -export const VOICE_MIME_TYPES = [ - 'audio/webm;codecs=opus', - 'audio/webm', - 'audio/mp4', - 'audio/ogg;codecs=opus', - 'audio/ogg', - 'audio/wav' -] diff --git a/apps/desktop/src/app/chat/composer/context-menu.tsx b/apps/desktop/src/app/chat/composer/context-menu.tsx index 253b70e5a74..3bdbe981f3b 100644 --- a/apps/desktop/src/app/chat/composer/context-menu.tsx +++ b/apps/desktop/src/app/chat/composer/context-menu.tsx @@ -14,7 +14,7 @@ import { } from '@/components/ui/dropdown-menu' import { cn } from '@/lib/utils' -import { GHOST_ICON_BTN, PROMPT_SNIPPETS } from './constants' +import { GHOST_ICON_BTN } from './controls' import type { ChatBarState } from './types' export function ContextMenu({ @@ -77,7 +77,11 @@ export function ContextMenu({ Prompt snippets - {PROMPT_SNIPPETS.map(snippet => ( + {[ + { label: 'Code review', text: 'Please review this for bugs, regressions, and missing tests.' }, + { label: 'Implementation plan', text: 'Please make a concise implementation plan before changing code.' }, + { label: 'Explain this', text: 'Please explain how this works and point me to the key files.' } + ].map(snippet => ( onInsertText(snippet.text)}> {snippet.label} diff --git a/apps/desktop/src/app/chat/composer/controls.tsx b/apps/desktop/src/app/chat/composer/controls.tsx index c191762cd41..ec8ef04f179 100644 --- a/apps/desktop/src/app/chat/composer/controls.tsx +++ b/apps/desktop/src/app/chat/composer/controls.tsx @@ -4,10 +4,12 @@ import { Button } from '@/components/ui/button' import { triggerHaptic } from '@/lib/haptics' import { cn } from '@/lib/utils' -import { GHOST_ICON_BTN, ICON_BTN } from './constants' import type { ConversationStatus } from './hooks/use-voice-conversation' import type { ChatBarState, VoiceStatus } from './types' +export const ICON_BTN = 'h-8 w-8 shrink-0 rounded-full' +export const GHOST_ICON_BTN = cn(ICON_BTN, 'text-muted-foreground hover:bg-accent hover:text-foreground') + interface ConversationProps { active: boolean level: number diff --git a/apps/desktop/src/app/chat/composer/directive-popover.tsx b/apps/desktop/src/app/chat/composer/directive-popover.tsx index 4966a994743..609bc7903be 100644 --- a/apps/desktop/src/app/chat/composer/directive-popover.tsx +++ b/apps/desktop/src/app/chat/composer/directive-popover.tsx @@ -24,9 +24,9 @@ export function DirectivePopover({
{items.length === 0 ? ( - Try @ for shortcuts, or paths like{' '} - @~/Desktop /{' '} - @./src. + Try @ for shortcuts, or paths like{' '} + @~/Desktop /{' '} + @./src. ) : ( items.map((item, index) => ) 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 ( - - ) + tick() + + return () => cancelAnimationFrame(raf) + }, [audioElement]) + + return
- + {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])