From 50f9c553d2c784bc038f5d90c35a476fb1e1f168 Mon Sep 17 00:00:00 2001 From: ComputelessComputer <63365510+ComputelessComputer@users.noreply.github.com> Date: Mon, 1 Jun 2026 20:01:09 +0900 Subject: [PATCH] fix(desktop): highlight active notes in sidebar timeline Show active recording notes as red rows in sidebar timeline mode with a live activity glyph and stop control. --- .../src/sidebar/timeline/item.test.tsx | 192 ++++++++++++++++++ apps/desktop/src/sidebar/timeline/item.tsx | 144 +++++++++---- 2 files changed, 298 insertions(+), 38 deletions(-) create mode 100644 apps/desktop/src/sidebar/timeline/item.test.tsx diff --git a/apps/desktop/src/sidebar/timeline/item.test.tsx b/apps/desktop/src/sidebar/timeline/item.test.tsx new file mode 100644 index 0000000000..ec23b8af46 --- /dev/null +++ b/apps/desktop/src/sidebar/timeline/item.test.tsx @@ -0,0 +1,192 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + addDeletion: vi.fn(), + amplitude: { mic: 0.4, speaker: 0.3 }, + ignoreEvent: vi.fn(), + invalidateResource: vi.fn(), + isIgnored: vi.fn(() => false), + openCurrent: vi.fn(), + openNew: vi.fn(), + sessionMode: "inactive", + stop: vi.fn(), + storeTitle: "Live Note", + timelineSelection: { + selectedIds: [] as string[], + setAnchor: vi.fn(), + selectRange: vi.fn(), + toggleSelect: vi.fn(), + }, +})); + +vi.mock("@hypr/plugin-fs-sync", () => ({ + commands: { + sessionDir: vi.fn(() => Promise.resolve({ status: "ok", data: "" })), + }, +})); + +vi.mock("@hypr/plugin-opener2", () => ({ + commands: { + openPath: vi.fn(() => Promise.resolve()), + }, +})); + +vi.mock("@hypr/ui/components/ui/dancing-sticks", () => ({ + DancingSticks: ({ amplitude }: { amplitude: number }) => ( + + ), +})); + +vi.mock("@hypr/ui/components/ui/spinner", () => ({ + Spinner: () => , +})); + +vi.mock("@hypr/ui/components/ui/tooltip", () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}, +})); + +vi.mock("~/session/components/session-preview-card", () => ({ + SessionPreviewCard: ({ children }: { children: ReactNode }) => ( + <>{children} + ), +})); + +vi.mock("~/session/hooks/useEnhancedNotes", () => ({ + useIsSessionEnhancing: () => false, +})); + +vi.mock("~/shared/hooks/useNativeContextMenu", () => ({ + useNativeContextMenu: () => vi.fn(), +})); + +vi.mock("~/store/tinybase/hooks", () => ({ + useIgnoredEvents: () => ({ + ignoreEvent: mocks.ignoreEvent, + ignoreSeries: vi.fn(), + isIgnored: mocks.isIgnored, + unignoreEvent: vi.fn(), + unignoreSeries: vi.fn(), + }), +})); + +vi.mock("~/store/tinybase/store/deleteSession", () => ({ + captureSessionData: vi.fn(() => null), + deleteSessionCascade: vi.fn(), + finalizeSessionDeletion: vi.fn(), +})); + +vi.mock("~/store/tinybase/store/main", () => ({ + STORE_ID: "main", + UI: { + useCell: () => mocks.storeTitle, + useIndexes: () => ({}), + useRow: () => null, + useStore: () => ({}), + }, +})); + +vi.mock("~/store/zustand/live-title", () => ({ + useSessionTitle: (_sessionId: string, storeTitle: string | undefined) => + storeTitle, +})); + +vi.mock("~/store/zustand/tabs", () => ({ + useTabs: ( + selector: (state: { + invalidateResource: typeof mocks.invalidateResource; + openCurrent: typeof mocks.openCurrent; + openNew: typeof mocks.openNew; + }) => unknown, + ) => + selector({ + invalidateResource: mocks.invalidateResource, + openCurrent: mocks.openCurrent, + openNew: mocks.openNew, + }), +})); + +vi.mock("~/store/zustand/timeline-selection", () => ({ + useTimelineSelection: Object.assign( + (selector: (state: typeof mocks.timelineSelection) => unknown) => + selector(mocks.timelineSelection), + { + getState: () => mocks.timelineSelection, + }, + ), +})); + +vi.mock("~/store/zustand/undo-delete", () => ({ + useUndoDelete: ( + selector: (state: { addDeletion: typeof mocks.addDeletion }) => unknown, + ) => selector({ addDeletion: mocks.addDeletion }), +})); + +vi.mock("~/stt/contexts", () => ({ + useListener: ( + selector: (state: { + getSessionMode: (sessionId: string) => string; + live: { amplitude: { mic: number; speaker: number } }; + stop: typeof mocks.stop; + }) => unknown, + ) => + selector({ + getSessionMode: () => mocks.sessionMode, + live: { amplitude: mocks.amplitude }, + stop: mocks.stop, + }), +})); + +import { TimelineItemComponent } from "./item"; + +describe("TimelineItemComponent", () => { + beforeEach(() => { + cleanup(); + mocks.amplitude = { mic: 0.4, speaker: 0.3 }; + mocks.sessionMode = "inactive"; + mocks.stop.mockClear(); + mocks.openCurrent.mockClear(); + mocks.openNew.mockClear(); + mocks.timelineSelection.selectedIds = []; + mocks.timelineSelection.setAnchor.mockClear(); + mocks.timelineSelection.selectRange.mockClear(); + mocks.timelineSelection.toggleSelect.mockClear(); + }); + + it("marks the active session row red in the sidebar timeline", () => { + mocks.sessionMode = "active"; + + render( + , + ); + + const rowButton = screen.getByText("Live Note").closest("button"); + + expect(rowButton?.className).toContain("bg-red-500"); + expect(rowButton?.className).toContain("text-white"); + expect(rowButton?.className).not.toContain("bg-neutral-200"); + expect(screen.getByTestId("dancing-sticks").dataset.amplitude).toBe("0.5"); + + fireEvent.click(screen.getByRole("button", { name: "Stop listening" })); + + expect(mocks.stop).toHaveBeenCalledOnce(); + expect(mocks.openCurrent).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/sidebar/timeline/item.tsx b/apps/desktop/src/sidebar/timeline/item.tsx index bf8bab2a7f..ea36d9aa68 100644 --- a/apps/desktop/src/sidebar/timeline/item.tsx +++ b/apps/desktop/src/sidebar/timeline/item.tsx @@ -1,7 +1,9 @@ +import { SquareIcon } from "lucide-react"; import { memo, useCallback, useMemo } from "react"; import { commands as fsSyncCommands } from "@hypr/plugin-fs-sync"; import { commands as openerCommands } from "@hypr/plugin-opener2"; +import { DancingSticks } from "@hypr/ui/components/ui/dancing-sticks"; import { Spinner } from "@hypr/ui/components/ui/spinner"; import { Tooltip, @@ -82,6 +84,8 @@ function ItemBase({ title, displayTime, calendarId, + isLive, + amplitude, showSpinner, selected, ignored, @@ -90,11 +94,14 @@ function ItemBase({ onClick, onCmdClick, onShiftClick, + onStop, contextMenu, }: { title: string; displayTime: string; calendarId: string | null; + isLive?: boolean; + amplitude?: number; showSpinner?: boolean; selected: boolean; ignored?: boolean; @@ -103,50 +110,100 @@ function ItemBase({ onClick: () => void; onCmdClick: () => void; onShiftClick: () => void; + onStop?: () => void; contextMenu: MenuItemDef[]; }) { const hasSelection = useTimelineSelection((s) => s.selectedIds.length > 0); + const showLiveStop = isLive && onStop; return ( - -
- {showSpinner && ( -
- -
- )} -
-
- {title || "Untitled"} -
- {displayTime && ( -
- {displayTime} +
+ +
+ {showSpinner && ( +
+
)} +
+
+ {title || "Untitled"} +
+ {displayTime && ( +
+ {displayTime} +
+ )} +
+ {calendarId && }
- {calendarId && } -
- + + {showLiveStop ? ( + + ) : null} +
); } @@ -343,12 +400,17 @@ const SessionItem = memo( ) as string | undefined; const title = useSessionTitle(sessionId, storeTitle); - const sessionMode = useListener((state) => state.getSessionMode(sessionId)); + const { sessionMode, stop, amplitude } = useListener((state) => ({ + sessionMode: state.getSessionMode(sessionId), + stop: state.stop, + amplitude: state.live.amplitude, + })); const isEnhancing = useIsSessionEnhancing(sessionId); + const isLive = sessionMode === "active"; const isFinalizing = sessionMode === "finalizing"; const isBatching = sessionMode === "running_batch"; const showSpinner = - !selected && (isFinalizing || isEnhancing || isBatching); + !selected && !isLive && (isFinalizing || isEnhancing || isBatching); const sessionEvent = useMemo( () => getSessionEvent(item.data), @@ -457,6 +519,11 @@ const SessionItem = memo( title={title} displayTime={displayTime} calendarId={calendarId} + isLive={isLive} + amplitude={Math.max( + 0.25, + Math.min(Math.hypot(amplitude.mic, amplitude.speaker), 1), + )} showSpinner={showSpinner} selected={selected} muted={muted} @@ -464,6 +531,7 @@ const SessionItem = memo( onClick={handleClick} onCmdClick={handleCmdClick} onShiftClick={handleShiftClick} + onStop={stop} contextMenu={contextMenu} />