Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions apps/mobile/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand All @@ -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.
Expand Down
18 changes: 11 additions & 7 deletions apps/mobile/src/app/(tabs)/inbox.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";

Expand Down Expand Up @@ -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,
},
}),
);
}, [
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/app/automation/[id].tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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() {
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/app/automation/create.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";

Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/app/mcp-servers/add-custom.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand Down
8 changes: 4 additions & 4 deletions apps/mobile/src/app/mcp-servers/index.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";

Expand Down
6 changes: 3 additions & 3 deletions apps/mobile/src/app/mcp-servers/installation/[id].tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -75,7 +75,7 @@ export default function McpInstallationDetailScreen() {
);
}

const stdio = isStdioServer(installation);
const stdio = isStdioMcpServer(installation);

const handleEnabledChange = (enabled: boolean) => {
updateMutation.mutate({
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/app/mcp-servers/template/[id].tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -71,7 +71,7 @@ export default function McpTemplateDetailScreen() {
);
}

const stdio = isStdioServer(template);
const stdio = isStdioMcpServer(template);

const handleInstall = async () => {
if (!template) return;
Expand Down
51 changes: 32 additions & 19 deletions apps/mobile/src/app/task/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -411,7 +415,7 @@ export default function NewTaskScreen() {

const hasContent = !!prompt.trim() || attachments.length > 0;
const canSubmit =
hasLiveConfig &&
isConfigReady &&
hasContent &&
isRepositorySelectionComplete(selection) &&
!creating;
Expand All @@ -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,
Expand Down Expand Up @@ -649,7 +653,12 @@ export default function NewTaskScreen() {

<Pill
icon={<Robot size={14} color={themeColors.gray[11]} />}
label={getModelLabel(modelConfigOption, model)}
label={
getConfigOptionLabel(
modelConfigOption.options,
model,
) ?? model
}
onPress={() => setModelSheetOpen(true)}
/>

Expand Down Expand Up @@ -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) => ({
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/features/chat/components/AgentMessage.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -110,7 +110,7 @@ export function AgentMessage({
{isLoading && !content && !thinkingText && (
<View className="max-w-[95%] px-4 py-1">
<Text className="font-mono text-[13px] text-gray-9 italic">
{getRandomThinkingMessage()}
{pickThinkingActivity(Math.random())}...
</Text>
</View>
)}
Expand Down
15 changes: 8 additions & 7 deletions apps/mobile/src/features/chat/components/ToolMessage.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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 (
Expand Down
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down
Loading
Loading