Skip to content
Open
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
12 changes: 12 additions & 0 deletions docs/DEEP-LINKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ Open the new-task input, optionally pre-filled.
| `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) |
| `source` | No | Attribution for the launching tool (e.g. `linear`), recorded in analytics |
| `issue` | No | External issue identifier (e.g. a Linear `ENG-123`), carried for attribution — not fetched |

*At least one of `prompt` or `repo` must be present. `mode` and `model` alone are not enough to open a task with meaningful context.

Expand All @@ -36,6 +38,16 @@ posthog-code://new?prompt=Fix%20the%20login%20bug&repo=posthog%2Fposthog
posthog-code://new?repo=posthog%2Fposthog&model=claude-opus-4-7&mode=plan
```

#### Using PostHog Code as a Linear custom coding tool

Linear can open issues directly in PostHog Code. In Linear, go to **Settings → Code & reviews**, add a **custom coding tool**, and use this URL template (replace `OWNER/REPO` with your repository):

```
posthog-code://new?repo=OWNER/REPO&source=linear&issue={{issue.identifier}}&prompt={{context}}
```

`{{issue.identifier}}` and `{{context}}` are Linear template variables substituted at launch — `{{context}}` carries the full issue description, comments, and linked references, so the new-task composer opens pre-filled with everything needed. Keep `prompt` as the **last** parameter: very large issue contexts can be truncated by OS-level URL length limits (notably on Windows), and with `prompt` last only prompt text is clipped, never `repo`/`source`/`issue`. Dev builds use `posthog-code-dev://` instead. The same template is available to copy in **Settings → Linear** inside the app.

### `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.
Expand Down
28 changes: 28 additions & 0 deletions packages/core/src/deep-links/newTaskLinkResolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,34 @@ describe("NewTaskLinkResolver", () => {
expect(result.analytics.event).toBe(ANALYTICS_EVENTS.DEEP_LINK_NEW_TASK);
if (result.analytics.event !== ANALYTICS_EVENTS.DEEP_LINK_NEW_TASK) return;
expect(result.analytics.properties.has_prompt).toBe(true);
expect(result.analytics.properties.source).toBeUndefined();
expect(result.analytics.properties.issue_identifier).toBeUndefined();
});

it("carries source and issue identifier into analytics without touching navigation", async () => {
const resolver = makeResolver(vi.fn());
const payload: NewTaskLinkPayload = {
action: "new",
prompt: "do a thing",
repo: "acme/web",
source: "linear",
issueIdentifier: "ENG-123",
};

const result = await resolver.resolve(payload);

expect(result.kind).toBe("navigate");
if (result.kind !== "navigate") return;
expect(result.navigation).toEqual({
initialPrompt: "do a thing",
initialCloudRepository: "acme/web",
initialModel: undefined,
initialMode: undefined,
});
if (result.analytics.event !== ANALYTICS_EVENTS.DEEP_LINK_NEW_TASK) return;
expect(result.analytics.properties.source).toBe("linear");
expect(result.analytics.properties.issue_identifier).toBe("ENG-123");
expect(result.analytics.properties.prompt_length_chars).toBe(10);
});

it("uses the decoded plan as the prompt for a plan-action payload", async () => {
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/deep-links/newTaskLinkResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ export class NewTaskLinkResolver {
has_repo: !!payload.repo,
mode: payload.mode,
model: payload.model,
source: payload.source,
issue_identifier: payload.issueIdentifier,
prompt_length_chars: payload.prompt?.length,
},
},
};
Expand All @@ -68,6 +71,7 @@ export class NewTaskLinkResolver {
has_repo: !!payload.repo,
mode: payload.mode,
model: payload.model,
source: payload.source,
plan_length_chars: payload.plan.length,
},
},
Expand Down Expand Up @@ -141,6 +145,7 @@ export class NewTaskLinkResolver {
issue_number: payload.issueNumber,
mode: payload.mode,
model: payload.model,
source: payload.source,
},
},
};
Expand Down
47 changes: 47 additions & 0 deletions packages/core/src/links/new-task-link.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,53 @@ describe("NewTaskLinkService", () => {
model: "opus",
});
});

it("parses source and issue params", () => {
const listener = vi.fn();
service.on(NewTaskLinkEvent.Action, listener);

const result = mockDeepLink._invoke(
"new",
new URLSearchParams(
"repo=org/repo&source=linear&issue=ENG-123&prompt=ctx",
),
);

expect(result).toBe(true);
expect(listener).toHaveBeenCalledWith(
expect.objectContaining({
action: "new",
prompt: "ctx",
repo: "org/repo",
source: "linear",
issueIdentifier: "ENG-123",
}),
);
});

it("rejects source and issue without prompt or repo", () => {
const result = mockDeepLink._invoke(
"new",
new URLSearchParams("source=linear&issue=ENG-123"),
);
expect(result).toBe(false);
});

it("round-trips encoded newlines and unicode in prompt", () => {
const listener = vi.fn();
service.on(NewTaskLinkEvent.Action, listener);

const prompt = "Fix login 🐛\n\nSee comments — café";
const result = mockDeepLink._invoke(
"new",
new URLSearchParams(`prompt=${encodeURIComponent(prompt)}`),
);

expect(result).toBe(true);
expect(listener).toHaveBeenCalledWith(
expect.objectContaining({ prompt }),
);
});
});

describe("handlePlan", () => {
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/links/new-task-link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,14 @@ export class NewTaskLinkService extends TypedEventEmitter<NewTaskLinkEvents> {
repo: params.get("repo") ?? undefined,
mode: params.get("mode") ?? undefined,
model: params.get("model") ?? undefined,
source: params.get("source") ?? undefined,
};
}

private handleNew(params: URLSearchParams): boolean {
const shared = this.extractSharedParams(params);
const prompt = params.get("prompt") ?? undefined;
const issueIdentifier = params.get("issue") ?? undefined;

if (!prompt && !shared.repo) {
this.log.warn("New task link requires at least prompt or repo");
Expand All @@ -74,12 +76,16 @@ export class NewTaskLinkService extends TypedEventEmitter<NewTaskLinkEvents> {
const payload: NewTaskLinkPayload = {
action: "new",
prompt,
issueIdentifier,
...shared,
};

this.log.info("Handling new task link", {
hasPrompt: !!prompt,
promptLength: prompt?.length,
repo: shared.repo,
source: shared.source,
hasIssue: !!issueIdentifier,
});
return this.emitOrQueue(payload);
}
Expand Down
5 changes: 5 additions & 0 deletions packages/shared/src/analytics-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,12 +349,16 @@ export interface DeepLinkNewTaskProperties {
has_repo: boolean;
mode?: string;
model?: string;
source?: string;
issue_identifier?: string;
prompt_length_chars?: number;
}

export interface DeepLinkPlanProperties {
has_repo: boolean;
mode?: string;
model?: string;
source?: string;
plan_length_chars: number;
}

Expand All @@ -364,6 +368,7 @@ export interface DeepLinkIssueProperties {
issue_number: number;
mode?: string;
model?: string;
source?: string;
}

export interface DeepLinkIssueFailedProperties {
Expand Down
9 changes: 8 additions & 1 deletion packages/shared/src/deep-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,17 @@ export interface NewTaskSharedParams {
repo?: string;
mode?: string;
model?: string;
/** Attribution for links launched by an external tool, e.g. "linear". */
source?: string;
}

export type NewTaskLinkPayload =
| ({ action: "new"; prompt?: string } & NewTaskSharedParams)
| ({
action: "new";
prompt?: string;
/** External issue identifier carried for attribution, e.g. a Linear "ENG-123". */
issueIdentifier?: string;
} & NewTaskSharedParams)
| ({ action: "plan"; plan: string } & NewTaskSharedParams)
| ({
action: "issue";
Expand Down
34 changes: 34 additions & 0 deletions packages/ui/src/features/settings/CopyableCommand.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Check, Copy } from "@phosphor-icons/react";
import { Tooltip } from "@posthog/ui/primitives/Tooltip";
import { Flex, IconButton, Text } from "@radix-ui/themes";
import { useCallback, useState } from "react";

export function CopyableCommand({ command }: { command: string }) {
const [copied, setCopied] = useState(false);

const handleCopy = useCallback(() => {
navigator.clipboard.writeText(command);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [command]);

return (
<Flex
align="center"
gap="2"
className="rounded border border-gray-6 bg-gray-2 px-2 py-1"
>
<Text className="text-[13px] text-gray-11">{command}</Text>
<Tooltip content={copied ? "Copied!" : "Copy"}>
<IconButton
variant="ghost"
size="1"
color={copied ? "green" : "gray"}
onClick={handleCopy}
>
{copied ? <Check size={12} /> : <Copy size={12} />}
</IconButton>
</Tooltip>
</Flex>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
Folder,
GearSix,
GithubLogo,
Kanban,
Keyboard,
Palette,
SignOut,
Expand All @@ -33,6 +34,7 @@ import { DiscordSettings } from "@posthog/ui/features/settings/sections/DiscordS
import { EnvironmentsSettings } from "@posthog/ui/features/settings/sections/environments/EnvironmentsSettings";
import { GeneralSettings } from "@posthog/ui/features/settings/sections/GeneralSettings";
import { GitHubSettings } from "@posthog/ui/features/settings/sections/GitHubSettings";
import { LinearSettings } from "@posthog/ui/features/settings/sections/LinearSettings";
import { NotificationsSettings } from "@posthog/ui/features/settings/sections/NotificationsSettings";
import { PersonalizationSettings } from "@posthog/ui/features/settings/sections/PersonalizationSettings";
import { PlanUsageSettings } from "@posthog/ui/features/settings/sections/PlanUsageSettings";
Expand Down Expand Up @@ -76,6 +78,7 @@ const SIDEBAR_ITEMS: SidebarItem[] = [
{ id: "shortcuts", label: "Shortcuts", icon: <Keyboard size={16} /> },
{ id: "github", label: "GitHub", icon: <GithubLogo size={16} /> },
{ id: "slack", label: "Slack", icon: <SlackLogo size={16} /> },
{ id: "linear", label: "Linear", icon: <Kanban size={16} /> },
{ id: "discord", label: "Discord", icon: <DiscordLogo size={16} /> },
{ id: "signals", label: "Self-driving", icon: <TrafficSignal size={16} /> },
{ id: "updates", label: "Updates", icon: <ArrowsClockwise size={16} /> },
Expand All @@ -92,6 +95,7 @@ const LOCAL_ONLY_CATEGORIES: ReadonlySet<SettingsCategory> = new Set([
"claude-code",
"discord",
"updates",
"linear",
]);

const CATEGORY_TITLES: Record<SettingsCategory, string> = {
Expand All @@ -108,6 +112,7 @@ const CATEGORY_TITLES: Record<SettingsCategory, string> = {
shortcuts: "Shortcuts",
github: "GitHub",
slack: "Slack integration",
linear: "Linear",
discord: "Discord",
signals: "Self-driving",
updates: "Updates",
Expand All @@ -128,6 +133,7 @@ const CATEGORY_COMPONENTS: Record<SettingsCategory, React.ComponentType> = {
shortcuts: ShortcutsSettings,
github: GitHubSettings,
slack: SlackSettings,
linear: LinearSettings,
discord: DiscordSettings,
// Slack notification config lives in the dedicated Slack section; the Signals
// section links out to it rather than duplicating the controls.
Expand Down
35 changes: 2 additions & 33 deletions packages/ui/src/features/settings/sections/ClaudeCodeSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,52 +1,21 @@
import { ArrowSquareOut, Check, Copy, Warning } from "@phosphor-icons/react";
import { ArrowSquareOut, Warning } from "@phosphor-icons/react";
import { ANALYTICS_EVENTS } from "@posthog/shared";
import { CopyableCommand } from "@posthog/ui/features/settings/CopyableCommand";
import { SettingRow } from "@posthog/ui/features/settings/SettingRow";
import { PermissionsSettings } from "@posthog/ui/features/settings/sections/PermissionsSettings";
import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore";
import { Tooltip } from "@posthog/ui/primitives/Tooltip";
import { track } from "@posthog/ui/shell/analytics";
import {
AlertDialog,
Button,
Callout,
Flex,
IconButton,
Link,
Switch,
Text,
} from "@radix-ui/themes";
import { useCallback, useState } from "react";

function CopyableCommand({ command }: { command: string }) {
const [copied, setCopied] = useState(false);

const handleCopy = useCallback(() => {
navigator.clipboard.writeText(command);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [command]);

return (
<Flex
align="center"
gap="2"
className="rounded border border-gray-6 bg-gray-2 px-2 py-1"
>
<Text className="text-[13px] text-gray-11">{command}</Text>
<Tooltip content={copied ? "Copied!" : "Copy"}>
<IconButton
variant="ghost"
size="1"
color={copied ? "green" : "gray"}
onClick={handleCopy}
>
{copied ? <Check size={12} /> : <Copy size={12} />}
</IconButton>
</Tooltip>
</Flex>
);
}

function SettingDescription({
text,
docsUrl,
Expand Down
20 changes: 20 additions & 0 deletions packages/ui/src/features/settings/sections/LinearSettings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { getDeeplinkProtocol } from "@posthog/shared";
import { CopyableCommand } from "@posthog/ui/features/settings/CopyableCommand";
import { SettingRow } from "@posthog/ui/features/settings/SettingRow";
import { Flex } from "@radix-ui/themes";

const DEEPLINK_TEMPLATE = `${getDeeplinkProtocol(import.meta.env.DEV)}://new?repo=OWNER/REPO&source=linear&issue={{issue.identifier}}&prompt={{context}}`;

export function LinearSettings() {
return (
<Flex direction="column">
<SettingRow
label="Open issues from Linear"
description="In Linear, go to Settings → Code & reviews and add a custom coding tool with this URL. Replace OWNER/REPO with your repository. {{issue.identifier}} and {{context}} are Linear template variables substituted when the tool is launched."
noBorder
>
<CopyableCommand command={DEEPLINK_TEMPLATE} />
</SettingRow>
</Flex>
);
}
2 changes: 2 additions & 0 deletions packages/ui/src/features/settings/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type SettingsCategory =
| "shortcuts"
| "github"
| "slack"
| "linear"
| "signals"
| "updates"
| "advanced"
Expand All @@ -31,6 +32,7 @@ export const SETTINGS_CATEGORIES: readonly SettingsCategory[] = [
"shortcuts",
"github",
"slack",
"linear",
"signals",
"updates",
"advanced",
Expand Down