From e00297782d02a5b60aead2df1c94c6186315bf46 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 1 May 2026 19:53:41 -0500 Subject: [PATCH] chore: uptick --- .../app/chat/composer/completion-drawer.tsx | 47 +++++++ .../src/app/chat/composer/constants.ts | 12 +- .../app/chat/composer/directive-popover.tsx | 109 +++++----------- .../src/app/chat/composer/help-hint.tsx | 67 ++++++++++ .../chat/composer/hooks/use-at-completions.ts | 116 ++++++++++++++++++ .../hooks/use-live-completion-adapter.ts | 115 +++++++++++++++++ .../composer/hooks/use-slash-completions.ts | 107 ++++++++++++++++ .../composer/hooks/use-voice-conversation.ts | 6 +- apps/desktop/src/app/chat/composer/index.tsx | 56 +++++---- .../src/app/chat/composer/slash-popover.tsx | 63 ++++++++++ apps/desktop/src/app/chat/composer/types.ts | 3 + .../src/app/chat/composer/voice-activity.tsx | 96 +++++++++++++-- apps/desktop/src/app/chat/index.tsx | 7 +- .../app/session/hooks/use-prompt-actions.ts | 43 +++++++ .../assistant-ui/directive-text.tsx | 25 ++++ apps/desktop/src/components/notifications.tsx | 2 +- apps/desktop/src/lib/voice-playback.ts | 10 +- apps/desktop/src/store/voice-playback.ts | 2 + apps/desktop/src/styles.css | 49 ++++---- 19 files changed, 782 insertions(+), 153 deletions(-) create mode 100644 apps/desktop/src/app/chat/composer/completion-drawer.tsx create mode 100644 apps/desktop/src/app/chat/composer/help-hint.tsx create mode 100644 apps/desktop/src/app/chat/composer/hooks/use-at-completions.ts create mode 100644 apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts create mode 100644 apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts create mode 100644 apps/desktop/src/app/chat/composer/slash-popover.tsx diff --git a/apps/desktop/src/app/chat/composer/completion-drawer.tsx b/apps/desktop/src/app/chat/composer/completion-drawer.tsx new file mode 100644 index 0000000000..7ca303a198 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/completion-drawer.tsx @@ -0,0 +1,47 @@ +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 function ComposerCompletionDrawer({ + adapter, + ariaLabel, + char, + children +}: { + adapter: Unstable_TriggerAdapter + ariaLabel: string + char: string + children: ReactNode +}) { + return ( + + {children} + + ) +} + +export function CompletionDrawerEmpty({ + children, + title +}: { + children?: ReactNode + title: string +}) { + return ( +
+

{title}

+ {children &&

{children}

} +
+ ) +} diff --git a/apps/desktop/src/app/chat/composer/constants.ts b/apps/desktop/src/app/chat/composer/constants.ts index 945f2a59e2..aaae461b82 100644 --- a/apps/desktop/src/app/chat/composer/constants.ts +++ b/apps/desktop/src/app/chat/composer/constants.ts @@ -1,4 +1,3 @@ -import type { Unstable_IconComponent } from '@assistant-ui/react' import { FileText, FolderOpen, ImageIcon, Link, type LucideIcon } from 'lucide-react' import type { CSSProperties } from 'react' @@ -28,15 +27,8 @@ export const ATTACHMENT_ICON: Record = { file: FileText } -export const DIRECTIVE_ICONS: Record = { - file: FileText, - folder: FolderOpen, - image: ImageIcon, - url: Link -} - -export const DIRECTIVE_POPOVER_CLASS = - 'absolute bottom-24 left-1/2 z-50 w-[min(calc(100vw-1.5rem),26rem)] max-h-[min(24rem,calc(100vh-8rem))] -translate-x-1/2 overflow-y-auto overscroll-contain rounded-2xl border border-border/60 bg-popover/95 p-1.5 text-popover-foreground shadow-2xl backdrop-blur-md ring-1 ring-black/5' +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 = [ { diff --git a/apps/desktop/src/app/chat/composer/directive-popover.tsx b/apps/desktop/src/app/chat/composer/directive-popover.tsx index d5d69b36f2..4966a99474 100644 --- a/apps/desktop/src/app/chat/composer/directive-popover.tsx +++ b/apps/desktop/src/app/chat/composer/directive-popover.tsx @@ -1,103 +1,56 @@ import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core' -import { - ComposerPrimitive, - type Unstable_IconComponent, - type Unstable_MentionCategory, - type Unstable_MentionDirective -} from '@assistant-ui/react' -import { FileText } from 'lucide-react' +import { ComposerPrimitive, type Unstable_MentionDirective } from '@assistant-ui/react' -import { DIRECTIVE_POPOVER_CLASS } from './constants' -import type { ContextSuggestion } from './types' +import { + ComposerCompletionDrawer, + CompletionDrawerEmpty, + COMPLETION_DRAWER_ROW_CLASS +} from './completion-drawer' export function DirectivePopover({ adapter, directive, - fallbackIcon: Fallback, - iconMap + loading = false }: { adapter: Unstable_TriggerAdapter directive: Unstable_MentionDirective - fallbackIcon: Unstable_IconComponent - iconMap: Record + loading?: boolean }) { return ( - + {items => ( -
-
- Reference a file -
+
{items.length === 0 ? ( -
-

No file suggestions yet.

-

- Keep typing to filter, or click + to attach - files, folders, or a URL. -

-
+ + Try @ for shortcuts, or paths like{' '} + @~/Desktop /{' '} + @./src. + ) : ( - items.map((item, index) => { - const Icon = directiveIcon(item, iconMap, Fallback) - - return ( - - - - {item.label} - {item.description && ( - {item.description} - )} - - - ) - }) + items.map((item, index) => ) )}
)} - + ) } -export function buildMentionCategories(suggestions: ContextSuggestion[] | undefined): Unstable_MentionCategory[] { - const items: Unstable_TriggerItem[] = [] +function DirectiveRow({ index, item }: { index: number; item: Unstable_TriggerItem }) { + const metadata = item.metadata as { display?: string; meta?: string } | undefined + const display = metadata?.display || item.label + const description = metadata?.meta || item.description - for (const s of suggestions ?? []) { - const match = s.text.match(/^@(file|folder|url|image):(.+)$/) - - if (!match) { - continue - } - - const [, type, id] = match - - items.push({ - id, - type, - label: s.display || id, - description: s.meta, - metadata: { icon: type } - }) - } - - return [{ id: 'context', label: 'References', items }] -} - -function directiveIcon( - item: Unstable_TriggerItem, - iconMap: Record, - fallback: Unstable_IconComponent -): Unstable_IconComponent { - const meta = item.metadata as Record | undefined - const key = typeof meta?.icon === 'string' ? meta.icon : item.type - - return iconMap[key] ?? iconMap[item.type] ?? fallback ?? FileText + return ( + + {display} + {description && {description}} + + ) } diff --git a/apps/desktop/src/app/chat/composer/help-hint.tsx b/apps/desktop/src/app/chat/composer/help-hint.tsx new file mode 100644 index 0000000000..d981aca86f --- /dev/null +++ b/apps/desktop/src/app/chat/composer/help-hint.tsx @@ -0,0 +1,67 @@ +import { COMPLETION_DRAWER_CLASS } from './constants' + +const COMMON_COMMANDS: [string, string][] = [ + ['/help', 'full list of commands + hotkeys'], + ['/clear', 'start a new session'], + ['/resume', 'resume a prior session'], + ['/details', 'control transcript detail level'], + ['/copy', 'copy selection or last assistant message'], + ['/quit', 'exit hermes'] +] + +const HOTKEYS: [string, string][] = [ + ['@', 'reference files, folders, urls, git'], + ['/', 'slash command palette'], + ['?', 'this quick help (delete to dismiss)'], + ['Enter', 'send · Shift+Enter for newline'], + ['Cmd/Ctrl+K', 'send next queued turn'], + ['Cmd/Ctrl+L', 'redraw'], + ['Esc', 'close popover · cancel run'], + ['↑ / ↓', 'cycle popover / history'] +] + +export function HelpHint() { + return ( +
+
+ {COMMON_COMMANDS.map(([key, desc]) => ( + + ))} +
+ +
+ {HOTKEYS.map(([key, desc]) => ( + + ))} +
+ +

+ /help opens the full panel · backspace dismisses +

+
+ ) +} + +function Section({ children, title }: { children: React.ReactNode; title: string }) { + return ( +
+

+ {title} +

+ {children} +
+ ) +} + +function Row({ description, keyLabel, mono = false }: { description: string; keyLabel: string; mono?: boolean }) { + return ( +
+ + {keyLabel} + + {description} +
+ ) +} diff --git a/apps/desktop/src/app/chat/composer/hooks/use-at-completions.ts b/apps/desktop/src/app/chat/composer/hooks/use-at-completions.ts new file mode 100644 index 0000000000..5b710db8ed --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-at-completions.ts @@ -0,0 +1,116 @@ +import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core' +import { useCallback } from 'react' + +import type { HermesGateway } from '@/hermes' + +import type { CompletionEntry, CompletionPayload } from './use-live-completion-adapter' +import { useLiveCompletionAdapter } from './use-live-completion-adapter' + +const KIND_RE = /^@(file|folder|url|image|tool|git):(.*)$/ + +interface AtItemMetadata extends Record { + icon: string + display: string + meta: string + /** Raw `text` field from the gateway, e.g. `@file:src/main.tsx` or `@diff`. */ + rawText: string + /** Just the value portion (after `@kind:`), or empty for simple refs. */ + insertId: string +} + +function textValue(value: unknown, fallback = ''): string { + return typeof value === 'string' ? value : fallback +} + +/** Parse the gateway's `text` field (`@file:src/foo.ts`, `@diff`, `@folder:`) into popover-ready data. */ +function classify(entry: CompletionEntry): { + type: string + insertId: string + display: string + meta: string +} { + const match = KIND_RE.exec(entry.text) + + if (match) { + const [, kind, rest] = match + + return { + type: kind, + insertId: rest, + display: textValue(entry.display, rest || `@${kind}:`), + meta: textValue(entry.meta) + } + } + + return { + type: 'simple', + insertId: entry.text, + display: textValue(entry.display, entry.text), + meta: textValue(entry.meta) + } +} + +/** Live `@` completions backed by the gateway's `complete.path` RPC. */ +export function useAtCompletions(options: { + gateway: HermesGateway | null + sessionId: string | null + cwd: string | null +}): { adapter: Unstable_TriggerAdapter; loading: boolean } { + const { gateway, sessionId, cwd } = options + const enabled = Boolean(gateway) + + const fetcher = useCallback( + async (query: string): Promise => { + if (!gateway) { + return { items: [], query } + } + + const word = `@${query}` + const params: Record = { word } + + if (sessionId) { + params.session_id = sessionId + } + + if (cwd) { + params.cwd = cwd + } + + try { + const result = await gateway.request<{ items?: CompletionEntry[] }>('complete.path', params) + + return { items: result.items ?? [], query } + } catch { + return { items: [], query } + } + }, + [gateway, sessionId, cwd] + ) + + const toItem = useCallback((entry: CompletionEntry, index: number): Unstable_TriggerItem => { + const classified = classify(entry) + + const metadata: AtItemMetadata = { + icon: classified.type, + display: classified.display, + meta: classified.meta, + rawText: entry.text, + insertId: classified.insertId + } + + return { + // Unique id keyed on the gateway's full `text` so two entries that share + // a basename (e.g. multiple `index.ts`) don't collide in keyboard nav. + id: `${entry.text}|${index}`, + type: classified.type, + label: classified.display, + ...(classified.meta ? { description: classified.meta } : {}), + metadata + } + }, []) + + return useLiveCompletionAdapter({ enabled, fetcher, toItem }) +} + +/** Re-export `classify` for use by the formatter (insertion side). */ +export { classify } diff --git a/apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts b/apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts new file mode 100644 index 0000000000..fc9eab4955 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts @@ -0,0 +1,115 @@ +import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +export interface CompletionEntry { + text: string + display?: unknown + meta?: unknown +} + +export interface CompletionPayload { + items: CompletionEntry[] + 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. + */ +export function useLiveCompletionAdapter(options: { + enabled: boolean + debounceMs?: number + fetcher: (query: string) => Promise + toItem: (entry: CompletionEntry, index: number) => Unstable_TriggerItem +}): { adapter: Unstable_TriggerAdapter; loading: boolean } { + const { enabled, debounceMs = 60, fetcher, toItem } = options + + const [state, setState] = useState<{ query: string; items: Unstable_TriggerItem[] }>({ + query: '\u0000', + items: [] + }) + + const [loading, setLoading] = useState(false) + + const tokenRef = useRef(0) + const timerRef = useRef(null) + const pendingQueryRef = useRef(null) + + const cancelTimer = useCallback(() => { + if (timerRef.current !== null) { + window.clearTimeout(timerRef.current) + timerRef.current = null + } + }, []) + + useEffect(() => () => cancelTimer(), [cancelTimer]) + + const scheduleFetch = useCallback( + (query: string) => { + if (!enabled) { + return + } + + if (pendingQueryRef.current === query) { + return + } + + pendingQueryRef.current = query + cancelTimer() + const token = ++tokenRef.current + setLoading(true) + + timerRef.current = window.setTimeout(() => { + timerRef.current = null + + fetcher(query) + .then(payload => { + if (token !== tokenRef.current) { + return + } + + setState({ + query: payload.query, + items: payload.items.map((entry, index) => toItem(entry, index)) + }) + }) + .catch(() => { + if (token !== tokenRef.current) { + return + } + + setState({ query, items: [] }) + }) + .finally(() => { + if (token === tokenRef.current) { + setLoading(false) + } + }) + }, debounceMs) + }, + [cancelTimer, debounceMs, enabled, fetcher, toItem] + ) + + const adapter = useMemo( + () => ({ + categories: () => [], + categoryItems: () => [], + search: (query: string) => { + if (query !== state.query) { + scheduleFetch(query) + } + + return state.items + } + }), + [scheduleFetch, state] + ) + + return { adapter, loading } +} diff --git a/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts b/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts new file mode 100644 index 0000000000..fd4ab74eca --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts @@ -0,0 +1,107 @@ +import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core' +import { useCallback } from 'react' + +import type { HermesGateway } from '@/hermes' + +import type { CompletionEntry, CompletionPayload } from './use-live-completion-adapter' +import { useLiveCompletionAdapter } from './use-live-completion-adapter' + +const PICKER_OWNED = new Set(['/model', '/provider', 'model', 'provider']) + +interface SlashItemMetadata extends Record { + command: string + display: string + meta: string +} + +interface CommandsCatalogResponse { + pairs?: [string, string][] +} + +function textValue(value: unknown, fallback = ''): string { + if (typeof value === 'string') { + return value + } + + if (Array.isArray(value)) { + return value + .map(part => (Array.isArray(part) ? String(part[1] ?? '') : typeof part === 'string' ? part : '')) + .join('') + .trim() + } + + return fallback +} + +function commandText(value: string): string { + return value.startsWith('/') ? value : `/${value}` +} + +/** Live `/` completions backed by the gateway's `complete.slash` RPC. */ +export function useSlashCompletions(options: { + gateway: HermesGateway | null +}): { adapter: Unstable_TriggerAdapter; loading: boolean } { + const { gateway } = options + const enabled = Boolean(gateway) + + const fetcher = useCallback( + async (query: string): Promise => { + if (!gateway) { + return { items: [], query } + } + + const text = `/${query}` + + // Model/provider have a dedicated picker; suppress slash completions for them once typed. + if (text.startsWith('/model') || text.startsWith('/provider')) { + return { items: [], query } + } + + try { + if (!query) { + const catalog = await gateway.request('commands.catalog') + + const items = (catalog.pairs ?? []) + .map(([command, meta]) => ({ + text: command, + display: command, + meta + })) + .filter(item => !PICKER_OWNED.has(item.text)) + + return { items, query } + } + + const result = await gateway.request<{ items?: CompletionEntry[] }>('complete.slash', { text }) + const items = (result.items ?? []).filter(item => !PICKER_OWNED.has(item.text)) + + return { items, query } + } catch { + return { items: [], query } + } + }, + [gateway] + ) + + const toItem = useCallback((entry: CompletionEntry, index: number): Unstable_TriggerItem => { + const command = commandText(entry.text) + const display = textValue(entry.display, commandText(entry.text)) + const meta = textValue(entry.meta) + + const metadata: SlashItemMetadata = { + command, + display, + meta + } + + return { + id: `${entry.text}|${index}`, + type: 'slash', + label: display.startsWith('/') ? display.slice(1) : display, + ...(meta ? { description: meta } : {}), + metadata + } + }, []) + + return useLiveCompletionAdapter({ enabled, fetcher, toItem }) +} 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 483451e21c..c445246393 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 @@ -85,13 +85,11 @@ export function useVoiceConversation({ } const appendSpeechText = (text: string) => { - const cleaned = text - - if (!cleaned) { + if (!text) { return } - speechBufferRef.current = `${speechBufferRef.current} ${cleaned}`.trim() + speechBufferRef.current = `${speechBufferRef.current} ${text}`.trim() } 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 f8eae715ff..21062ff0eb 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -1,8 +1,7 @@ -import { ComposerPrimitive, unstable_useMentionAdapter, useAui, useAuiState } from '@assistant-ui/react' +import { ComposerPrimitive, useAui, useAuiState } from '@assistant-ui/react' import { useStore } from '@nanostores/react' import LiquidGlass from 'liquid-glass-react' -import { FileText } from 'lucide-react' -import { type ClipboardEvent, type CSSProperties, useEffect, useMemo, useRef, useState } from 'react' +import { type ClipboardEvent, type CSSProperties, useEffect, useRef, useState } from 'react' import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text' import { chatMessageText } from '@/lib/chat-messages' @@ -17,7 +16,6 @@ import { ASK_PLACEHOLDERS, COMPOSER_BACKDROP_STYLE, DEFAULT_MAX_RECORDING_SECONDS, - DIRECTIVE_ICONS, EDGE_NEWLINES_RE, EXPAND_HEIGHT_PX, NARROW_VIEWPORT, @@ -26,10 +24,14 @@ import { } from './constants' import { ContextMenu } from './context-menu' import { ComposerControls } from './controls' -import { buildMentionCategories, DirectivePopover } from './directive-popover' +import { DirectivePopover } from './directive-popover' +import { HelpHint } from './help-hint' +import { useAtCompletions } from './hooks/use-at-completions' import { useComposerGlassTweaks } from './hooks/use-composer-glass-tweaks' +import { useSlashCompletions } from './hooks/use-slash-completions' import { useVoiceConversation } from './hooks/use-voice-conversation' import { useVoiceRecorder } from './hooks/use-voice-recorder' +import { SlashPopover } from './slash-popover' import type { ChatBarProps } from './types' import { UrlDialog } from './url-dialog' import { VoiceActivity, VoicePlaybackActivity } from './voice-activity' @@ -40,9 +42,12 @@ function trimPastedEdgeNewlines(text: string): string { export function ChatBar({ busy, + cwd, disabled, focusKey, + gateway, maxRecordingSeconds = DEFAULT_MAX_RECORDING_SECONDS, + sessionId, state, onCancel, onAddUrl, @@ -76,19 +81,13 @@ export function ChatBar({ () => ASK_PLACEHOLDERS[Math.floor(Math.random() * ASK_PLACEHOLDERS.length)] || 'Ask anything' ) - const mentionCategories = useMemo(() => buildMentionCategories(state.tools.suggestions), [state.tools.suggestions]) - - const mention = unstable_useMentionAdapter({ - categories: mentionCategories, - includeModelContextTools: false, - formatter: hermesDirectiveFormatter, - iconMap: DIRECTIVE_ICONS, - fallbackIcon: FileText - }) + const at = useAtCompletions({ gateway: gateway ?? null, sessionId: sessionId ?? null, cwd: cwd ?? null }) + const slash = useSlashCompletions({ gateway: gateway ?? null }) const stacked = expanded || stack const hasComposerPayload = draft.trim().length > 0 || attachments.length > 0 const canSubmit = busy || hasComposerPayload + const showHelpHint = draft === '?' const glassTweaks = useComposerGlassTweaks() @@ -344,20 +343,27 @@ export function ChatBar({ return ( <> - { e.preventDefault() submitDraft() }} ref={composerRef} > + {showHelpHint && } + +
diff --git a/apps/desktop/src/app/chat/composer/slash-popover.tsx b/apps/desktop/src/app/chat/composer/slash-popover.tsx new file mode 100644 index 0000000000..ece5cba207 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/slash-popover.tsx @@ -0,0 +1,63 @@ +import type { Unstable_DirectiveFormatter, Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core' +import { ComposerPrimitive } from '@assistant-ui/react' + +import { + ComposerCompletionDrawer, + CompletionDrawerEmpty, + COMPLETION_DRAWER_ROW_CLASS +} from './completion-drawer' + +const slashFormatter: Unstable_DirectiveFormatter = { + serialize(item: Unstable_TriggerItem): string { + const metadata = item.metadata as { command?: unknown; display?: unknown } | undefined + const command = typeof metadata?.command === 'string' ? metadata.command : null + + if (command) { + return command + } + + return `/${item.label}` + }, + parse() { + return [] + } +} + +export function SlashPopover({ adapter, loading }: { adapter: Unstable_TriggerAdapter; loading: boolean }) { + return ( + + + + {items => ( +
+ {items.length === 0 ? ( + + Try /help for the full list. + + ) : ( + items.map((item, index) => { + const meta = item.metadata as { command?: string; display?: string; meta?: string } | undefined + const display = meta?.display ?? meta?.command ?? `/${item.label}` + const description = meta?.meta || item.description + + return ( + + {display} + {description && ( + {description} + )} + + ) + }) + )} +
+ )} +
+
+ ) +} diff --git a/apps/desktop/src/app/chat/composer/types.ts b/apps/desktop/src/app/chat/composer/types.ts index 7a39715bc8..f7318183ba 100644 --- a/apps/desktop/src/app/chat/composer/types.ts +++ b/apps/desktop/src/app/chat/composer/types.ts @@ -28,6 +28,9 @@ export interface ChatBarProps { focusKey?: string | null maxRecordingSeconds?: number state: ChatBarState + gateway?: import('@/hermes').HermesGateway | null + sessionId?: string | null + cwd?: string | null onCancel: () => void onAddContextRef?: (refText: string, label?: string, detail?: string) => void onAddUrl?: (url: string) => void diff --git a/apps/desktop/src/app/chat/composer/voice-activity.tsx b/apps/desktop/src/app/chat/composer/voice-activity.tsx index 2f653bc198..59f090c91c 100644 --- a/apps/desktop/src/app/chat/composer/voice-activity.tsx +++ b/apps/desktop/src/app/chat/composer/voice-activity.tsx @@ -1,5 +1,7 @@ +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 { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' @@ -8,6 +10,15 @@ import { $voicePlayback } from '@/store/voice-playback' import type { VoiceActivityState } from './types' +type BrowserAudioContext = typeof AudioContext + +interface ElementAnalyser { + analyser: AnalyserNode + context: AudioContext +} + +const elementAnalysers = new WeakMap() + function formatElapsed(seconds: number) { const safeSeconds = Math.max(0, Math.floor(seconds)) const minutes = Math.floor(safeSeconds / 60) @@ -40,21 +51,80 @@ function VoiceLevelBars({ level, active }: { active: boolean; level: number }) { ) } -function PlaybackBars() { - const bars = [820, 940, 760, 880, 700, 980, 790] +function PlaybackWaveform({ audioElement }: { audioElement: HTMLAudioElement | null }) { + const provider = useMemo( + () => ({ + onAudioData(callback) { + if (!audioElement) { + return () => undefined + } + + 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) + } + }), + [audioElement] + ) + + const { source } = useCustomAudio({ + provider, + status: audioElement ? 'active' : 'idle' + }) return ( - ) } @@ -129,7 +199,7 @@ export function VoicePlaybackActivity() {
{title} - {!preparing && } + {!preparing && }