Skip to content

Commit 2e634b5

Browse files
Refactor TTS into FloatingTTSButton component with amber/orange styling (#162)
1 parent b9b5d50 commit 2e634b5

3 files changed

Lines changed: 63 additions & 24 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { Volume2, VolumeX, Loader2 } from 'lucide-react'
2+
import { useTTS } from '@/hooks/useTTS'
3+
import { useMobile } from '@/hooks/useMobile'
4+
5+
interface FloatingTTSButtonProps {
6+
content: string
7+
}
8+
9+
export function FloatingTTSButton({ content }: FloatingTTSButtonProps) {
10+
const { speak, stop, isEnabled, isPlaying, isLoading, originalText } = useTTS()
11+
const isMobile = useMobile()
12+
13+
if (!isEnabled || !content.trim()) {
14+
return null
15+
}
16+
17+
const isThisPlaying = (isPlaying || isLoading) && originalText === content
18+
19+
const handleClick = () => {
20+
if (isThisPlaying) {
21+
stop()
22+
} else {
23+
speak(content)
24+
}
25+
}
26+
27+
const buttonPosition = isMobile ? 'bottom-24' : 'bottom-20'
28+
29+
return (
30+
<button
31+
onClick={handleClick}
32+
className={`fixed ${buttonPosition} right-4 z-50 flex items-center gap-2 px-4 py-3 rounded-xl transition-all duration-200 active:scale-95 hover:scale-105 shadow-lg backdrop-blur-md ${
33+
isThisPlaying
34+
? 'bg-gradient-to-br from-red-600 to-red-700 hover:from-red-500 hover:to-red-600 text-destructive-foreground border border-red-500/60 shadow-red-500/30'
35+
: 'bg-gradient-to-br from-amber-500 to-orange-600 hover:from-amber-400 hover:to-orange-500 text-white border border-amber-400/60 shadow-amber-500/30'
36+
}`}
37+
title={isThisPlaying ? 'Stop playback' : 'Play last response'}
38+
aria-label={isThisPlaying ? 'Stop playback' : 'Play last response'}
39+
disabled={isLoading && originalText !== content}
40+
>
41+
{isLoading && isThisPlaying ? (
42+
<Loader2 className="w-5 h-5 animate-spin" />
43+
) : isThisPlaying ? (
44+
<VolumeX className="w-5 h-5" />
45+
) : (
46+
<Volume2 className="w-5 h-5" />
47+
)}
48+
<span className="text-sm font-medium hidden sm:inline">
49+
{isThisPlaying ? 'Stop' : 'Play Last'}
50+
</span>
51+
</button>
52+
)
53+
}

frontend/src/components/message/MessagePart.tsx

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import { memo } from 'react'
22
import type { components } from '@/api/opencode-types'
3-
import { Volume2, Loader2 } from 'lucide-react'
3+
import { Volume2, VolumeX, Loader2 } from 'lucide-react'
44
import { TextPart } from './TextPart'
5-
import { SquareFill } from '@/components/ui/square-fill'
65
import { PatchPart } from './PatchPart'
76
import { ToolCallPart } from './ToolCallPart'
87
import { RetryPart } from './RetryPart'
@@ -59,14 +58,12 @@ function getCopyableContent(part: Part, allParts?: Part[]): string {
5958
}
6059
}
6160

62-
63-
6461
interface TTSButtonProps {
6562
content: string
6663
className?: string
6764
}
6865

69-
export function TTSButton({ content, className = "" }: TTSButtonProps) {
66+
function TTSButton({ content, className = "" }: TTSButtonProps) {
7067
const { speak, stop, isEnabled, isPlaying, isLoading, originalText } = useTTS()
7168

7269
if (!isEnabled || !content.trim()) {
@@ -93,16 +90,14 @@ export function TTSButton({ content, className = "" }: TTSButtonProps) {
9390
{isLoading && isThisPlaying ? (
9491
<Loader2 className="w-4 h-4 animate-spin" />
9592
) : isThisPlaying ? (
96-
<SquareFill className="w-4 h-4" />
93+
<VolumeX className="w-4 h-4" />
9794
) : (
9895
<Volume2 className="w-4 h-4" />
9996
)}
10097
</button>
10198
)
10299
}
103100

104-
105-
106101
export const MessagePart = memo(function MessagePart({ part, role, allParts, partIndex, onFileClick, onChildSessionClick, messageTextContent }: MessagePartProps) {
107102
const copyableContent = getCopyableContent(part, allParts)
108103
const isMobile = useMobile()

frontend/src/pages/SessionDetail.tsx

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import { useQuery } from "@tanstack/react-query";
44
import { getRepo } from "@/api/repos";
55
import { MessageThread } from "@/components/message/MessageThread";
66
import { PromptInput, type PromptInputHandle } from "@/components/message/PromptInput";
7-
import { X, VolumeX, FolderOpen, Plug, Settings, CornerUpLeft, GitCommitHorizontal, Brain, ShieldOff, Code } from "lucide-react";
7+
import { FloatingTTSButton } from '@/components/message/FloatingTTSButton'
8+
import { X, FolderOpen, Plug, Settings, CornerUpLeft, GitCommitHorizontal, Brain, ShieldOff, Code } from "lucide-react";
89
import { ModelSelectDialog } from "@/components/model/ModelSelectDialog";
910
import { Header } from "@/components/ui/header";
1011
import { SessionList } from "@/components/session/SessionList";
@@ -130,13 +131,14 @@ export function SessionDetail() {
130131
const { open: openSettings } = useSettingsDialog();
131132
const { model, modelString } = useModelSelection(opcodeUrl, repoDirectory);
132133
const isEditingMessage = useUIState((state) => state.isEditingMessage);
133-
const { isPlaying, stop } = useTTS();
134+
const { isEnabled: ttsEnabled } = useTTS();
134135
const setSessionStatus = useSessionStatus((state) => state.setStatus);
135136
const { current: currentQuestion, reply: replyToQuestion, reject: rejectQuestion } = useQuestions();
136137

137138
const sessionStatus = useSessionStatusForSession(sessionId);
138139
const isSessionActive = sessionStatus.type === 'busy' || sessionStatus.type === 'retry';
139140
const lastAssistantMessage = messages?.filter(m => m.info.role === 'assistant').at(-1);
141+
const lastAssistantText = lastAssistantMessage?.parts.filter(p => p.type === 'text').map(p => p.text).join('\n\n') || '';
140142
const hasIncompleteMessages = lastAssistantMessage ? !('completed' in lastAssistantMessage.info.time && lastAssistantMessage.info.time.completed) : false;
141143
const hasActiveStream = hasIncompleteMessages && isSessionActive;
142144

@@ -497,20 +499,9 @@ export function SessionDetail() {
497499
<span className="text-sm font-medium">Waiting for shortcut key...</span>
498500
</div>
499501
)}
500-
{isPlaying && !leaderActive && (
501-
<button
502-
onMouseDown={(e) => e.preventDefault()}
503-
onTouchEnd={(e) => {
504-
e.preventDefault()
505-
stop()
506-
}}
507-
onClick={stop}
508-
className="absolute -top-12 left-0 md:left-4 z-50 flex items-center gap-2 px-4 py-2 rounded-xl bg-gradient-to-br from-red-700 to-red-800 hover:from-red-600 hover:to-red-700 text-destructive-foreground border-2 border-red-600/80 hover:border-red-500 shadow-2xl shadow-red-600/40 hover:shadow-red-600/60 backdrop-blur-md transition-all duration-200 active:scale-95 hover:scale-105 ring-2 ring-red-600/30 hover:ring-red-600/50 animate-[pulse_2s_cubic-bezier(0.4,0,0.6,1)_infinite]"
509-
aria-label="Stop Audio"
510-
>
511-
<VolumeX className="w-6 h-6" />
512-
<span className="text-sm font-medium hidden sm:inline">Stop Audio</span>
513-
</button>
502+
503+
{ttsEnabled && lastAssistantText && !hasPromptContent && !hasActiveStream && (
504+
<FloatingTTSButton content={lastAssistantText} />
514505
)}
515506
{currentQuestion && currentQuestion.sessionID === sessionId && (
516507
<QuestionPrompt

0 commit comments

Comments
 (0)