From 322cc94c9816b69132f7669217a8c121be1ecddb Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 1 May 2026 20:29:54 -0500 Subject: [PATCH] chore: uptick --- .../app/chat/composer/completion-drawer.tsx | 21 ++- .../composer/hooks/use-voice-conversation.ts | 2 +- apps/desktop/src/app/chat/composer/index.tsx | 105 ++++++------ .../chat/composer/liquid-glass-overrides.css | 79 ++++++++++ .../src/app/chat/composer/voice-activity.tsx | 39 +++-- .../src/components/assistant-ui/thread.tsx | 8 +- apps/desktop/src/lib/speech-text.ts | 21 ++- apps/desktop/src/lib/voice-playback.ts | 30 +++- apps/desktop/src/styles.css | 149 ------------------ 9 files changed, 227 insertions(+), 227 deletions(-) create mode 100644 apps/desktop/src/app/chat/composer/liquid-glass-overrides.css diff --git a/apps/desktop/src/app/chat/composer/completion-drawer.tsx b/apps/desktop/src/app/chat/composer/completion-drawer.tsx index 215cdc0066..ef540f30a6 100644 --- a/apps/desktop/src/app/chat/composer/completion-drawer.tsx +++ b/apps/desktop/src/app/chat/composer/completion-drawer.tsx @@ -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, 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 dd2231cbec..0a9360e832 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 @@ -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 => { diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index a239f3d52e..87e7bbe121 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -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({ <> { e.preventDefault() submitDraft() }} ref={composerRef} + style={ + { + '--composer-active-radius': `${glassTweaks.liquid.cornerRadius}px`, + '--composer-glass-radius': `${glassTweaks.liquid.cornerRadius}px` + } as CSSProperties + } > {showHelpHint && }
+
-
+ 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" + > {attachments.length > 0 && ( @@ -470,19 +469,13 @@ export function ChatBar({ export function ChatBarFallback() { return ( -
-
-
+
+
+
) diff --git a/apps/desktop/src/app/chat/composer/liquid-glass-overrides.css b/apps/desktop/src/app/chat/composer/liquid-glass-overrides.css new file mode 100644 index 0000000000..e993c5954a --- /dev/null +++ b/apps/desktop/src/app/chat/composer/liquid-glass-overrides.css @@ -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; +} diff --git a/apps/desktop/src/app/chat/composer/voice-activity.tsx b/apps/desktop/src/app/chat/composer/voice-activity.tsx index 842a4f35a5..884951cd2c 100644 --- a/apps/desktop/src/app/chat/composer/voice-activity.tsx +++ b/apps/desktop/src/app/chat/composer/voice-activity.tsx @@ -13,10 +13,27 @@ type BrowserAudioContext = typeof AudioContext interface ElementAnalyser { analyser: AnalyserNode - context: AudioContext } const elementAnalysers = new WeakMap() +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 } diff --git a/apps/desktop/src/components/assistant-ui/thread.tsx b/apps/desktop/src/components/assistant-ui/thread.tsx index b9a0b99a3e..3631540bea 100644 --- a/apps/desktop/src/components/assistant-ui/thread.tsx +++ b/apps/desktop/src/components/assistant-ui/thread.tsx @@ -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 { diff --git a/apps/desktop/src/lib/speech-text.ts b/apps/desktop/src/lib/speech-text.ts index d2b6a5852f..82cdac7e98 100644 --- a/apps/desktop/src/lib/speech-text.ts +++ b/apps/desktop/src/lib/speech-text.ts @@ -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 ') diff --git a/apps/desktop/src/lib/voice-playback.ts b/apps/desktop/src/lib/voice-playback.ts index 02e5a7761f..1554ed8a31 100644 --- a/apps/desktop/src/lib/voice-playback.ts +++ b/apps/desktop/src/lib/voice-playback.ts @@ -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((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')) } diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index ce9f6ed45a..e6729fd487 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -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']),