@@ -50,6 +50,9 @@ export async function loader({ request, params }: Route.LoaderArgs) {
5050 title : true ,
5151 id : true ,
5252 isAnonymous : true ,
53+ callerTranscript : true ,
54+ callerTranscriptStatus : true ,
55+ callerTranscriptErrorMessage : true ,
5356 episodeDraft : {
5457 select : {
5558 id : true ,
@@ -102,6 +105,12 @@ function CallListing({ call }: { call: SerializeFrom<typeof loader>['call'] }) {
102105 const [ audioEl , setAudioEl ] = React . useState < HTMLAudioElement | null > ( null )
103106 const [ playbackRate , setPlaybackRate ] = React . useState ( 2 )
104107 const dc = useDoubleCheck ( )
108+ const callerTranscriptStatus = call . callerTranscriptStatus
109+ const callerTranscript = call . callerTranscript ?. trim ( ) ?? ''
110+ const [ callerTranscriptValue , setCallerTranscriptValue ] =
111+ React . useState ( callerTranscript )
112+ const callerTranscriptLocked = Boolean ( call . episodeDraft )
113+ const callerTranscriptError = call . callerTranscriptErrorMessage
105114 const mailtoHref = `mailto:${ call . user . email } ?${ new URLSearchParams ( {
106115 subject : `Re: Call Kent - ${ call . title } ` ,
107116 body : `I just wanted to talk about your call on the Call Kent podcast` ,
@@ -112,6 +121,10 @@ function CallListing({ call }: { call: SerializeFrom<typeof loader>['call'] }) {
112121 audioEl . playbackRate = playbackRate
113122 } , [ audioEl , playbackRate ] )
114123
124+ React . useEffect ( ( ) => {
125+ setCallerTranscriptValue ( callerTranscript )
126+ } , [ callerTranscript ] )
127+
115128 return (
116129 < section
117130 className = { `set-color-team-current-${ call . user . team . toLowerCase ( ) } rounded-lg bg-gray-100 p-6 lg:p-8 dark:bg-gray-800` }
@@ -169,6 +182,104 @@ function CallListing({ call }: { call: SerializeFrom<typeof loader>['call'] }) {
169182 </ Paragraph >
170183 </ div >
171184
185+ { /* Caller Transcript */ }
186+ < div className = "mb-6 rounded-lg bg-gray-200 p-4 dark:bg-gray-700" >
187+ < div className = "mb-3 flex flex-wrap items-center justify-between gap-3" >
188+ < H6 as = "h3" id = "caller-transcript" >
189+ Caller transcript
190+ </ H6 >
191+ < Form method = "post" action = { recordingFormActionPath } >
192+ < input type = "hidden" name = "intent" value = "generate-caller-transcript" />
193+ < input type = "hidden" name = "callId" value = { call . id } />
194+ < Button
195+ type = "submit"
196+ variant = "secondary"
197+ size = "small"
198+ disabled = {
199+ callerTranscriptStatus === 'PROCESSING' || callerTranscriptLocked
200+ }
201+ >
202+ { callerTranscriptStatus === 'READY'
203+ ? 'Regenerate transcript'
204+ : callerTranscriptStatus === 'PROCESSING'
205+ ? 'Generating...'
206+ : 'Generate transcript' }
207+ </ Button >
208+ </ Form >
209+ </ div >
210+
211+ { callerTranscriptStatus === 'PROCESSING' ? (
212+ < div className = "flex items-center gap-2 text-sm text-gray-600 dark:text-slate-300" >
213+ < Spinner showSpinner = { true } size = { 16 } />
214+ < span > Generating caller transcript…</ span >
215+ </ div >
216+ ) : null }
217+
218+ { callerTranscriptStatus === 'ERROR' ? (
219+ < Paragraph className = "whitespace-pre-wrap text-red-700 dark:text-red-300" >
220+ { callerTranscriptError || 'Unable to generate caller transcript.' }
221+ </ Paragraph >
222+ ) : null }
223+
224+ { callerTranscriptLocked ? (
225+ < div className = "mt-3 flex flex-col gap-3" >
226+ < Paragraph className = "text-xs text-gray-500 dark:text-slate-400" >
227+ Caller transcript edits are disabled after an episode draft exists.
228+ Update the{ ' ' }
229+ < a href = "#draft-transcript" className = "underline" >
230+ episode draft transcript
231+ </ a > { ' ' }
232+ below instead.
233+ </ Paragraph >
234+ < div className = "rounded-lg bg-white px-4 py-3 text-sm whitespace-pre-wrap text-gray-800 dark:bg-gray-800 dark:text-white" >
235+ { callerTranscriptValue || 'No caller transcript available.' }
236+ </ div >
237+ </ div >
238+ ) : (
239+ < Form
240+ method = "post"
241+ action = { recordingFormActionPath }
242+ className = "mt-3 flex flex-col gap-3"
243+ >
244+ < input type = "hidden" name = "intent" value = "update-caller-transcript" />
245+ < input type = "hidden" name = "callId" value = { call . id } />
246+ < textarea
247+ name = "callerTranscript"
248+ value = { callerTranscriptValue }
249+ onChange = { ( event ) => {
250+ setCallerTranscriptValue ( event . currentTarget . value )
251+ } }
252+ placeholder = "Caller transcript"
253+ rows = { 6 }
254+ aria-labelledby = "caller-transcript"
255+ className = "focus-ring w-full rounded-lg bg-white px-4 py-3 text-sm text-gray-800 dark:bg-gray-800 dark:text-white"
256+ disabled = { callerTranscriptStatus === 'PROCESSING' }
257+ />
258+ < div className = "flex items-center justify-between gap-3" >
259+ < Paragraph className = "text-xs text-gray-500 dark:text-slate-400" >
260+ Edit this transcript before recording; this version is used for
261+ the full episode transcript.
262+ </ Paragraph >
263+ < Button
264+ type = "submit"
265+ variant = "secondary"
266+ size = "small"
267+ disabled = { callerTranscriptStatus === 'PROCESSING' }
268+ >
269+ Save caller transcript
270+ </ Button >
271+ </ div >
272+ </ Form >
273+ ) }
274+
275+ { callerTranscriptStatus === 'NOT_STARTED' && ! callerTranscriptLocked ? (
276+ < Paragraph className = "text-gray-500 dark:text-slate-400" >
277+ Generate this now to review the caller's transcript before recording
278+ your response.
279+ </ Paragraph >
280+ ) : null }
281+ </ div >
282+
172283 { /* Audio Player */ }
173284 < div className = "rounded-lg bg-gray-200 p-4 dark:bg-gray-700" >
174285 < H6 as = "h3" className = "mb-3" >
@@ -599,6 +710,16 @@ function RecordingDetailScreen({
599710 setPolledStatus ( null )
600711 } , [ draft ?. id ] )
601712
713+ // Caller transcript generation runs before episode-draft creation, so poll the
714+ // full loader while that lightweight status is processing.
715+ useInterval (
716+ async ( ) => {
717+ if ( revalidator . state !== 'idle' ) return
718+ await revalidator . revalidate ( )
719+ } ,
720+ data . call . callerTranscriptStatus === 'PROCESSING' ? 1500 : 0 ,
721+ )
722+
602723 // Use lightweight status-only endpoint when polling to avoid re-fetching
603724 // transcript, title, description, keywords on every 1.5s poll.
604725 useInterval (
@@ -640,7 +761,7 @@ function RecordingDetailScreen({
640761 Episode draft
641762 </ H6 >
642763 < Paragraph className = "mb-6 text-gray-500 dark:text-slate-400" >
643- { `Record your response , then the app will generate the full episode audio, transcript, and AI metadata before you publish.` }
764+ { `You can review the caller transcript first , then record your response. The app will generate full episode audio and metadata before publish.` }
644765 </ Paragraph >
645766
646767 { draft ? (
0 commit comments