From 1f79babc4d1665a0c23422911b6bb8fcc72bf056 Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Thu, 12 Feb 2026 16:06:50 +0900 Subject: [PATCH 01/17] feat(timeline): add multi-session deletion and selection support --- .../src/components/interactive-button.tsx | 9 +- .../main/sidebar/timeline/index.tsx | 105 ++++++++++++++++-- .../components/main/sidebar/timeline/item.tsx | 70 ++++++++---- .../src/store/zustand/timeline-selection.ts | 41 +++++++ 4 files changed, 195 insertions(+), 30 deletions(-) create mode 100644 apps/desktop/src/store/zustand/timeline-selection.ts diff --git a/apps/desktop/src/components/interactive-button.tsx b/apps/desktop/src/components/interactive-button.tsx index e944acbc2e..5986813e87 100644 --- a/apps/desktop/src/components/interactive-button.tsx +++ b/apps/desktop/src/components/interactive-button.tsx @@ -9,6 +9,7 @@ interface InteractiveButtonProps { children: ReactNode; onClick?: () => void; onCmdClick?: () => void; + onShiftClick?: () => void; onMouseDown?: (e: MouseEvent) => void; contextMenu?: MenuItemDef[]; className?: string; @@ -20,6 +21,7 @@ export function InteractiveButton({ children, onClick, onCmdClick, + onShiftClick, onMouseDown, contextMenu, className, @@ -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"; diff --git a/apps/desktop/src/components/main/sidebar/timeline/index.tsx b/apps/desktop/src/components/main/sidebar/timeline/index.tsx index 8ca72cc42a..06e621d473 100644 --- a/apps/desktop/src/components/main/sidebar/timeline/index.tsx +++ b/apps/desktop/src/components/main/sidebar/timeline/index.tsx @@ -6,8 +6,14 @@ import { cn, startOfDay } from "@hypr/utils"; import { useConfigValue } from "../../../../config/use-config"; import { useNativeContextMenu } from "../../../../hooks/useNativeContextMenu"; +import { + captureSessionData, + deleteSessionCascade, +} from "../../../../store/tinybase/store/deleteSession"; import * as main from "../../../../store/tinybase/store/main"; import { useTabs } from "../../../../store/zustand/tabs"; +import { useTimelineSelection } from "../../../../store/zustand/timeline-selection"; +import { useUndoDelete } from "../../../../store/zustand/undo-delete"; import { buildTimelineBuckets, calculateIndicatorIndex, @@ -63,6 +69,22 @@ export function TimelineView() { return session?.event_id ? String(session.event_id) : undefined; }, [selectedSessionId, store]); + const selectedIds = useTimelineSelection((s) => s.selectedIds); + const clearSelection = useTimelineSelection((s) => s.clear); + const indexes = main.UI.useIndexes(main.STORE_ID); + const invalidateResource = useTabs((state) => state.invalidateResource); + const addDeletion = useUndoDelete((state) => state.addDeletion); + + const flatItemKeys = useMemo(() => { + const keys: string[] = []; + for (const bucket of buckets) { + for (const item of bucket.items) { + keys.push(`${item.type}-${item.id}`); + } + } + return keys; + }, [buckets]); + const { containerRef, isAnchorVisible: isTodayVisible, @@ -106,15 +128,66 @@ export function TimelineView() { setShowIgnored((prev) => !prev); }, []); + 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); + if (capturedData) { + const performDelete = () => { + invalidateResource("sessions", sessionId); + void deleteSessionCascade(store, indexes, sessionId); + }; + addDeletion(capturedData, performDelete); + } + } + + clearSelection(); + }, [ + store, + indexes, + selectedIds, + invalidateResource, + addDeletion, + clearSelection, + ]); + + const sessionCount = useMemo( + () => selectedIds.filter((key) => key.startsWith("session-")).length, + [selectedIds], + ); + const contextMenuItems = useMemo( - () => [ - { - id: "toggle-ignored", - text: showIgnored ? "Hide Ignored Events" : "Show Ignored Events", - action: toggleShowIgnored, - }, + () => + selectedIds.length > 1 + ? [ + { + id: "delete-selected", + text: `Delete Selected (${sessionCount})`, + action: handleDeleteSelected, + disabled: sessionCount === 0, + }, + ] + : [ + { + id: "toggle-ignored", + text: showIgnored ? "Hide Ignored Events" : "Show Ignored Events", + action: toggleShowIgnored, + }, + ], + [ + selectedIds, + sessionCount, + handleDeleteSelected, + showIgnored, + toggleShowIgnored, ], - [showIgnored, toggleShowIgnored], ); const showContextMenu = useNativeContextMenu(contextMenuItems); @@ -157,20 +230,25 @@ export function TimelineView() { selectedSessionId={selectedSessionId} selectedEventId={selectedEventId} timezone={timezone} + selectedIds={selectedIds} + flatItemKeys={flatItemKeys} /> ) : ( bucket.items.map((item) => { + const itemKey = `${item.type}-${item.id}`; const selected = item.type === "session" ? item.id === selectedSessionId : item.id === selectedEventId; return ( ); }) @@ -217,6 +295,8 @@ function TodayBucket({ selectedSessionId, selectedEventId, timezone, + selectedIds, + flatItemKeys, }: { items: TimelineItem[]; precision: TimelinePrecision; @@ -224,6 +304,8 @@ function TodayBucket({ selectedSessionId: string | undefined; selectedEventId: string | undefined; timezone?: string; + selectedIds: string[]; + flatItemKeys: string[]; }) { const currentTimeMs = useCurrentTimeMs(); @@ -267,6 +349,7 @@ function TodayBucket({ ); } + const itemKey = `${entry.item.type}-${entry.item.id}`; const selected = entry.item.type === "session" ? entry.item.id === selectedSessionId @@ -274,11 +357,13 @@ function TodayBucket({ nodes.push( , ); }); @@ -301,6 +386,8 @@ function TodayBucket({ selectedSessionId, selectedEventId, timezone, + selectedIds, + flatItemKeys, ]); return renderedEntries; diff --git a/apps/desktop/src/components/main/sidebar/timeline/item.tsx b/apps/desktop/src/components/main/sidebar/timeline/item.tsx index b25a1e4f46..7bc32dd2fb 100644 --- a/apps/desktop/src/components/main/sidebar/timeline/item.tsx +++ b/apps/desktop/src/components/main/sidebar/timeline/item.tsx @@ -20,6 +20,7 @@ import * as main from "../../../../store/tinybase/store/main"; import { save } from "../../../../store/tinybase/store/save"; import { getOrCreateSessionForEventId } from "../../../../store/tinybase/store/sessions"; import { type TabInput, useTabs } from "../../../../store/zustand/tabs"; +import { useTimelineSelection } from "../../../../store/zustand/timeline-selection"; import { useUndoDelete } from "../../../../store/zustand/undo-delete"; import { type EventTimelineItem, @@ -36,11 +37,15 @@ export const TimelineItemComponent = memo( precision, selected, timezone, + multiSelected, + flatItemKeys, }: { item: TimelineItem; precision: TimelinePrecision; selected: boolean; timezone?: string; + multiSelected: boolean; + flatItemKeys: string[]; }) => { if (item.type === "event") { return ( @@ -49,6 +54,8 @@ export const TimelineItemComponent = memo( precision={precision} selected={selected} timezone={timezone} + multiSelected={multiSelected} + flatItemKeys={flatItemKeys} /> ); } @@ -58,6 +65,8 @@ export const TimelineItemComponent = memo( precision={precision} selected={selected} timezone={timezone} + multiSelected={multiSelected} + flatItemKeys={flatItemKeys} /> ); }, @@ -70,8 +79,10 @@ function ItemBase({ showSpinner, selected, ignored, + multiSelected, onClick, onCmdClick, + onShiftClick, contextMenu, }: { title: string; @@ -80,19 +91,23 @@ function ItemBase({ showSpinner?: boolean; selected: boolean; ignored?: boolean; + multiSelected: boolean; onClick: () => void; onCmdClick: () => void; + onShiftClick: () => void; contextMenu: Array<{ id: string; text: string; action: () => void }>; }) { return ( @@ -127,11 +142,15 @@ const EventItem = memo( precision, selected, timezone, + multiSelected, + flatItemKeys, }: { item: EventTimelineItem; precision: TimelinePrecision; selected: boolean; timezone?: string; + multiSelected: boolean; + flatItemKeys: string[]; }) => { const store = main.UI.useStore(main.STORE_ID); const indexes = main.UI.useIndexes(main.STORE_ID); @@ -162,9 +181,18 @@ const EventItem = memo( [eventId, store, title, openCurrent, openNew], ); - const handleClick = useCallback(() => openEvent(false), [openEvent]); + const itemKey = `event-${item.id}`; + + const handleClick = useCallback(() => { + useTimelineSelection.getState().setAnchor(itemKey); + openEvent(false); + }, [openEvent, itemKey]); const handleCmdClick = useCallback(() => openEvent(true), [openEvent]); + const handleShiftClick = useCallback(() => { + useTimelineSelection.getState().selectRange(flatItemKeys, itemKey); + }, [flatItemKeys, itemKey]); + const handleIgnore = useCallback(() => { if (!store) { return; @@ -273,8 +301,10 @@ const EventItem = memo( calendarId={calendarId} selected={selected} ignored={ignored} + multiSelected={multiSelected} onClick={handleClick} onCmdClick={handleCmdClick} + onShiftClick={handleShiftClick} contextMenu={contextMenu} /> ); @@ -287,18 +317,22 @@ const SessionItem = memo( precision, selected, timezone, + multiSelected, + flatItemKeys, }: { item: SessionTimelineItem; precision: TimelinePrecision; selected: boolean; timezone?: string; + multiSelected: boolean; + flatItemKeys: string[]; }) => { const store = main.UI.useStore(main.STORE_ID); const indexes = main.UI.useIndexes(main.STORE_ID); const openCurrent = useTabs((state) => state.openCurrent); const openNew = useTabs((state) => state.openNew); const invalidateResource = useTabs((state) => state.invalidateResource); - const { setDeletedSession, setTimeoutId } = useUndoDelete(); + const addDeletion = useUndoDelete((state) => state.addDeletion); const sessionId = item.id; const title = @@ -336,14 +370,21 @@ const SessionItem = memo( [eventStartedAt, item.data.created_at, precision, timezone], ); + const itemKey = `session-${item.id}`; + const handleClick = useCallback(() => { + useTimelineSelection.getState().setAnchor(itemKey); openCurrent({ id: sessionId, type: "sessions" }); - }, [sessionId, openCurrent]); + }, [sessionId, openCurrent, itemKey]); const handleCmdClick = useCallback(() => { openNew({ id: sessionId, type: "sessions" }); }, [sessionId, openNew]); + const handleShiftClick = useCallback(() => { + useTimelineSelection.getState().selectRange(flatItemKeys, itemKey); + }, [flatItemKeys, itemKey]); + const handleDelete = useCallback(() => { if (!store) { return; @@ -357,20 +398,9 @@ const SessionItem = memo( void deleteSessionCascade(store, indexes, sessionId); }; - setDeletedSession(capturedData, performDelete); - const timeoutId = setTimeout(() => { - useUndoDelete.getState().confirmDelete(); - }, 5000); - setTimeoutId(timeoutId); + addDeletion(capturedData, performDelete); } - }, [ - store, - indexes, - sessionId, - invalidateResource, - setDeletedSession, - setTimeoutId, - ]); + }, [store, indexes, sessionId, invalidateResource, addDeletion]); const handleRevealInFinder = useCallback(async () => { await save(); @@ -409,8 +439,10 @@ const SessionItem = memo( calendarId={calendarId} showSpinner={showSpinner} selected={selected} + multiSelected={multiSelected} onClick={handleClick} onCmdClick={handleCmdClick} + onShiftClick={handleShiftClick} contextMenu={contextMenu} /> diff --git a/apps/desktop/src/store/zustand/timeline-selection.ts b/apps/desktop/src/store/zustand/timeline-selection.ts new file mode 100644 index 0000000000..f564d4ff47 --- /dev/null +++ b/apps/desktop/src/store/zustand/timeline-selection.ts @@ -0,0 +1,41 @@ +import { create } from "zustand"; + +interface TimelineSelectionState { + selectedIds: string[]; + anchorId: string | null; + setAnchor: (id: string) => void; + selectRange: (flatItemKeys: string[], targetId: string) => void; + clear: () => void; + isSelected: (id: string) => boolean; +} + +export const useTimelineSelection = create( + (set, get) => ({ + selectedIds: [], + anchorId: null, + setAnchor: (id) => set({ anchorId: id, selectedIds: [] }), + selectRange: (flatItemKeys, targetId) => { + const { anchorId } = get(); + if (!anchorId) { + set({ anchorId: targetId, selectedIds: [targetId] }); + return; + } + + const anchorIndex = flatItemKeys.indexOf(anchorId); + const targetIndex = flatItemKeys.indexOf(targetId); + + if (anchorIndex === -1 || targetIndex === -1) { + set({ anchorId: targetId, selectedIds: [targetId] }); + return; + } + + const start = Math.min(anchorIndex, targetIndex); + const end = Math.max(anchorIndex, targetIndex); + const range = flatItemKeys.slice(start, end + 1); + + set({ selectedIds: range }); + }, + clear: () => set({ selectedIds: [], anchorId: null }), + isSelected: (id) => get().selectedIds.includes(id), + }), +); From d23f20bc14e66aeb9e665264288d79b1d260d92f Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Thu, 12 Feb 2026 16:18:08 +0900 Subject: [PATCH 02/17] feat(sessions): support batching session mode with new states --- .../components/main/body/sessions/index.tsx | 17 ++++++++++++++++- .../components/main/sidebar/timeline/item.tsx | 18 +++++++++--------- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/components/main/body/sessions/index.tsx b/apps/desktop/src/components/main/body/sessions/index.tsx index 9afbea0a44..943f2ddb4c 100644 --- a/apps/desktop/src/components/main/body/sessions/index.tsx +++ b/apps/desktop/src/components/main/body/sessions/index.tsx @@ -54,7 +54,9 @@ export const TabItemNote: TabItem> = ({ 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" && @@ -101,11 +103,24 @@ export function TabContentNote({ tab: Extract; }) { 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; diff --git a/apps/desktop/src/components/main/sidebar/timeline/item.tsx b/apps/desktop/src/components/main/sidebar/timeline/item.tsx index 7bc32dd2fb..f2e7c12050 100644 --- a/apps/desktop/src/components/main/sidebar/timeline/item.tsx +++ b/apps/desktop/src/components/main/sidebar/timeline/item.tsx @@ -343,7 +343,9 @@ const SessionItem = memo( const sessionMode = useListener((state) => state.getSessionMode(sessionId)); const isEnhancing = useIsSessionEnhancing(sessionId); const isFinalizing = sessionMode === "finalizing"; - const showSpinner = !selected && (isFinalizing || isEnhancing); + const isBatching = sessionMode === "running_batch"; + const showSpinner = + !selected && (isFinalizing || isEnhancing || isBatching); const calendarId = main.UI.useCell( @@ -378,8 +380,8 @@ const SessionItem = memo( }, [sessionId, openCurrent, itemKey]); const handleCmdClick = useCallback(() => { - openNew({ id: sessionId, type: "sessions" }); - }, [sessionId, openNew]); + useTimelineSelection.getState().toggleSelect(itemKey); + }, [itemKey]); const handleShiftClick = useCallback(() => { useTimelineSelection.getState().selectRange(flatItemKeys, itemKey); @@ -392,13 +394,11 @@ const SessionItem = memo( const capturedData = captureSessionData(store, indexes, sessionId); - if (capturedData) { - const performDelete = () => { - invalidateResource("sessions", sessionId); - void deleteSessionCascade(store, indexes, sessionId); - }; + invalidateResource("sessions", sessionId); + void deleteSessionCascade(store, indexes, sessionId); - addDeletion(capturedData, performDelete); + if (capturedData) { + addDeletion(capturedData); } }, [store, indexes, sessionId, invalidateResource, addDeletion]); From 371b4c3799e39a1d37c78a3012ceef0eef049d1a Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Thu, 12 Feb 2026 16:19:55 +0900 Subject: [PATCH 03/17] refactor(timeline): simplify selection and deletion logic --- .../sessions/outer-header/overflow/delete.tsx | 21 +++---------------- .../main/sidebar/timeline/index.tsx | 10 ++++----- .../components/main/sidebar/timeline/item.tsx | 13 +++++++++--- .../src/store/zustand/timeline-selection.ts | 16 ++++++++++++++ 4 files changed, 34 insertions(+), 26 deletions(-) diff --git a/apps/desktop/src/components/main/body/sessions/outer-header/overflow/delete.tsx b/apps/desktop/src/components/main/body/sessions/outer-header/overflow/delete.tsx index 23f5016e3d..a05a42d0f9 100644 --- a/apps/desktop/src/components/main/body/sessions/outer-header/overflow/delete.tsx +++ b/apps/desktop/src/components/main/body/sessions/outer-header/overflow/delete.tsx @@ -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) { @@ -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 ( { - invalidateResource("sessions", sessionId); - void deleteSessionCascade(store, indexes, sessionId); - }; - addDeletion(capturedData, performDelete); + addDeletion(capturedData); } } diff --git a/apps/desktop/src/components/main/sidebar/timeline/item.tsx b/apps/desktop/src/components/main/sidebar/timeline/item.tsx index f2e7c12050..72b75e8515 100644 --- a/apps/desktop/src/components/main/sidebar/timeline/item.tsx +++ b/apps/desktop/src/components/main/sidebar/timeline/item.tsx @@ -187,7 +187,10 @@ const EventItem = memo( useTimelineSelection.getState().setAnchor(itemKey); openEvent(false); }, [openEvent, itemKey]); - const handleCmdClick = useCallback(() => openEvent(true), [openEvent]); + + const handleCmdClick = useCallback(() => { + useTimelineSelection.getState().toggleSelect(itemKey); + }, [itemKey]); const handleShiftClick = useCallback(() => { useTimelineSelection.getState().selectRange(flatItemKeys, itemKey); @@ -387,6 +390,10 @@ const SessionItem = memo( useTimelineSelection.getState().selectRange(flatItemKeys, itemKey); }, [flatItemKeys, itemKey]); + const handleOpenNewTab = useCallback(() => { + openNew({ id: sessionId, type: "sessions" }); + }, [sessionId, openNew]); + const handleDelete = useCallback(() => { if (!store) { return; @@ -415,7 +422,7 @@ const SessionItem = memo( { id: "open-new-tab", text: "Open in New Tab", - action: handleCmdClick, + action: handleOpenNewTab, }, { id: "reveal", @@ -428,7 +435,7 @@ const SessionItem = memo( action: handleDelete, }, ], - [handleCmdClick, handleRevealInFinder, handleDelete, hasEvent], + [handleOpenNewTab, handleRevealInFinder, handleDelete, hasEvent], ); return ( diff --git a/apps/desktop/src/store/zustand/timeline-selection.ts b/apps/desktop/src/store/zustand/timeline-selection.ts index f564d4ff47..de006e66b3 100644 --- a/apps/desktop/src/store/zustand/timeline-selection.ts +++ b/apps/desktop/src/store/zustand/timeline-selection.ts @@ -4,6 +4,7 @@ interface TimelineSelectionState { selectedIds: string[]; anchorId: string | null; setAnchor: (id: string) => void; + toggleSelect: (id: string) => void; selectRange: (flatItemKeys: string[], targetId: string) => void; clear: () => void; isSelected: (id: string) => boolean; @@ -14,6 +15,21 @@ export const useTimelineSelection = create( selectedIds: [], anchorId: null, setAnchor: (id) => set({ anchorId: id, selectedIds: [] }), + toggleSelect: (id) => { + const { selectedIds, anchorId } = get(); + if (selectedIds.includes(id)) { + const filtered = selectedIds.filter((s) => s !== id); + set({ + selectedIds: filtered, + anchorId: filtered.length > 0 ? anchorId : null, + }); + } else { + set({ + selectedIds: [...selectedIds, id], + anchorId: anchorId ?? id, + }); + } + }, selectRange: (flatItemKeys, targetId) => { const { anchorId } = get(); if (!anchorId) { From 9aff6dc8dc6bd957633dd976606687e156b1d463 Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Thu, 12 Feb 2026 16:28:06 +0900 Subject: [PATCH 04/17] refactor(store): support multiple pending deletions --- .../main/sidebar/toast/undo-delete-toast.tsx | 52 ++++-- .../components/ui/dissolving-container.tsx | 29 ++-- apps/desktop/src/store/zustand/undo-delete.ts | 162 +++++++++++------- 3 files changed, 150 insertions(+), 93 deletions(-) diff --git a/apps/desktop/src/components/main/sidebar/toast/undo-delete-toast.tsx b/apps/desktop/src/components/main/sidebar/toast/undo-delete-toast.tsx index 0d7d9a3ec6..7cc45e6a83 100644 --- a/apps/desktop/src/components/main/sidebar/toast/undo-delete-toast.tsx +++ b/apps/desktop/src/components/main/sidebar/toast/undo-delete-toast.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { restoreSessionData } from "../../../../store/tinybase/store/deleteSession"; @@ -11,21 +11,36 @@ import { export function useUndoDeleteHandler() { const store = main.UI.useStore(main.STORE_ID); - const { deletedSession, clear } = useUndoDelete(); + const pendingDeletions = useUndoDelete((state) => state.pendingDeletions); + const clearDeletion = useUndoDelete((state) => state.clearDeletion); const openCurrent = useTabs((state) => state.openCurrent); + const latestSessionId = useMemo(() => { + let latest: string | null = null; + let latestTime = 0; + for (const [sessionId, pending] of Object.entries(pendingDeletions)) { + if (pending.addedAt > latestTime) { + latestTime = pending.addedAt; + latest = sessionId; + } + } + return latest; + }, [pendingDeletions]); + const handleUndo = useCallback(() => { - if (!store || !deletedSession) return; + if (!store || !latestSessionId) return; + const pending = pendingDeletions[latestSessionId]; + if (!pending) return; - restoreSessionData(store, deletedSession); - openCurrent({ type: "sessions", id: deletedSession.session.id }); - clear(); - }, [store, deletedSession, openCurrent, clear]); + restoreSessionData(store, pending.data); + openCurrent({ type: "sessions", id: latestSessionId }); + clearDeletion(latestSessionId); + }, [store, latestSessionId, pendingDeletions, openCurrent, clearDeletion]); useHotkeys( "mod+z", () => { - if (deletedSession) { + if (latestSessionId) { handleUndo(); } }, @@ -34,10 +49,10 @@ export function useUndoDeleteHandler() { enableOnFormTags: true, enableOnContentEditable: true, }, - [deletedSession, handleUndo], + [latestSessionId, handleUndo], ); - return { handleUndo, deletedSession }; + return { handleUndo, hasPendingDeletion: latestSessionId !== null }; } export function UndoDeleteKeyboardHandler() { @@ -46,24 +61,25 @@ export function UndoDeleteKeyboardHandler() { } export function useDissolvingProgress(sessionId: string | null) { - const { deletedSession, isPaused, remainingTime } = useUndoDelete(); + const pending = useUndoDelete((state) => + sessionId ? state.pendingDeletions[sessionId] : undefined, + ); const [progress, setProgress] = useState(100); - const isDissolving = - deletedSession !== null && deletedSession.session.id === sessionId; + const isDissolving = pending !== undefined; useEffect(() => { - if (!isDissolving || !deletedSession) { + if (!isDissolving || !pending) { setProgress(100); return; } - if (isPaused) { - setProgress((remainingTime / UNDO_TIMEOUT_MS) * 100); + if (pending.isPaused) { + setProgress((pending.remainingTime / UNDO_TIMEOUT_MS) * 100); return; } - const startTime = deletedSession.deletedAt; + const startTime = pending.data.deletedAt; const endTime = startTime + UNDO_TIMEOUT_MS; const updateProgress = () => { @@ -83,7 +99,7 @@ export function useDissolvingProgress(sessionId: string | null) { cancelAnimationFrame(animationIdRef.current); } }; - }, [isDissolving, deletedSession, isPaused, remainingTime]); + }, [isDissolving, pending]); return { isDissolving, progress }; } diff --git a/apps/desktop/src/components/ui/dissolving-container.tsx b/apps/desktop/src/components/ui/dissolving-container.tsx index 570dd40259..8dc49cd173 100644 --- a/apps/desktop/src/components/ui/dissolving-container.tsx +++ b/apps/desktop/src/components/ui/dissolving-container.tsx @@ -26,24 +26,27 @@ export function DissolvingContainer({ variant = "sidebar", }: DissolvingContainerProps) { const store = main.UI.useStore(main.STORE_ID); - const { deletedSession, clear, pause, resume, isPaused } = useUndoDelete(); + const pending = useUndoDelete((state) => state.pendingDeletions[sessionId]); + const pauseDeletion = useUndoDelete((state) => state.pause); + const resumeDeletion = useUndoDelete((state) => state.resume); + const clearDeletion = useUndoDelete((state) => state.clearDeletion); const openCurrent = useTabs((state) => state.openCurrent); const { isDissolving, progress } = useDissolvingProgress(sessionId); const handleMouseEnter = useCallback(() => { - pause(); - }, [pause]); + pauseDeletion(sessionId); + }, [pauseDeletion, sessionId]); const handleMouseLeave = useCallback(() => { - resume(); - }, [resume]); + resumeDeletion(sessionId); + }, [resumeDeletion, sessionId]); const handleRestore = useCallback(() => { - if (!store || !deletedSession) return; - restoreSessionData(store, deletedSession); - openCurrent({ type: "sessions", id: deletedSession.session.id }); - clear(); - }, [store, deletedSession, openCurrent, clear]); + if (!store || !pending) return; + restoreSessionData(store, pending.data); + openCurrent({ type: "sessions", id: sessionId }); + clearDeletion(sessionId); + }, [store, pending, sessionId, openCurrent, clearDeletion]); if (!isDissolving) { return <>{children}; @@ -80,7 +83,7 @@ export function DissolvingContainer({ "pointer-events-none", ])} > - {isPaused ? ( + {pending?.isPaused ? ( - {!isPaused && ( + {!pending?.isPaused && ( {remainingSeconds} )} - {isPaused && ( + {pending?.isPaused && ( | null; isPaused: boolean; remainingTime: number; onDeleteConfirm: (() => void) | null; - setDeletedSession: ( - data: DeletedSessionData | null, - onConfirm?: () => void, - ) => void; - setTimeoutId: (id: ReturnType | null) => void; - pause: () => void; - resume: () => void; - clear: () => void; - confirmDelete: () => void; + addedAt: number; +}; + +interface UndoDeleteState { + pendingDeletions: Record; + addDeletion: (data: DeletedSessionData, onConfirm?: () => void) => void; + pause: (sessionId: string) => void; + resume: (sessionId: string) => void; + clearDeletion: (sessionId: string) => void; + confirmDeletion: (sessionId: string) => void; } export const useUndoDelete = create((set, get) => ({ - deletedSession: null, - timeoutId: null, - isPaused: false, - remainingTime: UNDO_TIMEOUT_MS, - onDeleteConfirm: null, - setDeletedSession: (data, onConfirm) => - set({ - deletedSession: data, - remainingTime: UNDO_TIMEOUT_MS, - onDeleteConfirm: onConfirm ?? null, - }), - setTimeoutId: (id) => { - const currentId = get().timeoutId; - if (currentId) { - clearTimeout(currentId); - } - set({ timeoutId: id }); + pendingDeletions: {}, + + addDeletion: (data, onConfirm) => { + const sessionId = data.session.id; + + const timeoutId = setTimeout(() => { + get().confirmDeletion(sessionId); + }, UNDO_TIMEOUT_MS); + + set((state) => ({ + pendingDeletions: { + ...state.pendingDeletions, + [sessionId]: { + data, + timeoutId, + isPaused: false, + remainingTime: UNDO_TIMEOUT_MS, + onDeleteConfirm: onConfirm ?? null, + addedAt: Date.now(), + }, + }, + })); }, - pause: () => { - const { timeoutId, deletedSession, isPaused } = get(); - if (isPaused || !deletedSession) return; - if (timeoutId) { - clearTimeout(timeoutId); + pause: (sessionId) => { + const pending = get().pendingDeletions[sessionId]; + if (!pending || pending.isPaused) return; + + if (pending.timeoutId) { + clearTimeout(pending.timeoutId); } - const elapsed = Date.now() - deletedSession.deletedAt; + const elapsed = Date.now() - pending.data.deletedAt; const remaining = Math.max(0, UNDO_TIMEOUT_MS - elapsed); - set({ isPaused: true, remainingTime: remaining, timeoutId: null }); - }, - resume: () => { - const { isPaused, remainingTime, deletedSession, confirmDelete } = get(); - if (!isPaused || !deletedSession) return; - - const newDeletedAt = Date.now() - (UNDO_TIMEOUT_MS - remainingTime); - set({ - isPaused: false, - deletedSession: { ...deletedSession, deletedAt: newDeletedAt }, + + set((state) => { + const current = state.pendingDeletions[sessionId]; + if (!current) return state; + return { + pendingDeletions: { + ...state.pendingDeletions, + [sessionId]: { + ...current, + isPaused: true, + remainingTime: remaining, + timeoutId: null, + }, + }, + }; }); + }, + + resume: (sessionId) => { + const pending = get().pendingDeletions[sessionId]; + if (!pending || !pending.isPaused) return; + + const newDeletedAt = Date.now() - (UNDO_TIMEOUT_MS - pending.remainingTime); const timeoutId = setTimeout(() => { - confirmDelete(); - }, remainingTime); - set({ timeoutId }); + get().confirmDeletion(sessionId); + }, pending.remainingTime); + + set((state) => { + const current = state.pendingDeletions[sessionId]; + if (!current) return state; + return { + pendingDeletions: { + ...state.pendingDeletions, + [sessionId]: { + ...current, + isPaused: false, + data: { ...current.data, deletedAt: newDeletedAt }, + timeoutId, + }, + }, + }; + }); }, - clear: () => { - const currentId = get().timeoutId; - if (currentId) { - clearTimeout(currentId); + + clearDeletion: (sessionId) => { + const pending = get().pendingDeletions[sessionId]; + if (!pending) return; + + if (pending.timeoutId) { + clearTimeout(pending.timeoutId); } - set({ - deletedSession: null, - timeoutId: null, - isPaused: false, - remainingTime: UNDO_TIMEOUT_MS, - onDeleteConfirm: null, + + set((state) => { + const { [sessionId]: _, ...rest } = state.pendingDeletions; + return { pendingDeletions: rest }; }); }, - confirmDelete: () => { - const { onDeleteConfirm, clear } = get(); - if (onDeleteConfirm) { - onDeleteConfirm(); + + confirmDeletion: (sessionId) => { + const pending = get().pendingDeletions[sessionId]; + if (!pending) return; + + if (pending.onDeleteConfirm) { + pending.onDeleteConfirm(); } - clear(); + get().clearDeletion(sessionId); }, })); From e39ad256627add1c0b1d42d5053d1f5c56365578 Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Thu, 12 Feb 2026 16:38:11 +0900 Subject: [PATCH 05/17] fix(timeline): improve selection context menu and styling Adjust context menu visibility when items are selected and update selected item background color for better visual consistency. --- apps/desktop/src/components/main/sidebar/timeline/index.tsx | 2 +- apps/desktop/src/components/main/sidebar/timeline/item.tsx | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/components/main/sidebar/timeline/index.tsx b/apps/desktop/src/components/main/sidebar/timeline/index.tsx index 71c391b8c7..6adc84be3f 100644 --- a/apps/desktop/src/components/main/sidebar/timeline/index.tsx +++ b/apps/desktop/src/components/main/sidebar/timeline/index.tsx @@ -165,7 +165,7 @@ export function TimelineView() { const contextMenuItems = useMemo( () => - selectedIds.length > 1 + selectedIds.length > 0 ? [ { id: "delete-selected", diff --git a/apps/desktop/src/components/main/sidebar/timeline/item.tsx b/apps/desktop/src/components/main/sidebar/timeline/item.tsx index 72b75e8515..d04278665e 100644 --- a/apps/desktop/src/components/main/sidebar/timeline/item.tsx +++ b/apps/desktop/src/components/main/sidebar/timeline/item.tsx @@ -97,15 +97,17 @@ function ItemBase({ onShiftClick: () => void; contextMenu: Array<{ id: string; text: string; action: () => void }>; }) { + const hasSelection = useTimelineSelection((s) => s.selectedIds.length > 0); + return ( Date: Thu, 12 Feb 2026 13:57:37 +0900 Subject: [PATCH 06/17] feat(desktop): update button focus style for better accessibility From a49f5fba14171e23019d81b517dd7985fe4e0e05 Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Thu, 12 Feb 2026 13:55:57 +0900 Subject: [PATCH 07/17] feat(import): improve audio import experience and error handling --- .../components/main/body/sessions/floating/options-menu.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/components/main/body/sessions/floating/options-menu.tsx b/apps/desktop/src/components/main/body/sessions/floating/options-menu.tsx index b8fb73d9dd..f00b479480 100644 --- a/apps/desktop/src/components/main/body/sessions/floating/options-menu.tsx +++ b/apps/desktop/src/components/main/body/sessions/floating/options-menu.tsx @@ -291,9 +291,9 @@ export function OptionsMenu({ action={ onConfigure ? { - label: "Configure", - handleClick: onConfigure, - } + label: "Configure", + handleClick: onConfigure, + } : undefined } /> From cb8c29b92d1898382d00d186efd0f06755a73277 Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Thu, 12 Feb 2026 14:08:28 +0900 Subject: [PATCH 08/17] feat(transcript): add batching state to empty state component From 5f096a469c7f3003ccbc2aa06fe427279d9c0601 Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Thu, 12 Feb 2026 14:08:01 +0900 Subject: [PATCH 09/17] fix(audio): improve duration tracking with wavesurfer events From 76b9ae8add525d1727604e6e6c983d4f296f74f0 Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Thu, 12 Feb 2026 14:11:25 +0900 Subject: [PATCH 10/17] fix(desktop): handle unknown error type in batch session From 1edb2fbb7b2fe3830fc5c32692e3dcd26c479bb9 Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Thu, 12 Feb 2026 14:16:06 +0900 Subject: [PATCH 11/17] feat(sessions): add AI-powered note enhancement flow --- .../main/body/sessions/floating/options-menu.tsx | 14 +++++++++++++- apps/desktop/src/hooks/useAutoEnhance.ts | 1 + 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/components/main/body/sessions/floating/options-menu.tsx b/apps/desktop/src/components/main/body/sessions/floating/options-menu.tsx index f00b479480..03a4c55fe4 100644 --- a/apps/desktop/src/components/main/body/sessions/floating/options-menu.tsx +++ b/apps/desktop/src/components/main/body/sessions/floating/options-menu.tsx @@ -20,10 +20,17 @@ import { TooltipTrigger, } from "@hypr/ui/components/ui/tooltip"; +import { useAITask } from "../../../../../contexts/ai-task"; import { useListener } from "../../../../../contexts/listener"; import { fromResult } from "../../../../../effect"; +import { useCreateEnhancedNote } from "../../../../../hooks/useEnhancedNotes"; +import { + useLanguageModel, + useLLMConnection, +} 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"; @@ -50,9 +57,14 @@ 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 { conn: llmConn } = useLLMConnection(); + const generate = useAITask((state) => state.generate); const sessionTab = useTabs((state) => { const found = state.tabs.find( (tab): tab is Extract => diff --git a/apps/desktop/src/hooks/useAutoEnhance.ts b/apps/desktop/src/hooks/useAutoEnhance.ts index a53d78c794..43fd95a99a 100644 --- a/apps/desktop/src/hooks/useAutoEnhance.ts +++ b/apps/desktop/src/hooks/useAutoEnhance.ts @@ -42,6 +42,7 @@ export function useAutoEnhance(tab: Extract) { isInitialRenderRef.current = false; prevSessionModeRef.current = sessionMode; prevTranscriptCountRef.current = transcriptIds?.length ?? 0; + return; } From cc1267f8707a19341a628e39b2d9392c42b525ce Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Thu, 12 Feb 2026 14:19:42 +0900 Subject: [PATCH 12/17] feat(enhancement): add auto-trigger for session enhancement --- .../body/sessions/floating/options-menu.tsx | 85 +++++++++++++++++-- apps/desktop/src/hooks/autoEnhance/runner.ts | 16 +++- 2 files changed, 95 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/components/main/body/sessions/floating/options-menu.tsx b/apps/desktop/src/components/main/body/sessions/floating/options-menu.tsx index 03a4c55fe4..2dbe42bc85 100644 --- a/apps/desktop/src/components/main/body/sessions/floating/options-menu.tsx +++ b/apps/desktop/src/components/main/body/sessions/floating/options-menu.tsx @@ -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, @@ -23,11 +24,9 @@ import { 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, - useLLMConnection, -} from "../../../../../hooks/useLLMConnection"; +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"; @@ -63,7 +62,6 @@ export function OptionsMenu({ const updateSessionTabState = useTabs((state) => state.updateSessionTabState); const createEnhancedNote = useCreateEnhancedNote(); const model = useLanguageModel(); - const { conn: llmConn } = useLLMConnection(); const generate = useAITask((state) => state.generate); const sessionTab = useTabs((state) => { const found = state.tabs.find( @@ -73,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, { + 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 !== "") { + 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) { @@ -138,6 +209,8 @@ export function OptionsMenu({ file_type: "transcript", token_count: subtitle.tokens.length, }); + + triggerEnhance(); }), ), ); @@ -183,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); @@ -200,6 +274,7 @@ export function OptionsMenu({ sessionId, sessionTab, store, + triggerEnhance, updateSessionTabState, user_id, ], diff --git a/apps/desktop/src/hooks/autoEnhance/runner.ts b/apps/desktop/src/hooks/autoEnhance/runner.ts index f686c8804d..8b7c4715ea 100644 --- a/apps/desktop/src/hooks/autoEnhance/runner.ts +++ b/apps/desktop/src/hooks/autoEnhance/runner.ts @@ -53,9 +53,14 @@ export function useAutoEnhanceRunner( const titleTaskId = createTaskId(sessionId, "title"); - const { generate, tasks } = useAITask((state) => ({ + const { + generate, + tasks, + getState: getAITaskState, + } = useAITask((state) => ({ generate: state.generate, tasks: state.tasks, + getState: state.getState, })); const handleTitleSuccess = useCallback( @@ -162,6 +167,14 @@ export function useAutoEnhanceRunner( } const enhanceTaskId = createTaskId(enhancedNoteId, "enhance"); + const existingTask = getAITaskState(enhanceTaskId); + if ( + existingTask?.status === "generating" || + existingTask?.status === "success" + ) { + return { type: "started", noteId: enhancedNoteId }; + } + void generate(enhanceTaskId, { model, taskType: "enhance", @@ -213,6 +226,7 @@ export function useAutoEnhanceRunner( updateSessionTabState, llmConn, generate, + getAITaskState, ]); const currentEnhanceTaskId = currentNoteIdRef.current From 2e242bc6c40f03a7a9599c783ddadf52613c5b39 Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Thu, 12 Feb 2026 15:21:14 +0900 Subject: [PATCH 13/17] fmt --- .../components/main/body/sessions/floating/options-menu.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/components/main/body/sessions/floating/options-menu.tsx b/apps/desktop/src/components/main/body/sessions/floating/options-menu.tsx index 2dbe42bc85..d3fac4d269 100644 --- a/apps/desktop/src/components/main/body/sessions/floating/options-menu.tsx +++ b/apps/desktop/src/components/main/body/sessions/floating/options-menu.tsx @@ -378,9 +378,9 @@ export function OptionsMenu({ action={ onConfigure ? { - label: "Configure", - handleClick: onConfigure, - } + label: "Configure", + handleClick: onConfigure, + } : undefined } /> From 89c85c40cd321c5b0124138a8cb76120dda1a527 Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Thu, 12 Feb 2026 15:37:30 +0900 Subject: [PATCH 14/17] refactor(batch): improve batch state and persistence handling --- .../body/sessions/floating/options-menu.tsx | 1 + .../note-input/transcript/progress.tsx | 5 +- apps/desktop/src/hooks/useRunBatch.ts | 5 +- .../src/store/zustand/listener/batch.ts | 79 +++++++++++++++---- .../src/store/zustand/listener/general.ts | 8 +- 5 files changed, 74 insertions(+), 24 deletions(-) diff --git a/apps/desktop/src/components/main/body/sessions/floating/options-menu.tsx b/apps/desktop/src/components/main/body/sessions/floating/options-menu.tsx index d3fac4d269..55dbdb2513 100644 --- a/apps/desktop/src/components/main/body/sessions/floating/options-menu.tsx +++ b/apps/desktop/src/components/main/body/sessions/floating/options-menu.tsx @@ -63,6 +63,7 @@ export function OptionsMenu({ const createEnhancedNote = useCreateEnhancedNote(); const model = useLanguageModel(); const generate = useAITask((state) => state.generate); + const getAITaskState = useAITask((state) => state.getState); const sessionTab = useTabs((state) => { const found = state.tabs.find( (tab): tab is Extract => diff --git a/apps/desktop/src/components/main/body/sessions/note-input/transcript/progress.tsx b/apps/desktop/src/components/main/body/sessions/note-input/transcript/progress.tsx index 11f0b843ab..97e3c1e4a5 100644 --- a/apps/desktop/src/components/main/body/sessions/note-input/transcript/progress.tsx +++ b/apps/desktop/src/components/main/body/sessions/note-input/transcript/progress.tsx @@ -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); diff --git a/apps/desktop/src/hooks/useRunBatch.ts b/apps/desktop/src/hooks/useRunBatch.ts index 5e6dd0277d..159b2a7bd4 100644 --- a/apps/desktop/src/hooks/useRunBatch.ts +++ b/apps/desktop/src/hooks/useRunBatch.ts @@ -67,8 +67,9 @@ export const useRunBatch = (sessionId: string) => { return useCallback( async (filePath: string, options?: RunOptions) => { if (!store || !conn || !runBatch) { - console.error("no_batch_connection"); - return; + throw new Error( + "STT connection is not available. Please configure your speech-to-text provider.", + ); } const provider = getBatchProvider(conn.provider, conn.model); diff --git a/apps/desktop/src/store/zustand/listener/batch.ts b/apps/desktop/src/store/zustand/listener/batch.ts index 06b33cdacc..2f7ef0f9c5 100644 --- a/apps/desktop/src/store/zustand/listener/batch.ts +++ b/apps/desktop/src/store/zustand/listener/batch.ts @@ -10,6 +10,8 @@ import { import type { HandlePersistCallback } from "./transcript"; import { transformWordEntries } from "./utils"; +export type BatchPhase = "importing" | "transcribing"; + export type BatchState = { batch: Record< string, @@ -17,12 +19,14 @@ export type BatchState = { percentage: number; isComplete?: boolean; error?: string; + phase?: BatchPhase; } >; + batchPersist: Record; }; export type BatchActions = { - handleBatchStarted: (sessionId: string) => void; + handleBatchStarted: (sessionId: string, phase?: BatchPhase) => void; handleBatchResponse: (sessionId: string, response: BatchResponse) => void; handleBatchResponseStreamed: ( sessionId: string, @@ -31,38 +35,40 @@ export type BatchActions = { ) => void; handleBatchFailed: (sessionId: string, error: string) => void; clearBatchSession: (sessionId: string) => void; + setBatchPersist: (sessionId: string, callback: HandlePersistCallback) => void; + clearBatchPersist: (sessionId: string) => void; }; -export const createBatchSlice = < - T extends BatchState & { - handlePersist?: HandlePersistCallback; - handleTranscriptResponse?: (response: StreamResponse) => void; - }, ->( +export const createBatchSlice = ( set: StoreApi["setState"], get: StoreApi["getState"], ): BatchState & BatchActions => ({ batch: {}, + batchPersist: {}, - handleBatchStarted: (sessionId) => { + handleBatchStarted: (sessionId, phase) => { set((state) => ({ ...state, batch: { ...state.batch, - [sessionId]: { percentage: 0, isComplete: false }, + [sessionId]: { + percentage: 0, + isComplete: false, + phase: phase ?? "transcribing", + }, }, })); }, handleBatchResponse: (sessionId, response) => { - const { handlePersist } = get(); + const persist = get().batchPersist[sessionId]; const [words, hints] = transformBatch(response); if (!words.length) { return; } - handlePersist?.(words, hints); + persist?.(words, hints); set((state) => { if (!state.batch[sessionId]) { @@ -78,9 +84,24 @@ export const createBatchSlice = < }, handleBatchResponseStreamed: (sessionId, response, percentage) => { - const { handleTranscriptResponse } = get(); - - handleTranscriptResponse?.(response); + const persist = get().batchPersist[sessionId]; + + if (persist && response.type === "Results") { + const channelIndex = response.channel_index[0]; + const alternative = response.channel.alternatives[0]; + + if (channelIndex !== undefined && alternative) { + const [words, hints] = transformWordEntries( + alternative.words, + alternative.transcript, + channelIndex, + ); + + if (words.length > 0) { + persist(words, hints); + } + } + } const isComplete = response.type === "Results" && response.from_finalize; @@ -88,7 +109,11 @@ export const createBatchSlice = < ...state, batch: { ...state.batch, - [sessionId]: { percentage, isComplete: isComplete || false }, + [sessionId]: { + percentage, + isComplete: isComplete || false, + phase: "transcribing", + }, }, })); }, @@ -120,6 +145,30 @@ export const createBatchSlice = < }; }); }, + + setBatchPersist: (sessionId, callback) => { + set((state) => ({ + ...state, + batchPersist: { + ...state.batchPersist, + [sessionId]: callback, + }, + })); + }, + + clearBatchPersist: (sessionId) => { + set((state) => { + if (!(sessionId in state.batchPersist)) { + return state; + } + + const { [sessionId]: _, ...rest } = state.batchPersist; + return { + ...state, + batchPersist: rest, + }; + }); + }, }); function transformBatch( diff --git a/apps/desktop/src/store/zustand/listener/general.ts b/apps/desktop/src/store/zustand/listener/general.ts index acec8d0bc9..ab2c36deef 100644 --- a/apps/desktop/src/store/zustand/listener/general.ts +++ b/apps/desktop/src/store/zustand/listener/general.ts @@ -473,10 +473,8 @@ export const createGeneralSlice = < return; } - const shouldResetPersist = Boolean(options?.handlePersist); - if (options?.handlePersist) { - get().setTranscriptPersist(options.handlePersist); + get().setBatchPersist(sessionId, options.handlePersist); } get().handleBatchStarted(sessionId); @@ -489,9 +487,7 @@ export const createGeneralSlice = < unlisten = undefined; } - if (shouldResetPersist) { - get().setTranscriptPersist(undefined); - } + get().clearBatchPersist(sessionId); if (clearSession) { get().clearBatchSession(sessionId); From cab718f997c4e31c9759771100858cce1ab7c5c4 Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Thu, 12 Feb 2026 16:07:33 +0900 Subject: [PATCH 15/17] fix(useAutoEnhance): prevent auto-enhance during batch loading --- apps/desktop/src/hooks/useAutoEnhance.ts | 6 ++++-- apps/desktop/src/routes/app/main/_layout.tsx | 6 +++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/hooks/useAutoEnhance.ts b/apps/desktop/src/hooks/useAutoEnhance.ts index 43fd95a99a..3e961d6b86 100644 --- a/apps/desktop/src/hooks/useAutoEnhance.ts +++ b/apps/desktop/src/hooks/useAutoEnhance.ts @@ -22,6 +22,7 @@ export function useAutoEnhance(tab: Extract) { const [skipReason, setSkipReason] = useState(null); const sessionMode = useListener((state) => state.getSessionMode(sessionId)); + const loading = useListener((state) => state.live.loading); const prevSessionModeRef = useRef(sessionMode); const prevTranscriptCountRef = useRef(transcriptIds?.length ?? 0); const isInitialRenderRef = useRef(true); @@ -59,7 +60,8 @@ export function useAutoEnhance(tab: Extract) { prevCount === 0 && currentCount > 0 && prevMode === "inactive" && - sessionMode === "inactive"; + sessionMode === "inactive" && + !loading; if (batchJustCompleted || transcriptJustUploaded) { const result = runner.run(); @@ -68,7 +70,7 @@ export function useAutoEnhance(tab: Extract) { } } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [sessionMode, transcriptIds?.length]); + }, [sessionMode, transcriptIds?.length, loading]); useEffect(() => { if (skipReason) { diff --git a/apps/desktop/src/routes/app/main/_layout.tsx b/apps/desktop/src/routes/app/main/_layout.tsx index d3f7bb40f0..59d9410406 100644 --- a/apps/desktop/src/routes/app/main/_layout.tsx +++ b/apps/desktop/src/routes/app/main/_layout.tsx @@ -18,6 +18,7 @@ import { useDeeplinkHandler } from "../../../hooks/useDeeplinkHandler"; import { deleteSessionCascade } from "../../../store/tinybase/store/deleteSession"; import * as main from "../../../store/tinybase/store/main"; import { isSessionEmpty } from "../../../store/tinybase/store/sessions"; +import { listenerStore } from "../../../store/zustand/listener/instance"; import { restorePinnedTabsToStore, restoreRecentlyOpenedToStore, @@ -84,7 +85,10 @@ function Component() { registerOnClose((tab) => { if (tab.type === "sessions") { const sessionId = tab.id; - if (isSessionEmpty(store, sessionId)) { + const isBatchRunning = + listenerStore.getState().getSessionMode(sessionId) === + "running_batch"; + if (!isBatchRunning && isSessionEmpty(store, sessionId)) { invalidateResource("sessions", sessionId); void deleteSessionCascade(store, indexes, sessionId); } From 0a0816f6be196c961d9596614bcf0b780a68ad12 Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Thu, 12 Feb 2026 16:28:14 +0900 Subject: [PATCH 16/17] refactor(sessions): remove unused getAITaskState selector --- .../src/components/main/body/sessions/floating/options-menu.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/desktop/src/components/main/body/sessions/floating/options-menu.tsx b/apps/desktop/src/components/main/body/sessions/floating/options-menu.tsx index 55dbdb2513..d3fac4d269 100644 --- a/apps/desktop/src/components/main/body/sessions/floating/options-menu.tsx +++ b/apps/desktop/src/components/main/body/sessions/floating/options-menu.tsx @@ -63,7 +63,6 @@ export function OptionsMenu({ const createEnhancedNote = useCreateEnhancedNote(); const model = useLanguageModel(); const generate = useAITask((state) => state.generate); - const getAITaskState = useAITask((state) => state.getState); const sessionTab = useTabs((state) => { const found = state.tabs.find( (tab): tab is Extract => From 903b58f1bb89fcefb4a452dc10fca6eb14a128f7 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 12 Feb 2026 08:05:01 +0000 Subject: [PATCH 17/17] fix(test): update batch state test to include phase field Co-Authored-By: john@hyprnote.com --- apps/desktop/src/store/zustand/listener/general.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/store/zustand/listener/general.test.ts b/apps/desktop/src/store/zustand/listener/general.test.ts index 5f3ebbbd1d..6ed7331a69 100644 --- a/apps/desktop/src/store/zustand/listener/general.test.ts +++ b/apps/desktop/src/store/zustand/listener/general.test.ts @@ -110,6 +110,7 @@ describe("General Listener Slice", () => { expect(store.getState().batch[sessionId]).toEqual({ percentage: 0.5, isComplete: false, + phase: "transcribing", }); clearBatchSession(sessionId);