mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
code mode output sticks to bottom; mic input in direct drive; fix dark mode and minor margins (#649)
This commit is contained in:
parent
e6ff631191
commit
470b75e1b6
7 changed files with 242 additions and 34 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6413,6 +6413,7 @@ function App() {
|
|||
session={activeCodeSession.session}
|
||||
status={activeCodeSession.status}
|
||||
onOpenDiff={setCodeDiffPath}
|
||||
voiceAvailable={voiceAvailable}
|
||||
/>
|
||||
</ResizableRightPane>
|
||||
) : isRightPaneContext && (
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue