feat(desktop): AI-powered note enhancement with auto-trigger and batch improvements#3903
Conversation
| const enhanceTaskId = createTaskId(enhancedNoteId, "enhance"); | ||
| void generate(enhanceTaskId, { |
There was a problem hiding this comment.
🟡 triggerEnhance missing task-already-running guard before calling generate
The new triggerEnhance callback in options-menu.tsx calls generate(enhanceTaskId, ...) at line 97 without first checking whether the enhance task is already running. In contrast, the same PR adds exactly this guard to runner.run() at runner.ts:170-176:
const existingTask = getAITaskState(enhanceTaskId);
if (existingTask?.status === "generating" || existingTask?.status === "success") {
return { type: "started", noteId: enhancedNoteId };
}Detailed trigger scenario and impact
This can be triggered in two ways:
-
Rapid successive uploads: If a user uploads two transcript (or audio) files in quick succession for the same session,
triggerEnhancefires twice. SincecreateEnhancedNotededuplicates and returns the sameenhancedNoteId, both calls produce the sameenhanceTaskId. The secondgeneratecall overwrites the first task's state (including itsAbortController) attasks.ts:138-148, while the first async stream continues running in the background. This causes:- Resource leak: The first stream's
AbortControlleris lost, so it can never be cancelled. - Concurrent writes: Both streams concurrently call
setto updatestreamedTexton the same task, leading to non-deterministic interleaving. - Duplicate
onComplete: Bothgenerateinvocations fire their ownonCompletecallback, causing the enhanced note content to be written twice and title generation to be started twice.
- Resource leak: The first stream's
-
Interaction with
useAutoEnhance: WhentriggerEnhancefires (e.g., after transcript upload at line 213),useAutoEnhancealso detects the new transcript and callsrunner.run(). The runner's new guard correctly prevents a duplicategeneratecall, butrunner.run()still setscurrentNoteIdRefatrunner.ts:152(before the guard at line 170). This causes the runner'suseEffectatrunner.ts:109-126to firehandleEnhanceSuccesswhen the task completes — in addition totriggerEnhance's ownonComplete— resulting in duplicate title generation via two separategenerate(titleTaskId, ...)calls.
Prompt for agents
In apps/desktop/src/components/main/body/sessions/floating/options-menu.tsx, add a task-existence check before calling generate in the triggerEnhance callback, mirroring the guard already present in runner.ts lines 170-176. Specifically:
1. Import or use the AI task store's getState function (similar to how runner.ts extracts getState from useAITask at line 59).
2. Before the generate call at line 97, add a check like:
const existingTask = getAITaskState(enhanceTaskId);
if (existingTask?.status === 'generating' || existingTask?.status === 'success') return;
3. Make sure getAITaskState is included in the useCallback dependency array at line 136.
Additionally, consider whether runner.ts line 152 (currentNoteIdRef.current = enhancedNoteId) should be moved after the getAITaskState guard at line 170-176 to prevent the runner's handleEnhanceSuccess effect from firing for tasks it did not start.
Was this helpful? React with 👍 or 👎 to provide feedback.
ba8f3c7 to
558c510
Compare
ba92af2 to
084dc0f
Compare
✅ Deploy Preview for hyprnote-storybook canceled.
|
✅ Deploy Preview for hyprnote ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
a03ac40 to
00787fb
Compare
There was a problem hiding this comment.
🟡 handleBatchStarted never called with 'importing' phase, making 'Importing audio...' label dead code
In options-menu.tsx:239, handleBatchStarted(sessionId) is called without a phase parameter before the audio import step (fsSyncCommands.audioImport). In batch.ts:57, the phase defaults to "transcribing" when not provided.
Detailed Explanation
The PR adds a new BatchPhase type ("importing" | "transcribing") and updates progress.tsx:19-22 to conditionally show "Importing audio..." only when phase === "importing":
if (progressRaw?.phase === "importing") {
return "Importing audio...";
}
return "Processing...";Previously (old code at progress.tsx:18-19), the label was always "Importing audio..." when percentage was 0. Now it requires phase === "importing", but no caller ever passes "importing" as the phase:
options-menu.tsx:239:handleBatchStarted(sessionId)→ defaults to"transcribing"general.ts:480:get().handleBatchStarted(sessionId)→ defaults to"transcribing"general.ts:505:get().handleBatchStarted(payload.session_id)→ defaults to"transcribing"
Impact: The "Importing audio..." message is never displayed. Users always see "Processing..." during audio import, which is a regression from the previous behavior.
(Refers to line 239)
Was this helpful? React with 👍 or 👎 to provide feedback.
Adjust context menu visibility when items are selected and update selected item background color for better visual consistency.
Co-Authored-By: john@hyprnote.com <john@hyprnote.com>
00787fb to
0a0816f
Compare
|
|
||
| const capturedData = captureSessionData(store, indexes, sessionId); | ||
|
|
||
| invalidateResource("sessions", sessionId); | ||
| void deleteSessionCascade(store, indexes, sessionId); | ||
|
|
||
| if (capturedData) { | ||
| const performDelete = () => { | ||
| invalidateResource("sessions", sessionId); | ||
| void deleteSessionCascade(store, indexes, sessionId); | ||
| }; | ||
|
|
||
| setDeletedSession(capturedData, performDelete); | ||
| const timeoutId = setTimeout(() => { | ||
| useUndoDelete.getState().confirmDelete(); | ||
| }, 5000); | ||
| setTimeoutId(timeoutId); | ||
| addDeletion(capturedData); | ||
| } | ||
| }, [ | ||
| store, | ||
| indexes, | ||
| sessionId, | ||
| invalidateResource, | ||
| setDeletedSession, | ||
| setTimeoutId, | ||
| ]); | ||
| }, [store, indexes, sessionId, invalidateResource, addDeletion]); |
There was a problem hiding this comment.
🔴 Immediate deletion in SessionItem makes audio file unrecoverable on undo
The handleDelete in SessionItem now calls deleteSessionCascade immediately before addDeletion, which permanently deletes the audio file on disk via fsSyncCommands.audioDelete(sessionId) (apps/desktop/src/store/tinybase/store/deleteSession.ts:234). When the user clicks "Restore" during the undo window, restoreSessionData only restores store rows (session, transcripts, participants, etc.) but NOT the audio file.
Root Cause: Regression from deferred to immediate deletion
The old code deferred actual deletion until after the 5-second undo timeout expired:
// OLD (deferred — audio preserved during undo window)
const performDelete = () => {
invalidateResource("sessions", sessionId);
void deleteSessionCascade(store, indexes, sessionId);
};
setDeletedSession(capturedData, performDelete);
setTimeout(() => confirmDelete(), 5000); // performDelete runs hereIf the user clicked undo, the timeout was cancelled and performDelete never ran — both store data AND the audio file were preserved.
The new code deletes immediately:
// NEW (immediate — audio gone forever)
invalidateResource("sessions", sessionId);
void deleteSessionCascade(store, indexes, sessionId); // deletes audio file!
if (capturedData) { addDeletion(capturedData); } // undo only restores store rowsdeleteSessionCascade calls fsSyncCommands.audioDelete(sessionId) at line 234 of deleteSession.ts, which is irreversible. restoreSessionData (deleteSession.ts:131-188) only calls store.setRow() for each table — it has no audio restoration logic.
Impact: Users who delete a session from the timeline sidebar and then undo will lose their audio recording permanently. The same pattern also applies to the bulk-delete path in apps/desktop/src/components/main/sidebar/timeline/index.tsx:139-148.
(Refers to lines 399-412)
Prompt for agents
The delete flow needs to be reverted to a deferred-deletion pattern to preserve audio files during the undo window. There are three locations to fix:
1. apps/desktop/src/components/main/sidebar/timeline/item.tsx (SessionItem handleDelete, lines 399-412): Defer the invalidateResource and deleteSessionCascade calls. Pass a performDelete callback to addDeletion via the onConfirm parameter. Only call invalidateResource and deleteSessionCascade inside that callback. The addDeletion function already accepts an optional onConfirm parameter, and confirmDeletion already calls it.
2. apps/desktop/src/components/main/sidebar/timeline/index.tsx (handleDeleteSelected, lines 130-158): Same pattern — for each session, pass the deletion logic as onConfirm to addDeletion instead of calling it immediately.
3. apps/desktop/src/components/main/body/sessions/outer-header/overflow/delete.tsx (DeleteNote handleDeleteNote, lines 24-42): Same fix — defer the invalidateResource and deleteSessionCascade into the onConfirm callback.
For all three sites, the pattern should be:
const capturedData = captureSessionData(store, indexes, sessionId);
if (capturedData) {
addDeletion(capturedData, () => {
invalidateResource('sessions', sessionId);
void deleteSessionCascade(store, indexes, sessionId);
});
}
Note: You may also want to visually hide or mark the session as pending-delete in the UI immediately (e.g. via a separate flag) so users get instant visual feedback while the actual deletion is deferred.
Was this helpful? React with 👍 or 👎 to provide feedback.
Co-Authored-By: john@hyprnote.com <john@hyprnote.com>
| const handleDeleteSelected = useCallback(() => { | ||
| if (!store || !indexes) { | ||
| return; | ||
| } | ||
|
|
||
| const sessionIds = selectedIds | ||
| .filter((key) => key.startsWith("session-")) | ||
| .map((key) => key.replace("session-", "")); | ||
|
|
||
| for (const sessionId of sessionIds) { | ||
| const capturedData = captureSessionData(store, indexes, sessionId); | ||
|
|
||
| invalidateResource("sessions", sessionId); | ||
| void deleteSessionCascade(store, indexes, sessionId); | ||
|
|
||
| if (capturedData) { | ||
| addDeletion(capturedData); | ||
| } | ||
| } | ||
|
|
||
| clearSelection(); | ||
| }, [ | ||
| store, | ||
| indexes, | ||
| selectedIds, | ||
| invalidateResource, | ||
| addDeletion, | ||
| clearSelection, | ||
| ]); |
There was a problem hiding this comment.
Logic inconsistency: handleDeleteSelected allows deletion of sessions that may be actively running batch operations, which contradicts the protection added in _layout.tsx (lines 88-91) that prevents deletion of batch-running sessions when tabs close. This could cause data loss or corruption if a user deletes a session mid-batch.
const handleDeleteSelected = useCallback(() => {
if (!store || !indexes) {
return;
}
const sessionIds = selectedIds
.filter((key) => key.startsWith("session-"))
.map((key) => key.replace("session-", ""));
for (const sessionId of sessionIds) {
// Check if session is running batch before deleting
const isBatchRunning =
listenerStore.getState().getSessionMode(sessionId) === "running_batch";
if (isBatchRunning) {
continue; // Skip batch-running sessions
}
const capturedData = captureSessionData(store, indexes, sessionId);
// ... rest of deletion logic
}
// ...
});| const handleDeleteSelected = useCallback(() => { | |
| if (!store || !indexes) { | |
| return; | |
| } | |
| const sessionIds = selectedIds | |
| .filter((key) => key.startsWith("session-")) | |
| .map((key) => key.replace("session-", "")); | |
| for (const sessionId of sessionIds) { | |
| const capturedData = captureSessionData(store, indexes, sessionId); | |
| invalidateResource("sessions", sessionId); | |
| void deleteSessionCascade(store, indexes, sessionId); | |
| if (capturedData) { | |
| addDeletion(capturedData); | |
| } | |
| } | |
| clearSelection(); | |
| }, [ | |
| store, | |
| indexes, | |
| selectedIds, | |
| invalidateResource, | |
| addDeletion, | |
| clearSelection, | |
| ]); | |
| const handleDeleteSelected = useCallback(() => { | |
| if (!store || !indexes) { | |
| return; | |
| } | |
| const sessionIds = selectedIds | |
| .filter((key) => key.startsWith("session-")) | |
| .map((key) => key.replace("session-", "")); | |
| for (const sessionId of sessionIds) { | |
| // Check if session is running batch before deleting | |
| const isBatchRunning = | |
| listenerStore.getState().getSessionMode(sessionId) === "running_batch"; | |
| if (isBatchRunning) { | |
| continue; // Skip batch-running sessions | |
| } | |
| const capturedData = captureSessionData(store, indexes, sessionId); | |
| invalidateResource("sessions", sessionId); | |
| void deleteSessionCascade(store, indexes, sessionId); | |
| if (capturedData) { | |
| addDeletion(capturedData); | |
| } | |
| } | |
| clearSelection(); | |
| }, [ | |
| store, | |
| indexes, | |
| selectedIds, | |
| invalidateResource, | |
| addDeletion, | |
| clearSelection, | |
| ]); |
Spotted by Graphite Agent
Is this helpful? React 👍 or 👎 to let us know.
Summary
Adds an AI-powered note enhancement flow that automatically triggers after transcript upload or audio batch processing. Also refactors batch state to be per-session and improves error handling during batch operations.
Built on top of #3909 (multi-session deletion & timeline selection).
Changes
AI Enhancement Trigger (
options-menu.tsx)triggerEnhancecallback: checks transcript eligibility → creates enhanced note → switches tab view → runsgenerate()withenhancetaskenhanced_notes, and auto-generates session title if emptyAuto-Enhance Guard (
autoEnhance/runner.ts)getAITaskStatecheck beforegenerate()to prevent duplicate enhance tasks when status is"generating"or"success"Batch Phase & Progress (
batch.ts,progress.tsx)BatchPhasetype:"importing" | "transcribing"handleBatchStartedaccepts optional phase parameter (defaults to"transcribing")Per-Session Batch Persistence (
batch.ts,general.ts)handlePersist/handleTranscriptResponseto per-sessionbatchPersist: Record<string, HandlePersistCallback>setBatchPersist(sessionId, callback)andclearBatchPersist(sessionId)actionshandleBatchResponseStreameduses per-session persist callback and handles word extraction inlineBatch Loading Guard (
useAutoEnhance.ts)loadingis trueloadingto effect dependency arrayError Handling & Tab Protection
useRunBatch.ts: throws descriptiveErrorinstead of silently returning when STT connection unavailable_layout.tsx: prevents auto-deletion of empty session tab while batch isrunning_batchReview & Testing Checklist for Human
triggerEnhancehas no duplicate-task guard: Unlikerunner.ts:170-176, thetriggerEnhancecallback inoptions-menu.tsxdoes NOT checkgetAITaskStatebefore callinggenerate(). Rapid successive uploads or interaction withuseAutoEnhancecould cause concurrent enhance tasks on the same note. Verify whether this is intentional or needs the same guard.phase: "importing"tohandleBatchStarted— all default to"transcribing". Verify thatprogress.tsx's"importing"branch is reachable, or add the missinghandleBatchStarted(sessionId, "importing")call inoptions-menu.tsx:239.Recommended test plan:
Notes
Link to Devin run: https://app.devin.ai/sessions/1b9ae9acc87349e393883ca54ed961c6
Requested by: @ComputelessComputer
This is part 2 of 2 in a stack made with GitButler: