chore: uptick

This commit is contained in:
Brooklyn Nicholson
2026-05-01 20:29:54 -05:00
parent cd381d6ba5
commit 322cc94c98
9 changed files with 227 additions and 227 deletions
@@ -2,8 +2,25 @@ import type { Unstable_TriggerAdapter } from '@assistant-ui/core'
import { ComposerPrimitive } from '@assistant-ui/react'
import type { ReactNode } from 'react'
export const COMPLETION_DRAWER_CLASS = 'composer-completion-drawer'
export const COMPLETION_DRAWER_ROW_CLASS = 'composer-completion-row'
export const COMPLETION_DRAWER_CLASS = [
'absolute inset-x-0 bottom-[calc(100%-0.5rem)] z-50',
'max-h-[min(23rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
'rounded-t-(--composer-active-radius) border border-b-0',
'border-[color-mix(in_srgb,var(--dt-ring)_45%,transparent)]',
'bg-[color-mix(in_srgb,var(--dt-popover)_96%,transparent)]',
'px-1.5 pb-3 pt-1.5 text-popover-foreground',
'backdrop-blur-[0.75rem] backdrop-saturate-[1.1]',
'[-webkit-backdrop-filter:blur(0.75rem)_saturate(1.1)]',
'data-[state=open]:-mb-2',
'data-[state=open]:shadow-[0_-0.0625rem_0_0.0625rem_color-mix(in_srgb,var(--dt-ring)_35%,transparent),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)]'
].join(' ')
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-[color-mix(in_srgb,var(--dt-accent)_70%,transparent)]',
'data-[highlighted]:bg-[color-mix(in_srgb,var(--dt-accent)_70%,transparent)]'
].join(' ')
export function ComposerCompletionDrawer({
adapter,
@@ -82,7 +82,7 @@ export function useVoiceConversation({
return
}
speechBufferRef.current = `${speechBufferRef.current} ${text}`.trim()
speechBufferRef.current = `${speechBufferRef.current}${text}`
}
const takeSpeechChunk = (force = false): string | null => {
+49 -56
View File
@@ -3,6 +3,8 @@ import { useStore } from '@nanostores/react'
import LiquidGlass from 'liquid-glass-react'
import { type ClipboardEvent, type CSSProperties, useEffect, useRef, useState } from 'react'
import './liquid-glass-overrides.css'
import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
import { useMediaQuery } from '@/hooks/use-media-query'
import { chatMessageText } from '@/lib/chat-messages'
@@ -27,13 +29,20 @@ import type { ChatBarProps } from './types'
import { UrlDialog } from './url-dialog'
import { VoiceActivity, VoicePlaybackActivity } from './voice-activity'
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_SHELL_CLASS =
'group/composer 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 pt-2 pb-[var(--composer-shell-pad-block-end)]'
const COMPOSER_BACKDROP_STYLE = {
backdropFilter: 'blur(0.75rem) saturate(1.12)',
WebkitBackdropFilter: 'blur(0.75rem) saturate(1.12)'
} satisfies CSSProperties
const COMPOSER_FROST_CLASS = cn(
'pointer-events-none absolute inset-0 -z-10 rounded-(--composer-active-radius)',
'bg-[color-mix(in_srgb,var(--dt-card)_72%,transparent)]',
'backdrop-blur-[0.75rem] backdrop-saturate-[1.12]',
'[-webkit-backdrop-filter:blur(0.75rem)_saturate(1.12)]',
'transition-[background-color,backdrop-filter,-webkit-backdrop-filter] duration-150 ease-out',
'group-data-[thread-scrolled-up]/composer:bg-[color-mix(in_srgb,var(--dt-card)_48%,transparent)]',
'group-focus-within/composer:bg-[var(--dt-card)]',
'group-focus-within/composer:[backdrop-filter:none]',
'group-focus-within/composer:[-webkit-backdrop-filter:none]'
)
export function ChatBar({
busy,
@@ -344,18 +353,20 @@ export function ChatBar({
<>
<ComposerPrimitive.Unstable_TriggerPopoverRoot>
<ComposerPrimitive.Root
className={cn(SHELL, 'group/composer pb-(--composer-shell-pad-block-end) pt-2')}
className={COMPOSER_SHELL_CLASS}
data-slot="composer-root"
style={
{
'--composer-active-radius': `${glassTweaks.liquid.cornerRadius}px`
} as CSSProperties
}
data-thread-scrolled-up={scrolledUp ? '' : undefined}
onSubmit={e => {
e.preventDefault()
submitDraft()
}}
ref={composerRef}
style={
{
'--composer-active-radius': `${glassTweaks.liquid.cornerRadius}px`,
'--composer-glass-radius': `${glassTweaks.liquid.cornerRadius}px`
} as CSSProperties
}
>
{showHelpHint && <HelpHint />}
<DirectivePopover
@@ -365,26 +376,22 @@ export function ChatBar({
/>
<SlashPopover adapter={slash.adapter} loading={slash.loading} />
<div
className="pointer-events-none absolute inset-x-0 bottom-0 top-0"
className="pointer-events-none absolute inset-0"
style={{ background: glassTweaks.fadeBackground }}
/>
<div className="relative w-full">
<div
className={cn(
'composer-liquid-shell-wrap absolute inset-0 transition-opacity duration-200 ease-out',
'composer-liquid-shell-wrap absolute inset-0 isolate overflow-hidden rounded-(--composer-glass-radius,20px) transition-opacity duration-200 ease-out',
'group-has-data-[state=open]/composer:rounded-t-none',
scrolledUp
? 'opacity-70 group-hover/composer:opacity-100 group-focus-within/composer:opacity-100'
? 'opacity-30 group-hover/composer:opacity-100 group-focus-within/composer:opacity-100'
: 'opacity-100'
)}
data-glass-frame="true"
data-show-library-rims={glassTweaks.showLibraryRims ? 'true' : undefined}
data-slot="composer-liquid-shell-wrap"
ref={glassShellRef}
style={
{
'--composer-active-radius': `${glassTweaks.liquid.cornerRadius}px`,
'--composer-glass-radius': `${glassTweaks.liquid.cornerRadius}px`
} as CSSProperties
}
>
<LiquidGlass
aberrationIntensity={glassTweaks.liquid.aberrationIntensity}
@@ -398,38 +405,30 @@ export function ChatBar({
mouseContainer={composerRef}
padding="0"
saturation={glassTweaks.liquid.saturation}
style={{ position: 'absolute', top: '0', left: '0', width: '100%', height: '100%' }}
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }}
>
<span className="block h-full w-full" />
</LiquidGlass>
</div>
<div
className={cn(
'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'
'relative z-4 isolate overflow-hidden rounded-(--composer-active-radius) 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',
'group-has-data-[state=open]/composer:rounded-t-none group-has-data-[state=open]/composer:border-t-transparent',
'group-has-data-[state=open]/composer:shadow-[0_0.0625rem_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)]'
)}
data-slot="composer-surface"
style={
{
borderRadius: `${glassTweaks.liquid.cornerRadius}px`,
'--composer-active-radius': `${glassTweaks.liquid.cornerRadius}px`
} as CSSProperties
}
>
<div aria-hidden className={COMPOSER_FROST_CLASS} />
<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">
className={cn(
'relative z-1 flex min-h-0 w-full flex-col gap-1.5 px-2 py-1.5 transition-opacity duration-200 ease-out',
scrolledUp
? 'opacity-30 group-hover/composer:opacity-100 group-focus-within/composer:opacity-100'
: 'opacity-100'
)}
data-slot="composer-fade"
>
<VoiceActivity state={voiceActivityState} />
<VoicePlaybackActivity />
{attachments.length > 0 && (
@@ -470,19 +469,13 @@ export function ChatBar({
export function ChatBarFallback() {
return (
<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
className={cn(COMPOSER_SHELL_CLASS, 'bg-linear-to-b from-transparent to-background/55')}
data-slot="composer-root"
style={{ '--composer-active-radius': '1.25rem' } as CSSProperties}
>
<div className="relative isolate h-11 w-full overflow-hidden rounded-(--composer-active-radius) border border-input/70 shadow-composer">
<div aria-hidden className={COMPOSER_FROST_CLASS} />
</div>
</div>
)
@@ -0,0 +1,79 @@
/* liquid-glass-react emits helper nodes that ignore local utility classes. Keep
these overrides scoped by class so the rest of app styling stays utility-first. */
.composer-liquid-shell-wrap > div:not(.composer-liquid-shell) {
position: absolute !important;
inset: 0 !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
transform: none !important;
margin: 0 !important;
box-sizing: border-box;
}
.composer-liquid-shell-wrap:not([data-show-library-rims='true']) > span {
display: none !important;
}
.composer-liquid-shell-wrap[data-show-library-rims='true'] > span {
position: absolute !important;
inset: 0 !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
transform: none !important;
margin: 0 !important;
box-sizing: border-box;
display: block !important;
}
.composer-liquid-shell {
z-index: 1;
top: 0 !important;
left: 0 !important;
transform: none !important;
transition: none !important;
}
.composer-liquid-shell > svg {
position: absolute !important;
inset: 0 !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
transform: none !important;
}
.composer-liquid-shell > .glass,
.composer-liquid-shell > :not(svg):not(.glass) {
position: absolute !important;
inset: 0 !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
transform: none !important;
}
.composer-liquid-shell > .glass {
width: 100% !important;
height: 100% !important;
padding: 0 !important;
border-radius: var(--composer-glass-radius, 20px) !important;
box-shadow: none !important;
}
.composer-liquid-shell > .glass > .glass__warp {
border-radius: var(--composer-glass-radius, 20px) !important;
}
.composer-liquid-shell > .glass > div {
width: 100%;
height: 100%;
font: inherit !important;
text-shadow: none !important;
color: inherit !important;
}
@@ -13,10 +13,27 @@ type BrowserAudioContext = typeof AudioContext
interface ElementAnalyser {
analyser: AnalyserNode
context: AudioContext
}
const elementAnalysers = new WeakMap<HTMLAudioElement, ElementAnalyser>()
let playbackAudioContext: AudioContext | null = null
function getPlaybackAudioContext(): AudioContext | null {
if (playbackAudioContext && playbackAudioContext.state !== 'closed') {
return playbackAudioContext
}
const audioWindow = window as Window & { webkitAudioContext?: BrowserAudioContext }
const AudioContextCtor = window.AudioContext || audioWindow.webkitAudioContext
if (!AudioContextCtor) {
return null
}
playbackAudioContext = new AudioContextCtor()
return playbackAudioContext
}
function formatElapsed(seconds: number) {
const safeSeconds = Math.max(0, Math.floor(seconds))
@@ -51,17 +68,15 @@ 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()
if (!entry) {
const context = getPlaybackAudioContext()
if (!context) {
return null
}
const source = context.createMediaElementSource(audioElement)
const analyser = context.createAnalyser()
@@ -69,11 +84,11 @@ function getElementAnalyser(audioElement: HTMLAudioElement): ElementAnalyser | n
analyser.smoothingTimeConstant = 0.65
source.connect(analyser)
analyser.connect(context.destination)
entry = { analyser, context }
entry = { analyser }
elementAnalysers.set(audioElement, entry)
}
void entry.context.resume()
void playbackAudioContext?.resume()
return entry
}
@@ -111,11 +111,13 @@ function partText(part: unknown): string {
return part
}
if (part && typeof part === 'object' && 'text' in part && typeof part.text === 'string') {
return part.text
if (!part || typeof part !== 'object') {
return ''
}
return ''
const row = part as { text?: unknown; type?: unknown }
return (!row.type || row.type === 'text') && typeof row.text === 'string' ? row.text : ''
}
function messageContentText(content: unknown): string {
+19 -2
View File
@@ -1,12 +1,29 @@
const EMOJI_RE = /[\p{Extended_Pictographic}\uFE0F\u200D]+/gu
const EMOJI_RE =
/(?:[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}]|[\u{FE0F}\u{200D}]|[\u{E0020}-\u{E007F}])+/gu
const FENCED_CODE_RE = /```[\s\S]*?(?:```|$)/g
const INLINE_CODE_RE = /`([^`]+)`/g
const MARKDOWN_LINK_RE = /\[([^\]]+)\]\(([^)]+)\)/g
const PARAGRAPH_BREAK_RE = /[ \t]*\n{2,}[ \t]*/g
const SOFT_BREAK_RE = /[ \t]*\n[ \t]*/g
const THINKING_PREFIX_RE =
/^\s*(?:\([^)\n]{1,48}\)\s*)?(?:processing|thinking|reasoning|analyzing|pondering|contemplating|musing|cogitating|ruminating|deliberating|mulling|reflecting|computing|synthesizing|formulating|brainstorming)\.\.\.\s*/i
const URL_RE = /\bhttps?:\/\/\S+/gi
export function sanitizeTextForSpeech(text: string): string {
function normalizeLineBreaks(text: string): string {
return text
.replace(/\r\n?/g, '\n')
.replace(/(\p{L})-\n(\p{L})/gu, '$1$2')
.replace(PARAGRAPH_BREAK_RE, '. ')
.replace(SOFT_BREAK_RE, ' ')
}
export function sanitizeTextForSpeech(text: string): string {
return normalizeLineBreaks(text)
.replace(FENCED_CODE_RE, ' ')
.replace(THINKING_PREFIX_RE, ' ')
.replace(MARKDOWN_LINK_RE, '$1')
.replace(INLINE_CODE_RE, '$1')
.replace(URL_RE, ' link ')
+28 -2
View File
@@ -9,6 +9,7 @@ import {
import { sanitizeTextForSpeech } from './speech-text'
let currentAudio: HTMLAudioElement | null = null
let currentStop: (() => void) | null = null
let sequence = 0
function currentState(
@@ -32,10 +33,13 @@ export interface VoicePlaybackOptions {
export function stopVoicePlayback() {
sequence += 1
currentStop?.()
currentStop = null
if (currentAudio) {
currentAudio.pause()
currentAudio.src = ''
currentAudio.load()
currentAudio = null
}
@@ -74,8 +78,29 @@ export async function playSpeechText(text: string, options: VoicePlaybackOptions
setVoicePlaybackState(currentState('speaking', options, audio))
await new Promise<void>((resolve, reject) => {
audio.addEventListener('ended', () => resolve(), { once: true })
audio.addEventListener('error', () => reject(new Error('Playback failed')), { once: true })
const cleanup = () => {
audio.removeEventListener('ended', onEnded)
audio.removeEventListener('error', onError)
currentStop = null
}
const onEnded = () => {
cleanup()
resolve()
}
const onError = () => {
cleanup()
reject(new Error('Playback failed'))
}
currentStop = () => {
cleanup()
resolve()
}
audio.addEventListener('ended', onEnded, { once: true })
audio.addEventListener('error', onError, { once: true })
void audio.play().catch(reject)
})
@@ -89,6 +114,7 @@ export async function playSpeechText(text: string, options: VoicePlaybackOptions
return true
} catch (error) {
if (isCurrent()) {
currentStop = null
currentAudio = null
setVoicePlaybackState(currentState('idle'))
}
-149
View File
@@ -186,155 +186,6 @@ button {
-webkit-app-region: no-drag;
}
.composer-liquid-shell-wrap {
pointer-events: none;
border-radius: var(--composer-glass-radius, 20px);
isolation: isolate;
overflow: hidden;
}
/* Fragment siblings emitted by liquid-glass-react (not inside .composer-liquid-shell). */
.composer-liquid-shell-wrap > div:not(.composer-liquid-shell) {
position: absolute !important;
inset: 0 !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
transform: none !important;
margin: 0 !important;
box-sizing: border-box;
}
/* Liquid-glass rim spans (toggle via data-show-library-rims on shell wrap). */
.composer-liquid-shell-wrap:not([data-show-library-rims='true']) > span {
display: none !important;
}
.composer-liquid-shell-wrap[data-show-library-rims='true'] > span {
position: absolute !important;
inset: 0 !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
transform: none !important;
margin: 0 !important;
box-sizing: border-box;
display: block !important;
}
.composer-liquid-shell {
z-index: 1;
top: 0 !important;
left: 0 !important;
transform: none !important;
transition: none !important;
}
.composer-liquid-shell > svg {
position: absolute !important;
inset: 0 !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
transform: none !important;
}
.composer-liquid-shell > .glass,
.composer-liquid-shell > :not(svg):not(.glass) {
position: absolute !important;
inset: 0 !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
transform: none !important;
}
.composer-liquid-shell > .glass {
width: 100% !important;
height: 100% !important;
padding: 0 !important;
border-radius: var(--composer-glass-radius, 20px) !important;
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;
border-top-right-radius: 0 !important;
border-top-color: transparent;
box-shadow:
0 0.0625rem 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);
}
[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;
}
[data-slot='composer-root']:has([data-slot='composer-completion-drawer'][data-state='open'])
[data-slot='composer-completion-drawer'] {
margin-bottom: -0.5rem;
border-bottom: 0;
box-shadow:
0 -0.0625rem 0 0.0625rem color-mix(in srgb, var(--dt-ring) 35%, transparent),
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);
}
.composer-liquid-shell > .glass > .glass__warp {
border-radius: var(--composer-glass-radius, 20px) !important;
}
.composer-liquid-shell > .glass > div {
width: 100%;
height: 100%;
font: inherit !important;
text-shadow: none !important;
color: inherit !important;
}
input,
textarea,
[contenteditable]:not([contenteditable='false']),