Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion public/locale/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -130,5 +130,7 @@
"least_tokens_used": "Least Tokens Used",
"no_quotas_found": "No Quotas found",
"started_using_since": "Started Using Since",
"no_facility_found": "No facility found"
"no_facility_found": "No facility found",
"live_transcription": "Live Transcription",
"live": "Live"
}
48 changes: 7 additions & 41 deletions src/components/Controller.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,17 +33,9 @@ import { twMerge } from "tailwind-merge";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { useQuota } from "@/hooks/useQuota";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "./ui/dialog";
import TncDialog from "./TncDialog";
import ControllerDropDownMenu from "./ControllerDropDownMenu";
import { useStorage } from "@/hooks/useStorage";
import { useContainerRef } from "@/hooks/useContainerRef";
import { cleanAIResponse, poller } from "@/utils/response-utils";
import {
getFieldsToReview,
Expand Down Expand Up @@ -101,7 +93,6 @@ export function Controller(props: {
const [scribe, setScribe] = useState<ScribeModel | null>(null);
const [files, setFiles] = useState<File[]>([]);
const path = usePath();
const containerRef = useContainerRef();
const isAbortedRef = useRef(false);
const [formStateSnapshot, setFormStateSnapshot] =
useState<typeof props.formState>(null);
Expand Down Expand Up @@ -666,37 +657,12 @@ export function Controller(props: {
/>
)}

<Dialog open={showTnc} onOpenChange={setShowTnc}>
<DialogContent
portalProps={{ container: containerRef?.current }}
className="break-normal"
>
<DialogHeader>
<DialogTitle>{t("terms_and_conditions")}</DialogTitle>
<DialogDescription>
{t("terms_and_conditions_description")}
</DialogDescription>
</DialogHeader>
<div className="max-h-[60vh] overflow-auto rounded-md bg-neutral-50 p-2 text-sm">
<div
className="reset-tw"
dangerouslySetInnerHTML={{
__html: quota.tnc || "LOADING...",
}}
/>
</div>
<DialogFooter>
<Button
onClick={async () => {
quota.acceptTnc();
setShowTnc(false);
}}
>
{t("accept")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<TncDialog
open={showTnc}
onOpenChange={setShowTnc}
tnc={quota.tnc}
onAccept={quota.acceptTnc}
/>
</>
);
}
1 change: 1 addition & 0 deletions src/components/History.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export default function HistorySheet(props: {
offset: pageParam,
benchmark: false,
limit: 10,
live: false,
ordering: "-modified_date",
}),
getNextPageParam: (lastPage, _, lastPageParam) => {
Expand Down
209 changes: 209 additions & 0 deletions src/components/NotesScribe.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import { cn } from "@/utils/utils";
import { Button } from "./ui/button";
import { MicrophoneIcon } from "@/utils/icons";
import { ReloadIcon } from "@radix-ui/react-icons";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import "../style/index.css";
import { ContainerRefProvider, useContainerRef } from "@/hooks/useContainerRef";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTimer } from "@/hooks/useTimer";
import {
useLiveTranscription,
type RecordingResult,
} from "@/hooks/useLiveTranscription";
import { usePath } from "raviger";
import { useQuota } from "@/hooks/useQuota";
import { API } from "@/utils/api";
import { ScribeFileType } from "@/types";
import TncDialog from "./TncDialog";
import { toast } from "sonner";
import { Toaster } from "./ui/sonner";
import { useControlState } from "@/hooks/useControlState";

export type NotesScribeProps = {
className?: string;
};

export function NotesScribe(props: NotesScribeProps) {
const { className } = props;
const [message, setMessage] = useControlState("noteMessage", "");

const container = useRef<HTMLDivElement>(null);
const containerRef = useContainerRef();
const timer = useTimer();
const messageBeforeRecording = useRef("");
const path = usePath();
const [showTnc, setShowTnc] = useState(false);

const facilityId = path?.includes("/facility/")
? path.split("/facility/")[1].split("/")[0]
: undefined;

const encounterId = path?.includes("/encounter/")
? path.split("/encounter/")[1].split("/")[0]
: undefined;

const quota = useQuota(facilityId);
const SCRIBE_ENABLED =
!!quota.quotas?.length &&
quota.quotas.some((q) => q.enable_live_transcription);

const [isStarting, setIsStarting] = useState(false);

const { isRecording, transcript, error, startRecording, stopRecording } =
useLiveTranscription({ facilityId, encounterId });

useEffect(() => {
if (container.current) {
containerRef.current = container.current;
}
}, [container, containerRef]);
Comment on lines +57 to +61
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Clear the shared container ref on unmount.

This effect publishes a DOM node into shared context but never resets it, so consumers can keep a detached element after NotesScribe unmounts.

🧹 Minimal fix
   useEffect(() => {
-    if (container.current) {
-      containerRef.current = container.current;
+    const node = container.current;
+    if (node) {
+      containerRef.current = node;
     }
+    return () => {
+      if (containerRef.current === node) {
+        containerRef.current = null;
+      }
+    };
   }, [container, containerRef]);
Based on learnings, "Applies to src/**/*.{ts,tsx} : Handle cleanup in useEffect hooks".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/NotesScribe.tsx` around lines 57 - 61, The effect in
NotesScribe that assigns the DOM node to the shared ref (useEffect watching
container and containerRef) never clears it on unmount; update the effect (the
useEffect that reads container.current and writes containerRef.current) to
return a cleanup function that resets containerRef.current to null (or
undefined) so consumers don’t retain a detached element when NotesScribe
unmounts; keep the same effect location and references (container, containerRef)
and ensure the cleanup runs on unmount/dep change.


// Append the live transcript to whatever was already in the message
useEffect(() => {
if (transcript) {
const prefix = messageBeforeRecording.current;
setMessage(prefix ? `${prefix} ${transcript}` : transcript);
}
}, [transcript, setMessage]);
Comment on lines +64 to +69
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't overwrite later draft updates with the start-of-recording snapshot.

Because noteMessage lives in a shared control store, this effect discards any other updates that happen after recording starts and replaces them with messageBeforeRecording.current + transcript on the next chunk. Keep the transcript portion separate, or merge against the current draft instead of the initial snapshot.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/NotesScribe.tsx` around lines 64 - 69, The effect currently
replaces the shared draft with the start-of-recording snapshot
(messageBeforeRecording.current) plus the new transcript, overwriting any edits
made after recording started; change the update to merge the transcript into the
current draft instead of using the initial snapshot—inside the useEffect that
depends on transcript, call setMessage with a functional updater that reads the
latest message (prev) and returns a merged string (e.g., combine prev and
transcript with a space), or alternatively read the current noteMessage from the
shared store before appending; avoid relying solely on
messageBeforeRecording.current so later edits are preserved.


const uploadAndComplete = useCallback(async (result: RecordingResult) => {
const { sessionId, audioBlob, audioDuration, mimeType } = result;
const baseMimeType = mimeType.split(";")[0];
const extension = baseMimeType.split("/")[1] || "webm";

try {
// Step 3a: Create file record
const fileData = await API.scribe.createFileUpload({
file_type: ScribeFileType.AUDIO,
file_category: "AUDIO",
name: `live_recording_${Date.now()}.${extension}`,
original_name: `live_recording.${extension}`,
associating_id: sessionId,
mime_type: baseMimeType,
length: audioDuration,
});

// Step 3b: Upload to signed URL
const file = new File([audioBlob], fileData.internal_name, {
type: baseMimeType,
});
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("PUT", fileData.signed_url);
xhr.setRequestHeader("Content-Type", baseMimeType);
xhr.setRequestHeader("Content-Disposition", "inline");
xhr.onload = () =>
xhr.status === 200
? resolve()
: reject(new Error(`Upload failed: ${xhr.status}`));
Comment on lines +97 to +100
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Treat any 2xx signed-URL response as success.

This currently rejects valid 201/204 uploads, which can surface a false error toast and skip the upload_completed patch even though the blob was stored successfully.

✅ Small fix
         xhr.onload = () =>
-          xhr.status === 200
+          xhr.status >= 200 && xhr.status < 300
             ? resolve()
             : reject(new Error(`Upload failed: ${xhr.status}`));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/NotesScribe.tsx` around lines 97 - 100, The onload handler for
the XHR in NotesScribe.tsx currently treats only status === 200 as success,
causing valid 2xx responses like 201/204 to be rejected; update the xhr.onload
callback (the anonymous function assigned to xhr.onload) to consider any status
in the 200–299 range as success (e.g., check xhr.status >= 200 && xhr.status <
300) and call resolve() for those statuses, otherwise reject(new Error(...)) as
before so successful uploads don’t surface false errors or skip the
upload_completed patch.

xhr.onerror = () => reject(new Error("Upload network error"));
xhr.send(file);
});

// Step 3c: Mark upload complete
await API.scribe.editFileUpload(fileData.id, "SCRIBE_AUDIO", sessionId, {
upload_completed: true,
});
} catch (err) {
console.error("Failed to upload recording", err);
toast.error("Failed to upload recording.");
}

// Step 4: Complete session (always attempted)
try {
await API.liveTranscription.complete({
session_id: sessionId,
transcript: result.transcript,
});
} catch (err) {
console.error("Failed to complete live transcription session", err);
toast.error("Failed to complete transcription session.");
}
}, []);

const handleToggleRecording = async () => {
if (isRecording) {
timer.stop();
const result = await stopRecording();
if (result) {
uploadAndComplete(result);
}
return;
}

if (!quota.tncAccepted) {
setShowTnc(true);
return;
}

try {
setIsStarting(true);
messageBeforeRecording.current = message;
await startRecording();
timer.start();
} catch (err) {
console.error("Failed to start live transcription", err);
} finally {
setIsStarting(false);
}
};

if (!SCRIBE_ENABLED) return null;

return (
<div className="scribe-container relative" ref={container}>
{isRecording && (
<div className="absolute -top-12 left-1/2 z-10 -translate-x-1/2 rounded-md bg-neutral-900 px-3 py-1.5 text-xs font-medium text-white shadow-sm">
{timer.time}
</div>
)}
{error && (
<div className="absolute -top-12 left-1/2 z-10 -translate-x-1/2 rounded-md bg-red-600 px-3 py-1.5 text-xs font-medium text-white shadow-sm">
{error}
</div>
)}
<Button
className={cn(
className,
"size-10 shrink-0",
isRecording
? "animate-pulse bg-red-500 text-white hover:bg-red-500"
: "text-white",
)}
onClick={handleToggleRecording}
disabled={isStarting}
type="button"
>
{isStarting ? (
<ReloadIcon className="size-5 animate-spin text-white" />
) : (
<MicrophoneIcon className="size-8 fill-current text-white" />
)}
</Button>
Comment thread
shivankacker marked this conversation as resolved.
Comment thread
shivankacker marked this conversation as resolved.
<TncDialog
open={showTnc}
onOpenChange={setShowTnc}
tnc={quota.tnc}
onAccept={quota.acceptTnc}
/>
</div>
);
}

const queryClient = new QueryClient();

export default function NotesScribeProvider(props: NotesScribeProps) {
return (
<QueryClientProvider client={queryClient}>
<ContainerRefProvider>
<NotesScribe {...props} />
{createPortal(
<Toaster position="top-right" richColors expand theme="light" />,
document.body,
)}
</ContainerRefProvider>
</QueryClientProvider>
);
}
12 changes: 12 additions & 0 deletions src/components/QuotaSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export default function QuotaSheet(props: {
facility_external_id: "",
tokens: 1000000,
allow_ocr: false,
enable_live_transcription: false,
tokens_per_user: 100000,
});

Expand All @@ -64,6 +65,7 @@ export default function QuotaSheet(props: {
facility_external_id: initQuota?.facility.id || "",
tokens: initQuota.tokens,
allow_ocr: initQuota.allow_ocr,
enable_live_transcription: initQuota.enable_live_transcription,
tokens_per_user: initQuota.tokens_per_user,
});
}
Expand Down Expand Up @@ -202,6 +204,15 @@ export default function QuotaSheet(props: {
}
/>
</div>
<div className="mt-4 flex items-center gap-2">
<label>{t("live_transcription")}</label>
<Switch
checked={quota.enable_live_transcription}
onCheckedChange={(checked) =>
setQuota({ ...quota, enable_live_transcription: checked })
}
/>
</div>
<SheetFooter className="p-0">
<SheetClose>
<Button
Expand All @@ -212,6 +223,7 @@ export default function QuotaSheet(props: {
facility_external_id: "",
tokens: 1000000,
allow_ocr: false,
enable_live_transcription: false,
tokens_per_user: 100000,
});
}}
Expand Down
Loading
Loading