feat: add install readme et al

This commit is contained in:
Brooklyn Nicholson
2026-05-01 22:20:05 -05:00
parent 935970898f
commit 420f68e4e2
46 changed files with 3462 additions and 327 deletions
@@ -46,13 +46,7 @@ export function ComposerCompletionDrawer({
)
}
export function CompletionDrawerEmpty({
children,
title
}: {
children?: ReactNode
title: string
}) {
export function CompletionDrawerEmpty({ children, title }: { children?: ReactNode; title: string }) {
return (
<div className="px-3 py-3 text-sm text-muted-foreground">
<p>{title}</p>
@@ -1,4 +1,13 @@
import { Clipboard, FileText, FolderOpen, ImageIcon, Link, type LucideIcon, MessageSquareText, Plus } from 'lucide-react'
import {
Clipboard,
FileText,
FolderOpen,
ImageIcon,
Link,
type LucideIcon,
MessageSquareText,
Plus
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
@@ -178,13 +178,7 @@ function ConversationIndicator({
{bars.map((weight, index) => {
const height = listening ? 0.3 + Math.min(0.7, normalized * weight) : 0.3
return (
<span
className="w-0.5 rounded-full bg-current"
key={index}
style={{ height: `${height * 100}%` }}
/>
)
return <span className="w-0.5 rounded-full bg-current" key={index} style={{ height: `${height * 100}%` }} />
})}
</span>
)
@@ -204,11 +198,7 @@ function DictationButton({
const active = state.active || status !== 'idle'
const aria =
status === 'recording'
? 'Stop dictation'
: status === 'transcribing'
? 'Transcribing dictation'
: 'Voice dictation'
status === 'recording' ? 'Stop dictation' : status === 'transcribing' ? 'Transcribing dictation' : 'Voice dictation'
return (
<Button
@@ -1,11 +1,7 @@
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
import { ComposerPrimitive, type Unstable_MentionDirective } from '@assistant-ui/react'
import {
ComposerCompletionDrawer,
CompletionDrawerEmpty,
COMPLETION_DRAWER_ROW_CLASS
} from './completion-drawer'
import { COMPLETION_DRAWER_ROW_CLASS, CompletionDrawerEmpty, ComposerCompletionDrawer } from './completion-drawer'
export function DirectivePopover({
adapter,
@@ -44,11 +40,7 @@ function DirectiveRow({ index, item }: { index: number; item: Unstable_TriggerIt
const description = metadata?.meta || item.description
return (
<ComposerPrimitive.Unstable_TriggerPopoverItem
className={COMPLETION_DRAWER_ROW_CLASS}
index={index}
item={item}
>
<ComposerPrimitive.Unstable_TriggerPopoverItem className={COMPLETION_DRAWER_ROW_CLASS} index={index} item={item}>
<span className="shrink-0 truncate font-mono font-medium leading-5 text-foreground">{display}</span>
{description && <span className="min-w-0 truncate leading-5 text-muted-foreground/80">{description}</span>}
</ComposerPrimitive.Unstable_TriggerPopoverItem>
@@ -59,7 +59,9 @@ function Row({ description, keyLabel, mono = false }: { description: string; key
return (
<div className="flex min-w-0 items-baseline gap-2 rounded-md px-2.5 py-1 text-xs">
<span
className={mono ? 'shrink-0 truncate font-mono font-medium text-foreground/85' : 'shrink-0 truncate text-foreground/85'}
className={
mono ? 'shrink-0 truncate font-mono font-medium text-foreground/85' : 'shrink-0 truncate text-foreground/85'
}
>
{keyLabel}
</span>
@@ -181,6 +181,7 @@ export function useMicRecorder(): { handle: MicRecorderHandle; level: number; re
['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 {
@@ -38,9 +38,10 @@ function commandText(value: string): string {
}
/** Live `/` completions backed by the gateway's `complete.slash` RPC. */
export function useSlashCompletions(options: {
gateway: HermesGateway | null
}): { adapter: Unstable_TriggerAdapter; loading: boolean } {
export function useSlashCompletions(options: { gateway: HermesGateway | null }): {
adapter: Unstable_TriggerAdapter
loading: boolean
} {
const { gateway } = options
const enabled = Boolean(gateway)
@@ -104,7 +104,11 @@ export function useVoiceConversation({
}
if (!force && buffer.length > 220) {
const softBoundary = Math.max(buffer.lastIndexOf(', ', 180), buffer.lastIndexOf('; ', 180), buffer.lastIndexOf(': ', 180))
const softBoundary = Math.max(
buffer.lastIndexOf(', ', 180),
buffer.lastIndexOf('; ', 180),
buffer.lastIndexOf(': ', 180)
)
if (softBoundary > 80) {
const chunk = buffer.slice(0, softBoundary + 1).trim()
@@ -123,33 +127,21 @@ export function useVoiceConversation({
return buffer
}
const handleTurn = useCallback(async (forceTranscribe = false) => {
if (turnClosingRef.current) {
return
}
turnClosingRef.current = true
clearTurnTimeout()
setStatus('transcribing')
try {
const result = await handle.stop()
if (!result || (!result.heardSpeech && !forceTranscribe) || !onTranscribeAudio) {
if (enabledRef.current && !mutedRef.current && !busyRef.current && statusRef.current !== 'speaking') {
pendingStartRef.current = true
}
setStatus('idle')
const handleTurn = useCallback(
async (forceTranscribe = false) => {
if (turnClosingRef.current) {
return
}
try {
const transcript = (await onTranscribeAudio(result.audio)).trim()
turnClosingRef.current = true
clearTurnTimeout()
setStatus('transcribing')
if (!transcript) {
if (enabledRef.current) {
try {
const result = await handle.stop()
if (!result || (!result.heardSpeech && !forceTranscribe) || !onTranscribeAudio) {
if (enabledRef.current && !mutedRef.current && !busyRef.current && statusRef.current !== 'speaking') {
pendingStartRef.current = true
}
@@ -158,23 +150,38 @@ export function useVoiceConversation({
return
}
awaitingSpokenResponseRef.current = true
resetSpeechBuffer()
await onSubmit(transcript)
setStatus('thinking')
} catch (error) {
notifyError(error, 'Voice transcription failed')
try {
const transcript = (await onTranscribeAudio(result.audio)).trim()
if (enabledRef.current && !mutedRef.current && !busyRef.current) {
pendingStartRef.current = true
if (!transcript) {
if (enabledRef.current) {
pendingStartRef.current = true
}
setStatus('idle')
return
}
awaitingSpokenResponseRef.current = true
resetSpeechBuffer()
await onSubmit(transcript)
setStatus('thinking')
} catch (error) {
notifyError(error, 'Voice transcription failed')
if (enabledRef.current && !mutedRef.current && !busyRef.current) {
pendingStartRef.current = true
}
setStatus('idle')
}
setStatus('idle')
} finally {
turnClosingRef.current = false
}
} finally {
turnClosingRef.current = false
}
}, [handle, onSubmit, onTranscribeAudio])
},
[handle, onSubmit, onTranscribeAudio]
)
const startListening = useCallback(async () => {
pendingStartRef.current = false
@@ -210,25 +217,22 @@ export function useVoiceConversation({
}
}, [handle, handleTurn, onFatalError])
const speak = useCallback(
async (text: string) => {
setStatus('speaking')
const speak = useCallback(async (text: string) => {
setStatus('speaking')
try {
await playSpeechText(text, { source: 'voice-conversation' })
} catch (error) {
notifyError(error, 'Voice playback failed')
} finally {
if (enabledRef.current) {
pendingStartRef.current = true
setStatus('idle')
} else {
setStatus('idle')
}
try {
await playSpeechText(text, { source: 'voice-conversation' })
} catch (error) {
notifyError(error, 'Voice playback failed')
} finally {
if (enabledRef.current) {
pendingStartRef.current = true
setStatus('idle')
} else {
setStatus('idle')
}
},
[]
)
}
}, [])
const start = useCallback(async () => {
if (!onTranscribeAudio) {
@@ -86,10 +86,7 @@ export function useVoiceRecorder({
startedAtRef.current = Date.now()
setElapsedSeconds(0)
setVoiceStatus('recording')
intervalRef.current = window.setInterval(
() => setElapsedSeconds((Date.now() - startedAtRef.current) / 1000),
250
)
intervalRef.current = window.setInterval(() => setElapsedSeconds((Date.now() - startedAtRef.current) / 1000), 250)
const cap = Math.max(1, Math.min(Math.trunc(maxRecordingSeconds), 600))
timeoutRef.current = window.setTimeout(() => void stop(), cap * 1000)
} catch (error) {
+2 -7
View File
@@ -378,10 +378,7 @@ export function ChatBar({
loading={at.loading}
/>
<SlashPopover adapter={slash.adapter} loading={slash.loading} />
<div
className="pointer-events-none absolute inset-0"
style={{ background: glassTweaks.fadeBackground }}
/>
<div className="pointer-events-none absolute inset-0" style={{ background: glassTweaks.fadeBackground }} />
<div className="relative w-full">
<div
className={cn(
@@ -430,9 +427,7 @@ export function ChatBar({
>
<VoiceActivity state={voiceActivityState} />
<VoicePlaybackActivity />
{attachments.length > 0 && (
<AttachmentList attachments={attachments} onRemove={onRemoveAttachment} />
)}
{attachments.length > 0 && <AttachmentList attachments={attachments} onRemove={onRemoveAttachment} />}
{stacked ? (
<>
{input}
@@ -1,11 +1,7 @@
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'
import { COMPLETION_DRAWER_ROW_CLASS, CompletionDrawerEmpty, ComposerCompletionDrawer } from './completion-drawer'
const slashFormatter: Unstable_DirectiveFormatter = {
serialize(item: Unstable_TriggerItem): string {
+3 -1
View File
@@ -1,3 +1,5 @@
import type { HermesGateway } from '@/hermes'
export interface ContextSuggestion {
text: string
display: string
@@ -28,7 +30,7 @@ export interface ChatBarProps {
focusKey?: string | null
maxRecordingSeconds?: number
state: ChatBarState
gateway?: import('@/hermes').HermesGateway | null
gateway?: HermesGateway | null
sessionId?: string | null
cwd?: string | null
onCancel: () => void
@@ -2,7 +2,14 @@ import { Globe } from 'lucide-react'
import type * as React from 'react'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
const URL_HINT = /^https?:\/\//i
@@ -162,11 +162,7 @@ function PlaybackWaveform({ audioElement }: { audioElement: HTMLAudioElement | n
return <canvas aria-hidden="true" className="block h-4 w-[88px]" ref={canvasRef} />
}
export function VoiceActivity({
state
}: {
state: VoiceActivityState
}) {
export function VoiceActivity({ state }: { state: VoiceActivityState }) {
if (state.status === 'idle') {
return null
}
@@ -194,7 +190,9 @@ export function VoiceActivity({
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="truncate font-medium text-foreground/85">{title}</span>
<span className="font-mono text-[0.6875rem] text-muted-foreground/85">{formatElapsed(state.elapsedSeconds)}</span>
<span className="font-mono text-[0.6875rem] text-muted-foreground/85">
{formatElapsed(state.elapsedSeconds)}
</span>
</div>
<VoiceLevelBars active={recording} level={state.level} />
@@ -2,11 +2,7 @@ import { useCallback } from 'react'
import { formatRefValue } from '@/components/assistant-ui/directive-text'
import { attachmentId, contextPath, pathLabel } from '@/lib/chat-runtime'
import {
addComposerAttachment,
type ComposerAttachment,
removeComposerAttachment
} from '@/store/composer'
import { addComposerAttachment, type ComposerAttachment, removeComposerAttachment } from '@/store/composer'
import { notify, notifyError } from '@/store/notifications'
import type { ImageAttachResponse, ImageDetachResponse } from '../../types'
@@ -92,6 +88,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
session_id: activeSessionId,
path
})
const attachedPath = result.path || path
if (result.attached) {
@@ -14,8 +14,10 @@ import {
$gatewayState
} from '@/store/session'
interface ChatRightRailProps
extends Pick<React.ComponentProps<typeof SessionInspector>, 'onBrowseCwd' | 'onChangeCwd'> {
interface ChatRightRailProps extends Pick<
React.ComponentProps<typeof SessionInspector>,
'onBrowseCwd' | 'onChangeCwd'
> {
onOpenModelPicker: () => void
onSelectPersonality: (name: string) => void
}
+9 -5
View File
@@ -74,12 +74,16 @@ export function ChatSidebar({
const sessionsLoading = useStore($sessionsLoading)
const workingSessionIds = useStore($workingSessionIds)
const sortedSessions = useMemo(() => [...sessions].sort((a, b) => {
const aTime = a.last_active || a.started_at || 0
const bTime = b.last_active || b.started_at || 0
const sortedSessions = useMemo(
() =>
[...sessions].sort((a, b) => {
const aTime = a.last_active || a.started_at || 0
const bTime = b.last_active || b.started_at || 0
return bTime - aTime
}), [sessions])
return bTime - aTime
}),
[sessions]
)
const sessionsById = useMemo(() => new Map(sessions.map(session => [session.id, session])), [sessions])
const workingSessionIdSet = useMemo(() => new Set(workingSessionIds), [workingSessionIds])