chore: uptick

This commit is contained in:
Brooklyn Nicholson 2026-05-01 20:15:00 -05:00
parent e00297782d
commit cd381d6ba5
17 changed files with 300 additions and 317 deletions

View file

@ -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 (
<div className="group/attachment flex max-w-full items-center gap-2 rounded-2xl border border-border/70 bg-muted/35 py-1 pl-1 pr-1.5 text-xs text-foreground/90">

View file

@ -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,

View file

@ -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<ComposerAttachment['kind'], LucideIcon> = {
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'
]

View file

@ -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({
<span>Prompt snippets</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-72">
{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 => (
<ContextMenuItem icon={MessageSquareText} key={snippet.label} onSelect={() => onInsertText(snippet.text)}>
{snippet.label}
</ContextMenuItem>

View file

@ -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

View file

@ -24,9 +24,9 @@ export function DirectivePopover({
<div className="grid gap-0.5 pt-0.5">
{items.length === 0 ? (
<CompletionDrawerEmpty title={loading ? 'Looking up...' : 'No matches.'}>
Try <span className="font-mono text-foreground/80">@</span> for shortcuts, or paths like{' '}
<span className="font-mono text-foreground/80">@~/Desktop</span> /{' '}
<span className="font-mono text-foreground/80">@./src</span>.
Try <span className="font-mono text-foreground/80">@</span> for shortcuts, or paths like{' '}
<span className="font-mono text-foreground/80">@~/Desktop</span> /{' '}
<span className="font-mono text-foreground/80">@./src</span>.
</CompletionDrawerEmpty>
) : (
items.map((item, index) => <DirectiveRow index={index} item={item} key={item.id} />)

View file

@ -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 (
<div className="grid gap-0.5 pt-0.5">
<p className="px-2.5 pb-0.5 pt-1 text-[0.65rem] font-medium uppercase tracking-wide text-muted-foreground/75">

View file

@ -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) {

View file

@ -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 {

View file

@ -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

View file

@ -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<string | null>(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({
<>
<ComposerPrimitive.Unstable_TriggerPopoverRoot>
<ComposerPrimitive.Root
className={cn(SHELL, 'group/composer pb-8 pt-2')}
className={cn(SHELL, 'group/composer pb-(--composer-shell-pad-block-end) pt-2')}
data-slot="composer-root"
style={
{
@ -405,38 +405,52 @@ export function ChatBar({
</div>
<div
className={cn(
'relative z-4 flex w-full flex-col gap-1.5 overflow-hidden border border-input/70 bg-card/72 px-2 py-1.5 shadow-composer transition-[border-color,box-shadow,opacity] duration-200 ease-out group-focus-within/composer:border-ring/35 group-focus-within/composer:shadow-composer-focus',
scrolledUp
? 'opacity-60 group-hover/composer:opacity-100 group-focus-within/composer:opacity-100'
: 'opacity-100'
'relative z-4 isolate overflow-hidden border border-input/70 shadow-composer transition-[border-color,box-shadow] duration-200 ease-out group-focus-within/composer:border-ring/35 group-focus-within/composer:shadow-composer-focus',
scrolledUp ? 'border-input/48' : 'border-input/70'
)}
data-slot="composer-surface"
style={
{
...COMPOSER_BACKDROP_STYLE,
'--composer-active-radius': `${glassTweaks.liquid.cornerRadius}px`,
borderRadius: `${glassTweaks.liquid.cornerRadius}px`
borderRadius: `${glassTweaks.liquid.cornerRadius}px`,
'--composer-active-radius': `${glassTweaks.liquid.cornerRadius}px`
} as CSSProperties
}
>
<VoiceActivity state={voiceActivityState} />
<VoicePlaybackActivity />
{attachments.length > 0 && <AttachmentList attachments={attachments} onRemove={onRemoveAttachment} />}
{stacked ? (
<>
{input}
<div className="flex w-full items-center gap-1.5">
<div
aria-hidden
className="pointer-events-none absolute inset-0 -z-10"
style={
{
...COMPOSER_BACKDROP_STYLE,
borderRadius: `${glassTweaks.liquid.cornerRadius}px`,
backgroundColor: scrolledUp
? 'color-mix(in srgb, var(--dt-card) 48%, transparent)'
: 'color-mix(in srgb, var(--dt-card) 72%, transparent)'
} satisfies CSSProperties
}
/>
<div className="relative z-1 flex min-h-0 w-full flex-col gap-1.5 px-2 py-1.5">
<VoiceActivity state={voiceActivityState} />
<VoicePlaybackActivity />
{attachments.length > 0 && (
<AttachmentList attachments={attachments} onRemove={onRemoveAttachment} />
)}
{stacked ? (
<>
{input}
<div className="flex w-full items-center gap-1.5">
{contextMenu}
{controls}
</div>
</>
) : (
<div className="flex w-full items-end gap-1.5">
{contextMenu}
{input}
{controls}
</div>
</>
) : (
<div className="flex w-full items-end gap-1.5">
{contextMenu}
{input}
{controls}
</div>
)}
)}
</div>
</div>
</div>
</ComposerPrimitive.Root>
@ -456,10 +470,19 @@ export function ChatBar({
export function ChatBarFallback() {
return (
<div className={cn(SHELL, 'bg-linear-to-b from-transparent to-background/55 pb-8 pt-2')}>
<div className="relative h-11 w-full">
<div className="absolute inset-0 rounded-[1.25rem] bg-card/1" style={COMPOSER_BACKDROP_STYLE} />
<div className="absolute inset-0 rounded-[1.25rem] border border-input/70 bg-card/72 shadow-composer" />
<div className={cn(SHELL, 'bg-linear-to-b from-transparent to-background/55 pb-(--composer-shell-pad-block-end) pt-2')}>
<div className="relative isolate h-11 w-full overflow-hidden rounded-[1.25rem] border border-input/70 shadow-composer">
<div
aria-hidden
className="pointer-events-none absolute inset-0"
style={
{
...COMPOSER_BACKDROP_STYLE,
borderRadius: '1.25rem',
backgroundColor: 'color-mix(in srgb, var(--dt-card) 72%, transparent)'
} satisfies CSSProperties
}
/>
</div>
</div>
)

View file

@ -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<AudioDataProvider>(
() => ({
onAudioData(callback) {
if (!audioElement) {
return () => undefined
const canvasRef = useRef<HTMLCanvasElement | null>(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 (
<div aria-hidden="true" className="h-4 w-22 overflow-hidden rounded-full">
{source ? (
<AudioWave
amplitudeMode="adaptive"
animateCurrentPick
backgroundColor="transparent"
barColor="rgb(37 99 235)"
barWidth={2}
gain={1.8}
gap={1}
height={16}
onlyActive
rounded={2}
secondaryBarColor="transparent"
source={source}
speed={2}
width="100%"
/>
) : null}
</div>
)
tick()
return () => cancelAnimationFrame(raf)
}, [audioElement])
return <canvas aria-hidden="true" className="block h-4 w-[88px]" ref={canvasRef} />
}
export function VoiceActivity({

View file

@ -258,7 +258,7 @@ export const Thread: FC<{
{loading === 'response' && <ResponseLoadingIndicator />}
{loading === 'working' && <WorkingIndicator />}
</div>
<ThreadPrimitive.ViewportFooter className="h-[220px] shrink-0" />
<ThreadPrimitive.ViewportFooter className="h-(--thread-composer-clearance) shrink-0" />
</ThreadPrimitive.Viewport>
{loading === 'session' && <CenteredThreadSpinner />}
</ThreadPrimitive.Root>

View file

@ -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
}

View file

@ -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<boolean | undefined>(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)`)

View file

@ -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;
}

View file

@ -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<ThemeDensity, string> = {
const INJECTED_FONT_URLS = new Set<string>()
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])