Skip to content
Draft
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
2 changes: 2 additions & 0 deletions apps/code/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,9 @@
"@posthog/workspace-server": "workspace:*",
"@radix-ui/themes": "^3.2.1",
"@tailwindcss/vite": "^4.2.2",
"@tanstack/query-async-storage-persister": "5.90.20",
"@tanstack/react-query": "^5.90.2",
"@tanstack/react-query-persist-client": "5.90.20",
"@tanstack/router-plugin": "^1.95.0",
"@trpc/client": "^11.12.0",
"@trpc/server": "^11.12.0",
Expand Down
19 changes: 11 additions & 8 deletions apps/code/src/renderer/components/Providers.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
import { HostTRPCProvider } from "@posthog/host-router/react";
import { ThemeWrapper } from "@posthog/ui/primitives/ThemeWrapper";
import { buildCanvasPersistOptions } from "@posthog/ui/shell/queryPersistence";
import { WorkspaceClientProvider } from "@posthog/workspace-client/provider";
import {
hostTrpcClient,
TRPCProvider,
trpcClient,
useTRPC,
} from "@renderer/trpc/client";
import {
QueryClientProvider,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import { useSubscription } from "@trpc/tanstack-react-query";
import { queryClient } from "@utils/queryClient";
import { queryPersister } from "@utils/queryPersister";
import type React from "react";
import { useCallback, useState } from "react";
import { HotkeysProvider } from "react-hotkeys-hook";

const persistOptions = buildCanvasPersistOptions(queryPersister);

function WorkspaceServerErrorBanner({
onRetry,
disabled,
Expand Down Expand Up @@ -107,7 +107,10 @@ export const Providers: React.FC<{ children: React.ReactNode }> = ({
}) => {
return (
<HotkeysProvider>
<QueryClientProvider client={queryClient}>
<PersistQueryClientProvider
client={queryClient}
persistOptions={persistOptions}
>
<TRPCProvider trpcClient={trpcClient} queryClient={queryClient}>
<HostTRPCProvider
trpcClient={hostTrpcClient}
Expand All @@ -118,7 +121,7 @@ export const Providers: React.FC<{ children: React.ReactNode }> = ({
</ConnectedWorkspaceProvider>
</HostTRPCProvider>
</TRPCProvider>
</QueryClientProvider>
</PersistQueryClientProvider>
</HotkeysProvider>
);
};
4 changes: 4 additions & 0 deletions apps/code/src/renderer/utils/queryClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5,
// Keep entries around for a day so the persisted cache can be restored on
// reload — PersistQueryClientProvider only restores a query whose gcTime
// is >= the persist maxAge (see queryPersistence.CANVAS_PERSIST_MAX_AGE).
gcTime: 1000 * 60 * 60 * 24,
refetchOnWindowFocus: true,
},
},
Expand Down
17 changes: 17 additions & 0 deletions apps/code/src/renderer/utils/queryPersister.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { CANVAS_PERSIST_KEY } from "@posthog/ui/shell/queryPersistence";
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
import { trpcClient } from "../trpc";

// Persist the query cache through the same electron-backed secure store that
// holds the renderer's other persisted state. We talk to the raw string-in/out
// secureStore backend directly (not the createJSONStorage `electronStorage`
// wrapper) so the persister owns serialization without double-encoding.
export const queryPersister = createAsyncStoragePersister({
key: CANVAS_PERSIST_KEY,
storage: {
getItem: (key) => trpcClient.secureStore.getItem.query({ key }),
setItem: (key, value) =>
trpcClient.secureStore.setItem.query({ key, value }),
removeItem: (key) => trpcClient.secureStore.removeItem.query({ key }),
},
});
2 changes: 2 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@
"@posthog/shared": "workspace:*",
"@posthog/ui": "workspace:*",
"@posthog/workspace-client": "workspace:*",
"@tanstack/query-sync-storage-persister": "5.90.20",
"@tanstack/react-query": "^5.90.2",
"@tanstack/react-query-persist-client": "5.90.20",
"@trpc/client": "^11.12.0",
"@trpc/tanstack-react-query": "^11.12.0",
"inversify": "^7.10.6",
Expand Down
13 changes: 10 additions & 3 deletions apps/web/src/Providers.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { HostTRPCProvider } from "@posthog/host-router/react";
import { ThemeWrapper } from "@posthog/ui/primitives/ThemeWrapper";
import { QueryClientProvider } from "@tanstack/react-query";
import { buildCanvasPersistOptions } from "@posthog/ui/shell/queryPersistence";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import type React from "react";
import { HotkeysProvider } from "react-hotkeys-hook";
import { queryClient } from "./web-container";
import { queryPersister } from "./web-persister";
import { hostTrpcClient } from "./web-trpc";

const persistOptions = buildCanvasPersistOptions(queryPersister);

// Web transport wiring — the per-host counterpart of apps/code's Providers.tsx.
// @posthog/ui consumes the HOST router context (useHostTRPCClient), so web only
// needs HostTRPCProvider over the HTTP client. No electron TrpcRouter context.
Expand All @@ -15,11 +19,14 @@ export const Providers: React.FC<{ children: React.ReactNode }> = ({
}) => {
return (
<HotkeysProvider>
<QueryClientProvider client={queryClient}>
<PersistQueryClientProvider
client={queryClient}
persistOptions={persistOptions}
>
<HostTRPCProvider trpcClient={hostTrpcClient} queryClient={queryClient}>
<ThemeWrapper>{children}</ThemeWrapper>
</HostTRPCProvider>
</QueryClientProvider>
</PersistQueryClientProvider>
</HotkeysProvider>
);
};
12 changes: 11 additions & 1 deletion apps/web/src/web-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,17 @@ interface WebBindings {
[MCP_SANDBOX_PROXY_URL]: McpSandboxProxyUrlProvider;
}

export const queryClient = new QueryClient();
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5,
// Match the persist maxAge so PersistQueryClientProvider can restore the
// canvas cache on reload (see queryPersistence.CANVAS_PERSIST_MAX_AGE).
gcTime: 1000 * 60 * 60 * 24,
refetchOnWindowFocus: true,
},
},
});

export const container = new TypedContainer<WebBindings>({
defaultScope: "Singleton",
Expand Down
9 changes: 9 additions & 0 deletions apps/web/src/web-persister.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { CANVAS_PERSIST_KEY } from "@posthog/ui/shell/queryPersistence";
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";

// Web persists the query cache to localStorage (synchronous, always available
// in the browser). The desktop host uses an async electron-backed persister.
export const queryPersister = createSyncStoragePersister({
key: CANVAS_PERSIST_KEY,
storage: window.localStorage,
});
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,5 +60,10 @@
"biome check --write --unsafe --files-ignore-unknown=true --no-errors-on-unmatched",
"bash -c 'pnpm typecheck'"
]
},
"pnpm": {
"overrides": {
"@tanstack/query-core": "5.90.20"
}
}
}
5 changes: 5 additions & 0 deletions packages/core/src/canvas/channelTaskSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ export type ChannelTaskRecord = z.infer<typeof channelTaskRecordSchema>;

export const listChannelTasksInput = z.object({
channelId: z.string().min(1),
// The channel folder's file-system path, if the caller already knows it (it
// rides on the channel row from `useChannels`). Lets the service skip the
// extra getEntry round-trip that resolves channelId → path. Optional so older
// callers and tests still work; the service falls back to resolving it.
channelPath: z.string().optional(),
});

export const fileChannelTaskInput = z.object({
Expand Down
73 changes: 73 additions & 0 deletions packages/core/src/canvas/channelTasksService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, expect, it, vi } from "vitest";
import { ChannelTasksService } from "./channelTasksService";
import type { DesktopFsClient, FsEntryBase } from "./desktopFsClient";

// A task FS row carrying a `ref` (the task id) under the channel folder, as the
// backend returns it from a parent-scoped list.
function taskRow(id: string, taskId: string, createdAt: string): FsEntryBase {
return {
id,
path: `Channels/chan-1/${id}`,
type: "task",
ref: taskId,
created_at: createdAt,
} as FsEntryBase;
}

// A fake DesktopFsClient exposing the two methods `list` touches: getEntry (to
// resolve the channel folder path) and fetch (the parent-scoped list GET).
function fakeFs(rows: FsEntryBase[]) {
const fetch = vi.fn(async (_suffix: string) => ({
ok: true,
status: 200,
json: async () => ({ results: rows }),
}));
const getEntry = vi.fn(async (id: string) => ({
id,
path: `Channels/${id}`,
}));
const fs = { getEntry, fetch } as unknown as DesktopFsClient;
return { fs, fetch, getEntry };
}

describe("ChannelTasksService.list", () => {
it.each([
{
name: "skips getEntry when a channelPath is supplied",
channelPath: "marketing/team" as string | undefined,
getEntryCalls: 0,
},
{
name: "resolves the path via getEntry when none is supplied",
channelPath: undefined as string | undefined,
getEntryCalls: 1,
},
])("$name", async ({ channelPath, getEntryCalls }) => {
const { fs, fetch, getEntry } = fakeFs([]);
const service = new ChannelTasksService(fs);

await service.list("chan-1", channelPath);

expect(getEntry).toHaveBeenCalledTimes(getEntryCalls);
// The list GET filters by task type under the resolved/supplied path.
const [suffix] = fetch.mock.calls[0];
expect(suffix).toContain(
encodeURIComponent(channelPath ?? "Channels/chan-1"),
);
expect(suffix).toContain("type=task");
});

it("maps rows to records sorted by createdAt descending, dropping ref-less rows", async () => {
const { fs } = fakeFs([
taskRow("a", "task-a", "2026-01-01T00:00:00Z"),
taskRow("c", "task-c", "2026-01-03T00:00:00Z"),
{ id: "no-ref", path: "Channels/chan-1/x", type: "task" } as FsEntryBase,
taskRow("b", "task-b", "2026-01-02T00:00:00Z"),
]);
const service = new ChannelTasksService(fs);

const result = await service.list("chan-1", "Channels/chan-1");

expect(result.map((r) => r.taskId)).toEqual(["task-c", "task-b", "task-a"]);
});
});
9 changes: 7 additions & 2 deletions packages/core/src/canvas/channelTasksService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,13 @@ export class ChannelTasksService {
private readonly fs: DesktopFsClient,
) {}

async list(channelId: string): Promise<ChannelTaskRecord[]> {
const channelPath = await this.channelPath(channelId);
async list(
channelId: string,
knownChannelPath?: string,
): Promise<ChannelTaskRecord[]> {
// The caller usually already knows the channel path (it rides on the channel
// row from useChannels), so accept it to skip the getEntry resolve.
const channelPath = knownChannelPath ?? (await this.channelPath(channelId));
const entries = await this.listUnderParent(channelPath);
return entries
.filter((e) => !!e.ref)
Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/canvas/dashboardSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,14 @@ export const dashboardSummarySchema = z.object({
});
export type DashboardSummary = z.infer<typeof dashboardSummarySchema>;

export const listDashboardsInput = z.object({ channelId: z.string().min(1) });
export const listDashboardsInput = z.object({
channelId: z.string().min(1),
// The channel folder's file-system path, if the caller already knows it (it
// rides on the channel row from `useChannels`). Lets the service skip the
// extra getEntry round-trip that resolves channelId → path. Optional so older
// callers and tests still work; the service falls back to resolving it.
channelPath: z.string().optional(),
});

export const createDashboardInput = z.object({
channelId: z.string().min(1),
Expand Down
32 changes: 30 additions & 2 deletions packages/core/src/canvas/dashboardsService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,15 @@ function fakeFs(rows: FsEntryBase[]) {
const listByQuery = vi.fn(
async (_query: string, _errorLabel: string): Promise<FsEntryBase[]> => rows,
);
const getEntry = vi.fn(async (id: string) => ({
id,
path: `Channels/${id}`,
}));
const fs = {
getEntry: async (id: string) => ({ id, path: `Channels/${id}` }),
getEntry,
listByQuery,
};
return { fs: fs as unknown as DesktopFsClient, listByQuery };
return { fs: fs as unknown as DesktopFsClient, listByQuery, getEntry };
}

describe("DashboardsService.list", () => {
Expand All @@ -51,6 +55,30 @@ describe("DashboardsService.list", () => {
expect(query).toContain("type=dashboard");
});

it.each([
{
name: "skips getEntry when a channelPath is supplied",
channelPath: "marketing/team" as string | undefined,
getEntryCalls: 0,
},
{
name: "resolves the path via getEntry when none is supplied",
channelPath: undefined as string | undefined,
getEntryCalls: 1,
},
])("$name", async ({ channelPath, getEntryCalls }) => {
const { fs, listByQuery, getEntry } = fakeFs([]);
const service = new DashboardsService(fs, {} as never);

await service.list("chan-1", channelPath);

expect(getEntry).toHaveBeenCalledTimes(getEntryCalls);
const [query] = listByQuery.mock.calls[0];
expect(query).toContain(
encodeURIComponent(channelPath ?? "Channels/chan-1"),
);
});

it("maps rows to summaries sorted by updatedAt descending", async () => {
const { fs } = fakeFs([
dashboardRow("a", "Older", "chan-1", 100),
Expand Down
9 changes: 7 additions & 2 deletions packages/core/src/canvas/dashboardsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,17 @@ export class DashboardsService {
return this.fs.getEntry<FsEntry>(id, "dashboard");
}

async list(channelId: string): Promise<DashboardSummary[]> {
async list(
channelId: string,
knownChannelPath?: string,
): Promise<DashboardSummary[]> {
// Fetch only this channel's dashboards via a server-side filter
// (`parent=<channelPath>&type=dashboard`) rather than walking the whole
// project file system and filtering client-side. Dashboards are created as
// direct children of the channel folder, so the parent filter matches them.
const channelPath = await this.channelPath(channelId);
// The caller usually already knows the channel path (it rides on the channel
// row from useChannels), so accept it to skip the getEntry resolve.
const channelPath = knownChannelPath ?? (await this.channelPath(channelId));
const entries = await this.fs.listByQuery<FsEntry>(
`parent=${encodeURIComponent(channelPath)}&type=${DASHBOARD_TYPE}`,
"dashboards",
Expand Down
8 changes: 6 additions & 2 deletions packages/core/src/canvas/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ export interface ICanvasTemplatesService {
}

export interface IDashboardsService {
list(channelId: string): Promise<DashboardSummary[]>;
// channelPath is an optional optimization: when the caller already knows the
// channel folder's path, the service skips the getEntry round-trip.
list(channelId: string, channelPath?: string): Promise<DashboardSummary[]>;
get(id: string): Promise<DashboardRecord | null>;
create(input: {
channelId: string;
Expand Down Expand Up @@ -61,7 +63,9 @@ export interface ICanvasDataService {
}

export interface IChannelTasksService {
list(channelId: string): Promise<ChannelTaskRecord[]>;
// channelPath is an optional optimization: when the caller already knows the
// channel folder's path, the service skips the getEntry round-trip.
list(channelId: string, channelPath?: string): Promise<ChannelTaskRecord[]>;
file(input: {
channelId: string;
taskId: string;
Expand Down
2 changes: 1 addition & 1 deletion packages/host-router/src/routers/channel-tasks.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export const channelTasksRouter = router({
.query(({ ctx, input }) =>
ctx.container
.get<IChannelTasksService>(CHANNEL_TASKS_SERVICE)
.list(input.channelId),
.list(input.channelId, input.channelPath),
),
file: publicProcedure
.input(fileChannelTaskInput)
Expand Down
Loading
Loading