From e9ef96af72ac283d840aaddf3b39080126292a6f Mon Sep 17 00:00:00 2001 From: Scott Davis Date: Thu, 9 Jul 2026 12:39:14 -0500 Subject: [PATCH] feat(web): inline transcript segment editing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Let users correct mistranscribed names/entities directly on the conversation transcript in the web app. Each transcript block gets a pencil affordance that opens per-segment inline text editors; edits save to the existing `PATCH /v1/conversations/{id}/segments/text` endpoint (keyed by segment id). After edits, a nudge offers to reprocess the conversation so the summary/search reflect the change. - api: `updateSegmentText()` wrapper + `SegmentEditPlanRequiredError` (402) - TranscriptView: pencil toggle + `SegmentTextEditor`. Enter saves & closes, Esc cancels & closes, blur commits & closes, Done exits; merged same-speaker blocks split into per-segment fields (1:1 with the API). - ConversationDetailPanel: optimistic updates + reprocess nudge + analytics - Neutral styling only — no new purple (INV-UI-1 ratchet holds) Concurrency & resilience (from PR review): - Serialized save queue — one PATCH in flight at a time, so the backend's read-modify-write of the whole segments array can't lose-update between concurrent client saves. "Saving X of N" banner + per-segment spinner; failed saves revert to the persisted text. - Editing and reprocess are mutually exclusive (no edit clobbered by a reprocess snapshot write, and vice versa). - Async completions (save + reprocess) are guarded by the conversation id captured at request time, and optimistic updates read the latest state via refs — so switching conversations mid-request can't let a stale response overwrite the newly displayed conversation. - Reprocess is raced against a 90s timeout and failures are surfaced with a retry banner (no more indefinite "Reprocessing…" / silent console error). Cross-client/cross-tab races and the reprocess stale-snapshot write are backend concerns tracked in #9392; this PR fixes the single-client path. Verified: tsc clean; Prettier clean; INV-UI-1 guard passes; exercised the real user path against the production backend. --- .../conversations/ConversationDetailPanel.tsx | 251 +++++++++++++++++- .../conversations/TranscriptView.tsx | 211 +++++++++++++-- web/app/src/lib/api.ts | 43 +++ 3 files changed, 480 insertions(+), 25 deletions(-) diff --git a/web/app/src/components/conversations/ConversationDetailPanel.tsx b/web/app/src/components/conversations/ConversationDetailPanel.tsx index f888067507f..b3633e6298a 100644 --- a/web/app/src/components/conversations/ConversationDetailPanel.tsx +++ b/web/app/src/components/conversations/ConversationDetailPanel.tsx @@ -17,6 +17,7 @@ import { Volume2, ChevronDown, ChevronUp, + RefreshCw, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { formatTime, formatDuration } from '@/lib/utils'; @@ -29,7 +30,13 @@ import { SpeakerTagSheet } from './SpeakerTagSheet'; import { ManagePeopleModal } from './ManagePeopleModal'; import { AudioPlayer, AudioPlayerRef } from './AudioPlayer'; import { usePeople } from '@/hooks/usePeople'; -import { precacheConversationAudio, getConversationAudioUrls } from '@/lib/api'; +import { + precacheConversationAudio, + getConversationAudioUrls, + updateSegmentText, + reprocessConversation, +} from '@/lib/api'; +import { MixpanelManager } from '@/lib/analytics/mixpanel'; import type { Conversation, AppResponse, @@ -59,6 +66,10 @@ interface ConversationDetailPanelProps { onDelete?: () => void; } +// Give a reprocess this long before we stop waiting and surface a failure, so a +// hung request can't leave the UI stuck on "Reprocessing…" indefinitely. +const REPROCESS_TIMEOUT_MS = 90_000; + type TabId = 'summary' | 'actions' | 'transcript'; interface Tab { @@ -403,9 +414,55 @@ export function ConversationDetailPanel({ const [selectedSegment, setSelectedSegment] = useState(null); const [showTagSheet, setShowTagSheet] = useState(false); const [showManagePeople, setShowManagePeople] = useState(false); + // Whether a transcript segment was edited this session (derived summary/search + // is now stale until the conversation is reprocessed — see the nudge banner). + const [transcriptEdited, setTranscriptEdited] = useState(false); + const [isReprocessing, setIsReprocessing] = useState(false); + // Set when a reprocess errors or times out, so the failure is surfaced to the + // user (not just console) and they can retry — the summary stays stale. + const [reprocessFailed, setReprocessFailed] = useState(false); + // Serialized transcript-edit save queue. Segment edits are persisted one at a + // time so concurrent PATCHes can't lose-update the backend's read-modify-write + // of the whole segments array (each save reads the state left by the previous + // one). `saveBatch` drives the "Saving X of N" progress UI. + const saveQueueRef = useRef< + Array<{ segmentId: string; text: string; prevText: string }> + >([]); + const processingRef = useRef(false); + const [saveBatch, setSaveBatch] = useState<{ + total: number; + done: number; + savingId: string | null; + failed: number; + }>({ total: 0, done: 0, savingId: null, failed: 0 }); const router = useRouter(); const { people } = usePeople(); + // Refs holding the *currently displayed* conversation id and object, so async + // completions (segment saves, reprocess) can (a) build optimistic updates from + // the latest state and (b) bail out if the user switched conversations while a + // request was in flight — otherwise a stale response would clobber the new + // conversation's UI (displayed id vs. request id divergence). + const convIdRef = useRef(conversationId); + const conversationRef = useRef(conversation); + useEffect(() => { + convIdRef.current = conversationId; + conversationRef.current = conversation; + }, [conversationId, conversation]); + + // Reset all edit/save state when switching to a different conversation so + // nothing leaks across (queued saves, progress, "edited" nudge, reprocessing). + useEffect(() => { + saveQueueRef.current = []; + processingRef.current = false; + setSaveBatch({ total: 0, done: 0, savingId: null, failed: 0 }); + setTranscriptEdited(false); + setIsReprocessing(false); + setReprocessFailed(false); + }, [conversationId]); + + const isSavingSegments = saveBatch.total > saveBatch.done; + // Audio state const audioPlayerRef = useRef(null); const [audioAvailable, setAudioAvailable] = useState(false); @@ -523,6 +580,128 @@ export function ConversationDetailPanel({ [conversation, onConversationUpdate], ); + // Immutably patch a single segment's text from the *latest* conversation state + // (via ref, not a stale closure) so overlapping edits don't drop each other. + const applyOptimisticText = useCallback( + (segmentId: string, text: string) => { + const latest = conversationRef.current; + if (!latest || !onConversationUpdate) return; + onConversationUpdate({ + ...latest, + transcript_segments: (latest.transcript_segments ?? []).map((seg) => + seg.id === segmentId ? { ...seg, text } : seg, + ), + }); + }, + [onConversationUpdate], + ); + + // Drain the save queue one PATCH at a time. Serializing is the correctness fix: + // the backend rewrites the whole segments array, so concurrent writes lose-update + // — running them sequentially means each save reads the previous one's result. + const processSaveQueue = useCallback(async () => { + if (processingRef.current) return; + processingRef.current = true; + const convId = convIdRef.current; + try { + while (saveQueueRef.current.length > 0) { + // Abandon the batch if the user navigated to another conversation. + if (convIdRef.current !== convId) { + saveQueueRef.current = []; + break; + } + const task = saveQueueRef.current.shift()!; + setSaveBatch((s) => ({ ...s, savingId: task.segmentId })); + try { + await updateSegmentText(convId, task.segmentId, task.text); + if (convIdRef.current === convId) { + setTranscriptEdited(true); + MixpanelManager.track('Transcript Segment Edited', { + conversation_id: convId, + segment_id: task.segmentId, + }); + } + } catch (error) { + console.error('Failed to save transcript segment edit:', error); + // Revert the optimistic change so the UI reflects the persisted truth. + if (convIdRef.current === convId) + applyOptimisticText(task.segmentId, task.prevText); + setSaveBatch((s) => ({ ...s, failed: s.failed + 1 })); + } finally { + setSaveBatch((s) => ({ ...s, done: s.done + 1, savingId: null })); + } + } + } finally { + processingRef.current = false; + // Collapse the progress counters once the queue is fully drained (keep the + // failure count so the error banner persists until the next edit). + if (saveQueueRef.current.length === 0) { + setSaveBatch((s) => ({ total: 0, done: 0, savingId: null, failed: s.failed })); + } + } + }, [applyOptimisticText]); + + // Enqueue a segment edit: show it optimistically now, persist it in order. + const enqueueSegmentSave = useCallback( + (segmentId: string, text: string) => { + const latest = conversationRef.current; + if (!latest) return; + const prevText = + (latest.transcript_segments ?? []).find((seg) => seg.id === segmentId)?.text ?? + ''; + const trimmed = text.trim(); + if (!trimmed || trimmed === prevText) return; + + applyOptimisticText(segmentId, trimmed); + saveQueueRef.current.push({ segmentId, text: trimmed, prevText }); + // A new edit while the queue is idle starts a fresh batch (clears old errors). + setSaveBatch((s) => { + const fresh = s.total === s.done; + return { + total: (fresh ? 0 : s.total) + 1, + done: fresh ? 0 : s.done, + savingId: s.savingId, + failed: fresh ? 0 : s.failed, + }; + }); + void processSaveQueue(); + }, + [applyOptimisticText, processSaveQueue], + ); + + // Reprocess the conversation to refresh summary/search after transcript edits. + // Reprocessing is heavy; race it against a timeout so a hung request can't leave + // the UI stuck on "Reprocessing…", and surface failures to the user (not console). + const handleReprocessAfterEdit = useCallback(async () => { + const convId = convIdRef.current; + setIsReprocessing(true); + setReprocessFailed(false); + try { + const updated = await Promise.race([ + reprocessConversation(convId), + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Reprocess timed out')), + REPROCESS_TIMEOUT_MS, + ), + ), + ]); + // Ignore the response if the user has since switched conversations. + if (convIdRef.current === convId) { + onConversationUpdate?.(updated); + setTranscriptEdited(false); + MixpanelManager.track('Conversation Reprocessed After Edit', { + conversation_id: convId, + }); + } + } catch (error) { + console.error('Failed to reprocess conversation after edit:', error); + if (convIdRef.current === convId) setReprocessFailed(true); + } finally { + if (convIdRef.current === convId) setIsReprocessing(false); + } + }, [onConversationUpdate]); + // Handle title change - update conversation with new title const handleTitleChange = useCallback( (newTitle: string) => { @@ -744,6 +923,73 @@ export function ConversationDetailPanel({ Loading audio... )} + {/* Save-queue progress: edits persist one at a time (serialized) */} + {isSavingSegments && ( +
+
+ + Saving edit {Math.min(saveBatch.done + 1, saveBatch.total)} of{' '} + {saveBatch.total}… + +
+ )} + {/* Failed-save banner (edits were reverted to their saved value) */} + {!isSavingSegments && saveBatch.failed > 0 && ( +
+ + Couldn't save {saveBatch.failed} edit + {saveBatch.failed > 1 ? 's' : ''} — reverted to the saved text. + Please try again. + +
+ )} + {/* Reprocess failed/timed out — surface it and let the user retry */} + {reprocessFailed && !isReprocessing && !conversation.discarded && ( +
+ + Reprocessing failed — the summary and search may be out of date. + + +
+ )} + {/* Nudge to refresh derived summary/search after edits (once saved) */} + {transcriptEdited && + !conversation.discarded && + !isSavingSegments && + saveBatch.failed === 0 && + !reprocessFailed && ( +
+
+ + + Transcript edited. Reprocess to update the summary and search. + +
+ +
+ )} void; + /** + * Queue an edit to a single segment's text. Fire-and-forget: the parent shows + * the change optimistically and persists it (serialized). When omitted, text + * editing is disabled and only the speaker remains editable. + */ + onSegmentTextChange?: (segmentId: string, text: string) => void; + /** Id of the segment whose save is currently in flight (shows a spinner). */ + savingSegmentId?: string | null; + /** When true, text editing is suppressed (e.g. while reprocessing). */ + editingDisabled?: boolean; /** Current audio playback time in seconds */ currentTime?: number; /** Whether audio is available for this conversation */ @@ -114,6 +124,89 @@ function isActiveGroup( return currentTime >= firstSegment.start && currentTime <= lastSegment.end; } +/** + * Inline editor for a single transcript segment's text. + * + * The transcript is displayed as merged same-speaker blocks, but the backend + * edits one segment at a time (keyed by `segment.id`). So in edit mode each + * underlying segment gets its own field — the edit granularity matches the API. + * Mirrors the `EditableTitle` interaction: Enter / blur saves, Escape cancels. + */ +function SegmentTextEditor({ + segment, + onEnqueueSave, + onExitEdit, +}: { + segment: TranscriptSegment; + onEnqueueSave: (segmentId: string, text: string) => void; + onExitEdit: () => void; +}) { + const [draft, setDraft] = useState(segment.text); + // Guard so Enter/Done (which close the editor) don't also commit a second time + // via the blur that firing focus-loss triggers as the field unmounts. + const committedRef = useRef(false); + + const canEdit = Boolean(segment.id); + + // Queue the edit (if changed) and close. The parent persists it serially and + // owns the saving/error UI, so the editor stays intentionally lightweight. + const commit = useCallback(() => { + if (committedRef.current) return; + committedRef.current = true; + const trimmed = draft.trim(); + if (segment.id && trimmed && trimmed !== segment.text) { + onEnqueueSave(segment.id, trimmed); + } + onExitEdit(); + }, [draft, segment.id, segment.text, onEnqueueSave, onExitEdit]); + + const cancel = useCallback(() => { + committedRef.current = true; // stop the blur handler from committing + onExitEdit(); + }, [onExitEdit]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + // Enter commits & closes; Shift+Enter inserts a newline; Escape cancels. + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + commit(); + } else if (e.key === 'Escape') { + e.preventDefault(); + cancel(); + } + }; + + if (!canEdit) { + // Segments without an id cannot be targeted by the edit API. + return ( +

+ {segment.text} +

+ ); + } + + return ( +
+