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);
|
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"] {
|
[data-slot="message-content"] {
|
||||||
line-height: 1.62;
|
line-height: 1.62;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6413,6 +6413,7 @@ function App() {
|
||||||
session={activeCodeSession.session}
|
session={activeCodeSession.session}
|
||||||
status={activeCodeSession.status}
|
status={activeCodeSession.status}
|
||||||
onOpenDiff={setCodeDiffPath}
|
onOpenDiff={setCodeDiffPath}
|
||||||
|
voiceAvailable={voiceAvailable}
|
||||||
/>
|
/>
|
||||||
</ResizableRightPane>
|
</ResizableRightPane>
|
||||||
) : isRightPaneContext && (
|
) : isRightPaneContext && (
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ export const Conversation = ({
|
||||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
const spacerRef = useRef<HTMLDivElement | null>(null);
|
const spacerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const stickToBottomRef = useRef(true);
|
||||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||||
|
|
||||||
const updateBottomState = useCallback(() => {
|
const updateBottomState = useCallback(() => {
|
||||||
|
|
@ -50,7 +51,9 @@ export const Conversation = ({
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
const distanceFromBottom =
|
const distanceFromBottom =
|
||||||
container.scrollHeight - container.scrollTop - container.clientHeight;
|
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(
|
const applyAnchorLayout = useCallback(
|
||||||
|
|
@ -131,7 +134,12 @@ export const Conversation = ({
|
||||||
cancelAnimationFrame(rafId);
|
cancelAnimationFrame(rafId);
|
||||||
}
|
}
|
||||||
rafId = requestAnimationFrame(() => {
|
rafId = requestAnimationFrame(() => {
|
||||||
|
const shouldStick = !anchorMessageId && stickToBottomRef.current;
|
||||||
applyAnchorLayout(false);
|
applyAnchorLayout(false);
|
||||||
|
if (shouldStick) {
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
updateBottomState();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -178,6 +186,7 @@ export const Conversation = ({
|
||||||
const container = scrollRef.current;
|
const container = scrollRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
container.scrollTop = container.scrollHeight;
|
container.scrollTop = container.scrollHeight;
|
||||||
|
stickToBottomRef.current = true;
|
||||||
updateBottomState();
|
updateBottomState();
|
||||||
}, [updateBottomState]);
|
}, [updateBottomState]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,12 @@ const darkHighlight = HighlightStyle.define([
|
||||||
])
|
])
|
||||||
|
|
||||||
export function cmBaseExtensions(isDark: boolean): Extension[] {
|
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 [
|
return [
|
||||||
lineNumbers(),
|
lineNumbers(),
|
||||||
bracketMatching(),
|
bracketMatching(),
|
||||||
|
|
@ -39,18 +45,62 @@ export function cmBaseExtensions(isDark: boolean): Extension[] {
|
||||||
EditorView.theme(
|
EditorView.theme(
|
||||||
{
|
{
|
||||||
'&': {
|
'&': {
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: bg,
|
||||||
|
color: text,
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
},
|
},
|
||||||
|
'.cm-editor': {
|
||||||
|
backgroundColor: bg,
|
||||||
|
color: text,
|
||||||
|
},
|
||||||
|
'.cm-content': {
|
||||||
|
caretColor: text,
|
||||||
|
},
|
||||||
'.cm-scroller': {
|
'.cm-scroller': {
|
||||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
},
|
},
|
||||||
|
'.cm-line': {
|
||||||
|
color: text,
|
||||||
|
},
|
||||||
'.cm-gutters': {
|
'.cm-gutters': {
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: panelBg,
|
||||||
border: 'none',
|
borderRight: `1px solid ${border}`,
|
||||||
color: isDark ? '#6b7280' : '#9ca3af',
|
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' },
|
'&.cm-focused': { outline: 'none' },
|
||||||
// GitHub-style expander bar for folded unchanged regions (@codemirror/merge).
|
// GitHub-style expander bar for folded unchanged regions (@codemirror/merge).
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState, type DragEvent, type MutableRefObject } from 'react'
|
||||||
import { ArrowUp, FileText, Loader2, LoaderIcon, Plus, Square, Terminal, X } from 'lucide-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 type { CodeSession, CodeSessionStatus } from '@x/shared/src/code-sessions.js'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
@ -15,9 +15,54 @@ import { CodeRunPermissionRequest, CodingRunTimeline } from '@/components/coding
|
||||||
import { PermissionRequest } from '@/components/ai-elements/permission-request'
|
import { PermissionRequest } from '@/components/ai-elements/permission-request'
|
||||||
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'
|
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'
|
||||||
import { WebSearchResult } from '@/components/ai-elements/web-search-result'
|
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'
|
import { useCodeChat, isDirectTurn, isChatToolCall, isChatErrorMessage, type CodeChatItem } from './use-code-chat'
|
||||||
|
|
||||||
const AGENT_LABEL: Record<string, string> = { claude: 'Claude Code', codex: 'Codex' }
|
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 }) {
|
function RowboatToolCall({ item, onOpenDiff }: { item: ToolCall; onOpenDiff: (path: string) => void }) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
|
@ -97,10 +142,12 @@ export function CodeChat({
|
||||||
session,
|
session,
|
||||||
status,
|
status,
|
||||||
onOpenDiff,
|
onOpenDiff,
|
||||||
|
voiceAvailable = false,
|
||||||
}: {
|
}: {
|
||||||
session: CodeSession
|
session: CodeSession
|
||||||
status: CodeSessionStatus
|
status: CodeSessionStatus
|
||||||
onOpenDiff: (path: string) => void
|
onOpenDiff: (path: string) => void
|
||||||
|
voiceAvailable?: boolean
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
items, liveText, isProcessing, compactionStatus, contextUsage,
|
items, liveText, isProcessing, compactionStatus, contextUsage,
|
||||||
|
|
@ -110,8 +157,12 @@ export function CodeChat({
|
||||||
const [draft, setDraft] = useState('')
|
const [draft, setDraft] = useState('')
|
||||||
const [stopping, setStopping] = useState(false)
|
const [stopping, setStopping] = useState(false)
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
const voice = useVoiceMode()
|
||||||
|
const voiceWarmup = voice.warmup
|
||||||
|
|
||||||
const busy = isProcessing || status === 'working' || status === 'needs-you'
|
const busy = isProcessing || status === 'working' || status === 'needs-you'
|
||||||
|
const recording = voice.state !== 'idle'
|
||||||
|
const recordingStopping = voice.state === 'submitting'
|
||||||
const contextUsedPercent = contextUsage
|
const contextUsedPercent = contextUsage
|
||||||
? Math.min(100, Math.round((contextUsage.used / contextUsage.size) * 100))
|
? Math.min(100, Math.round((contextUsage.used / contextUsage.size) * 100))
|
||||||
: null
|
: null
|
||||||
|
|
@ -123,6 +174,7 @@ export function CodeChat({
|
||||||
setDraft('')
|
setDraft('')
|
||||||
setAttachments([])
|
setAttachments([])
|
||||||
setStopping(false)
|
setStopping(false)
|
||||||
|
voice.cancel()
|
||||||
textareaRef.current?.focus()
|
textareaRef.current?.focus()
|
||||||
}, [session.id])
|
}, [session.id])
|
||||||
|
|
||||||
|
|
@ -130,6 +182,10 @@ export function CodeChat({
|
||||||
if (!busy) setStopping(false)
|
if (!busy) setStopping(false)
|
||||||
}, [busy])
|
}, [busy])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (voiceAvailable) voiceWarmup()
|
||||||
|
}, [voiceAvailable, voiceWarmup])
|
||||||
|
|
||||||
const addAttachments = (paths: string[]) => {
|
const addAttachments = (paths: string[]) => {
|
||||||
const cleaned = paths.filter(Boolean)
|
const cleaned = paths.filter(Boolean)
|
||||||
if (cleaned.length === 0) return
|
if (cleaned.length === 0) return
|
||||||
|
|
@ -145,7 +201,7 @@ export function CodeChat({
|
||||||
textareaRef.current?.focus()
|
textareaRef.current?.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDrop = (e: React.DragEvent) => {
|
const handleDrop = (e: DragEvent) => {
|
||||||
if (!e.dataTransfer?.files?.length) return
|
if (!e.dataTransfer?.files?.length) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const paths = Array.from(e.dataTransfer.files)
|
const paths = Array.from(e.dataTransfer.files)
|
||||||
|
|
@ -179,6 +235,27 @@ export function CodeChat({
|
||||||
await stop()
|
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
|
const basename = (p: string) => p.split(/[\\/]/).pop() || p
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -266,8 +343,8 @@ export function CodeChat({
|
||||||
|
|
||||||
{/* Composer — mirrors the assistant chat input's look (rounded card,
|
{/* Composer — mirrors the assistant chat input's look (rounded card,
|
||||||
borderless textarea, round primary send / destructive stop). */}
|
borderless textarea, round primary send / destructive stop). */}
|
||||||
<div className="p-3">
|
<div className="bg-background p-3 dark:bg-black">
|
||||||
<div className="rowboat-chat-input mx-auto w-full max-w-3xl rounded-lg border border-border bg-background shadow-none">
|
<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 && (
|
{attachments.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2 px-4 pb-1 pt-3">
|
<div className="flex flex-wrap gap-2 px-4 pb-1 pt-3">
|
||||||
{attachments.map((p) => (
|
{attachments.map((p) => (
|
||||||
|
|
@ -290,22 +367,58 @@ export function CodeChat({
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="px-4 pb-2 pt-4">
|
{recording ? (
|
||||||
<Textarea
|
<div className="flex items-center gap-3 px-4 py-3">
|
||||||
ref={textareaRef}
|
<button
|
||||||
value={draft}
|
type="button"
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onClick={handleCancelRecording}
|
||||||
onKeyDown={(e) => {
|
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"
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
aria-label="Cancel recording"
|
||||||
e.preventDefault()
|
>
|
||||||
void handleSend()
|
<X className="h-4 w-4" />
|
||||||
}
|
</button>
|
||||||
}}
|
<div className="flex min-w-0 flex-1 flex-col gap-1 overflow-hidden">
|
||||||
placeholder="Type your message..."
|
<VoiceWaveform audioLevelsRef={voice.audioLevelsRef} />
|
||||||
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"
|
<div className={cn('min-h-5 truncate text-sm leading-5', voice.interimText.trim() ? 'text-foreground' : 'text-muted-foreground')}>
|
||||||
rows={2}
|
{voice.interimText.trim() || (recordingStopping ? 'Finalizing...' : 'Listening...')}
|
||||||
/>
|
</div>
|
||||||
</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">
|
<div className="flex items-center gap-2 px-3 pb-3">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|
@ -320,6 +433,22 @@ export function CodeChat({
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">Attach files — the agent reads them from disk (or drag & drop)</TooltipContent>
|
<TooltipContent side="top">Attach files — the agent reads them from disk (or drag & drop)</TooltipContent>
|
||||||
</Tooltip>
|
</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">
|
<span className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
<Terminal className="size-3.5 shrink-0" />
|
<Terminal className="size-3.5 shrink-0" />
|
||||||
<span className="truncate">Direct — straight to {AGENT_LABEL[session.agent]}</span>
|
<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" />}
|
{terminalOpen ? <ChevronDown className="size-3.5" /> : <ChevronUp className="size-3.5" />}
|
||||||
</button>
|
</button>
|
||||||
{terminalOpen && (
|
{terminalOpen && (
|
||||||
<div style={{ height: terminalHeight }}>
|
<div className="bg-background pb-3 dark:bg-black" style={{ height: terminalHeight + 12 }}>
|
||||||
<TerminalPane
|
<div className="h-full min-h-0">
|
||||||
key={selectedSession.id}
|
<TerminalPane
|
||||||
terminalId={selectedSession.id}
|
key={selectedSession.id}
|
||||||
cwd={selectedSession.cwd}
|
terminalId={selectedSession.id}
|
||||||
/>
|
cwd={selectedSession.cwd}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { useTheme } from '@/contexts/theme-context'
|
||||||
|
|
||||||
// xterm color schemes tuned to the app's light/dark backgrounds.
|
// xterm color schemes tuned to the app's light/dark backgrounds.
|
||||||
const DARK_THEME = {
|
const DARK_THEME = {
|
||||||
background: '#1b1b1f',
|
background: '#000000',
|
||||||
foreground: '#d4d4d8',
|
foreground: '#d4d4d8',
|
||||||
cursor: '#d4d4d8',
|
cursor: '#d4d4d8',
|
||||||
selectionBackground: 'rgba(120, 140, 255, 0.3)',
|
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
|
if (term) term.options.theme = resolvedTheme === 'dark' ? DARK_THEME : LIGHT_THEME
|
||||||
}, [resolvedTheme])
|
}, [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