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", - }; -}