rowboat/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx
gagan a12bf4837b
feat(voice): audio-reactive waveform while recording (no live transcript) (#634)
* feat(voice): show audio-reactive waveform instead of live transcript

When recording, the chat input now displays only a live waveform that
accumulates from the left and grows to full width, with bar heights
driven by real mic amplitude. The transcribed words are still captured
and submitted, just not shown while recording. New bars animate in and
flow smoothly at ~16 updates/sec.

* feat(voice): auto-gain waveform bar heights to track voice dynamics

Normalize each frame's amplitude against a running peak (instant attack,
slow release) at capture time and map it with a near-linear curve, so bar
heights accurately reflect how loud/soft the voice is regardless of mic
gain — replacing the old fixed-gain sqrt curve that saturated near max.
2026-06-23 02:40:56 +05:30

1531 lines
61 KiB
TypeScript

import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import {
ArrowUp,
AudioLines,
ChevronDown,
FileArchive,
FileCode2,
FileIcon,
FileSpreadsheet,
FileText,
FileVideo,
FolderCheck,
FolderClock,
FolderCog,
FolderOpen,
Globe,
Headphones,
ImagePlus,
LoaderIcon,
Lock,
Mic,
MoreHorizontal,
Plus,
ShieldCheck,
Square,
Terminal,
X,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
type AttachmentIconKind,
getAttachmentDisplayName,
getAttachmentIconKind,
getAttachmentToneClass,
getAttachmentTypeLabel,
} from '@/lib/attachment-presentation'
import { getExtension, getFileDisplayName, getMimeFromExtension, isImageMime } from '@/lib/file-utils'
import { cn } from '@/lib/utils'
import {
type FileMention,
type PromptInputMessage,
PromptInputProvider,
PromptInputTextarea,
usePromptInputController,
} from '@/components/ai-elements/prompt-input'
import { toast } from 'sonner'
export type StagedAttachment = {
id: string
path: string
filename: string
mimeType: string
isImage: boolean
size: number
thumbnailUrl?: string
}
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 // 10MB
const MAX_VISIBLE_RECENT_WORK_DIRS = 3
const MAX_STORED_RECENT_WORK_DIRS = 8
const CHAT_INPUT_TOOLTIP_DELAY_MS = 1000
// Stored in the workspace (~/.rowboat/config) so it travels with the workspace and
// stays consistent with the other config/*.json files (e.g. coding-agents.json).
const RECENT_WORK_DIRS_CONFIG_PATH = 'config/recent-work-dirs.json'
const RECENT_WORK_DIRS_CHANGED_EVENT = 'rowboat-chat-recent-work-dirs-changed'
const providerDisplayNames: Record<string, string> = {
openai: 'OpenAI',
anthropic: 'Anthropic',
google: 'Gemini',
ollama: 'Ollama',
openrouter: 'OpenRouter',
aigateway: 'AI Gateway',
'openai-compatible': 'OpenAI-Compatible',
rowboat: 'Rowboat',
}
type ProviderName = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible" | "rowboat"
interface ConfiguredModel {
provider: ProviderName
model: string
}
type RecentWorkDir = {
path: string
lastUsedAt: number
}
export interface SelectedModel {
provider: string
model: string
}
export type PermissionMode = 'manual' | 'auto'
function getSelectedModelDisplayName(model: string) {
return model.split('/').pop() || model
}
function getAttachmentIcon(kind: AttachmentIconKind) {
switch (kind) {
case 'audio':
return AudioLines
case 'video':
return FileVideo
case 'spreadsheet':
return FileSpreadsheet
case 'archive':
return FileArchive
case 'code':
return FileCode2
case 'text':
return FileText
default:
return FileIcon
}
}
function normalizeRecentWorkDir(value: unknown): RecentWorkDir | null {
if (typeof value === 'string') {
const path = value.trim()
return path ? { path, lastUsedAt: 0 } : null
}
if (!value || typeof value !== 'object') return null
const entry = value as Record<string, unknown>
const path = typeof entry.path === 'string' ? entry.path.trim() : ''
const lastUsedAt = typeof entry.lastUsedAt === 'number' && Number.isFinite(entry.lastUsedAt)
? entry.lastUsedAt
: 0
return path ? { path, lastUsedAt } : null
}
async function readRecentWorkDirs(): Promise<RecentWorkDir[]> {
try {
const result = await window.ipc.invoke('workspace:readFile', { path: RECENT_WORK_DIRS_CONFIG_PATH })
const parsed = JSON.parse(result.data)
if (!Array.isArray(parsed)) return []
const seen = new Set<string>()
const dirs: RecentWorkDir[] = []
for (const value of parsed) {
const entry = normalizeRecentWorkDir(value)
if (!entry || seen.has(entry.path)) continue
seen.add(entry.path)
dirs.push(entry)
if (dirs.length >= MAX_STORED_RECENT_WORK_DIRS) break
}
return dirs
} catch {
// File missing or invalid — no recents yet.
return []
}
}
async function writeRecentWorkDirs(dirs: RecentWorkDir[]) {
try {
await window.ipc.invoke('workspace:writeFile', {
path: RECENT_WORK_DIRS_CONFIG_PATH,
data: JSON.stringify(dirs.slice(0, MAX_STORED_RECENT_WORK_DIRS), null, 2),
})
} catch (err) {
console.error('Failed to persist recent work directories', err)
}
// Notify other mounted chat inputs in this window to re-read.
window.dispatchEvent(new CustomEvent(RECENT_WORK_DIRS_CHANGED_EVENT))
}
function formatRecentWorkDirTime(lastUsedAt: number) {
if (!lastUsedAt) return ''
const now = Date.now()
const diffMs = Math.max(0, now - lastUsedAt)
const minute = 60 * 1000
const hour = 60 * minute
const day = 24 * hour
if (diffMs < minute) return 'now'
if (diffMs < hour) return `${Math.max(1, Math.floor(diffMs / minute))}m ago`
if (diffMs < day) return `${Math.floor(diffMs / hour)}h ago`
const used = new Date(lastUsedAt)
const yesterday = new Date(now - day)
if (
used.getFullYear() === yesterday.getFullYear() &&
used.getMonth() === yesterday.getMonth() &&
used.getDate() === yesterday.getDate()
) {
return 'Yesterday'
}
if (diffMs < 7 * day) {
return used.toLocaleDateString(undefined, { weekday: 'short' })
}
return used.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
}
function compactWorkDirPath(path: string) {
return path.replace(/^\/Users\/[^/]+/, '~')
}
interface ChatInputInnerProps {
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => void
onStop?: () => void
isProcessing: boolean
isStopping?: boolean
isActive: boolean
presetMessage?: string
onPresetMessageConsumed?: () => void
runId?: string | null
initialDraft?: string
onDraftChange?: (text: string) => void
isRecording?: boolean
recordingText?: string
recordingState?: 'connecting' | 'listening'
/** Live mic amplitude history (RMS per frame) driving the recording waveform. */
audioLevelsRef?: React.MutableRefObject<number[]>
onStartRecording?: () => void
onSubmitRecording?: () => void
onCancelRecording?: () => void
voiceAvailable?: boolean
ttsAvailable?: boolean
ttsEnabled?: boolean
ttsMode?: 'summary' | 'full'
onToggleTts?: () => void
onTtsModeChange?: (mode: 'summary' | 'full') => void
/** Fired when the user picks a different model in the dropdown (only when no run exists yet). */
onSelectedModelChange?: (model: SelectedModel | null) => void
/** Work directory for this chat (per-chat). Null when none is set. */
workDir?: string | null
/** Fired when the user sets/changes/clears the work directory for this chat. */
onWorkDirChange?: (value: string | null) => void
/**
* Set when this chat is bound to a Code-section session: the work directory
* and coding agent come from the session and are FROZEN — the backend pins
* them server-side regardless, so the composer must not pretend otherwise.
*/
codeSessionLock?: { cwd: string; agent: 'claude' | 'codex' } | null
}
function ChatInputInner({
onSubmit,
onStop,
isProcessing,
isStopping,
isActive,
presetMessage,
onPresetMessageConsumed,
runId,
initialDraft,
onDraftChange,
isRecording,
recordingText,
audioLevelsRef,
onStartRecording,
onSubmitRecording,
onCancelRecording,
voiceAvailable,
ttsAvailable,
ttsEnabled,
ttsMode,
onToggleTts,
onTtsModeChange,
onSelectedModelChange,
workDir = null,
onWorkDirChange,
codeSessionLock = null,
}: ChatInputInnerProps) {
const controller = usePromptInputController()
const message = controller.textInput.value
const [attachments, setAttachments] = useState<StagedAttachment[]>([])
const [focusNonce, setFocusNonce] = useState(0)
const fileInputRef = useRef<HTMLInputElement>(null)
const canSubmit = (Boolean(message.trim()) || attachments.length > 0) && !isProcessing
const [configuredModels, setConfiguredModels] = useState<ConfiguredModel[]>([])
const [activeModelKey, setActiveModelKey] = useState('')
const [lockedModel, setLockedModel] = useState<SelectedModel | null>(null)
const [searchEnabled, setSearchEnabled] = useState(false)
const [searchAvailable, setSearchAvailable] = useState(false)
const [isRowboatConnected, setIsRowboatConnected] = useState(false)
const [codingAgent, setCodingAgent] = useState<'claude' | 'codex'>('claude')
const [codeModeEnabled, setCodeModeEnabled] = useState(false)
const [codeModeFeatureEnabled, setCodeModeFeatureEnabled] = useState(false)
const [permissionMode, setPermissionMode] = useState<PermissionMode>('auto')
const [recentWorkDirs, setRecentWorkDirs] = useState<RecentWorkDir[]>([])
// Responsive toolbar: measure real overflow and progressively collapse items
// right→left until everything fits. Stages:
// 1 code→icon · 2 perm→icon · 3 search label hidden · 4 workDir→icon
// 5 code→menu · 6 perm→menu · 7 search→menu · 8 workDir→menu
// Once items move into the "⋯" overflow menu (≥5) no icon is ever hidden.
// overflow-hidden on the left group is the hard guarantee against any overlap.
const toolbarRef = useRef<HTMLDivElement>(null)
const leftGroupRef = useRef<HTMLDivElement>(null)
const lastWidthRef = useRef(0)
const [collapseLevel, setCollapseLevel] = useState(0)
// Re-evaluate from scratch (level 0) whenever the available width changes…
useEffect(() => {
const outer = toolbarRef.current
if (!outer) return
const ro = new ResizeObserver(() => {
const w = outer.clientWidth
if (w !== lastWidthRef.current) {
lastWidthRef.current = w
setCollapseLevel(0)
}
})
ro.observe(outer)
return () => ro.disconnect()
}, [])
// …or when the *set* of items changes (an item appears/disappears, or the model
// name width changes). Deliberately excludes the in-place toggles (searchEnabled,
// permissionMode, codeModeEnabled, codingAgent): those fire from the overflow menu
// for items already inside it, so resetting here would unmount the open menu. The
// no-dep effect below still re-collapses if any toggle happens to widen the row.
useLayoutEffect(() => {
setCollapseLevel(0)
}, [workDir, searchAvailable, codeModeFeatureEnabled, lockedModel, activeModelKey])
// After each render, if the left group still overflows, collapse one more step.
// Runs before paint, so the intermediate (overflowing) state is never visible.
useLayoutEffect(() => {
const el = leftGroupRef.current
if (!el) return
if (el.scrollWidth > el.clientWidth + 1 && collapseLevel < 8) {
setCollapseLevel((l) => Math.min(8, l + 1))
}
})
// When a run exists, freeze the dropdown to the run's resolved model+provider.
useEffect(() => {
if (!runId) {
setLockedModel(null)
setPermissionMode('auto')
return
}
let cancelled = false
window.ipc.invoke('runs:fetch', { runId }).then((run) => {
if (cancelled) return
if (run.provider && run.model) {
setLockedModel({ provider: run.provider, model: run.model })
}
setPermissionMode(run.permissionMode ?? 'manual')
}).catch(() => { /* legacy run or fetch failure — leave unlocked */ })
return () => { cancelled = true }
}, [runId])
useEffect(() => {
const syncRecentWorkDirs = () => { void readRecentWorkDirs().then(setRecentWorkDirs) }
syncRecentWorkDirs()
window.addEventListener(RECENT_WORK_DIRS_CHANGED_EVENT, syncRecentWorkDirs)
return () => {
window.removeEventListener(RECENT_WORK_DIRS_CHANGED_EVENT, syncRecentWorkDirs)
}
}, [])
// Check Rowboat sign-in state
useEffect(() => {
window.ipc.invoke('oauth:getState', null).then((result) => {
setIsRowboatConnected(result.config?.rowboat?.connected ?? false)
}).catch(() => setIsRowboatConnected(false))
}, [isActive])
// Update sign-in state when OAuth events fire
useEffect(() => {
const cleanup = window.ipc.on('oauth:didConnect', () => {
window.ipc.invoke('oauth:getState', null).then((result) => {
setIsRowboatConnected(result.config?.rowboat?.connected ?? false)
}).catch(() => setIsRowboatConnected(false))
})
return cleanup
}, [])
// Load the list of models the user can choose from.
// Signed-in: gateway model list. Signed-out: providers configured in models.json.
const loadModelConfig = useCallback(async () => {
try {
if (isRowboatConnected) {
const listResult = await window.ipc.invoke('models:list', null)
const rowboatProvider = listResult.providers?.find(
(p: { id: string }) => p.id === 'rowboat'
)
const models: ConfiguredModel[] = (rowboatProvider?.models || []).map(
(m: { id: string }) => ({ provider: 'rowboat', model: m.id })
)
setConfiguredModels(models)
} else {
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' })
const parsed = JSON.parse(result.data)
const models: ConfiguredModel[] = []
if (parsed?.providers) {
for (const [flavor, entry] of Object.entries(parsed.providers)) {
const e = entry as Record<string, unknown>
const modelList: string[] = Array.isArray(e.models) ? e.models as string[] : []
const singleModel = typeof e.model === 'string' ? e.model : ''
const allModels = modelList.length > 0 ? modelList : singleModel ? [singleModel] : []
for (const model of allModels) {
if (model) {
models.push({ provider: flavor as ProviderName, model })
}
}
}
}
setConfiguredModels(models)
}
} catch {
// No config yet
}
}, [isRowboatConnected])
useEffect(() => {
loadModelConfig()
}, [isActive, loadModelConfig])
// Reload when model config changes (e.g. from settings dialog)
useEffect(() => {
const handler = () => { loadModelConfig() }
window.addEventListener('models-config-changed', handler)
return () => window.removeEventListener('models-config-changed', handler)
}, [loadModelConfig])
// Load the global code-mode feature flag (from settings) and stay in sync.
useEffect(() => {
const load = () => {
window.ipc.invoke('codeMode:getConfig', null)
.then((r) => setCodeModeFeatureEnabled(r.enabled))
.catch(() => setCodeModeFeatureEnabled(false))
}
load()
window.addEventListener('code-mode-config-changed', load)
return () => window.removeEventListener('code-mode-config-changed', load)
}, [])
// If the feature is turned off in settings, also turn off any per-conversation chip.
useEffect(() => {
if (!codeModeFeatureEnabled && codeModeEnabled) {
setCodeModeEnabled(false)
}
}, [codeModeFeatureEnabled, codeModeEnabled])
// Cross-platform basename — handles both / and \ separators.
const basename = useCallback((p: string): string => {
const trimmed = p.replace(/[\\/]+$/, '')
const idx = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
return idx >= 0 ? trimmed.slice(idx + 1) : trimmed
}, [])
const rememberWorkDir = useCallback(async (dir: string) => {
const trimmed = dir.trim()
if (!trimmed) return
const next = [
{ path: trimmed, lastUsedAt: Date.now() },
...(await readRecentWorkDirs()).filter((item) => item.path !== trimmed),
].slice(0, MAX_STORED_RECENT_WORK_DIRS)
setRecentWorkDirs(next)
await writeRecentWorkDirs(next)
}, [])
// Load coding-agent preference for a given workdir.
// Storage: config/coding-agents.json — { [workDirPath]: 'claude' | 'codex' }
const loadCodingAgentFor = useCallback(async (dir: string | null): Promise<'claude' | 'codex'> => {
if (!dir) return 'claude'
try {
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/coding-agents.json' })
const parsed = JSON.parse(result.data) as Record<string, unknown>
const value = parsed?.[dir]
if (value === 'codex' || value === 'claude') return value
} catch {
/* file missing or invalid — fall through to default */
}
return 'claude'
}, [])
const persistCodingAgent = useCallback(async (dir: string, agent: 'claude' | 'codex') => {
const existing: Record<string, 'claude' | 'codex'> = {}
try {
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/coding-agents.json' })
const parsed = JSON.parse(result.data) as Record<string, unknown>
for (const [k, v] of Object.entries(parsed ?? {})) {
if (v === 'claude' || v === 'codex') existing[k] = v
}
} catch { /* start fresh */ }
existing[dir] = agent
await window.ipc.invoke('workspace:writeFile', {
path: 'config/coding-agents.json',
data: JSON.stringify(existing, null, 2),
})
}, [])
// A chat bound to a Code-section session has its work directory and coding
// agent frozen to the session's — the backend pins them server-side, so the
// composer reflects that instead of offering controls that wouldn't apply.
const isCodeLocked = Boolean(codeSessionLock)
const effectiveWorkDir = codeSessionLock?.cwd ?? workDir
// Work directory is owned per-chat by the parent (App). This component only
// drives the picker dialog and reports changes up via onWorkDirChange. Whenever
// the work directory changes, load its persisted coding-agent preference.
useEffect(() => {
if (codeSessionLock) {
setCodingAgent(codeSessionLock.agent)
return
}
let cancelled = false
loadCodingAgentFor(workDir).then((agent) => {
if (!cancelled) setCodingAgent(agent)
})
return () => { cancelled = true }
}, [workDir, loadCodingAgentFor, codeSessionLock])
useEffect(() => {
if (isActive && workDir && !isCodeLocked) void rememberWorkDir(workDir)
}, [isActive, workDir, rememberWorkDir, isCodeLocked])
const handleSetWorkDir = useCallback(async () => {
if (isCodeLocked) return
try {
let defaultPath: string | undefined = workDir ?? undefined
try {
const { root } = await window.ipc.invoke('workspace:getRoot', null)
const workspaceRel = 'knowledge/Workspace'
const exists = await window.ipc.invoke('workspace:exists', { path: workspaceRel })
if (!exists.exists) {
await window.ipc.invoke('workspace:mkdir', { path: workspaceRel, recursive: true })
}
defaultPath = `${root.replace(/\/$/, '')}/${workspaceRel}`
} catch (err) {
console.error('Failed to resolve Workspace path; falling back to current workDir', err)
}
const { path: chosen } = await window.ipc.invoke('dialog:openDirectory', {
title: 'Choose work directory',
defaultPath,
})
if (!chosen) return
onWorkDirChange?.(chosen)
await rememberWorkDir(chosen)
setCodingAgent(await loadCodingAgentFor(chosen))
toast.success(`Work directory set: ${chosen}`)
} catch (err) {
console.error('Failed to set work directory', err)
toast.error('Failed to set work directory')
}
}, [workDir, onWorkDirChange, rememberWorkDir, loadCodingAgentFor, isCodeLocked])
const handleSelectRecentWorkDir = useCallback(async (dir: string) => {
onWorkDirChange?.(dir)
await rememberWorkDir(dir)
setCodingAgent(await loadCodingAgentFor(dir))
toast.success(`Work directory set: ${dir}`)
}, [onWorkDirChange, rememberWorkDir, loadCodingAgentFor])
const handleClearWorkDir = useCallback(() => {
if (isCodeLocked) return
onWorkDirChange?.(null)
setCodingAgent('claude')
toast.success('Work directory cleared')
}, [onWorkDirChange, isCodeLocked])
const handleToggleCodingAgent = useCallback(async () => {
if (isCodeLocked) return
const next: 'claude' | 'codex' = codingAgent === 'claude' ? 'codex' : 'claude'
setCodingAgent(next)
// Persist only when scoped to a workdir; without one there's nothing to key on.
if (!workDir) return
try {
await persistCodingAgent(workDir, next)
} catch (err) {
console.error('Failed to save coding agent', err)
toast.error('Failed to save coding agent')
// revert on failure
setCodingAgent(codingAgent)
}
}, [workDir, codingAgent, persistCodingAgent, isCodeLocked])
// Check search tool availability (exa or signed-in via gateway)
useEffect(() => {
const checkSearch = async () => {
if (isRowboatConnected) {
setSearchAvailable(true)
return
}
let available = false
try {
const raw = await window.ipc.invoke('workspace:readFile', { path: 'config/exa-search.json' })
const config = JSON.parse(raw.data)
if (config.apiKey) available = true
} catch { /* not configured */ }
setSearchAvailable(available)
}
checkSearch()
}, [isActive, isRowboatConnected])
// Selecting a model affects only the *next* run created from this tab.
// Once a run exists, model is frozen on the run and the dropdown is read-only.
const handleModelChange = useCallback((key: string) => {
if (lockedModel) return
const entry = configuredModels.find((m) => `${m.provider}/${m.model}` === key)
if (!entry) return
setActiveModelKey(key)
onSelectedModelChange?.({ provider: entry.provider, model: entry.model })
}, [configuredModels, lockedModel, onSelectedModelChange])
// Restore the tab draft when this input mounts.
useEffect(() => {
if (initialDraft) {
controller.textInput.setInput(initialDraft)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
onDraftChange?.(message)
}, [message, onDraftChange])
useEffect(() => {
if (presetMessage) {
controller.textInput.setInput(presetMessage)
onPresetMessageConsumed?.()
}
}, [presetMessage, controller.textInput, onPresetMessageConsumed])
const addFiles = useCallback(async (paths: string[]) => {
const newAttachments: StagedAttachment[] = []
for (const filePath of paths) {
try {
const result = await window.ipc.invoke('shell:readFileBase64', { path: filePath })
if (result.size > MAX_ATTACHMENT_SIZE) {
toast.error(`File too large: ${getFileDisplayName(filePath)} (max 10MB)`)
continue
}
const mime = result.mimeType || getMimeFromExtension(getExtension(filePath))
const image = isImageMime(mime)
newAttachments.push({
id: `att-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
path: filePath,
filename: getFileDisplayName(filePath),
mimeType: mime,
isImage: image,
size: result.size,
thumbnailUrl: image ? `data:${mime};base64,${result.data}` : undefined,
})
} catch (err) {
console.error('Failed to read file:', filePath, err)
toast.error(`Failed to read: ${getFileDisplayName(filePath)}`)
}
}
if (newAttachments.length > 0) {
setAttachments((prev) => [...prev, ...newAttachments])
setFocusNonce((value) => value + 1)
}
}, [])
const removeAttachment = useCallback((id: string) => {
setAttachments((prev) => prev.filter((attachment) => attachment.id !== id))
}, [])
const handleSubmit = useCallback(() => {
if (!canSubmit) return
// codeMode is sticky per conversation — don't reset after send. A code
// session forces it (the backend pins the agent anyway).
const effectiveCodeMode = codeSessionLock ? codeSessionLock.agent : (codeModeEnabled ? codingAgent : undefined)
onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments, searchEnabled || undefined, effectiveCodeMode, permissionMode)
controller.textInput.clear()
controller.mentions.clearMentions()
setAttachments([])
// Web search toggle stays on for the rest of the chat session; the user
// turns it off explicitly. (Not persisted across app restarts.)
}, [attachments, canSubmit, controller, message, onSubmit, searchEnabled, codeModeEnabled, codingAgent, permissionMode, workDir, codeSessionLock])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit()
}
}, [handleSubmit])
useEffect(() => {
if (!isActive) return
const onDragOver = (e: DragEvent) => {
if (e.dataTransfer?.types?.includes('Files')) {
e.preventDefault()
}
}
const onDrop = (e: DragEvent) => {
if (e.dataTransfer?.types?.includes('Files')) {
e.preventDefault()
}
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
const paths = Array.from(e.dataTransfer.files)
.map((file) => window.electronUtils?.getPathForFile(file))
.filter(Boolean) as string[]
if (paths.length > 0) {
void addFiles(paths)
}
}
}
document.addEventListener('dragover', onDragOver)
document.addEventListener('drop', onDrop)
return () => {
document.removeEventListener('dragover', onDragOver)
document.removeEventListener('drop', onDrop)
}
}, [addFiles, isActive])
const visibleRecentWorkDirs = recentWorkDirs
.filter((entry) => entry.path !== workDir)
.slice(0, MAX_VISIBLE_RECENT_WORK_DIRS)
const currentWorkDirLabel = effectiveWorkDir ? basename(effectiveWorkDir) || effectiveWorkDir : 'Not set'
const currentWorkDirPath = effectiveWorkDir ? compactWorkDirPath(effectiveWorkDir) : ''
return (
<div className="rowboat-chat-input rounded-lg border border-border bg-background shadow-none">
{attachments.length > 0 && (
<div className="flex flex-wrap gap-2 px-4 pb-1 pt-3">
{attachments.map((attachment) => {
const attachmentType = getAttachmentTypeLabel(attachment)
const attachmentName = getAttachmentDisplayName(attachment)
const Icon = getAttachmentIcon(getAttachmentIconKind(attachment))
return (
<span
key={attachment.id}
className="group relative inline-flex min-w-[230px] max-w-[320px] items-center gap-2 rounded-xl border border-border/50 bg-muted/80 px-2.5 py-2"
>
<span
className={cn(
'flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-lg',
attachment.isImage && attachment.thumbnailUrl
? 'bg-muted'
: getAttachmentToneClass(attachmentType)
)}
>
{attachment.isImage && attachment.thumbnailUrl ? (
<img src={attachment.thumbnailUrl} alt="" className="size-full object-cover" />
) : (
<Icon className="size-5" />
)}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm leading-tight font-medium">{attachmentName}</span>
<span className="block pt-0.5 text-xs leading-tight text-muted-foreground">{attachmentType}</span>
</span>
<button
type="button"
onClick={() => removeAttachment(attachment.id)}
className="absolute right-1 top-1 flex size-5 items-center justify-center rounded-full border border-border/70 bg-background/70 text-muted-foreground opacity-0 transition-[opacity,color] duration-150 hover:text-foreground group-hover:opacity-100 focus-visible:opacity-100"
>
<X className="size-3.5" />
</button>
</span>
)
})}
</div>
)}
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => {
const files = e.target.files
if (!files || files.length === 0) return
const paths = Array.from(files)
.map((file) => window.electronUtils?.getPathForFile(file))
.filter(Boolean) as string[]
if (paths.length > 0) {
void addFiles(paths)
}
e.target.value = ''
}}
/>
{isRecording ? (
/* ── Recording bar ── */
<div className="flex items-center gap-3 px-4 py-3">
<button
type="button"
onClick={onCancelRecording}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Cancel recording"
>
<X className="h-4 w-4" />
</button>
{/* Audio-reactive waveform only — the transcribed words are intentionally
not shown while recording; they're still captured and submitted. */}
<div className="flex flex-1 items-center overflow-hidden">
<VoiceWaveform audioLevelsRef={audioLevelsRef} />
</div>
<Button
size="icon"
onClick={onSubmitRecording}
disabled={!recordingText?.trim()}
className={cn(
'h-7 w-7 shrink-0 rounded-full transition-all',
recordingText?.trim()
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'bg-muted text-muted-foreground'
)}
>
<ArrowUp className="h-4 w-4" />
</Button>
</div>
) : (
/* ── Normal input ── */
<>
<div className="px-4 pt-4 pb-2">
<PromptInputTextarea
placeholder="Type your message..."
onKeyDown={handleKeyDown}
autoFocus={isActive}
focusTrigger={isActive ? `${runId ?? 'new'}:${focusNonce}` : undefined}
className="min-h-6 rounded-none border-0 py-0 shadow-none focus-visible:ring-0"
/>
</div>
<div ref={toolbarRef} className="flex items-center gap-2 px-4 pb-3">
<div ref={leftGroupRef} className="flex min-w-0 items-center gap-2 overflow-hidden">
<DropdownMenu>
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Add"
>
<Plus className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top">
{isCodeLocked ? 'Add files' : workDir ? 'Add files or change work directory' : 'Add files or set work directory'}
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="start" className="w-72 max-w-[calc(100vw-2rem)] p-2">
<div className="rounded-[14px] border border-border/80 bg-background p-1">
<DropdownMenuItem onSelect={() => fileInputRef.current?.click()} className="h-9 rounded-[9px] px-2.5">
<ImagePlus className="size-4" />
<span>Add files or photos</span>
</DropdownMenuItem>
{/* A bound code session pins the directory — show it, no controls. */}
{isCodeLocked ? (
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
<TooltipTrigger asChild>
<div className="flex h-auto items-center gap-2 rounded-[9px] px-2.5 py-2 text-muted-foreground">
<FolderCheck className="size-4 shrink-0" />
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate text-sm">{currentWorkDirLabel}</span>
<span className="truncate text-xs">Pinned by the coding session</span>
</span>
</div>
</TooltipTrigger>
<TooltipContent side="right">{effectiveWorkDir}</TooltipContent>
</Tooltip>
) : (
/* Working directory lives behind a submenu so the main menu stays to two
items. One hover/click away for power users; out of the way otherwise. */
<DropdownMenuSub>
<DropdownMenuSubTrigger className="h-9 rounded-[9px] px-2.5">
<FolderCog className="size-4" />
<span className="flex min-w-0 flex-1 items-center justify-between gap-3">
<span>Set working directory</span>
<span className="min-w-0 max-w-[110px] truncate text-xs text-muted-foreground">
{currentWorkDirLabel}
</span>
</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-72 max-w-[calc(100vw-2rem)] p-1">
{/* Current selection — shown for context only when one is set. */}
{workDir && (
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
<TooltipTrigger asChild>
<div className="mb-1 flex items-center gap-2 rounded-[9px] bg-blue-50/80 px-2.5 py-2 text-blue-700 dark:bg-blue-950/30 dark:text-blue-300">
<FolderCheck className="size-4 shrink-0 text-blue-600 dark:text-blue-300" />
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate text-sm font-medium">{currentWorkDirLabel}</span>
<span className="truncate text-xs text-blue-700/70 dark:text-blue-300/70">
{currentWorkDirPath}
</span>
</span>
</div>
</TooltipTrigger>
<TooltipContent side="right">{workDir}</TooltipContent>
</Tooltip>
)}
{/* Primary action: choose when unset, change when set. Always on top. */}
<DropdownMenuItem
onSelect={() => { void handleSetWorkDir() }}
className="h-9 rounded-[9px] px-2.5"
>
<FolderOpen className="size-4" />
<span>{workDir ? 'Change folder…' : 'Choose a folder…'}</span>
</DropdownMenuItem>
{visibleRecentWorkDirs.length > 0 && (
<>
<div className="px-2.5 pb-1 pt-2 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground">
Recent
</div>
{visibleRecentWorkDirs.map((entry) => {
const name = basename(entry.path) || entry.path
const when = formatRecentWorkDirTime(entry.lastUsedAt)
return (
<Tooltip key={entry.path} delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
<TooltipTrigger asChild>
<DropdownMenuItem
onSelect={() => { void handleSelectRecentWorkDir(entry.path) }}
className="h-8 rounded-[9px] px-2.5"
>
<FolderClock className="size-4" />
<span className="min-w-0 flex-1 truncate">{name}</span>
{when && <span className="shrink-0 text-xs text-muted-foreground">{when}</span>}
</DropdownMenuItem>
</TooltipTrigger>
<TooltipContent side="right">{entry.path}</TooltipContent>
</Tooltip>
)
})}
</>
)}
{/* Clear — only meaningful once a directory is set. Kept at the bottom. */}
{workDir && (
<>
<div className="my-1 h-px bg-border/60" />
<DropdownMenuItem
onSelect={handleClearWorkDir}
className="h-8 rounded-[9px] px-2.5 text-red-600 focus:bg-red-50 focus:text-red-600 dark:text-red-400 dark:focus:bg-red-950/30"
>
<X className="size-4" />
<span>Clear folder</span>
</DropdownMenuItem>
</>
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
{effectiveWorkDir && collapseLevel < 8 && (
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
<TooltipTrigger asChild>
{/* Level 4: collapse to a square icon */}
<div className={cn(
"group flex h-7 shrink-0 items-center rounded-full border border-border bg-muted/40 text-xs text-muted-foreground transition-colors",
!isCodeLocked && "hover:bg-muted hover:text-foreground",
collapseLevel >= 4 ? "w-7 justify-center" : "max-w-[180px] pl-2.5 pr-2"
)}>
<button
type="button"
onClick={handleSetWorkDir}
disabled={isCodeLocked}
className={cn("flex min-w-0 items-center gap-1.5", isCodeLocked && "cursor-default")}
>
{isCodeLocked
? <Lock className="h-3 w-3 shrink-0" />
: <FolderCog className="h-3.5 w-3.5 shrink-0" />}
{collapseLevel < 4 && <span className="truncate">{basename(effectiveWorkDir) || effectiveWorkDir}</span>}
</button>
{collapseLevel < 4 && !isCodeLocked && (
<button
type="button"
onClick={handleClearWorkDir}
aria-label="Remove work directory"
className="flex h-3.5 w-0 shrink-0 items-center justify-center overflow-hidden opacity-0 transition-all duration-150 ease-out hover:text-red-500 group-hover:ml-1 group-hover:w-3.5 group-hover:opacity-100"
>
<X className="h-3.5 w-3.5 shrink-0" />
</button>
)}
</div>
</TooltipTrigger>
<TooltipContent side="top">
{isCodeLocked
? `Pinned by the coding session: ${effectiveWorkDir}`
: `Work directory: ${effectiveWorkDir}`}
</TooltipContent>
</Tooltip>
)}
{searchAvailable && collapseLevel < 7 && (
<button
type="button"
onClick={() => setSearchEnabled((v) => !v)}
aria-label="Search"
aria-pressed={searchEnabled}
className={cn(
'flex h-7 shrink-0 items-center rounded-full border px-1.5 transition-colors duration-150 ease-out',
searchEnabled
? 'border-blue-200 bg-blue-50 text-blue-600 hover:bg-blue-100 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-400 dark:hover:bg-blue-900'
: 'border-transparent text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
<Globe className="h-4 w-4 shrink-0" />
{searchEnabled && collapseLevel < 3 && (
<span className="ml-1.5 whitespace-nowrap text-xs font-medium">
Search
</span>
)}
</button>
)}
{collapseLevel < 6 && (
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => {
if (runId) return
setPermissionMode((mode) => mode === 'auto' ? 'manual' : 'auto')
}}
disabled={Boolean(runId)}
className={cn(
"flex h-7 shrink-0 items-center gap-1.5 rounded-full text-xs font-medium transition-colors",
collapseLevel >= 2 ? "w-7 justify-center" : "px-2.5",
permissionMode === 'auto'
? "bg-secondary text-foreground hover:bg-secondary/70"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
runId && "cursor-not-allowed opacity-70 hover:bg-secondary"
)}
aria-label="Permission mode"
>
<ShieldCheck className="h-3.5 w-3.5 shrink-0" />
{collapseLevel < 2 && <span>{permissionMode === 'auto' ? 'Auto' : 'Manual'}</span>}
</button>
</TooltipTrigger>
<TooltipContent side="top">
{runId
? `Permission mode is fixed for this run: ${permissionMode === 'auto' ? 'Auto' : 'Manual'}`
: permissionMode === 'auto'
? 'Auto-permission on — click for manual approval prompts'
: 'Manual approval prompts — click for auto-permission'}
</TooltipContent>
</Tooltip>
)}
{codeModeFeatureEnabled && collapseLevel < 5 && ((isCodeLocked || codeModeEnabled) ? (
collapseLevel >= 1 ? (
/* Level 1: collapse the pill to a single icon */
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => { if (!isCodeLocked) setCodeModeEnabled(false) }}
disabled={isCodeLocked}
className={cn(
"flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-secondary text-foreground transition-colors",
isCodeLocked ? "cursor-default" : "hover:bg-secondary/70",
)}
>
<Terminal className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top">
{isCodeLocked
? `Coding session — ${codingAgent === 'claude' ? 'Claude Code' : 'Codex'}`
: `Code mode on (${codingAgent === 'claude' ? 'Claude Code' : 'Codex'}) — click to disable`}
</TooltipContent>
</Tooltip>
) : (
<div className="flex h-7 shrink-0 items-center rounded-full bg-secondary text-xs font-medium text-foreground">
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => { if (!isCodeLocked) setCodeModeEnabled(false) }}
disabled={isCodeLocked}
className={cn(
"flex h-full items-center gap-1.5 rounded-l-full pl-2.5 pr-2 transition-colors",
isCodeLocked ? "cursor-default" : "hover:bg-secondary/70",
)}
>
{isCodeLocked ? <Lock className="h-3 w-3" /> : <Terminal className="h-3.5 w-3.5" />}
<span>Code</span>
</button>
</TooltipTrigger>
<TooltipContent side="top">
{isCodeLocked ? 'Pinned by the coding session' : 'Code mode on — click to disable'}
</TooltipContent>
</Tooltip>
<span className="text-foreground/30">·</span>
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleToggleCodingAgent}
disabled={isCodeLocked}
className={cn(
"flex h-full items-center rounded-r-full pl-2 pr-2.5 transition-colors",
isCodeLocked ? "cursor-default" : "hover:bg-secondary/70",
)}
>
<span>{codingAgent === 'claude' ? 'Claude' : 'Codex'}</span>
</button>
</TooltipTrigger>
<TooltipContent side="top">
{isCodeLocked
? `Coding agent fixed by the session: ${codingAgent === 'claude' ? 'Claude Code' : 'Codex'}`
: `Coding agent: ${codingAgent === 'claude' ? 'Claude Code' : 'Codex'} — click to swap`}
</TooltipContent>
</Tooltip>
</div>
)
) : (
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setCodeModeEnabled(true)}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Code mode"
>
<Terminal className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Use a coding agent (Claude Code or Codex)</TooltipContent>
</Tooltip>
))}
</div>
{collapseLevel >= 5 && (
<DropdownMenu>
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="More options"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<MoreHorizontal className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top">More options</TooltipContent>
</Tooltip>
<DropdownMenuContent align="start" side="top" className="min-w-52">
{effectiveWorkDir && collapseLevel >= 8 && (
<DropdownMenuItem disabled={isCodeLocked} onSelect={() => { void handleSetWorkDir() }}>
{isCodeLocked ? <Lock className="size-4" /> : <FolderCog className="size-4" />}
<span className="min-w-0 flex-1 truncate">{basename(effectiveWorkDir) || effectiveWorkDir}</span>
</DropdownMenuItem>
)}
{searchAvailable && collapseLevel >= 7 && (
<DropdownMenuCheckboxItem
checked={searchEnabled}
onSelect={(e) => e.preventDefault()}
onCheckedChange={(c) => setSearchEnabled(Boolean(c))}
>
Web search
</DropdownMenuCheckboxItem>
)}
{collapseLevel >= 6 && (
<DropdownMenuCheckboxItem
checked={permissionMode === 'auto'}
disabled={Boolean(runId)}
onSelect={(e) => e.preventDefault()}
onCheckedChange={(c) => setPermissionMode(c ? 'auto' : 'manual')}
>
Auto-approve actions
</DropdownMenuCheckboxItem>
)}
{codeModeFeatureEnabled && collapseLevel >= 5 && (
<>
<DropdownMenuCheckboxItem
checked={isCodeLocked || codeModeEnabled}
disabled={isCodeLocked}
onSelect={(e) => e.preventDefault()}
onCheckedChange={(c) => setCodeModeEnabled(Boolean(c))}
>
Code mode
</DropdownMenuCheckboxItem>
{(isCodeLocked || codeModeEnabled) && (
<DropdownMenuItem disabled={isCodeLocked} onSelect={(e) => { e.preventDefault(); handleToggleCodingAgent() }}>
<Terminal className="size-4" />
<span className="min-w-0 flex-1">Coding agent</span>
<span className="text-xs text-muted-foreground">{codingAgent === 'claude' ? 'Claude' : 'Codex'}</span>
</DropdownMenuItem>
)}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
<div className="flex-1" />
{lockedModel ? (
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
<TooltipTrigger asChild>
<span className="flex h-7 min-w-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground">
<span className="min-w-0 truncate">{getSelectedModelDisplayName(lockedModel.model)}</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">
{providerDisplayNames[lockedModel.provider] || lockedModel.provider} fixed for this chat
</TooltipContent>
</Tooltip>
) : configuredModels.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-7 min-w-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<span className="min-w-0 truncate">
{getSelectedModelDisplayName(configuredModels.find((m) => `${m.provider}/${m.model}` === activeModelKey)?.model || configuredModels[0]?.model || 'Model')}
</span>
<ChevronDown className="h-3 w-3 shrink-0" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup value={activeModelKey} onValueChange={handleModelChange}>
{configuredModels.map((m) => {
const key = `${m.provider}/${m.model}`
return (
<DropdownMenuRadioItem key={key} value={key}>
<span className="truncate">{m.model}</span>
<span className="ml-2 text-xs text-muted-foreground">{providerDisplayNames[m.provider] || m.provider}</span>
</DropdownMenuRadioItem>
)
})}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
) : null}
{onToggleTts && ttsAvailable && (
<div className="flex shrink-0 items-center">
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
<TooltipTrigger asChild>
<button
type="button"
onClick={onToggleTts}
className={cn(
'relative flex h-7 w-7 shrink-0 items-center justify-center rounded-full transition-colors',
ttsEnabled
? 'text-foreground hover:bg-muted'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
aria-label={ttsEnabled ? 'Disable voice output' : 'Enable voice output'}
>
<Headphones className="h-4 w-4" />
{!ttsEnabled && (
<span className="absolute inset-0 flex items-center justify-center pointer-events-none">
<span className="block h-[1.5px] w-5 -rotate-45 rounded-full bg-muted-foreground" />
</span>
)}
</button>
</TooltipTrigger>
<TooltipContent side="top">
{ttsEnabled ? 'Voice output on' : 'Voice output off'}
</TooltipContent>
</Tooltip>
{ttsEnabled && onTtsModeChange && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-7 w-4 shrink-0 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
>
<ChevronDown className="h-3 w-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup value={ttsMode ?? 'summary'} onValueChange={(v) => onTtsModeChange(v as 'summary' | 'full')}>
<DropdownMenuRadioItem value="summary">Speak summary</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="full">Speak full response</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
)}
{voiceAvailable && onStartRecording && (
<button
type="button"
onClick={onStartRecording}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Voice input"
>
<Mic className="h-4 w-4" />
</button>
)}
{isProcessing ? (
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
<TooltipTrigger asChild>
<Button
size="icon"
onClick={onStop}
aria-label={isStopping ? 'Force stop generation' : 'Stop generation'}
className={cn(
'h-7 w-7 shrink-0 rounded-full transition-all',
isStopping
? 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
: 'bg-primary text-primary-foreground hover:bg-primary/90'
)}
>
{isStopping ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<Square className="h-3 w-3 fill-current" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{isStopping ? 'Click again to force stop' : 'Stop generation'}
</TooltipContent>
</Tooltip>
) : (
<Button
size="icon"
onClick={handleSubmit}
disabled={!canSubmit}
className={cn(
'h-7 w-7 shrink-0 rounded-full transition-all',
canSubmit
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'bg-muted text-muted-foreground'
)}
>
<ArrowUp className="h-4 w-4" />
</Button>
)}
</div>
</>
)}
</div>
)
}
/** Animated waveform bars for the recording indicator */
// Live recording waveform. Each bar is one captured audio frame; bars accumulate
// from the left and grow rightward until they fill the width, then scroll (oldest
// drops off the left). Bar height tracks that frame's mic amplitude, so the
// waveform visibly reacts to how loud the user is speaking.
const WAVE_BAR_WIDTH = 3 // px
const WAVE_BAR_GAP = 2 // px
const WAVE_BAR_PITCH = WAVE_BAR_WIDTH + WAVE_BAR_GAP
const WAVE_BAR_MIN = 1.5 // px — floor so silence still shows a faint line
const WAVE_BAR_MAX = 18 // px — fits inside the h-5 (20px) row
const WAVE_CURVE = 0.8 // <1 lifts quiet speech slightly; near-linear keeps loud peaks tall
function waveBarHeight(level: number): number {
// `level` is already auto-gained to ~0..1 in the hook, so map it close to linearly
// (a gentle curve) — louder voice ⇒ visibly taller bar, quiet ⇒ short.
const amp = Math.min(1, Math.max(0, level)) ** WAVE_CURVE
return WAVE_BAR_MIN + amp * (WAVE_BAR_MAX - WAVE_BAR_MIN)
}
function VoiceWaveform({ audioLevelsRef }: { audioLevelsRef?: React.MutableRefObject<number[]> }) {
const containerRef = useRef<HTMLDivElement>(null)
const [bars, setBars] = useState<number[]>([])
// How many bars fit in the current width; recomputed on resize.
const maxBarsRef = useRef(48)
useEffect(() => {
const el = containerRef.current
if (!el) return
const measure = () => {
maxBarsRef.current = Math.max(1, Math.floor(el.clientWidth / WAVE_BAR_PITCH))
}
measure()
const ro = new ResizeObserver(measure)
ro.observe(el)
return () => ro.disconnect()
}, [])
useEffect(() => {
if (!audioLevelsRef) return
let raf = 0
let lastSig = ''
const tick = () => {
const levels = audioLevelsRef.current
const maxBars = maxBarsRef.current
const next = levels.length > maxBars ? levels.slice(levels.length - maxBars) : levels
// Only re-render when the visible window actually changed. Length covers
// the growth phase; the trailing value covers the scrolling phase once full.
const sig = `${next.length}:${next.length ? next[next.length - 1] : 0}`
if (sig !== lastSig) {
lastSig = sig
setBars(next.slice())
}
raf = requestAnimationFrame(tick)
}
raf = requestAnimationFrame(tick)
return () => cancelAnimationFrame(raf)
}, [audioLevelsRef])
return (
<div
ref={containerRef}
className="flex h-5 w-full items-center overflow-hidden"
style={{ gap: `${WAVE_BAR_GAP}px` }}
>
{/* Each newly-appended bar mounts with `voice-bar-in` (grows + fades in) so it
doesn't pop. Once the strip is full and values scroll through the bars, the
height transition makes them flow smoothly instead of stepping. */}
{bars.map((level, i) => (
<span
key={i}
className="shrink-0 rounded-full bg-primary"
style={{
width: `${WAVE_BAR_WIDTH}px`,
height: `${waveBarHeight(level)}px`,
transformOrigin: 'center',
transition: 'height 90ms linear',
animation: 'voice-bar-in 130ms ease-out',
}}
/>
))}
<style>{`
@keyframes voice-bar-in {
from { transform: scaleY(0.15); opacity: 0; }
to { transform: scaleY(1); opacity: 1; }
}
`}</style>
</div>
)
}
export interface ChatInputWithMentionsProps {
knowledgeFiles: string[]
recentFiles: string[]
visibleFiles: string[]
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => void
onStop?: () => void
isProcessing: boolean
isStopping?: boolean
isActive?: boolean
presetMessage?: string
onPresetMessageConsumed?: () => void
runId?: string | null
initialDraft?: string
onDraftChange?: (text: string) => void
isRecording?: boolean
recordingText?: string
recordingState?: 'connecting' | 'listening'
audioLevelsRef?: React.MutableRefObject<number[]>
onStartRecording?: () => void
onSubmitRecording?: () => void
onCancelRecording?: () => void
voiceAvailable?: boolean
ttsAvailable?: boolean
ttsEnabled?: boolean
ttsMode?: 'summary' | 'full'
onToggleTts?: () => void
onTtsModeChange?: (mode: 'summary' | 'full') => void
onSelectedModelChange?: (model: SelectedModel | null) => void
workDir?: string | null
onWorkDirChange?: (value: string | null) => void
/** Set when this chat is bound to a Code-section session — freezes workdir + agent. */
codeSessionLock?: { cwd: string; agent: 'claude' | 'codex' } | null
}
export function ChatInputWithMentions({
knowledgeFiles,
recentFiles,
visibleFiles,
onSubmit,
onStop,
isProcessing,
isStopping,
isActive = true,
presetMessage,
onPresetMessageConsumed,
runId,
initialDraft,
onDraftChange,
isRecording,
recordingText,
recordingState,
audioLevelsRef,
onStartRecording,
onSubmitRecording,
onCancelRecording,
voiceAvailable,
ttsAvailable,
ttsEnabled,
ttsMode,
onToggleTts,
onTtsModeChange,
onSelectedModelChange,
workDir,
onWorkDirChange,
codeSessionLock,
}: ChatInputWithMentionsProps) {
return (
<PromptInputProvider knowledgeFiles={knowledgeFiles} recentFiles={recentFiles} visibleFiles={visibleFiles}>
<ChatInputInner
onSubmit={onSubmit}
onStop={onStop}
isProcessing={isProcessing}
isStopping={isStopping}
isActive={isActive}
presetMessage={presetMessage}
onPresetMessageConsumed={onPresetMessageConsumed}
runId={runId}
initialDraft={initialDraft}
onDraftChange={onDraftChange}
isRecording={isRecording}
recordingText={recordingText}
recordingState={recordingState}
audioLevelsRef={audioLevelsRef}
onStartRecording={onStartRecording}
onSubmitRecording={onSubmitRecording}
onCancelRecording={onCancelRecording}
voiceAvailable={voiceAvailable}
ttsAvailable={ttsAvailable}
ttsEnabled={ttsEnabled}
ttsMode={ttsMode}
onToggleTts={onToggleTts}
onTtsModeChange={onTtsModeChange}
onSelectedModelChange={onSelectedModelChange}
workDir={workDir}
onWorkDirChange={onWorkDirChange}
codeSessionLock={codeSessionLock}
/>
</PromptInputProvider>
)
}