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
1 change: 1 addition & 0 deletions apps/desktop/src/session/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export {
} from "./queries/participants";
export {
loadSessionEvent,
preloadSession,
updateSession,
useSession,
useSessionHasTranscript,
Expand Down
58 changes: 55 additions & 3 deletions apps/desktop/src/session/queries/sessions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import { renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";

const mocks = vi.hoisted(() => ({
execute: vi.fn(),
options: null as null | {
enabled?: boolean;
mapRows?: (rows: Array<Record<string, unknown>>) => unknown;
params?: unknown[];
sql: string;
},
Expand All @@ -13,26 +15,76 @@ const mocks = vi.hoisted(() => ({

vi.mock("~/db", () => ({
executeTransaction: vi.fn(),
liveQueryClient: { execute: vi.fn() },
liveQueryClient: { execute: mocks.execute },
useLiveQuery: (options: {
enabled?: boolean;
mapRows?: (rows: Array<Record<string, unknown>>) => unknown;
params?: unknown[];
sql: string;
}) => {
mocks.options = options;
return {
data: options.enabled === false || mocks.loading ? undefined : mocks.rows,
data:
options.enabled === false || mocks.loading
? undefined
: options.mapRows
? options.mapRows(mocks.rows)
: mocks.rows,
};
},
}));

import { useSessionSummariesByIds } from "./sessions";
import {
preloadSession,
useSession,
useSessionSummariesByIds,
} from "./sessions";

describe("session SQLite queries", () => {
beforeEach(() => {
mocks.options = null;
mocks.rows = [];
mocks.loading = false;
mocks.execute.mockReset();
});

it("uses prefetched content while the live subscription starts", async () => {
mocks.loading = true;
mocks.execute.mockResolvedValue([
{
id: "prefetched-session",
owner_user_id: "user-1",
created_at: "2026-08-24T09:00:00.000Z",
folder_path: "",
event_json: "{}",
title: "Planning",
raw_body:
'{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Ready immediately"}]}]}',
raw_body_format: "prosemirror_json",
raw_template_id: "",
locked: 0,
},
]);

await preloadSession("prefetched-session");
const { result } = renderHook(() => useSession("prefetched-session"));

expect(result.current?.raw_md).toContain("Ready immediately");
expect(mocks.execute).toHaveBeenCalledWith(
expect.stringContaining("FROM sessions"),
["prefetched-session"],
);
});

it("deduplicates concurrent session preloads", async () => {
mocks.execute.mockResolvedValue([]);

await Promise.all([
preloadSession("deduplicated-session"),
preloadSession("deduplicated-session"),
]);

expect(mocks.execute).toHaveBeenCalledOnce();
});

it("loads deduplicated summaries only for referenced ids", () => {
Expand Down
71 changes: 69 additions & 2 deletions apps/desktop/src/session/queries/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,16 @@ type SessionTranscriptStateSqlRow = {
type SessionEventSqlRow = { event_json: string };

const EMPTY_SESSION_SUMMARIES: SessionSummaryRecord[] = [];
const SESSION_PREFETCH_TTL_MS = 5_000;
const MAX_PREFETCHED_SESSIONS = 1;

type PrefetchedSession = {
createdAt: number;
promise: Promise<SessionRecord | null>;
value?: SessionRecord | null;
};

const prefetchedSessions = new Map<string, PrefetchedSession>();

const SESSION_SELECT_SQL = `
SELECT
Expand All @@ -62,7 +72,7 @@ const SESSION_SELECT_SQL = `
`;

export function useSession(sessionId: string): SessionRecord | null {
const { data = null } = useLiveQuery<SessionSqlRow, SessionRecord | null>({
const { data } = useLiveQuery<SessionSqlRow, SessionRecord | null>({
sql: SESSION_SELECT_SQL,
params: [sessionId],
enabled: Boolean(sessionId),
Expand All @@ -71,7 +81,48 @@ export function useSession(sessionId: string): SessionRecord | null {
return row ? mapSessionRow(row) : null;
},
});
return sessionId ? data : null;
if (!sessionId) return null;
if (data !== undefined) {
prefetchedSessions.delete(sessionId);
return data;
}

const prefetched = getPrefetchedSession(sessionId);
return prefetched && "value" in prefetched
? (prefetched.value ?? null)
: null;
}

export function preloadSession(
sessionId: string,
): Promise<SessionRecord | null> {
if (!sessionId) return Promise.resolve(null);

const existing = getPrefetchedSession(sessionId);
if (existing) return existing.promise;

let entry: PrefetchedSession;
const promise = liveQueryClient
.execute<SessionSqlRow>(SESSION_SELECT_SQL, [sessionId])
.then((rows) => {
const row = rows[0];
const value = row ? mapSessionRow(row) : null;
if (prefetchedSessions.get(sessionId) === entry) {
entry.value = value;
}
return value;
})
.catch((error) => {
if (prefetchedSessions.get(sessionId) === entry) {
prefetchedSessions.delete(sessionId);
}
throw error;
});

entry = { createdAt: Date.now(), promise };
prefetchedSessions.set(sessionId, entry);
trimPrefetchedSessions();
return promise;
}

export function useSessionSummary(
Expand Down Expand Up @@ -275,6 +326,22 @@ function useHeldLiveQueryRows<T>(
return data ?? previous.current;
}

function getPrefetchedSession(sessionId: string) {
const entry = prefetchedSessions.get(sessionId);
if (!entry) return undefined;
if (Date.now() - entry.createdAt <= SESSION_PREFETCH_TTL_MS) return entry;
prefetchedSessions.delete(sessionId);
return undefined;
}

function trimPrefetchedSessions() {
while (prefetchedSessions.size > MAX_PREFETCHED_SESSIONS) {
const oldestSessionId = prefetchedSessions.keys().next().value;
if (!oldestSessionId) return;
prefetchedSessions.delete(oldestSessionId);
}
}

function mapSessionRow(row: SessionSqlRow): SessionRecord {
let rawMd = row.raw_body;
if (rawMd && row.raw_body_format === "markdown") {
Expand Down
76 changes: 66 additions & 10 deletions apps/desktop/src/sidebar/timeline/item.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";

Expand All @@ -11,6 +17,7 @@ const mocks = vi.hoisted(() => ({
openCurrent: vi.fn(),
openNew: vi.fn(),
platform: "macos",
preloadSession: vi.fn(() => Promise.resolve(null)),
sessionMode: "inactive",
stop: vi.fn(),
getOrCreateSessionForEventId: vi.fn(() => Promise.resolve("session-event")),
Expand Down Expand Up @@ -78,6 +85,7 @@ vi.mock("~/session/hooks/useEnhancedNotes", () => ({

vi.mock("~/session/queries", () => ({
getOrCreateSessionForEventId: mocks.getOrCreateSessionForEventId,
preloadSession: mocks.preloadSession,
}));

vi.mock("~/lock/notes", () => ({
Expand Down Expand Up @@ -184,6 +192,8 @@ describe("TimelineItemComponent", () => {
mocks.openCurrent.mockClear();
mocks.openNew.mockClear();
mocks.platform = "macos";
mocks.preloadSession.mockReset();
mocks.preloadSession.mockResolvedValue(null);
mocks.authAvailable = false;
mocks.revealedNoteIds = {};
mocks.windowShow.mockClear();
Expand Down Expand Up @@ -528,7 +538,7 @@ describe("TimelineItemComponent", () => {
expect(sharedIcon.parentElement?.lastElementChild).toBe(sharedIcon);
});

it("opens the current tab after a single-click on a session row", () => {
it("preloads a session before opening it in the current tab", async () => {
render(
<TimelineItemComponent
item={{
Expand All @@ -548,18 +558,62 @@ describe("TimelineItemComponent", () => {
);

const rowButton = screen.getByText("Live Note").closest("button");
fireEvent.pointerDown(rowButton!);
fireEvent.click(rowButton!, { detail: 1 });

expect(mocks.timelineSelection.setAnchor).toHaveBeenCalledWith(
"session-session-note",
);
expect(mocks.openCurrent).toHaveBeenCalledWith({
id: "session-note",
type: "sessions",
expect(mocks.preloadSession).toHaveBeenCalledWith("session-note");
await waitFor(() => {
expect(mocks.openCurrent).toHaveBeenCalledWith({
id: "session-note",
type: "sessions",
});
});
});

it("opens a standalone note window when a session row is double-clicked", () => {
it("keeps the current note open until the target session is preloaded", async () => {
let finishPreload: ((value: null) => void) | undefined;
mocks.preloadSession.mockReturnValue(
new Promise((resolve) => {
finishPreload = resolve;
}),
);

render(
<TimelineItemComponent
item={{
type: "session",
id: "slow-session",
data: {
title: "Slow note",
created_at: "2024-01-15T10:30:00.000Z",
},
}}
precision="time"
selected={false}
timezone="UTC"
multiSelected={false}
flatItemKeys={["session-slow-session"]}
/>,
);

fireEvent.click(screen.getByText("Live Note").closest("button")!);

expect(mocks.openCurrent).not.toHaveBeenCalled();
expect(screen.getByTestId("spinner")).toBeTruthy();

finishPreload?.(null);
await waitFor(() => {
expect(mocks.openCurrent).toHaveBeenCalledWith({
id: "slow-session",
type: "sessions",
});
});
});

it("opens a standalone note window when a session row is double-clicked", async () => {
render(
<TimelineItemComponent
item={{
Expand All @@ -583,10 +637,12 @@ describe("TimelineItemComponent", () => {
fireEvent.click(rowButton!, { detail: 2 });
fireEvent.doubleClick(rowButton!);

expect(mocks.openCurrent).toHaveBeenCalledTimes(1);
expect(mocks.openCurrent).toHaveBeenCalledWith({
id: "session-note-window",
type: "sessions",
await waitFor(() => {
expect(mocks.openCurrent).toHaveBeenCalledTimes(1);
expect(mocks.openCurrent).toHaveBeenCalledWith({
id: "session-note-window",
type: "sessions",
});
});
expect(mocks.timelineSelection.setAnchor).toHaveBeenCalledTimes(1);
expect(mocks.timelineSelection.setAnchor).toHaveBeenCalledWith(
Expand Down
Loading
Loading