diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index 915c2bdad7..09ebacf3d0 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -28,6 +28,7 @@
"@modelcontextprotocol/ext-apps": "^1.2.2",
"@modelcontextprotocol/sdk": "^1.29.0",
"@posthog/api-client": "workspace:*",
+ "@posthog/core": "workspace:*",
"@posthog/shared": "workspace:*",
"@react-native-async-storage/async-storage": "^2.2.0",
"@react-native-community/netinfo": "^12.0.1",
diff --git a/apps/mobile/src/app/(tabs)/inbox.tsx b/apps/mobile/src/app/(tabs)/inbox.tsx
index 4013102faa..1e64ab8df4 100644
--- a/apps/mobile/src/app/(tabs)/inbox.tsx
+++ b/apps/mobile/src/app/(tabs)/inbox.tsx
@@ -1,3 +1,5 @@
+import { INBOX_PIPELINE_STATUSES } from "@posthog/core/inbox/reportFiltering";
+import type { SignalReport } from "@posthog/shared/domain-types";
import { useFocusEffect, useRouter } from "expo-router";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { View } from "react-native";
@@ -20,12 +22,8 @@ import {
decidedIds,
useDismissedReportsStore,
} from "@/features/inbox/stores/dismissedReportsStore";
-import {
- DEFAULT_STATUS_FILTER,
- useInboxFilterStore,
-} from "@/features/inbox/stores/inboxFilterStore";
+import { useInboxFilterStore } from "@/features/inbox/stores/inboxFilterStore";
import { useInboxStore } from "@/features/inbox/stores/inboxStore";
-import type { SignalReport } from "@/features/inbox/types";
import { buildInboxViewedProperties } from "@/features/inbox/utils";
import { useIntegrations } from "@/features/tasks/hooks/useIntegrations";
import { ANALYTICS_EVENTS, useAnalytics } from "@/lib/analytics";
@@ -74,7 +72,7 @@ export default function InboxScreen() {
statusFilter,
suggestedReviewerFilter,
priorityFilter,
- defaultStatusFilter: DEFAULT_STATUS_FILTER,
+ defaultStatusFilter: INBOX_PIPELINE_STATUSES,
}),
);
}, [
diff --git a/apps/mobile/src/app/inbox/[...id].tsx b/apps/mobile/src/app/inbox/[...id].tsx
index 48fa3daab2..8ad989bfa9 100644
--- a/apps/mobile/src/app/inbox/[...id].tsx
+++ b/apps/mobile/src/app/inbox/[...id].tsx
@@ -1,4 +1,16 @@
import { Text } from "@components/text";
+import {
+ formatSignalReportSummaryMarkdown,
+ inboxStatusLabel,
+} from "@posthog/core/inbox/reportPresentation";
+import { DISMISSAL_REASON_OPTIONS } from "@posthog/shared";
+import type {
+ ActionabilityJudgmentContent,
+ SignalFindingContent,
+ SignalReportPriority,
+ SignalReportStatus,
+ SuggestedReviewersArtefact,
+} from "@posthog/shared/domain-types";
import { differenceInHours, format, formatDistanceToNow } from "date-fns";
import * as Haptics from "expo-haptics";
import { useLocalSearchParams, useRouter } from "expo-router";
@@ -39,7 +51,6 @@ import {
type ReviewerActionExtra,
SuggestedReviewers,
} from "@/features/inbox/components/SuggestedReviewers";
-import { DISMISSAL_REASON_OPTIONS } from "@/features/inbox/constants";
import { useInboxEngagementTracker } from "@/features/inbox/hooks/useInboxEngagementTracker";
import {
useInboxReport,
@@ -47,17 +58,6 @@ import {
useInboxReportSignals,
} from "@/features/inbox/hooks/useInboxReports";
import { useInboxStore } from "@/features/inbox/stores/inboxStore";
-import type {
- ActionabilityJudgmentContent,
- SignalFindingContent,
- SignalReportPriority,
- SignalReportStatus,
- SuggestedReviewersArtefact,
-} from "@/features/inbox/types";
-import {
- formatSignalReportSummaryMarkdown,
- inboxStatusLabel,
-} from "@/features/inbox/utils";
import { PrStatusBadge } from "@/features/tasks/components/PrStatusBadge";
import {
computeReportAgeHours,
diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx
index c06bd0da15..51afbf92db 100644
--- a/apps/mobile/src/app/task/[id].tsx
+++ b/apps/mobile/src/app/task/[id].tsx
@@ -3,12 +3,21 @@ import type {
CloudTaskQueuedMessage,
CloudTaskQueueMoveDirection,
} from "@posthog/core/sessions/cloudTaskQueue";
+import { DEFAULT_CLAUDE_EXECUTION_MODE } from "@posthog/core/sessions/executionModes";
import {
countUserMessages,
getSessionActivityPhase,
} from "@posthog/core/sessions/sessionActivity";
import { isTaskRunning } from "@posthog/core/tasks/taskArchive";
-import type { Task } from "@posthog/shared";
+import {
+ DEFAULT_GATEWAY_MODEL,
+ DEFAULT_REASONING_EFFORT,
+ type ExecutionMode,
+ getReasoningEffortOptions,
+ type SupportedReasoningEffort,
+ serializeCloudPrompt,
+ type Task,
+} from "@posthog/shared";
import { useQueryClient } from "@tanstack/react-query";
import * as Haptics from "expo-haptics";
import { useLocalSearchParams, useRouter } from "expo-router";
@@ -24,7 +33,6 @@ import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller
import Animated, { useAnimatedStyle } from "react-native-reanimated";
import { FloatingBackButton } from "@/components/FloatingBackButton";
import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore";
-import { runTaskInCloud } from "@/features/tasks/api";
import { CustomImageBadge } from "@/features/tasks/components/CustomImageBadge";
import { FloatingTaskHeader } from "@/features/tasks/components/FloatingTaskHeader";
import { PrDiffStatsBadge } from "@/features/tasks/components/PrDiffStatsBadge";
@@ -32,16 +40,7 @@ import { PrStatusBadge } from "@/features/tasks/components/PrStatusBadge";
import { StopRunButton } from "@/features/tasks/components/StopRunButton";
import { TaskSessionView } from "@/features/tasks/components/TaskSessionView";
import { buildCloudPromptBlocks } from "@/features/tasks/composer/attachments/buildCloudPrompt";
-import { serializeCloudPrompt } from "@/features/tasks/composer/attachments/cloudPrompt";
import type { PendingAttachment } from "@/features/tasks/composer/attachments/types";
-import {
- DEFAULT_EXECUTION_MODE,
- DEFAULT_MODEL,
- DEFAULT_REASONING,
- type ExecutionMode,
- modelSupportsReasoning,
- type ReasoningEffort,
-} from "@/features/tasks/composer/options";
import { QueuedMessagesDock } from "@/features/tasks/composer/QueuedMessagesDock";
import { TaskChatComposer } from "@/features/tasks/composer/TaskChatComposer";
import {
@@ -134,7 +133,13 @@ export default function TaskDetailScreen() {
// Cleared when the screen unmounts. Matches the desktop super-property.
useActiveTaskAnalyticsContext(task?.signal_report ?? null);
- const session = taskId ? getTaskSession(taskId) : undefined;
+ const session = useTaskSessionStore((state) =>
+ taskId
+ ? Object.values(state.sessions).find(
+ (candidate) => candidate.taskId === taskId,
+ )
+ : undefined,
+ );
// Optimistic echo set by the new-task screen (or the terminal-resume path
// below) so the user's prompt appears in the thread immediately, before
@@ -176,14 +181,14 @@ export default function TaskDetailScreen() {
string | undefined
>();
const composerMode: ExecutionMode =
- composerConfig?.mode ?? DEFAULT_EXECUTION_MODE;
- const composerModel = composerConfig?.model ?? DEFAULT_MODEL;
- const composerReasoning: ReasoningEffort =
- composerConfig?.reasoning ?? DEFAULT_REASONING;
+ composerConfig?.mode ?? DEFAULT_CLAUDE_EXECUTION_MODE;
+ const composerModel = composerConfig?.model ?? DEFAULT_GATEWAY_MODEL;
+ const composerReasoning: SupportedReasoningEffort =
+ composerConfig?.reasoning ?? DEFAULT_REASONING_EFFORT;
const messagingMode = useMessagingMode(taskId);
const queuedCount = useQueuedCount(taskId);
- const { editingId: editingQueuedId } = useTaskMessageQueue(taskId);
+ const editingQueuedId = useTaskMessageQueue(taskId ?? "").editingId;
const toggleMessagingMode = useToggleMessagingMode(taskId);
const analytics = useAnalytics();
@@ -317,16 +322,21 @@ export default function TaskDetailScreen() {
)
: text;
- const supportsReasoning = modelSupportsReasoning(composerModel);
- const updatedTask = await runTaskInCloud(taskId, {
- resumeFromRunId: task.latest_run?.id,
- pendingUserMessage,
- runtimeAdapter: "claude",
- model: composerModel,
- reasoningEffort: supportsReasoning ? composerReasoning : undefined,
- initialPermissionMode: composerMode,
- rtkEnabled: usePreferencesStore.getState().rtkEnabledCloud,
- });
+ const supportsReasoning =
+ getReasoningEffortOptions("claude", composerModel) !== null;
+ const updatedTask = await getPostHogApiClient().runTaskInCloud(
+ taskId,
+ undefined,
+ {
+ resumeFromRunId: task.latest_run?.id,
+ pendingUserMessage,
+ adapter: "claude",
+ model: composerModel,
+ reasoningLevel: supportsReasoning ? composerReasoning : undefined,
+ initialPermissionMode: composerMode,
+ rtkEnabled: usePreferencesStore.getState().rtkEnabledCloud,
+ },
+ );
setTask(updatedTask);
await connectToTask(updatedTask);
updateTaskInCache(updatedTask);
@@ -513,7 +523,7 @@ export default function TaskDetailScreen() {
);
const handleReasoningChange = useCallback(
- (value: ReasoningEffort) => {
+ (value: SupportedReasoningEffort) => {
if (!taskId) return;
setComposerConfig(taskId, { reasoning: value });
setConfigOption(taskId, "effort", value).catch(() => {});
@@ -566,10 +576,14 @@ export default function TaskDetailScreen() {
setRetrying(true);
disconnectFromTask(taskId);
- const updatedTask = await runTaskInCloud(taskId, {
- resumeFromRunId: task.latest_run?.id,
- rtkEnabled: usePreferencesStore.getState().rtkEnabledCloud,
- });
+ const updatedTask = await getPostHogApiClient().runTaskInCloud(
+ taskId,
+ undefined,
+ {
+ resumeFromRunId: task.latest_run?.id,
+ rtkEnabled: usePreferencesStore.getState().rtkEnabledCloud,
+ },
+ );
setTask(updatedTask);
await connectToTask(updatedTask);
updateTaskInCache(updatedTask);
diff --git a/apps/mobile/src/app/task/index.tsx b/apps/mobile/src/app/task/index.tsx
index 012b206dcc..9df757f9de 100644
--- a/apps/mobile/src/app/task/index.tsx
+++ b/apps/mobile/src/app/task/index.tsx
@@ -1,4 +1,17 @@
import { Text } from "@components/text";
+import {
+ DEFAULT_CLAUDE_EXECUTION_MODE,
+ getAvailableModes,
+} from "@posthog/core/sessions/executionModes";
+import {
+ DEFAULT_GATEWAY_MODEL,
+ DEFAULT_REASONING_EFFORT,
+ type ExecutionMode,
+ getReasoningEffortOptions,
+ isSupportedReasoningEffort,
+ type SupportedReasoningEffort,
+ serializeCloudPrompt,
+} from "@posthog/shared";
import { LinearGradient } from "expo-linear-gradient";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import {
@@ -15,7 +28,7 @@ import {
Sparkle,
StopIcon,
} from "phosphor-react-native";
-import { useCallback, useState } from "react";
+import { useCallback, useEffect, useState } from "react";
import {
ActivityIndicator,
Pressable,
@@ -30,13 +43,11 @@ import {
import Animated, { runOnJS, useAnimatedStyle } from "react-native-reanimated";
import { useVoiceRecording } from "@/features/chat";
import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore";
-import { runTaskInCloud } from "@/features/tasks/api";
import { GitHubConnectionPrompt } from "@/features/tasks/components/GitHubConnectionPrompt";
import { GitHubLoadNotice } from "@/features/tasks/components/GitHubLoadNotice";
import { AttachmentSheet } from "@/features/tasks/composer/attachments/AttachmentSheet";
import { AttachmentsBar } from "@/features/tasks/composer/attachments/AttachmentsBar";
import { buildCloudPromptBlocks } from "@/features/tasks/composer/attachments/buildCloudPrompt";
-import { serializeCloudPrompt } from "@/features/tasks/composer/attachments/cloudPrompt";
import {
captureFromCamera,
pickDocument,
@@ -45,22 +56,15 @@ import {
import type { PendingAttachment } from "@/features/tasks/composer/attachments/types";
import { DotBackground } from "@/features/tasks/composer/DotBackground";
import {
- DEFAULT_EXECUTION_MODE,
- DEFAULT_MODEL,
- DEFAULT_REASONING,
- EXECUTION_MODES,
- type ExecutionMode,
- MODELS,
- modeLabel,
- modelLabel,
- modelSupportsReasoning,
- REASONING_LEVELS,
- type ReasoningEffort,
- reasoningLabel,
+ getMobileModelOptions,
+ getModelConfigOption,
+ getModelLabel,
+ resolveAvailableModel,
} from "@/features/tasks/composer/options";
import { Pill } from "@/features/tasks/composer/Pill";
import { RepositoryPickerInline } from "@/features/tasks/composer/RepositoryPickerInline";
import { SelectSheet } from "@/features/tasks/composer/SelectSheet";
+import { useCloudTaskConfigOptions } from "@/features/tasks/hooks/useCloudTaskConfigOptions";
import { useUserIntegrations } from "@/features/tasks/hooks/useUserIntegrations";
import { useWarmTask } from "@/features/tasks/hooks/useWarmTask";
import { pendingPromptRecoveryStoreApi } from "@/features/tasks/stores/pendingPromptRecoveryStore";
@@ -84,6 +88,7 @@ import { getPostHogApiClient } from "@/lib/posthogApiClient";
import { toRgba, useThemeColors } from "@/lib/theme";
const log = logger.scope("task-create");
+const EXECUTION_MODES = getAvailableModes();
const SUGGESTIONS = [
"Create or update my CLAUDE.md file",
@@ -99,6 +104,11 @@ function modeIcon(mode: ExecutionMode, color: string, size = 14) {
return ;
case "acceptEdits":
return ;
+ case "bypassPermissions":
+ case "full-access":
+ return ;
+ case "read-only":
+ return ;
case "auto":
return ;
}
@@ -119,6 +129,9 @@ export default function NewTaskScreen() {
const { insets, bottom } = useScreenInsets();
const keyboard = useReanimatedKeyboardAnimation();
const restingBottom = bottom("compact");
+ const { configOptions, hasLiveConfig } = useCloudTaskConfigOptions("claude");
+ const modelConfigOption = getModelConfigOption(configOptions);
+ const mobileModelOptions = getMobileModelOptions(modelConfigOption);
const {
error,
hasGithubIntegration,
@@ -182,22 +195,32 @@ export default function NewTaskScreen() {
const prefs = usePreferencesStore.getState();
if (prefs.defaultInitialTaskMode === "last_used") {
const last = prefs.lastNewTaskMode;
- const isValidMode = EXECUTION_MODES.some((m) => m.value === last);
+ const isValidMode = EXECUTION_MODES.some((mode) => mode.id === last);
if (isValidMode) return last as ExecutionMode;
}
- return DEFAULT_EXECUTION_MODE;
+ return DEFAULT_CLAUDE_EXECUTION_MODE;
});
- const [model, setModel] = useState(DEFAULT_MODEL);
- const [reasoning, setReasoning] = useState(() => {
+ const [model, setModel] = useState(DEFAULT_GATEWAY_MODEL);
+ const [reasoning, setReasoning] = useState(() => {
const prefs = usePreferencesStore.getState();
- const isValidReasoning = (v: string): v is ReasoningEffort =>
- REASONING_LEVELS.some((r) => r.value === v);
const desired =
prefs.defaultReasoningEffort === "last_used"
? prefs.lastUsedReasoningEffort
: prefs.defaultReasoningEffort;
- return isValidReasoning(desired) ? desired : DEFAULT_REASONING;
+ return isSupportedReasoningEffort("claude", DEFAULT_GATEWAY_MODEL, desired)
+ ? desired
+ : DEFAULT_REASONING_EFFORT;
});
+
+ useEffect(() => {
+ if (!hasLiveConfig) return;
+ const availableModel = resolveAvailableModel(modelConfigOption, model);
+ if (availableModel === model) return;
+ setModel(availableModel);
+ if (!isSupportedReasoningEffort("claude", availableModel, reasoning)) {
+ setReasoning(DEFAULT_REASONING_EFFORT);
+ }
+ }, [hasLiveConfig, model, modelConfigOption, reasoning]);
const [creating, setCreating] = useState(false);
const [repoSheetOpen, setRepoSheetOpen] = useState(false);
const [modeSheetOpen, setModeSheetOpen] = useState(false);
@@ -309,7 +332,8 @@ export default function NewTaskScreen() {
? `Attached: ${attachments[0].fileName}`
: `Attached ${attachments.length} files`);
- const task = await getPostHogApiClient().createTask({
+ const client = getPostHogApiClient();
+ const task = await client.createTask({
description: descriptionText,
title: descriptionText.slice(0, 100),
repository: selection.repository ?? undefined,
@@ -334,7 +358,7 @@ export default function NewTaskScreen() {
// Seed the per-task composer config with the mode/model/reasoning the
// user picked here, so the task detail screen reflects them and every
// subsequent run (resume-after-terminal) reuses the selected mode rather
- // than falling back to DEFAULT_EXECUTION_MODE ("plan").
+ // than falling back to the default plan mode.
setComposerConfig(task.id, { mode, model, reasoning });
const pendingUserMessage =
@@ -344,13 +368,14 @@ export default function NewTaskScreen() {
)
: trimmedPrompt;
- const supportsReasoning = modelSupportsReasoning(model);
+ const supportsReasoning =
+ getReasoningEffortOptions("claude", model) !== null;
- await runTaskInCloud(task.id, {
+ await client.runTaskInCloud(task.id, undefined, {
pendingUserMessage,
- runtimeAdapter: "claude",
+ adapter: "claude",
model,
- reasoningEffort: supportsReasoning ? reasoning : undefined,
+ reasoningLevel: supportsReasoning ? reasoning : undefined,
initialPermissionMode: mode,
autoPublish: usePreferencesStore.getState().autoPublishCloudRuns,
rtkEnabled: usePreferencesStore.getState().rtkEnabledCloud,
@@ -386,8 +411,12 @@ export default function NewTaskScreen() {
const hasContent = !!prompt.trim() || attachments.length > 0;
const canSubmit =
- hasContent && isRepositorySelectionComplete(selection) && !creating;
- const showReasoningPill = modelSupportsReasoning(model);
+ hasLiveConfig &&
+ hasContent &&
+ isRepositorySelectionComplete(selection) &&
+ !creating;
+ const reasoningOptions = getReasoningEffortOptions("claude", model) ?? [];
+ const showReasoningPill = reasoningOptions.length > 0;
// Best-effort prewarm; failures are swallowed. `selection.integrationId` is
// the GitHub installation id, not a PostHog integration id — the backend
@@ -395,7 +424,7 @@ export default function NewTaskScreen() {
useWarmTask({
repository: selection.repository,
githubIntegrationId: selection.integrationId,
- composerIsEmpty: !hasContent,
+ composerIsEmpty: !hasContent || !hasLiveConfig,
runtimeAdapter: "claude",
model,
reasoningEffort: showReasoningPill ? reasoning : null,
@@ -610,14 +639,17 @@ export default function NewTaskScreen() {
? themeColors.accent[11]
: themeColors.gray[11],
)}
- label={modeLabel(mode)}
+ label={
+ EXECUTION_MODES.find((option) => option.id === mode)
+ ?.name ?? mode
+ }
accent={mode === "plan"}
onPress={() => setModeSheetOpen(true)}
/>
}
- label={modelLabel(model)}
+ label={getModelLabel(modelConfigOption, model)}
onPress={() => setModelSheetOpen(true)}
/>
@@ -626,7 +658,11 @@ export default function NewTaskScreen() {
icon={
}
- label={reasoningLabel(reasoning)}
+ label={
+ reasoningOptions.find(
+ (option) => option.value === reasoning,
+ )?.name ?? reasoning
+ }
onPress={() => setReasoningSheetOpen(true)}
/>
) : null}
@@ -715,12 +751,12 @@ export default function NewTaskScreen() {
}}
onClose={() => setModeSheetOpen(false)}
options={EXECUTION_MODES.map((executionMode) => ({
- value: executionMode.value,
- label: executionMode.label,
+ value: executionMode.id,
+ label: executionMode.name,
description: executionMode.description,
icon: modeIcon(
- executionMode.value,
- executionMode.value === "plan"
+ executionMode.id as ExecutionMode,
+ executionMode.id === "plan"
? themeColors.accent[11]
: themeColors.gray[11],
16,
@@ -734,15 +770,16 @@ export default function NewTaskScreen() {
value={model}
onChange={(value) => {
setModel(value);
- if (!modelSupportsReasoning(value)) {
- setReasoning(DEFAULT_REASONING);
+ if (!isSupportedReasoningEffort("claude", value, reasoning)) {
+ setReasoning(DEFAULT_REASONING_EFFORT);
}
}}
onClose={() => setModelSheetOpen(false)}
- options={MODELS.map((modelOption) => ({
+ options={mobileModelOptions.map((modelOption) => ({
value: modelOption.value,
label: modelOption.label,
description: modelOption.description,
+ disabled: modelOption.disabled,
icon: ,
}))}
/>
@@ -752,14 +789,14 @@ export default function NewTaskScreen() {
title="Reasoning"
value={reasoning}
onChange={(value) => {
- const next = value as ReasoningEffort;
+ const next = value as SupportedReasoningEffort;
setReasoning(next);
usePreferencesStore.getState().setLastUsedReasoningEffort(next);
}}
onClose={() => setReasoningSheetOpen(false)}
- options={REASONING_LEVELS.map((reasoningLevel) => ({
+ options={reasoningOptions.map((reasoningLevel) => ({
value: reasoningLevel.value,
- label: reasoningLevel.label,
+ label: reasoningLevel.name,
icon: ,
}))}
/>
diff --git a/apps/mobile/src/features/inbox/activityLog.test.ts b/apps/mobile/src/features/inbox/activityLog.test.ts
index 8b791296be..8ba0a681fe 100644
--- a/apps/mobile/src/features/inbox/activityLog.test.ts
+++ b/apps/mobile/src/features/inbox/activityLog.test.ts
@@ -1,3 +1,4 @@
+import type { AnySignalReportArtefact } from "@posthog/shared/domain-types";
import { describe, expect, it } from "vitest";
import {
attributionLabel,
@@ -6,9 +7,8 @@ import {
shortSha,
taskRunLabel,
} from "./activityLog";
-import type { ReportArtefact } from "./types";
-function commit(id: string, createdAt: string): ReportArtefact {
+function commit(id: string, createdAt: string): AnySignalReportArtefact {
return {
id,
type: "commit",
@@ -22,7 +22,7 @@ function commit(id: string, createdAt: string): ReportArtefact {
};
}
-function taskRun(id: string, createdAt: string): ReportArtefact {
+function taskRun(id: string, createdAt: string): AnySignalReportArtefact {
return {
id,
type: "task_run",
@@ -33,13 +33,13 @@ function taskRun(id: string, createdAt: string): ReportArtefact {
describe("selectActivityArtefacts", () => {
it("keeps only commit and task_run, sorted oldest-first", () => {
- const artefacts: ReportArtefact[] = [
+ const artefacts: AnySignalReportArtefact[] = [
taskRun("b", "2026-01-02T00:00:00Z"),
{
id: "x",
type: "note",
created_at: "2026-01-03T00:00:00Z",
- content: {},
+ content: { note: "" },
},
commit("a", "2026-01-01T00:00:00Z"),
];
@@ -51,12 +51,12 @@ describe("selectActivityArtefacts", () => {
});
it("returns an empty list when there is no activity", () => {
- const artefacts: ReportArtefact[] = [
+ const artefacts: AnySignalReportArtefact[] = [
{
id: "x",
type: "note",
created_at: "2026-01-01T00:00:00Z",
- content: {},
+ content: { note: "" },
},
];
expect(selectActivityArtefacts(artefacts)).toEqual([]);
diff --git a/apps/mobile/src/features/inbox/activityLog.ts b/apps/mobile/src/features/inbox/activityLog.ts
index 053b84f04b..cb11aefa22 100644
--- a/apps/mobile/src/features/inbox/activityLog.ts
+++ b/apps/mobile/src/features/inbox/activityLog.ts
@@ -1,12 +1,12 @@
-import type { ReportArtefact } from "./types";
+import type { AnySignalReportArtefact } from "@posthog/shared/domain-types";
export type ActivityArtefact = Extract<
- ReportArtefact,
+ AnySignalReportArtefact,
{ type: "commit" | "task_run" }
>;
export function selectActivityArtefacts(
- artefacts: ReportArtefact[],
+ artefacts: AnySignalReportArtefact[],
): ActivityArtefact[] {
return artefacts
.filter(
diff --git a/apps/mobile/src/features/inbox/api.ts b/apps/mobile/src/features/inbox/api.ts
index 9b37b82725..ed6461ce9f 100644
--- a/apps/mobile/src/features/inbox/api.ts
+++ b/apps/mobile/src/features/inbox/api.ts
@@ -1,14 +1,9 @@
-import { authedFetch, getBaseUrl, getProjectId, HttpError } from "@/lib/api";
-import { logger } from "@/lib/logger";
-import type { DismissalReasonOptionValue } from "./constants";
-
-const log = logger.scope("inbox-api");
-
+import type { DismissalReasonOptionValue } from "@posthog/shared";
import type {
+ AnySignalReportArtefact,
AvailableSuggestedReviewer,
AvailableSuggestedReviewersResponse,
CommitDiffResponse,
- ReportArtefact,
SignalProcessingStateResponse,
SignalReport,
SignalReportArtefactsResponse,
@@ -16,7 +11,11 @@ import type {
SignalReportsQueryParams,
SignalReportsResponse,
SuggestedReviewerWriteEntry,
-} from "./types";
+} from "@posthog/shared/domain-types";
+import { authedFetch, getBaseUrl, getProjectId, HttpError } from "@/lib/api";
+import { logger } from "@/lib/logger";
+
+const log = logger.scope("inbox-api");
export async function getSignalReports(
params?: SignalReportsQueryParams,
@@ -172,7 +171,7 @@ export async function getSignalReportArtefacts(
}
const data = await response.json();
- const results: ReportArtefact[] = data.results ?? [];
+ const results: AnySignalReportArtefact[] = data.results ?? [];
return { results, count: data.count ?? results.length };
}
@@ -245,11 +244,11 @@ export async function getSignalReportSignals(
reportId,
status: response.status,
});
- return { signals: [] };
+ return { report: null, signals: [] };
}
const data = await response.json();
- return { signals: data.signals ?? [] };
+ return { report: data.report ?? null, signals: data.signals ?? [] };
}
/** Resolve the repository associated with a signal report via its repo_selection artefact. */
diff --git a/apps/mobile/src/features/inbox/components/ArchivedReportList.tsx b/apps/mobile/src/features/inbox/components/ArchivedReportList.tsx
index abe60b8a46..a0aed9bc98 100644
--- a/apps/mobile/src/features/inbox/components/ArchivedReportList.tsx
+++ b/apps/mobile/src/features/inbox/components/ArchivedReportList.tsx
@@ -1,4 +1,7 @@
import { Text } from "@components/text";
+import { inboxStatusLabel } from "@posthog/core/inbox/reportPresentation";
+import { dismissalReasonLabel } from "@posthog/shared";
+import type { SignalReport } from "@posthog/shared/domain-types";
import * as Haptics from "expo-haptics";
import { ArrowCounterClockwise, Tray } from "phosphor-react-native";
import { memo, useCallback, useEffect, useRef, useState } from "react";
@@ -11,13 +14,7 @@ import {
} from "react-native";
import { useThemeColors } from "@/lib/theme";
import { useArchivedReports, useRestoreReport } from "../hooks/useInboxReports";
-import type { SignalReport } from "../types";
-import {
- dismissalReasonLabel,
- formatReportTimestamp,
- inboxStatusLabel,
- isRestorableReport,
-} from "../utils";
+import { formatReportTimestamp, isRestorableReport } 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 fb176374a6..19f4cc2510 100644
--- a/apps/mobile/src/features/inbox/components/ArtefactCommit.tsx
+++ b/apps/mobile/src/features/inbox/components/ArtefactCommit.tsx
@@ -1,11 +1,11 @@
import { Text } from "@components/text";
+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 type { CommitContent } from "../types";
import { DiffBlock } from "./DiffBlock";
export function ArtefactCommit({
diff --git a/apps/mobile/src/features/inbox/components/ArtefactTaskRun.tsx b/apps/mobile/src/features/inbox/components/ArtefactTaskRun.tsx
index bea2436c62..2c2b004a58 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 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";
-import type { TaskRunArtefactContent } from "../types";
export function ArtefactTaskRun({
content,
diff --git a/apps/mobile/src/features/inbox/components/DismissReportSheet.tsx b/apps/mobile/src/features/inbox/components/DismissReportSheet.tsx
index 65fc913399..56253e4a2c 100644
--- a/apps/mobile/src/features/inbox/components/DismissReportSheet.tsx
+++ b/apps/mobile/src/features/inbox/components/DismissReportSheet.tsx
@@ -1,4 +1,8 @@
import { Text } from "@components/text";
+import {
+ DISMISSAL_REASON_OPTIONS,
+ type DismissalReasonOptionValue,
+} from "@posthog/shared";
import * as Haptics from "expo-haptics";
import { Check } from "phosphor-react-native";
import { useEffect, useState } from "react";
@@ -14,10 +18,6 @@ import {
} from "react-native";
import { useScreenInsets } from "@/hooks/useScreenInsets";
import { useThemeColors } from "@/lib/theme";
-import {
- DISMISSAL_REASON_OPTIONS,
- type DismissalReasonOptionValue,
-} from "../constants";
import { useDismissReport } from "../hooks/useInboxReports";
export interface DismissReportResult {
diff --git a/apps/mobile/src/features/inbox/components/EditReviewersSheet.tsx b/apps/mobile/src/features/inbox/components/EditReviewersSheet.tsx
index 2e30695576..ad1d1e013c 100644
--- a/apps/mobile/src/features/inbox/components/EditReviewersSheet.tsx
+++ b/apps/mobile/src/features/inbox/components/EditReviewersSheet.tsx
@@ -3,6 +3,10 @@ import {
buildReviewerOptions,
reviewerMatchesAvailable,
} from "@posthog/core/inbox/artefacts";
+import type {
+ AvailableSuggestedReviewer,
+ SuggestedReviewer,
+} from "@posthog/shared/domain-types";
import { MagnifyingGlass } from "phosphor-react-native";
import { useMemo, useState } from "react";
import {
@@ -17,7 +21,6 @@ import { useDebouncedValue } from "@/hooks/useDebouncedValue";
import { useScreenInsets } from "@/hooks/useScreenInsets";
import { useThemeColors } from "@/lib/theme";
import { useAvailableSuggestedReviewers } from "../hooks/useInboxReports";
-import type { AvailableSuggestedReviewer, SuggestedReviewer } from "../types";
import { ReviewerOptionRow } from "./ReviewerOptionRow";
interface EditReviewersSheetProps {
diff --git a/apps/mobile/src/features/inbox/components/FilterSheet.tsx b/apps/mobile/src/features/inbox/components/FilterSheet.tsx
index 5243c67648..2f0f092f94 100644
--- a/apps/mobile/src/features/inbox/components/FilterSheet.tsx
+++ b/apps/mobile/src/features/inbox/components/FilterSheet.tsx
@@ -1,14 +1,13 @@
import { Text } from "@components/text";
+import { INBOX_PIPELINE_STATUSES } from "@posthog/core/inbox/reportFiltering";
+import { inboxStatusLabel } from "@posthog/core/inbox/reportPresentation";
+import type { SourceProduct } from "@posthog/shared";
+import type { SignalReportPriority } from "@posthog/shared/domain-types";
import { Check } from "phosphor-react-native";
import { Modal, Pressable, ScrollView, View } from "react-native";
import { useScreenInsets } from "@/hooks/useScreenInsets";
import { useThemeColors } from "@/lib/theme";
-import {
- type SourceProduct,
- useInboxFilterStore,
-} from "../stores/inboxFilterStore";
-import type { SignalReportPriority, SignalReportStatus } from "../types";
-import { inboxStatusLabel } from "../utils";
+import { useInboxFilterStore } from "../stores/inboxFilterStore";
interface FilterSheetProps {
visible: boolean;
@@ -28,15 +27,6 @@ const SORT_OPTIONS: SortOption[] = [
{ label: "Oldest first", field: "created_at", direction: "asc" },
];
-const FILTERABLE_STATUSES: SignalReportStatus[] = [
- "ready",
- "pending_input",
- "in_progress",
- "failed",
- "candidate",
- "potential",
-];
-
function useStatusDotColors(): Record {
const themeColors = useThemeColors();
return {
@@ -133,7 +123,7 @@ export function FilterSheet({ visible, onClose }: FilterSheetProps) {
const hasActiveFilters =
sourceProductFilter.length > 0 ||
priorityFilter.length > 0 ||
- statusFilter.length < FILTERABLE_STATUSES.length;
+ statusFilter.length < INBOX_PIPELINE_STATUSES.length;
return (
- {FILTERABLE_STATUSES.map((status) => (
+ {INBOX_PIPELINE_STATUSES.map((status) => (
s.currentIndex);
@@ -240,7 +245,8 @@ export function TinderView({
// 3. Create the task
const prompt = `Act on this signal report. Investigate the root cause, implement the fix, and open a PR if appropriate.\n\n${report.summary ?? ""}`;
- const task = await getPostHogApiClient().createTask({
+ const client = getPostHogApiClient();
+ const task = await client.createTask({
description: prompt,
title: prompt.slice(0, 255),
repository: match?.repository ?? repo ?? undefined,
@@ -251,10 +257,10 @@ export function TinderView({
} as CreateTaskOptions);
// 4. Run it
- await runTaskInCloud(task.id, {
+ await client.runTaskInCloud(task.id, undefined, {
pendingUserMessage: prompt,
- runtimeAdapter: "claude",
- model: DEFAULT_MODEL,
+ adapter: "claude",
+ model,
initialPermissionMode: "plan",
runSource: "signal_report",
signalReportId: report.id,
@@ -276,6 +282,7 @@ export function TinderView({
},
[
repositoryOptions,
+ model,
showToastPending,
showToastDone,
acceptReport,
@@ -489,7 +496,7 @@ export function TinderView({
setExpandedReport(null);
}}
className="h-16 w-16 items-center justify-center rounded-full border-2 border-status-success bg-status-success/10 active:bg-status-success/20"
- disabled={creating}
+ disabled={creating || !hasLiveConfig}
hitSlop={8}
>
{creating ? (
diff --git a/apps/mobile/src/features/inbox/constants.ts b/apps/mobile/src/features/inbox/constants.ts
deleted file mode 100644
index f15aca7c5b..0000000000
--- a/apps/mobile/src/features/inbox/constants.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-/**
- * Reasons offered when the user dismisses a signal report.
- * Mirrors apps/code/src/shared/dismissalReasons.ts.
- */
-export const DISMISSAL_REASON_OPTIONS = [
- {
- value: "already_fixed",
- label: "Already fixed",
- snoozesInsteadOfDismiss: true,
- },
- { value: "report_unclear", label: "Report is unclear to me" },
- { value: "analysis_wrong", label: "Agent's analysis is wrong" },
- { value: "wontfix_intentional", label: "Won't fix — intentional behavior" },
- {
- value: "wontfix_irrelevant",
- label: "Won't fix — issue is real but insignificant",
- },
- { value: "other", label: "Something else…" },
-] as const;
-
-export type DismissalReasonOptionValue =
- (typeof DISMISSAL_REASON_OPTIONS)[number]["value"];
diff --git a/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.test.ts b/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.test.ts
index 2e970def6b..16cd3b1f64 100644
--- a/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.test.ts
+++ b/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.test.ts
@@ -9,8 +9,8 @@ vi.mock("posthog-react-native", () => ({
usePostHog: () => null,
}));
+import type { SignalReport } from "@posthog/shared/domain-types";
import { ANALYTICS_EVENTS, type Analytics } from "@/lib/analytics";
-import type { SignalReport } from "../types";
import {
type InboxEngagementTracker,
type UseInboxEngagementTrackerOptions,
diff --git a/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.ts b/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.ts
index 67ac03e03a..323f03a938 100644
--- a/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.ts
+++ b/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.ts
@@ -1,3 +1,4 @@
+import type { SignalReport } from "@posthog/shared/domain-types";
import { useCallback, useEffect, useRef } from "react";
import {
ANALYTICS_EVENTS,
@@ -7,7 +8,6 @@ import {
type InboxReportCloseMethod,
type InboxReportOpenMethod,
} from "@/lib/analytics";
-import type { SignalReport } from "../types";
interface OpenInfo {
reportId: string;
diff --git a/apps/mobile/src/features/inbox/hooks/useInboxReports.test.ts b/apps/mobile/src/features/inbox/hooks/useInboxReports.test.ts
index 407730a054..ad4abf8190 100644
--- a/apps/mobile/src/features/inbox/hooks/useInboxReports.test.ts
+++ b/apps/mobile/src/features/inbox/hooks/useInboxReports.test.ts
@@ -1,3 +1,7 @@
+import type {
+ SignalReport,
+ SignalReportsResponse,
+} from "@posthog/shared/domain-types";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createElement } from "react";
import { act, create } from "react-test-renderer";
@@ -16,7 +20,6 @@ vi.mock("../api", () => ({
getAvailableSuggestedReviewers(query),
}));
-import type { SignalReport, SignalReportsResponse } from "../types";
import {
getReportsNextPageParam,
useAvailableSuggestedReviewers,
diff --git a/apps/mobile/src/features/inbox/hooks/useInboxReports.ts b/apps/mobile/src/features/inbox/hooks/useInboxReports.ts
index ac37c738d1..a2d91f0520 100644
--- a/apps/mobile/src/features/inbox/hooks/useInboxReports.ts
+++ b/apps/mobile/src/features/inbox/hooks/useInboxReports.ts
@@ -7,6 +7,19 @@ import {
INBOX_DISMISSED_STATUS_FILTER,
INBOX_REFETCH_INTERVAL_MS,
} from "@posthog/core/inbox/reportFiltering";
+import type {
+ AvailableSuggestedReviewersResponse,
+ CommitDiffResponse,
+ SignalProcessingStateResponse,
+ SignalReport,
+ SignalReportArtefactsResponse,
+ SignalReportSignalsResponse,
+ SignalReportsQueryParams,
+ SignalReportsResponse,
+ SuggestedReviewer,
+ SuggestedReviewersArtefact,
+ SuggestedReviewerWriteEntry,
+} from "@posthog/shared/domain-types";
import {
useInfiniteQuery,
useMutation,
@@ -29,18 +42,6 @@ import {
updateSignalReportArtefact,
} from "../api";
import { useInboxFilterStore } from "../stores/inboxFilterStore";
-import type {
- AvailableSuggestedReviewersResponse,
- CommitDiffResponse,
- SignalProcessingStateResponse,
- SignalReport,
- SignalReportArtefactsResponse,
- SignalReportSignalsResponse,
- SignalReportsQueryParams,
- SignalReportsResponse,
- SuggestedReviewer,
- SuggestedReviewerWriteEntry,
-} from "../types";
import { isRestorableReport } from "../utils";
export const inboxKeys = {
@@ -264,12 +265,20 @@ export function useUpdateSuggestedReviewers(reportId: string) {
if (previous) {
queryClient.setQueryData(queryKey, {
...previous,
- results: previous.results.map((artefact) =>
- artefact.id === artefactId &&
- artefact.type === "suggested_reviewers"
- ? { ...artefact, content: optimisticReviewers }
- : artefact,
- ),
+ results: previous.results.map((artefact) => {
+ if (
+ artefact.id === artefactId &&
+ artefact.type === "suggested_reviewers"
+ ) {
+ const updatedArtefact: SuggestedReviewersArtefact = {
+ ...artefact,
+ type: "suggested_reviewers",
+ content: optimisticReviewers,
+ };
+ return updatedArtefact;
+ }
+ return artefact;
+ }),
});
}
return { previous };
diff --git a/apps/mobile/src/features/inbox/stores/inboxFilterStore.test.ts b/apps/mobile/src/features/inbox/stores/inboxFilterStore.test.ts
index e8dfa483f9..b5708b1687 100644
--- a/apps/mobile/src/features/inbox/stores/inboxFilterStore.test.ts
+++ b/apps/mobile/src/features/inbox/stores/inboxFilterStore.test.ts
@@ -1,3 +1,4 @@
+import type { SourceProduct } from "@posthog/shared";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@react-native-async-storage/async-storage", () => ({
@@ -8,7 +9,7 @@ vi.mock("@react-native-async-storage/async-storage", () => ({
},
}));
-import { type SourceProduct, useInboxFilterStore } from "./inboxFilterStore";
+import { useInboxFilterStore } from "./inboxFilterStore";
describe("inboxFilterStore", () => {
beforeEach(() => {
diff --git a/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts b/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts
index a0bc079ac4..81d29da1f3 100644
--- a/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts
+++ b/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts
@@ -1,12 +1,13 @@
import { INBOX_PIPELINE_STATUSES } from "@posthog/core/inbox/reportFiltering";
-import AsyncStorage from "@react-native-async-storage/async-storage";
-import { create } from "zustand";
-import { createJSONStorage, persist } from "zustand/middleware";
+import type { SourceProduct } from "@posthog/shared";
import type {
SignalReportOrderingField,
SignalReportPriority,
SignalReportStatus,
-} from "../types";
+} from "@posthog/shared/domain-types";
+import AsyncStorage from "@react-native-async-storage/async-storage";
+import { create } from "zustand";
+import { createJSONStorage, persist } from "zustand/middleware";
type SortField = Extract<
SignalReportOrderingField,
@@ -15,20 +16,6 @@ type SortField = Extract<
type SortDirection = "asc" | "desc";
-export type SourceProduct =
- | "session_replay"
- | "error_tracking"
- | "llm_analytics"
- | "github"
- | "linear"
- | "zendesk"
- | "conversations"
- | "signals_scout";
-
-export const DEFAULT_STATUS_FILTER: SignalReportStatus[] = [
- ...INBOX_PIPELINE_STATUSES,
-];
-
interface InboxFilterState {
sortField: SortField;
sortDirection: SortDirection;
@@ -57,7 +44,7 @@ export const useInboxFilterStore = create()(
(set) => ({
sortField: "priority",
sortDirection: "asc",
- statusFilter: DEFAULT_STATUS_FILTER,
+ statusFilter: [...INBOX_PIPELINE_STATUSES],
sourceProductFilter: [],
suggestedReviewerFilter: [],
priorityFilter: [],
@@ -105,7 +92,7 @@ export const useInboxFilterStore = create()(
set({ priorityFilter: Array.from(new Set(priorities)) }),
resetFilters: () =>
set({
- statusFilter: DEFAULT_STATUS_FILTER,
+ statusFilter: [...INBOX_PIPELINE_STATUSES],
sourceProductFilter: [],
suggestedReviewerFilter: [],
priorityFilter: [],
diff --git a/apps/mobile/src/features/inbox/stores/inboxStore.ts b/apps/mobile/src/features/inbox/stores/inboxStore.ts
index 7bdb2ec206..32120c07a2 100644
--- a/apps/mobile/src/features/inbox/stores/inboxStore.ts
+++ b/apps/mobile/src/features/inbox/stores/inboxStore.ts
@@ -1,5 +1,5 @@
+import type { SignalReportOrderingField } from "@posthog/shared/domain-types";
import { create } from "zustand";
-import type { SignalReportOrderingField } from "../types";
type OrderDirection = "asc" | "desc";
diff --git a/apps/mobile/src/features/inbox/types.ts b/apps/mobile/src/features/inbox/types.ts
deleted file mode 100644
index 248ab5f86b..0000000000
--- a/apps/mobile/src/features/inbox/types.ts
+++ /dev/null
@@ -1,209 +0,0 @@
-import type { DismissalReasonOptionValue } from "./constants";
-
-export type SignalReportStatus =
- | "potential"
- | "candidate"
- | "in_progress"
- | "ready"
- | "failed"
- | "pending_input"
- | "resolved"
- | "suppressed"
- | "deleted";
-
-export type SignalReportPriority = "P0" | "P1" | "P2" | "P3" | "P4";
-
-export type SignalReportActionability =
- | "immediately_actionable"
- | "requires_human_input"
- | "not_actionable";
-
-export interface SignalReport {
- id: string;
- title: string | null;
- summary: string | null;
- status: SignalReportStatus;
- total_weight: number;
- signal_count: number;
- signals_at_run?: number;
- created_at: string;
- updated_at: string;
- artefact_count: number;
- priority?: SignalReportPriority | null;
- actionability?: SignalReportActionability | null;
- already_addressed?: boolean | null;
- dismissal_reason?: DismissalReasonOptionValue | null;
- dismissal_note?: string | null;
- is_suggested_reviewer?: boolean;
- source_products?: string[];
- implementation_pr_url?: string | null;
-}
-
-export interface SignalReportsResponse {
- results: SignalReport[];
- count: number;
-}
-
-export type SignalReportOrderingField =
- | "priority"
- | "signal_count"
- | "total_weight"
- | "created_at"
- | "updated_at";
-
-export interface SignalReportsQueryParams {
- limit?: number;
- offset?: number;
- status?: string;
- ordering?: string;
- source_product?: string;
- suggested_reviewers?: string;
- priority?: string;
-}
-
-export interface SignalProcessingStateResponse {
- paused_until: string | null;
-}
-
-export interface AvailableSuggestedReviewer {
- uuid: string;
- name: string;
- email: string;
- github_login: string;
-}
-
-export interface AvailableSuggestedReviewersResponse {
- results: AvailableSuggestedReviewer[];
- count: number;
-}
-
-export interface Signal {
- signal_id: string;
- content: string;
- source_product: string;
- source_type: string;
- source_id: string;
- weight: number;
- timestamp: string;
- extra: Record;
-}
-
-export interface SignalFindingContent {
- signal_id: string;
- relevant_code_paths: string[];
- relevant_commit_hashes: Record;
- data_queried: string;
- verified: boolean;
-}
-
-export interface PriorityJudgmentContent {
- explanation: string;
- priority: SignalReportPriority;
-}
-
-export interface ActionabilityJudgmentContent {
- explanation: string;
- actionability: SignalReportActionability;
- already_addressed: boolean;
-}
-
-export interface SuggestedReviewerCommit {
- sha: string;
- url: string;
- reason: string;
-}
-
-export interface SuggestedReviewerUser {
- id: number;
- uuid: string;
- email: string;
- first_name: string;
- last_name: string;
-}
-
-export interface SuggestedReviewer {
- github_login: string;
- github_name: string | null;
- relevant_commits: SuggestedReviewerCommit[];
- user: SuggestedReviewerUser | null;
-}
-
-export interface SuggestedReviewersArtefact {
- id: string;
- type: "suggested_reviewers";
- created_at: string;
- content: SuggestedReviewer[];
-}
-
-/**
- * Write shape for replacing the suggested_reviewers artefact. The server
- * canonicalizes to a lowercase `github_login`, with `user_uuid` winning when
- * both are supplied.
- */
-export interface SuggestedReviewerWriteEntry {
- github_login?: string;
- user_uuid?: string;
- github_name?: string;
-}
-
-export interface ArtefactUser {
- uuid?: string;
- email: string;
- first_name?: string;
- last_name?: string;
-}
-
-export interface CommitContent {
- repository: string;
- branch: string;
- commit_sha: string;
- message: string;
- note?: string | null;
-}
-
-export interface TaskRunArtefactContent {
- task_id: string;
- product: string;
- type: string;
-}
-
-export interface CommitDiffResponse {
- diff: string;
- truncated: boolean;
-}
-
-/**
- * Fields shared by every artefact row. `created_by` / `task_id` carry
- * attribution: at most one is set — `created_by` for user writes, `task_id`
- * for agent writes, neither for system writes.
- */
-interface BaseArtefact {
- id: string;
- created_at: string;
- created_by?: ArtefactUser | null;
- task_id?: string | null;
-}
-
-export type ReportArtefact =
- | (BaseArtefact & {
- type: "priority_judgment";
- content: PriorityJudgmentContent;
- })
- | (BaseArtefact & {
- type: "actionability_judgment";
- content: ActionabilityJudgmentContent;
- })
- | (BaseArtefact & { type: "signal_finding"; content: SignalFindingContent })
- | (BaseArtefact & { type: "commit"; content: CommitContent })
- | (BaseArtefact & { type: "task_run"; content: TaskRunArtefactContent })
- | (BaseArtefact & SuggestedReviewersArtefact)
- | (BaseArtefact & { type: string; content: unknown });
-
-export interface SignalReportArtefactsResponse {
- results: ReportArtefact[];
- count: number;
-}
-
-export interface SignalReportSignalsResponse {
- signals: Signal[];
-}
diff --git a/apps/mobile/src/features/inbox/utils.test.ts b/apps/mobile/src/features/inbox/utils.test.ts
index 6f2bfbcf8c..cf80329954 100644
--- a/apps/mobile/src/features/inbox/utils.test.ts
+++ b/apps/mobile/src/features/inbox/utils.test.ts
@@ -1,20 +1,18 @@
-import { describe, expect, it } from "vitest";
-import type { SignalReport, SignalReportStatus } from "./types";
import {
- buildInboxViewedProperties,
- dismissalReasonLabel,
- formatSignalReportSummaryMarkdown,
- isRestorableReport,
-} from "./utils";
-
-const DEFAULT_STATUS_FILTER: SignalReportStatus[] = [
- "ready",
- "pending_input",
- "in_progress",
- "failed",
- "candidate",
- "potential",
-];
+ buildArchiveListOrdering,
+ buildPriorityFilterParam,
+ buildSignalReportListOrdering,
+ INBOX_PIPELINE_STATUSES,
+} from "@posthog/core/inbox/reportFiltering";
+import { formatSignalReportSummaryMarkdown } from "@posthog/core/inbox/reportPresentation";
+import { dismissalReasonLabel } from "@posthog/shared";
+import type {
+ SignalReport,
+ SignalReportOrderingField,
+ SignalReportStatus,
+} from "@posthog/shared/domain-types";
+import { describe, expect, it } from "vitest";
+import { buildInboxViewedProperties, isRestorableReport } from "./utils";
function makeReport(
partial: Partial & Pick,
@@ -74,10 +72,10 @@ describe("buildInboxViewedProperties", () => {
it("emits zero counts for an empty list", () => {
const props = buildInboxViewedProperties([], 0, {
sourceProductFilter: [],
- statusFilter: DEFAULT_STATUS_FILTER,
+ statusFilter: INBOX_PIPELINE_STATUSES,
suggestedReviewerFilter: [],
priorityFilter: [],
- defaultStatusFilter: DEFAULT_STATUS_FILTER,
+ defaultStatusFilter: INBOX_PIPELINE_STATUSES,
});
expect(props).toMatchObject({
report_count: 0,
@@ -123,10 +121,10 @@ describe("buildInboxViewedProperties", () => {
const props = buildInboxViewedProperties(reports, 4, {
sourceProductFilter: [],
- statusFilter: DEFAULT_STATUS_FILTER,
+ statusFilter: INBOX_PIPELINE_STATUSES,
suggestedReviewerFilter: [],
priorityFilter: [],
- defaultStatusFilter: DEFAULT_STATUS_FILTER,
+ defaultStatusFilter: INBOX_PIPELINE_STATUSES,
});
expect(props.report_count).toBe(4);
@@ -147,36 +145,36 @@ describe("buildInboxViewedProperties", () => {
statusFilter: ["ready"],
suggestedReviewerFilter: [],
priorityFilter: [],
- defaultStatusFilter: DEFAULT_STATUS_FILTER,
+ defaultStatusFilter: INBOX_PIPELINE_STATUSES,
});
expect(narrowed.has_active_filters).toBe(true);
expect(narrowed.status_filter_count).toBe(1);
const sourced = buildInboxViewedProperties([], 0, {
sourceProductFilter: ["error_tracking"],
- statusFilter: DEFAULT_STATUS_FILTER,
+ statusFilter: INBOX_PIPELINE_STATUSES,
suggestedReviewerFilter: [],
priorityFilter: [],
- defaultStatusFilter: DEFAULT_STATUS_FILTER,
+ defaultStatusFilter: INBOX_PIPELINE_STATUSES,
});
expect(sourced.has_active_filters).toBe(true);
expect(sourced.source_product_filter).toEqual(["error_tracking"]);
const reviewer = buildInboxViewedProperties([], 0, {
sourceProductFilter: [],
- statusFilter: DEFAULT_STATUS_FILTER,
+ statusFilter: INBOX_PIPELINE_STATUSES,
suggestedReviewerFilter: ["uuid-1"],
priorityFilter: [],
- defaultStatusFilter: DEFAULT_STATUS_FILTER,
+ defaultStatusFilter: INBOX_PIPELINE_STATUSES,
});
expect(reviewer.has_active_filters).toBe(true);
const prioritized = buildInboxViewedProperties([], 0, {
sourceProductFilter: [],
- statusFilter: DEFAULT_STATUS_FILTER,
+ statusFilter: INBOX_PIPELINE_STATUSES,
suggestedReviewerFilter: [],
priorityFilter: ["P0"],
- defaultStatusFilter: DEFAULT_STATUS_FILTER,
+ defaultStatusFilter: INBOX_PIPELINE_STATUSES,
});
expect(prioritized.has_active_filters).toBe(true);
});
@@ -184,15 +182,89 @@ describe("buildInboxViewedProperties", () => {
it("treats a reordered default status set as not filtered", () => {
const props = buildInboxViewedProperties([], 0, {
sourceProductFilter: [],
- statusFilter: [...DEFAULT_STATUS_FILTER].reverse(),
+ statusFilter: [...INBOX_PIPELINE_STATUSES].reverse(),
suggestedReviewerFilter: [],
priorityFilter: [],
- defaultStatusFilter: DEFAULT_STATUS_FILTER,
+ defaultStatusFilter: INBOX_PIPELINE_STATUSES,
});
expect(props.has_active_filters).toBe(false);
});
});
+describe("buildSignalReportListOrdering", () => {
+ it.each([
+ {
+ field: "priority" as SignalReportOrderingField,
+ direction: "desc" as const,
+ expected: "status,-priority,-created_at",
+ },
+ {
+ field: "priority" as SignalReportOrderingField,
+ direction: "asc" as const,
+ expected: "status,priority,-created_at",
+ },
+ {
+ field: "signal_count" as SignalReportOrderingField,
+ direction: "desc" as const,
+ expected: "status,-signal_count,priority",
+ },
+ {
+ field: "total_weight" as SignalReportOrderingField,
+ direction: "asc" as const,
+ expected: "status,total_weight,priority",
+ },
+ {
+ field: "created_at" as SignalReportOrderingField,
+ direction: "desc" as const,
+ expected: "status,-created_at,priority",
+ },
+ {
+ field: "updated_at" as SignalReportOrderingField,
+ direction: "asc" as const,
+ expected: "status,updated_at,priority",
+ },
+ ])(
+ "orders $field $direction as $expected",
+ ({ field, direction, expected }) => {
+ expect(buildSignalReportListOrdering(field, direction)).toBe(expected);
+ },
+ );
+});
+
+describe("buildPriorityFilterParam", () => {
+ it.each([
+ {
+ name: "returns undefined for an empty selection",
+ input: [],
+ expected: undefined,
+ },
+ {
+ name: "joins selected priorities with commas",
+ input: ["P0", "P2"] as const,
+ expected: "P0,P2",
+ },
+ {
+ name: "dedupes repeated priorities",
+ input: ["P1", "P1", "P3"] as const,
+ expected: "P1,P3",
+ },
+ ])("$name", ({ input, expected }) => {
+ expect(buildPriorityFilterParam([...input])).toBe(expected);
+ });
+});
+
+describe("buildArchiveListOrdering", () => {
+ it.each([
+ { direction: "desc" as const, expected: "-updated_at" },
+ { direction: "asc" as const, expected: "updated_at" },
+ ])(
+ "sorts by field without a status prefix ($direction)",
+ ({ direction, expected }) => {
+ expect(buildArchiveListOrdering("updated_at", direction)).toBe(expected);
+ },
+ );
+});
+
describe("isRestorableReport", () => {
it.each([
{ status: "suppressed" as SignalReportStatus, expected: true },
diff --git a/apps/mobile/src/features/inbox/utils.ts b/apps/mobile/src/features/inbox/utils.ts
index 6fa4c895cc..cbc8c348ec 100644
--- a/apps/mobile/src/features/inbox/utils.ts
+++ b/apps/mobile/src/features/inbox/utils.ts
@@ -1,37 +1,10 @@
-import { differenceInHours, format, formatDistanceToNow } from "date-fns";
-import type { InboxViewedProperties } from "@/lib/analytics";
-import { DISMISSAL_REASON_OPTIONS } from "./constants";
import type {
SignalReport,
SignalReportPriority,
SignalReportStatus,
-} from "./types";
-
-const SIGNAL_SUMMARY_SECTION_HEADERS = [
- "What's happening",
- "Root cause",
- "How to resolve",
-] as const;
-
-/**
- * Inserts blank lines around signal report summary section headers so each
- * label and its body render on their own line (agent output often packs them
- * together, e.g. `**What's happening:** text **Root cause:** ...`).
- */
-export function formatSignalReportSummaryMarkdown(content: string): string {
- let result = content;
-
- for (const header of SIGNAL_SUMMARY_SECTION_HEADERS) {
- const boldHeader = `\\*\\*${header}:\\*\\*`;
- result = result.replace(
- new RegExp(`([^\\n])\\s*(${boldHeader})`, "gi"),
- "$1\n\n$2",
- );
- result = result.replace(new RegExp(`(${boldHeader})\\s+`, "gi"), "$1\n\n");
- }
-
- return result;
-}
+} from "@posthog/shared/domain-types";
+import { differenceInHours, format, formatDistanceToNow } from "date-fns";
+import type { InboxViewedProperties } from "@/lib/analytics";
/** Relative time for the last day, absolute "MMM d" beyond it. */
export function formatReportTimestamp(date: Date): string {
@@ -50,38 +23,6 @@ export function isRestorableReport(
return report.status === "suppressed";
}
-/** Human label for a persisted dismissal reason, falling back to the raw code. */
-export function dismissalReasonLabel(value: string): string {
- return (
- DISMISSAL_REASON_OPTIONS.find((o) => o.value === value)?.label ?? value
- );
-}
-
-export function inboxStatusLabel(status: SignalReportStatus): string {
- switch (status) {
- case "ready":
- return "Ready";
- case "resolved":
- return "Resolved";
- case "pending_input":
- return "Needs input";
- case "in_progress":
- return "Researching";
- case "candidate":
- return "Queued";
- case "potential":
- return "Gathering";
- case "failed":
- return "Failed";
- case "suppressed":
- return "Suppressed";
- case "deleted":
- return "Deleted";
- default:
- return status;
- }
-}
-
/**
* Returns only reports that are actionable for the tinder-like card deck:
* ready, immediately actionable, not already addressed.
@@ -97,11 +38,11 @@ export function getActionableReports(reports: SignalReport[]): SignalReport[] {
interface InboxViewedFilterState {
sourceProductFilter: string[];
- statusFilter: SignalReportStatus[];
+ 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: SignalReportStatus[];
+ defaultStatusFilter: readonly SignalReportStatus[];
}
/**
diff --git a/apps/mobile/src/features/tasks/api.test.ts b/apps/mobile/src/features/tasks/api.test.ts
deleted file mode 100644
index ee7dcf02c8..0000000000
--- a/apps/mobile/src/features/tasks/api.test.ts
+++ /dev/null
@@ -1,143 +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", () => ({
- HttpError: class HttpError extends Error {
- constructor(
- readonly status: number,
- readonly statusText: string,
- message: string,
- ) {
- super(message);
- this.name = "HttpError";
- }
- },
- getBaseUrl: () => "https://app.posthog.test",
- getProjectId: () => 42,
- getAccessToken: () => "token",
- createTimeoutSignal: () => undefined,
- authedFetch: (url: string, init?: RequestInit) =>
- mockFetch(url, {
- ...init,
- headers: {
- Authorization: "Bearer token",
- "Content-Type": "application/json",
- ...((init?.headers as Record | undefined) ?? {}),
- },
- }),
-}));
-
-import { cancelRun, HttpError, runTaskInCloud } from "./api";
-
-function bodyOf(call: unknown): Record {
- const [, init] = call as [string, RequestInit];
- return JSON.parse(init.body as string);
-}
-
-describe("runTaskInCloud", () => {
- beforeEach(() => {
- mockFetch.mockReset();
- mockFetch.mockResolvedValue(
- new Response(JSON.stringify({ id: "task-1" }), { status: 200 }),
- );
- });
-
- it.each([true, false])(
- "forwards auto_publish=%s to the payload",
- async (flag) => {
- await runTaskInCloud("task-1", { autoPublish: flag });
-
- expect(bodyOf(mockFetch.mock.calls[0])).toMatchObject({
- auto_publish: flag,
- });
- },
- );
-
- it("omits auto_publish when not provided", async () => {
- await runTaskInCloud("task-1", { model: "claude-opus-4-8" });
-
- expect(bodyOf(mockFetch.mock.calls[0])).not.toHaveProperty("auto_publish");
- });
-
- it("sends no body for the plain initial run", async () => {
- await runTaskInCloud("task-1");
-
- const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
- expect(init.body).toBeUndefined();
- });
-
- it("sends rtk_enabled=false when the run opts out", async () => {
- await runTaskInCloud("task-1", { rtkEnabled: false });
-
- expect(bodyOf(mockFetch.mock.calls[0])).toMatchObject({
- rtk_enabled: false,
- });
- });
-
- it("omits rtk_enabled when the run keeps compression on", async () => {
- await runTaskInCloud("task-1", { rtkEnabled: true });
-
- const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
- expect(init.body).toBeUndefined();
- });
-});
-
-describe("cancelRun", () => {
- beforeEach(() => {
- mockFetch.mockReset();
- });
-
- it("POSTs to the run cancel endpoint with an empty body", async () => {
- mockFetch.mockResolvedValue(
- new Response(JSON.stringify({ status: "cancelled" }), { status: 200 }),
- );
-
- const result = await cancelRun("task-1", "run-1");
-
- const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
- expect(url).toBe(
- "https://app.posthog.test/api/projects/42/tasks/task-1/runs/run-1/cancel/",
- );
- expect(init.method).toBe("POST");
- expect(bodyOf(mockFetch.mock.calls[0])).toEqual({});
- expect(result).toEqual({ status: "cancelled" });
- });
-
- it("forwards a reason when provided", async () => {
- mockFetch.mockResolvedValue(new Response("{}", { status: 200 }));
-
- await cancelRun("task-1", "run-1", "user requested");
-
- expect(bodyOf(mockFetch.mock.calls[0])).toEqual({
- reason: "user requested",
- });
- });
-
- it("throws with the backend error message on failure", async () => {
- mockFetch.mockResolvedValue(
- new Response(JSON.stringify({ error: "Run already finished" }), {
- status: 409,
- }),
- );
-
- await expect(cancelRun("task-1", "run-1")).rejects.toMatchObject({
- status: 409,
- message: expect.stringContaining("Run already finished"),
- });
- });
-
- it("falls back to a generic message when the body has no error", async () => {
- mockFetch.mockResolvedValue(new Response("boom", { status: 500 }));
-
- await expect(cancelRun("task-1", "run-1")).rejects.toBeInstanceOf(
- HttpError,
- );
- });
-});
diff --git a/apps/mobile/src/features/tasks/api.ts b/apps/mobile/src/features/tasks/api.ts
deleted file mode 100644
index 2408f688c6..0000000000
--- a/apps/mobile/src/features/tasks/api.ts
+++ /dev/null
@@ -1,425 +0,0 @@
-import type {
- Adapter,
- StoredLogEntry,
- Task,
- TaskRun,
-} from "@posthog/shared";
-import { fetch } from "expo/fetch";
-import {
- authedFetch,
- createTimeoutSignal,
- getAccessToken,
- getBaseUrl,
- getProjectId,
- HttpError,
-} from "@/lib/api";
-
-export { HttpError } from "@/lib/api";
-
-async function withRetry(
- fn: () => Promise,
- options: {
- maxRetries?: number;
- baseDelayMs?: number;
- shouldRetry?: (error: unknown) => boolean;
- } = {},
-): Promise {
- const { maxRetries = 3, baseDelayMs = 200, shouldRetry } = options;
-
- for (let attempt = 1; attempt <= maxRetries; attempt++) {
- try {
- return await fn();
- } catch (error) {
- const isLastAttempt = attempt === maxRetries;
- const canRetry = shouldRetry ? shouldRetry(error) : true;
-
- if (isLastAttempt || !canRetry) {
- throw error;
- }
-
- const delay = baseDelayMs * 2 ** (attempt - 1);
- await new Promise((resolve) => setTimeout(resolve, delay));
- }
- }
- throw new Error("Unreachable");
-}
-
-function isRetryableError(error: unknown): boolean {
- if (
- error instanceof Error &&
- "status" in error &&
- typeof error.status === "number"
- ) {
- return error.status >= 500 && error.status < 600;
- }
- if (error instanceof Error) {
- const message = error.message.toLowerCase();
- if (message.includes("network")) return true;
- if (message.includes("timeout")) return true;
- if (message.includes("econnreset")) return true;
- }
- return false;
-}
-
-export interface RunTaskInCloudOptions {
- branch?: string | null;
- resumeFromRunId?: string;
- pendingUserMessage?: string;
- mode?: "interactive" | "background";
- /** Adapter to use on the cloud runner. Currently only "claude" on mobile. */
- runtimeAdapter?: Adapter;
- /** Gateway model ID, e.g. "claude-opus-4-8". */
- model?: string;
- /** Reasoning effort: "low" | "medium" | "high" (model-dependent). */
- reasoningEffort?: string;
- /** Permission mode: "default" | "acceptEdits" | "plan" | "auto". */
- initialPermissionMode?: string;
- /** Source that triggered this run. */
- runSource?: "manual" | "signal_report";
- /** Signal report ID when run_source is "signal_report". */
- signalReportId?: string;
- /** When true, the cloud run pushes its changes and opens a draft PR on
- * completion without waiting for an explicit ask. */
- autoPublish?: boolean;
- /** Only false is sent: opts the run out of rtk command-output compression. */
- rtkEnabled?: boolean;
-}
-
-export async function runTaskInCloud(
- taskId: string,
- options?: RunTaskInCloudOptions,
-): Promise {
- const baseUrl = getBaseUrl();
- const projectId = getProjectId();
-
- // Only serialize a body when we have options to send. Sending an empty
- // or minimal body on the initial run historically changed backend
- // behavior, so we preserve the "no body" path for the common case.
- const hasOptions =
- !!options &&
- (options.branch !== undefined ||
- options.resumeFromRunId !== undefined ||
- options.pendingUserMessage !== undefined ||
- options.mode !== undefined ||
- options.runtimeAdapter !== undefined ||
- options.model !== undefined ||
- options.reasoningEffort !== undefined ||
- options.initialPermissionMode !== undefined ||
- options.runSource !== undefined ||
- options.signalReportId !== undefined ||
- options.autoPublish !== undefined ||
- options.rtkEnabled === false);
-
- let body: string | undefined;
- if (hasOptions) {
- const payload: Record = {
- mode: options?.mode ?? "interactive",
- };
- if (options?.branch) payload.branch = options.branch;
- if (options?.resumeFromRunId) {
- payload.resume_from_run_id = options.resumeFromRunId;
- }
- if (options?.pendingUserMessage) {
- payload.pending_user_message = options.pendingUserMessage;
- }
- if (options?.runtimeAdapter) {
- payload.runtime_adapter = options.runtimeAdapter;
- if (options?.model) payload.model = options.model;
- if (options?.reasoningEffort) {
- payload.reasoning_effort = options.reasoningEffort;
- }
- }
- if (options?.initialPermissionMode) {
- payload.initial_permission_mode = options.initialPermissionMode;
- }
- if (options?.runSource) payload.run_source = options.runSource;
- if (options?.signalReportId)
- payload.signal_report_id = options.signalReportId;
- if (options?.autoPublish !== undefined) {
- payload.auto_publish = options.autoPublish;
- }
- if (options?.rtkEnabled === false) {
- payload.rtk_enabled = false;
- }
- body = JSON.stringify(payload);
- }
-
- const response = await authedFetch(
- `${baseUrl}/api/projects/${projectId}/tasks/${taskId}/run/`,
- {
- method: "POST",
- body,
- },
- );
-
- if (!response.ok) {
- throw new HttpError(
- response.status,
- response.statusText,
- "Failed to run task",
- );
- }
-
- return await response.json();
-}
-
-export async function getTaskRun(
- taskId: string,
- runId: string,
-): Promise {
- const baseUrl = getBaseUrl();
- const projectId = getProjectId();
-
- const response = await authedFetch(
- `${baseUrl}/api/projects/${projectId}/tasks/${taskId}/runs/${runId}/`,
- );
-
- if (!response.ok) {
- throw new HttpError(
- response.status,
- response.statusText,
- "Failed to fetch task run",
- );
- }
-
- return await response.json();
-}
-
-export async function cancelRun(
- taskId: string,
- runId: string,
- reason?: string,
-): Promise<{ status?: string }> {
- const baseUrl = getBaseUrl();
- const projectId = getProjectId();
-
- const response = await authedFetch(
- `${baseUrl}/api/projects/${projectId}/tasks/${taskId}/runs/${runId}/cancel/`,
- {
- method: "POST",
- body: JSON.stringify(reason ? { reason } : {}),
- },
- );
-
- if (!response.ok) {
- const payload = (await response.json().catch(() => null)) as {
- error?: unknown;
- } | null;
- const message =
- typeof payload?.error === "string" && payload.error
- ? payload.error
- : "Failed to stop run";
- throw new HttpError(response.status, response.statusText, message);
- }
-
- return (await response.json().catch(() => ({}))) as { status?: string };
-}
-
-export async function appendTaskRunLog(
- taskId: string,
- runId: string,
- entries: StoredLogEntry[],
-): Promise {
- return withRetry(
- async () => {
- const baseUrl = getBaseUrl();
- const projectId = getProjectId();
-
- const response = await authedFetch(
- `${baseUrl}/api/projects/${projectId}/tasks/${taskId}/runs/${runId}/append_log/`,
- {
- method: "POST",
- body: JSON.stringify({ entries }),
- },
- );
-
- if (!response.ok) {
- throw new HttpError(
- response.status,
- response.statusText,
- "Failed to append log",
- );
- }
- },
- { shouldRetry: isRetryableError },
- );
-}
-
-/**
- * Structured error thrown by `sendCloudCommand`. Exposes the HTTP status and
- * the backend error payload so callers can branch on specific failure modes
- * (e.g. "No active sandbox for this task run" → trigger a resume flow).
- */
-export class CloudCommandError extends Error {
- readonly status: number;
- readonly backendError: string | null;
- readonly method: string;
-
- constructor(
- method: string,
- status: number,
- backendError: string | null,
- message: string,
- ) {
- super(message);
- this.name = "CloudCommandError";
- this.method = method;
- this.status = status;
- this.backendError = backendError;
- }
-
- /** True when the cloud sandbox for this run has terminated. */
- isSandboxInactive(): boolean {
- return (
- !!this.backendError?.includes("No active sandbox") ||
- !!this.backendError?.includes("returned 404") ||
- this.status === 404
- );
- }
-}
-
-/**
- * Sends a JSON-RPC command to a running cloud task. This is the correct path
- * for delivering follow-up user prompts to the agent — it gets translated into
- * `session/prompt` on the agent side. Note: `appendTaskRunLog` only writes to
- * S3 for display; it does NOT notify the agent.
- */
-export async function sendCloudCommand(
- taskId: string,
- runId: string,
- method: string,
- params: Record = {},
-): Promise {
- const baseUrl = getBaseUrl();
- const projectId = getProjectId();
-
- const body = {
- jsonrpc: "2.0",
- method,
- params,
- id: `posthog-mobile-${Date.now()}`,
- };
-
- const response = await authedFetch(
- `${baseUrl}/api/projects/${projectId}/tasks/${taskId}/runs/${runId}/command/`,
- {
- method: "POST",
- body: JSON.stringify(body),
- },
- );
-
- if (!response.ok) {
- const text = await response.text().catch(() => "");
- let backendError: string | null = null;
- try {
- const parsed = JSON.parse(text);
- backendError =
- typeof parsed?.error === "string"
- ? parsed.error
- : (parsed?.error?.message ?? null);
- } catch {
- backendError = text || null;
- }
- throw new CloudCommandError(
- method,
- response.status,
- backendError,
- `Cloud command '${method}' failed: ${response.status} ${response.statusText} ${text}`,
- );
- }
-
- const data = await response.json();
- if (data?.error) {
- const message =
- typeof data.error === "string"
- ? data.error
- : (data.error.message ?? JSON.stringify(data.error));
- throw new CloudCommandError(
- method,
- 200,
- message,
- `Cloud command '${method}' error: ${message}`,
- );
- }
- return data?.result;
-}
-
-export interface SessionLogsPage {
- entries: StoredLogEntry[];
- hasMore: boolean;
-}
-
-export async function fetchSessionLogs(
- taskId: string,
- runId: string,
- options: { limit?: number; offset?: number } = {},
-): Promise {
- return withRetry(
- async () => {
- const baseUrl = getBaseUrl();
- const projectId = getProjectId();
-
- const params = new URLSearchParams({
- limit: String(options.limit ?? 5000),
- offset: String(options.offset ?? 0),
- });
-
- const response = await authedFetch(
- `${baseUrl}/api/projects/${projectId}/tasks/${taskId}/runs/${runId}/session_logs/?${params}`,
- { signal: createTimeoutSignal(10_000) },
- );
-
- if (!response.ok) {
- throw new HttpError(
- response.status,
- response.statusText,
- "Failed to fetch session logs",
- );
- }
-
- const entries = (await response.json()) as StoredLogEntry[];
- return {
- entries,
- hasMore: response.headers.get("X-Has-More") === "true",
- };
- },
- { shouldRetry: isRetryableError },
- );
-}
-
-export interface StreamCloudTaskOptions {
- lastEventId?: string | null;
- startLatest?: boolean;
- signal: AbortSignal;
-}
-
-export async function streamCloudTask(
- taskId: string,
- runId: string,
- options: StreamCloudTaskOptions,
-): Promise {
- const baseUrl = getBaseUrl();
- const projectId = getProjectId();
- const accessToken = getAccessToken();
-
- const url = new URL(
- `${baseUrl}/api/projects/${projectId}/tasks/${taskId}/runs/${runId}/stream/`,
- );
- if (options.startLatest && !options.lastEventId) {
- url.searchParams.set("start", "latest");
- }
-
- const headers: Record = {
- Accept: "text/event-stream",
- Authorization: `Bearer ${accessToken}`,
- };
- if (options.lastEventId) {
- headers["Last-Event-ID"] = options.lastEventId;
- }
-
- return await fetch(url.toString(), {
- method: "GET",
- headers,
- signal: options.signal,
- });
-}
diff --git a/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx b/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx
index 1aa452517a..e3d65b3d33 100644
--- a/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx
+++ b/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx
@@ -1,4 +1,16 @@
import { Text } from "@components/text";
+import {
+ DEFAULT_CLAUDE_EXECUTION_MODE,
+ getAvailableModes,
+} from "@posthog/core/sessions/executionModes";
+import {
+ DEFAULT_GATEWAY_MODEL,
+ DEFAULT_REASONING_EFFORT,
+ type ExecutionMode,
+ getReasoningEffortOptions,
+ isSupportedReasoningEffort,
+ type SupportedReasoningEffort,
+} from "@posthog/shared";
import * as Haptics from "expo-haptics";
import {
ArrowUp,
@@ -32,6 +44,7 @@ import {
View,
} from "react-native";
import { useVoiceRecording } from "@/features/chat";
+import { useCloudTaskConfigOptions } from "@/features/tasks/hooks/useCloudTaskConfigOptions";
import { logger } from "@/lib/logger";
import { useThemeColors } from "@/lib/theme";
import type { MessagingMode } from "../stores/messagingModeStore";
@@ -44,23 +57,16 @@ import {
} from "./attachments/pickers";
import type { PendingAttachment } from "./attachments/types";
import {
- DEFAULT_EXECUTION_MODE,
- DEFAULT_MODEL,
- DEFAULT_REASONING,
- EXECUTION_MODES,
- type ExecutionMode,
- MODELS,
- modeLabel,
- modelLabel,
- modelSupportsReasoning,
- REASONING_LEVELS,
- type ReasoningEffort,
- reasoningLabel,
+ getMobileModelOptions,
+ getModelConfigOption,
+ getModelLabel,
+ resolveAvailableModel,
} from "./options";
import { Pill } from "./Pill";
import { SelectSheet } from "./SelectSheet";
const log = logger.scope("task-chat-composer");
+const EXECUTION_MODES = getAvailableModes();
interface TaskChatComposerProps {
onSend: (message: string, attachments: PendingAttachment[]) => void;
@@ -72,10 +78,10 @@ interface TaskChatComposerProps {
/** Current pill values (persisted per-task by the caller). */
mode: ExecutionMode;
model: string;
- reasoning: ReasoningEffort;
+ reasoning: SupportedReasoningEffort;
onModeChange: (mode: ExecutionMode) => void;
onModelChange: (model: string) => void;
- onReasoningChange: (reasoning: ReasoningEffort) => void;
+ onReasoningChange: (reasoning: SupportedReasoningEffort) => void;
/** Steer vs Queue behaviour for messages sent while a turn is running. */
messagingMode: MessagingMode;
queuedCount: number;
@@ -95,6 +101,11 @@ function modeIcon(mode: ExecutionMode, color: string, size = 14): ReactNode {
return ;
case "acceptEdits":
return ;
+ case "bypassPermissions":
+ case "full-access":
+ return ;
+ case "read-only":
+ return ;
case "auto":
return ;
}
@@ -175,6 +186,9 @@ export function TaskChatComposer({
onCancelEdit,
}: TaskChatComposerProps) {
const themeColors = useThemeColors();
+ const { configOptions, hasLiveConfig } = useCloudTaskConfigOptions("claude");
+ const modelConfigOption = getModelConfigOption(configOptions);
+ const mobileModelOptions = getMobileModelOptions(modelConfigOption);
const [message, setMessage] = useState(() => initialMessage ?? "");
const [attachments, setAttachments] = useState([]);
const [attachmentSheetOpen, setAttachmentSheetOpen] = useState(false);
@@ -190,6 +204,23 @@ export function TaskChatComposer({
setAttachments(restoredDraft.attachments);
}, [restoredDraft]);
+ useEffect(() => {
+ if (!hasLiveConfig) return;
+ const availableModel = resolveAvailableModel(modelConfigOption, model);
+ if (availableModel === model) return;
+ onModelChange(availableModel);
+ if (!isSupportedReasoningEffort("claude", availableModel, reasoning)) {
+ onReasoningChange(DEFAULT_REASONING_EFFORT);
+ }
+ }, [
+ hasLiveConfig,
+ model,
+ modelConfigOption,
+ onModelChange,
+ onReasoningChange,
+ reasoning,
+ ]);
+
const appendTranscript = useCallback((transcript: string) => {
setMessage((prev) => (prev ? `${prev} ${transcript}` : transcript));
}, []);
@@ -204,7 +235,8 @@ export function TaskChatComposer({
const [modelSheetOpen, setModelSheetOpen] = useState(false);
const [reasoningSheetOpen, setReasoningSheetOpen] = useState(false);
- const showReasoningPill = modelSupportsReasoning(model);
+ const reasoningOptions = getReasoningEffortOptions("claude", model) ?? [];
+ const showReasoningPill = reasoningOptions.length > 0;
const hasContent = message.trim().length > 0 || attachments.length > 0;
const canSend = hasContent && !disabled && !isRecording;
@@ -368,21 +400,28 @@ export function TaskChatComposer({
? themeColors.accent[11]
: themeColors.gray[11],
)}
- label={modeLabel(mode)}
+ label={
+ EXECUTION_MODES.find((option) => option.id === mode)
+ ?.name ?? mode
+ }
accent={mode === "plan"}
onPress={() => setModeSheetOpen(true)}
/>
}
- label={modelLabel(model)}
+ label={getModelLabel(modelConfigOption, model)}
onPress={() => setModelSheetOpen(true)}
/>
{showReasoningPill ? (
}
- label={reasoningLabel(reasoning)}
+ label={
+ reasoningOptions.find(
+ (option) => option.value === reasoning,
+ )?.name ?? reasoning
+ }
onPress={() => setReasoningSheetOpen(true)}
/>
) : null}
@@ -431,12 +470,12 @@ export function TaskChatComposer({
onChange={(v) => onModeChange(v as ExecutionMode)}
onClose={() => setModeSheetOpen(false)}
options={EXECUTION_MODES.map((m) => ({
- value: m.value,
- label: m.label,
+ value: m.id,
+ label: m.name,
description: m.description,
icon: modeIcon(
- m.value,
- m.value === "plan" ? themeColors.accent[11] : themeColors.gray[11],
+ m.id as ExecutionMode,
+ m.id === "plan" ? themeColors.accent[11] : themeColors.gray[11],
16,
),
}))}
@@ -448,18 +487,16 @@ export function TaskChatComposer({
value={model}
onChange={(v) => {
onModelChange(v);
- // If the new model doesn't support reasoning, drop the level so the
- // payload stays consistent. Default reasoning re-applies when
- // switching back to a reasoning-capable model.
- if (!modelSupportsReasoning(v)) {
- onReasoningChange(DEFAULT_REASONING);
+ if (!isSupportedReasoningEffort("claude", v, reasoning)) {
+ onReasoningChange(DEFAULT_REASONING_EFFORT);
}
}}
onClose={() => setModelSheetOpen(false)}
- options={MODELS.map((m) => ({
+ options={mobileModelOptions.map((m) => ({
value: m.value,
label: m.label,
description: m.description,
+ disabled: m.disabled,
icon: ,
}))}
/>
@@ -468,11 +505,11 @@ export function TaskChatComposer({
open={reasoningSheetOpen}
title="Reasoning"
value={reasoning}
- onChange={(v) => onReasoningChange(v as ReasoningEffort)}
+ onChange={(v) => onReasoningChange(v as SupportedReasoningEffort)}
onClose={() => setReasoningSheetOpen(false)}
- options={REASONING_LEVELS.map((r) => ({
+ options={reasoningOptions.map((r) => ({
value: r.value,
- label: r.label,
+ label: r.name,
icon: ,
}))}
/>
@@ -489,7 +526,7 @@ export function TaskChatComposer({
}
export const TASK_CHAT_DEFAULTS = {
- mode: DEFAULT_EXECUTION_MODE,
- model: DEFAULT_MODEL,
- reasoning: DEFAULT_REASONING,
+ mode: DEFAULT_CLAUDE_EXECUTION_MODE,
+ model: DEFAULT_GATEWAY_MODEL,
+ reasoning: DEFAULT_REASONING_EFFORT,
} as const;
diff --git a/apps/mobile/src/features/tasks/composer/attachments/cloudPrompt.ts b/apps/mobile/src/features/tasks/composer/attachments/cloudPrompt.ts
deleted file mode 100644
index 35f885cdc7..0000000000
--- a/apps/mobile/src/features/tasks/composer/attachments/cloudPrompt.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import type { CloudPromptBlock } from "./types";
-
-/**
- * Wire format prefix shared with `packages/shared/src/cloud-prompt.ts`. The
- * backend's `deserializeCloudPrompt` looks for this prefix and decodes the
- * trailing JSON as `{ blocks: ContentBlock[] }`. Plain-text prompts without
- * attachments are sent as strings (no prefix) so chat echoes stay readable.
- */
-export const CLOUD_PROMPT_PREFIX = "__twig_cloud_prompt_v1__:";
-
-export function serializeCloudPrompt(blocks: CloudPromptBlock[]): string {
- if (blocks.length === 1 && blocks[0].type === "text") {
- return blocks[0].text.trim();
- }
- return `${CLOUD_PROMPT_PREFIX}${JSON.stringify({ blocks })}`;
-}
diff --git a/apps/mobile/src/features/tasks/composer/options.test.ts b/apps/mobile/src/features/tasks/composer/options.test.ts
index 75328ebf04..c155d51849 100644
--- a/apps/mobile/src/features/tasks/composer/options.test.ts
+++ b/apps/mobile/src/features/tasks/composer/options.test.ts
@@ -1,27 +1,68 @@
+import {
+ type CloudTaskConfigOption,
+ DEFAULT_GATEWAY_MODEL,
+ restrictedModelMeta,
+} from "@posthog/shared";
import { describe, expect, it } from "vitest";
import {
- DEFAULT_MODEL,
- DEFAULT_REASONING,
- modelSupportsReasoning,
- REASONING_LEVELS,
+ getMobileModelOptions,
+ getModelConfigOption,
+ getModelLabel,
+ resolveAvailableModel,
} from "./options";
-describe("task composer options", () => {
- it("uses an eligible non-premium default model", () => {
- expect(DEFAULT_MODEL).toBe("claude-opus-4-8");
- expect(DEFAULT_MODEL).not.toContain("fable");
- });
+const modelOption: CloudTaskConfigOption = {
+ id: "model",
+ name: "Model",
+ type: "select",
+ currentValue: DEFAULT_GATEWAY_MODEL,
+ options: [
+ {
+ value: DEFAULT_GATEWAY_MODEL,
+ name: "Claude Opus 4.8",
+ description: "Default",
+ },
+ {
+ value: "claude-fable-5",
+ name: "Claude Fable 5",
+ _meta: restrictedModelMeta(),
+ },
+ ],
+ category: "model",
+ description: "Choose a model",
+};
- it("derives reasoning defaults and options from shared policy", () => {
- expect(DEFAULT_REASONING).toBe("high");
- expect(REASONING_LEVELS.map((option) => option.value)).toEqual([
- "low",
- "medium",
- "high",
- "xhigh",
- "max",
+describe("mobile cloud task model options", () => {
+ it("adapts live model options and disables restricted entries", () => {
+ expect(getMobileModelOptions(modelOption)).toEqual([
+ {
+ value: DEFAULT_GATEWAY_MODEL,
+ label: "Claude Opus 4.8",
+ description: "Default",
+ disabled: false,
+ },
+ {
+ value: "claude-fable-5",
+ label: "Claude Fable 5",
+ description: undefined,
+ disabled: true,
+ },
]);
- expect(modelSupportsReasoning("claude-opus-4-8")).toBe(true);
- expect(modelSupportsReasoning("claude-haiku-4-5")).toBe(false);
+ });
+
+ 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",
+ );
});
});
diff --git a/apps/mobile/src/features/tasks/composer/options.ts b/apps/mobile/src/features/tasks/composer/options.ts
index 486843c8c7..1db34b57a1 100644
--- a/apps/mobile/src/features/tasks/composer/options.ts
+++ b/apps/mobile/src/features/tasks/composer/options.ts
@@ -1,101 +1,56 @@
import {
- DEFAULT_CLAUDE_EXECUTION_MODE,
- getAvailableModes,
-} from "@posthog/core/sessions/executionModes";
-import {
- DEFAULT_GATEWAY_MODEL,
- DEFAULT_REASONING_EFFORT,
- defaultEligibleModel,
- getReasoningEffortOptions,
- type ExecutionMode as SharedExecutionMode,
- type SupportedReasoningEffort,
+ type CloudTaskConfigOption,
+ isRestrictedModelOption,
} from "@posthog/shared";
-export type ExecutionMode = Extract<
- SharedExecutionMode,
- "default" | "acceptEdits" | "plan" | "auto"
->;
-export type ReasoningEffort = SupportedReasoningEffort;
-
-export const EXECUTION_MODES: {
- value: ExecutionMode;
- label: string;
- description: string;
-}[] = getAvailableModes()
- .filter(
- (mode): mode is typeof mode & { id: ExecutionMode } =>
- mode.id === "default" ||
- mode.id === "acceptEdits" ||
- mode.id === "plan" ||
- mode.id === "auto",
- )
- .map((mode) => ({
- value: mode.id,
- label: mode.name,
- description: mode.description,
- }));
-
-export interface ModelOption {
+export interface MobileModelOption {
value: string;
label: string;
description?: string;
- supportsReasoning: boolean;
+ disabled: boolean;
}
-export const MODELS: ModelOption[] = [
- {
- value: "claude-fable-5",
- label: "Claude Fable 5",
- description: "Newest, most capable",
- supportsReasoning: true,
- },
- {
- value: "claude-opus-4-8",
- label: "Claude Opus 4.8",
- description: "Most capable, slower",
- supportsReasoning: true,
- },
- {
- value: "claude-sonnet-5",
- label: "Claude Sonnet 5",
- description: "Balanced, fast",
- supportsReasoning: true,
- },
- {
- value: "claude-sonnet-4-6",
- label: "Claude Sonnet 4.6",
- description: "Balanced",
- supportsReasoning: true,
- },
-];
-
-export const DEFAULT_EXECUTION_MODE: ExecutionMode =
- DEFAULT_CLAUDE_EXECUTION_MODE as ExecutionMode;
-export const DEFAULT_MODEL =
- defaultEligibleModel(DEFAULT_GATEWAY_MODEL) ??
- MODELS.find((model) => defaultEligibleModel(model.value))?.value ??
- DEFAULT_GATEWAY_MODEL;
-export const DEFAULT_REASONING: ReasoningEffort = DEFAULT_REASONING_EFFORT;
-
-export const REASONING_LEVELS: {
- value: ReasoningEffort;
- label: string;
-}[] = (getReasoningEffortOptions("claude", DEFAULT_MODEL) ?? []).map(
- (option) => ({ value: option.value, label: option.name }),
-);
-
-export function modelLabel(value: string): string {
- return MODELS.find((m) => m.value === value)?.label ?? value;
+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;
}
-export function modeLabel(value: ExecutionMode): string {
- return EXECUTION_MODES.find((m) => m.value === value)?.label ?? value;
+export function getMobileModelOptions(
+ modelOption: CloudTaskConfigOption,
+): MobileModelOption[] {
+ return modelOption.options.map((option) => ({
+ value: option.value,
+ label: option.name,
+ description: option.description,
+ disabled: isRestrictedModelOption(option._meta),
+ }));
}
-export function reasoningLabel(value: ReasoningEffort): string {
- return REASONING_LEVELS.find((r) => r.value === value)?.label ?? value;
+export function getModelLabel(
+ modelOption: CloudTaskConfigOption,
+ value: string,
+): string {
+ return (
+ modelOption.options.find((option) => option.value === value)?.name ?? value
+ );
}
-export function modelSupportsReasoning(value: string): boolean {
- return getReasoningEffortOptions("claude", value) !== null;
+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;
}
diff --git a/apps/mobile/src/features/tasks/hooks/useAutomations.test.ts b/apps/mobile/src/features/tasks/hooks/useAutomations.test.ts
index 772d9a4f69..fb47817a8c 100644
--- a/apps/mobile/src/features/tasks/hooks/useAutomations.test.ts
+++ b/apps/mobile/src/features/tasks/hooks/useAutomations.test.ts
@@ -8,36 +8,28 @@ const {
mockGetTaskAutomations,
mockCreateTaskAutomation,
mockUpdateTaskAutomation,
- mockApiClient,
} = vi.hoisted(() => ({
mockUseAuthStore: vi.fn(),
mockGetTaskAutomations: vi.fn(),
mockCreateTaskAutomation: vi.fn(),
mockUpdateTaskAutomation: vi.fn(),
- mockApiClient: {
- listTaskAutomations: vi.fn(),
- getTaskAutomation: vi.fn(),
- createTaskAutomation: vi.fn(),
- updateTaskAutomation: vi.fn(),
- deleteTaskAutomation: vi.fn(),
- runTaskAutomation: vi.fn(),
- },
}));
vi.mock("@/features/auth", () => ({
useAuthStore: mockUseAuthStore,
}));
-vi.mock("../api", () => ({ runTaskInCloud: vi.fn() }));
-
vi.mock("@/lib/posthogApiClient", () => ({
- getPostHogApiClient: () => mockApiClient,
+ getPostHogApiClient: vi.fn(() => ({
+ listTaskAutomations: mockGetTaskAutomations,
+ getTaskAutomation: vi.fn(),
+ createTaskAutomation: mockCreateTaskAutomation,
+ updateTaskAutomation: mockUpdateTaskAutomation,
+ deleteTaskAutomation: vi.fn(),
+ runTaskAutomation: vi.fn(),
+ })),
}));
-mockApiClient.listTaskAutomations = mockGetTaskAutomations;
-mockApiClient.createTaskAutomation = mockCreateTaskAutomation;
-mockApiClient.updateTaskAutomation = mockUpdateTaskAutomation;
-
import {
automationKeys,
getAutomationPollingInterval,
diff --git a/apps/mobile/src/features/tasks/hooks/useCloudTaskConfigOptions.test.ts b/apps/mobile/src/features/tasks/hooks/useCloudTaskConfigOptions.test.ts
new file mode 100644
index 0000000000..4b516f1445
--- /dev/null
+++ b/apps/mobile/src/features/tasks/hooks/useCloudTaskConfigOptions.test.ts
@@ -0,0 +1,121 @@
+import {
+ type CloudTaskConfigOption,
+ DEFAULT_GATEWAY_MODEL,
+} from "@posthog/shared";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { createElement, type PropsWithChildren } from "react";
+import { act, create } from "react-test-renderer";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const { mockGetCloudTaskConfigOptions, mockUseAuthStore } = vi.hoisted(() => ({
+ mockGetCloudTaskConfigOptions: vi.fn(),
+ mockUseAuthStore: vi.fn(),
+}));
+
+vi.mock("posthog-react-native", () => ({
+ useFeatureFlag: () => false,
+}));
+
+vi.mock("@/features/auth", () => ({
+ useAuthStore: mockUseAuthStore,
+}));
+
+vi.mock("@/lib/posthogApiClient", () => ({
+ getPostHogApiClient: () => ({
+ getCloudTaskConfigOptions: mockGetCloudTaskConfigOptions,
+ }),
+}));
+
+import { getModelConfigOption } from "../composer/options";
+import { useCloudTaskConfigOptions } from "./useCloudTaskConfigOptions";
+
+function createWrapper(queryClient: QueryClient) {
+ return function Wrapper({ children }: PropsWithChildren) {
+ return createElement(
+ QueryClientProvider,
+ { client: queryClient },
+ children,
+ );
+ };
+}
+
+async function renderHook() {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ let currentResult: ReturnType;
+
+ function HookProbe() {
+ currentResult = useCloudTaskConfigOptions("claude");
+ return null;
+ }
+
+ const Wrapper = createWrapper(queryClient);
+ await act(async () => {
+ create(createElement(Wrapper, null, createElement(HookProbe)));
+ await Promise.resolve();
+ });
+
+ return {
+ get current() {
+ return currentResult;
+ },
+ };
+}
+
+async function waitForAssertion(assertion: () => void): Promise {
+ const timeoutAt = Date.now() + 2_000;
+ while (Date.now() < timeoutAt) {
+ try {
+ assertion();
+ return;
+ } catch (error) {
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ if (Date.now() >= timeoutAt) throw error;
+ }
+ }
+}
+
+describe("useCloudTaskConfigOptions", () => {
+ beforeEach(() => {
+ mockGetCloudTaskConfigOptions.mockReset();
+ mockUseAuthStore.mockImplementation((selector) =>
+ selector({ oauthAccessToken: "token" }),
+ );
+ });
+
+ it("uses the authenticated live Claude catalog", async () => {
+ const liveOptions: CloudTaskConfigOption[] = [
+ {
+ id: "model",
+ name: "Model",
+ type: "select",
+ currentValue: "claude-sonnet-5",
+ options: [{ value: "claude-sonnet-5", name: "Claude Sonnet 5" }],
+ category: "model",
+ description: "Choose a model",
+ },
+ ];
+ mockGetCloudTaskConfigOptions.mockResolvedValue(liveOptions);
+
+ const result = await renderHook();
+ await waitForAssertion(() => {
+ expect(result.current.configOptions).toEqual(liveOptions);
+ expect(result.current.hasLiveConfig).toBe(true);
+ });
+ expect(mockGetCloudTaskConfigOptions).toHaveBeenCalledWith("claude");
+ });
+
+ it("keeps the shared fallback when unauthenticated", async () => {
+ mockUseAuthStore.mockImplementation((selector) =>
+ selector({ oauthAccessToken: null }),
+ );
+
+ const result = await renderHook();
+
+ expect(
+ getModelConfigOption(result.current.configOptions).currentValue,
+ ).toBe(DEFAULT_GATEWAY_MODEL);
+ expect(mockGetCloudTaskConfigOptions).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/mobile/src/features/tasks/hooks/useCloudTaskConfigOptions.ts b/apps/mobile/src/features/tasks/hooks/useCloudTaskConfigOptions.ts
new file mode 100644
index 0000000000..aaedf3375d
--- /dev/null
+++ b/apps/mobile/src/features/tasks/hooks/useCloudTaskConfigOptions.ts
@@ -0,0 +1,52 @@
+import {
+ type Adapter,
+ buildCloudTaskConfigOptions,
+ type CloudTaskConfigOption,
+ GLM_MODEL_FLAG,
+ isGlmModelId,
+} from "@posthog/shared";
+import { useQuery } from "@tanstack/react-query";
+import { useFeatureFlag } from "posthog-react-native";
+import { useAuthStore } from "@/features/auth";
+import { getPostHogApiClient } from "@/lib/posthogApiClient";
+
+export const cloudTaskConfigOptionKeys = {
+ all: ["cloud-task-config-options"] as const,
+ adapter: (adapter: Adapter) =>
+ [...cloudTaskConfigOptionKeys.all, adapter] as const,
+};
+
+const fallbackOptionsByAdapter: Record = {
+ claude: buildCloudTaskConfigOptions([], "claude"),
+ codex: buildCloudTaskConfigOptions([], "codex"),
+};
+
+export function useCloudTaskConfigOptions(adapter: Adapter = "claude") {
+ const oauthAccessToken = useAuthStore((state) => state.oauthAccessToken);
+ const glmEnabled = useFeatureFlag(GLM_MODEL_FLAG);
+ const query = useQuery({
+ queryKey: cloudTaskConfigOptionKeys.adapter(adapter),
+ queryFn: () => getPostHogApiClient().getCloudTaskConfigOptions(adapter),
+ enabled: !!oauthAccessToken,
+ staleTime: 5 * 60 * 1000,
+ });
+ const configOptions = query.data ?? fallbackOptionsByAdapter[adapter];
+ const visibleConfigOptions = glmEnabled
+ ? configOptions
+ : configOptions.map((option) =>
+ option.category === "model"
+ ? {
+ ...option,
+ options: option.options.filter(
+ (model) => !isGlmModelId(model.value),
+ ),
+ }
+ : option,
+ );
+
+ return {
+ ...query,
+ configOptions: visibleConfigOptions,
+ hasLiveConfig: query.data !== undefined,
+ };
+}
diff --git a/apps/mobile/src/features/tasks/hooks/useIntegrations.test.ts b/apps/mobile/src/features/tasks/hooks/useIntegrations.test.ts
index 45040a2df6..68394d2aba 100644
--- a/apps/mobile/src/features/tasks/hooks/useIntegrations.test.ts
+++ b/apps/mobile/src/features/tasks/hooks/useIntegrations.test.ts
@@ -15,10 +15,10 @@ vi.mock("@/features/auth", () => ({
}));
vi.mock("@/lib/posthogApiClient", () => ({
- getPostHogApiClient: () => ({
+ getPostHogApiClient: vi.fn(() => ({
getGithubRepositories: mockGetGithubRepositories,
getIntegrations: mockGetIntegrations,
- }),
+ })),
}));
import { useRepositoryCacheStore } from "../stores/repositoryCacheStore";
@@ -123,7 +123,7 @@ describe("useIntegrations", () => {
},
]);
mockGetGithubRepositories
- .mockResolvedValueOnce(["annika/mobile-app"])
+ .mockResolvedValueOnce(["Annika/Mobile-App", ""])
.mockRejectedValueOnce(new Error("GitHub repos failed"));
const queryClient = new QueryClient({
diff --git a/apps/mobile/src/features/tasks/hooks/useIntegrations.ts b/apps/mobile/src/features/tasks/hooks/useIntegrations.ts
index bf2aab3c77..f0ca06ade5 100644
--- a/apps/mobile/src/features/tasks/hooks/useIntegrations.ts
+++ b/apps/mobile/src/features/tasks/hooks/useIntegrations.ts
@@ -3,7 +3,7 @@ import { useEffect, useMemo } from "react";
import { useAuthStore } from "@/features/auth";
import { getPostHogApiClient } from "@/lib/posthogApiClient";
import { useRepositoryCacheStore } from "../stores/repositoryCacheStore";
-import type { Integration, RepositoryOption } from "../types";
+import type { RepositoryOption } from "../types";
import { buildRepositoryOptions } from "../utils/repositorySelection";
/** Cheap content-equality check for repository option lists. Lets the cache
@@ -60,26 +60,7 @@ export function useIntegrations(options: UseIntegrationsOptions = {}) {
queryKey: integrationKeys.github(),
queryFn: async () => {
const data = await getPostHogApiClient().getIntegrations();
- return data.flatMap((integration): Integration[] => {
- if (
- integration.kind !== "github" ||
- typeof integration.id !== "number"
- ) {
- return [];
- }
-
- return [
- {
- id: integration.id,
- kind: integration.kind,
- display_name:
- typeof integration.display_name === "string"
- ? integration.display_name
- : undefined,
- config: integration.config as Integration["config"],
- },
- ];
- });
+ return data.filter((i) => i.kind === "github");
},
enabled: enabled && !!projectId && !!oauthAccessToken,
});
@@ -97,9 +78,11 @@ export function useIntegrations(options: UseIntegrationsOptions = {}) {
const results = await Promise.allSettled(
githubIntegrations.map(async (integration) => ({
integrationId: integration.id,
- repositories: await getPostHogApiClient().getGithubRepositories(
- integration.id,
- ),
+ repositories: (
+ await getPostHogApiClient().getGithubRepositories(integration.id)
+ )
+ .map((repository) => repository.toLowerCase())
+ .filter((repository) => repository.length > 0),
})),
);
diff --git a/apps/mobile/src/features/tasks/hooks/useTasks.test.ts b/apps/mobile/src/features/tasks/hooks/useTasks.test.ts
index 579ec4bce2..1395c283e8 100644
--- a/apps/mobile/src/features/tasks/hooks/useTasks.test.ts
+++ b/apps/mobile/src/features/tasks/hooks/useTasks.test.ts
@@ -27,18 +27,15 @@ vi.mock("@/lib/logger", () => {
};
});
-vi.mock("../api", () => ({
- runTaskInCloud: vi.fn(),
-}));
-
vi.mock("@/lib/posthogApiClient", () => ({
- getPostHogApiClient: () => ({
+ getPostHogApiClient: vi.fn(() => ({
createTask: vi.fn(),
deleteTask: vi.fn(),
getTask: vi.fn(),
getTasks: vi.fn(),
+ runTaskInCloud: vi.fn(),
updateTask: vi.fn(),
- }),
+ })),
}));
vi.mock("../stores/taskStore", () => ({
diff --git a/apps/mobile/src/features/tasks/hooks/useTasks.ts b/apps/mobile/src/features/tasks/hooks/useTasks.ts
index 95b5d4725f..862459708e 100644
--- a/apps/mobile/src/features/tasks/hooks/useTasks.ts
+++ b/apps/mobile/src/features/tasks/hooks/useTasks.ts
@@ -4,7 +4,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuthStore, useUserQuery } from "@/features/auth";
import { logger } from "@/lib/logger";
import { getPostHogApiClient } from "@/lib/posthogApiClient";
-import { runTaskInCloud } from "../api";
import { useTaskStore } from "../stores/taskStore";
import type { CreateTaskOptions } from "../types";
@@ -136,13 +135,13 @@ export function useUpdateTask() {
}: {
taskId: string;
updates: Partial;
- }) =>
- getPostHogApiClient().updateTask(
+ }) => {
+ const client = getPostHogApiClient();
+ return client.updateTask(
taskId,
- updates as Parameters<
- ReturnType["updateTask"]
- >[1],
- ),
+ updates as Parameters[1],
+ );
+ },
onSuccess: (updatedTask, { taskId }) => {
// Update the detail cache immediately
queryClient.setQueryData(taskKeys.detail(taskId), updatedTask);
@@ -174,7 +173,8 @@ export function useRunTask() {
const queryClient = useQueryClient();
return useMutation({
- mutationFn: (taskId: string) => runTaskInCloud(taskId),
+ mutationFn: (taskId: string) =>
+ getPostHogApiClient().runTaskInCloud(taskId),
onSuccess: (updatedTask, taskId) => {
queryClient.setQueryData(taskKeys.detail(taskId), updatedTask);
queryClient.invalidateQueries({ queryKey: taskKeys.lists() });
diff --git a/apps/mobile/src/features/tasks/hooks/useUserIntegrations.ts b/apps/mobile/src/features/tasks/hooks/useUserIntegrations.ts
index 1ba6655cf2..0724faad9c 100644
--- a/apps/mobile/src/features/tasks/hooks/useUserIntegrations.ts
+++ b/apps/mobile/src/features/tasks/hooks/useUserIntegrations.ts
@@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query";
import { useCallback, useMemo } from "react";
import { useAuthStore } from "@/features/auth";
import { getPostHogApiClient } from "@/lib/posthogApiClient";
-import type { RepositoryOption } from "../types";
+import type { RepositoryOption, UserGithubIntegration } from "../types";
/**
* User-scoped sibling of {@link useIntegrations}. Reads the authenticated
@@ -29,10 +29,7 @@ interface UseUserIntegrationsOptions {
enabled?: boolean;
}
-function integrationLabel(integration: {
- installation_id: string;
- account?: { name?: string | null } | null;
-}): string {
+function integrationLabel(integration: UserGithubIntegration): string {
return integration.account?.name ?? `GitHub ${integration.installation_id}`;
}
@@ -42,7 +39,19 @@ export function useUserIntegrations(options: UseUserIntegrationsOptions = {}) {
const integrationsQuery = useQuery({
queryKey: userIntegrationKeys.github(),
- queryFn: () => getPostHogApiClient().getGithubUserIntegrations(),
+ queryFn: async () => {
+ const integrations =
+ await getPostHogApiClient().getGithubUserIntegrations();
+ return integrations.map(({ account, ...integration }) => ({
+ ...integration,
+ account: account
+ ? {
+ name: account.name ?? undefined,
+ type: account.type ?? undefined,
+ }
+ : undefined,
+ }));
+ },
enabled: enabled && !!oauthAccessToken,
});
@@ -57,9 +66,13 @@ export function useUserIntegrations(options: UseUserIntegrationsOptions = {}) {
const results = await Promise.allSettled(
integrations.map(async (integration) => ({
installationId: integration.installation_id,
- repositories: await getPostHogApiClient().getGithubUserRepositories(
- integration.installation_id,
- ),
+ repositories: (
+ await getPostHogApiClient().getGithubUserRepositories(
+ integration.installation_id,
+ )
+ )
+ .map((repository) => repository.toLowerCase())
+ .filter((repository) => repository.length > 0),
})),
);
diff --git a/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx b/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx
index 43bfeca02b..e8479dfc1d 100644
--- a/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx
+++ b/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx
@@ -9,7 +9,9 @@ vi.mock("posthog-react-native", () => ({
useFeatureFlag: () => flagState.enabled,
}));
vi.mock("@/lib/posthogApiClient", () => ({
- getPostHogApiClient: () => ({ warmTask: mockWarmTask }),
+ getPostHogApiClient: vi.fn(() => ({
+ warmTask: mockWarmTask,
+ })),
}));
vi.mock("@/lib/logger", () => {
const mockLogger = {
diff --git a/apps/mobile/src/features/tasks/index.ts b/apps/mobile/src/features/tasks/index.ts
index 2bb6820f00..610ff5b87c 100644
--- a/apps/mobile/src/features/tasks/index.ts
+++ b/apps/mobile/src/features/tasks/index.ts
@@ -21,9 +21,3 @@ export { useTaskStore } from "./stores/taskStore";
// Types
export * from "./types";
-
-// Utils
-export {
- convertStoredEntriesToEvents,
- parseSessionLogs,
-} from "./utils/parseSessionLogs";
diff --git a/apps/mobile/src/features/tasks/services/taskSessionService.ts b/apps/mobile/src/features/tasks/services/taskSessionService.ts
index d51cf22e7a..48d4a4c47d 100644
--- a/apps/mobile/src/features/tasks/services/taskSessionService.ts
+++ b/apps/mobile/src/features/tasks/services/taskSessionService.ts
@@ -1,9 +1,14 @@
+import {
+ CloudCommandError,
+ type CloudRunOptions,
+} from "@posthog/api-client/posthog-client";
import { combineCloudTaskQueuedMessages } from "@posthog/core/sessions/cloudTaskQueue";
import {
type CloudTaskSessionNotificationKind,
CloudTaskSessionService,
type CloudTaskSessionTask,
} from "@posthog/core/sessions/cloudTaskSessionService";
+import type { ExecutionMode } from "@posthog/shared";
import { serializeCloudPrompt, type Task } from "@posthog/shared";
import * as Haptics from "expo-haptics";
import { AppState } from "react-native";
@@ -11,12 +16,6 @@ import { presentLocalNotification } from "@/features/notifications/lib/notificat
import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore";
import { logger } from "@/lib/logger";
import { getPostHogApiClient } from "@/lib/posthogApiClient";
-import {
- CloudCommandError,
- cancelRun,
- runTaskInCloud,
- sendCloudCommand,
-} from "../api";
import { buildCloudPromptBlocks } from "../composer/attachments/buildCloudPrompt";
import type { PendingAttachment } from "../composer/attachments/types";
import { watchCloudTask } from "../lib/cloudTaskStream";
@@ -131,24 +130,35 @@ export const taskSessionService =
getTask: async (taskId) =>
toSessionTask(await getPostHogApiClient().getTask(taskId)),
runTask: async (taskId, options) => {
- if (!options) return toSessionTask(await runTaskInCloud(taskId));
+ const client = getPostHogApiClient();
+ if (!options) return toSessionTask(await client.runTaskInCloud(taskId));
+ const cloudOptions: CloudRunOptions & {
+ resumeFromRunId?: string;
+ pendingUserMessage?: string;
+ } = {
+ adapter: "claude",
+ reasoningLevel: options.reasoningEffort,
+ initialPermissionMode: options.initialPermissionMode as
+ | ExecutionMode
+ | undefined,
+ rtkEnabled: options.rtkEnabled,
+ resumeFromRunId: options.resumeFromRunId,
+ pendingUserMessage: options.pendingUserMessage,
+ };
return toSessionTask(
- await runTaskInCloud(taskId, {
- branch: options.branch,
- runtimeAdapter: "claude",
- reasoningEffort: options.reasoningEffort,
- initialPermissionMode: options.initialPermissionMode,
- rtkEnabled: options.rtkEnabled,
- resumeFromRunId: options.resumeFromRunId,
- pendingUserMessage: options.pendingUserMessage,
- }),
+ await client.runTaskInCloud(taskId, options.branch, cloudOptions),
);
},
sendCommand: async (taskId, runId, command, payload) => {
- await sendCloudCommand(taskId, runId, command, payload);
+ await getPostHogApiClient().sendCloudRunCommand(
+ taskId,
+ runId,
+ command,
+ payload,
+ );
},
cancelRun: async (taskId, runId) => {
- await cancelRun(taskId, runId);
+ await getPostHogApiClient().cancelTaskRun(taskId, runId);
},
classifyCommandError: (error) => {
if (error instanceof CloudCommandError) {
diff --git a/apps/mobile/src/features/tasks/stores/taskStore.ts b/apps/mobile/src/features/tasks/stores/taskStore.ts
index 456eae37a1..39276a7eeb 100644
--- a/apps/mobile/src/features/tasks/stores/taskStore.ts
+++ b/apps/mobile/src/features/tasks/stores/taskStore.ts
@@ -1,11 +1,12 @@
+import type { TaskActivitySortMode } from "@posthog/core/tasks/taskActivity";
+import type { ExecutionMode, SupportedReasoningEffort } from "@posthog/shared";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
-import type { ExecutionMode, ReasoningEffort } from "../composer/options";
import type { RepositorySelection } from "../types";
export type OrganizeMode = "by-project" | "chronological";
-export type SortMode = "created" | "updated";
+export type SortMode = TaskActivitySortMode;
const EMPTY_REPOSITORY_SELECTION: RepositorySelection = {
integrationId: null,
@@ -17,7 +18,7 @@ const EMPTY_REPOSITORY_SELECTION: RepositorySelection = {
export interface TaskComposerConfig {
mode?: ExecutionMode;
model?: string;
- reasoning?: ReasoningEffort;
+ reasoning?: SupportedReasoningEffort;
}
interface TaskUIState {
diff --git a/apps/mobile/src/features/tasks/types.ts b/apps/mobile/src/features/tasks/types.ts
index 29a2754d7f..ad45e83576 100644
--- a/apps/mobile/src/features/tasks/types.ts
+++ b/apps/mobile/src/features/tasks/types.ts
@@ -1,14 +1,8 @@
import type {
CloudPermissionOption,
CloudTaskPermissionRequestUpdate,
- StoredLogEntry as SharedStoredLogEntry,
- TaskRunStatus,
} from "@posthog/shared";
-export interface MobileStoredLogEntry extends SharedStoredLogEntry {
- direction?: "client" | "agent";
-}
-
export interface SessionNotificationAttachment {
kind: "image" | "document";
uri: string;
@@ -16,51 +10,12 @@ export interface SessionNotificationAttachment {
mimeType?: string;
}
-export interface SessionNotification {
- update?: {
- sessionUpdate?: string;
- content?: { type: string; text: string };
- // Sidecar carrying user-uploaded attachments on user_message_chunk events.
- // The wire format embeds the bytes themselves in a separate serialized
- // cloud-prompt payload sent to the agent; this field exists only so the
- // local feed can render the attachments alongside the echoed text.
- attachments?: SessionNotificationAttachment[];
- title?: string;
- toolCallId?: string;
- status?: "pending" | "in_progress" | "completed" | "failed" | null;
- rawInput?: Record;
- rawOutput?: unknown;
- entries?: PlanEntry[];
- _meta?: {
- claudeCode?: {
- toolName?: string;
- parentToolCallId?: string;
- };
- };
- };
-}
-
export interface PlanEntry {
content: string;
status: "pending" | "in_progress" | "completed" | "failed";
priority: string;
}
-export interface AcpMessage {
- type: "acp_message";
- direction: "client" | "agent";
- ts: number;
- message: unknown;
-}
-
-export interface SessionUpdateEvent {
- type: "session_update";
- ts: number;
- notification: SessionNotification;
-}
-
-export type SessionEvent = AcpMessage | SessionUpdateEvent;
-
export interface CloudPermissionResponseSelection {
optionId: string;
displayText: string;
@@ -75,64 +30,6 @@ export interface CloudPendingPermissionRequest {
response?: CloudPermissionResponseSelection;
}
-export interface TaskRunStateEvent {
- type: "task_run_state";
- status?: TaskRunStatus;
- stage?: string | null;
- output?: Record | null;
- error_message?: string | null;
- branch?: string | null;
- updated_at?: string | null;
- completed_at?: string | null;
-}
-
-export interface PermissionRequestEventData {
- type: "permission_request";
- requestId: string;
- toolCall: CloudTaskPermissionRequestUpdate["toolCall"];
- options: CloudPermissionOption[];
-}
-
-export interface SseErrorEventData {
- error: string;
-}
-
-export function isTaskRunStateEvent(data: unknown): data is TaskRunStateEvent {
- return (
- typeof data === "object" &&
- data !== null &&
- (data as { type?: string }).type === "task_run_state"
- );
-}
-
-export function isPermissionRequestEvent(
- data: unknown,
-): data is PermissionRequestEventData {
- return (
- typeof data === "object" &&
- data !== null &&
- (data as { type?: string }).type === "permission_request" &&
- typeof (data as { requestId?: string }).requestId === "string"
- );
-}
-
-export function isKeepaliveEvent(data: unknown): boolean {
- return (
- typeof data === "object" &&
- data !== null &&
- (data as { type?: string }).type === "keepalive"
- );
-}
-
-export function isSseErrorEvent(data: unknown): data is SseErrorEventData {
- return (
- typeof data === "object" &&
- data !== null &&
- "error" in data &&
- typeof (data as SseErrorEventData).error === "string"
- );
-}
-
export interface Integration {
id: number;
kind: string;
diff --git a/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts b/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts
deleted file mode 100644
index a6d512d59d..0000000000
--- a/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import { convertStoredEntriesToPortableSessionEvents } from "@posthog/core/sessions/portableSessionEvents";
-import type { MobileStoredLogEntry, SessionNotification } from "../types";
-
-export interface ParsedSessionLogs {
- notifications: SessionNotification[];
- rawEntries: MobileStoredLogEntry[];
-}
-
-export function parseSessionLogs(content: string): ParsedSessionLogs {
- if (!content?.trim()) {
- return { notifications: [], rawEntries: [] };
- }
-
- const notifications: SessionNotification[] = [];
- const rawEntries: MobileStoredLogEntry[] = [];
-
- for (const line of content.trim().split("\n")) {
- try {
- const stored = JSON.parse(line) as MobileStoredLogEntry;
-
- const msg = stored.notification;
- if (msg) {
- const hasId = msg.id !== undefined;
- const hasMethod = msg.method !== undefined;
- const hasResult = msg.result !== undefined || msg.error !== undefined;
-
- if (hasId && hasMethod) {
- stored.direction = "client";
- } else if (hasId && hasResult) {
- stored.direction = "agent";
- } else if (hasMethod && !hasId) {
- stored.direction = "agent";
- }
- }
-
- rawEntries.push(stored);
-
- if (
- stored.type === "notification" &&
- stored.notification?.method === "session/update" &&
- stored.notification?.params
- ) {
- notifications.push(stored.notification.params as SessionNotification);
- }
- } catch {
- // Skip malformed lines
- }
- }
-
- return { notifications, rawEntries };
-}
-
-export const convertStoredEntriesToEvents =
- convertStoredEntriesToPortableSessionEvents;
diff --git a/packages/core/src/cloud-task/cloud-task-types.ts b/packages/core/src/cloud-task/cloud-task-types.ts
deleted file mode 100644
index a2cd0c377e..0000000000
--- a/packages/core/src/cloud-task/cloud-task-types.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-import type { StoredLogEntry, TaskRunStatus } from "@posthog/shared";
-
-interface CloudTaskUpdateBase {
- taskId: string;
- runId: string;
-}
-
-export interface CloudTaskLogsUpdate extends CloudTaskUpdateBase {
- kind: "logs";
- newEntries: StoredLogEntry[];
- totalEntryCount: number;
-}
-
-export interface CloudTaskStatusUpdate extends CloudTaskUpdateBase {
- kind: "status";
- status?: TaskRunStatus;
- stage?: string | null;
- output?: Record | null;
- errorMessage?: string | null;
- branch?: string | null;
- sandboxAlive?: boolean | null;
-}
-
-export interface CloudTaskSnapshotUpdate extends CloudTaskUpdateBase {
- kind: "snapshot";
- newEntries: StoredLogEntry[];
- totalEntryCount: number;
- status?: TaskRunStatus;
- stage?: string | null;
- output?: Record | null;
- errorMessage?: string | null;
- branch?: string | null;
- sandboxAlive?: boolean | null;
-}
-
-export interface CloudTaskErrorUpdate extends CloudTaskUpdateBase {
- kind: "error";
- errorTitle: string;
- errorMessage: string;
- retryable: boolean;
-}
-
-export interface CloudPermissionOption {
- kind: string;
- optionId: string;
- name: string;
- _meta?: Record;
-}
-
-export interface CloudTaskPermissionRequestUpdate extends CloudTaskUpdateBase {
- kind: "permission_request";
- requestId: string;
- toolCall: {
- toolCallId: string;
- title: string;
- kind: string;
- content?: unknown[];
- rawInput?: Record;
- _meta?: Record;
- };
- options: CloudPermissionOption[];
-}
-
-export type CloudTaskUpdatePayload =
- | CloudTaskLogsUpdate
- | CloudTaskStatusUpdate
- | CloudTaskSnapshotUpdate
- | CloudTaskErrorUpdate
- | CloudTaskPermissionRequestUpdate;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f65dc4088b..ece21aa003 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -479,6 +479,9 @@ importers:
'@posthog/api-client':
specifier: workspace:*
version: link:../../packages/api-client
+ '@posthog/core':
+ specifier: workspace:*
+ version: link:../../packages/core
'@posthog/shared':
specifier: workspace:*
version: link:../../packages/shared