Skip to content

Commit 63300a7

Browse files
Show live transcript in standalone note windows
Expose active capture snapshots and attach standalone note routes to listener events.
1 parent 703cf84 commit 63300a7

17 files changed

Lines changed: 375 additions & 16 deletions

File tree

apps/desktop/src/routes/app/-note.$sessionId.test.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
44
import { resetTabsStore } from "~/store/zustand/tabs/test-utils";
55

66
const mocks = vi.hoisted(() => ({
7+
attachLiveSession: vi.fn(),
78
close: vi.fn(),
89
}));
910

@@ -13,7 +14,16 @@ vi.mock("@tauri-apps/api/window", () => ({
1314
}),
1415
}));
1516

17+
vi.mock("~/stt/contexts", () => ({
18+
useListener: (
19+
selector: (state: {
20+
attachLiveSession: typeof mocks.attachLiveSession;
21+
}) => unknown,
22+
) => selector({ attachLiveSession: mocks.attachLiveSession }),
23+
}));
24+
1625
import {
26+
useAttachStandaloneNoteToLiveSession,
1727
useCloseStandaloneNoteWindowOnEscape,
1828
useStandaloneNoteTab,
1929
} from "./note.$sessionId";
@@ -22,6 +32,7 @@ import { useTabs } from "~/store/zustand/tabs";
2232

2333
describe("standalone note window route", () => {
2434
beforeEach(() => {
35+
mocks.attachLiveSession.mockClear();
2536
mocks.close.mockClear();
2637
resetTabsStore();
2738
});
@@ -71,6 +82,12 @@ describe("standalone note window route", () => {
7182

7283
expect(result.current.state.view).toEqual({ type: "raw" });
7384
});
85+
86+
it("attaches the standalone note to live session events", () => {
87+
renderHook(() => useAttachStandaloneNoteToLiveSession("session-1"));
88+
89+
expect(mocks.attachLiveSession).toHaveBeenCalledWith("session-1");
90+
});
7491
});
7592

7693
function dispatchKeyDown(key: string) {

apps/desktop/src/routes/app/note.$sessionId.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { ClassicMainLayout } from "~/main/layout";
66
import { TabContentNote } from "~/session";
77
import { StandaloneWindowShell } from "~/shared/window-shell";
88
import { type Tab, useTabs } from "~/store/zustand/tabs";
9+
import { useListener } from "~/stt/contexts";
910

1011
export const Route = createFileRoute("/app/note/$sessionId")({
1112
component: Component,
@@ -14,6 +15,7 @@ export const Route = createFileRoute("/app/note/$sessionId")({
1415
function Component() {
1516
const { sessionId } = Route.useParams();
1617
useCloseStandaloneNoteWindowOnEscape();
18+
useAttachStandaloneNoteToLiveSession(sessionId);
1719
const tab = useStandaloneNoteTab(sessionId);
1820

1921
return (
@@ -27,6 +29,14 @@ function Component() {
2729
);
2830
}
2931

32+
export function useAttachStandaloneNoteToLiveSession(sessionId: string) {
33+
const attachLiveSession = useListener((state) => state.attachLiveSession);
34+
35+
useEffect(() => {
36+
void attachLiveSession(sessionId);
37+
}, [attachLiveSession, sessionId]);
38+
}
39+
3040
export function useStandaloneNoteTab(sessionId: string) {
3141
const tab = useMemo(
3242
() =>

apps/desktop/src/store/zustand/listener/general-live.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
type CaptureDataEvent,
1313
type CaptureConfigUpdate,
1414
type CaptureLifecycleEvent,
15+
type CaptureSnapshot,
1516
type CaptureParams,
1617
type CaptureStatusEvent,
1718
type LiveTranscriptDelta,
@@ -255,6 +256,13 @@ export const startLiveSession = <T extends LiveStore>(
255256
targetSessionId: string,
256257
params: CaptureParams,
257258
): Promise<boolean> => {
259+
clearLiveEventUnlisteners(
260+
get().live.eventUnlistenersBySession[targetSessionId],
261+
);
262+
setLiveState(set, (live) => {
263+
delete live.eventUnlistenersBySession[targetSessionId];
264+
});
265+
258266
const handlers = createSessionEventHandlers(set, get, targetSessionId);
259267

260268
const program = Effect.gen(function* () {
@@ -339,6 +347,92 @@ export const startLiveSession = <T extends LiveStore>(
339347
);
340348
};
341349

350+
export const attachLiveSession = <T extends LiveStore>(
351+
set: StoreApi<T>["setState"],
352+
get: StoreApi<T>["getState"],
353+
targetSessionId: string,
354+
): Promise<void> => {
355+
const currentLive = get().live;
356+
if (currentLive.eventUnlistenersBySession[targetSessionId]) {
357+
return Promise.resolve();
358+
}
359+
360+
const handlers = createSessionEventHandlers(set, get, targetSessionId);
361+
362+
const program = Effect.gen(function* () {
363+
const unlisteners = yield* listenToAllSessionEvents(handlers);
364+
setLiveState(set, (live) => {
365+
live.eventUnlistenersBySession[targetSessionId] = unlisteners;
366+
});
367+
368+
const snapshot = yield* fromResult(listenerCommands.getCaptureSnapshot());
369+
applyCaptureSnapshot(set, get, targetSessionId, snapshot);
370+
});
371+
372+
return Effect.runPromiseExit(program).then((exit) =>
373+
Exit.match(exit, {
374+
onFailure: (cause) => {
375+
console.error("[listener] failed to attach live session:", cause);
376+
clearLiveEventUnlisteners(
377+
get().live.eventUnlistenersBySession[targetSessionId],
378+
);
379+
setLiveState(set, (live) => {
380+
delete live.eventUnlistenersBySession[targetSessionId];
381+
});
382+
},
383+
onSuccess: () => undefined,
384+
}),
385+
);
386+
};
387+
388+
function applyCaptureSnapshot<T extends GeneralState>(
389+
set: StoreApi<T>["setState"],
390+
get: StoreApi<T>["getState"],
391+
targetSessionId: string,
392+
snapshot: CaptureSnapshot,
393+
) {
394+
if (
395+
snapshot.state === "active" &&
396+
snapshot.activeSessionId === targetSessionId
397+
) {
398+
const currentLive = get().live;
399+
const intervalId =
400+
currentLive.sessionId === targetSessionId && currentLive.intervalId
401+
? currentLive.intervalId
402+
: setInterval(() => {
403+
setLiveState(set, (live) => {
404+
if (
405+
live.sessionId === targetSessionId &&
406+
live.status === "active"
407+
) {
408+
live.seconds += 1;
409+
}
410+
});
411+
}, 1000);
412+
413+
setLiveState(set, (live) => {
414+
markLiveActive(
415+
live,
416+
targetSessionId,
417+
intervalId,
418+
snapshot.requestedLiveTranscription ?? true,
419+
snapshot.liveTranscriptionActive ?? true,
420+
null,
421+
);
422+
});
423+
return;
424+
}
425+
426+
if (
427+
snapshot.state === "finalizing" &&
428+
snapshot.finalizingSessionIds.includes(targetSessionId)
429+
) {
430+
setLiveState(set, (live) => {
431+
markLiveFinalizing(live, targetSessionId);
432+
});
433+
}
434+
}
435+
342436
export const stopLiveSession = <T extends GeneralState>(
343437
set: StoreApi<T>["setState"],
344438
get: StoreApi<T>["getState"],

apps/desktop/src/store/zustand/listener/general.test.ts

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,20 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
33

44
const {
55
getIdentifierMock,
6+
getCaptureSnapshotMock,
7+
listenCaptureDataMock,
8+
listenCaptureLifecycleMock,
9+
listenCaptureStatusMock,
610
runEventHooksMock,
711
setRecordingIndicatorMock,
812
stopCaptureMock,
913
vaultBaseMock,
1014
} = vi.hoisted(() => ({
1115
getIdentifierMock: vi.fn(),
16+
getCaptureSnapshotMock: vi.fn(),
17+
listenCaptureDataMock: vi.fn(),
18+
listenCaptureLifecycleMock: vi.fn(),
19+
listenCaptureStatusMock: vi.fn(),
1220
runEventHooksMock: vi.fn(),
1321
setRecordingIndicatorMock: vi.fn(),
1422
stopCaptureMock: vi.fn(),
@@ -45,6 +53,7 @@ vi.mock("@hypr/plugin-settings", () => ({
4553

4654
vi.mock("@hypr/plugin-transcription", () => ({
4755
commands: {
56+
getCaptureSnapshot: getCaptureSnapshotMock,
4857
setMicMuted: vi.fn(),
4958
startCapture: vi.fn(),
5059
startTranscription: vi.fn(),
@@ -54,13 +63,13 @@ vi.mock("@hypr/plugin-transcription", () => ({
5463
},
5564
events: {
5665
captureDataEvent: {
57-
listen: vi.fn(),
66+
listen: listenCaptureDataMock,
5867
},
5968
captureLifecycleEvent: {
60-
listen: vi.fn(),
69+
listen: listenCaptureLifecycleMock,
6170
},
6271
captureStatusEvent: {
63-
listen: vi.fn(),
72+
listen: listenCaptureStatusMock,
6473
},
6574
},
6675
}));
@@ -79,6 +88,19 @@ describe("General Listener Slice", () => {
7988
store = createListenerStore();
8089
vi.clearAllMocks();
8190
getIdentifierMock.mockResolvedValue("com.hyprnote.stable");
91+
getCaptureSnapshotMock.mockResolvedValue({
92+
status: "ok",
93+
data: {
94+
activeSessionId: null,
95+
finalizingSessionIds: [],
96+
liveTranscriptionActive: null,
97+
requestedLiveTranscription: null,
98+
state: "inactive",
99+
},
100+
});
101+
listenCaptureDataMock.mockResolvedValue(() => {});
102+
listenCaptureLifecycleMock.mockResolvedValue(() => {});
103+
listenCaptureStatusMock.mockResolvedValue(() => {});
82104
runEventHooksMock.mockResolvedValue({ status: "ok", data: null });
83105
setRecordingIndicatorMock.mockResolvedValue({ status: "ok", data: null });
84106
stopCaptureMock.mockResolvedValue({ status: "ok", data: null });
@@ -632,6 +654,46 @@ describe("General Listener Slice", () => {
632654
expect(typeof start).toBe("function");
633655
});
634656

657+
test("attachLiveSession hydrates the active native capture for the same session", async () => {
658+
getCaptureSnapshotMock.mockResolvedValueOnce({
659+
status: "ok",
660+
data: {
661+
activeSessionId: "session-a",
662+
finalizingSessionIds: [],
663+
liveTranscriptionActive: true,
664+
requestedLiveTranscription: true,
665+
state: "active",
666+
},
667+
});
668+
669+
await store.getState().attachLiveSession("session-a");
670+
671+
expect(store.getState().getSessionMode("session-a")).toBe("active");
672+
expect(store.getState().live.sessionId).toBe("session-a");
673+
expect(store.getState().live.liveTranscriptionActive).toBe(true);
674+
expect(listenCaptureLifecycleMock).toHaveBeenCalledTimes(1);
675+
expect(listenCaptureStatusMock).toHaveBeenCalledTimes(1);
676+
expect(listenCaptureDataMock).toHaveBeenCalledTimes(1);
677+
});
678+
679+
test("attachLiveSession does not mark a different active native capture as current", async () => {
680+
getCaptureSnapshotMock.mockResolvedValueOnce({
681+
status: "ok",
682+
data: {
683+
activeSessionId: "session-b",
684+
finalizingSessionIds: [],
685+
liveTranscriptionActive: true,
686+
requestedLiveTranscription: true,
687+
state: "active",
688+
},
689+
});
690+
691+
await store.getState().attachLiveSession("session-a");
692+
693+
expect(store.getState().getSessionMode("session-a")).toBe("inactive");
694+
expect(store.getState().live.sessionId).toBeNull();
695+
});
696+
635697
test("start returns false while another session is active", async () => {
636698
store.setState((state) =>
637699
mutate(state, (draft) => {

apps/desktop/src/store/zustand/listener/general.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type { TranscriptionParams } from "@hypr/plugin-transcription";
1010
import type { BatchActions, BatchState } from "./batch";
1111
import { runBatchSession } from "./general-batch";
1212
import {
13+
attachLiveSession,
1314
startLiveSession,
1415
stopLiveSession,
1516
updateLiveSessionConfig,
@@ -41,6 +42,7 @@ export type GeneralActions = {
4142
},
4243
) => Promise<boolean>;
4344
stop: () => void;
45+
attachLiveSession: (sessionId: string) => Promise<void>;
4446
setMuted: (value: boolean) => void;
4547
setTriggerAppIds: (appIds: string[] | null) => void;
4648
updateCaptureConfig: (
@@ -118,6 +120,13 @@ export const createGeneralSlice = <
118120
stop: () => {
119121
stopLiveSession(set, get);
120122
},
123+
attachLiveSession: async (sessionId) => {
124+
if (!sessionId) {
125+
return;
126+
}
127+
128+
await attachLiveSession(set, get, sessionId);
129+
},
121130
setMuted: (value) => {
122131
set((state) =>
123132
mutate(state, (draft) => {

0 commit comments

Comments
 (0)