Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
TooltipTrigger,
} from "@hypr/ui/components/ui/tooltip";

import { useListener } from "../../../../../contexts/listener";
import { fromResult } from "../../../../../effect";
import { useRunBatch } from "../../../../../hooks/useRunBatch";
import * as main from "../../../../../store/tinybase/store/main";
Expand All @@ -45,6 +46,9 @@ export function OptionsMenu({
const [open, setOpen] = useState(false);
const runBatch = useRunBatch(sessionId);
const queryClient = useQueryClient();
const handleBatchStarted = useListener((state) => state.handleBatchStarted);
const handleBatchFailed = useListener((state) => state.handleBatchFailed);
const clearBatchSession = useListener((state) => state.clearBatchSession);

const store = main.UI.useStore(main.STORE_ID);
const { user_id } = main.UI.useValues(main.STORE_ID);
Expand Down Expand Up @@ -139,7 +143,18 @@ export function OptionsMenu({
}

return pipe(
fromResult(fsSyncCommands.audioImport(sessionId, path)),
Effect.sync(() => {
if (sessionTab) {
updateSessionTabState(sessionTab, {
...sessionTab.state,
view: { type: "transcript" },
});
}
handleBatchStarted(sessionId);
}),
Effect.flatMap(() =>
fromResult(fsSyncCommands.audioImport(sessionId, path)),
),
Effect.tap(() =>
Effect.sync(() => {
void analyticsCommands.event({
Expand All @@ -154,10 +169,20 @@ export function OptionsMenu({
});
}),
),
Effect.tap(() => Effect.sync(() => clearBatchSession(sessionId))),
Effect.flatMap(() => Effect.promise(() => runBatch(path))),
Comment on lines +172 to 173
Copy link
Contributor

Choose a reason for hiding this comment

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

🔴 clearBatchSession before runBatch causes silent failure when STT connection is unavailable

When an audio file is imported, clearBatchSession(sessionId) is called at line 172 before runBatch(path) at line 173. Inside useRunBatch (hooks/useRunBatch.ts:69-71), if !store || !conn || !runBatch, the callback returns undefined without throwing. Since Effect.promise(() => runBatch(path)) resolves successfully with undefined, the catchAll error handler never fires.

Root Cause and Impact

The sequence of operations is:

  1. handleBatchStarted(sessionId) — creates batch entry with { percentage: 0 }
  2. audioImport succeeds — audio file is imported
  3. clearBatchSession(sessionId)removes the batch entry entirely
  4. runBatch(path) — if STT connection (conn) is null (e.g., no provider configured, local model not downloaded, cloud model without auth), useRunBatch's callback silently returns undefined

At this point:

  • The audio was imported successfully
  • The batch entry was cleared (step 3)
  • runBatch didn't create a new batch entry or throw an error
  • The catchAll doesn't fire because the promise resolved
  • The TranscriptionProgress component shows nothing — no progress indicator, no error message

The user sees the "Importing audio..." indicator appear, then disappear, with no indication that transcription failed to start. The conn being null is a common scenario — it happens whenever the user hasn't fully configured their STT provider, the local model isn't downloaded yet, or the cloud auth session expired.

Impact: Users who import audio without a working STT connection get no error feedback. The audio is imported but transcription silently doesn't start.

Prompt for agents
The fix should ensure that if runBatch resolves without starting transcription (silent early return), the user gets error feedback. There are two approaches:

1. (Preferred) In hooks/useRunBatch.ts lines 69-71, change the early return to throw an Error instead of silently returning:
   if (!store || !conn || !runBatch) {
     throw new Error("No STT connection available. Please configure a speech-to-text provider.");
   }
   This way the Effect.promise wrapper will reject, and the catchAll in options-menu.tsx will call handleBatchFailed with a meaningful message.

2. (Alternative) In options-menu.tsx, after the Effect.promise(() => runBatch(path)) step, add a check that verifies the batch actually started, and if not, call handleBatchFailed with an appropriate message. For example, wrap runBatch in a helper that detects the silent return and throws.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +172 to 173
Copy link
Contributor

Choose a reason for hiding this comment

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

🟡 clearBatchSession before runBatch causes transient 'inactive' session mode, potentially dropping transcript tab

clearBatchSession(sessionId) at options-menu.tsx:172 removes the batch entry from state, causing getSessionMode to return "inactive". The subsequent runBatch(path) at options-menu.tsx:173 re-creates the batch entry via handleBatchStarted inside general.ts:482. Between these two calls, any React re-render would see sessionMode === "inactive".

Detailed Explanation

The useEditorTabs hook at header.tsx:633 uses sessionMode to determine which tabs to show:

if (sessionMode === "active" || sessionMode === "running_batch") {
    const tabs: EditorView[] = [{ type: "raw" }, { type: "transcript" }];
    return tabs;
}

When sessionMode is "inactive" (after clearBatchSession but before runBatch re-establishes the batch state), and there's no transcript yet (which is the case during first audio import), useEditorTabs returns [{ type: "raw" }] — without the transcript tab. This could cause the view to briefly switch away from the transcript tab.

Similarly, TranscriptEmptyState at shared/index.tsx:130 would show "No transcript available" instead of "Generating transcript..." during this window.

While React 18 batching may mitigate this in most cases (since clearBatchSession and handleBatchStarted inside runBatch may run in the same synchronous block), the Effect.promise wrapper and async function boundaries make this timing-dependent and fragile.

Impact: Potential UI flicker where the transcript tab disappears and reappears, or the empty state briefly shows the wrong message.

Prompt for agents
Instead of calling clearBatchSession before runBatch (which creates a window where the session mode is 'inactive'), remove the clearBatchSession call at options-menu.tsx:172 entirely. The runBatch function in general.ts already handles the batch state lifecycle internally (it calls handleBatchStarted at line 482 and clearBatchSession on completion). The initial handleBatchStarted at options-menu.tsx:153 should be kept to show the importing state, but the clearBatchSession should not be called before runBatch. Instead, either: (1) add a phase/stage field to the batch state to distinguish 'importing' from 'transcribing', or (2) let runBatch's internal handleBatchStarted reset the percentage naturally without clearing first. The guard at general.ts:469-474 that prevents running batch when already in running_batch mode would need to be adjusted to allow re-starting from the same flow (e.g., by passing a flag or checking if the caller is the same audio import flow).
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Effect.catchAll((error: unknown) =>
Effect.sync(() => {
const msg = error instanceof Error ? error.message : String(error);
handleBatchFailed(sessionId, msg);
}),
Comment on lines 173 to +178
Copy link
Contributor

Choose a reason for hiding this comment

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

🔴 Effect.catchAll cannot catch rejections from Effect.promise, so runBatch errors bypass the error handler

The catchAll at line 174 is intended to catch errors from both audioImport and runBatch, but Effect.promise (line 173) treats promise rejections as defects (unexpected errors), not recoverable errors. Effect.catchAll only catches recoverable errors (from Effect.fail/Effect.tryPromise), not defects.

Root Cause and Impact

The fromResult helper at apps/desktop/src/effect.ts:5-17 uses Effect.tryPromise, which maps rejections into the error channel (E) — catchable by Effect.catchAll. However, Effect.promise at line 173 maps rejections into defects (Cause.Die) — NOT catchable by Effect.catchAll.

When runBatch(path) rejects (e.g., the provider check at apps/desktop/src/hooks/useRunBatch.ts:77 throws "Batch transcription is not supported for provider: ..." before state.runBatch is even called), the error becomes a defect. Effect.catchAll doesn't intercept it, so handleBatchFailed is never invoked. The defect propagates to Effect.runPromise, which rejects, and the outer .catch at line 226 only logs the error to the console.

At that point, clearBatchSession (line 172) has already removed the batch entry, so the user sees "No transcript available" with no error feedback — even though the audio was imported but transcription silently failed.

For errors originating inside state.runBatch (in general.ts), handleBatchFailed IS called internally before rejecting, partially mitigating the issue. But for early failures in useRunBatch (like an unsupported provider), the error is completely swallowed from the user's perspective.

Fix: Replace Effect.promise with Effect.tryPromise so rejections enter the error channel:

Effect.flatMap(() => Effect.tryPromise({
  try: () => runBatch(path),
  catch: (error) => error,
})),
Suggested change
Effect.flatMap(() => Effect.promise(() => runBatch(path))),
Effect.catchAll((error: unknown) =>
Effect.sync(() => {
const msg = error instanceof Error ? error.message : String(error);
handleBatchFailed(sessionId, msg);
}),
Effect.flatMap(() =>
Effect.tryPromise({
try: () => runBatch(path),
catch: (error) => error,
}),
),
Effect.catchAll((error: unknown) =>
Effect.sync(() => {
const msg = error instanceof Error ? error.message : String(error);
handleBatchFailed(sessionId, msg);
}),
),
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

),
);
},
[
clearBatchSession,
handleBatchFailed,
handleBatchStarted,
queryClient,
runBatch,
sessionId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,9 @@ export function FloatingButton({
<Button
size="lg"
className={cn([
"border-2 rounded-full transition-[border-color,opacity] duration-200",
"border-2 rounded-full transition-[border-color,opacity] duration-200 focus-visible:ring-0",
error && "border-red-500",
!error && "border-neutral-200 focus-within:border-stone-500",
!error && "border-neutral-200",
subtle && "opacity-40 hover:opacity-100",
className,
])}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import { AudioLinesIcon } from "lucide-react";

export function TranscriptEmptyState() {
import { Spinner } from "@hypr/ui/components/ui/spinner";

export function TranscriptEmptyState({ isBatching }: { isBatching?: boolean }) {
return (
<div className="h-full flex flex-col items-center justify-center gap-3 text-neutral-400">
<AudioLinesIcon className="w-8 h-8" />
<p className="text-sm">No transcript available</p>
{isBatching ? (
<Spinner size={28} />
) : (
<AudioLinesIcon className="w-8 h-8" />
)}
<p className="text-sm">
{isBatching ? "Generating transcript..." : "No transcript available"}
</p>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export function TranscriptionProgress({ sessionId }: { sessionId: string }) {

const statusLabel = useMemo(() => {
if (!progressRaw || progressRaw.percentage === 0) {
return "...";
return "Importing audio...";
Copy link
Contributor

Choose a reason for hiding this comment

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

🟡 "Importing audio..." label shown during batch transcription phase (not just audio import)

The statusLabel in TranscriptionProgress returns "Importing audio..." whenever progressRaw is null or progressRaw.percentage === 0. After the audio import completes, clearBatchSession is called (options-menu.tsx:172), which removes the batch entry. Then runBatch is called (options-menu.tsx:173), which internally calls handleBatchStarted (general.ts:482), resetting the batch state to { percentage: 0, isComplete: false }. At this point, the audio import is already done and transcription is starting, but the progress indicator still shows "Importing audio..." until the first batchProgress event arrives with a non-zero percentage.

Root Cause and Impact

The TranscriptionProgress component has no way to distinguish between the "importing audio" phase and the "starting transcription" phase — both have percentage: 0. The label "Importing audio..." is hardcoded for this state at progress.tsx:19.

This also affects the "redo transcript" flow (header.tsx:154: await runBatch(audioPath)), where no audio import happens at all. In that case, the user sees "Importing audio..." when they're just re-running transcription on existing audio.

Actual: User sees "Importing audio..." during the transcription phase and during redo operations.
Expected: The label should reflect the actual phase — e.g., "Processing..." or "Starting transcription..." when transcription is beginning, and "Importing audio..." only during actual audio import.

Suggested change
return "Importing audio...";
return "Processing...";
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}

const percent = Math.round(progressRaw.percentage * 100);
Expand Down Expand Up @@ -45,7 +45,11 @@ export function TranscriptionProgress({ sessionId }: { sessionId: string }) {
<div className="mb-3">
<div className="flex w-fit items-center gap-2 rounded-full bg-neutral-900 px-3 py-1 text-xs text-white shadow-xs">
<Spinner size={12} className="text-white/80" />
<span>Processing · {statusLabel}</span>
<span>
{!progressRaw || progressRaw.percentage === 0
? statusLabel
: `Processing · ${statusLabel}`}
</span>
</div>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,9 @@ export function TranscriptContainer({

// TOOD: this can't handle words=[]
if (transcriptIds.length === 0) {
return <TranscriptEmptyState />;
return (
<TranscriptEmptyState isBatching={sessionMode === "running_batch"} />
);
}

const handleSelectionAction = (action: string, selectedText: string) => {
Expand Down
14 changes: 12 additions & 2 deletions apps/desktop/src/contexts/audio-player/provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,16 @@ export function AudioPlayerProvider({
let audioContext: AudioContext | null = null;

const handleReady = async () => {
const dur = ws.getDuration();
if (dur && isFinite(dur)) {
setDuration(dur);
}

const media = ws.getMediaElement();
if (!media) {
return;
}

setDuration(media.duration);

audioContext = new AudioContext();
if (audioContext.state === "suspended") {
await audioContext.resume();
Expand All @@ -128,10 +131,17 @@ export function AudioPlayerProvider({
setCurrentTime(ws.getCurrentTime());
};

const handleDecode = (dur: number) => {
if (dur && isFinite(dur)) {
setDuration(dur);
}
};

const handleDestroy = () => {
setState("stopped");
};

ws.on("decode", handleDecode);
ws.on("ready", handleReady);
ws.on("audioprocess", handleAudioprocess);
ws.on("timeupdate", handleTimeupdate);
Expand Down
Loading