Skip to content

Commit 1318a12

Browse files
feat(web): add trigger button and status indicators for file transcription (#2188)
* feat(web): add trigger button and status indicators for file transcription - Separate file upload from pipeline start - Add 'Start Transcription' button that appears after file upload - Add detailed status indicators (uploading, uploaded, queued, transcribing, summarizing, done, error) - Update FileInfo component to show upload progress and disable remove during processing - Update TranscriptDisplay to handle new status types Co-Authored-By: yujonglee <[email protected]> * refactor(web): use useMutation for upload and pipeline state management - Replace manual isUploading/uploadError state with uploadMutation - Replace manual pipeline start error handling with startPipelineMutation - Derive loading/error states from mutation.isPending and mutation.error - Reset mutations on file select and remove to clear stale state Co-Authored-By: yujonglee <[email protected]> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: yujonglee <[email protected]>
1 parent cb94c17 commit 1318a12

File tree

2 files changed

+173
-75
lines changed

2 files changed

+173
-75
lines changed

apps/web/src/components/transcription/transcript-display.tsx

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,20 @@ import { cn } from "@hypr/utils";
33
type Status =
44
| "idle"
55
| "uploading"
6+
| "uploaded"
67
| "queued"
78
| "transcribing"
9+
| "summarizing"
810
| "done"
911
| "error";
1012

1113
const statusMessages: Record<Status, string> = {
1214
idle: "Upload an audio file to see the transcript",
1315
uploading: "Uploading audio file...",
16+
uploaded: "Ready to transcribe",
1417
queued: "Queued for transcription...",
1518
transcribing: "Transcribing audio...",
19+
summarizing: "Generating summary...",
1620
done: "",
1721
error: "",
1822
};
@@ -35,7 +39,10 @@ export function TranscriptDisplay({
3539
}
3640

3741
const isProcessing =
38-
status === "uploading" || status === "queued" || status === "transcribing";
42+
status === "uploading" ||
43+
status === "queued" ||
44+
status === "transcribing" ||
45+
status === "summarizing";
3946

4047
if (isProcessing) {
4148
return (
@@ -48,6 +55,16 @@ export function TranscriptDisplay({
4855
);
4956
}
5057

58+
if (status === "uploaded") {
59+
return (
60+
<div className="border border-neutral-200 rounded-sm p-8 text-center">
61+
<p className="text-neutral-500">
62+
Click "Start Transcription" to begin processing your audio
63+
</p>
64+
</div>
65+
);
66+
}
67+
5168
if (!transcript) {
5269
return (
5370
<div className="border border-neutral-200 rounded-sm p-8 text-center">
@@ -71,17 +88,23 @@ export function FileInfo({
7188
fileName,
7289
fileSize,
7390
onRemove,
91+
isUploading,
92+
isProcessing,
7493
}: {
7594
fileName: string;
7695
fileSize: number;
7796
onRemove: () => void;
97+
isUploading?: boolean;
98+
isProcessing?: boolean;
7899
}) {
79100
const formatSize = (bytes: number) => {
80101
if (bytes < 1024) return `${bytes} B`;
81102
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
82103
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
83104
};
84105

106+
const canRemove = !isUploading && !isProcessing;
107+
85108
return (
86109
<div
87110
className={cn([
@@ -90,15 +113,28 @@ export function FileInfo({
90113
"bg-stone-50/30",
91114
])}
92115
>
93-
<div className="flex-1 min-w-0">
94-
<p className="text-sm font-medium text-neutral-700 truncate">
95-
{fileName}
96-
</p>
97-
<p className="text-xs text-neutral-500">{formatSize(fileSize)}</p>
116+
<div className="flex-1 min-w-0 flex items-center gap-3">
117+
{isUploading && (
118+
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-stone-600 shrink-0" />
119+
)}
120+
<div>
121+
<p className="text-sm font-medium text-neutral-700 truncate">
122+
{fileName}
123+
</p>
124+
<p className="text-xs text-neutral-500">
125+
{isUploading ? "Uploading..." : formatSize(fileSize)}
126+
</p>
127+
</div>
98128
</div>
99129
<button
100130
onClick={onRemove}
101-
className="ml-4 px-3 py-1 text-sm text-neutral-600 hover:text-neutral-800 border border-neutral-200 rounded-full hover:bg-neutral-50 transition-all"
131+
disabled={!canRemove}
132+
className={cn([
133+
"ml-4 px-3 py-1 text-sm border border-neutral-200 rounded-full transition-all",
134+
canRemove &&
135+
"text-neutral-600 hover:text-neutral-800 hover:bg-neutral-50",
136+
!canRemove && "text-neutral-400 cursor-not-allowed",
137+
])}
102138
>
103139
Remove
104140
</button>

apps/web/src/routes/_view/app/file-transcription.tsx

Lines changed: 130 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
import { useQuery } from "@tanstack/react-query";
1+
import { useMutation, useQuery } from "@tanstack/react-query";
22
import { createFileRoute } from "@tanstack/react-router";
3+
import { Play } from "lucide-react";
34
import { useEffect, useMemo, useState } from "react";
45

56
import NoteEditor, { type JSONContent } from "@hypr/tiptap/editor";
67
import { EMPTY_TIPTAP_DOC } from "@hypr/tiptap/shared";
78
import "@hypr/tiptap/styles.css";
9+
import { cn } from "@hypr/utils";
810

911
import {
1012
FileInfo,
@@ -27,16 +29,74 @@ export const Route = createFileRoute("/_view/app/file-transcription")({
2729

2830
function Component() {
2931
const [file, setFile] = useState<File | null>(null);
32+
const [fileId, setFileId] = useState<string | null>(null);
3033
const [pipelineId, setPipelineId] = useState<string | null>(null);
3134
const [transcript, setTranscript] = useState<string | null>(null);
32-
const [uploadError, setUploadError] = useState<string | null>(null);
3335
const [noteContent, setNoteContent] = useState<JSONContent>(EMPTY_TIPTAP_DOC);
3436
const [isMounted, setIsMounted] = useState(false);
3537

3638
useEffect(() => {
3739
setIsMounted(true);
3840
}, []);
3941

42+
const uploadMutation = useMutation({
43+
mutationFn: async (selectedFile: File) => {
44+
const base64Data = await new Promise<string>((resolve, reject) => {
45+
const reader = new FileReader();
46+
reader.onload = () => {
47+
const result = reader.result?.toString().split(",")[1];
48+
if (!result) {
49+
reject(new Error("Failed to read file"));
50+
} else {
51+
resolve(result);
52+
}
53+
};
54+
reader.onerror = () => reject(new Error("Failed to read file"));
55+
reader.readAsDataURL(selectedFile);
56+
});
57+
58+
const uploadResult = await uploadAudioFile({
59+
data: {
60+
fileName: selectedFile.name,
61+
fileType: selectedFile.type,
62+
fileData: base64Data,
63+
},
64+
});
65+
66+
if ("error" in uploadResult && uploadResult.error) {
67+
throw new Error(uploadResult.message || "Failed to upload file");
68+
}
69+
if (!("fileId" in uploadResult)) {
70+
throw new Error("Failed to get file ID");
71+
}
72+
73+
return uploadResult.fileId;
74+
},
75+
onSuccess: (newFileId) => {
76+
setFileId(newFileId);
77+
},
78+
});
79+
80+
const startPipelineMutation = useMutation({
81+
mutationFn: async (fileIdArg: string) => {
82+
const pipelineResult = await startAudioPipeline({
83+
data: { fileId: fileIdArg },
84+
});
85+
86+
if ("error" in pipelineResult && pipelineResult.error) {
87+
throw new Error(pipelineResult.message || "Failed to start pipeline");
88+
}
89+
if (!("pipelineId" in pipelineResult)) {
90+
throw new Error("Failed to get pipeline ID");
91+
}
92+
93+
return pipelineResult.pipelineId;
94+
},
95+
onSuccess: (newPipelineId) => {
96+
setPipelineId(newPipelineId);
97+
},
98+
});
99+
40100
const pipelineStatusQuery = useQuery({
41101
queryKey: ["audioPipelineStatus", pipelineId],
42102
queryFn: async (): Promise<StatusStateType> => {
@@ -70,8 +130,11 @@ function Component() {
70130
}, [pipelineStatusQuery.data]);
71131

72132
const isProcessing =
73-
!!pipelineId &&
74-
!["DONE", "ERROR"].includes(pipelineStatusQuery.data?.status ?? "");
133+
(!!pipelineId &&
134+
!["DONE", "ERROR"].includes(pipelineStatusQuery.data?.status ?? "")) ||
135+
startPipelineMutation.isPending;
136+
137+
const pipelineStatus = pipelineStatusQuery.data?.status;
75138

76139
const status = (() => {
77140
if (pipelineStatusQuery.data?.status === "ERROR") {
@@ -80,86 +143,64 @@ function Component() {
80143
if (pipelineStatusQuery.data?.status === "DONE" || transcript) {
81144
return "done" as const;
82145
}
83-
if (pipelineId) {
146+
if (pipelineStatus === "LLM_RUNNING") {
147+
return "summarizing" as const;
148+
}
149+
if (pipelineStatus === "TRANSCRIBED") {
150+
return "summarizing" as const;
151+
}
152+
if (pipelineStatus === "TRANSCRIBING") {
84153
return "transcribing" as const;
85154
}
155+
if (pipelineStatus === "QUEUED" || pipelineId) {
156+
return "queued" as const;
157+
}
158+
if (uploadMutation.isPending) {
159+
return "uploading" as const;
160+
}
161+
if (fileId) {
162+
return "uploaded" as const;
163+
}
86164
return "idle" as const;
87165
})();
88166

89167
const errorMessage =
90-
uploadError ??
168+
(uploadMutation.error instanceof Error
169+
? uploadMutation.error.message
170+
: null) ??
171+
(startPipelineMutation.error instanceof Error
172+
? startPipelineMutation.error.message
173+
: null) ??
91174
(pipelineStatusQuery.isError && pipelineStatusQuery.error instanceof Error
92175
? pipelineStatusQuery.error.message
93176
: null) ??
94177
(pipelineStatusQuery.data?.status === "ERROR"
95-
? pipelineStatusQuery.data.error
178+
? (pipelineStatusQuery.data.error ?? null)
96179
: null);
97180

98-
const handleFileSelect = async (selectedFile: File) => {
181+
const handleFileSelect = (selectedFile: File) => {
99182
setFile(selectedFile);
100-
setTranscript(null);
101-
setUploadError(null);
183+
setFileId(null);
102184
setPipelineId(null);
185+
setTranscript(null);
186+
uploadMutation.reset();
187+
startPipelineMutation.reset();
188+
uploadMutation.mutate(selectedFile);
189+
};
103190

104-
try {
105-
const reader = new FileReader();
106-
reader.readAsDataURL(selectedFile);
107-
108-
reader.onload = async () => {
109-
const base64Data = reader.result?.toString().split(",")[1];
110-
if (!base64Data) {
111-
setUploadError("Failed to read file");
112-
return;
113-
}
114-
115-
const uploadResult = await uploadAudioFile({
116-
data: {
117-
fileName: selectedFile.name,
118-
fileType: selectedFile.type,
119-
fileData: base64Data,
120-
},
121-
});
122-
123-
if ("error" in uploadResult && uploadResult.error) {
124-
setUploadError(uploadResult.message || "Failed to upload file");
125-
return;
126-
}
127-
128-
if (!("fileId" in uploadResult)) {
129-
setUploadError("Failed to get file ID");
130-
return;
131-
}
132-
133-
const pipelineResult = await startAudioPipeline({
134-
data: {
135-
fileId: uploadResult.fileId,
136-
},
137-
});
138-
139-
if ("error" in pipelineResult && pipelineResult.error) {
140-
setUploadError(pipelineResult.message || "Failed to start pipeline");
141-
return;
142-
}
143-
144-
if ("pipelineId" in pipelineResult) {
145-
setPipelineId(pipelineResult.pipelineId);
146-
}
147-
};
148-
149-
reader.onerror = () => {
150-
setUploadError("Failed to read file");
151-
};
152-
} catch (err) {
153-
setUploadError(err instanceof Error ? err.message : "Unknown error");
154-
}
191+
const handleStartTranscription = () => {
192+
if (!fileId) return;
193+
startPipelineMutation.mutate(fileId);
155194
};
156195

157196
const handleRemoveFile = () => {
158197
setFile(null);
159-
setTranscript(null);
160-
setUploadError(null);
198+
setFileId(null);
161199
setPipelineId(null);
200+
setTranscript(null);
162201
setNoteContent(EMPTY_TIPTAP_DOC);
202+
uploadMutation.reset();
203+
startPipelineMutation.reset();
163204
};
164205

165206
const mentionConfig = useMemo(
@@ -224,11 +265,32 @@ function Component() {
224265
disabled={isProcessing}
225266
/>
226267
) : (
227-
<FileInfo
228-
fileName={file.name}
229-
fileSize={file.size}
230-
onRemove={handleRemoveFile}
231-
/>
268+
<div className="space-y-4">
269+
<FileInfo
270+
fileName={file.name}
271+
fileSize={file.size}
272+
onRemove={handleRemoveFile}
273+
isUploading={uploadMutation.isPending}
274+
isProcessing={isProcessing}
275+
/>
276+
{status === "uploaded" && (
277+
<button
278+
onClick={handleStartTranscription}
279+
className={cn([
280+
"w-full flex items-center justify-center gap-2",
281+
"px-4 py-3 rounded-lg",
282+
"bg-gradient-to-t from-stone-600 to-stone-500",
283+
"text-white font-medium",
284+
"shadow-md hover:shadow-lg",
285+
"hover:scale-[101%] active:scale-[99%]",
286+
"transition-all",
287+
])}
288+
>
289+
<Play size={18} />
290+
Start Transcription
291+
</button>
292+
)}
293+
</div>
232294
)}
233295

234296
<div>

0 commit comments

Comments
 (0)