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
5 changes: 5 additions & 0 deletions apps/code/src/main/di/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,13 @@ import type {
CANVAS_LINK_SERVICE,
INBOX_LINK_SERVICE,
NEW_TASK_LINK_SERVICE,
OPEN_TARGET_LINK_SERVICE,
SCOUT_LINK_SERVICE,
TASK_LINK_SERVICE,
} from "@posthog/core/links/identifiers";
import type { InboxLinkService } from "@posthog/core/links/inbox-link";
import type { NewTaskLinkService } from "@posthog/core/links/new-task-link";
import type { OpenTargetLinkService } from "@posthog/core/links/open-target-link";
import type { ScoutLinkService } from "@posthog/core/links/scout-link";
import type { TaskLinkService } from "@posthog/core/links/task-link";
import type {
Expand Down Expand Up @@ -257,6 +259,7 @@ import type {
LLM_GATEWAY_SERVICE as MAIN_LLM_GATEWAY_SERVICE,
MCP_APPS_SERVICE as MAIN_MCP_APPS_SERVICE,
NEW_TASK_LINK_SERVICE as MAIN_NEW_TASK_LINK_SERVICE,
OPEN_TARGET_LINK_SERVICE as MAIN_OPEN_TARGET_LINK_SERVICE,
POSTHOG_PLUGIN_SERVICE as MAIN_POSTHOG_PLUGIN_SERVICE,
PROCESS_TRACKING_SERVICE as MAIN_PROCESS_TRACKING_SERVICE,
PROVISIONING_SERVICE as MAIN_PROVISIONING_SERVICE,
Expand Down Expand Up @@ -408,12 +411,14 @@ export interface MainBindings {
[MAIN_SCOUT_LINK_SERVICE]: ScoutLinkService;
[MAIN_NEW_TASK_LINK_SERVICE]: NewTaskLinkService;
[MAIN_APPROVAL_LINK_SERVICE]: ApprovalLinkService;
[MAIN_OPEN_TARGET_LINK_SERVICE]: OpenTargetLinkService;
[MAIN_CANVAS_LINK_SERVICE]: CanvasLinkService;
[TASK_LINK_SERVICE]: TaskLinkService;
[INBOX_LINK_SERVICE]: InboxLinkService;
[SCOUT_LINK_SERVICE]: ScoutLinkService;
[NEW_TASK_LINK_SERVICE]: NewTaskLinkService;
[APPROVAL_LINK_SERVICE]: ApprovalLinkService;
[OPEN_TARGET_LINK_SERVICE]: OpenTargetLinkService;
[CANVAS_LINK_SERVICE]: CanvasLinkService;

// Watcher registry
Expand Down
7 changes: 7 additions & 0 deletions apps/code/src/main/di/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,13 @@ import {
CANVAS_LINK_SERVICE,
INBOX_LINK_SERVICE,
NEW_TASK_LINK_SERVICE,
OPEN_TARGET_LINK_SERVICE,
SCOUT_LINK_SERVICE,
TASK_LINK_SERVICE,
} from "@posthog/core/links/identifiers";
import { InboxLinkService } from "@posthog/core/links/inbox-link";
import { NewTaskLinkService } from "@posthog/core/links/new-task-link";
import { OpenTargetLinkService } from "@posthog/core/links/open-target-link";
import { ScoutLinkService } from "@posthog/core/links/scout-link";
import { TaskLinkService } from "@posthog/core/links/task-link";
import {
Expand Down Expand Up @@ -270,6 +272,7 @@ import {
LLM_GATEWAY_SERVICE as MAIN_LLM_GATEWAY_SERVICE,
MCP_APPS_SERVICE as MAIN_MCP_APPS_SERVICE,
NEW_TASK_LINK_SERVICE as MAIN_NEW_TASK_LINK_SERVICE,
OPEN_TARGET_LINK_SERVICE as MAIN_OPEN_TARGET_LINK_SERVICE,
POSTHOG_PLUGIN_SERVICE as MAIN_POSTHOG_PLUGIN_SERVICE,
PROCESS_TRACKING_SERVICE as MAIN_PROCESS_TRACKING_SERVICE,
PROVISIONING_SERVICE as MAIN_PROVISIONING_SERVICE,
Expand Down Expand Up @@ -626,6 +629,10 @@ container.bind(MAIN_NEW_TASK_LINK_SERVICE).to(NewTaskLinkService);
container.bind(NEW_TASK_LINK_SERVICE).toService(MAIN_NEW_TASK_LINK_SERVICE);
container.bind(MAIN_APPROVAL_LINK_SERVICE).to(ApprovalLinkService);
container.bind(APPROVAL_LINK_SERVICE).toService(MAIN_APPROVAL_LINK_SERVICE);
container.bind(MAIN_OPEN_TARGET_LINK_SERVICE).to(OpenTargetLinkService);
container
.bind(OPEN_TARGET_LINK_SERVICE)
.toService(MAIN_OPEN_TARGET_LINK_SERVICE);
container.bind(MAIN_CANVAS_LINK_SERVICE).to(CanvasLinkService);
container.bind(CANVAS_LINK_SERVICE).toService(MAIN_CANVAS_LINK_SERVICE);
container.load(watcherRegistryModule);
Expand Down
3 changes: 3 additions & 0 deletions apps/code/src/main/di/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ export const NEW_TASK_LINK_SERVICE = Symbol.for(
export const APPROVAL_LINK_SERVICE = Symbol.for(
"posthog.host.main.approval-link.service",
);
export const OPEN_TARGET_LINK_SERVICE = Symbol.for(
"posthog.host.main.open-target-link.service",
);
export const CANVAS_LINK_SERVICE = Symbol.for(
"posthog.host.main.canvas-link.service",
);
Expand Down
30 changes: 26 additions & 4 deletions apps/code/src/renderer/desktop-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { ROOT_LOGGER, type RootLogger } from "@posthog/di/logger";
import {
type INotifications,
NOTIFICATIONS_SERVICE,
type NotificationTarget,
} from "@posthog/platform/notifications";
import type { CloudRegion } from "@posthog/shared";
import {
Expand Down Expand Up @@ -72,7 +73,7 @@ import {
type AgentPromptSender,
} from "@posthog/ui/features/sessions/agentPromptSender";
import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore";
import { getAppViewSnapshot } from "@posthog/ui/router/useAppView";
import { getCurrentMatches } from "@posthog/ui/router/navigationBridge";
import { HEDGEHOG_MODE_HOST } from "@posthog/ui/shell/hedgehogModeHost";
import { posthogFeatureFlags } from "@posthog/ui/shell/posthogAnalyticsImpl";
import { IMPERATIVE_QUERY_CLIENT } from "@posthog/ui/shell/queryClient";
Expand Down Expand Up @@ -207,9 +208,30 @@ container

container.bind<IActiveView>(ACTIVE_VIEW_PROVIDER).toConstantValue({
hasFocus: () => document.hasFocus(),
getActiveTaskId: () => {
const view = getAppViewSnapshot();
return view.type === "task-detail" ? view.taskId : undefined;
// Read the active leaf route directly: AppView collapses the channel routes
// and drops channelId/dashboardId, which we need to identify a canvas target.
getActiveTarget: (): NotificationTarget | undefined => {
const matches = getCurrentMatches();
const last = matches[matches.length - 1];
if (!last) return undefined;
const params = last.params as Record<string, string | undefined>;
switch (last.routeId) {
case "/code/tasks/$taskId":
case "/website/$channelId/tasks/$taskId":
return params.taskId
? { kind: "task", taskId: params.taskId }
: undefined;
case "/website/$channelId/dashboards/$dashboardId":
return params.channelId && params.dashboardId
? {
kind: "canvas",
channelId: params.channelId,
dashboardId: params.dashboardId,
}
: undefined;
default:
return undefined;
}
},
});

Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/links/identifiers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,10 @@ export const NEW_TASK_LINK_SERVICE = Symbol.for(
export const APPROVAL_LINK_SERVICE = Symbol.for(
"posthog.core.approvalLinkService",
);
// Carries notification-click "open this target" intent from main → renderer.
// Unlike the link services above, it registers no OS URL-scheme handler — it
// exists purely so a clicked native notification can navigate to its target.
export const OPEN_TARGET_LINK_SERVICE = Symbol.for(
"posthog.core.openTargetLinkService",
);
export const CANVAS_LINK_SERVICE = Symbol.for("posthog.core.canvasLinkService");
63 changes: 63 additions & 0 deletions packages/core/src/links/open-target-link.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { ROOT_LOGGER, type RootLogger } from "@posthog/di/logger";
import {
type IMainWindow,
MAIN_WINDOW_SERVICE,
} from "@posthog/platform/main-window";
import type { NotificationTarget } from "@posthog/platform/notifications";
import { TypedEventEmitter } from "@posthog/shared";
import { inject, injectable } from "inversify";
import type { LinkLogger } from "./identifiers";

export const OpenTargetLinkEvent = {
Open: "open",
} as const;

export interface OpenTargetLinkEvents {
[OpenTargetLinkEvent.Open]: NotificationTarget;
}

// Carries "open this target" intents (from a clicked native notification) out of
// the main process to the renderer, which navigates by target kind. Mirrors
// TaskLinkService's pending-replay + window-focus, but registers NO OS
// URL-scheme handler — notification clicks are its only source, so it stays
// target-generic without entangling URL parsing.
@injectable()
export class OpenTargetLinkService extends TypedEventEmitter<OpenTargetLinkEvents> {
private pending: NotificationTarget | null = null;
private readonly log: LinkLogger;

constructor(
@inject(MAIN_WINDOW_SERVICE)
private readonly mainWindow: IMainWindow,
@inject(ROOT_LOGGER)
rootLogger: RootLogger,
) {
super();
this.log = rootLogger.scope("open-target-link-service");
}

// Called from the notification click handler (main process). Emits to the
// renderer if it's listening, else queues for replay once it subscribes.
open(target: NotificationTarget): void {
if (this.listenerCount(OpenTargetLinkEvent.Open) > 0) {
this.log.info("Emitting open-target event", { kind: target.kind });
this.emit(OpenTargetLinkEvent.Open, target);
} else {
this.log.info("Queueing open-target (renderer not ready)", {
kind: target.kind,
});
this.pending = target;
}

if (this.mainWindow.isMinimized()) {
this.mainWindow.restore();
}
this.mainWindow.focus();
Comment thread
adamleithp marked this conversation as resolved.
}

consumePending(): NotificationTarget | null {
const pending = this.pending;
this.pending = null;
return pending;
}
}
24 changes: 11 additions & 13 deletions packages/core/src/notification/notification.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { INotifier, NotifyOptions } from "@posthog/platform/notifier";
import { describe, expect, it, vi } from "vitest";
import { TaskLinkEvent } from "../links/task-link";
import { NotificationService } from "./notification";

function makeLogger() {
Expand Down Expand Up @@ -35,10 +34,10 @@ function createDeps(supported = true) {
focus: vi.fn(),
};

const taskLinkService = { emit: vi.fn() };
const openTargetLink = { open: vi.fn() };

const service = new NotificationService(
taskLinkService as never,
openTargetLink as never,
notifier,
mainWindow as never,
makeLogger(),
Expand All @@ -48,7 +47,7 @@ function createDeps(supported = true) {
service,
notifier,
mainWindow,
taskLinkService,
openTargetLink,
getLastNotify: () => lastNotify,
getFocusHandler: () => focusHandler,
};
Expand Down Expand Up @@ -82,24 +81,23 @@ describe("NotificationService.send", () => {
expect(mainWindow.focus).toHaveBeenCalled();
});

it("emits OpenTask on click when a taskId is provided", () => {
const { service, taskLinkService, getLastNotify } = createDeps();
it("opens the target on click when one is provided", () => {
const { service, openTargetLink, getLastNotify } = createDeps();

service.send("Title", "Body", false, "task-9");
const target = { kind: "task" as const, taskId: "task-9" };
service.send("Title", "Body", false, target);
getLastNotify()?.onClick?.();

expect(taskLinkService.emit).toHaveBeenCalledWith(TaskLinkEvent.OpenTask, {
taskId: "task-9",
});
expect(openTargetLink.open).toHaveBeenCalledWith(target);
});

it("does not emit OpenTask on click without a taskId", () => {
const { service, taskLinkService, getLastNotify } = createDeps();
it("does not open a target on click when none is provided", () => {
const { service, openTargetLink, getLastNotify } = createDeps();

service.send("Title", "Body", false);
getLastNotify()?.onClick?.();

expect(taskLinkService.emit).not.toHaveBeenCalled();
expect(openTargetLink.open).not.toHaveBeenCalled();
});
});

Expand Down
30 changes: 20 additions & 10 deletions packages/core/src/notification/notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,20 @@ import {
type IMainWindow,
MAIN_WINDOW_SERVICE,
} from "@posthog/platform/main-window";
import type { NotificationTarget } from "@posthog/platform/notifications";
import { type INotifier, NOTIFIER_SERVICE } from "@posthog/platform/notifier";
import { inject, injectable, postConstruct } from "inversify";
import { TASK_LINK_SERVICE } from "../links/identifiers";
import { TaskLinkEvent, type TaskLinkService } from "../links/task-link";
import { OPEN_TARGET_LINK_SERVICE } from "../links/identifiers";
import type { OpenTargetLinkService } from "../links/open-target-link";

@injectable()
export class NotificationService {
private hasBadge = false;
private readonly log: ScopedLogger;

constructor(
@inject(TASK_LINK_SERVICE)
private readonly taskLinkService: TaskLinkService,
@inject(OPEN_TARGET_LINK_SERVICE)
private readonly openTargetLink: OpenTargetLinkService,
@inject(NOTIFIER_SERVICE)
private readonly notifier: INotifier,
@inject(MAIN_WINDOW_SERVICE)
Expand All @@ -35,7 +36,12 @@ export class NotificationService {
this.mainWindow.onFocus(() => this.clearDockBadge());
}

send(title: string, body: string, silent: boolean, taskId?: string): void {
send(
title: string,
body: string,
silent: boolean,
target?: NotificationTarget,
): void {
if (!this.notifier.isSupported()) {
this.log.warn("Notifications not supported on this platform");
return;
Expand All @@ -48,20 +54,24 @@ export class NotificationService {
onClick: () => {
this.log.info("Notification clicked, focusing window", {
title,
taskId,
target: target?.kind,
});
if (this.mainWindow.isMinimized()) {
this.mainWindow.restore();
}
this.mainWindow.focus();

if (taskId) {
this.taskLinkService.emit(TaskLinkEvent.OpenTask, { taskId });
this.log.info("Notification clicked, navigating to task", { taskId });
if (target) {
// Window focus is handled inside open(); we still focus above so a
// targetless notification raises the app too.
this.openTargetLink.open(target);
this.log.info("Notification clicked, navigating to target", {
kind: target.kind,
});
}
},
});
this.log.info("Notification sent", { title, body, silent, taskId });
this.log.info("Notification sent", { title, body, silent, target });
}

showDockBadge(): void {
Expand Down
28 changes: 28 additions & 0 deletions packages/host-router/src/routers/deep-link.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
CANVAS_LINK_SERVICE,
INBOX_LINK_SERVICE,
NEW_TASK_LINK_SERVICE,
OPEN_TARGET_LINK_SERVICE,
SCOUT_LINK_SERVICE,
TASK_LINK_SERVICE,
} from "@posthog/core/links/identifiers";
Expand All @@ -26,6 +27,10 @@ import {
type NewTaskLinkPayload,
type NewTaskLinkService,
} from "@posthog/core/links/new-task-link";
import {
OpenTargetLinkEvent,
type OpenTargetLinkService,
} from "@posthog/core/links/open-target-link";
import {
ScoutLinkEvent,
type ScoutLinkPayload,
Expand All @@ -37,6 +42,7 @@ import {
type TaskLinkService,
} from "@posthog/core/links/task-link";
import { publicProcedure, router } from "@posthog/host-trpc/trpc";
import type { NotificationTarget } from "@posthog/platform/notifications";

export const deepLinkRouter = router({
onOpenTask: publicProcedure.subscription(async function* (opts) {
Expand Down Expand Up @@ -135,6 +141,28 @@ export const deepLinkRouter = router({
},
),

// Generic "open this target" intents from clicked native notifications. The
// renderer subscribes and navigates by target kind (task / canvas / …).
onOpenTarget: publicProcedure.subscription(async function* (opts) {
const service = opts.ctx.container.get<OpenTargetLinkService>(
OPEN_TARGET_LINK_SERVICE,
);
const iterable = service.toIterable(OpenTargetLinkEvent.Open, {
signal: opts.signal,
});
for await (const data of iterable) {
yield data;
}
}),

getPendingOpenTarget: publicProcedure.query(
({ ctx }): NotificationTarget | null => {
return ctx.container
.get<OpenTargetLinkService>(OPEN_TARGET_LINK_SERVICE)
.consumePending();
},
),

onOpenCanvas: publicProcedure.subscription(async function* (opts) {
const service =
opts.ctx.container.get<CanvasLinkService>(CANVAS_LINK_SERVICE);
Expand Down
Loading
Loading