From 44e916d4837eb9fffedf28eff604e880a16b1a47 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sun, 3 May 2026 21:04:19 -0700 Subject: [PATCH 1/8] Add deep links v2 for task creation via URL --- apps/code/src/main/di/container.ts | 2 + apps/code/src/main/di/tokens.ts | 1 + .../main/services/new-task-link/service.ts | 190 ++++++++++++++++++ apps/code/src/main/trpc/routers/deep-link.ts | 33 +++ .../src/renderer/components/MainLayout.tsx | 2 + .../src/renderer/hooks/useNewTaskDeepLink.ts | 163 +++++++++++++++ apps/code/src/shared/types.ts | 17 ++ apps/code/src/shared/types/analytics.ts | 38 ++++ 8 files changed, 446 insertions(+) create mode 100644 apps/code/src/main/services/new-task-link/service.ts create mode 100644 apps/code/src/renderer/hooks/useNewTaskDeepLink.ts diff --git a/apps/code/src/main/di/container.ts b/apps/code/src/main/di/container.ts index 5d6a8d508d..5b1fababfe 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -53,6 +53,7 @@ import { LocalLogsService } from "../services/local-logs/service"; import { McpAppsService } from "../services/mcp-apps/service"; import { McpCallbackService } from "../services/mcp-callback/service"; import { McpProxyService } from "../services/mcp-proxy/service"; +import { NewTaskLinkService } from "../services/new-task-link/service"; import { NotificationService } from "../services/notification/service"; import { OAuthService } from "../services/oauth/service"; import { PosthogPluginService } from "../services/posthog-plugin/service"; @@ -148,6 +149,7 @@ container.bind(MAIN_TOKENS.UIService).to(UIService); container.bind(MAIN_TOKENS.UpdatesService).to(UpdatesService); container.bind(MAIN_TOKENS.TaskLinkService).to(TaskLinkService); container.bind(MAIN_TOKENS.InboxLinkService).to(InboxLinkService); +container.bind(MAIN_TOKENS.NewTaskLinkService).to(NewTaskLinkService); container.bind(MAIN_TOKENS.WatcherRegistryService).to(WatcherRegistryService); container.bind(MAIN_TOKENS.WorkspaceService).to(WorkspaceService); diff --git a/apps/code/src/main/di/tokens.ts b/apps/code/src/main/di/tokens.ts index aeade0e774..c754b589ea 100644 --- a/apps/code/src/main/di/tokens.ts +++ b/apps/code/src/main/di/tokens.ts @@ -77,6 +77,7 @@ export const MAIN_TOKENS = Object.freeze({ UpdatesService: Symbol.for("Main.UpdatesService"), TaskLinkService: Symbol.for("Main.TaskLinkService"), InboxLinkService: Symbol.for("Main.InboxLinkService"), + NewTaskLinkService: Symbol.for("Main.NewTaskLinkService"), WatcherRegistryService: Symbol.for("Main.WatcherRegistryService"), EnvironmentService: Symbol.for("Main.EnvironmentService"), ProvisioningService: Symbol.for("Main.ProvisioningService"), diff --git a/apps/code/src/main/services/new-task-link/service.ts b/apps/code/src/main/services/new-task-link/service.ts new file mode 100644 index 0000000000..dd55533fe0 --- /dev/null +++ b/apps/code/src/main/services/new-task-link/service.ts @@ -0,0 +1,190 @@ +import type { IMainWindow } from "@posthog/platform/main-window"; +import type { NewTaskLinkPayload } from "@shared/types"; +import { inject, injectable } from "inversify"; +import { MAIN_TOKENS } from "../../di/tokens"; +import { logger } from "../../utils/logger"; +import { TypedEventEmitter } from "../../utils/typed-event-emitter"; +import type { DeepLinkService } from "../deep-link/service"; + +const log = logger.scope("new-task-link-service"); + +export const NewTaskLinkEvent = { + Action: "action", +} as const; + +export type { NewTaskLinkPayload }; + +export interface NewTaskLinkEvents { + [NewTaskLinkEvent.Action]: NewTaskLinkPayload; +} + +interface SharedParams { + repo?: string; + mode?: string; + model?: string; +} + +@injectable() +export class NewTaskLinkService extends TypedEventEmitter { + private pendingLink: NewTaskLinkPayload | null = null; + + constructor( + @inject(MAIN_TOKENS.DeepLinkService) + private readonly deepLinkService: DeepLinkService, + @inject(MAIN_TOKENS.MainWindow) + private readonly mainWindow: IMainWindow, + ) { + super(); + + this.deepLinkService.registerHandler("new", (_path, params) => + this.handleNew(params), + ); + this.deepLinkService.registerHandler("plan", (_path, params) => + this.handlePlan(params), + ); + this.deepLinkService.registerHandler("issue", (_path, params) => + this.handleIssue(params), + ); + } + + private extractSharedParams(params: URLSearchParams): SharedParams { + return { + repo: params.get("repo") ?? undefined, + mode: params.get("mode") ?? undefined, + model: params.get("model") ?? undefined, + }; + } + + private handleNew(params: URLSearchParams): boolean { + const shared = this.extractSharedParams(params); + const prompt = params.get("prompt") ?? undefined; + + if (!prompt && !shared.repo && !shared.mode && !shared.model) { + log.warn("New task link has no parameters"); + return false; + } + + const payload: NewTaskLinkPayload = { + action: "new", + prompt, + ...shared, + }; + + log.info("Handling new task link", { + hasPrompt: !!prompt, + repo: shared.repo, + }); + return this.emitOrQueue(payload); + } + + private handlePlan(params: URLSearchParams): boolean { + const planEncoded = params.get("plan"); + + if (!planEncoded) { + log.warn("Plan link missing plan parameter"); + return false; + } + + let plan: string; + try { + plan = atob(planEncoded); + } catch { + log.error("Plan link has invalid base64 encoding"); + return false; + } + + const shared = this.extractSharedParams(params); + const payload: NewTaskLinkPayload = { + action: "plan", + plan, + ...shared, + }; + + log.info("Handling plan link", { + planLength: plan.length, + repo: shared.repo, + }); + return this.emitOrQueue(payload); + } + + private handleIssue(params: URLSearchParams): boolean { + const url = params.get("url"); + + if (!url) { + log.warn("Issue link missing url parameter"); + return false; + } + + const parsed = this.parseGitHubIssueUrl(url); + if (!parsed) { + log.warn("Issue link has invalid GitHub issue URL", { url }); + return false; + } + + const shared = this.extractSharedParams(params); + const payload: NewTaskLinkPayload = { + action: "issue", + url, + owner: parsed.owner, + issueRepo: parsed.repo, + issueNumber: parsed.number, + ...shared, + }; + + log.info("Handling issue link", { + owner: parsed.owner, + repo: parsed.repo, + number: parsed.number, + }); + return this.emitOrQueue(payload); + } + + private parseGitHubIssueUrl( + url: string, + ): { owner: string; repo: string; number: number } | null { + try { + const parsed = new URL(url); + if (parsed.hostname !== "github.com") return null; + + const parts = parsed.pathname.split("/").filter(Boolean); + if (parts.length < 4 || parts[2] !== "issues") return null; + + const issueNumber = Number.parseInt(parts[3], 10); + if (Number.isNaN(issueNumber) || issueNumber <= 0) return null; + + return { owner: parts[0], repo: parts[1], number: issueNumber }; + } catch { + return null; + } + } + + private emitOrQueue(payload: NewTaskLinkPayload): boolean { + const hasListeners = this.listenerCount(NewTaskLinkEvent.Action) > 0; + + if (hasListeners) { + log.info(`Emitting new task link event: action=${payload.action}`); + this.emit(NewTaskLinkEvent.Action, payload); + } else { + log.info( + `Queueing new task link (renderer not ready): action=${payload.action}`, + ); + this.pendingLink = payload; + } + + if (this.mainWindow.isMinimized()) { + this.mainWindow.restore(); + } + this.mainWindow.focus(); + + return true; + } + + public consumePendingLink(): NewTaskLinkPayload | null { + const pending = this.pendingLink; + this.pendingLink = null; + if (pending) { + log.info(`Consumed pending new task link: action=${pending.action}`); + } + 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 7bde40c804..76300704bf 100644 --- a/apps/code/src/main/trpc/routers/deep-link.ts +++ b/apps/code/src/main/trpc/routers/deep-link.ts @@ -5,6 +5,11 @@ import { type InboxLinkService, type PendingInboxDeepLink, } from "../../services/inbox-link/service"; +import { + NewTaskLinkEvent, + type NewTaskLinkPayload, + type NewTaskLinkService, +} from "../../services/new-task-link/service"; import { type PendingDeepLink, TaskLinkEvent, @@ -18,6 +23,9 @@ const getTaskLinkService = () => const getInboxLinkService = () => container.get(MAIN_TOKENS.InboxLinkService); +const getNewTaskLinkService = () => + container.get(MAIN_TOKENS.NewTaskLinkService); + export const deepLinkRouter = router({ /** * Subscribe to task link deep link events. @@ -66,4 +74,29 @@ export const deepLinkRouter = router({ return service.consumePendingDeepLink(); }, ), + + /** + * Subscribe to new task deep link events (new, plan, issue). + * Emits a discriminated union payload when posthog-code://new/..., + * posthog-code://plan/..., or posthog-code://issue/... is opened. + */ + onNewTaskAction: publicProcedure.subscription(async function* (opts) { + const service = getNewTaskLinkService(); + const iterable = service.toIterable(NewTaskLinkEvent.Action, { + 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( + (): NewTaskLinkPayload | null => { + const service = getNewTaskLinkService(); + return service.consumePendingLink(); + }, + ), }); diff --git a/apps/code/src/renderer/components/MainLayout.tsx b/apps/code/src/renderer/components/MainLayout.tsx index ce3690e68f..f568dd8056 100644 --- a/apps/code/src/renderer/components/MainLayout.tsx +++ b/apps/code/src/renderer/components/MainLayout.tsx @@ -38,6 +38,7 @@ import { useShortcutsSheetStore } from "@stores/shortcutsSheetStore"; import { useQueryClient } from "@tanstack/react-query"; import { logger } from "@utils/logger"; import { useCallback, useEffect, useRef } from "react"; +import { useNewTaskDeepLink } from "../hooks/useNewTaskDeepLink"; import { useTaskDeepLink } from "../hooks/useTaskDeepLink"; import { GlobalEventHandlers } from "./GlobalEventHandlers"; @@ -81,6 +82,7 @@ export function MainLayout() { useTaskDeepLink(); useInboxDeepLink(); useSetupDiscovery(); + useNewTaskDeepLink(); useEffect(() => { if (tasks) { diff --git a/apps/code/src/renderer/hooks/useNewTaskDeepLink.ts b/apps/code/src/renderer/hooks/useNewTaskDeepLink.ts new file mode 100644 index 0000000000..fc67f42b30 --- /dev/null +++ b/apps/code/src/renderer/hooks/useNewTaskDeepLink.ts @@ -0,0 +1,163 @@ +import { useAuthStateValue } from "@features/auth/hooks/authQueries"; +import { trpcClient, useTRPC } from "@renderer/trpc"; +import type { NewTaskLinkPayload } from "@shared/types"; +import { ANALYTICS_EVENTS } from "@shared/types/analytics"; +import { useNavigationStore } from "@stores/navigationStore"; +import { useSubscription } from "@trpc/tanstack-react-query"; +import { track } from "@utils/analytics"; +import { logger } from "@utils/logger"; +import { useCallback, useEffect, useRef } from "react"; +import { toast } from "sonner"; + +const log = logger.scope("new-task-deep-link"); + +export function useNewTaskDeepLink() { + const trpcReact = useTRPC(); + const navigateToTaskInput = useNavigationStore( + (state) => state.navigateToTaskInput, + ); + const isAuthenticated = useAuthStateValue( + (state) => state.status === "authenticated", + ); + const hasFetchedPending = useRef(false); + + const handleAction = useCallback( + async (payload: NewTaskLinkPayload) => { + log.info(`Handling deep link action: ${payload.action}`); + + switch (payload.action) { + case "new": + return handleNew(payload, navigateToTaskInput); + case "plan": + return handlePlan(payload, navigateToTaskInput); + case "issue": + return handleIssue(payload, navigateToTaskInput); + } + }, + [navigateToTaskInput], + ); + + useEffect(() => { + if (!isAuthenticated || hasFetchedPending.current) return; + + const fetchPending = async () => { + hasFetchedPending.current = true; + try { + const pending = await trpcClient.deepLink.getPendingNewTaskLink.query(); + if (pending) { + log.info(`Found pending new task link: action=${pending.action}`); + handleAction(pending); + } + } catch (error) { + log.error("Failed to check for pending new task link:", error); + } + }; + + fetchPending(); + }, [isAuthenticated, handleAction]); + + useSubscription( + trpcReact.deepLink.onNewTaskAction.subscriptionOptions(undefined, { + onData: (data) => { + log.info(`Received new task link event: action=${data.action}`); + handleAction(data); + }, + }), + ); +} + +type NavigateToTaskInput = (options?: { + folderId?: string; + initialPrompt?: string; + initialCloudRepository?: string; +}) => void; + +function handleNew( + payload: Extract, + navigateToTaskInput: NavigateToTaskInput, +) { + navigateToTaskInput({ + initialPrompt: payload.prompt, + initialCloudRepository: payload.repo, + }); + + track(ANALYTICS_EVENTS.DEEP_LINK_NEW_TASK, { + action: "new", + has_prompt: !!payload.prompt, + has_repo: !!payload.repo, + mode: payload.mode, + model: payload.model, + }); + + log.info("Navigated to task input from new deep link"); +} + +function handlePlan( + payload: Extract, + navigateToTaskInput: NavigateToTaskInput, +) { + navigateToTaskInput({ + initialPrompt: payload.plan, + initialCloudRepository: payload.repo, + }); + + track(ANALYTICS_EVENTS.DEEP_LINK_PLAN, { + action: "plan", + has_repo: !!payload.repo, + mode: payload.mode, + model: payload.model, + plan_length_chars: payload.plan.length, + }); + + log.info("Navigated to task input from plan deep link"); +} + +async function handleIssue( + payload: Extract, + navigateToTaskInput: NavigateToTaskInput, +) { + try { + const issue = await trpcClient.git.getGithubIssue.query({ + owner: payload.owner, + repo: payload.issueRepo, + number: payload.issueNumber, + }); + + if (!issue) { + toast.error("GitHub issue not found"); + log.warn("GitHub issue not found", { + owner: payload.owner, + repo: payload.issueRepo, + number: payload.issueNumber, + }); + return; + } + + const labelsText = + issue.labels.length > 0 ? `\nLabels: ${issue.labels.join(", ")}` : ""; + const prompt = `GitHub Issue: ${issue.title}\n${issue.url}${labelsText}`; + + const cloudRepo = payload.repo ?? `${payload.owner}/${payload.issueRepo}`; + + navigateToTaskInput({ + initialPrompt: prompt, + initialCloudRepository: cloudRepo, + }); + + track(ANALYTICS_EVENTS.DEEP_LINK_ISSUE, { + action: "issue", + owner: payload.owner, + repo: payload.issueRepo, + issue_number: payload.issueNumber, + mode: payload.mode, + model: payload.model, + }); + + log.info("Navigated to task input from issue deep link", { + issue: issue.title, + }); + } catch (error) { + log.error("Failed to fetch GitHub issue:", error); + toast.error("Failed to fetch GitHub issue"); + } +} diff --git a/apps/code/src/shared/types.ts b/apps/code/src/shared/types.ts index 25aca5189a..b0daaf5b3d 100644 --- a/apps/code/src/shared/types.ts +++ b/apps/code/src/shared/types.ts @@ -564,3 +564,20 @@ export interface SlackChannelsQueryParams { offset?: number; channelId?: string; } + +export interface SharedLinkParams { + repo?: string; + mode?: string; + model?: string; +} + +export type NewTaskLinkPayload = + | ({ action: "new"; prompt?: string } & SharedLinkParams) + | ({ action: "plan"; plan: string } & SharedLinkParams) + | ({ + action: "issue"; + url: string; + owner: string; + issueRepo: string; + issueNumber: number; + } & SharedLinkParams); diff --git a/apps/code/src/shared/types/analytics.ts b/apps/code/src/shared/types/analytics.ts index cc2ce6be1e..535dcf872a 100644 --- a/apps/code/src/shared/types/analytics.ts +++ b/apps/code/src/shared/types/analytics.ts @@ -278,6 +278,34 @@ export interface BranchMismatchActionProperties { current_branch: string; } +// Deep link events +type DeepLinkAction = "new" | "plan" | "issue"; + +export interface DeepLinkNewTaskProperties { + action: DeepLinkAction; + has_prompt: boolean; + has_repo: boolean; + mode?: string; + model?: string; +} + +export interface DeepLinkPlanProperties { + action: DeepLinkAction; + has_repo: boolean; + mode?: string; + model?: string; + plan_length_chars: number; +} + +export interface DeepLinkIssueProperties { + action: DeepLinkAction; + owner: string; + repo: string; + issue_number: number; + mode?: string; + model?: string; +} + // Feedback events export interface TaskFeedbackProperties { task_id: string; @@ -633,6 +661,11 @@ export const ANALYTICS_EVENTS = { SETUP_TASK_SELECTED: "Setup task selected", SETUP_TASK_DISMISSED: "Setup task dismissed", + // Deep link events + DEEP_LINK_NEW_TASK: "Deep link new task", + DEEP_LINK_PLAN: "Deep link plan", + DEEP_LINK_ISSUE: "Deep link issue", + // Error events TASK_CREATION_FAILED: "Task creation failed", AGENT_SESSION_ERROR: "Agent session error", @@ -739,6 +772,11 @@ export type EventPropertyMap = { [ANALYTICS_EVENTS.SETUP_TASK_SELECTED]: SetupTaskSelectedProperties; [ANALYTICS_EVENTS.SETUP_TASK_DISMISSED]: SetupTaskDismissedProperties; + // Deep link events + [ANALYTICS_EVENTS.DEEP_LINK_NEW_TASK]: DeepLinkNewTaskProperties; + [ANALYTICS_EVENTS.DEEP_LINK_PLAN]: DeepLinkPlanProperties; + [ANALYTICS_EVENTS.DEEP_LINK_ISSUE]: DeepLinkIssueProperties; + // Error events [ANALYTICS_EVENTS.TASK_CREATION_FAILED]: TaskCreationFailedProperties; [ANALYTICS_EVENTS.AGENT_SESSION_ERROR]: AgentSessionErrorProperties; From 5e0ac6917736051614da51cd870d572369dd4a96 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sun, 3 May 2026 21:11:03 -0700 Subject: [PATCH 2/8] fix review feedback and add service tests --- .../services/new-task-link/service.test.ts | 360 ++++++++++++++++++ .../main/services/new-task-link/service.ts | 10 +- .../src/renderer/hooks/useNewTaskDeepLink.ts | 7 +- apps/code/src/shared/types/analytics.ts | 5 - 4 files changed, 365 insertions(+), 17 deletions(-) create mode 100644 apps/code/src/main/services/new-task-link/service.test.ts diff --git a/apps/code/src/main/services/new-task-link/service.test.ts b/apps/code/src/main/services/new-task-link/service.test.ts new file mode 100644 index 0000000000..ffcbe4a4db --- /dev/null +++ b/apps/code/src/main/services/new-task-link/service.test.ts @@ -0,0 +1,360 @@ +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 { IMainWindow } from "@posthog/platform/main-window"; +import type { DeepLinkService } from "../deep-link/service"; +import { NewTaskLinkEvent, NewTaskLinkService } from "./service"; + +function createMockDeepLinkService() { + const handlers = new Map< + string, + (path: string, params: URLSearchParams) => boolean + >(); + return { + registerHandler: vi.fn((key, handler) => handlers.set(key, handler)), + _handlers: handlers, + _invoke(key: string, params: URLSearchParams) { + const handler = handlers.get(key); + if (!handler) throw new Error(`No handler for key: ${key}`); + return handler("", params); + }, + }; +} + +function createMockMainWindow(): IMainWindow { + return { + isMinimized: vi.fn(() => false), + restore: vi.fn(), + focus: vi.fn(), + close: vi.fn(), + show: vi.fn(), + hide: vi.fn(), + minimize: vi.fn(), + maximize: vi.fn(), + unmaximize: vi.fn(), + isMaximized: vi.fn(() => false), + isVisible: vi.fn(() => true), + setTitle: vi.fn(), + loadURL: vi.fn(), + webContents: {} as never, + on: vi.fn(), + once: vi.fn(), + removeListener: vi.fn(), + } as unknown as IMainWindow; +} + +describe("NewTaskLinkService", () => { + let service: NewTaskLinkService; + let mockDeepLink: ReturnType; + let mockWindow: IMainWindow; + + beforeEach(() => { + vi.clearAllMocks(); + mockDeepLink = createMockDeepLinkService(); + mockWindow = createMockMainWindow(); + service = new NewTaskLinkService( + mockDeepLink as unknown as DeepLinkService, + mockWindow, + ); + }); + + describe("constructor", () => { + it("registers handlers for new, plan and issue", () => { + expect(mockDeepLink.registerHandler).toHaveBeenCalledWith( + "new", + expect.any(Function), + ); + expect(mockDeepLink.registerHandler).toHaveBeenCalledWith( + "plan", + expect.any(Function), + ); + expect(mockDeepLink.registerHandler).toHaveBeenCalledWith( + "issue", + expect.any(Function), + ); + expect(mockDeepLink.registerHandler).toHaveBeenCalledTimes(3); + }); + }); + + describe("handleNew", () => { + it("rejects empty params", () => { + const result = mockDeepLink._invoke("new", new URLSearchParams()); + expect(result).toBe(false); + }); + + it("accepts prompt only", () => { + const listener = vi.fn(); + service.on(NewTaskLinkEvent.Action, listener); + + const result = mockDeepLink._invoke( + "new", + new URLSearchParams("prompt=hello+world"), + ); + + expect(result).toBe(true); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + action: "new", + prompt: "hello world", + }), + ); + }); + + it("accepts repo only", () => { + const listener = vi.fn(); + service.on(NewTaskLinkEvent.Action, listener); + + const result = mockDeepLink._invoke( + "new", + new URLSearchParams("repo=posthog/posthog"), + ); + + expect(result).toBe(true); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + action: "new", + repo: "posthog/posthog", + prompt: undefined, + }), + ); + }); + + it("passes shared params (repo, mode, model)", () => { + const listener = vi.fn(); + service.on(NewTaskLinkEvent.Action, listener); + + mockDeepLink._invoke( + "new", + new URLSearchParams("prompt=test&repo=org/repo&mode=cloud&model=opus"), + ); + + expect(listener).toHaveBeenCalledWith({ + action: "new", + prompt: "test", + repo: "org/repo", + mode: "cloud", + model: "opus", + }); + }); + }); + + describe("handlePlan", () => { + it("rejects missing plan param", () => { + const result = mockDeepLink._invoke( + "plan", + new URLSearchParams("repo=org/repo"), + ); + expect(result).toBe(false); + }); + + it("rejects invalid base64", () => { + const result = mockDeepLink._invoke( + "plan", + new URLSearchParams("plan=!!!invalid-base64!!!"), + ); + expect(result).toBe(false); + }); + + it("accepts valid base64 plan", () => { + const listener = vi.fn(); + service.on(NewTaskLinkEvent.Action, listener); + + const planText = "# My Plan\n\n1. Do thing\n2. Do other thing"; + const encoded = btoa(planText); + + const result = mockDeepLink._invoke( + "plan", + new URLSearchParams(`plan=${encoded}&repo=org/repo`), + ); + + expect(result).toBe(true); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + action: "plan", + plan: planText, + repo: "org/repo", + }), + ); + }); + + it("passes shared params", () => { + const listener = vi.fn(); + service.on(NewTaskLinkEvent.Action, listener); + + const encoded = btoa("plan content"); + mockDeepLink._invoke( + "plan", + new URLSearchParams(`plan=${encoded}&mode=worktree&model=sonnet`), + ); + + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + mode: "worktree", + model: "sonnet", + }), + ); + }); + }); + + describe("handleIssue", () => { + it("rejects missing url param", () => { + const result = mockDeepLink._invoke("issue", new URLSearchParams()); + expect(result).toBe(false); + }); + + it("rejects non-GitHub URLs", () => { + const result = mockDeepLink._invoke( + "issue", + new URLSearchParams("url=https://gitlab.com/org/repo/issues/1"), + ); + expect(result).toBe(false); + }); + + it("rejects GitHub URLs that are not issues", () => { + const result = mockDeepLink._invoke( + "issue", + new URLSearchParams("url=https://github.com/org/repo/pull/1"), + ); + expect(result).toBe(false); + }); + + it("rejects issue URLs with non-numeric issue number", () => { + const result = mockDeepLink._invoke( + "issue", + new URLSearchParams("url=https://github.com/org/repo/issues/abc"), + ); + expect(result).toBe(false); + }); + + it("rejects issue URLs with zero or negative issue number", () => { + expect( + mockDeepLink._invoke( + "issue", + new URLSearchParams("url=https://github.com/org/repo/issues/0"), + ), + ).toBe(false); + + expect( + mockDeepLink._invoke( + "issue", + new URLSearchParams("url=https://github.com/org/repo/issues/-1"), + ), + ).toBe(false); + }); + + it("accepts valid GitHub issue URL", () => { + const listener = vi.fn(); + service.on(NewTaskLinkEvent.Action, listener); + + const result = mockDeepLink._invoke( + "issue", + new URLSearchParams("url=https://github.com/posthog/posthog/issues/42"), + ); + + expect(result).toBe(true); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + action: "issue", + url: "https://github.com/posthog/posthog/issues/42", + owner: "posthog", + issueRepo: "posthog", + issueNumber: 42, + }), + ); + }); + + it("passes shared params", () => { + const listener = vi.fn(); + service.on(NewTaskLinkEvent.Action, listener); + + mockDeepLink._invoke( + "issue", + new URLSearchParams( + "url=https://github.com/org/repo/issues/1&repo=other/repo&model=opus", + ), + ); + + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + repo: "other/repo", + model: "opus", + }), + ); + }); + }); + + describe("emitOrQueue", () => { + it("emits when listeners exist", () => { + const listener = vi.fn(); + service.on(NewTaskLinkEvent.Action, listener); + + mockDeepLink._invoke("new", new URLSearchParams("prompt=test")); + + expect(listener).toHaveBeenCalledTimes(1); + expect(service.consumePendingLink()).toBeNull(); + }); + + it("queues when no listeners exist", () => { + mockDeepLink._invoke("new", new URLSearchParams("prompt=test")); + + const pending = service.consumePendingLink(); + expect(pending).toEqual( + expect.objectContaining({ action: "new", prompt: "test" }), + ); + }); + + it("focuses the window", () => { + mockDeepLink._invoke("new", new URLSearchParams("prompt=test")); + + expect(mockWindow.focus).toHaveBeenCalled(); + }); + + it("restores the window if minimized", () => { + vi.mocked(mockWindow.isMinimized).mockReturnValue(true); + + mockDeepLink._invoke("new", new URLSearchParams("prompt=test")); + + expect(mockWindow.restore).toHaveBeenCalled(); + expect(mockWindow.focus).toHaveBeenCalled(); + }); + + it("does not restore the window if not minimized", () => { + vi.mocked(mockWindow.isMinimized).mockReturnValue(false); + + mockDeepLink._invoke("new", new URLSearchParams("prompt=test")); + + expect(mockWindow.restore).not.toHaveBeenCalled(); + }); + }); + + describe("consumePendingLink", () => { + it("returns null when no pending link", () => { + expect(service.consumePendingLink()).toBeNull(); + }); + + it("clears after consuming", () => { + mockDeepLink._invoke("new", new URLSearchParams("prompt=test")); + + expect(service.consumePendingLink()).not.toBeNull(); + expect(service.consumePendingLink()).toBeNull(); + }); + + it("latest link overwrites previous pending", () => { + mockDeepLink._invoke("new", new URLSearchParams("prompt=first")); + mockDeepLink._invoke("new", new URLSearchParams("prompt=second")); + + const pending = service.consumePendingLink(); + expect(pending).toEqual(expect.objectContaining({ prompt: "second" })); + }); + }); +}); diff --git a/apps/code/src/main/services/new-task-link/service.ts b/apps/code/src/main/services/new-task-link/service.ts index dd55533fe0..8e8391aab1 100644 --- a/apps/code/src/main/services/new-task-link/service.ts +++ b/apps/code/src/main/services/new-task-link/service.ts @@ -1,5 +1,5 @@ import type { IMainWindow } from "@posthog/platform/main-window"; -import type { NewTaskLinkPayload } from "@shared/types"; +import type { NewTaskLinkPayload, SharedLinkParams } from "@shared/types"; import { inject, injectable } from "inversify"; import { MAIN_TOKENS } from "../../di/tokens"; import { logger } from "../../utils/logger"; @@ -18,12 +18,6 @@ export interface NewTaskLinkEvents { [NewTaskLinkEvent.Action]: NewTaskLinkPayload; } -interface SharedParams { - repo?: string; - mode?: string; - model?: string; -} - @injectable() export class NewTaskLinkService extends TypedEventEmitter { private pendingLink: NewTaskLinkPayload | null = null; @@ -47,7 +41,7 @@ export class NewTaskLinkService extends TypedEventEmitter { ); } - private extractSharedParams(params: URLSearchParams): SharedParams { + private extractSharedParams(params: URLSearchParams): SharedLinkParams { return { repo: params.get("repo") ?? undefined, mode: params.get("mode") ?? undefined, diff --git a/apps/code/src/renderer/hooks/useNewTaskDeepLink.ts b/apps/code/src/renderer/hooks/useNewTaskDeepLink.ts index fc67f42b30..8f435673f0 100644 --- a/apps/code/src/renderer/hooks/useNewTaskDeepLink.ts +++ b/apps/code/src/renderer/hooks/useNewTaskDeepLink.ts @@ -60,7 +60,9 @@ export function useNewTaskDeepLink() { trpcReact.deepLink.onNewTaskAction.subscriptionOptions(undefined, { onData: (data) => { log.info(`Received new task link event: action=${data.action}`); - handleAction(data); + handleAction(data).catch((error) => { + log.error("Failed to handle new task link action:", error); + }); }, }), ); @@ -82,7 +84,6 @@ function handleNew( }); track(ANALYTICS_EVENTS.DEEP_LINK_NEW_TASK, { - action: "new", has_prompt: !!payload.prompt, has_repo: !!payload.repo, mode: payload.mode, @@ -102,7 +103,6 @@ function handlePlan( }); track(ANALYTICS_EVENTS.DEEP_LINK_PLAN, { - action: "plan", has_repo: !!payload.repo, mode: payload.mode, model: payload.model, @@ -145,7 +145,6 @@ async function handleIssue( }); track(ANALYTICS_EVENTS.DEEP_LINK_ISSUE, { - action: "issue", owner: payload.owner, repo: payload.issueRepo, issue_number: payload.issueNumber, diff --git a/apps/code/src/shared/types/analytics.ts b/apps/code/src/shared/types/analytics.ts index 535dcf872a..4c7707c6c5 100644 --- a/apps/code/src/shared/types/analytics.ts +++ b/apps/code/src/shared/types/analytics.ts @@ -279,10 +279,7 @@ export interface BranchMismatchActionProperties { } // Deep link events -type DeepLinkAction = "new" | "plan" | "issue"; - export interface DeepLinkNewTaskProperties { - action: DeepLinkAction; has_prompt: boolean; has_repo: boolean; mode?: string; @@ -290,7 +287,6 @@ export interface DeepLinkNewTaskProperties { } export interface DeepLinkPlanProperties { - action: DeepLinkAction; has_repo: boolean; mode?: string; model?: string; @@ -298,7 +294,6 @@ export interface DeepLinkPlanProperties { } export interface DeepLinkIssueProperties { - action: DeepLinkAction; owner: string; repo: string; issue_number: number; From 47b20d52484a85984ca14e63f08790e79bd8c73f Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sun, 3 May 2026 21:19:17 -0700 Subject: [PATCH 3/8] wire model and mode deep link params into task input form --- .../src/renderer/components/MainLayout.tsx | 2 ++ .../task-detail/components/TaskInput.tsx | 21 +++++++++++++++++++ .../src/renderer/hooks/useNewTaskDeepLink.ts | 8 +++++++ .../src/renderer/stores/navigationStore.ts | 8 +++++++ 4 files changed, 39 insertions(+) diff --git a/apps/code/src/renderer/components/MainLayout.tsx b/apps/code/src/renderer/components/MainLayout.tsx index f568dd8056..d4fb8bc328 100644 --- a/apps/code/src/renderer/components/MainLayout.tsx +++ b/apps/code/src/renderer/components/MainLayout.tsx @@ -151,6 +151,8 @@ export function MainLayout() { initialCloudRepository={ view.initialCloudRepository ?? taskInputCloudRepository } + initialModel={view.initialModel} + initialMode={view.initialMode} reportAssociation={ view.reportAssociation ?? taskInputReportAssociation } diff --git a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx index b4a0c481c0..6dd8b10f8b 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -61,6 +61,8 @@ interface TaskInputProps { initialPrompt?: string; initialPromptKey?: string; initialCloudRepository?: string; + initialModel?: string; + initialMode?: string; reportAssociation?: TaskInputReportAssociation; } @@ -70,6 +72,8 @@ export function TaskInput({ initialPrompt, initialPromptKey, initialCloudRepository, + initialModel, + initialMode, reportAssociation, }: TaskInputProps = {}) { const { cloudRegion } = useAuthStore(); @@ -351,6 +355,23 @@ export function TaskInput({ setConfigOption, } = usePreviewConfig(adapter); + useEffect(() => { + if (isPreviewLoading) return; + if (initialModel && modelOption) { + setConfigOption(modelOption.id, initialModel); + } + if (initialMode && modeOption) { + setConfigOption(modeOption.id, initialMode); + } + }, [ + isPreviewLoading, + initialModel, + initialMode, + modelOption, + modeOption, + setConfigOption, + ]); + const { folders } = useFolders(); useEffect(() => { diff --git a/apps/code/src/renderer/hooks/useNewTaskDeepLink.ts b/apps/code/src/renderer/hooks/useNewTaskDeepLink.ts index 8f435673f0..beab8a79e3 100644 --- a/apps/code/src/renderer/hooks/useNewTaskDeepLink.ts +++ b/apps/code/src/renderer/hooks/useNewTaskDeepLink.ts @@ -72,6 +72,8 @@ type NavigateToTaskInput = (options?: { folderId?: string; initialPrompt?: string; initialCloudRepository?: string; + initialModel?: string; + initialMode?: string; }) => void; function handleNew( @@ -81,6 +83,8 @@ function handleNew( navigateToTaskInput({ initialPrompt: payload.prompt, initialCloudRepository: payload.repo, + initialModel: payload.model, + initialMode: payload.mode, }); track(ANALYTICS_EVENTS.DEEP_LINK_NEW_TASK, { @@ -100,6 +104,8 @@ function handlePlan( navigateToTaskInput({ initialPrompt: payload.plan, initialCloudRepository: payload.repo, + initialModel: payload.model, + initialMode: payload.mode, }); track(ANALYTICS_EVENTS.DEEP_LINK_PLAN, { @@ -142,6 +148,8 @@ async function handleIssue( navigateToTaskInput({ initialPrompt: prompt, initialCloudRepository: cloudRepo, + initialModel: payload.model, + initialMode: payload.mode, }); track(ANALYTICS_EVENTS.DEEP_LINK_ISSUE, { diff --git a/apps/code/src/renderer/stores/navigationStore.ts b/apps/code/src/renderer/stores/navigationStore.ts index c3f4d8b2ab..8b5f896974 100644 --- a/apps/code/src/renderer/stores/navigationStore.ts +++ b/apps/code/src/renderer/stores/navigationStore.ts @@ -32,6 +32,8 @@ interface TaskInputNavigationOptions { folderId?: string; initialPrompt?: string; initialCloudRepository?: string; + initialModel?: string; + initialMode?: string; reportAssociation?: TaskInputReportAssociation; } @@ -43,6 +45,8 @@ interface ViewState { taskInputRequestId?: string; initialPrompt?: string; initialCloudRepository?: string; + initialModel?: string; + initialMode?: string; reportAssociation?: TaskInputReportAssociation; pendingTaskKey?: string; } @@ -211,6 +215,8 @@ export const useNavigationStore = create()( const hasTransientState = !!options.initialPrompt || !!options.initialCloudRepository || + !!options.initialModel || + !!options.initialMode || !!options.reportAssociation; if (options.reportAssociation || options.initialCloudRepository) { set({ @@ -223,6 +229,8 @@ export const useNavigationStore = create()( folderId: options.folderId, initialPrompt: options.initialPrompt, initialCloudRepository: options.initialCloudRepository, + initialModel: options.initialModel, + initialMode: options.initialMode, reportAssociation: options.reportAssociation, taskInputRequestId: hasTransientState ? (globalThis.crypto?.randomUUID?.() ?? `${Date.now()}`) From 2313d152ace9e6eb586c6abf3c6dbaa86ca4cd07 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sun, 17 May 2026 23:18:02 -0700 Subject: [PATCH 4/8] document posthog-code:// deep links --- README.md | 1 + docs/DEEP-LINKS.md | 152 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 docs/DEEP-LINKS.md diff --git a/README.md b/README.md index 164d7cc80a..464e4cd28d 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ posthog-code/ | [docs/LOCAL-DEVELOPMENT.md](./docs/LOCAL-DEVELOPMENT.md) | Connecting PostHog Code to a local PostHog instance | | [docs/UPDATES.md](./docs/UPDATES.md) | Release versioning and git tagging | | [docs/TROUBLESHOOTING.md](./docs/TROUBLESHOOTING.md) | Common issues and fixes | +| [docs/DEEP-LINKS.md](./docs/DEEP-LINKS.md) | `posthog-code://` deep link schemes and parameters | ## Contributing diff --git a/docs/DEEP-LINKS.md b/docs/DEEP-LINKS.md new file mode 100644 index 0000000000..08bd01da29 --- /dev/null +++ b/docs/DEEP-LINKS.md @@ -0,0 +1,152 @@ +# Deep Links + +PostHog Code registers custom URL schemes so the desktop app can be opened with context from a browser, another app, or the shell. Opening a deep link focuses the app window and routes the URL to the matching handler. + +## Schemes + +| Environment | Scheme | +|---|---| +| Production | `posthog-code://` | +| Development | `posthog-code-dev://` | +| Legacy (production only) | `twig://`, `array://` | + +All schemes route through the same dispatcher. The host portion of the URL selects the handler (`task`, `inbox`, `new`, `plan`, `issue`, etc.). + +If the app is not running, the OS launches it and the link is queued until the renderer is ready. If the app is minimised, it is restored and focused before the link is handled. + +## User-facing links + +These are the deep links you would share with someone or wire up from another tool. + +### `posthog-code://new` + +Open the new-task input, optionally pre-filled. + +| Parameter | Required | Description | +|---|---|---| +| `prompt` | No* | Pre-filled prompt text | +| `repo` | No | Cloud repository slug (e.g. `posthog/posthog`) | +| `mode` | No | Initial mode for the task | +| `model` | No | Initial model for the task | + +*At least one of `prompt`, `repo`, `mode`, or `model` must be present. + +``` +posthog-code://new?prompt=Fix%20the%20login%20bug&repo=posthog%2Fposthog +posthog-code://new?repo=posthog%2Fposthog&model=claude-opus-4-7&mode=plan +``` + +### `posthog-code://plan` + +Open the new-task input with a longer, base64-encoded plan as the initial prompt. Use this when the prompt is too large or contains characters that are awkward to URL-encode. + +| Parameter | Required | Description | +|---|---|---| +| `plan` | Yes | Base64-encoded plan text (decoded with `atob`) | +| `repo` | No | Cloud repository slug | +| `mode` | No | Initial mode | +| `model` | No | Initial model | + +``` +posthog-code://plan?plan=SGVsbG8gV29ybGQ%3D&repo=posthog%2Fposthog +``` + +The link is rejected if `plan` is missing or is not valid base64. + +### `posthog-code://issue` + +Open the new-task input pre-filled with a GitHub issue's title, URL, and labels. The issue is fetched at link-open time, so the prompt always reflects the latest issue state. + +| Parameter | Required | Description | +|---|---|---| +| `url` | Yes | Full GitHub issue URL (`https://github.com///issues/`) | +| `repo` | No | Override the cloud repository slug (defaults to `/` parsed from `url`) | +| `mode` | No | Initial mode | +| `model` | No | Initial model | + +``` +posthog-code://issue?url=https%3A%2F%2Fgithub.com%2Fposthog%2Fposthog%2Fissues%2F12345 +``` + +The link is rejected if `url` is missing, is not a `github.com` URL, or does not match `///issues/`. If the issue cannot be fetched, a toast is shown and no navigation happens. + +### `posthog-code://task/[/run/]` + +Open an existing task. Optionally jump to a specific run. + +| Segment | Required | Description | +|---|---|---| +| `` | Yes | Task ID | +| `run/` | No | Specific run to open | + +``` +posthog-code://task/abc123 +posthog-code://task/abc123/run/xyz789 +``` + +### `posthog-code://inbox/` + +Open a specific inbox report. + +| Segment | Required | Description | +|---|---|---| +| `` | Yes | Inbox report ID | + +``` +posthog-code://inbox/report_abc123 +``` + +## OAuth callback links + +These are issued by external services and consumed by the app. You should not need to construct them yourself, but they are documented for completeness. + +### `posthog-code://callback` + +PKCE OAuth callback for user sign-in. PostHog Cloud redirects to this URL after the user authorises in their browser. + +| Parameter | Required | Description | +|---|---|---| +| `code` | Conditional | Authorisation code on success | +| `error` | Conditional | Error string on failure | + +In development the same payload is delivered to `http://localhost:8237/callback` instead. + +### `posthog-code://integration` + +OAuth callback for the GitHub App installation flow. + +| Parameter | Description | +|---|---| +| `provider` | Integration provider (e.g. `github`) | +| `project_id` | PostHog project ID | +| `installation_id` | GitHub App installation ID | +| `status` | `success` or `error` | +| `error_code` | Error code on failure | +| `error_message` | Human-readable error message on failure | + +### `posthog-code://mcp-oauth-complete` + +OAuth completion callback for MCP server integrations. + +| Parameter | Description | +|---|---| +| `status` | `success` or `error` | +| `installation_id` | MCP server installation ID on success | +| `error` | Error string on failure | + +In development the same payload is delivered to `http://localhost:8238/mcp-oauth-complete` instead. + +## Implementation + +| Handler | Source | +|---|---| +| Dispatcher | [apps/code/src/main/services/deep-link/service.ts](../apps/code/src/main/services/deep-link/service.ts) | +| `task` | [apps/code/src/main/services/task-link/service.ts](../apps/code/src/main/services/task-link/service.ts) | +| `inbox` | [apps/code/src/main/services/inbox-link/service.ts](../apps/code/src/main/services/inbox-link/service.ts) | +| `new`, `plan`, `issue` | [apps/code/src/main/services/new-task-link/service.ts](../apps/code/src/main/services/new-task-link/service.ts) | +| `callback` | [apps/code/src/main/services/oauth/service.ts](../apps/code/src/main/services/oauth/service.ts) | +| `integration` | [apps/code/src/main/services/github-integration/service.ts](../apps/code/src/main/services/github-integration/service.ts) | +| `mcp-oauth-complete` | [apps/code/src/main/services/mcp-callback/service.ts](../apps/code/src/main/services/mcp-callback/service.ts) | +| Scheme constants | [apps/code/src/shared/deeplink.ts](../apps/code/src/shared/deeplink.ts) | + +To add a new deep link, register a handler with `DeepLinkService.registerHandler(key, handler)` and route renderer-side events through the [`deepLinkRouter`](../apps/code/src/main/trpc/routers/deep-link.ts) tRPC router. From 93c8baf90091192c7078746950d317feef973e34 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 19 May 2026 18:20:37 -0700 Subject: [PATCH 5/8] address creview feedback for deep links v2 --- .../services/new-task-link/service.test.ts | 62 +++++++++++++++++++ .../main/services/new-task-link/service.ts | 27 +++++--- .../task-detail/components/TaskInput.tsx | 27 +++++++- .../src/renderer/hooks/useNewTaskDeepLink.ts | 39 ++++++++---- .../src/renderer/stores/navigationStore.ts | 2 +- apps/code/src/shared/types.ts | 8 +-- apps/code/src/shared/types/analytics.ts | 10 +++ docs/DEEP-LINKS.md | 12 ++-- 8 files changed, 155 insertions(+), 32 deletions(-) diff --git a/apps/code/src/main/services/new-task-link/service.test.ts b/apps/code/src/main/services/new-task-link/service.test.ts index ffcbe4a4db..ec19f66b10 100644 --- a/apps/code/src/main/services/new-task-link/service.test.ts +++ b/apps/code/src/main/services/new-task-link/service.test.ts @@ -92,6 +92,30 @@ describe("NewTaskLinkService", () => { expect(result).toBe(false); }); + it("rejects when only mode is provided", () => { + const result = mockDeepLink._invoke( + "new", + new URLSearchParams("mode=plan"), + ); + expect(result).toBe(false); + }); + + it("rejects when only model is provided", () => { + const result = mockDeepLink._invoke( + "new", + new URLSearchParams("model=opus"), + ); + expect(result).toBe(false); + }); + + it("rejects when only mode and model are provided", () => { + const result = mockDeepLink._invoke( + "new", + new URLSearchParams("mode=plan&model=opus"), + ); + expect(result).toBe(false); + }); + it("accepts prompt only", () => { const listener = vi.fn(); service.on(NewTaskLinkEvent.Action, listener); @@ -187,6 +211,44 @@ describe("NewTaskLinkService", () => { ); }); + it("accepts URL-safe base64 with - and _ instead of + and /", () => { + const listener = vi.fn(); + service.on(NewTaskLinkEvent.Action, listener); + + const planText = "\xfb\xff"; // bytes that base64 to "+/8=" + const standard = btoa(planText); + const urlSafe = standard + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + + const result = mockDeepLink._invoke( + "plan", + new URLSearchParams(`plan=${urlSafe}`), + ); + + expect(result).toBe(true); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ action: "plan", plan: planText }), + ); + }); + + it("recovers when + was decoded to space by URLSearchParams", () => { + const listener = vi.fn(); + service.on(NewTaskLinkEvent.Action, listener); + + // "+/8=" arrives as " /8=" because URLSearchParams turns + into space + const result = mockDeepLink._invoke( + "plan", + new URLSearchParams("plan=+/8="), + ); + + expect(result).toBe(true); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ action: "plan", plan: "\xfb\xff" }), + ); + }); + it("passes shared params", () => { const listener = vi.fn(); service.on(NewTaskLinkEvent.Action, listener); diff --git a/apps/code/src/main/services/new-task-link/service.ts b/apps/code/src/main/services/new-task-link/service.ts index 8e8391aab1..00be9a9103 100644 --- a/apps/code/src/main/services/new-task-link/service.ts +++ b/apps/code/src/main/services/new-task-link/service.ts @@ -1,5 +1,5 @@ import type { IMainWindow } from "@posthog/platform/main-window"; -import type { NewTaskLinkPayload, SharedLinkParams } from "@shared/types"; +import type { NewTaskLinkPayload, NewTaskSharedParams } from "@shared/types"; import { inject, injectable } from "inversify"; import { MAIN_TOKENS } from "../../di/tokens"; import { logger } from "../../utils/logger"; @@ -8,6 +8,19 @@ import type { DeepLinkService } from "../deep-link/service"; const log = logger.scope("new-task-link-service"); +function decodePlanBase64(encoded: string): string | null { + try { + const normalized = encoded + .replace(/-/g, "+") + .replace(/_/g, "/") + .replace(/ /g, "+"); + const padding = (4 - (normalized.length % 4)) % 4; + return atob(normalized + "=".repeat(padding)); + } catch { + return null; + } +} + export const NewTaskLinkEvent = { Action: "action", } as const; @@ -41,7 +54,7 @@ export class NewTaskLinkService extends TypedEventEmitter { ); } - private extractSharedParams(params: URLSearchParams): SharedLinkParams { + private extractSharedParams(params: URLSearchParams): NewTaskSharedParams { return { repo: params.get("repo") ?? undefined, mode: params.get("mode") ?? undefined, @@ -53,8 +66,8 @@ export class NewTaskLinkService extends TypedEventEmitter { const shared = this.extractSharedParams(params); const prompt = params.get("prompt") ?? undefined; - if (!prompt && !shared.repo && !shared.mode && !shared.model) { - log.warn("New task link has no parameters"); + if (!prompt && !shared.repo) { + log.warn("New task link requires at least prompt or repo"); return false; } @@ -79,10 +92,8 @@ export class NewTaskLinkService extends TypedEventEmitter { return false; } - let plan: string; - try { - plan = atob(planEncoded); - } catch { + const plan = decodePlanBase64(planEncoded); + if (plan === null) { log.error("Plan link has invalid base64 encoding"); return false; } diff --git a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx index 6dd8b10f8b..ded2f0095b 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -1,3 +1,4 @@ +import type { SessionConfigOption } from "@agentclientprotocol/sdk"; import { DotPatternBackground } from "@components/DotPatternBackground"; import { EnvironmentSelector } from "@features/environments/components/EnvironmentSelector"; import { FolderPicker } from "@features/folder-picker/components/FolderPicker"; @@ -66,6 +67,20 @@ interface TaskInputProps { reportAssociation?: TaskInputReportAssociation; } +function isValidConfigValue( + option: SessionConfigOption | undefined, + value: string, +): option is Extract { + if (!option || option.type !== "select") return false; + const items = option.options as Array<{ + value?: string; + options?: Array<{ value: string }>; + }>; + return items.some((o) => + o.options ? o.options.some((g) => g.value === value) : o.value === value, + ); +} + export function TaskInput({ sessionId = "task-input", onTaskCreated, @@ -355,16 +370,24 @@ export function TaskInput({ setConfigOption, } = usePreviewConfig(adapter); + const lastAppliedDeepLinkConfigKey = useRef(undefined); + useEffect(() => { if (isPreviewLoading) return; - if (initialModel && modelOption) { + if (!initialPromptKey) return; + if (lastAppliedDeepLinkConfigKey.current === initialPromptKey) return; + if (!initialModel && !initialMode) return; + + if (initialModel && isValidConfigValue(modelOption, initialModel)) { setConfigOption(modelOption.id, initialModel); } - if (initialMode && modeOption) { + if (initialMode && isValidConfigValue(modeOption, initialMode)) { setConfigOption(modeOption.id, initialMode); } + lastAppliedDeepLinkConfigKey.current = initialPromptKey; }, [ isPreviewLoading, + initialPromptKey, initialModel, initialMode, modelOption, diff --git a/apps/code/src/renderer/hooks/useNewTaskDeepLink.ts b/apps/code/src/renderer/hooks/useNewTaskDeepLink.ts index beab8a79e3..112ca3545d 100644 --- a/apps/code/src/renderer/hooks/useNewTaskDeepLink.ts +++ b/apps/code/src/renderer/hooks/useNewTaskDeepLink.ts @@ -2,7 +2,10 @@ import { useAuthStateValue } from "@features/auth/hooks/authQueries"; import { trpcClient, useTRPC } from "@renderer/trpc"; import type { NewTaskLinkPayload } from "@shared/types"; import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import { useNavigationStore } from "@stores/navigationStore"; +import { + type TaskInputNavigationOptions, + useNavigationStore, +} from "@stores/navigationStore"; import { useSubscription } from "@trpc/tanstack-react-query"; import { track } from "@utils/analytics"; import { logger } from "@utils/logger"; @@ -11,6 +14,8 @@ import { toast } from "sonner"; const log = logger.scope("new-task-deep-link"); +type NavigateToTaskInput = (options?: TaskInputNavigationOptions) => void; + export function useNewTaskDeepLink() { const trpcReact = useTRPC(); const navigateToTaskInput = useNavigationStore( @@ -46,7 +51,9 @@ export function useNewTaskDeepLink() { const pending = await trpcClient.deepLink.getPendingNewTaskLink.query(); if (pending) { log.info(`Found pending new task link: action=${pending.action}`); - handleAction(pending); + handleAction(pending).catch((error) => { + log.error("Failed to handle pending new task link:", error); + }); } } catch (error) { log.error("Failed to check for pending new task link:", error); @@ -68,14 +75,6 @@ export function useNewTaskDeepLink() { ); } -type NavigateToTaskInput = (options?: { - folderId?: string; - initialPrompt?: string; - initialCloudRepository?: string; - initialModel?: string; - initialMode?: string; -}) => void; - function handleNew( payload: Extract, navigateToTaskInput: NavigateToTaskInput, @@ -130,12 +129,20 @@ async function handleIssue( }); if (!issue) { - toast.error("GitHub issue not found"); + toast.error("GitHub issue not found", { + description: `${payload.owner}/${payload.issueRepo}#${payload.issueNumber} could not be opened.`, + }); log.warn("GitHub issue not found", { owner: payload.owner, repo: payload.issueRepo, number: payload.issueNumber, }); + track(ANALYTICS_EVENTS.DEEP_LINK_ISSUE_FAILED, { + owner: payload.owner, + repo: payload.issueRepo, + issue_number: payload.issueNumber, + reason: "not_found", + }); return; } @@ -164,7 +171,15 @@ async function handleIssue( issue: issue.title, }); } catch (error) { + const message = error instanceof Error ? error.message : String(error); log.error("Failed to fetch GitHub issue:", error); - toast.error("Failed to fetch GitHub issue"); + toast.error("Failed to fetch GitHub issue", { description: message }); + track(ANALYTICS_EVENTS.DEEP_LINK_ISSUE_FAILED, { + owner: payload.owner, + repo: payload.issueRepo, + issue_number: payload.issueNumber, + reason: "fetch_failed", + error_message: message, + }); } } diff --git a/apps/code/src/renderer/stores/navigationStore.ts b/apps/code/src/renderer/stores/navigationStore.ts index 8b5f896974..06ad8a56e1 100644 --- a/apps/code/src/renderer/stores/navigationStore.ts +++ b/apps/code/src/renderer/stores/navigationStore.ts @@ -28,7 +28,7 @@ export interface TaskInputReportAssociation { title: string; } -interface TaskInputNavigationOptions { +export interface TaskInputNavigationOptions { folderId?: string; initialPrompt?: string; initialCloudRepository?: string; diff --git a/apps/code/src/shared/types.ts b/apps/code/src/shared/types.ts index b0daaf5b3d..53d4f54f34 100644 --- a/apps/code/src/shared/types.ts +++ b/apps/code/src/shared/types.ts @@ -565,19 +565,19 @@ export interface SlackChannelsQueryParams { channelId?: string; } -export interface SharedLinkParams { +export interface NewTaskSharedParams { repo?: string; mode?: string; model?: string; } export type NewTaskLinkPayload = - | ({ action: "new"; prompt?: string } & SharedLinkParams) - | ({ action: "plan"; plan: string } & SharedLinkParams) + | ({ action: "new"; prompt?: string } & NewTaskSharedParams) + | ({ action: "plan"; plan: string } & NewTaskSharedParams) | ({ action: "issue"; url: string; owner: string; issueRepo: string; issueNumber: number; - } & SharedLinkParams); + } & NewTaskSharedParams); diff --git a/apps/code/src/shared/types/analytics.ts b/apps/code/src/shared/types/analytics.ts index 4c7707c6c5..61f56c0cfc 100644 --- a/apps/code/src/shared/types/analytics.ts +++ b/apps/code/src/shared/types/analytics.ts @@ -301,6 +301,14 @@ export interface DeepLinkIssueProperties { model?: string; } +export interface DeepLinkIssueFailedProperties { + owner: string; + repo: string; + issue_number: number; + reason: "not_found" | "fetch_failed"; + error_message?: string; +} + // Feedback events export interface TaskFeedbackProperties { task_id: string; @@ -660,6 +668,7 @@ export const ANALYTICS_EVENTS = { DEEP_LINK_NEW_TASK: "Deep link new task", DEEP_LINK_PLAN: "Deep link plan", DEEP_LINK_ISSUE: "Deep link issue", + DEEP_LINK_ISSUE_FAILED: "Deep link issue failed", // Error events TASK_CREATION_FAILED: "Task creation failed", @@ -771,6 +780,7 @@ export type EventPropertyMap = { [ANALYTICS_EVENTS.DEEP_LINK_NEW_TASK]: DeepLinkNewTaskProperties; [ANALYTICS_EVENTS.DEEP_LINK_PLAN]: DeepLinkPlanProperties; [ANALYTICS_EVENTS.DEEP_LINK_ISSUE]: DeepLinkIssueProperties; + [ANALYTICS_EVENTS.DEEP_LINK_ISSUE_FAILED]: DeepLinkIssueFailedProperties; // Error events [ANALYTICS_EVENTS.TASK_CREATION_FAILED]: TaskCreationFailedProperties; diff --git a/docs/DEEP-LINKS.md b/docs/DEEP-LINKS.md index 08bd01da29..26f4dd52b5 100644 --- a/docs/DEEP-LINKS.md +++ b/docs/DEEP-LINKS.md @@ -25,11 +25,11 @@ Open the new-task input, optionally pre-filled. | Parameter | Required | Description | |---|---|---| | `prompt` | No* | Pre-filled prompt text | -| `repo` | No | Cloud repository slug (e.g. `posthog/posthog`) | -| `mode` | No | Initial mode for the task | -| `model` | No | Initial model for the task | +| `repo` | No* | Cloud repository slug (e.g. `posthog/posthog`) | +| `mode` | No | Initial mode for the task (ignored unless it matches a known mode) | +| `model` | No | Initial model for the task (ignored unless it matches a known model) | -*At least one of `prompt`, `repo`, `mode`, or `model` must be present. +*At least one of `prompt` or `repo` must be present. `mode` and `model` alone are not enough to open a task with meaningful context. ``` posthog-code://new?prompt=Fix%20the%20login%20bug&repo=posthog%2Fposthog @@ -42,7 +42,7 @@ Open the new-task input with a longer, base64-encoded plan as the initial prompt | Parameter | Required | Description | |---|---|---| -| `plan` | Yes | Base64-encoded plan text (decoded with `atob`) | +| `plan` | Yes | Base64-encoded plan text. Standard or URL-safe alphabet, padding optional. | | `repo` | No | Cloud repository slug | | `mode` | No | Initial mode | | `model` | No | Initial model | @@ -53,6 +53,8 @@ posthog-code://plan?plan=SGVsbG8gV29ybGQ%3D&repo=posthog%2Fposthog The link is rejected if `plan` is missing or is not valid base64. +Encoding tip: prefer URL-safe base64 (`-` and `_` instead of `+` and `/`, padding stripped). Standard base64 also works, but `+` must be percent-encoded as `%2B` or it will be decoded as a space by the URL parser. The decoder transparently handles both alphabets and missing padding. + ### `posthog-code://issue` Open the new-task input pre-filled with a GitHub issue's title, URL, and labels. The issue is fetched at link-open time, so the prompt always reflects the latest issue state. From db64abae31d201344b0007f6b8e29c01d9a86c2c Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 19 May 2026 18:43:52 -0700 Subject: [PATCH 6/8] address second creview feedback for deep links v2 --- .../services/new-task-link/service.test.ts | 8 ++++++ .../main/services/new-task-link/service.ts | 2 +- .../task-detail/components/TaskInput.tsx | 16 +---------- .../task-detail/hooks/usePreviewConfig.ts | 28 ++++--------------- .../task-detail/utils/configOptions.ts | 21 ++++++++++++++ .../src/renderer/hooks/useNewTaskDeepLink.ts | 12 ++++++-- 6 files changed, 46 insertions(+), 41 deletions(-) create mode 100644 apps/code/src/renderer/features/task-detail/utils/configOptions.ts diff --git a/apps/code/src/main/services/new-task-link/service.test.ts b/apps/code/src/main/services/new-task-link/service.test.ts index ec19f66b10..7e40f19695 100644 --- a/apps/code/src/main/services/new-task-link/service.test.ts +++ b/apps/code/src/main/services/new-task-link/service.test.ts @@ -298,6 +298,14 @@ describe("NewTaskLinkService", () => { expect(result).toBe(false); }); + it("rejects issue URLs with extra trailing path segments", () => { + const result = mockDeepLink._invoke( + "issue", + new URLSearchParams("url=https://github.com/org/repo/issues/42/edit"), + ); + expect(result).toBe(false); + }); + it("rejects issue URLs with zero or negative issue number", () => { expect( mockDeepLink._invoke( diff --git a/apps/code/src/main/services/new-task-link/service.ts b/apps/code/src/main/services/new-task-link/service.ts index 00be9a9103..3306af15a7 100644 --- a/apps/code/src/main/services/new-task-link/service.ts +++ b/apps/code/src/main/services/new-task-link/service.ts @@ -152,7 +152,7 @@ export class NewTaskLinkService extends TypedEventEmitter { if (parsed.hostname !== "github.com") return null; const parts = parsed.pathname.split("/").filter(Boolean); - if (parts.length < 4 || parts[2] !== "issues") return null; + if (parts.length !== 4 || parts[2] !== "issues") return null; const issueNumber = Number.parseInt(parts[3], 10); if (Number.isNaN(issueNumber) || issueNumber <= 0) return null; diff --git a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx index ded2f0095b..ef70db5c90 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -1,4 +1,3 @@ -import type { SessionConfigOption } from "@agentclientprotocol/sdk"; import { DotPatternBackground } from "@components/DotPatternBackground"; import { EnvironmentSelector } from "@features/environments/components/EnvironmentSelector"; import { FolderPicker } from "@features/folder-picker/components/FolderPicker"; @@ -52,6 +51,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useInitialDirectoryFromFolderId } from "../hooks/useInitialDirectoryFromFolderId"; import { usePreviewConfig } from "../hooks/usePreviewConfig"; import { useTaskCreation } from "../hooks/useTaskCreation"; +import { isValidConfigValue } from "../utils/configOptions"; import { CloudGithubMissingNotice } from "./CloudGithubMissingNotice"; import { SuggestedTasksPanel } from "./SuggestedTasksPanel"; import { type WorkspaceMode, WorkspaceModeSelect } from "./WorkspaceModeSelect"; @@ -67,20 +67,6 @@ interface TaskInputProps { reportAssociation?: TaskInputReportAssociation; } -function isValidConfigValue( - option: SessionConfigOption | undefined, - value: string, -): option is Extract { - if (!option || option.type !== "select") return false; - const items = option.options as Array<{ - value?: string; - options?: Array<{ value: string }>; - }>; - return items.some((o) => - o.options ? o.options.some((g) => g.value === value) : o.value === value, - ); -} - export function TaskInput({ sessionId = "task-input", onTaskCreated, diff --git a/apps/code/src/renderer/features/task-detail/hooks/usePreviewConfig.ts b/apps/code/src/renderer/features/task-detail/hooks/usePreviewConfig.ts index 94a6dfbe7b..1c7c950c35 100644 --- a/apps/code/src/renderer/features/task-detail/hooks/usePreviewConfig.ts +++ b/apps/code/src/renderer/features/task-detail/hooks/usePreviewConfig.ts @@ -6,6 +6,7 @@ import { trpcClient } from "@renderer/trpc/client"; import { getCloudUrlFromRegion } from "@shared/utils/urls"; import { logger } from "@utils/logger"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { flattenConfigValues } from "../utils/configOptions"; const log = logger.scope("preview-config"); @@ -27,14 +28,6 @@ function getOptionByCategory( ); } -function flattenValues( - options: Array<{ value?: string; options?: Array<{ value: string }> }>, -): string[] { - return options.flatMap((o) => - o.options ? o.options.map((go) => go.value) : o.value ? [o.value] : [], - ); -} - const EFFORT_RANK: Record = { low: 0, medium: 1, @@ -119,15 +112,9 @@ export function usePreviewConfig( // available modes. const modeOpt = options.find((o) => o.id === "mode"); const serverDefault = modeOpt?.currentValue; - const availableValues: string[] = - modeOpt?.type === "select" - ? flattenValues( - modeOpt.options as Array<{ - value?: string; - options?: Array<{ value: string }>; - }>, - ) - : []; + const availableValues: string[] = modeOpt + ? flattenConfigValues(modeOpt) + : []; let initialMode: string; if ( @@ -155,12 +142,7 @@ export function usePreviewConfig( if (opt.category !== "thought_level" || opt.type !== "select") { return opt; } - const validValues = flattenValues( - opt.options as Array<{ - value?: string; - options?: Array<{ value: string }>; - }>, - ); + const validValues = flattenConfigValues(opt); if (defaultReasoningEffort === "last_used") { if ( lastUsedReasoningEffort && diff --git a/apps/code/src/renderer/features/task-detail/utils/configOptions.ts b/apps/code/src/renderer/features/task-detail/utils/configOptions.ts new file mode 100644 index 0000000000..ff3e6b716c --- /dev/null +++ b/apps/code/src/renderer/features/task-detail/utils/configOptions.ts @@ -0,0 +1,21 @@ +import type { SessionConfigOption } from "@agentclientprotocol/sdk"; + +type RawOptionItem = { + value?: string; + options?: Array<{ value: string }>; +}; + +export function flattenConfigValues(option: SessionConfigOption): string[] { + if (option.type !== "select") return []; + return (option.options as RawOptionItem[]).flatMap((o) => + o.options ? o.options.map((g) => g.value) : o.value ? [o.value] : [], + ); +} + +export function isValidConfigValue( + option: SessionConfigOption | undefined, + value: string, +): option is Extract { + if (!option || option.type !== "select") return false; + return flattenConfigValues(option).includes(value); +} diff --git a/apps/code/src/renderer/hooks/useNewTaskDeepLink.ts b/apps/code/src/renderer/hooks/useNewTaskDeepLink.ts index 112ca3545d..8d71b14f1a 100644 --- a/apps/code/src/renderer/hooks/useNewTaskDeepLink.ts +++ b/apps/code/src/renderer/hooks/useNewTaskDeepLink.ts @@ -21,6 +21,9 @@ export function useNewTaskDeepLink() { const navigateToTaskInput = useNavigationStore( (state) => state.navigateToTaskInput, ); + const clearTaskInputReportAssociation = useNavigationStore( + (state) => state.clearTaskInputReportAssociation, + ); const isAuthenticated = useAuthStateValue( (state) => state.status === "authenticated", ); @@ -29,6 +32,7 @@ export function useNewTaskDeepLink() { const handleAction = useCallback( async (payload: NewTaskLinkPayload) => { log.info(`Handling deep link action: ${payload.action}`); + clearTaskInputReportAssociation(); switch (payload.action) { case "new": @@ -39,11 +43,15 @@ export function useNewTaskDeepLink() { return handleIssue(payload, navigateToTaskInput); } }, - [navigateToTaskInput], + [navigateToTaskInput, clearTaskInputReportAssociation], ); useEffect(() => { - if (!isAuthenticated || hasFetchedPending.current) return; + if (!isAuthenticated) { + hasFetchedPending.current = false; + return; + } + if (hasFetchedPending.current) return; const fetchPending = async () => { hasFetchedPending.current = true; From d2d84179d3bca4d8cefdf7cdacd025ae3a43c5ae Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 19 May 2026 22:20:29 -0700 Subject: [PATCH 7/8] address greptile feedback for deep links v2 --- .../services/new-task-link/service.test.ts | 29 +++++++++++++++---- .../main/services/new-task-link/service.ts | 4 ++- .../src/renderer/hooks/useNewTaskDeepLink.ts | 1 + docs/DEEP-LINKS.md | 4 ++- 4 files changed, 31 insertions(+), 7 deletions(-) diff --git a/apps/code/src/main/services/new-task-link/service.test.ts b/apps/code/src/main/services/new-task-link/service.test.ts index 7e40f19695..bfb6c84b0a 100644 --- a/apps/code/src/main/services/new-task-link/service.test.ts +++ b/apps/code/src/main/services/new-task-link/service.test.ts @@ -215,8 +215,9 @@ describe("NewTaskLinkService", () => { const listener = vi.fn(); service.on(NewTaskLinkEvent.Action, listener); - const planText = "\xfb\xff"; // bytes that base64 to "+/8=" - const standard = btoa(planText); + // "??>" base64 is "Pz8+" — contains `+` so URL-safe substitutes to `-`. + const planText = "??>"; + const standard = Buffer.from(planText, "utf-8").toString("base64"); const urlSafe = standard .replace(/\+/g, "-") .replace(/\//g, "_") @@ -237,15 +238,33 @@ describe("NewTaskLinkService", () => { const listener = vi.fn(); service.on(NewTaskLinkEvent.Action, listener); - // "+/8=" arrives as " /8=" because URLSearchParams turns + into space + // "Pz8+" arrives as "Pz8 " because URLSearchParams turns + into space. const result = mockDeepLink._invoke( "plan", - new URLSearchParams("plan=+/8="), + new URLSearchParams("plan=Pz8+"), ); expect(result).toBe(true); expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ action: "plan", plan: "\xfb\xff" }), + expect.objectContaining({ action: "plan", plan: "??>" }), + ); + }); + + it("round-trips UTF-8 (emoji, non-ASCII)", () => { + const listener = vi.fn(); + service.on(NewTaskLinkEvent.Action, listener); + + const planText = "Plan 🚀: café — naïve résumé"; + const encoded = Buffer.from(planText, "utf-8").toString("base64"); + + const result = mockDeepLink._invoke( + "plan", + new URLSearchParams(`plan=${encoded}`), + ); + + expect(result).toBe(true); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ action: "plan", plan: planText }), ); }); diff --git a/apps/code/src/main/services/new-task-link/service.ts b/apps/code/src/main/services/new-task-link/service.ts index 3306af15a7..fbbe19c428 100644 --- a/apps/code/src/main/services/new-task-link/service.ts +++ b/apps/code/src/main/services/new-task-link/service.ts @@ -15,7 +15,9 @@ function decodePlanBase64(encoded: string): string | null { .replace(/_/g, "/") .replace(/ /g, "+"); const padding = (4 - (normalized.length % 4)) % 4; - return atob(normalized + "=".repeat(padding)); + const padded = normalized + "=".repeat(padding); + if (!/^[A-Za-z0-9+/]*=*$/.test(padded)) return null; + return Buffer.from(padded, "base64").toString("utf-8"); } catch { return null; } diff --git a/apps/code/src/renderer/hooks/useNewTaskDeepLink.ts b/apps/code/src/renderer/hooks/useNewTaskDeepLink.ts index 8d71b14f1a..bfc614286a 100644 --- a/apps/code/src/renderer/hooks/useNewTaskDeepLink.ts +++ b/apps/code/src/renderer/hooks/useNewTaskDeepLink.ts @@ -64,6 +64,7 @@ export function useNewTaskDeepLink() { }); } } catch (error) { + hasFetchedPending.current = false; log.error("Failed to check for pending new task link:", error); } }; diff --git a/docs/DEEP-LINKS.md b/docs/DEEP-LINKS.md index 26f4dd52b5..e5741168fe 100644 --- a/docs/DEEP-LINKS.md +++ b/docs/DEEP-LINKS.md @@ -42,7 +42,7 @@ Open the new-task input with a longer, base64-encoded plan as the initial prompt | Parameter | Required | Description | |---|---|---| -| `plan` | Yes | Base64-encoded plan text. Standard or URL-safe alphabet, padding optional. | +| `plan` | Yes | Base64-encoded UTF-8 plan text. Standard or URL-safe alphabet, padding optional. | | `repo` | No | Cloud repository slug | | `mode` | No | Initial mode | | `model` | No | Initial model | @@ -53,6 +53,8 @@ posthog-code://plan?plan=SGVsbG8gV29ybGQ%3D&repo=posthog%2Fposthog The link is rejected if `plan` is missing or is not valid base64. +Encoding: the plan must be base64-encoded UTF-8 (e.g. `Buffer.from(text, "utf-8").toString("base64")` in Node, or `btoa(unescape(encodeURIComponent(text)))` in the browser). Multibyte characters (emoji, non-English text) round-trip correctly only when the sender uses UTF-8. + Encoding tip: prefer URL-safe base64 (`-` and `_` instead of `+` and `/`, padding stripped). Standard base64 also works, but `+` must be percent-encoded as `%2B` or it will be decoded as a space by the URL parser. The decoder transparently handles both alphabets and missing padding. ### `posthog-code://issue` From 63db6b1ab1080f0275491e17a1c4369034f7836b Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Wed, 20 May 2026 20:58:06 -0700 Subject: [PATCH 8/8] fix new task deep link cold start --- apps/code/src/main/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/code/src/main/index.ts b/apps/code/src/main/index.ts index b8a1254930..6a005d365e 100644 --- a/apps/code/src/main/index.ts +++ b/apps/code/src/main/index.ts @@ -15,6 +15,7 @@ import type { AuthService } from "./services/auth/service"; import type { ExternalAppsService } from "./services/external-apps/service"; import type { GitHubIntegrationService } from "./services/github-integration/service"; import type { InboxLinkService } from "./services/inbox-link/service"; +import type { NewTaskLinkService } from "./services/new-task-link/service"; import type { NotificationService } from "./services/notification/service"; import type { OAuthService } from "./services/oauth/service"; import { @@ -150,6 +151,7 @@ async function initializeServices(): Promise { container.get(MAIN_TOKENS.UpdatesService); container.get(MAIN_TOKENS.TaskLinkService); container.get(MAIN_TOKENS.InboxLinkService); + container.get(MAIN_TOKENS.NewTaskLinkService); container.get(MAIN_TOKENS.GitHubIntegrationService); container.get(MAIN_TOKENS.SlackIntegrationService); container.get(MAIN_TOKENS.ExternalAppsService);