diff --git a/apps/code/src/main/services/task-link/service.test.ts b/apps/code/src/main/services/task-link/service.test.ts new file mode 100644 index 000000000..74d56707d --- /dev/null +++ b/apps/code/src/main/services/task-link/service.test.ts @@ -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(); + 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; + restore: ReturnType; + isMinimized: ReturnType; + }; +} + +describe("TaskLinkService", () => { + let deepLinkService: ReturnType; + let mainWindow: ReturnType; + 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/ 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); + }); + }); +}); diff --git a/apps/code/src/main/services/task-link/service.ts b/apps/code/src/main/services/task-link/service.ts index 463cf71c0..794b6d9d2 100644 --- a/apps/code/src/main/services/task-link/service.ts +++ b/apps/code/src/main/services/task-link/service.ts @@ -9,10 +9,12 @@ 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 { @@ -20,6 +22,10 @@ export interface PendingDeepLink { taskRunId?: string; } +export interface PendingNewTaskDeepLink { + prompt?: string; +} + @injectable() export class TaskLinkService extends TypedEventEmitter { /** @@ -27,6 +33,7 @@ export class TaskLinkService extends TypedEventEmitter { * 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) @@ -39,6 +46,9 @@ export class TaskLinkService extends TypedEventEmitter { this.deepLinkService.registerHandler("task", (path) => this.handleTaskLink(path), ); + this.deepLinkService.registerHandler("new", (_path, searchParams) => + this.handleNewTaskLink(searchParams), + ); } private handleTaskLink(path: string): boolean { @@ -80,6 +90,34 @@ export class TaskLinkService extends TypedEventEmitter { 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. @@ -94,4 +132,15 @@ export class TaskLinkService extends TypedEventEmitter { } 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; + } } diff --git a/apps/code/src/main/trpc/routers/deep-link.ts b/apps/code/src/main/trpc/routers/deep-link.ts index 7bde40c80..74a94f450 100644 --- a/apps/code/src/main/trpc/routers/deep-link.ts +++ b/apps/code/src/main/trpc/routers/deep-link.ts @@ -7,6 +7,7 @@ import { } from "../../services/inbox-link/service"; import { type PendingDeepLink, + type PendingNewTaskDeepLink, TaskLinkEvent, type TaskLinkService, } from "../../services/task-link/service"; @@ -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. diff --git a/apps/code/src/renderer/hooks/useTaskDeepLink.ts b/apps/code/src/renderer/hooks/useTaskDeepLink.ts index 73c0b101d..522928d2c 100644 --- a/apps/code/src/renderer/hooks/useTaskDeepLink.ts +++ b/apps/code/src/renderer/hooks/useTaskDeepLink.ts @@ -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) => { @@ -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; @@ -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, { @@ -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); + }, + }), + ); }