@@ -24,6 +24,7 @@ import {
2424} from "@t3tools/client-runtime/connection" ;
2525import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger" ;
2626import { createModelSelection , normalizeModelSlug } from "@t3tools/shared/model" ;
27+ import { useNavigate } from "@tanstack/react-router" ;
2728import {
2829 memo ,
2930 type ReactNode ,
@@ -212,7 +213,7 @@ import {
212213 type VoiceDraft ,
213214 useVoiceRecorderStore ,
214215} from "../../voiceRecorderStore" ;
215- import { VoiceWaveform } from "./VoiceWaveform" ;
216+ import { VoiceWaveform , voiceSeekTargetSeconds } from "./VoiceWaveform" ;
216217
217218const IMAGE_SIZE_LIMIT_LABEL = `${ Math . round ( PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / ( 1024 * 1024 ) ) } MB` ;
218219
@@ -226,7 +227,8 @@ function VoiceDraftPreview({
226227 const audioRef = useRef < HTMLAudioElement | null > ( null ) ;
227228 const [ playing , setPlaying ] = useState ( false ) ;
228229 const [ elapsedMs , setElapsedMs ] = useState ( 0 ) ;
229- const progress = draft . durationMs > 0 ? elapsedMs / draft . durationMs : 0 ;
230+ const [ playbackDurationMs , setPlaybackDurationMs ] = useState ( draft . durationMs ) ;
231+ const progress = playbackDurationMs > 0 ? elapsedMs / playbackDurationMs : 0 ;
230232
231233 return (
232234 < div className = "mx-3 mb-2 flex items-center gap-2 rounded-xl bg-muted/45 px-2.5 py-2" >
@@ -250,12 +252,17 @@ function VoiceDraftPreview({
250252 onSeek = { ( nextProgress ) => {
251253 const audio = audioRef . current ;
252254 if ( ! audio ) return ;
253- audio . currentTime = nextProgress * ( audio . duration || draft . durationMs / 1_000 ) ;
254- setElapsedMs ( audio . currentTime * 1_000 ) ;
255+ const targetTime = voiceSeekTargetSeconds (
256+ nextProgress ,
257+ audio . duration ,
258+ playbackDurationMs ,
259+ ) ;
260+ audio . currentTime = targetTime ;
261+ setElapsedMs ( targetTime * 1_000 ) ;
255262 } }
256263 />
257- < span className = "text-muted-foreground text-xs tabular-nums" >
258- { formatVoiceDuration ( elapsedMs ) } / { formatVoiceDuration ( draft . durationMs ) }
264+ < span className = "w-24 shrink-0 text-right text-muted-foreground text-xs tabular-nums" >
265+ { formatVoiceDuration ( elapsedMs ) } / { formatVoiceDuration ( playbackDurationMs ) }
259266 </ span >
260267 < Button
261268 type = "button"
@@ -270,13 +277,25 @@ function VoiceDraftPreview({
270277 ref = { audioRef }
271278 src = { draft . previewUrl }
272279 className = "hidden"
273- onPlay = { ( ) => setPlaying ( true ) }
280+ onPlay = { ( event ) => {
281+ document . querySelectorAll < HTMLAudioElement > ( "audio[data-voice-note]" ) . forEach ( ( other ) => {
282+ if ( other !== event . currentTarget ) other . pause ( ) ;
283+ } ) ;
284+ setPlaying ( true ) ;
285+ } }
274286 onPause = { ( ) => setPlaying ( false ) }
275287 onEnded = { ( ) => {
276288 setPlaying ( false ) ;
277289 setElapsedMs ( 0 ) ;
278290 } }
279291 onTimeUpdate = { ( event ) => setElapsedMs ( event . currentTarget . currentTime * 1_000 ) }
292+ onLoadedMetadata = { ( event ) => {
293+ const durationMs = event . currentTarget . duration * 1_000 ;
294+ if ( Number . isFinite ( durationMs ) && durationMs > 0 ) {
295+ setPlaybackDurationMs ( durationMs ) ;
296+ }
297+ } }
298+ data-voice-note
280299 />
281300 </ div >
282301 ) ;
@@ -706,6 +725,7 @@ export interface ChatComposerProps {
706725// --------------------------------------------------------------------------
707726
708727export const ChatComposer = memo ( function ChatComposer ( props : ChatComposerProps ) {
728+ const navigate = useNavigate ( ) ;
709729 const {
710730 composerDraftTarget,
711731 environmentId,
@@ -955,6 +975,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
955975 const voiceRecorder = useVoiceRecorderStore ( ( state ) => state . recorder ) ;
956976 const voiceRecorderError = useVoiceRecorderStore ( ( state ) => state . error ) ;
957977 const startVoiceRecording = useVoiceRecorderStore ( ( state ) => state . start ) ;
978+ const pauseVoiceRecording = useVoiceRecorderStore ( ( state ) => state . pause ) ;
979+ const resumeVoiceRecording = useVoiceRecorderStore ( ( state ) => state . resume ) ;
958980 const stopVoiceRecording = useVoiceRecorderStore ( ( state ) => state . stop ) ;
959981 const discardVoiceRecording = useVoiceRecorderStore ( ( state ) => state . discard ) ;
960982 const restoreVoiceRecording = useVoiceRecorderStore ( ( state ) => state . restore ) ;
@@ -964,6 +986,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
964986 voiceRecorder . status !== "idle" && voiceRecorder . threadId === voiceTargetThreadId ;
965987 const activeVoiceDraft =
966988 voiceRecorder . status === "draft" && voiceBelongsToActiveThread ? voiceRecorder . draft : null ;
989+ const voiceInputLocked = voiceRecorder . status !== "idle" && voiceBelongsToActiveThread ;
967990 useEffect ( ( ) => {
968991 void restoreVoiceRecording ( ) ;
969992 } , [ restoreVoiceRecording ] ) ;
@@ -2715,7 +2738,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
27152738 : "Ask anything, @tag files/folders, $use skills, or / for commands"
27162739 }
27172740 ghostHint = { commandGhostHint }
2718- disabled = { isConnecting || isComposerApprovalState || projectSelectionRequired }
2741+ disabled = {
2742+ isConnecting ||
2743+ isComposerApprovalState ||
2744+ projectSelectionRequired ||
2745+ voiceInputLocked
2746+ }
27192747 />
27202748 { showMobilePendingAnswerActions ? (
27212749 < div
@@ -2749,11 +2777,40 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
27492777
27502778 { voiceRecorder . status === "recording" && voiceBelongsToActiveThread ? (
27512779 < div className = "mx-3 mb-2 flex items-center gap-2 rounded-xl bg-red-500/8 px-2.5 py-2" >
2752- < span className = "size-2 shrink-0 animate-pulse rounded-full bg-red-500" />
2780+ < span
2781+ className = { cn (
2782+ "size-2 shrink-0 rounded-full bg-red-500" ,
2783+ ! voiceRecorder . paused && "animate-pulse" ,
2784+ ) }
2785+ />
27532786 < VoiceWaveform levels = { voiceRecorder . waveform } live />
2754- < span className = "text-red-500 text-xs tabular-nums" >
2787+ < span className = "w-12 shrink-0 text-right text-red-500 text-xs tabular-nums" >
27552788 { formatVoiceDuration ( voiceRecorder . elapsedMs ) }
27562789 </ span >
2790+ < Button
2791+ type = "button"
2792+ size = "icon-sm"
2793+ variant = "ghost"
2794+ className = "rounded-full text-muted-foreground"
2795+ aria-label = { voiceRecorder . paused ? "Resume recording" : "Pause recording" }
2796+ onClick = { voiceRecorder . paused ? resumeVoiceRecording : pauseVoiceRecording }
2797+ >
2798+ { voiceRecorder . paused ? (
2799+ < PlayIcon className = "size-3.5 fill-current" />
2800+ ) : (
2801+ < PauseIcon className = "size-3.5 fill-current" />
2802+ ) }
2803+ </ Button >
2804+ < Button
2805+ type = "button"
2806+ size = "icon-sm"
2807+ variant = "ghost"
2808+ className = "rounded-full text-muted-foreground"
2809+ aria-label = "Cancel recording"
2810+ onClick = { discardVoiceRecording }
2811+ >
2812+ < XIcon className = "size-4" />
2813+ </ Button >
27572814 < Button
27582815 type = "button"
27592816 size = "icon-sm"
@@ -2890,6 +2947,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
28902947 size = "icon"
28912948 variant = "ghost"
28922949 aria-label = "Record voice note"
2950+ disabled = { composerSendState . hasSendableContent }
28932951 onClick = { ( ) =>
28942952 void startVoiceRecording (
28952953 environmentId ,
@@ -2903,7 +2961,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
29032961 >
29042962 < MicIcon />
29052963 </ TooltipTrigger >
2906- < TooltipPopup side = "top" > Record voice note</ TooltipPopup >
2964+ < TooltipPopup side = "top" >
2965+ { composerSendState . hasSendableContent
2966+ ? "Clear the composer to record a voice note"
2967+ : "Record voice note" }
2968+ </ TooltipPopup >
29072969 </ Tooltip >
29082970 ) : null
29092971 ) : null }
@@ -2941,20 +3003,29 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
29413003 ) : null }
29423004 { voiceRecorder . status !== "idle" && ! voiceBelongsToActiveThread ? (
29433005 < div className = "fixed right-4 bottom-4 z-50 flex items-center gap-3 rounded-full border border-border/70 bg-background/95 px-3 py-2 shadow-lg backdrop-blur" >
2944- < span className = "size-2 animate-pulse rounded-full bg-red-500" />
2945- < span className = "text-sm" >
3006+ < span
3007+ className = { cn (
3008+ "size-2 rounded-full bg-red-500" ,
3009+ voiceRecorder . status === "recording" && ! voiceRecorder . paused && "animate-pulse" ,
3010+ ) }
3011+ />
3012+ < span className = "min-w-28 text-sm tabular-nums" >
29463013 { voiceRecorder . status === "recording"
2947- ? `Recording · ${ formatVoiceDuration ( voiceRecorder . elapsedMs ) } `
3014+ ? `${ voiceRecorder . paused ? "Paused" : " Recording" } · ${ formatVoiceDuration ( voiceRecorder . elapsedMs ) } `
29483015 : `Voice draft · ${ formatVoiceDuration ( voiceRecorder . draft . durationMs ) } ` }
29493016 </ span >
29503017 < Button
29513018 type = "button"
29523019 size = "sm"
29533020 variant = "ghost"
29543021 onClick = { ( ) => {
2955- window . location . assign (
2956- `/${ voiceRecorder . environmentId } /${ voiceRecorder . threadId } ` ,
2957- ) ;
3022+ void navigate ( {
3023+ to : "/$environmentId/$threadId" ,
3024+ params : {
3025+ environmentId : voiceRecorder . environmentId ,
3026+ threadId : voiceRecorder . threadId ,
3027+ } ,
3028+ } ) ;
29583029 } }
29593030 >
29603031 Return
0 commit comments