Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions apps/desktop/src/routes/app/-note.$sessionId.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { resetTabsStore } from "~/store/zustand/tabs/test-utils";

const mocks = vi.hoisted(() => ({
attachLiveSession: vi.fn(),
close: vi.fn(),
listenerState: {
attachLiveSession: vi.fn(),
live: {
eventUnlistenersBySession: {} as Record<string, (() => void)[]>,
},
},
}));

vi.mock("@tauri-apps/api/window", () => ({
Expand All @@ -13,7 +20,13 @@ vi.mock("@tauri-apps/api/window", () => ({
}),
}));

vi.mock("~/stt/contexts", () => ({
useListener: (selector: (state: typeof mocks.listenerState) => unknown) =>
selector(mocks.listenerState),
}));

import {
useAttachStandaloneNoteToLiveSession,
useCloseStandaloneNoteWindowOnEscape,
useStandaloneNoteTab,
} from "./note.$sessionId";
Expand All @@ -22,6 +35,9 @@ import { useTabs } from "~/store/zustand/tabs";

describe("standalone note window route", () => {
beforeEach(() => {
mocks.listenerState.attachLiveSession = mocks.attachLiveSession;
mocks.listenerState.live.eventUnlistenersBySession = {};
mocks.attachLiveSession.mockClear();
mocks.close.mockClear();
resetTabsStore();
});
Expand Down Expand Up @@ -71,6 +87,29 @@ describe("standalone note window route", () => {

expect(result.current.state.view).toEqual({ type: "raw" });
});

it("attaches the standalone note to live session events", () => {
renderHook(() => useAttachStandaloneNoteToLiveSession("session-1"));

expect(mocks.attachLiveSession).toHaveBeenCalledWith("session-1");
});

it("reattaches after standalone live session events are removed", () => {
const { rerender } = renderHook(() =>
useAttachStandaloneNoteToLiveSession("session-1"),
);
expect(mocks.attachLiveSession).toHaveBeenCalledTimes(1);

mocks.listenerState.live.eventUnlistenersBySession = {
"session-1": [vi.fn()],
};
rerender();
expect(mocks.attachLiveSession).toHaveBeenCalledTimes(1);

mocks.listenerState.live.eventUnlistenersBySession = {};
rerender();
expect(mocks.attachLiveSession).toHaveBeenCalledTimes(2);
});
});

function dispatchKeyDown(key: string) {
Expand Down
17 changes: 17 additions & 0 deletions apps/desktop/src/routes/app/note.$sessionId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ClassicMainLayout } from "~/main/layout";
import { TabContentNote } from "~/session";
import { StandaloneWindowShell } from "~/shared/window-shell";
import { type Tab, useTabs } from "~/store/zustand/tabs";
import { useListener } from "~/stt/contexts";

export const Route = createFileRoute("/app/note/$sessionId")({
component: Component,
Expand All @@ -14,6 +15,7 @@ export const Route = createFileRoute("/app/note/$sessionId")({
function Component() {
const { sessionId } = Route.useParams();
useCloseStandaloneNoteWindowOnEscape();
useAttachStandaloneNoteToLiveSession(sessionId);
const tab = useStandaloneNoteTab(sessionId);

return (
Expand All @@ -27,6 +29,21 @@ function Component() {
);
}

export function useAttachStandaloneNoteToLiveSession(sessionId: string) {
const attachLiveSession = useListener((state) => state.attachLiveSession);
const hasLiveSessionEvents = useListener((state) =>
Boolean(state.live.eventUnlistenersBySession[sessionId]),
);

useEffect(() => {
if (hasLiveSessionEvents) {
return;
}

void attachLiveSession(sessionId);
}, [attachLiveSession, hasLiveSessionEvents, sessionId]);
}

export function useStandaloneNoteTab(sessionId: string) {
const tab = useMemo(
() =>
Expand Down
135 changes: 135 additions & 0 deletions apps/desktop/src/store/zustand/listener/general-live.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type CaptureDataEvent,
type CaptureConfigUpdate,
type CaptureLifecycleEvent,
type CaptureSnapshot,
type CaptureParams,
type CaptureStatusEvent,
type LiveTranscriptDelta,
Expand Down Expand Up @@ -255,6 +256,13 @@ export const startLiveSession = <T extends LiveStore>(
targetSessionId: string,
params: CaptureParams,
): Promise<boolean> => {
clearLiveEventUnlisteners(
get().live.eventUnlistenersBySession[targetSessionId],
);
setLiveState(set, (live) => {
delete live.eventUnlistenersBySession[targetSessionId];
});

const handlers = createSessionEventHandlers(set, get, targetSessionId);

const program = Effect.gen(function* () {
Expand Down Expand Up @@ -339,6 +347,133 @@ export const startLiveSession = <T extends LiveStore>(
);
};

export const attachLiveSession = <T extends LiveStore>(
Comment thread
cursor[bot] marked this conversation as resolved.
set: StoreApi<T>["setState"],
get: StoreApi<T>["getState"],
targetSessionId: string,
): Promise<void> => {
const currentLive = get().live;
if (currentLive.eventUnlistenersBySession[targetSessionId]) {
return Promise.resolve();
}

const pendingUnlisteners: (() => void)[] = [];
let registeredUnlisteners = pendingUnlisteners;
setLiveState(set, (live) => {
live.eventUnlistenersBySession[targetSessionId] = pendingUnlisteners;
if (!live.sessionId) {
live.sessionId = targetSessionId;
}
});

const handlers = createSessionEventHandlers(set, get, targetSessionId);

const program = Effect.gen(function* () {
const unlisteners = yield* listenToAllSessionEvents(handlers);
if (
get().live.eventUnlistenersBySession[targetSessionId] !==
pendingUnlisteners
) {
clearLiveEventUnlisteners(unlisteners);
return;
}

registeredUnlisteners = unlisteners;
setLiveState(set, (live) => {
live.eventUnlistenersBySession[targetSessionId] = unlisteners;
Comment thread
cursor[bot] marked this conversation as resolved.
});

const snapshot = yield* fromResult(listenerCommands.getCaptureSnapshot());
applyCaptureSnapshot(set, get, targetSessionId, snapshot);
Comment thread
cursor[bot] marked this conversation as resolved.
});

return Effect.runPromiseExit(program).then((exit) =>
Exit.match(exit, {
onFailure: (cause) => {
console.error("[listener] failed to attach live session:", cause);
clearLiveEventUnlisteners(registeredUnlisteners);
setLiveState(set, (live) => {
if (
live.eventUnlistenersBySession[targetSessionId] ===
registeredUnlisteners
) {
delete live.eventUnlistenersBySession[targetSessionId];
}
if (
live.sessionId === targetSessionId &&
live.status === "inactive"
) {
live.sessionId = null;
}
});
},
onSuccess: () => undefined,
}),
);
};

function applyCaptureSnapshot<T extends GeneralState>(
set: StoreApi<T>["setState"],
get: StoreApi<T>["getState"],
targetSessionId: string,
snapshot: CaptureSnapshot,
) {
if (
snapshot.state === "active" &&
snapshot.activeSessionId === targetSessionId
) {
const currentLive = get().live;
if (currentLive.sessionId !== targetSessionId) {
clearLiveInterval(currentLive.intervalId);
}

const intervalId =
currentLive.sessionId === targetSessionId && currentLive.intervalId
? currentLive.intervalId
: setInterval(() => {
setLiveState(set, (live) => {
if (
live.sessionId === targetSessionId &&
live.status === "active"
) {
live.seconds += 1;
}
});
}, 1000);
Comment thread
cursor[bot] marked this conversation as resolved.

setLiveState(set, (live) => {
markLiveActive(
live,
targetSessionId,
intervalId,
snapshot.requestedLiveTranscription ?? true,
snapshot.liveTranscriptionActive ?? true,
null,
);
});
return;
}

if (
snapshot.state === "finalizing" &&
snapshot.finalizingSessionIds.includes(targetSessionId)
) {
setLiveState(set, (live) => {
if (!live.sessionId) {
live.sessionId = targetSessionId;
}
markLiveFinalizing(live, targetSessionId);
});
return;
}
Comment thread
cursor[bot] marked this conversation as resolved.

setLiveState(set, (live) => {
if (live.sessionId === targetSessionId && live.status === "inactive") {
live.sessionId = null;
}
});
}

export const stopLiveSession = <T extends GeneralState>(
set: StoreApi<T>["setState"],
get: StoreApi<T>["getState"],
Expand Down
Loading
Loading