From 1f79babc4d1665a0c23422911b6bb8fcc72bf056 Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Thu, 12 Feb 2026 16:06:50 +0900 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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 (