mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-10 08:32:09 +00:00
chore: uptick
This commit is contained in:
parent
e00297782d
commit
cd381d6ba5
17 changed files with 300 additions and 317 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
]
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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} />)
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
24
apps/desktop/src/hooks/use-media-query.ts
Normal file
24
apps/desktop/src/hooks/use-media-query.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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)`)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue