diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css index bdb1430f..1fd8c287 100644 --- a/apps/x/apps/renderer/src/App.css +++ b/apps/x/apps/renderer/src/App.css @@ -1017,6 +1017,17 @@ var(--rowboat-shadow); } +.dark .rowboat-code-chat-input { + border-color: color-mix(in oklab, var(--border) 76%, transparent); + background: #000000; + box-shadow: none; +} + +.dark .rowboat-code-chat-input:focus-within { + border-color: color-mix(in oklab, var(--ring) 70%, var(--border) 30%); + box-shadow: 0 0 0 1px color-mix(in oklab, var(--ring) 30%, transparent); +} + [data-slot="message-content"] { line-height: 1.62; } diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 7ccb56e3..cf1d3d8f 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -6413,6 +6413,7 @@ function App() { session={activeCodeSession.session} status={activeCodeSession.status} onOpenDiff={setCodeDiffPath} + voiceAvailable={voiceAvailable} /> ) : isRightPaneContext && ( diff --git a/apps/x/apps/renderer/src/components/ai-elements/conversation.tsx b/apps/x/apps/renderer/src/components/ai-elements/conversation.tsx index 7a3f8836..6dfeedaf 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/conversation.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/conversation.tsx @@ -43,6 +43,7 @@ export const Conversation = ({ const contentRef = useRef(null); const scrollRef = useRef(null); const spacerRef = useRef(null); + const stickToBottomRef = useRef(true); const [isAtBottom, setIsAtBottom] = useState(true); const updateBottomState = useCallback(() => { @@ -50,7 +51,9 @@ export const Conversation = ({ if (!container) return; const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight; - setIsAtBottom(distanceFromBottom <= BOTTOM_THRESHOLD_PX); + const atBottom = distanceFromBottom <= BOTTOM_THRESHOLD_PX; + stickToBottomRef.current = atBottom; + setIsAtBottom(atBottom); }, []); const applyAnchorLayout = useCallback( @@ -131,7 +134,12 @@ export const Conversation = ({ cancelAnimationFrame(rafId); } rafId = requestAnimationFrame(() => { + const shouldStick = !anchorMessageId && stickToBottomRef.current; applyAnchorLayout(false); + if (shouldStick) { + container.scrollTop = container.scrollHeight; + updateBottomState(); + } }); }; @@ -178,6 +186,7 @@ export const Conversation = ({ const container = scrollRef.current; if (!container) return; container.scrollTop = container.scrollHeight; + stickToBottomRef.current = true; updateBottomState(); }, [updateBottomState]); diff --git a/apps/x/apps/renderer/src/components/code/cm.ts b/apps/x/apps/renderer/src/components/code/cm.ts index 4b75c1b2..8fa523f7 100644 --- a/apps/x/apps/renderer/src/components/code/cm.ts +++ b/apps/x/apps/renderer/src/components/code/cm.ts @@ -29,6 +29,12 @@ const darkHighlight = HighlightStyle.define([ ]) export function cmBaseExtensions(isDark: boolean): Extension[] { + const bg = isDark ? '#0f1117' : '#ffffff' + const panelBg = isDark ? '#151821' : '#f6f8fa' + const text = isDark ? '#d4d4d8' : '#24292f' + const muted = isDark ? '#7d8590' : '#6e7781' + const border = isDark ? '#2f3542' : '#d0d7de' + return [ lineNumbers(), bracketMatching(), @@ -39,18 +45,62 @@ export function cmBaseExtensions(isDark: boolean): Extension[] { EditorView.theme( { '&': { - backgroundColor: 'transparent', + backgroundColor: bg, + color: text, fontSize: '12px', height: '100%', }, + '.cm-editor': { + backgroundColor: bg, + color: text, + }, + '.cm-content': { + caretColor: text, + }, '.cm-scroller': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', overflow: 'auto', }, + '.cm-line': { + color: text, + }, '.cm-gutters': { - backgroundColor: 'transparent', - border: 'none', - color: isDark ? '#6b7280' : '#9ca3af', + backgroundColor: panelBg, + borderRight: `1px solid ${border}`, + color: muted, + }, + '.cm-activeLine': { + backgroundColor: isDark ? 'rgba(110, 118, 129, 0.16)' : 'rgba(175, 184, 193, 0.18)', + }, + '.cm-activeLineGutter': { + backgroundColor: isDark ? 'rgba(110, 118, 129, 0.16)' : 'rgba(175, 184, 193, 0.18)', + }, + '.cm-selectionBackground, &.cm-focused .cm-selectionBackground, .cm-content ::selection': { + backgroundColor: isDark ? 'rgba(88, 166, 255, 0.32)' : 'rgba(9, 105, 218, 0.22)', + }, + '.cm-panels, .cm-panel': { + backgroundColor: panelBg, + color: text, + borderColor: border, + }, + '.cm-mergeView': { + backgroundColor: bg, + color: text, + }, + '.cm-mergeViewEditors': { + backgroundColor: bg, + }, + '.cm-mergeView .cm-editor': { + borderColor: border, + }, + '.cm-changedLine': { + backgroundColor: isDark ? 'rgba(56, 139, 253, 0.14)' : 'rgba(9, 105, 218, 0.08)', + }, + '.cm-deletedChunk': { + backgroundColor: isDark ? 'rgba(248, 81, 73, 0.14)' : 'rgba(255, 235, 233, 0.95)', + }, + '.cm-insertedLine, .cm-insertedChunk': { + backgroundColor: isDark ? 'rgba(63, 185, 80, 0.14)' : 'rgba(234, 255, 234, 0.95)', }, '&.cm-focused': { outline: 'none' }, // GitHub-style expander bar for folded unchanged regions (@codemirror/merge). diff --git a/apps/x/apps/renderer/src/components/code/code-chat.tsx b/apps/x/apps/renderer/src/components/code/code-chat.tsx index 6c34c295..28a55db5 100644 --- a/apps/x/apps/renderer/src/components/code/code-chat.tsx +++ b/apps/x/apps/renderer/src/components/code/code-chat.tsx @@ -1,5 +1,5 @@ -import { useEffect, useRef, useState } from 'react' -import { ArrowUp, FileText, Loader2, LoaderIcon, Plus, Square, Terminal, X } from 'lucide-react' +import { useEffect, useRef, useState, type DragEvent, type MutableRefObject } from 'react' +import { ArrowUp, FileText, Loader2, LoaderIcon, Mic, Plus, Square, Terminal, X } from 'lucide-react' import type { CodeSession, CodeSessionStatus } from '@x/shared/src/code-sessions.js' import { cn } from '@/lib/utils' import { toast } from 'sonner' @@ -15,9 +15,54 @@ import { CodeRunPermissionRequest, CodingRunTimeline } from '@/components/coding import { PermissionRequest } from '@/components/ai-elements/permission-request' import { AskHumanRequest } from '@/components/ai-elements/ask-human-request' import { WebSearchResult } from '@/components/ai-elements/web-search-result' +import { useVoiceMode } from '@/hooks/useVoiceMode' import { useCodeChat, isDirectTurn, isChatToolCall, isChatErrorMessage, type CodeChatItem } from './use-code-chat' const AGENT_LABEL: Record = { claude: 'Claude Code', codex: 'Codex' } +const WAVE_BAR_WIDTH = 3 +const WAVE_BAR_GAP = 2 +const WAVE_BAR_MIN = 1.5 +const WAVE_BAR_MAX = 18 + +function VoiceWaveform({ audioLevelsRef }: { audioLevelsRef: MutableRefObject }) { + const [bars, setBars] = useState([]) + + useEffect(() => { + let raf = 0 + let lastSig = '' + const tick = () => { + const levels = audioLevelsRef.current + const next = levels.length > 48 ? levels.slice(levels.length - 48) : levels + 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 ( +
+ {bars.map((level, i) => { + const amp = Math.min(1, Math.max(0, level)) ** 0.8 + return ( + + ) + })} +
+ ) +} function RowboatToolCall({ item, onOpenDiff }: { item: ToolCall; onOpenDiff: (path: string) => void }) { const [open, setOpen] = useState(false) @@ -97,10 +142,12 @@ export function CodeChat({ session, status, onOpenDiff, + voiceAvailable = false, }: { session: CodeSession status: CodeSessionStatus onOpenDiff: (path: string) => void + voiceAvailable?: boolean }) { const { items, liveText, isProcessing, compactionStatus, contextUsage, @@ -110,8 +157,12 @@ export function CodeChat({ const [draft, setDraft] = useState('') const [stopping, setStopping] = useState(false) const textareaRef = useRef(null) + const voice = useVoiceMode() + const voiceWarmup = voice.warmup const busy = isProcessing || status === 'working' || status === 'needs-you' + const recording = voice.state !== 'idle' + const recordingStopping = voice.state === 'submitting' const contextUsedPercent = contextUsage ? Math.min(100, Math.round((contextUsage.used / contextUsage.size) * 100)) : null @@ -123,6 +174,7 @@ export function CodeChat({ setDraft('') setAttachments([]) setStopping(false) + voice.cancel() textareaRef.current?.focus() }, [session.id]) @@ -130,6 +182,10 @@ export function CodeChat({ if (!busy) setStopping(false) }, [busy]) + useEffect(() => { + if (voiceAvailable) voiceWarmup() + }, [voiceAvailable, voiceWarmup]) + const addAttachments = (paths: string[]) => { const cleaned = paths.filter(Boolean) if (cleaned.length === 0) return @@ -145,7 +201,7 @@ export function CodeChat({ textareaRef.current?.focus() } - const handleDrop = (e: React.DragEvent) => { + const handleDrop = (e: DragEvent) => { if (!e.dataTransfer?.files?.length) return e.preventDefault() const paths = Array.from(e.dataTransfer.files) @@ -179,6 +235,27 @@ export function CodeChat({ await stop() } + const handleStartRecording = () => { + if (busy) return + void voice.start() + } + + const handleSubmitRecording = async () => { + if (!recording || recordingStopping) return + const text = await voice.submit() + if (!text) return + const result = await send(text) + if (!result.ok && result.error) { + toast.error(result.error) + setDraft(text) + } + } + + const handleCancelRecording = () => { + voice.cancel() + textareaRef.current?.focus() + } + const basename = (p: string) => p.split(/[\\/]/).pop() || p return ( @@ -266,8 +343,8 @@ export function CodeChat({ {/* Composer — mirrors the assistant chat input's look (rounded card, borderless textarea, round primary send / destructive stop). */} -
-
+
+
{attachments.length > 0 && (
{attachments.map((p) => ( @@ -290,22 +367,58 @@ export function CodeChat({ ))}
)} -
-