Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
1f79bab
feat(timeline): add multi-session deletion and selection support
ComputelessComputer Feb 12, 2026
d23f20b
feat(sessions): support batching session mode with new states
ComputelessComputer Feb 12, 2026
371b4c3
refactor(timeline): simplify selection and deletion logic
ComputelessComputer Feb 12, 2026
9aff6dc
refactor(store): support multiple pending deletions
ComputelessComputer Feb 12, 2026
e39ad25
fix(timeline): improve selection context menu and styling
ComputelessComputer Feb 12, 2026
1dd8eb6
Merge branch 'main' into feat/ai-note-enhancement-flow
devin-ai-integration[bot] Feb 12, 2026
39e1998
feat(desktop): update button focus style for better accessibility
ComputelessComputer Feb 12, 2026
a49f5fb
feat(import): improve audio import experience and error handling
ComputelessComputer Feb 12, 2026
cb8c29b
feat(transcript): add batching state to empty state component
ComputelessComputer Feb 12, 2026
5f096a4
fix(audio): improve duration tracking with wavesurfer events
ComputelessComputer Feb 12, 2026
76b9ae8
fix(desktop): handle unknown error type in batch session
ComputelessComputer Feb 12, 2026
1edb2fb
feat(sessions): add AI-powered note enhancement flow
ComputelessComputer Feb 12, 2026
cc1267f
feat(enhancement): add auto-trigger for session enhancement
ComputelessComputer Feb 12, 2026
2e242bc
fmt
ComputelessComputer Feb 12, 2026
89c85c4
refactor(batch): improve batch state and persistence handling
ComputelessComputer Feb 12, 2026
cab718f
fix(useAutoEnhance): prevent auto-enhance during batch loading
ComputelessComputer Feb 12, 2026
0a0816f
refactor(sessions): remove unused getAITaskState selector
ComputelessComputer Feb 12, 2026
903b58f
fix(test): update batch state test to include phase field
devin-ai-integration[bot] Feb 12, 2026
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
9 changes: 7 additions & 2 deletions apps/desktop/src/components/interactive-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ interface InteractiveButtonProps {
children: ReactNode;
onClick?: () => void;
onCmdClick?: () => void;
onShiftClick?: () => void;
onMouseDown?: (e: MouseEvent<HTMLElement>) => void;
contextMenu?: MenuItemDef[];
className?: string;
Expand All @@ -20,6 +21,7 @@ export function InteractiveButton({
children,
onClick,
onCmdClick,
onShiftClick,
onMouseDown,
contextMenu,
className,
Expand All @@ -34,14 +36,17 @@ export function InteractiveButton({
return;
}

if (e.metaKey || e.ctrlKey) {
if (e.shiftKey) {
e.preventDefault();
onShiftClick?.();
} else if (e.metaKey || e.ctrlKey) {
e.preventDefault();
onCmdClick?.();
} else {
onClick?.();
}
},
[onClick, onCmdClick, disabled],
[onClick, onCmdClick, onShiftClick, disabled],
);

const Element = asChild ? "div" : "button";
Expand Down
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.

Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useCallback, useState } from "react";
import { commands as analyticsCommands } from "@hypr/plugin-analytics";
import { commands as fsSyncCommands } from "@hypr/plugin-fs-sync";
import { commands as listener2Commands } from "@hypr/plugin-listener2";
import { md2json } from "@hypr/tiptap/shared";
import { Button } from "@hypr/ui/components/ui/button";
import {
Popover,
Expand All @@ -20,10 +21,15 @@ import {
TooltipTrigger,
} from "@hypr/ui/components/ui/tooltip";

import { useAITask } from "../../../../../contexts/ai-task";
import { useListener } from "../../../../../contexts/listener";
import { fromResult } from "../../../../../effect";
import { getEligibility } from "../../../../../hooks/autoEnhance/eligibility";
import { useCreateEnhancedNote } from "../../../../../hooks/useEnhancedNotes";
import { useLanguageModel } from "../../../../../hooks/useLLMConnection";
import { useRunBatch } from "../../../../../hooks/useRunBatch";
import * as main from "../../../../../store/tinybase/store/main";
import { createTaskId } from "../../../../../store/zustand/ai-task/task-configs";
import { type Tab, useTabs } from "../../../../../store/zustand/tabs";
import { ChannelProfile } from "../../../../../utils/segment";
import { ActionableTooltipContent } from "./shared";
Expand All @@ -50,9 +56,13 @@ export function OptionsMenu({
const handleBatchFailed = useListener((state) => state.handleBatchFailed);
const clearBatchSession = useListener((state) => state.clearBatchSession);

const store = main.UI.useStore(main.STORE_ID);
const store = main.UI.useStore(main.STORE_ID) as main.Store | undefined;
const indexes = main.UI.useIndexes(main.STORE_ID);
const { user_id } = main.UI.useValues(main.STORE_ID);
const updateSessionTabState = useTabs((state) => state.updateSessionTabState);
const createEnhancedNote = useCreateEnhancedNote();
const model = useLanguageModel();
const generate = useAITask((state) => state.generate);
const sessionTab = useTabs((state) => {
const found = state.tabs.find(
(tab): tab is Extract<Tab, { type: "sessions" }> =>
Expand All @@ -61,6 +71,79 @@ export function OptionsMenu({
return found ?? null;
});

const triggerEnhance = useCallback(() => {
if (!store || !indexes || !model) return;

const transcriptIds = indexes.getSliceRowIds(
main.INDEXES.transcriptBySession,
sessionId,
);
const hasTranscript = transcriptIds.length > 0;
const eligibility = getEligibility(hasTranscript, transcriptIds, store);

if (!eligibility.eligible) return;

const enhancedNoteId = createEnhancedNote(sessionId);
if (!enhancedNoteId) return;

if (sessionTab) {
updateSessionTabState(sessionTab, {
...sessionTab.state,
view: { type: "enhanced", id: enhancedNoteId },
});
}

const enhanceTaskId = createTaskId(enhancedNoteId, "enhance");
void generate(enhanceTaskId, {
Comment on lines +96 to +97
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.

model,
taskType: "enhance",
args: { sessionId, enhancedNoteId },
onComplete: (text) => {
if (!text || !store) return;
try {
const jsonContent = md2json(text);
store.setPartialRow("enhanced_notes", enhancedNoteId, {
content: JSON.stringify(jsonContent),
});

const currentTitle = store.getCell("sessions", sessionId, "title");
const trimmedTitle =
typeof currentTitle === "string" ? currentTitle.trim() : "";

if (!trimmedTitle && model) {
const titleTaskId = createTaskId(sessionId, "title");
void generate(titleTaskId, {
model,
taskType: "title",
args: { sessionId },
onComplete: (titleText) => {
if (titleText && store) {
const trimmed = titleText.trim();
if (trimmed && trimmed !== "<EMPTY>") {
store.setPartialRow("sessions", sessionId, {
title: trimmed,
});
}
}
},
});
}
} catch (error) {
console.error("Failed to convert markdown to JSON:", error);
}
},
});
}, [
store,
indexes,
model,
sessionId,
createEnhancedNote,
sessionTab,
updateSessionTabState,
generate,
]);

const handleFilePath = useCallback(
(selection: FileSelection, kind: "audio" | "transcript") => {
if (!selection) {
Expand Down Expand Up @@ -126,6 +209,8 @@ export function OptionsMenu({
file_type: "transcript",
token_count: subtitle.tokens.length,
});

triggerEnhance();
}),
),
);
Expand Down Expand Up @@ -171,6 +256,7 @@ export function OptionsMenu({
),
Effect.tap(() => Effect.sync(() => clearBatchSession(sessionId))),
Effect.flatMap(() => Effect.promise(() => runBatch(path))),
Effect.tap(() => Effect.sync(() => triggerEnhance())),
Effect.catchAll((error: unknown) =>
Effect.sync(() => {
const msg = error instanceof Error ? error.message : String(error);
Expand All @@ -188,6 +274,7 @@ export function OptionsMenu({
sessionId,
sessionTab,
store,
triggerEnhance,
updateSessionTabState,
user_id,
],
Expand Down
17 changes: 16 additions & 1 deletion apps/desktop/src/components/main/body/sessions/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@ export const TabItemNote: TabItem<Extract<Tab, { type: "sessions" }>> = ({
const isEnhancing = useIsSessionEnhancing(tab.id);
const isActive = sessionMode === "active" || sessionMode === "finalizing";
const isFinalizing = sessionMode === "finalizing";
const showSpinner = !tab.active && (isFinalizing || isEnhancing);
const isBatching = sessionMode === "running_batch";
const showSpinner =
!tab.active && (isFinalizing || isEnhancing || isBatching);

const showCloseConfirmation =
pendingCloseConfirmationTab?.type === "sessions" &&
Expand Down Expand Up @@ -101,11 +103,24 @@ export function TabContentNote({
tab: Extract<Tab, { type: "sessions" }>;
}) {
const listenerStatus = useListener((state) => state.live.status);
const sessionMode = useListener((state) => state.getSessionMode(tab.id));
const updateSessionTabState = useTabs((state) => state.updateSessionTabState);
const { conn } = useSTTConnection();
const startListening = useStartListening(tab.id);
const hasAttemptedAutoStart = useRef(false);

useEffect(() => {
if (
sessionMode === "running_batch" &&
tab.state.view?.type !== "transcript"
) {
updateSessionTabState(tab, {
...tab.state,
view: { type: "transcript" },
});
}
}, [sessionMode, tab, updateSessionTabState]);

useEffect(() => {
if (!tab.state.autoStart) {
hasAttemptedAutoStart.current = false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ export function TranscriptionProgress({ sessionId }: { sessionId: string }) {

const statusLabel = useMemo(() => {
if (!progressRaw || progressRaw.percentage === 0) {
return "Importing audio...";
if (progressRaw?.phase === "importing") {
return "Importing audio...";
}
return "Processing...";
}

const percent = Math.round(progressRaw.percentage * 100);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,11 @@ import * as main from "../../../../../../store/tinybase/store/main";
import { useTabs } from "../../../../../../store/zustand/tabs";
import { useUndoDelete } from "../../../../../../store/zustand/undo-delete";

const UNDO_TIMEOUT_MS = 5000;

export function DeleteNote({ sessionId }: { sessionId: string }) {
const store = main.UI.useStore(main.STORE_ID);
const indexes = main.UI.useIndexes(main.STORE_ID);
const invalidateResource = useTabs((state) => state.invalidateResource);
const { setDeletedSession, setTimeoutId, clear } = useUndoDelete();
const addDeletion = useUndoDelete((state) => state.addDeletion);

const handleDeleteNote = useCallback(() => {
if (!store) {
Expand All @@ -34,27 +32,14 @@ export function DeleteNote({ sessionId }: { sessionId: string }) {
void deleteSessionCascade(store, indexes, sessionId);

if (capturedData) {
setDeletedSession(capturedData);

const timeoutId = setTimeout(() => {
clear();
}, UNDO_TIMEOUT_MS);
setTimeoutId(timeoutId);
addDeletion(capturedData);
}

void analyticsCommands.event({
event: "session_deleted",
includes_recording: true,
});
}, [
store,
indexes,
sessionId,
invalidateResource,
setDeletedSession,
setTimeoutId,
clear,
]);
}, [store, indexes, sessionId, invalidateResource, addDeletion]);

return (
<DropdownMenuItem
Expand Down
Loading
Loading