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
172 changes: 172 additions & 0 deletions apps/code/src/main/services/task-link/service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import type { IMainWindow } from "@posthog/platform/main-window";
import { beforeEach, describe, expect, it, vi } from "vitest";

vi.mock("../../utils/logger.js", () => ({
logger: {
scope: () => ({
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
}),
},
}));

import type { DeepLinkHandler, DeepLinkService } from "../deep-link/service";
import { TaskLinkEvent, TaskLinkService } from "./service";

function makeDeepLinkService() {
const handlers = new Map<string, DeepLinkHandler>();
const service = {
registerHandler: vi.fn((key: string, handler: DeepLinkHandler) => {
handlers.set(key, handler);
}),
trigger: (key: string, path: string, query?: string) => {
const handler = handlers.get(key);
if (!handler) throw new Error(`No handler for ${key}`);
return handler(path, new URLSearchParams(query ?? ""));
},
};
return service as unknown as DeepLinkService & {
trigger: (key: string, path: string, query?: string) => boolean;
};
}

function makeMainWindow() {
return {
focus: vi.fn(),
restore: vi.fn(),
isMinimized: vi.fn().mockReturnValue(false),
} as unknown as IMainWindow & {
focus: ReturnType<typeof vi.fn>;
restore: ReturnType<typeof vi.fn>;
isMinimized: ReturnType<typeof vi.fn>;
};
}

describe("TaskLinkService", () => {
let deepLinkService: ReturnType<typeof makeDeepLinkService>;
let mainWindow: ReturnType<typeof makeMainWindow>;
let service: TaskLinkService;

beforeEach(() => {
deepLinkService = makeDeepLinkService();
mainWindow = makeMainWindow();
service = new TaskLinkService(deepLinkService, mainWindow);
});

describe("task handler", () => {
it("registers a 'task' handler on the DeepLinkService", () => {
expect(deepLinkService.registerHandler).toHaveBeenCalledWith(
"task",
expect.any(Function),
);
});

it("emits OpenTask with just a task id", () => {
const listener = vi.fn();
service.on(TaskLinkEvent.OpenTask, listener);

const result = deepLinkService.trigger("task", "abc-123");

expect(result).toBe(true);
expect(listener).toHaveBeenCalledWith({
taskId: "abc-123",
taskRunId: undefined,
});
});

it("parses /run/<id> into taskRunId", () => {
const listener = vi.fn();
service.on(TaskLinkEvent.OpenTask, listener);

deepLinkService.trigger("task", "abc-123/run/xyz-789");

expect(listener).toHaveBeenCalledWith({
taskId: "abc-123",
taskRunId: "xyz-789",
});
});

it("queues a pending deep link when no listener is attached", () => {
deepLinkService.trigger("task", "abc-123/run/xyz-789");

expect(service.consumePendingDeepLink()).toEqual({
taskId: "abc-123",
taskRunId: "xyz-789",
});
expect(service.consumePendingDeepLink()).toBeNull();
});

it("returns false when path is empty", () => {
const result = deepLinkService.trigger("task", "");
expect(result).toBe(false);
});
});

describe("new task handler", () => {
it("registers a 'new' handler on the DeepLinkService", () => {
expect(deepLinkService.registerHandler).toHaveBeenCalledWith(
"new",
expect.any(Function),
);
});

it("emits CreateTask with the prompt query param", () => {
const listener = vi.fn();
service.on(TaskLinkEvent.CreateTask, listener);

const result = deepLinkService.trigger(
"new",
"",
"prompt=fix%20the%20bug",
);

expect(result).toBe(true);
expect(listener).toHaveBeenCalledWith({ prompt: "fix the bug" });
});

it("emits CreateTask with no prompt when query is empty", () => {
const listener = vi.fn();
service.on(TaskLinkEvent.CreateTask, listener);

deepLinkService.trigger("new", "");

expect(listener).toHaveBeenCalledWith({ prompt: undefined });
});

it("treats whitespace-only prompts as undefined", () => {
const listener = vi.fn();
service.on(TaskLinkEvent.CreateTask, listener);

deepLinkService.trigger("new", "", "prompt=%20%20%20");

expect(listener).toHaveBeenCalledWith({ prompt: undefined });
});

it("queues a pending new-task link when no listener is attached", () => {
deepLinkService.trigger("new", "", "prompt=hello");

expect(service.consumePendingNewTaskDeepLink()).toEqual({
prompt: "hello",
});
expect(service.consumePendingNewTaskDeepLink()).toBeNull();
});

it("focuses the main window", () => {
deepLinkService.trigger("new", "", "prompt=hello");

expect(mainWindow.focus).toHaveBeenCalledTimes(1);
expect(mainWindow.restore).not.toHaveBeenCalled();
});

it("restores the main window when minimized", () => {
mainWindow.isMinimized.mockReturnValue(true);

deepLinkService.trigger("new", "");

expect(mainWindow.restore).toHaveBeenCalledTimes(1);
expect(mainWindow.focus).toHaveBeenCalledTimes(1);
});
});
});
49 changes: 49 additions & 0 deletions apps/code/src/main/services/task-link/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,31 @@ const log = logger.scope("task-link-service");

export const TaskLinkEvent = {
OpenTask: "openTask",
CreateTask: "createTask",
} as const;

export interface TaskLinkEvents {
[TaskLinkEvent.OpenTask]: { taskId: string; taskRunId?: string };
[TaskLinkEvent.CreateTask]: { prompt?: string };
}

export interface PendingDeepLink {
taskId: string;
taskRunId?: string;
}

export interface PendingNewTaskDeepLink {
prompt?: string;
}

@injectable()
export class TaskLinkService extends TypedEventEmitter<TaskLinkEvents> {
/**
* Pending deep link that was received before renderer was ready.
* This handles the case where the app is launched via deep link.
*/
private pendingDeepLink: PendingDeepLink | null = null;
private pendingNewTaskDeepLink: PendingNewTaskDeepLink | null = null;

constructor(
@inject(MAIN_TOKENS.DeepLinkService)
Expand All @@ -39,6 +46,9 @@ export class TaskLinkService extends TypedEventEmitter<TaskLinkEvents> {
this.deepLinkService.registerHandler("task", (path) =>
this.handleTaskLink(path),
);
this.deepLinkService.registerHandler("new", (_path, searchParams) =>
this.handleNewTaskLink(searchParams),
);
}

private handleTaskLink(path: string): boolean {
Expand Down Expand Up @@ -80,6 +90,34 @@ export class TaskLinkService extends TypedEventEmitter<TaskLinkEvents> {
return true;
}

private handleNewTaskLink(searchParams: URLSearchParams): boolean {
// posthog-code://new?prompt=...
const rawPrompt = searchParams.get("prompt");
const prompt = rawPrompt?.trim() ? rawPrompt : undefined;

const hasListeners = this.listenerCount(TaskLinkEvent.CreateTask) > 0;

if (hasListeners) {
log.info(
`Emitting create task event: hasPrompt=${prompt !== undefined}, promptLength=${prompt?.length ?? 0}`,
);
this.emit(TaskLinkEvent.CreateTask, { prompt });
} else {
log.info(
`Queueing create task link (renderer not ready): hasPrompt=${prompt !== undefined}`,
);
this.pendingNewTaskDeepLink = { prompt };
}

log.info("Deep link focusing window for new task");
if (this.mainWindow.isMinimized()) {
this.mainWindow.restore();
}
this.mainWindow.focus();

return true;
}

/**
* Get and clear any pending deep link.
* Called by renderer on mount to handle deep links that arrived before it was ready.
Expand All @@ -94,4 +132,15 @@ export class TaskLinkService extends TypedEventEmitter<TaskLinkEvents> {
}
return pending;
}

public consumePendingNewTaskDeepLink(): PendingNewTaskDeepLink | null {
const pending = this.pendingNewTaskDeepLink;
this.pendingNewTaskDeepLink = null;
if (pending) {
log.info(
`Consumed pending new task link: hasPrompt=${pending.prompt !== undefined}`,
);
}
return pending;
}
}
25 changes: 25 additions & 0 deletions apps/code/src/main/trpc/routers/deep-link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from "../../services/inbox-link/service";
import {
type PendingDeepLink,
type PendingNewTaskDeepLink,
TaskLinkEvent,
type TaskLinkService,
} from "../../services/task-link/service";
Expand Down Expand Up @@ -43,6 +44,30 @@ export const deepLinkRouter = router({
return service.consumePendingDeepLink();
}),

/**
* Subscribe to new-task deep link events.
* Emits an optional prompt when posthog-code://new?prompt=... is opened.
*/
onCreateTask: publicProcedure.subscription(async function* (opts) {
const service = getTaskLinkService();
const iterable = service.toIterable(TaskLinkEvent.CreateTask, {
signal: opts.signal,
});
for await (const data of iterable) {
yield data;
}
}),

/**
* Get any pending new-task deep link that arrived before renderer was ready.
*/
getPendingNewTaskLink: publicProcedure.query(
(): PendingNewTaskDeepLink | null => {
const service = getTaskLinkService();
return service.consumePendingNewTaskDeepLink();
},
),

/**
* Subscribe to inbox report deep link events.
* Emits report ID when posthog-code://inbox/{reportId} is opened.
Expand Down
47 changes: 47 additions & 0 deletions apps/code/src/renderer/hooks/useTaskDeepLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,16 @@ const taskKeys = {
export function useTaskDeepLink() {
const trpcReact = useTRPC();
const navigateToTask = useNavigationStore((state) => state.navigateToTask);
const navigateToTaskInput = useNavigationStore(
(state) => state.navigateToTaskInput,
);
const { markAsViewed } = useTaskViewed();
const queryClient = useQueryClient();
const isAuthenticated = useAuthStateValue(
(state) => state.status === "authenticated",
);
const hasFetchedPending = useRef(false);
const hasFetchedPendingNewTask = useRef(false);

const handleOpenTask = useCallback(
async (taskId: string, taskRunId?: string) => {
Expand Down Expand Up @@ -87,6 +91,16 @@ export function useTaskDeepLink() {
[navigateToTask, markAsViewed, queryClient],
);

const handleCreateTask = useCallback(
(prompt: string | undefined) => {
log.info(
`Opening new task input from deep link: hasPrompt=${prompt !== undefined}`,
);
navigateToTaskInput({ initialPrompt: prompt });
},
[navigateToTaskInput],
);

// Check for pending deep link on mount (for cold start via deep link)
useEffect(() => {
if (!isAuthenticated || hasFetchedPending.current) return;
Expand All @@ -109,6 +123,28 @@ export function useTaskDeepLink() {
fetchPending();
}, [isAuthenticated, handleOpenTask]);

// Check for pending new-task deep link on mount
useEffect(() => {
if (!isAuthenticated || hasFetchedPendingNewTask.current) return;

const fetchPending = async () => {
hasFetchedPendingNewTask.current = true;
try {
const pending = await trpcClient.deepLink.getPendingNewTaskLink.query();
if (pending) {
log.info(
`Found pending new-task deep link: hasPrompt=${pending.prompt !== undefined}`,
);
handleCreateTask(pending.prompt);
}
} catch (error) {
log.error("Failed to check for pending new-task deep link:", error);
}
};

fetchPending();
}, [isAuthenticated, handleCreateTask]);

// Subscribe to deep link events (for warm start via deep link)
useSubscription(
trpcReact.deepLink.onOpenTask.subscriptionOptions(undefined, {
Expand All @@ -121,4 +157,15 @@ export function useTaskDeepLink() {
},
}),
);

useSubscription(
trpcReact.deepLink.onCreateTask.subscriptionOptions(undefined, {
onData: (data) => {
log.info(
`Received new-task deep link event: hasPrompt=${data?.prompt !== undefined}`,
);
handleCreateTask(data?.prompt);
},
}),
);
}
Loading