Skip to content

feat(desktop): AI-powered note enhancement with auto-trigger and batch improvements#3903

Merged
ComputelessComputer merged 18 commits intomainfrom
feat/ai-note-enhancement-flow
Feb 12, 2026
Merged

feat(desktop): AI-powered note enhancement with auto-trigger and batch improvements#3903
ComputelessComputer merged 18 commits intomainfrom
feat/ai-note-enhancement-flow

Conversation

@ComputelessComputer
Copy link
Collaborator

@ComputelessComputer ComputelessComputer commented Feb 12, 2026

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)

  • New triggerEnhance callback: checks transcript eligibility → creates enhanced note → switches tab view → runs generate() with enhance task
  • On completion: converts markdown to JSON, persists to enhanced_notes, and auto-generates session title if empty
  • Called after both transcript file upload and audio batch completion

Auto-Enhance Guard (autoEnhance/runner.ts)

  • Added getAITaskState check before generate() to prevent duplicate enhance tasks when status is "generating" or "success"

Batch Phase & Progress (batch.ts, progress.tsx)

  • New BatchPhase type: "importing" | "transcribing"
  • handleBatchStarted accepts optional phase parameter (defaults to "transcribing")
  • Progress label conditionally shows "Importing audio..." vs "Processing..."

Per-Session Batch Persistence (batch.ts, general.ts)

  • Refactored from global handlePersist/handleTranscriptResponse to per-session batchPersist: Record<string, HandlePersistCallback>
  • New setBatchPersist(sessionId, callback) and clearBatchPersist(sessionId) actions
  • handleBatchResponseStreamed uses per-session persist callback and handles word extraction inline

Batch Loading Guard (useAutoEnhance.ts)

  • Prevents auto-enhance from triggering while loading is true
  • Added loading to effect dependency array

Error Handling & Tab Protection

  • useRunBatch.ts: throws descriptive Error instead of silently returning when STT connection unavailable
  • _layout.tsx: prevents auto-deletion of empty session tab while batch is running_batch

Review & Testing Checklist for Human

  • triggerEnhance has no duplicate-task guard: Unlike runner.ts:170-176, the triggerEnhance callback in options-menu.tsx does NOT check getAITaskState before calling generate(). Rapid successive uploads or interaction with useAutoEnhance could cause concurrent enhance tasks on the same note. Verify whether this is intentional or needs the same guard.
  • Per-session batch persist correctness: Test that uploading audio to two different sessions concurrently persists transcripts to the correct sessions (no cross-contamination from the old global callback).
  • "Importing audio..." label may be dead code: No caller passes phase: "importing" to handleBatchStarted — all default to "transcribing". Verify that progress.tsx's "importing" branch is reachable, or add the missing handleBatchStarted(sessionId, "importing") call in options-menu.tsx:239.
  • Tab close protection: Verify that closing a session tab during batch processing does NOT delete the session data.

Recommended test plan:

  1. Upload a transcript file → verify enhanced note is auto-generated and tab switches to enhanced view
  2. Upload an audio file → verify batch processes, then enhanced note is auto-generated
  3. Upload audio to two sessions simultaneously → verify transcripts go to correct sessions
  4. During batch processing, try closing the session tab → verify session is NOT deleted

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:


Open with Devin

Copy link
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 1 potential issue.

View 6 additional findings in Devin Review.

Open in Devin Review

Comment on lines +96 to +97
const enhanceTaskId = createTaskId(enhancedNoteId, "enhance");
void generate(enhanceTaskId, {
Copy link
Contributor

Choose a reason for hiding this comment

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

🟡 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:

  1. Rapid successive uploads: If a user uploads two transcript (or audio) files in quick succession for the same session, triggerEnhance fires twice. Since createEnhancedNote deduplicates and returns the same enhancedNoteId, both calls produce the same enhanceTaskId. The second generate call overwrites the first task's state (including its AbortController) at tasks.ts:138-148, while the first async stream continues running in the background. This causes:

    • Resource leak: The first stream's AbortController is lost, so it can never be cancelled.
    • Concurrent writes: Both streams concurrently call set to update streamedText on the same task, leading to non-deterministic interleaving.
    • Duplicate onComplete: Both generate invocations fire their own onComplete callback, causing the enhanced note content to be written twice and title generation to be started twice.
  2. Interaction with useAutoEnhance: When triggerEnhance fires (e.g., after transcript upload at line 213), useAutoEnhance also detects the new transcript and calls runner.run(). The runner's new guard correctly prevents a duplicate generate call, but runner.run() still sets currentNoteIdRef at runner.ts:152 (before the guard at line 170). This causes the runner's useEffect at runner.ts:109-126 to fire handleEnhanceSuccess when the task completes — in addition to triggerEnhance's own onComplete — resulting in duplicate title generation via two separate generate(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.
Open in Devin Review

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

@ComputelessComputer ComputelessComputer force-pushed the feat/ai-note-enhancement-flow branch from ba92af2 to 084dc0f Compare February 12, 2026 05:55
Base automatically changed from feat/improve-audio-import to main February 12, 2026 06:01
@netlify
Copy link

netlify bot commented Feb 12, 2026

Deploy Preview for hyprnote-storybook canceled.

Name Link
🔨 Latest commit 903b58f
🔍 Latest deploy log https://app.netlify.com/projects/hyprnote-storybook/deploys/698d89b8d2dbac0008a57ffa

@netlify
Copy link

netlify bot commented Feb 12, 2026

Deploy Preview for hyprnote ready!

Name Link
🔨 Latest commit 903b58f
🔍 Latest deploy log https://app.netlify.com/projects/hyprnote/deploys/698d89b85a450c000817fb61
😎 Deploy Preview https://deploy-preview-3903--hyprnote.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@ComputelessComputer ComputelessComputer force-pushed the feat/ai-note-enhancement-flow branch from a03ac40 to 00787fb Compare February 12, 2026 07:16
Copy link
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

View 16 additional findings in Devin Review.

Open in Devin Review

Copy link
Contributor

Choose a reason for hiding this comment

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

🟡 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)

Open in Devin Review

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

@ComputelessComputer ComputelessComputer force-pushed the feat/ai-note-enhancement-flow branch from 00787fb to 0a0816f Compare February 12, 2026 07:38
@devin-ai-integration devin-ai-integration bot changed the title feat/ai-note-enhancement-flow feat(desktop): AI-powered note enhancement with auto-trigger and batch improvements Feb 12, 2026
Copy link
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

View 22 additional findings in Devin Review.

Open in Devin Review

Comment on lines 403 to +412

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]);
Copy link
Contributor

Choose a reason for hiding this comment

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

🔴 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 here

If 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 rows

deleteSessionCascade 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.
Open in Devin Review

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

Co-Authored-By: john@hyprnote.com <john@hyprnote.com>
Comment on lines +131 to +159
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,
]);
Copy link
Contributor

Choose a reason for hiding this comment

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

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
  }
  // ...
});
Suggested change
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

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant