diff --git a/apps/mobile/README.md b/apps/mobile/README.md
index 6c9bbbedae..c68a3daad0 100644
--- a/apps/mobile/README.md
+++ b/apps/mobile/README.md
@@ -32,7 +32,7 @@ pnpm --filter @posthog/mobile start
### Feature Folders
-Code is organized by feature in `src/features/`. Each feature is self-contained with its own components, hooks, stores, and API logic.
+Code is organized by feature in `src/features/`. Features own native components, one-source hooks, and view state. They do not own copies of cloud contracts, transport, orchestration, or presentation rules.
```
src/features/
@@ -46,18 +46,31 @@ src/features/
│ ├── hooks/
│ ├── stores/
│ └── types.ts
-├── conversations/ # PostHog AI conversation list & management
-│ ├── api.ts
+├── inbox/ # Native inbox rendering and query hooks
│ ├── components/
│ ├── hooks/
│ └── stores/
-└── tasks/ # Task management
- ├── api.ts
+└── tasks/ # Native cloud-task rendering and host adapters
├── components/
├── hooks/
+ ├── services/
└── stores/
```
+### Portability boundary
+
+Mobile and desktop use the same cloud-task architecture. New work must preserve these ownership rules:
+
+- `@posthog/shared` owns runtime contracts and Zod schemas.
+- `@posthog/api-client` owns authenticated PostHog HTTPS transport and its request/response types.
+- `@posthog/core` owns cloud-task orchestration and headless presentation decisions, including sessions, queues, permissions, models, repositories, inbox rules, and automation semantics.
+- `apps/mobile` owns Expo lifecycle, React Native rendering, gestures, sheets, notifications, audio, secure storage, and small persisted view-state stores.
+- `@posthog/ui` owns the DOM/Quill renderer and web view state.
+
+Do not add a mobile API facade, duplicate a shared type, or re-export a core helper through a mobile file. Import the owning package directly. If desktop and mobile need different visuals, add a headless descriptor or decision function to core and keep two thin renderers.
+
+Intentional host differences are limited to platform capabilities and view state. Mobile may persist native navigation state, cached picker snapshots, optimistic attachment echoes, and notification preferences; it must not implement retries, reconnection, transport parsing, task lifecycle, or cross-store decisions in those stores.
+
### File-Based Routing
Routes for the screens are defined by the file structure in `src/app/` using expo-router.
diff --git a/apps/mobile/src/app/(tabs)/inbox.tsx b/apps/mobile/src/app/(tabs)/inbox.tsx
index 1e64ab8df4..dd66e06c87 100644
--- a/apps/mobile/src/app/(tabs)/inbox.tsx
+++ b/apps/mobile/src/app/(tabs)/inbox.tsx
@@ -1,3 +1,4 @@
+import { buildInboxViewedProperties } from "@posthog/core/inbox/engagement";
import { INBOX_PIPELINE_STATUSES } from "@posthog/core/inbox/reportFiltering";
import type { SignalReport } from "@posthog/shared/domain-types";
import { useFocusEffect, useRouter } from "expo-router";
@@ -24,7 +25,6 @@ import {
} from "@/features/inbox/stores/dismissedReportsStore";
import { useInboxFilterStore } from "@/features/inbox/stores/inboxFilterStore";
import { useInboxStore } from "@/features/inbox/stores/inboxStore";
-import { buildInboxViewedProperties } from "@/features/inbox/utils";
import { useIntegrations } from "@/features/tasks/hooks/useIntegrations";
import { ANALYTICS_EVENTS, useAnalytics } from "@/lib/analytics";
@@ -67,12 +67,16 @@ export default function InboxScreen() {
viewedFiredForFocusRef.current = focusVersion;
analytics.track(
ANALYTICS_EVENTS.INBOX_VIEWED,
- buildInboxViewedProperties(reports, totalCount, {
- sourceProductFilter,
- statusFilter,
- suggestedReviewerFilter,
- priorityFilter,
- defaultStatusFilter: INBOX_PIPELINE_STATUSES,
+ buildInboxViewedProperties({
+ visibleReports: reports,
+ totalCount,
+ filters: {
+ sourceProductFilter,
+ statusFilter,
+ suggestedReviewerFilter,
+ priorityFilter,
+ defaultStatusFilter: INBOX_PIPELINE_STATUSES,
+ },
}),
);
}, [
diff --git a/apps/mobile/src/app/automation/[id].tsx b/apps/mobile/src/app/automation/[id].tsx
index 55f9bd5c6d..159742297c 100644
--- a/apps/mobile/src/app/automation/[id].tsx
+++ b/apps/mobile/src/app/automation/[id].tsx
@@ -1,5 +1,6 @@
import { Text } from "@components/text";
import { TaskAutomationValidationError } from "@posthog/api-client/posthog-client";
+import { parseSkillTemplateId } from "@posthog/core/automations/automationTemplatePresentation";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useState } from "react";
import {
@@ -20,7 +21,6 @@ import {
useUpdateTaskAutomation,
} from "@/features/tasks/hooks/useAutomations";
import { useTask } from "@/features/tasks/hooks/useTasks";
-import { parseSkillTemplateId } from "@/features/tasks/skills/skillTemplateIds";
import { useThemeColors } from "@/lib/theme";
export default function AutomationDetailScreen() {
diff --git a/apps/mobile/src/app/automation/create.tsx b/apps/mobile/src/app/automation/create.tsx
index 1f4d662bff..1fe2c452c0 100644
--- a/apps/mobile/src/app/automation/create.tsx
+++ b/apps/mobile/src/app/automation/create.tsx
@@ -1,4 +1,5 @@
import { TaskAutomationValidationError } from "@posthog/api-client/posthog-client";
+import { formatSkillTemplateId } from "@posthog/core/automations/automationTemplatePresentation";
import { getCalendars } from "expo-localization";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useMemo, useRef, useState } from "react";
@@ -14,7 +15,6 @@ import { Text } from "@/components/text";
import { AutomationForm } from "@/features/tasks/components/AutomationForm";
import { useCreateTaskAutomation } from "@/features/tasks/hooks/useAutomations";
import { useSkillStoreSkill } from "@/features/tasks/skills/hooks";
-import { formatSkillTemplateId } from "@/features/tasks/skills/skillTemplateIds";
import { useScreenInsets } from "@/hooks/useScreenInsets";
import { useThemeColors } from "@/lib/theme";
diff --git a/apps/mobile/src/app/mcp-servers/add-custom.tsx b/apps/mobile/src/app/mcp-servers/add-custom.tsx
index fff6f5e3bf..cb48d1b68e 100644
--- a/apps/mobile/src/app/mcp-servers/add-custom.tsx
+++ b/apps/mobile/src/app/mcp-servers/add-custom.tsx
@@ -1,4 +1,5 @@
import { Text } from "@components/text";
+import type { McpAuthType } from "@posthog/api-client/types";
import { router } from "expo-router";
import { Lock } from "phosphor-react-native";
import { useState } from "react";
@@ -14,7 +15,6 @@ import {
import { FloatingMcpHeader } from "@/features/mcp/components/FloatingMcpHeader";
import { useMcpInstallations } from "@/features/mcp/hooks";
import { installCustomWithOAuth } from "@/features/mcp/oauth";
-import type { McpAuthType } from "@/features/mcp/types";
import { useScreenInsets } from "@/hooks/useScreenInsets";
import { logger } from "@/lib/logger";
import { useThemeColors } from "@/lib/theme";
diff --git a/apps/mobile/src/app/mcp-servers/index.tsx b/apps/mobile/src/app/mcp-servers/index.tsx
index e566e7ef75..680609d400 100644
--- a/apps/mobile/src/app/mcp-servers/index.tsx
+++ b/apps/mobile/src/app/mcp-servers/index.tsx
@@ -1,4 +1,8 @@
import { Text } from "@components/text";
+import type {
+ McpRecommendedServer,
+ McpServerInstallation,
+} from "@posthog/api-client/types";
import { useRouter } from "expo-router";
import { MagnifyingGlass, Plus, PuzzlePiece } from "phosphor-react-native";
import { useMemo, useState } from "react";
@@ -17,10 +21,6 @@ import {
recommendedToRowProps,
} from "@/features/mcp/components/McpServerRow";
import { useMcpInstallations, useMcpMarketplace } from "@/features/mcp/hooks";
-import type {
- McpRecommendedServer,
- McpServerInstallation,
-} from "@/features/mcp/types";
import { useScreenInsets } from "@/hooks/useScreenInsets";
import { useThemeColors } from "@/lib/theme";
diff --git a/apps/mobile/src/app/mcp-servers/installation/[id].tsx b/apps/mobile/src/app/mcp-servers/installation/[id].tsx
index d0dd6fcd02..837edb91c9 100644
--- a/apps/mobile/src/app/mcp-servers/installation/[id].tsx
+++ b/apps/mobile/src/app/mcp-servers/installation/[id].tsx
@@ -1,4 +1,6 @@
import { Text } from "@components/text";
+import type { McpApprovalState } from "@posthog/api-client/types";
+import { isStdioMcpServer } from "@posthog/core/mcp-servers/presentation";
import { router, useLocalSearchParams } from "expo-router";
import {
ArrowsClockwise,
@@ -28,8 +30,6 @@ import {
} from "@/features/mcp/hooks";
import { reauthorizeInstallation } from "@/features/mcp/oauth";
import { getMcpConnectionManager } from "@/features/mcp/service";
-import type { McpApprovalState } from "@/features/mcp/types";
-import { isStdioServer } from "@/features/mcp/types";
import { useScreenInsets } from "@/hooks/useScreenInsets";
import { logger } from "@/lib/logger";
import { useThemeColors } from "@/lib/theme";
@@ -75,7 +75,7 @@ export default function McpInstallationDetailScreen() {
);
}
- const stdio = isStdioServer(installation);
+ const stdio = isStdioMcpServer(installation);
const handleEnabledChange = (enabled: boolean) => {
updateMutation.mutate({
diff --git a/apps/mobile/src/app/mcp-servers/template/[id].tsx b/apps/mobile/src/app/mcp-servers/template/[id].tsx
index 78e138c19a..348c7a488b 100644
--- a/apps/mobile/src/app/mcp-servers/template/[id].tsx
+++ b/apps/mobile/src/app/mcp-servers/template/[id].tsx
@@ -1,4 +1,5 @@
import { Text } from "@components/text";
+import { isStdioMcpServer } from "@posthog/core/mcp-servers/presentation";
import { router, useLocalSearchParams } from "expo-router";
import { Lock, Warning } from "phosphor-react-native";
import { useMemo, useState } from "react";
@@ -17,7 +18,6 @@ import {
useMcpMarketplace,
} from "@/features/mcp/hooks";
import { installTemplateWithOAuth } from "@/features/mcp/oauth";
-import { isStdioServer } from "@/features/mcp/types";
import { useScreenInsets } from "@/hooks/useScreenInsets";
import { logger } from "@/lib/logger";
import { openExternalUrl } from "@/lib/openExternalUrl";
@@ -71,7 +71,7 @@ export default function McpTemplateDetailScreen() {
);
}
- const stdio = isStdioServer(template);
+ const stdio = isStdioMcpServer(template);
const handleInstall = async () => {
if (!template) return;
diff --git a/apps/mobile/src/app/task/index.tsx b/apps/mobile/src/app/task/index.tsx
index 9df757f9de..6f567488a6 100644
--- a/apps/mobile/src/app/task/index.tsx
+++ b/apps/mobile/src/app/task/index.tsx
@@ -3,6 +3,7 @@ import {
DEFAULT_CLAUDE_EXECUTION_MODE,
getAvailableModes,
} from "@posthog/core/sessions/executionModes";
+import { resolveCloudComposerModelChange } from "@posthog/core/task-detail/composerModelPolicy";
import {
DEFAULT_GATEWAY_MODEL,
DEFAULT_REASONING_EFFORT,
@@ -56,10 +57,10 @@ import {
import type { PendingAttachment } from "@/features/tasks/composer/attachments/types";
import { DotBackground } from "@/features/tasks/composer/DotBackground";
import {
- getMobileModelOptions,
+ getComposerModelOptions,
+ getConfigOptionLabel,
+ getMobileExecutionModes,
getModelConfigOption,
- getModelLabel,
- resolveAvailableModel,
} from "@/features/tasks/composer/options";
import { Pill } from "@/features/tasks/composer/Pill";
import { RepositoryPickerInline } from "@/features/tasks/composer/RepositoryPickerInline";
@@ -88,7 +89,7 @@ import { getPostHogApiClient } from "@/lib/posthogApiClient";
import { toRgba, useThemeColors } from "@/lib/theme";
const log = logger.scope("task-create");
-const EXECUTION_MODES = getAvailableModes();
+const EXECUTION_MODES = getMobileExecutionModes(getAvailableModes());
const SUGGESTIONS = [
"Create or update my CLAUDE.md file",
@@ -129,9 +130,10 @@ export default function NewTaskScreen() {
const { insets, bottom } = useScreenInsets();
const keyboard = useReanimatedKeyboardAnimation();
const restingBottom = bottom("compact");
- const { configOptions, hasLiveConfig } = useCloudTaskConfigOptions("claude");
+ const { configOptions, hasLiveConfig, isConfigReady } =
+ useCloudTaskConfigOptions("claude");
const modelConfigOption = getModelConfigOption(configOptions);
- const mobileModelOptions = getMobileModelOptions(modelConfigOption);
+ const mobileModelOptions = getComposerModelOptions(modelConfigOption);
const {
error,
hasGithubIntegration,
@@ -214,12 +216,14 @@ export default function NewTaskScreen() {
useEffect(() => {
if (!hasLiveConfig) return;
- const availableModel = resolveAvailableModel(modelConfigOption, model);
- if (availableModel === model) return;
- setModel(availableModel);
- if (!isSupportedReasoningEffort("claude", availableModel, reasoning)) {
- setReasoning(DEFAULT_REASONING_EFFORT);
- }
+ const next = resolveCloudComposerModelChange({
+ adapter: "claude",
+ modelOption: modelConfigOption,
+ requestedModel: model,
+ reasoning,
+ });
+ if (next.model !== model) setModel(next.model);
+ if (next.reasoning !== reasoning) setReasoning(next.reasoning);
}, [hasLiveConfig, model, modelConfigOption, reasoning]);
const [creating, setCreating] = useState(false);
const [repoSheetOpen, setRepoSheetOpen] = useState(false);
@@ -411,7 +415,7 @@ export default function NewTaskScreen() {
const hasContent = !!prompt.trim() || attachments.length > 0;
const canSubmit =
- hasLiveConfig &&
+ isConfigReady &&
hasContent &&
isRepositorySelectionComplete(selection) &&
!creating;
@@ -424,7 +428,7 @@ export default function NewTaskScreen() {
useWarmTask({
repository: selection.repository,
githubIntegrationId: selection.integrationId,
- composerIsEmpty: !hasContent || !hasLiveConfig,
+ composerIsEmpty: !hasContent || !isConfigReady,
runtimeAdapter: "claude",
model,
reasoningEffort: showReasoningPill ? reasoning : null,
@@ -649,7 +653,12 @@ export default function NewTaskScreen() {
}
- label={getModelLabel(modelConfigOption, model)}
+ label={
+ getConfigOptionLabel(
+ modelConfigOption.options,
+ model,
+ ) ?? model
+ }
onPress={() => setModelSheetOpen(true)}
/>
@@ -769,10 +778,14 @@ export default function NewTaskScreen() {
title="Model"
value={model}
onChange={(value) => {
- setModel(value);
- if (!isSupportedReasoningEffort("claude", value, reasoning)) {
- setReasoning(DEFAULT_REASONING_EFFORT);
- }
+ const next = resolveCloudComposerModelChange({
+ adapter: "claude",
+ modelOption: modelConfigOption,
+ requestedModel: value,
+ reasoning,
+ });
+ setModel(next.model);
+ setReasoning(next.reasoning);
}}
onClose={() => setModelSheetOpen(false)}
options={mobileModelOptions.map((modelOption) => ({
diff --git a/apps/mobile/src/features/chat/components/AgentMessage.tsx b/apps/mobile/src/features/chat/components/AgentMessage.tsx
index e6fa274cb6..e767f2f43a 100644
--- a/apps/mobile/src/features/chat/components/AgentMessage.tsx
+++ b/apps/mobile/src/features/chat/components/AgentMessage.tsx
@@ -1,10 +1,10 @@
+import { pickThinkingActivity } from "@posthog/core/sessions/thinkingActivities";
import { Brain } from "phosphor-react-native";
import { useState } from "react";
import { Pressable, Text, View } from "react-native";
import { formatRelativeTime } from "@/lib/format";
import { useThemeColors } from "@/lib/theme";
import { usePeriodicRerender } from "../hooks/usePeriodicRerender";
-import { getRandomThinkingMessage } from "../utils/thinkingMessages";
import { CopyButton } from "./CopyButton";
import { MarkdownText } from "./MarkdownText";
import { ToolMessage } from "./ToolMessage";
@@ -110,7 +110,7 @@ export function AgentMessage({
{isLoading && !content && !thinkingText && (
- {getRandomThinkingMessage()}
+ {pickThinkingActivity(Math.random())}...
)}
diff --git a/apps/mobile/src/features/chat/components/ToolMessage.tsx b/apps/mobile/src/features/chat/components/ToolMessage.tsx
index 3859504777..2555a211e1 100644
--- a/apps/mobile/src/features/chat/components/ToolMessage.tsx
+++ b/apps/mobile/src/features/chat/components/ToolMessage.tsx
@@ -1,3 +1,9 @@
+import {
+ formatPosthogExecBody,
+ getPostHogExecDisplay,
+ isPostHogExecTool,
+} from "@posthog/core/sessions/posthogExecDisplay";
+import { parseMcpToolName } from "@posthog/shared";
import { useRouter } from "expo-router";
import {
ArrowsClockwise,
@@ -22,13 +28,7 @@ import {
TouchableOpacity,
View,
} from "react-native";
-import {
- formatPosthogExecBody,
- getPostHogExecDisplay,
- isPostHogExecTool,
-} from "@/features/chat/utils/posthogExecDisplay";
import { McpAppHost } from "@/features/mcp/components/McpAppHost";
-import { isMcpToolName } from "@/features/mcp/utils/mcpToolName";
import {
getColorForClass,
highlightCode,
@@ -942,7 +942,8 @@ export function ToolMessage({
// MCP App tools render via the WebView host — skip PostHog exec (which has
// its own renderer above) and only kick in once the tool finished or while
// it's running so we don't show empty WebView shells for pending tools.
- const isMcpAppTool = !isPostHogExec && isMcpToolName(effectiveToolName);
+ const isMcpAppTool =
+ !isPostHogExec && parseMcpToolName(effectiveToolName) !== undefined;
if (isMcpAppTool && !isPending) {
return (
diff --git a/apps/mobile/src/features/chat/utils/posthogExecDisplay.test.ts b/apps/mobile/src/features/chat/utils/posthogExecDisplay.test.ts
index c2a81f3e0f..ca08b80c33 100644
--- a/apps/mobile/src/features/chat/utils/posthogExecDisplay.test.ts
+++ b/apps/mobile/src/features/chat/utils/posthogExecDisplay.test.ts
@@ -1,9 +1,9 @@
-import { describe, expect, it } from "vitest";
import {
formatPosthogExecBody,
getPostHogExecDisplay,
isPostHogExecTool,
-} from "./posthogExecDisplay";
+} from "@posthog/core/sessions/posthogExecDisplay";
+import { describe, expect, it } from "vitest";
describe("isPostHogExecTool", () => {
it("matches the bare posthog exec tool", () => {
diff --git a/apps/mobile/src/features/chat/utils/posthogExecDisplay.ts b/apps/mobile/src/features/chat/utils/posthogExecDisplay.ts
deleted file mode 100644
index d46e759f25..0000000000
--- a/apps/mobile/src/features/chat/utils/posthogExecDisplay.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-/**
- * Mirrors the desktop PostHog MCP exec display logic so mobile unwraps the
- * dispatched sub-tool instead of showing the raw `exec` transport wrapper.
- *
- * Supported verbs:
- * tools
- * search
- * info
- * schema [field_path]
- * call [--json]
- */
-
-const POSTHOG_EXEC_TOOL_RE = /^mcp__(?:plugin_)?posthog(?:_[^_]+)*__exec$/;
-
-const POSTHOG_VERB_RE =
- /^\s*(tools|search|info|schema|call)(?:\s+([\s\S]*))?\s*$/;
-const POSTHOG_CALL_BODY_RE = /^(?:--json\s+)?([a-zA-Z0-9_-]+)\s*([\s\S]*)$/;
-const POSTHOG_TOOL_NAME_RE = /^([a-zA-Z0-9_-]+)\s*([\s\S]*)$/;
-
-export interface PostHogExecDisplay {
- label: string;
- input?: string;
-}
-
-export function isPostHogExecTool(toolName: string): boolean {
- return POSTHOG_EXEC_TOOL_RE.test(toolName);
-}
-
-export function getPostHogExecDisplay(
- toolInput: unknown,
-): PostHogExecDisplay | null {
- if (!toolInput || typeof toolInput !== "object") return null;
- const obj = toolInput as { command?: unknown; input?: unknown };
-
- if (typeof obj.command !== "string") return null;
- const verbMatch = obj.command.match(POSTHOG_VERB_RE);
- if (!verbMatch) return null;
-
- const verb = verbMatch[1] as "tools" | "search" | "info" | "schema" | "call";
- const rest = (verbMatch[2] ?? "").trim();
- const explicitInput = readExplicitInput(obj.input);
-
- switch (verb) {
- case "tools":
- return { label: "List tools", input: undefined };
-
- case "search":
- return {
- label: "Search tools",
- input: explicitInput ?? (rest.length > 0 ? rest : undefined),
- };
-
- case "info":
- return rest.length > 0
- ? { label: `Read ${rest}`, input: undefined }
- : { label: "Read tool", input: undefined };
-
- case "schema": {
- const match = rest.match(POSTHOG_TOOL_NAME_RE);
- if (!match) return { label: "Inspect schema", input: undefined };
- const subTool = match[1];
- const fieldPath = (match[2] ?? "").trim();
- const path =
- explicitInput ?? (fieldPath.length > 0 ? fieldPath : undefined);
- return {
- label: path
- ? `Inspect ${subTool}.${path}`
- : `Inspect ${subTool} fields`,
- input: undefined,
- };
- }
-
- case "call": {
- const match = rest.match(POSTHOG_CALL_BODY_RE);
- if (!match) return null;
- const subTool = match[1];
- const args = (match[2] ?? "").trim();
- return {
- label: subTool,
- input: explicitInput ?? (args.length > 0 ? args : undefined),
- };
- }
- }
-}
-
-function readExplicitInput(value: unknown): string | undefined {
- if (value === undefined || value === null) return undefined;
- if (typeof value === "string") return value.trim() || undefined;
- try {
- return JSON.stringify(value);
- } catch {
- return undefined;
- }
-}
-
-export function formatPosthogExecBody(
- input: string | undefined,
-): string | undefined {
- if (!input) return undefined;
- try {
- const parsed = JSON.parse(input);
- if (parsed && typeof parsed === "object") {
- return JSON.stringify(parsed, null, 2);
- }
- } catch {
- // Not JSON; show the raw input.
- }
- return input;
-}
diff --git a/apps/mobile/src/features/chat/utils/thinkingMessages.test.ts b/apps/mobile/src/features/chat/utils/thinkingMessages.test.ts
deleted file mode 100644
index de528d1560..0000000000
--- a/apps/mobile/src/features/chat/utils/thinkingMessages.test.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-import {
- getRandomThinkingActivity,
- getRandomThinkingMessage,
- THINKING_MESSAGES,
-} from "./thinkingMessages";
-
-describe("thinkingMessages", () => {
- it("includes the whimsical cloud-run loading messages from desktop", () => {
- expect(THINKING_MESSAGES).toContain("Kerfuffling");
- expect(THINKING_MESSAGES).toContain("Flibbertigibbeting");
- expect(THINKING_MESSAGES).toContain("Discombobulating");
- });
-
- it("returns a bare activity label and a message variant with ellipsis", () => {
- const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0);
-
- expect(getRandomThinkingActivity()).toBe("Booping");
- expect(getRandomThinkingMessage()).toBe("Booping...");
-
- randomSpy.mockRestore();
- });
-});
diff --git a/apps/mobile/src/features/chat/utils/thinkingMessages.ts b/apps/mobile/src/features/chat/utils/thinkingMessages.ts
deleted file mode 100644
index 35bd41b351..0000000000
--- a/apps/mobile/src/features/chat/utils/thinkingMessages.ts
+++ /dev/null
@@ -1,98 +0,0 @@
-// Random thinking messages displayed while AI is generating
-// Based on posthog/frontend/src/scenes/max/utils/thinkingMessages.ts
-
-export const THINKING_MESSAGES = [
- "Booping",
- "Crunching",
- "Digging",
- "Fetching",
- "Inferring",
- "Indexing",
- "Juggling",
- "Noodling",
- "Peeking",
- "Percolating",
- "Poking",
- "Pondering",
- "Scanning",
- "Scrambling",
- "Sifting",
- "Sniffing",
- "Spelunking",
- "Tinkering",
- "Unraveling",
- "Decoding",
- "Trekking",
- "Sorting",
- "Trimming",
- "Mulling",
- "Surfacing",
- "Rummaging",
- "Scouting",
- "Scouring",
- "Threading",
- "Hunting",
- "Swizzling",
- "Grokking",
- "Hedging",
- "Scheming",
- "Unfurling",
- "Puzzling",
- "Dissecting",
- "Stacking",
- "Snuffling",
- "Hashing",
- "Clustering",
- "Teasing",
- "Cranking",
- "Merging",
- "Snooping",
- "Rewiring",
- "Bundling",
- "Linking",
- "Mapping",
- "Tickling",
- "Flicking",
- "Hopping",
- "Rolling",
- "Zipping",
- "Twisting",
- "Blooming",
- "Sparking",
- "Nesting",
- "Looping",
- "Wiring",
- "Snipping",
- "Zoning",
- "Tracing",
- "Warping",
- "Twinkling",
- "Flipping",
- "Priming",
- "Snagging",
- "Scuttling",
- "Framing",
- "Sharpening",
- "Flibbertigibbeting",
- "Kerfuffling",
- "Dithering",
- "Discombobulating",
- "Rambling",
- "Befuddling",
- "Waffling",
- "Muckling",
- "Hobnobbing",
- "Galumphing",
- "Puttering",
- "Whiffling",
- "Thinking",
-];
-
-export function getRandomThinkingActivity(): string {
- const randomIndex = Math.floor(Math.random() * THINKING_MESSAGES.length);
- return THINKING_MESSAGES[randomIndex];
-}
-
-export function getRandomThinkingMessage(): string {
- return `${getRandomThinkingActivity()}...`;
-}
diff --git a/apps/mobile/src/features/inbox/activityLog.test.ts b/apps/mobile/src/features/inbox/activityLog.test.ts
index 8ba0a681fe..1032197c50 100644
--- a/apps/mobile/src/features/inbox/activityLog.test.ts
+++ b/apps/mobile/src/features/inbox/activityLog.test.ts
@@ -1,12 +1,12 @@
-import type { AnySignalReportArtefact } from "@posthog/shared/domain-types";
-import { describe, expect, it } from "vitest";
import {
attributionLabel,
parseDiffLines,
selectActivityArtefacts,
shortSha,
taskRunLabel,
-} from "./activityLog";
+} from "@posthog/core/inbox/activityLog";
+import type { AnySignalReportArtefact } from "@posthog/shared/domain-types";
+import { describe, expect, it } from "vitest";
function commit(id: string, createdAt: string): AnySignalReportArtefact {
return {
diff --git a/apps/mobile/src/features/inbox/activityLog.ts b/apps/mobile/src/features/inbox/activityLog.ts
deleted file mode 100644
index cb11aefa22..0000000000
--- a/apps/mobile/src/features/inbox/activityLog.ts
+++ /dev/null
@@ -1,82 +0,0 @@
-import type { AnySignalReportArtefact } from "@posthog/shared/domain-types";
-
-export type ActivityArtefact = Extract<
- AnySignalReportArtefact,
- { type: "commit" | "task_run" }
->;
-
-export function selectActivityArtefacts(
- artefacts: AnySignalReportArtefact[],
-): ActivityArtefact[] {
- return artefacts
- .filter(
- (a): a is ActivityArtefact =>
- a.type === "commit" || a.type === "task_run",
- )
- .sort((a, b) => a.created_at.localeCompare(b.created_at));
-}
-
-export function shortSha(sha: string): string {
- return sha.slice(0, 12);
-}
-
-const SIGNALS_TYPE_LABELS: Record = {
- research: "Research",
- implementation: "Implementation",
- repo_selection: "Repo selection",
-};
-
-function humanizeIdentifier(value: string): string {
- const spaced = value.replace(/[_-]+/g, " ").trim();
- return spaced.charAt(0).toUpperCase() + spaced.slice(1);
-}
-
-export function taskRunLabel(content: {
- product: string;
- type: string;
-}): string {
- if (content.product === "signals") {
- return (
- SIGNALS_TYPE_LABELS[content.type] ?? humanizeIdentifier(content.type)
- );
- }
- return humanizeIdentifier(content.type);
-}
-
-export function attributionLabel(artefact: {
- created_by?: { first_name?: string; email: string } | null;
- task_id?: string | null;
-}): string | null {
- if (artefact.created_by) {
- return artefact.created_by.first_name?.trim() || artefact.created_by.email;
- }
- if (artefact.task_id) {
- return "agent";
- }
- return null;
-}
-
-type DiffLineKind = "add" | "del" | "hunk" | "context";
-
-interface DiffLine {
- text: string;
- kind: DiffLineKind;
-}
-
-export function parseDiffLines(diff: string): DiffLine[] {
- return diff
- .replace(/\n$/, "")
- .split("\n")
- .map((text) => {
- if (text.startsWith("+") && !text.startsWith("+++")) {
- return { text, kind: "add" as const };
- }
- if (text.startsWith("-") && !text.startsWith("---")) {
- return { text, kind: "del" as const };
- }
- if (text.startsWith("@@")) {
- return { text, kind: "hunk" as const };
- }
- return { text, kind: "context" as const };
- });
-}
diff --git a/apps/mobile/src/features/inbox/components/ArchivedReportList.tsx b/apps/mobile/src/features/inbox/components/ArchivedReportList.tsx
index a0aed9bc98..72de972737 100644
--- a/apps/mobile/src/features/inbox/components/ArchivedReportList.tsx
+++ b/apps/mobile/src/features/inbox/components/ArchivedReportList.tsx
@@ -1,4 +1,5 @@
import { Text } from "@components/text";
+import { isRestorableReport } from "@posthog/core/inbox/reportMembership";
import { inboxStatusLabel } from "@posthog/core/inbox/reportPresentation";
import { dismissalReasonLabel } from "@posthog/shared";
import type { SignalReport } from "@posthog/shared/domain-types";
@@ -14,7 +15,7 @@ import {
} from "react-native";
import { useThemeColors } from "@/lib/theme";
import { useArchivedReports, useRestoreReport } from "../hooks/useInboxReports";
-import { formatReportTimestamp, isRestorableReport } from "../utils";
+import { formatReportTimestamp } from "../utils";
interface ArchivedReportListProps {
onReportPress?: (report: SignalReport) => void;
diff --git a/apps/mobile/src/features/inbox/components/ArtefactCommit.tsx b/apps/mobile/src/features/inbox/components/ArtefactCommit.tsx
index 19f4cc2510..3da3aa2355 100644
--- a/apps/mobile/src/features/inbox/components/ArtefactCommit.tsx
+++ b/apps/mobile/src/features/inbox/components/ArtefactCommit.tsx
@@ -1,10 +1,10 @@
import { Text } from "@components/text";
+import { shortSha } from "@posthog/core/inbox/activityLog";
import type { CommitContent } from "@posthog/shared/domain-types";
import { CaretDown, CaretRight } from "phosphor-react-native";
import { useState } from "react";
import { ActivityIndicator, Pressable, View } from "react-native";
import { useThemeColors } from "@/lib/theme";
-import { shortSha } from "../activityLog";
import { useCommitDiff } from "../hooks/useInboxReports";
import { DiffBlock } from "./DiffBlock";
diff --git a/apps/mobile/src/features/inbox/components/ArtefactTaskRun.tsx b/apps/mobile/src/features/inbox/components/ArtefactTaskRun.tsx
index 2c2b004a58..f7d5f2d154 100644
--- a/apps/mobile/src/features/inbox/components/ArtefactTaskRun.tsx
+++ b/apps/mobile/src/features/inbox/components/ArtefactTaskRun.tsx
@@ -1,11 +1,11 @@
import { Text } from "@components/text";
+import { taskRunLabel } from "@posthog/core/inbox/activityLog";
import type { TaskRunArtefactContent } from "@posthog/shared/domain-types";
import { useRouter } from "expo-router";
import { CaretRight } from "phosphor-react-native";
import { Pressable, View } from "react-native";
import { useTask } from "@/features/tasks";
import { useThemeColors } from "@/lib/theme";
-import { taskRunLabel } from "../activityLog";
export function ArtefactTaskRun({
content,
diff --git a/apps/mobile/src/features/inbox/components/DiffBlock.tsx b/apps/mobile/src/features/inbox/components/DiffBlock.tsx
index 4bf6bbc8c5..f6329cbd3b 100644
--- a/apps/mobile/src/features/inbox/components/DiffBlock.tsx
+++ b/apps/mobile/src/features/inbox/components/DiffBlock.tsx
@@ -1,6 +1,6 @@
import { Text } from "@components/text";
+import { parseDiffLines } from "@posthog/core/inbox/activityLog";
import { ScrollView, View } from "react-native";
-import { parseDiffLines } from "../activityLog";
const LINE_CLASS: Record = {
add: "bg-status-success/15 text-status-success",
diff --git a/apps/mobile/src/features/inbox/components/ReportActivity.tsx b/apps/mobile/src/features/inbox/components/ReportActivity.tsx
index a20d721bd4..d33b501b2f 100644
--- a/apps/mobile/src/features/inbox/components/ReportActivity.tsx
+++ b/apps/mobile/src/features/inbox/components/ReportActivity.tsx
@@ -1,15 +1,15 @@
import { Text } from "@components/text";
+import {
+ type ActivityArtefact,
+ attributionLabel,
+ selectActivityArtefacts,
+} from "@posthog/core/inbox/activityLog";
import type { AnySignalReportArtefact } from "@posthog/shared/domain-types";
import { ClockCounterClockwise } from "phosphor-react-native";
import { useMemo } from "react";
import { View } from "react-native";
import { formatRelativeTime } from "@/lib/format";
import { useThemeColors } from "@/lib/theme";
-import {
- type ActivityArtefact,
- attributionLabel,
- selectActivityArtefacts,
-} from "../activityLog";
import { ArtefactCommit } from "./ArtefactCommit";
import { ArtefactTaskRun } from "./ArtefactTaskRun";
diff --git a/apps/mobile/src/features/inbox/hooks/useInboxReports.ts b/apps/mobile/src/features/inbox/hooks/useInboxReports.ts
index e262300bf9..5a9229edc9 100644
--- a/apps/mobile/src/features/inbox/hooks/useInboxReports.ts
+++ b/apps/mobile/src/features/inbox/hooks/useInboxReports.ts
@@ -7,6 +7,7 @@ import {
INBOX_DISMISSED_STATUS_FILTER,
INBOX_REFETCH_INTERVAL_MS,
} from "@posthog/core/inbox/reportFiltering";
+import { isRestorableReport } from "@posthog/core/inbox/reportMembership";
import type { DismissalReasonOptionValue } from "@posthog/shared";
import type {
AvailableSuggestedReviewersResponse,
@@ -31,7 +32,6 @@ import { useMemo } from "react";
import { useAuthStore } from "@/features/auth";
import { getPostHogApiClient } from "@/lib/posthogApiClient";
import { useInboxFilterStore } from "../stores/inboxFilterStore";
-import { isRestorableReport } from "../utils";
export const inboxKeys = {
all: ["inbox", "signal-reports"] as const,
diff --git a/apps/mobile/src/features/inbox/utils.test.ts b/apps/mobile/src/features/inbox/utils.test.ts
index ce8b2b43af..4b5517264b 100644
--- a/apps/mobile/src/features/inbox/utils.test.ts
+++ b/apps/mobile/src/features/inbox/utils.test.ts
@@ -1,9 +1,11 @@
+import { buildInboxViewedProperties } from "@posthog/core/inbox/engagement";
import {
buildArchiveListOrdering,
buildPriorityFilterParam,
buildSignalReportListOrdering,
INBOX_PIPELINE_STATUSES,
} from "@posthog/core/inbox/reportFiltering";
+import { isRestorableReport } from "@posthog/core/inbox/reportMembership";
import { formatSignalReportSummaryMarkdown } from "@posthog/core/inbox/reportPresentation";
import { dismissalReasonLabel } from "@posthog/shared";
import type {
@@ -13,11 +15,7 @@ import type {
SignalReportStatus,
} from "@posthog/shared/domain-types";
import { describe, expect, it } from "vitest";
-import {
- buildInboxViewedProperties,
- isRestorableReport,
- sourceLine,
-} from "./utils";
+import { sourceLine } from "./utils";
function signal(source_product: string, source_type: string): Signal {
return {
@@ -32,6 +30,24 @@ function signal(source_product: string, source_type: string): Signal {
};
}
+function buildMobileInboxViewedProperties(
+ reports: SignalReport[],
+ totalCount: number,
+ filters: {
+ sourceProductFilter: string[];
+ statusFilter: readonly SignalReportStatus[];
+ suggestedReviewerFilter: string[];
+ priorityFilter: string[];
+ defaultStatusFilter: readonly SignalReportStatus[];
+ },
+) {
+ return buildInboxViewedProperties({
+ visibleReports: reports,
+ totalCount,
+ filters,
+ });
+}
+
function makeReport(
partial: Partial & Pick,
): SignalReport {
@@ -88,7 +104,7 @@ describe("formatSignalReportSummaryMarkdown", () => {
describe("buildInboxViewedProperties", () => {
it("emits zero counts for an empty list", () => {
- const props = buildInboxViewedProperties([], 0, {
+ const props = buildMobileInboxViewedProperties([], 0, {
sourceProductFilter: [],
statusFilter: INBOX_PIPELINE_STATUSES,
suggestedReviewerFilter: [],
@@ -137,7 +153,7 @@ describe("buildInboxViewedProperties", () => {
makeReport({ id: "4", status: "failed" }),
];
- const props = buildInboxViewedProperties(reports, 4, {
+ const props = buildMobileInboxViewedProperties(reports, 4, {
sourceProductFilter: [],
statusFilter: INBOX_PIPELINE_STATUSES,
suggestedReviewerFilter: [],
@@ -158,7 +174,7 @@ describe("buildInboxViewedProperties", () => {
});
it("marks filters active when any of status/source/reviewer/priority differs from defaults", () => {
- const narrowed = buildInboxViewedProperties([], 0, {
+ const narrowed = buildMobileInboxViewedProperties([], 0, {
sourceProductFilter: [],
statusFilter: ["ready"],
suggestedReviewerFilter: [],
@@ -168,7 +184,7 @@ describe("buildInboxViewedProperties", () => {
expect(narrowed.has_active_filters).toBe(true);
expect(narrowed.status_filter_count).toBe(1);
- const sourced = buildInboxViewedProperties([], 0, {
+ const sourced = buildMobileInboxViewedProperties([], 0, {
sourceProductFilter: ["error_tracking"],
statusFilter: INBOX_PIPELINE_STATUSES,
suggestedReviewerFilter: [],
@@ -178,7 +194,7 @@ describe("buildInboxViewedProperties", () => {
expect(sourced.has_active_filters).toBe(true);
expect(sourced.source_product_filter).toEqual(["error_tracking"]);
- const reviewer = buildInboxViewedProperties([], 0, {
+ const reviewer = buildMobileInboxViewedProperties([], 0, {
sourceProductFilter: [],
statusFilter: INBOX_PIPELINE_STATUSES,
suggestedReviewerFilter: ["uuid-1"],
@@ -187,7 +203,7 @@ describe("buildInboxViewedProperties", () => {
});
expect(reviewer.has_active_filters).toBe(true);
- const prioritized = buildInboxViewedProperties([], 0, {
+ const prioritized = buildMobileInboxViewedProperties([], 0, {
sourceProductFilter: [],
statusFilter: INBOX_PIPELINE_STATUSES,
suggestedReviewerFilter: [],
@@ -198,7 +214,7 @@ describe("buildInboxViewedProperties", () => {
});
it("treats a reordered default status set as not filtered", () => {
- const props = buildInboxViewedProperties([], 0, {
+ const props = buildMobileInboxViewedProperties([], 0, {
sourceProductFilter: [],
statusFilter: [...INBOX_PIPELINE_STATUSES].reverse(),
suggestedReviewerFilter: [],
diff --git a/apps/mobile/src/features/inbox/utils.ts b/apps/mobile/src/features/inbox/utils.ts
index 13a8e4c80b..912bcd7104 100644
--- a/apps/mobile/src/features/inbox/utils.ts
+++ b/apps/mobile/src/features/inbox/utils.ts
@@ -2,14 +2,8 @@ import {
EXTERNAL_INBOX_SOURCE_BY_PRODUCT,
type SourceProduct,
} from "@posthog/shared";
-import type {
- Signal,
- SignalReport,
- SignalReportPriority,
- SignalReportStatus,
-} from "@posthog/shared/domain-types";
+import type { Signal } from "@posthog/shared/domain-types";
import { differenceInHours, format, formatDistanceToNow } from "date-fns";
-import type { InboxViewedProperties } from "@/lib/analytics";
const ERROR_TRACKING_TYPE_LABELS: Record = {
issue_created: "New issue",
@@ -47,120 +41,9 @@ export function sourceLine(signal: Signal): string {
const product = warehouseSource?.label ?? source_product.replace(/_/g, " ");
return `${product} · ${source_type.replace(/_/g, " ")}`;
}
-
/** Relative time for the last day, absolute "MMM d" beyond it. */
export function formatReportTimestamp(date: Date): string {
return differenceInHours(new Date(), date) < 24
? formatDistanceToNow(date, { addSuffix: true })
: format(date, "MMM d");
}
-
-/**
- * Archive membership: `suppressed` (user-archived) and `resolved` (PR merged).
- * Only `suppressed` is restorable; `resolved` is terminal, shown for reference.
- */
-export function isRestorableReport(
- report: Pick,
-): boolean {
- return report.status === "suppressed";
-}
-
-/**
- * Returns only reports that are actionable for the tinder-like card deck:
- * ready, immediately actionable, not already addressed.
- */
-export function getActionableReports(reports: SignalReport[]): SignalReport[] {
- return reports.filter(
- (r) =>
- r.status === "ready" &&
- r.actionability === "immediately_actionable" &&
- !r.already_addressed,
- );
-}
-
-interface InboxViewedFilterState {
- sourceProductFilter: string[];
- statusFilter: readonly SignalReportStatus[];
- suggestedReviewerFilter: string[];
- priorityFilter: SignalReportPriority[];
- /** Default status filter as defined in the filter store, used to detect whether the user has narrowed it. */
- defaultStatusFilter: readonly SignalReportStatus[];
-}
-
-/**
- * Build the property payload for the `Inbox viewed` analytics event.
- *
- * Mirrors packages/ui/src/features/inbox/components/InboxSignalsTab.tsx so
- * desktop and mobile send the same shape into PostHog.
- */
-export function buildInboxViewedProperties(
- reports: SignalReport[],
- totalCount: number,
- filters: InboxViewedFilterState,
-): InboxViewedProperties {
- const priorityCounts = {
- P0: 0,
- P1: 0,
- P2: 0,
- P3: 0,
- P4: 0,
- unknown: 0,
- };
- const actionabilityCounts = {
- immediately_actionable: 0,
- requires_human_input: 0,
- not_actionable: 0,
- unknown: 0,
- };
- let readyCount = 0;
- for (const r of reports) {
- if (r.status === "ready") readyCount += 1;
- const p = r.priority;
- if (p === "P0" || p === "P1" || p === "P2" || p === "P3" || p === "P4") {
- priorityCounts[p] += 1;
- } else {
- priorityCounts.unknown += 1;
- }
- const a = r.actionability;
- if (
- a === "immediately_actionable" ||
- a === "requires_human_input" ||
- a === "not_actionable"
- ) {
- actionabilityCounts[a] += 1;
- } else {
- actionabilityCounts.unknown += 1;
- }
- }
-
- const statusFiltered =
- filters.statusFilter.length !== filters.defaultStatusFilter.length ||
- filters.statusFilter.some((s) => !filters.defaultStatusFilter.includes(s));
- const hasActiveFilters =
- statusFiltered ||
- filters.sourceProductFilter.length > 0 ||
- filters.suggestedReviewerFilter.length > 0 ||
- filters.priorityFilter.length > 0;
-
- return {
- report_count: reports.length,
- total_count: totalCount,
- ready_count: readyCount,
- has_active_filters: hasActiveFilters,
- source_product_filter: filters.sourceProductFilter,
- status_filter_count: filters.statusFilter.length,
- is_empty: totalCount === 0,
- priority_p0_count: priorityCounts.P0,
- priority_p1_count: priorityCounts.P1,
- priority_p2_count: priorityCounts.P2,
- priority_p3_count: priorityCounts.P3,
- priority_p4_count: priorityCounts.P4,
- priority_unknown_count: priorityCounts.unknown,
- actionability_immediately_actionable_count:
- actionabilityCounts.immediately_actionable,
- actionability_requires_human_input_count:
- actionabilityCounts.requires_human_input,
- actionability_not_actionable_count: actionabilityCounts.not_actionable,
- actionability_unknown_count: actionabilityCounts.unknown,
- };
-}
diff --git a/apps/mobile/src/features/mcp/api.ts b/apps/mobile/src/features/mcp/api.ts
deleted file mode 100644
index f7fa0a828c..0000000000
--- a/apps/mobile/src/features/mcp/api.ts
+++ /dev/null
@@ -1,185 +0,0 @@
-import { authedFetch, getBaseUrl, getProjectId } from "@/lib/api";
-import type {
- InstallCustomMcpServerOptions,
- InstallMcpTemplateOptions,
- McpApprovalState,
- McpInstallationTool,
- McpInstallResponse,
- McpOAuthRedirectResponse,
- McpRecommendedServer,
- McpServerInstallation,
- UpdateMcpServerInstallationOptions,
-} from "./types";
-
-function mcpBaseUrl(): string {
- const base = getBaseUrl();
- const projectId = getProjectId();
- return `${base}/api/environments/${projectId}/mcp_server_installations`;
-}
-
-async function readJsonOrThrow(
- response: Response,
- errorPrefix: string,
-): Promise {
- if (!response.ok) {
- const data = (await response.json().catch(() => ({}))) as {
- detail?: string;
- };
- throw new Error(data.detail ?? `${errorPrefix}: ${response.statusText}`);
- }
- return (await response.json()) as T;
-}
-
-/** GET /api/environments/{teamId}/mcp_servers/ — marketplace templates. */
-export async function getMcpRecommendedServers(): Promise<
- McpRecommendedServer[]
-> {
- const base = getBaseUrl();
- const projectId = getProjectId();
- const response = await authedFetch(
- `${base}/api/environments/${projectId}/mcp_servers/`,
- );
- const data = await readJsonOrThrow<
- McpRecommendedServer[] | { results?: McpRecommendedServer[] }
- >(response, "Failed to fetch MCP servers");
- return Array.isArray(data) ? data : (data.results ?? []);
-}
-
-/** GET /api/environments/{teamId}/mcp_server_installations/ */
-export async function getMcpServerInstallations(): Promise<
- McpServerInstallation[]
-> {
- const response = await authedFetch(`${mcpBaseUrl()}/`);
- const data = await readJsonOrThrow<
- McpServerInstallation[] | { results?: McpServerInstallation[] }
- >(response, "Failed to fetch MCP server installations");
- return Array.isArray(data) ? data : (data.results ?? []);
-}
-
-/** POST /api/environments/{teamId}/mcp_server_installations/install_custom/ */
-export async function installCustomMcpServer(
- options: InstallCustomMcpServerOptions,
-): Promise {
- const response = await authedFetch(`${mcpBaseUrl()}/install_custom/`, {
- method: "POST",
- body: JSON.stringify(options),
- });
- return readJsonOrThrow(
- response,
- "Failed to install MCP server",
- );
-}
-
-/** POST /api/environments/{teamId}/mcp_server_installations/install_template/ */
-export async function installMcpTemplate(
- options: InstallMcpTemplateOptions,
-): Promise {
- const response = await authedFetch(`${mcpBaseUrl()}/install_template/`, {
- method: "POST",
- body: JSON.stringify(options),
- });
- return readJsonOrThrow(
- response,
- "Failed to install MCP template",
- );
-}
-
-/** PATCH /api/environments/{teamId}/mcp_server_installations/{id}/ */
-export async function updateMcpServerInstallation(
- installationId: string,
- updates: UpdateMcpServerInstallationOptions,
-): Promise {
- const response = await authedFetch(`${mcpBaseUrl()}/${installationId}/`, {
- method: "PATCH",
- body: JSON.stringify(updates),
- });
- return readJsonOrThrow(
- response,
- "Failed to update MCP server",
- );
-}
-
-/** DELETE /api/environments/{teamId}/mcp_server_installations/{id}/ */
-export async function uninstallMcpServer(
- installationId: string,
-): Promise {
- const response = await authedFetch(`${mcpBaseUrl()}/${installationId}/`, {
- method: "DELETE",
- });
- if (!response.ok && response.status !== 204) {
- throw new Error(`Failed to uninstall MCP server: ${response.statusText}`);
- }
-}
-
-/** GET /api/environments/{teamId}/mcp_server_installations/authorize/?installation_id={id} */
-export async function authorizeMcpInstallation(options: {
- installation_id: string;
- install_source?: "posthog" | "posthog-code" | "posthog-mobile";
- posthog_code_callback_url?: string;
-}): Promise {
- const params = new URLSearchParams();
- params.set("installation_id", options.installation_id);
- if (options.install_source) {
- params.set("install_source", options.install_source);
- }
- if (options.posthog_code_callback_url) {
- params.set("posthog_code_callback_url", options.posthog_code_callback_url);
- }
- const response = await authedFetch(
- `${mcpBaseUrl()}/authorize/?${params.toString()}`,
- );
- return readJsonOrThrow(
- response,
- "Failed to authorize MCP installation",
- );
-}
-
-/** GET /api/environments/{teamId}/mcp_server_installations/{id}/tools/ */
-export async function getMcpInstallationTools(
- installationId: string,
- options: { includeRemoved?: boolean } = {},
-): Promise {
- const params = new URLSearchParams();
- if (options.includeRemoved) params.set("include_removed", "1");
- const query = params.toString();
- const response = await authedFetch(
- `${mcpBaseUrl()}/${installationId}/tools/${query ? `?${query}` : ""}`,
- );
- const data = await readJsonOrThrow<
- McpInstallationTool[] | { results?: McpInstallationTool[] }
- >(response, "Failed to fetch MCP installation tools");
- return Array.isArray(data) ? data : (data.results ?? []);
-}
-
-/** PATCH /api/environments/{teamId}/mcp_server_installations/{id}/tools/{name}/ */
-export async function updateMcpToolApproval(
- installationId: string,
- toolName: string,
- approval_state: McpApprovalState,
-): Promise {
- const response = await authedFetch(
- `${mcpBaseUrl()}/${installationId}/tools/${encodeURIComponent(toolName)}/`,
- {
- method: "PATCH",
- body: JSON.stringify({ approval_state }),
- },
- );
- return readJsonOrThrow(
- response,
- "Failed to update tool approval",
- );
-}
-
-/** POST /api/environments/{teamId}/mcp_server_installations/{id}/tools/refresh/ */
-export async function refreshMcpInstallationTools(
- installationId: string,
-): Promise {
- const response = await authedFetch(
- `${mcpBaseUrl()}/${installationId}/tools/refresh/`,
- { method: "POST" },
- );
- const data = await readJsonOrThrow<
- McpInstallationTool[] | { results?: McpInstallationTool[] }
- >(response, "Failed to refresh MCP tools");
- return Array.isArray(data) ? data : (data.results ?? []);
-}
diff --git a/apps/mobile/src/features/mcp/components/McpAppHost.tsx b/apps/mobile/src/features/mcp/components/McpAppHost.tsx
index e1f7638e7b..a92a6966e4 100644
--- a/apps/mobile/src/features/mcp/components/McpAppHost.tsx
+++ b/apps/mobile/src/features/mcp/components/McpAppHost.tsx
@@ -1,7 +1,7 @@
import { Text } from "@components/text";
import type { McpUiDisplayMode } from "@modelcontextprotocol/ext-apps/app-bridge";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
-import { isSafeExternalUrl } from "@posthog/shared";
+import { isSafeExternalUrl, parseMcpToolName } from "@posthog/shared";
import * as WebBrowser from "expo-web-browser";
import { ArrowsIn, ArrowsOut, Warning } from "phosphor-react-native";
import { useCallback, useMemo, useRef, useState } from "react";
@@ -22,7 +22,6 @@ import { sandboxProxyHtml } from "../sandbox/sandboxProxyHtml";
import { useMcpUiResource } from "../sandbox/useMcpUiResource";
import { type Phase, useMobileAppBridge } from "../sandbox/useMobileAppBridge";
import { getMcpConnectionManager } from "../service";
-import { parseMcpToolName } from "../utils/mcpToolName";
interface McpAppHostProps {
/** Raw tool name from the agent — `mcp____`. */
@@ -60,14 +59,12 @@ export function McpAppHost(props: McpAppHostProps) {
const installations = useMcpInstallations();
const installation = useMemo(() => {
if (!parsed) return null;
- return (
- installations.data?.find((i) => i.name === parsed.serverName) ?? null
- );
+ return installations.data?.find((i) => i.name === parsed.server) ?? null;
}, [installations.data, parsed]);
const uiResource = useMcpUiResource({
installation,
- toolName: parsed?.toolName ?? "",
+ toolName: parsed?.tool ?? "",
});
const webViewRef = useRef(null);
@@ -122,7 +119,7 @@ export function McpAppHost(props: McpAppHostProps) {
const { handleWebViewMessage } = useMobileAppBridge({
webViewRef,
uiResource: uiResource.data?.resource ?? null,
- serverName: parsed?.serverName ?? "",
+ serverName: parsed?.server ?? "",
toolDefinition: uiResource.data?.tool ?? null,
toolInput: props.toolArgs ?? null,
existingToolResult:
diff --git a/apps/mobile/src/features/mcp/components/McpServerRow.tsx b/apps/mobile/src/features/mcp/components/McpServerRow.tsx
index 0cfd05775f..7b0a4b9359 100644
--- a/apps/mobile/src/features/mcp/components/McpServerRow.tsx
+++ b/apps/mobile/src/features/mcp/components/McpServerRow.tsx
@@ -1,10 +1,13 @@
import { Text } from "@components/text";
+import type {
+ McpRecommendedServer,
+ McpServerInstallation,
+} from "@posthog/api-client/types";
+import { isStdioMcpServer } from "@posthog/core/mcp-servers/presentation";
import { CaretRight, Lock, Warning } from "phosphor-react-native";
import type { ReactNode } from "react";
import { Pressable, View } from "react-native";
import { useThemeColors } from "@/lib/theme";
-import type { McpRecommendedServer, McpServerInstallation } from "../types";
-import { isStdioServer } from "../types";
import { ServerIcon } from "./ServerIcon";
interface McpServerRowProps {
@@ -121,7 +124,7 @@ export function recommendedToRowProps(
title: template.name,
description: template.description,
authType: template.auth_type,
- isStdio: isStdioServer(template),
+ isStdio: isStdioMcpServer(template),
installed: installedNames.has(template.name),
iconDomain: template.icon_domain,
serverUrl: template.url,
@@ -137,7 +140,7 @@ export function installationToRowProps(
title: installation.display_name || installation.name,
subtitle: installation.url,
authType: installation.auth_type,
- isStdio: isStdioServer(installation),
+ isStdio: isStdioMcpServer(installation),
needsReauth: installation.needs_reauth,
installed: true,
iconDomain: installation.icon_domain,
diff --git a/apps/mobile/src/features/mcp/hooks.ts b/apps/mobile/src/features/mcp/hooks.ts
index 9c724aea1e..6d6f07756e 100644
--- a/apps/mobile/src/features/mcp/hooks.ts
+++ b/apps/mobile/src/features/mcp/hooks.ts
@@ -1,22 +1,11 @@
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import {
- authorizeMcpInstallation,
- getMcpInstallationTools,
- getMcpRecommendedServers,
- getMcpServerInstallations,
- installCustomMcpServer,
- installMcpTemplate,
- refreshMcpInstallationTools,
- uninstallMcpServer,
- updateMcpServerInstallation,
- updateMcpToolApproval,
-} from "./api";
import type {
InstallCustomMcpServerOptions,
InstallMcpTemplateOptions,
McpApprovalState,
UpdateMcpServerInstallationOptions,
-} from "./types";
+} from "@posthog/api-client/types";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { getPostHogApiClient } from "@/lib/posthogApiClient";
const mcpKeys = {
all: ["mcp"] as const,
@@ -29,7 +18,7 @@ const mcpKeys = {
export function useMcpMarketplace() {
return useQuery({
queryKey: mcpKeys.marketplace(),
- queryFn: getMcpRecommendedServers,
+ queryFn: () => getPostHogApiClient().getMcpServers(),
staleTime: 5 * 60 * 1000,
});
}
@@ -37,7 +26,7 @@ export function useMcpMarketplace() {
export function useMcpInstallations() {
return useQuery({
queryKey: mcpKeys.installations(),
- queryFn: getMcpServerInstallations,
+ queryFn: () => getPostHogApiClient().getMcpServerInstallations(),
staleTime: 30 * 1000,
});
}
@@ -45,7 +34,8 @@ export function useMcpInstallations() {
export function useMcpInstallationTools(installationId: string | null) {
return useQuery({
queryKey: mcpKeys.tools(installationId ?? ""),
- queryFn: () => getMcpInstallationTools(installationId as string),
+ queryFn: () =>
+ getPostHogApiClient().getMcpInstallationTools(installationId as string),
enabled: !!installationId,
staleTime: 30 * 1000,
});
@@ -61,7 +51,7 @@ export function useInstallCustomMcpServer() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (options: InstallCustomMcpServerOptions) =>
- installCustomMcpServer(options),
+ getPostHogApiClient().installCustomMcpServer(options),
onSuccess: () => invalidateInstallations(queryClient),
});
}
@@ -70,7 +60,7 @@ export function useInstallMcpTemplate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (options: InstallMcpTemplateOptions) =>
- installMcpTemplate(options),
+ getPostHogApiClient().installMcpTemplate(options),
onSuccess: () => invalidateInstallations(queryClient),
});
}
@@ -84,7 +74,11 @@ export function useUpdateMcpServerInstallation() {
}: {
installationId: string;
updates: UpdateMcpServerInstallationOptions;
- }) => updateMcpServerInstallation(installationId, updates),
+ }) =>
+ getPostHogApiClient().updateMcpServerInstallation(
+ installationId,
+ updates,
+ ),
onSuccess: () => invalidateInstallations(queryClient),
});
}
@@ -92,15 +86,19 @@ export function useUpdateMcpServerInstallation() {
export function useUninstallMcpServer() {
const queryClient = useQueryClient();
return useMutation({
- mutationFn: (installationId: string) => uninstallMcpServer(installationId),
+ mutationFn: (installationId: string) =>
+ getPostHogApiClient().uninstallMcpServer(installationId),
onSuccess: () => invalidateInstallations(queryClient),
});
}
export function useAuthorizeMcpInstallation() {
return useMutation({
- mutationFn: (args: Parameters[0]) =>
- authorizeMcpInstallation(args),
+ mutationFn: (args: {
+ installation_id: string;
+ install_source?: "posthog" | "posthog-code" | "posthog-mobile";
+ posthog_code_callback_url?: string;
+ }) => getPostHogApiClient().authorizeMcpInstallation(args),
});
}
@@ -108,7 +106,7 @@ export function useRefreshMcpInstallationTools() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (installationId: string) =>
- refreshMcpInstallationTools(installationId),
+ getPostHogApiClient().refreshMcpInstallationTools(installationId),
onSuccess: (_, installationId) => {
queryClient.invalidateQueries({
queryKey: mcpKeys.tools(installationId),
@@ -129,7 +127,12 @@ export function useUpdateMcpToolApproval() {
installationId: string;
toolName: string;
approval_state: McpApprovalState;
- }) => updateMcpToolApproval(installationId, toolName, approval_state),
+ }) =>
+ getPostHogApiClient().updateMcpToolApproval(
+ installationId,
+ toolName,
+ approval_state,
+ ),
onSuccess: (_, { installationId }) => {
queryClient.invalidateQueries({
queryKey: mcpKeys.tools(installationId),
diff --git a/apps/mobile/src/features/mcp/mcpUiResource.ts b/apps/mobile/src/features/mcp/mcpUiResource.ts
new file mode 100644
index 0000000000..f9e934bfa6
--- /dev/null
+++ b/apps/mobile/src/features/mcp/mcpUiResource.ts
@@ -0,0 +1,6 @@
+export interface McpUiResource {
+ uri: string;
+ html: string;
+ csp?: Record;
+ permissions?: Record>;
+}
diff --git a/apps/mobile/src/features/mcp/oauth.ts b/apps/mobile/src/features/mcp/oauth.ts
index 190fabd6bf..e687baa113 100644
--- a/apps/mobile/src/features/mcp/oauth.ts
+++ b/apps/mobile/src/features/mcp/oauth.ts
@@ -1,17 +1,13 @@
-import * as Linking from "expo-linking";
-import * as WebBrowser from "expo-web-browser";
-import {
- authorizeMcpInstallation,
- installCustomMcpServer,
- installMcpTemplate,
-} from "./api";
import type {
InstallCustomMcpServerOptions,
InstallMcpTemplateOptions,
McpInstallResponse,
McpServerInstallation,
-} from "./types";
-import { isOAuthRedirect } from "./types";
+} from "@posthog/api-client/types";
+import { isMcpOAuthRedirect } from "@posthog/core/mcp-servers/presentation";
+import * as Linking from "expo-linking";
+import * as WebBrowser from "expo-web-browser";
+import { getPostHogApiClient } from "@/lib/posthogApiClient";
/** Custom URL scheme registered via app.json (`scheme: "posthog"`). The cloud
* bounces the OAuth redirect back to this URL once the provider completes
@@ -51,13 +47,14 @@ export async function installTemplateWithOAuth(
"install_source" | "posthog_code_callback_url"
>,
): Promise {
- const response: McpInstallResponse = await installMcpTemplate({
- ...options,
- install_source: INSTALL_SOURCE,
- posthog_code_callback_url: OAUTH_CALLBACK_URL,
- });
+ const response: McpInstallResponse =
+ await getPostHogApiClient().installMcpTemplate({
+ ...options,
+ install_source: INSTALL_SOURCE,
+ posthog_code_callback_url: OAUTH_CALLBACK_URL,
+ });
- if (!isOAuthRedirect(response)) return response;
+ if (!isMcpOAuthRedirect(response)) return response;
const outcome = await waitForOAuthCallback(response.redirect_url);
if (outcome === "cancelled") return "cancelled";
@@ -75,13 +72,14 @@ export async function installCustomWithOAuth(
"install_source" | "posthog_code_callback_url"
>,
): Promise {
- const response: McpInstallResponse = await installCustomMcpServer({
- ...options,
- install_source: INSTALL_SOURCE,
- posthog_code_callback_url: OAUTH_CALLBACK_URL,
- });
+ const response: McpInstallResponse =
+ await getPostHogApiClient().installCustomMcpServer({
+ ...options,
+ install_source: INSTALL_SOURCE,
+ posthog_code_callback_url: OAUTH_CALLBACK_URL,
+ });
- if (!isOAuthRedirect(response)) return response;
+ if (!isMcpOAuthRedirect(response)) return response;
const outcome = await waitForOAuthCallback(response.redirect_url);
return outcome === "cancelled" ? "cancelled" : "cancelled";
}
@@ -94,11 +92,13 @@ export async function installCustomWithOAuth(
export async function reauthorizeInstallation(
installationId: string,
): Promise<"completed" | "cancelled"> {
- const { redirect_url } = await authorizeMcpInstallation({
- installation_id: installationId,
- install_source: INSTALL_SOURCE,
- posthog_code_callback_url: OAUTH_CALLBACK_URL,
- });
+ const { redirect_url } = await getPostHogApiClient().authorizeMcpInstallation(
+ {
+ installation_id: installationId,
+ install_source: INSTALL_SOURCE,
+ posthog_code_callback_url: OAUTH_CALLBACK_URL,
+ },
+ );
return waitForOAuthCallback(redirect_url);
}
diff --git a/apps/mobile/src/features/mcp/sandbox/useMcpUiResource.ts b/apps/mobile/src/features/mcp/sandbox/useMcpUiResource.ts
index 3e8d415412..4bcb2a4736 100644
--- a/apps/mobile/src/features/mcp/sandbox/useMcpUiResource.ts
+++ b/apps/mobile/src/features/mcp/sandbox/useMcpUiResource.ts
@@ -3,9 +3,10 @@ import {
RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/app-bridge";
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
+import type { McpServerInstallation } from "@posthog/api-client/types";
import { useQuery } from "@tanstack/react-query";
+import type { McpUiResource } from "../mcpUiResource";
import { getMcpConnectionManager } from "../service";
-import type { McpServerInstallation, McpUiResource } from "../types";
interface UseMcpUiResourceArgs {
installation: McpServerInstallation | null;
diff --git a/apps/mobile/src/features/mcp/types.ts b/apps/mobile/src/features/mcp/types.ts
deleted file mode 100644
index 74f259ef0c..0000000000
--- a/apps/mobile/src/features/mcp/types.ts
+++ /dev/null
@@ -1,119 +0,0 @@
-// Shared types for MCP server installations and marketplace templates.
-// Mirrors the PostHog cloud REST schema (see `apps/code/src/renderer/api/generated.ts`).
-
-export type McpAuthType = "api_key" | "oauth" | "none";
-
-export type McpApprovalState = "approved" | "needs_approval" | "do_not_use";
-
-export type McpInstallSource = "posthog" | "posthog-code" | "posthog-mobile";
-
-/** Server-side marketplace template — one entry per recommended server. */
-export interface McpRecommendedServer {
- id: string;
- name: string;
- url: string;
- docs_url?: string;
- description?: string;
- auth_type?: McpAuthType;
- /** The vendor's brand domain (e.g. "linear.app"), rendered via the
- * logo.dev icon proxy. Empty when no brand icon is known. */
- icon_domain?: string;
- category?: string;
- /** Some templates expose a `transport_type` ("stdio" | "streamable_http"); when
- * absent, treat as HTTP. Stdio servers can't run on mobile; we badge them. */
- transport_type?: "stdio" | "streamable_http";
-}
-
-/** Server-side record of one user's installation of a server. */
-export interface McpServerInstallation {
- id: string;
- template_id: string | null;
- name: string;
- /** Brand domain from the linked template, rendered via the logo.dev icon
- * proxy. Empty if custom install (no template). */
- icon_domain?: string;
- display_name?: string;
- url?: string;
- description?: string;
- auth_type?: McpAuthType;
- is_enabled?: boolean;
- needs_reauth: boolean;
- pending_oauth: boolean;
- /** Cloud-hosted proxy URL the client should hit to talk to the MCP server.
- * Desktop substitutes a local loopback; mobile uses whatever the API returns. */
- proxy_url: string;
- tool_count: number;
- transport_type?: "stdio" | "streamable_http";
- created_at: string;
- updated_at: string | null;
-}
-
-export interface McpInstallationTool {
- id: string;
- tool_name: string;
- display_name: string;
- description: string;
- input_schema: unknown;
- approval_state?: McpApprovalState;
- last_seen_at: string;
- removed_at: string | null;
- created_at: string;
- updated_at: string | null;
-}
-
-export interface McpOAuthRedirectResponse {
- redirect_url: string;
-}
-
-export type McpInstallResponse =
- | McpServerInstallation
- | McpOAuthRedirectResponse;
-
-export function isOAuthRedirect(
- response: McpInstallResponse,
-): response is McpOAuthRedirectResponse {
- return (
- typeof (response as McpOAuthRedirectResponse).redirect_url === "string"
- );
-}
-
-export interface InstallCustomMcpServerOptions {
- name: string;
- url: string;
- auth_type: McpAuthType;
- api_key?: string;
- description?: string;
- client_id?: string;
- client_secret?: string;
- install_source?: McpInstallSource;
- posthog_code_callback_url?: string;
-}
-
-export interface InstallMcpTemplateOptions {
- template_id: string;
- api_key?: string;
- install_source?: McpInstallSource;
- posthog_code_callback_url?: string;
-}
-
-export interface UpdateMcpServerInstallationOptions {
- display_name?: string;
- description?: string;
- is_enabled?: boolean;
-}
-
-export interface McpUiResource {
- uri: string;
- html: string;
- /** Opaque CSP descriptor handed straight to AppBridge (`McpUiResourceCsp`). */
- csp?: Record;
- permissions?: Record>;
-}
-
-/** Returns true if the template/installation requires stdio transport, which
- * the mobile app can't host. UI uses this to render a "Desktop only" badge. */
-export function isStdioServer(
- s: Pick,
-): boolean {
- return s.transport_type === "stdio";
-}
diff --git a/apps/mobile/src/features/mcp/utils/mcpToolName.test.ts b/apps/mobile/src/features/mcp/utils/mcpToolName.test.ts
deleted file mode 100644
index f9982e8658..0000000000
--- a/apps/mobile/src/features/mcp/utils/mcpToolName.test.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { isMcpToolName, parseMcpToolName } from "./mcpToolName";
-
-describe("isMcpToolName", () => {
- it("accepts a well-formed MCP tool name", () => {
- expect(isMcpToolName("mcp__github__create_issue")).toBe(true);
- });
-
- it("accepts tool names with extra underscores in the tool segment", () => {
- expect(isMcpToolName("mcp__github__list_pull_requests")).toBe(true);
- });
-
- it("rejects non-MCP tool names", () => {
- expect(isMcpToolName("read_file")).toBe(false);
- expect(isMcpToolName("Bash")).toBe(false);
- expect(isMcpToolName("")).toBe(false);
- expect(isMcpToolName(null)).toBe(false);
- expect(isMcpToolName(undefined)).toBe(false);
- });
-
- it("rejects malformed prefixes", () => {
- expect(isMcpToolName("mcp_github__tool")).toBe(false);
- expect(isMcpToolName("mcp__github")).toBe(false); // no second separator
- expect(isMcpToolName("mcp__")).toBe(false);
- });
-});
-
-describe("parseMcpToolName", () => {
- it("splits server and tool", () => {
- expect(parseMcpToolName("mcp__linear__create_issue")).toEqual({
- serverName: "linear",
- toolName: "create_issue",
- });
- });
-
- it("keeps double-underscore tool names intact on the tool side", () => {
- expect(parseMcpToolName("mcp__db__select__count")).toEqual({
- serverName: "db",
- toolName: "select__count",
- });
- });
-
- it("returns null for invalid names", () => {
- expect(parseMcpToolName("read_file")).toBeNull();
- expect(parseMcpToolName("mcp__only-server")).toBeNull();
- expect(parseMcpToolName(null)).toBeNull();
- });
-});
diff --git a/apps/mobile/src/features/mcp/utils/mcpToolName.ts b/apps/mobile/src/features/mcp/utils/mcpToolName.ts
deleted file mode 100644
index f2e0273ebb..0000000000
--- a/apps/mobile/src/features/mcp/utils/mcpToolName.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-// Helpers for detecting + parsing MCP tool names that arrive from the agent.
-//
-// Cloud agents prefix MCP tool calls with `mcp____` in the raw
-// tool name (mobile sees this on `_meta.claudeCode.toolName`). PostHog's own
-// MCP plugin already has its own dedicated renderer (`isPostHogExecTool`); we
-// pick up everything else.
-
-const MCP_PREFIX = "mcp__";
-
-/** Returns true for any tool name following the MCP naming convention. */
-export function isMcpToolName(toolName: string | undefined | null): boolean {
- if (!toolName) return false;
- if (!toolName.startsWith(MCP_PREFIX)) return false;
- const rest = toolName.slice(MCP_PREFIX.length);
- return rest.includes("__");
-}
-
-export interface ParsedMcpToolName {
- serverName: string;
- toolName: string;
-}
-
-/** Split `mcp____` into its parts, or `null` if it doesn't match. */
-export function parseMcpToolName(
- raw: string | undefined | null,
-): ParsedMcpToolName | null {
- if (!raw || !raw.startsWith(MCP_PREFIX)) return null;
- const rest = raw.slice(MCP_PREFIX.length);
- const splitIdx = rest.indexOf("__");
- if (splitIdx <= 0) return null;
- return {
- serverName: rest.slice(0, splitIdx),
- toolName: rest.slice(splitIdx + 2),
- };
-}
diff --git a/apps/mobile/src/features/tasks/components/AutomationDetail.tsx b/apps/mobile/src/features/tasks/components/AutomationDetail.tsx
index b560293b6b..dc7d026439 100644
--- a/apps/mobile/src/features/tasks/components/AutomationDetail.tsx
+++ b/apps/mobile/src/features/tasks/components/AutomationDetail.tsx
@@ -1,9 +1,9 @@
import { Text } from "@components/text";
import type { TaskAutomation } from "@posthog/api-client/posthog-client";
import { formatAutomationScheduleSummary } from "@posthog/core/automations/automationSchedule";
+import { getAutomationTemplatePresentation } from "@posthog/core/automations/automationTemplatePresentation";
import type { TaskRun } from "@posthog/shared";
import { ActivityIndicator, Pressable, View } from "react-native";
-import { getAutomationTemplatePresentation } from "../utils/automationTemplatePresentation";
import { AutomationStatusBadge } from "./AutomationStatusBadge";
interface AutomationDetailProps {
diff --git a/apps/mobile/src/features/tasks/components/AutomationItem.tsx b/apps/mobile/src/features/tasks/components/AutomationItem.tsx
index 89d4f4d5de..220f58fdeb 100644
--- a/apps/mobile/src/features/tasks/components/AutomationItem.tsx
+++ b/apps/mobile/src/features/tasks/components/AutomationItem.tsx
@@ -1,11 +1,11 @@
import { Text } from "@components/text";
import type { TaskAutomation } from "@posthog/api-client/posthog-client";
import { formatAutomationScheduleSummary } from "@posthog/core/automations/automationSchedule";
+import { getAutomationTemplatePresentation } from "@posthog/core/automations/automationTemplatePresentation";
import type { TaskRun } from "@posthog/shared";
import { format, formatDistanceToNow } from "date-fns";
import { memo } from "react";
import { Pressable, View } from "react-native";
-import { getAutomationTemplatePresentation } from "../utils/automationTemplatePresentation";
import { AutomationStatusBadge } from "./AutomationStatusBadge";
interface AutomationItemProps {
diff --git a/apps/mobile/src/features/tasks/components/AutomationSkillCard.tsx b/apps/mobile/src/features/tasks/components/AutomationSkillCard.tsx
index dace344923..12d7eba64f 100644
--- a/apps/mobile/src/features/tasks/components/AutomationSkillCard.tsx
+++ b/apps/mobile/src/features/tasks/components/AutomationSkillCard.tsx
@@ -1,3 +1,4 @@
+import type { LlmSkillListItem } from "@posthog/api-client";
import { CaretDown, CaretUp } from "phosphor-react-native";
import { useState } from "react";
import {
@@ -8,10 +9,9 @@ import {
} from "react-native";
import { Text } from "@/components/text";
import { useThemeColors } from "@/lib/theme";
-import type { SkillStoreListEntry } from "../skills/types";
interface AutomationSkillCardProps {
- skill: SkillStoreListEntry;
+ skill: LlmSkillListItem;
onPress: (skillName: string) => void;
}
diff --git a/apps/mobile/src/features/tasks/components/PlanApprovalCard.tsx b/apps/mobile/src/features/tasks/components/PlanApprovalCard.tsx
index 6781c7cdff..a03ff18bea 100644
--- a/apps/mobile/src/features/tasks/components/PlanApprovalCard.tsx
+++ b/apps/mobile/src/features/tasks/components/PlanApprovalCard.tsx
@@ -1,3 +1,9 @@
+import {
+ getPermissionOptionMeta,
+ isPermissionRejection,
+ permissionOptionUsesCustomInput,
+} from "@posthog/core/sessions/permissionResponse";
+import { extractPlanText } from "@posthog/core/sessions/planApprovalPresentation";
import {
ArrowsClockwise,
ChatCircle,
@@ -28,56 +34,6 @@ interface PlanApprovalCardProps {
onSendPermissionResponse?: (args: PermissionResponseArgs) => void;
}
-function optionMeta(option: CloudPendingPermissionRequest["options"][number]) {
- return option._meta as
- | {
- customInput?: boolean;
- description?: string;
- }
- | undefined;
-}
-
-function isRejectOption(
- option?: CloudPendingPermissionRequest["options"][number],
-) {
- if (!option) return false;
- return option.kind.startsWith("reject") || option.optionId.includes("reject");
-}
-
-function extractTextContent(item: unknown): string | null {
- if (!item || typeof item !== "object") return null;
-
- const record = item as Record;
- if (typeof record.text === "string") {
- return record.text;
- }
-
- if (!record.content || typeof record.content !== "object") {
- return null;
- }
-
- const content = record.content as Record;
- return typeof content.text === "string" ? content.text : null;
-}
-
-function extractPlanText(
- permission?: CloudPendingPermissionRequest,
-): string | null {
- const rawPlan = permission?.toolCall.rawInput?.plan;
- if (typeof rawPlan === "string" && rawPlan.trim().length > 0) {
- return rawPlan;
- }
-
- for (const item of permission?.toolCall.content ?? []) {
- const text = extractTextContent(item);
- if (text?.trim()) {
- return text;
- }
- }
-
- return null;
-}
-
export function PlanApprovalCard({
toolData,
permission,
@@ -90,7 +46,10 @@ export function PlanApprovalCard({
const [customInput, setCustomInput] = useState("");
const response = permission?.response;
- const planText = useMemo(() => extractPlanText(permission), [permission]);
+ const planText = useMemo(
+ () => (permission ? extractPlanText(permission.toolCall) : null),
+ [permission],
+ );
const selectedOption = useMemo(
() =>
permission?.options.find(
@@ -132,7 +91,9 @@ export function PlanApprovalCard({
selectedOption?.name ||
response?.displayText ||
null;
- const resolvedAsReject = isRejectOption(selectedOption);
+ const resolvedAsReject = selectedOption
+ ? isPermissionRejection(selectedOption)
+ : false;
return (
@@ -196,8 +157,8 @@ export function PlanApprovalCard({
) : (
{permission.options.map((option) => {
- const meta = optionMeta(option);
- const usesCustomInput = meta?.customInput === true;
+ const meta = getPermissionOptionMeta(option);
+ const usesCustomInput = permissionOptionUsesCustomInput(option);
const isCustomSelected = selectedCustomOptionId === option.optionId;
return (
@@ -225,7 +186,7 @@ export function PlanApprovalCard({
>
{option.name}
- {meta?.description && (
+ {meta.description && (
{meta.description}
diff --git a/apps/mobile/src/features/tasks/components/TaskSessionView.test.tsx b/apps/mobile/src/features/tasks/components/TaskSessionView.test.tsx
index 5bab080e15..e55c5f0f72 100644
--- a/apps/mobile/src/features/tasks/components/TaskSessionView.test.tsx
+++ b/apps/mobile/src/features/tasks/components/TaskSessionView.test.tsx
@@ -24,8 +24,8 @@ vi.mock("@/features/chat", () => ({
deriveToolKind: () => "other",
}));
-vi.mock("@/features/chat/utils/thinkingMessages", () => ({
- getRandomThinkingActivity: () => "Thinking",
+vi.mock("@posthog/core/sessions/thinkingActivities", () => ({
+ pickThinkingActivity: () => "Thinking",
}));
vi.mock("@/lib/theme", () => ({
diff --git a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx
index f30b4aec66..75a06938b0 100644
--- a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx
+++ b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx
@@ -1,3 +1,4 @@
+import { pickThinkingActivity } from "@posthog/core/sessions/thinkingActivities";
import {
ArrowDown,
Brain,
@@ -20,7 +21,6 @@ import {
ToolMessage,
type ToolStatus,
} from "@/features/chat";
-import { getRandomThinkingActivity } from "@/features/chat/utils/thinkingMessages";
import { useThemeColors } from "@/lib/theme";
import type {
CloudPendingPermissionRequest,
@@ -734,7 +734,9 @@ function useElapsedTimer() {
function ThinkingIndicator() {
const [dots, setDots] = useState(1);
- const [activity, setActivity] = useState(getRandomThinkingActivity);
+ const [activity, setActivity] = useState(() =>
+ pickThinkingActivity(Math.random()),
+ );
const elapsed = useElapsedTimer();
const themeColors = useThemeColors();
@@ -747,7 +749,7 @@ function ThinkingIndicator() {
useEffect(() => {
const interval = setInterval(() => {
- setActivity(getRandomThinkingActivity());
+ setActivity(pickThinkingActivity(Math.random()));
}, 2000);
return () => clearInterval(interval);
}, []);
diff --git a/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx b/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx
index e3d65b3d33..79c680ac0d 100644
--- a/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx
+++ b/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx
@@ -3,12 +3,12 @@ import {
DEFAULT_CLAUDE_EXECUTION_MODE,
getAvailableModes,
} from "@posthog/core/sessions/executionModes";
+import { resolveCloudComposerModelChange } from "@posthog/core/task-detail/composerModelPolicy";
import {
DEFAULT_GATEWAY_MODEL,
DEFAULT_REASONING_EFFORT,
type ExecutionMode,
getReasoningEffortOptions,
- isSupportedReasoningEffort,
type SupportedReasoningEffort,
} from "@posthog/shared";
import * as Haptics from "expo-haptics";
@@ -57,16 +57,17 @@ import {
} from "./attachments/pickers";
import type { PendingAttachment } from "./attachments/types";
import {
- getMobileModelOptions,
+ getComposerModelOptions,
+ getConfigOptionLabel,
+ getMobileExecutionModes,
getModelConfigOption,
- getModelLabel,
- resolveAvailableModel,
+ resolveComposerPrimaryAction,
} from "./options";
import { Pill } from "./Pill";
import { SelectSheet } from "./SelectSheet";
const log = logger.scope("task-chat-composer");
-const EXECUTION_MODES = getAvailableModes();
+const EXECUTION_MODES = getMobileExecutionModes(getAvailableModes());
interface TaskChatComposerProps {
onSend: (message: string, attachments: PendingAttachment[]) => void;
@@ -188,7 +189,7 @@ export function TaskChatComposer({
const themeColors = useThemeColors();
const { configOptions, hasLiveConfig } = useCloudTaskConfigOptions("claude");
const modelConfigOption = getModelConfigOption(configOptions);
- const mobileModelOptions = getMobileModelOptions(modelConfigOption);
+ const mobileModelOptions = getComposerModelOptions(modelConfigOption);
const [message, setMessage] = useState(() => initialMessage ?? "");
const [attachments, setAttachments] = useState([]);
const [attachmentSheetOpen, setAttachmentSheetOpen] = useState(false);
@@ -206,12 +207,14 @@ export function TaskChatComposer({
useEffect(() => {
if (!hasLiveConfig) return;
- const availableModel = resolveAvailableModel(modelConfigOption, model);
- if (availableModel === model) return;
- onModelChange(availableModel);
- if (!isSupportedReasoningEffort("claude", availableModel, reasoning)) {
- onReasoningChange(DEFAULT_REASONING_EFFORT);
- }
+ const next = resolveCloudComposerModelChange({
+ adapter: "claude",
+ modelOption: modelConfigOption,
+ requestedModel: model,
+ reasoning,
+ });
+ if (next.model !== model) onModelChange(next.model);
+ if (next.reasoning !== reasoning) onReasoningChange(next.reasoning);
}, [
hasLiveConfig,
model,
@@ -239,9 +242,16 @@ export function TaskChatComposer({
const showReasoningPill = reasoningOptions.length > 0;
const hasContent = message.trim().length > 0 || attachments.length > 0;
- const canSend = hasContent && !disabled && !isRecording;
- const showStop =
- !isUserTurn && !canSend && !isRecording && !isTranscribing && !!onStop;
+ const primaryAction = resolveComposerPrimaryAction({
+ hasContent,
+ disabled,
+ isRecording,
+ isTranscribing,
+ canStop: !isUserTurn && !!onStop,
+ allowSendWhileRunning: true,
+ });
+ const canSend = primaryAction === "send";
+ const showStop = primaryAction === "stop";
const handleSend = () => {
const trimmed = message.trim();
@@ -410,7 +420,10 @@ export function TaskChatComposer({
}
- label={getModelLabel(modelConfigOption, model)}
+ label={
+ getConfigOptionLabel(modelConfigOption.options, model) ??
+ model
+ }
onPress={() => setModelSheetOpen(true)}
/>
@@ -486,10 +499,14 @@ export function TaskChatComposer({
title="Model"
value={model}
onChange={(v) => {
- onModelChange(v);
- if (!isSupportedReasoningEffort("claude", v, reasoning)) {
- onReasoningChange(DEFAULT_REASONING_EFFORT);
- }
+ const next = resolveCloudComposerModelChange({
+ adapter: "claude",
+ modelOption: modelConfigOption,
+ requestedModel: v,
+ reasoning,
+ });
+ onModelChange(next.model);
+ onReasoningChange(next.reasoning);
}}
onClose={() => setModelSheetOpen(false)}
options={mobileModelOptions.map((m) => ({
diff --git a/apps/mobile/src/features/tasks/composer/options.test.ts b/apps/mobile/src/features/tasks/composer/options.test.ts
index c155d51849..76675adc5d 100644
--- a/apps/mobile/src/features/tasks/composer/options.test.ts
+++ b/apps/mobile/src/features/tasks/composer/options.test.ts
@@ -5,10 +5,9 @@ import {
} from "@posthog/shared";
import { describe, expect, it } from "vitest";
import {
- getMobileModelOptions,
- getModelConfigOption,
- getModelLabel,
- resolveAvailableModel,
+ getComposerModelOptions,
+ getMobileExecutionModes,
+ resolveComposerPrimaryAction,
} from "./options";
const modelOption: CloudTaskConfigOption = {
@@ -17,11 +16,7 @@ const modelOption: CloudTaskConfigOption = {
type: "select",
currentValue: DEFAULT_GATEWAY_MODEL,
options: [
- {
- value: DEFAULT_GATEWAY_MODEL,
- name: "Claude Opus 4.8",
- description: "Default",
- },
+ { value: DEFAULT_GATEWAY_MODEL, name: "Claude Opus 4.8" },
{
value: "claude-fable-5",
name: "Claude Fable 5",
@@ -32,13 +27,31 @@ const modelOption: CloudTaskConfigOption = {
description: "Choose a model",
};
-describe("mobile cloud task model options", () => {
- it("adapts live model options and disables restricted entries", () => {
- expect(getMobileModelOptions(modelOption)).toEqual([
+describe("mobile composer options", () => {
+ it("hides unrestricted execution modes", () => {
+ expect(
+ getMobileExecutionModes([
+ { id: "plan", name: "Plan", description: "Plan first" },
+ {
+ id: "bypassPermissions",
+ name: "Bypass permissions",
+ description: "Allow everything",
+ },
+ {
+ id: "full-access",
+ name: "Full access",
+ description: "Allow everything",
+ },
+ ]).map((mode) => mode.id),
+ ).toEqual(["plan"]);
+ });
+
+ it("adapts live model options for the mobile picker", () => {
+ expect(getComposerModelOptions(modelOption)).toEqual([
{
value: DEFAULT_GATEWAY_MODEL,
label: "Claude Opus 4.8",
- description: "Default",
+ description: undefined,
disabled: false,
},
{
@@ -50,19 +63,22 @@ describe("mobile cloud task model options", () => {
]);
});
- it("falls back from restricted or missing selections", () => {
- expect(resolveAvailableModel(modelOption, "claude-fable-5")).toBe(
- DEFAULT_GATEWAY_MODEL,
- );
- expect(resolveAvailableModel(modelOption, "missing-model")).toBe(
- DEFAULT_GATEWAY_MODEL,
- );
- });
-
- it("reads the live model label and config option", () => {
- expect(getModelConfigOption([modelOption])).toBe(modelOption);
- expect(getModelLabel(modelOption, DEFAULT_GATEWAY_MODEL)).toBe(
- "Claude Opus 4.8",
- );
+ it.each([
+ [{ hasContent: true }, "send"],
+ [{ canStop: true }, "stop"],
+ [{ isRecording: true }, "mic-stop"],
+ [{}, "mic"],
+ ])("derives the mobile primary action", (overrides, expected) => {
+ expect(
+ resolveComposerPrimaryAction({
+ hasContent: false,
+ disabled: false,
+ isRecording: false,
+ isTranscribing: false,
+ canStop: false,
+ allowSendWhileRunning: true,
+ ...overrides,
+ }),
+ ).toBe(expected);
});
});
diff --git a/apps/mobile/src/features/tasks/composer/options.ts b/apps/mobile/src/features/tasks/composer/options.ts
index 1db34b57a1..35daf8f3e0 100644
--- a/apps/mobile/src/features/tasks/composer/options.ts
+++ b/apps/mobile/src/features/tasks/composer/options.ts
@@ -1,3 +1,4 @@
+import type { ModeInfo } from "@posthog/core/sessions/executionModes";
import {
type CloudTaskConfigOption,
isRestrictedModelOption,
@@ -10,19 +11,23 @@ export interface MobileModelOption {
disabled: boolean;
}
+export function getMobileExecutionModes(
+ modes: readonly ModeInfo[],
+): ModeInfo[] {
+ return modes.filter(
+ (mode) => mode.id !== "bypassPermissions" && mode.id !== "full-access",
+ );
+}
+
export function getModelConfigOption(
configOptions: readonly CloudTaskConfigOption[],
): CloudTaskConfigOption {
- const modelOption = configOptions.find(
- (option) => option.category === "model",
- );
- if (!modelOption) {
- throw new Error("Cloud task model configuration is unavailable");
- }
- return modelOption;
+ const option = configOptions.find((item) => item.category === "model");
+ if (!option) throw new Error("Cloud task model configuration is unavailable");
+ return option;
}
-export function getMobileModelOptions(
+export function getComposerModelOptions(
modelOption: CloudTaskConfigOption,
): MobileModelOption[] {
return modelOption.options.map((option) => ({
@@ -33,24 +38,37 @@ export function getMobileModelOptions(
}));
}
-export function getModelLabel(
- modelOption: CloudTaskConfigOption,
- value: string,
-): string {
- return (
- modelOption.options.find((option) => option.value === value)?.name ?? value
- );
+export function getConfigOptionLabel(
+ options: ReadonlyArray<{ value: string; name: string }>,
+ value: string | undefined,
+): string | undefined {
+ return options.find((option) => option.value === value)?.name ?? value;
}
-export function resolveAvailableModel(
- modelOption: CloudTaskConfigOption,
- value: string,
-): string {
- const selectedOption = modelOption.options.find(
- (option) => option.value === value,
- );
- if (selectedOption && !isRestrictedModelOption(selectedOption._meta)) {
- return value;
- }
- return modelOption.currentValue;
+export type ComposerPrimaryAction =
+ | "send"
+ | "stop"
+ | "mic"
+ | "mic-stop"
+ | "disabled";
+
+export function resolveComposerPrimaryAction({
+ hasContent,
+ disabled,
+ isRecording,
+ isTranscribing,
+ canStop,
+ allowSendWhileRunning,
+}: {
+ hasContent: boolean;
+ disabled: boolean;
+ isRecording: boolean;
+ isTranscribing: boolean;
+ canStop: boolean;
+ allowSendWhileRunning: boolean;
+}): ComposerPrimaryAction {
+ if (disabled || isTranscribing) return "disabled";
+ if (canStop && (!allowSendWhileRunning || !hasContent)) return "stop";
+ if (hasContent && !isRecording) return "send";
+ return isRecording ? "mic-stop" : "mic";
}
diff --git a/apps/mobile/src/features/tasks/skills/api.test.ts b/apps/mobile/src/features/tasks/skills/api.test.ts
deleted file mode 100644
index 9c6a13f2a2..0000000000
--- a/apps/mobile/src/features/tasks/skills/api.test.ts
+++ /dev/null
@@ -1,92 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from "vitest";
-
-const { mockFetch } = vi.hoisted(() => ({
- mockFetch: vi.fn(),
-}));
-
-vi.mock("expo/fetch", () => ({
- fetch: mockFetch,
-}));
-
-vi.mock("@/lib/api", () => ({
- getBaseUrl: () => "https://app.posthog.test",
- getProjectId: () => 42,
- authedFetch: (url: string, init?: RequestInit) =>
- mockFetch(url, {
- ...init,
- headers: {
- Authorization: "Bearer token",
- "Content-Type": "application/json",
- ...((init?.headers as Record | undefined) ?? {}),
- },
- }),
-}));
-
-import { getSkillStoreSkill, getSkillStoreSkills } from "./api";
-
-describe("skill store api", () => {
- beforeEach(() => {
- mockFetch.mockReset();
- });
-
- it("parses paginated skill-list responses", async () => {
- mockFetch.mockResolvedValueOnce(
- new Response(
- JSON.stringify({
- results: [
- {
- name: "shared-daily-brief",
- description: "Shared morning briefing starter",
- },
- ],
- }),
- { status: 200 },
- ),
- );
-
- const skills = await getSkillStoreSkills();
-
- expect(skills).toEqual([
- {
- name: "shared-daily-brief",
- description: "Shared morning briefing starter",
- },
- ]);
- expect(mockFetch).toHaveBeenCalledWith(
- "https://app.posthog.test/api/environments/42/llm_skills/",
- expect.objectContaining({
- headers: expect.objectContaining({
- Authorization: "Bearer token",
- }),
- }),
- );
- });
-
- it("encodes skill names for detail requests and returns the full body", async () => {
- mockFetch.mockResolvedValueOnce(
- new Response(
- JSON.stringify({
- name: "shared/brief today",
- description: "Shared briefing",
- body: "Summarize what matters this morning.",
- }),
- { status: 200 },
- ),
- );
-
- const skill = await getSkillStoreSkill("shared/brief today");
-
- expect(skill).toMatchObject({
- name: "shared/brief today",
- body: "Summarize what matters this morning.",
- });
- expect(mockFetch).toHaveBeenCalledWith(
- "https://app.posthog.test/api/environments/42/llm_skills/name/shared%2Fbrief%20today/",
- expect.objectContaining({
- headers: expect.objectContaining({
- Authorization: "Bearer token",
- }),
- }),
- );
- });
-});
diff --git a/apps/mobile/src/features/tasks/skills/api.ts b/apps/mobile/src/features/tasks/skills/api.ts
deleted file mode 100644
index 46584a4241..0000000000
--- a/apps/mobile/src/features/tasks/skills/api.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import { authedFetch, getBaseUrl, getProjectId } from "@/lib/api";
-import type { SkillStoreListEntry, SkillStoreSkill } from "./types";
-
-function skillStoreBaseUrl(): string {
- const baseUrl = getBaseUrl();
- const projectId = getProjectId();
- return `${baseUrl}/api/environments/${projectId}/llm_skills`;
-}
-
-async function readJsonOrThrow(
- response: Response,
- errorPrefix: string,
-): Promise {
- if (!response.ok) {
- const data = (await response.json().catch(() => ({}))) as {
- detail?: string;
- };
- throw new Error(data.detail ?? `${errorPrefix}: ${response.statusText}`);
- }
-
- return (await response.json()) as T;
-}
-
-export async function getSkillStoreSkills(): Promise {
- const response = await authedFetch(`${skillStoreBaseUrl()}/`);
-
- const data = await readJsonOrThrow<
- SkillStoreListEntry[] | { results?: SkillStoreListEntry[] }
- >(response, "Failed to fetch skills");
-
- return Array.isArray(data) ? data : (data.results ?? []);
-}
-
-export async function getSkillStoreSkill(
- skillName: string,
-): Promise {
- const response = await authedFetch(
- `${skillStoreBaseUrl()}/name/${encodeURIComponent(skillName)}/`,
- );
-
- return readJsonOrThrow(response, "Failed to fetch skill");
-}
diff --git a/apps/mobile/src/features/tasks/skills/hooks.ts b/apps/mobile/src/features/tasks/skills/hooks.ts
index c78a85cded..fcba3e5523 100644
--- a/apps/mobile/src/features/tasks/skills/hooks.ts
+++ b/apps/mobile/src/features/tasks/skills/hooks.ts
@@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query";
-import { getSkillStoreSkill, getSkillStoreSkills } from "./api";
+import { getPostHogApiClient } from "@/lib/posthogApiClient";
const skillStoreKeys = {
all: ["skill-store"] as const,
@@ -13,7 +13,7 @@ const skillStoreKeys = {
export function useSkillStoreSkills() {
return useQuery({
queryKey: skillStoreKeys.list(),
- queryFn: getSkillStoreSkills,
+ queryFn: async () => (await getPostHogApiClient().listLlmSkills()) ?? [],
staleTime: 5 * 60 * 1000,
});
}
@@ -21,7 +21,7 @@ export function useSkillStoreSkills() {
export function useSkillStoreSkill(skillName: string | null) {
return useQuery({
queryKey: skillStoreKeys.detail(skillName ?? ""),
- queryFn: () => getSkillStoreSkill(skillName as string),
+ queryFn: () => getPostHogApiClient().getLlmSkillByName(skillName as string),
enabled: !!skillName,
staleTime: 5 * 60 * 1000,
});
diff --git a/apps/mobile/src/features/tasks/skills/skillTemplateIds.ts b/apps/mobile/src/features/tasks/skills/skillTemplateIds.ts
deleted file mode 100644
index 51231a393c..0000000000
--- a/apps/mobile/src/features/tasks/skills/skillTemplateIds.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-export const SKILL_TEMPLATE_ID_PREFIX = "llm-skill:";
-
-export function formatSkillTemplateId(skillName: string): string {
- return `${SKILL_TEMPLATE_ID_PREFIX}${skillName.trim()}`;
-}
-
-export function parseSkillTemplateId(
- templateId: string | null | undefined,
-): string | null {
- if (!templateId?.startsWith(SKILL_TEMPLATE_ID_PREFIX)) {
- return null;
- }
-
- const skillName = templateId.slice(SKILL_TEMPLATE_ID_PREFIX.length).trim();
- return skillName || null;
-}
diff --git a/apps/mobile/src/features/tasks/skills/types.ts b/apps/mobile/src/features/tasks/skills/types.ts
deleted file mode 100644
index 7db9836bb0..0000000000
--- a/apps/mobile/src/features/tasks/skills/types.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-export interface SkillStoreListEntry {
- name: string;
- description: string | null;
-}
-
-export interface SkillStoreSkill extends SkillStoreListEntry {
- body: string;
-}
diff --git a/apps/mobile/src/features/tasks/stores/pendingPromptRecoveryStore.ts b/apps/mobile/src/features/tasks/stores/pendingPromptRecoveryStore.ts
index 4e8894e353..bca210d849 100644
--- a/apps/mobile/src/features/tasks/stores/pendingPromptRecoveryStore.ts
+++ b/apps/mobile/src/features/tasks/stores/pendingPromptRecoveryStore.ts
@@ -1,3 +1,7 @@
+import {
+ capPendingPrompts,
+ listPendingPromptsNewestFirst,
+} from "@posthog/core/tasks/pendingPrompts";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
@@ -5,8 +9,6 @@ import { createJSONStorage, persist } from "zustand/middleware";
// Persisted (unlike the transient in-memory echo store, whose entries vanish
// the instant the live SSE copy lands) so a prompt survives the app being
// killed mid-create and can be recovered on the next launch.
-const MAX_RECOVERABLE_PROMPTS = 20;
-
export interface RecoverablePrompt {
promptText: string;
createdAt: number;
@@ -20,19 +22,6 @@ interface PendingPromptRecoveryState {
setHasHydrated: (hydrated: boolean) => void;
}
-function capToNewest(
- byKey: Record,
-): Record {
- const keys = Object.keys(byKey);
- if (keys.length <= MAX_RECOVERABLE_PROMPTS) return byKey;
- const kept = keys
- .sort((a, b) => byKey[b].createdAt - byKey[a].createdAt)
- .slice(0, MAX_RECOVERABLE_PROMPTS);
- const trimmed: Record = {};
- for (const key of kept) trimmed[key] = byKey[key];
- return trimmed;
-}
-
export const usePendingPromptRecoveryStore =
create()(
persist(
@@ -41,7 +30,7 @@ export const usePendingPromptRecoveryStore =
hasHydrated: false,
set: (key, promptText) =>
set((state) => ({
- byKey: capToNewest({
+ byKey: capPendingPrompts({
...state.byKey,
[key]: { promptText, createdAt: Date.now() },
}),
@@ -75,9 +64,9 @@ export const pendingPromptRecoveryStoreApi = {
usePendingPromptRecoveryStore.getState().clear(key);
},
getAllNewestFirst(): { key: string; prompt: RecoverablePrompt }[] {
- return Object.entries(usePendingPromptRecoveryStore.getState().byKey)
- .map(([key, prompt]) => ({ key, prompt }))
- .sort((a, b) => b.prompt.createdAt - a.prompt.createdAt);
+ return listPendingPromptsNewestFirst(
+ usePendingPromptRecoveryStore.getState().byKey,
+ );
},
whenHydrated(): Promise {
if (usePendingPromptRecoveryStore.getState().hasHydrated) {
diff --git a/apps/mobile/src/features/tasks/stores/pendingTaskPromptStore.ts b/apps/mobile/src/features/tasks/stores/pendingTaskPromptStore.ts
index e7face63f3..8d759d0086 100644
--- a/apps/mobile/src/features/tasks/stores/pendingTaskPromptStore.ts
+++ b/apps/mobile/src/features/tasks/stores/pendingTaskPromptStore.ts
@@ -1,3 +1,4 @@
+import { buildPendingPromptKey } from "@posthog/core/tasks/pendingPrompts";
import { create } from "zustand";
import type { SessionNotificationAttachment } from "../types";
@@ -79,8 +80,9 @@ export function generatePendingTaskKey(): string {
typeof globalThis !== "undefined"
? (globalThis as { crypto?: { randomUUID?: () => string } }).crypto
: undefined;
- if (cryptoObj?.randomUUID) {
- return cryptoObj.randomUUID();
- }
- return `pending-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
+ return buildPendingPromptKey(
+ cryptoObj?.randomUUID?.() ?? null,
+ Date.now(),
+ Math.random().toString(36).slice(2, 10),
+ );
}
diff --git a/apps/mobile/src/features/tasks/utils/automationTemplatePresentation.test.ts b/apps/mobile/src/features/tasks/utils/automationTemplatePresentation.test.ts
index f3e44f2f49..2020007f00 100644
--- a/apps/mobile/src/features/tasks/utils/automationTemplatePresentation.test.ts
+++ b/apps/mobile/src/features/tasks/utils/automationTemplatePresentation.test.ts
@@ -1,5 +1,5 @@
+import { getAutomationTemplatePresentation } from "@posthog/core/automations/automationTemplatePresentation";
import { describe, expect, it } from "vitest";
-import { getAutomationTemplatePresentation } from "./automationTemplatePresentation";
describe("automationTemplatePresentation", () => {
it("prefers repository context when one exists for skill-backed automations", () => {
diff --git a/apps/mobile/src/features/tasks/utils/automationTemplatePresentation.ts b/apps/mobile/src/features/tasks/utils/automationTemplatePresentation.ts
deleted file mode 100644
index 2f42d89acc..0000000000
--- a/apps/mobile/src/features/tasks/utils/automationTemplatePresentation.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import type { TaskAutomation } from "@posthog/api-client/posthog-client";
-import { parseSkillTemplateId } from "../skills/skillTemplateIds";
-
-export interface AutomationTemplatePresentation {
- templateName: string | null;
- repositoryLabel: string | null;
- contextLabel: string | null;
- secondaryLabel: string;
-}
-
-export function getAutomationTemplatePresentation(
- automation: Pick,
-): AutomationTemplatePresentation {
- const repositoryLabel = automation.repository.trim() || null;
- const skillName = parseSkillTemplateId(automation.template_id);
- const contextLabel = skillName ? "Skill store" : null;
-
- return {
- templateName:
- skillName ?? (automation.template_id ? "Template automation" : null),
- repositoryLabel,
- contextLabel,
- secondaryLabel: repositoryLabel ?? contextLabel ?? "No repository context",
- };
-}