code mode output sticks to bottom; mic input in direct drive; fix dark mode and minor margins (#649)

This commit is contained in:
arkml 2026-06-30 23:05:39 +05:30 committed by GitHub
parent e6ff631191
commit 470b75e1b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 242 additions and 34 deletions

View file

@ -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;
}

View file

@ -6413,6 +6413,7 @@ function App() {
session={activeCodeSession.session}
status={activeCodeSession.status}
onOpenDiff={setCodeDiffPath}
voiceAvailable={voiceAvailable}
/>
</ResizableRightPane>
) : isRightPaneContext && (

View file

@ -43,6 +43,7 @@ export const Conversation = ({
const contentRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const spacerRef = useRef<HTMLDivElement | null>(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]);

View file

@ -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).

View file

@ -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<string, string> = { 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<number[]> }) {
const [bars, setBars] = useState<number[]>([])
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 (
<div className="flex h-5 w-full items-center overflow-hidden" style={{ gap: WAVE_BAR_GAP }}>
{bars.map((level, i) => {
const amp = Math.min(1, Math.max(0, level)) ** 0.8
return (
<span
key={i}
className="shrink-0 rounded-full bg-primary"
style={{
width: WAVE_BAR_WIDTH,
height: WAVE_BAR_MIN + amp * (WAVE_BAR_MAX - WAVE_BAR_MIN),
transition: 'height 90ms linear',
}}
/>
)
})}
</div>
)
}
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<HTMLTextAreaElement>(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). */}
<div className="p-3">
<div className="rowboat-chat-input mx-auto w-full max-w-3xl rounded-lg border border-border bg-background shadow-none">
<div className="bg-background p-3 dark:bg-black">
<div className="rowboat-chat-input rowboat-code-chat-input mx-auto w-full max-w-3xl 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((p) => (
@ -290,22 +367,58 @@ export function CodeChat({
))}
</div>
)}
<div className="px-4 pb-2 pt-4">
<Textarea
ref={textareaRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
void handleSend()
}
}}
placeholder="Type your message..."
className="max-h-40 min-h-[24px] w-full resize-none border-0 bg-transparent p-0 text-sm shadow-none outline-none focus-visible:ring-0"
rows={2}
/>
</div>
{recording ? (
<div className="flex items-center gap-3 px-4 py-3">
<button
type="button"
onClick={handleCancelRecording}
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>
<div className="flex min-w-0 flex-1 flex-col gap-1 overflow-hidden">
<VoiceWaveform audioLevelsRef={voice.audioLevelsRef} />
<div className={cn('min-h-5 truncate text-sm leading-5', voice.interimText.trim() ? 'text-foreground' : 'text-muted-foreground')}>
{voice.interimText.trim() || (recordingStopping ? 'Finalizing...' : 'Listening...')}
</div>
</div>
<Button
size="icon"
onClick={() => void handleSubmitRecording()}
disabled={recordingStopping}
className={cn(
'h-7 w-7 shrink-0 rounded-full transition-all',
recordingStopping
? 'bg-muted text-muted-foreground'
: 'bg-primary text-primary-foreground hover:bg-primary/90',
)}
>
{recordingStopping ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<ArrowUp className="h-4 w-4" />
)}
</Button>
</div>
) : (
<div className="px-4 pb-2 pt-4">
<Textarea
ref={textareaRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
void handleSend()
}
}}
placeholder="Type your message..."
className="max-h-40 min-h-[24px] w-full resize-none border-0 bg-transparent p-0 text-sm shadow-none outline-none focus-visible:ring-0"
rows={2}
/>
</div>
)}
<div className="flex items-center gap-2 px-3 pb-3">
<Tooltip>
<TooltipTrigger asChild>
@ -320,6 +433,22 @@ export function CodeChat({
</TooltipTrigger>
<TooltipContent side="top">Attach files the agent reads them from disk (or drag & drop)</TooltipContent>
</Tooltip>
{voiceAvailable && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleStartRecording}
disabled={busy || recording}
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 disabled:cursor-not-allowed disabled:opacity-50"
aria-label="Voice input"
>
<Mic className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Voice input</TooltipContent>
</Tooltip>
)}
<span className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
<Terminal className="size-3.5 shrink-0" />
<span className="truncate">Direct straight to {AGENT_LABEL[session.agent]}</span>

View file

@ -338,12 +338,14 @@ export function CodeView({
{terminalOpen ? <ChevronDown className="size-3.5" /> : <ChevronUp className="size-3.5" />}
</button>
{terminalOpen && (
<div style={{ height: terminalHeight }}>
<TerminalPane
key={selectedSession.id}
terminalId={selectedSession.id}
cwd={selectedSession.cwd}
/>
<div className="bg-background pb-3 dark:bg-black" style={{ height: terminalHeight + 12 }}>
<div className="h-full min-h-0">
<TerminalPane
key={selectedSession.id}
terminalId={selectedSession.id}
cwd={selectedSession.cwd}
/>
</div>
</div>
)}
</div>

View file

@ -6,7 +6,7 @@ import { useTheme } from '@/contexts/theme-context'
// xterm color schemes tuned to the app's light/dark backgrounds.
const DARK_THEME = {
background: '#1b1b1f',
background: '#000000',
foreground: '#d4d4d8',
cursor: '#d4d4d8',
selectionBackground: 'rgba(120, 140, 255, 0.3)',
@ -106,5 +106,11 @@ export function TerminalPane({ terminalId, cwd }: { terminalId: string; cwd: str
if (term) term.options.theme = resolvedTheme === 'dark' ? DARK_THEME : LIGHT_THEME
}, [resolvedTheme])
return <div ref={containerRef} className="h-full w-full overflow-hidden px-2 pt-1" />
return (
<div
ref={containerRef}
className="h-full w-full overflow-hidden px-2 pt-1"
style={{ backgroundColor: resolvedTheme === 'dark' ? '#000000' : '#ffffff' }}
/>
)
}