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 76ae4917a3..27df1e9b4e 100644
--- a/apps/mobile/src/app/task/[id].tsx
+++ b/apps/mobile/src/app/task/[id].tsx
@@ -1,10 +1,19 @@
import { Text } from "@components/text";
+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";
@@ -20,7 +29,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";
@@ -28,16 +36,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 {
@@ -169,10 +168,10 @@ 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);
@@ -312,16 +311,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);
@@ -506,7 +510,7 @@ export default function TaskDetailScreen() {
);
const handleReasoningChange = useCallback(
- (value: ReasoningEffort) => {
+ (value: SupportedReasoningEffort) => {
if (!taskId) return;
setComposerConfig(taskId, { reasoning: value });
setConfigOption(taskId, "effort", value).catch(() => {});
@@ -559,10 +563,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..1afbcb3c8b 100644
--- a/apps/mobile/src/features/inbox/api.ts
+++ b/apps/mobile/src/features/inbox/api.ts
@@ -1,343 +1,11 @@
-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 {
- AvailableSuggestedReviewer,
- AvailableSuggestedReviewersResponse,
- CommitDiffResponse,
- ReportArtefact,
- SignalProcessingStateResponse,
- SignalReport,
- SignalReportArtefactsResponse,
- SignalReportSignalsResponse,
- SignalReportsQueryParams,
- SignalReportsResponse,
- SuggestedReviewerWriteEntry,
-} from "./types";
-
-export async function getSignalReports(
- params?: SignalReportsQueryParams,
-): Promise {
- const baseUrl = getBaseUrl();
- const projectId = getProjectId();
-
- const url = new URL(`${baseUrl}/api/projects/${projectId}/signals/reports/`);
-
- if (params?.limit != null) {
- url.searchParams.set("limit", String(params.limit));
- }
- if (params?.offset != null) {
- url.searchParams.set("offset", String(params.offset));
- }
- if (params?.status) {
- url.searchParams.set("status", params.status);
- }
- if (params?.ordering) {
- url.searchParams.set("ordering", params.ordering);
- }
- if (params?.source_product) {
- url.searchParams.set("source_product", params.source_product);
- }
- if (params?.suggested_reviewers) {
- url.searchParams.set("suggested_reviewers", params.suggested_reviewers);
- }
- if (params?.priority) {
- url.searchParams.set("priority", params.priority);
- }
-
- const response = await authedFetch(url.toString());
-
- if (!response.ok) {
- throw new HttpError(
- response.status,
- response.statusText,
- "Failed to fetch signal reports",
- );
- }
-
- const data = await response.json();
- return {
- results: data.results ?? [],
- count: data.count ?? data.results?.length ?? 0,
- };
-}
-
-export async function getSignalReport(
- reportId: string,
-): Promise {
- const baseUrl = getBaseUrl();
- const projectId = getProjectId();
-
- const response = await authedFetch(
- `${baseUrl}/api/projects/${projectId}/signals/reports/${reportId}/`,
- );
-
- if (response.status === 404 || response.status === 403) {
- return null;
- }
-
- if (!response.ok) {
- throw new HttpError(
- response.status,
- response.statusText,
- "Failed to fetch signal report",
- );
- }
-
- return await response.json();
-}
-
-export async function getSignalProcessingState(): Promise {
- const baseUrl = getBaseUrl();
- const projectId = getProjectId();
-
- const response = await authedFetch(
- `${baseUrl}/api/projects/${projectId}/signals/processing_state/`,
- );
-
- if (!response.ok) {
- throw new HttpError(
- response.status,
- response.statusText,
- "Failed to fetch signal processing state",
- );
- }
-
- return await response.json();
-}
-
-export async function getAvailableSuggestedReviewers(
- query?: string,
-): Promise {
- const baseUrl = getBaseUrl();
- const projectId = getProjectId();
-
- const url = new URL(
- `${baseUrl}/api/projects/${projectId}/signals/reports/available_reviewers/`,
- );
-
- if (query?.trim()) {
- url.searchParams.set("query", query.trim());
- }
-
- const response = await authedFetch(url.toString());
-
- if (!response.ok) {
- throw new HttpError(
- response.status,
- response.statusText,
- "Failed to fetch available suggested reviewers",
- );
- }
-
- // API returns a dict keyed by UUID: { "uuid": { name, email, github_login } }
- const data = await response.json();
- const results = Object.entries(data)
- .map(([uuid, value]) => {
- if (typeof value !== "object" || value === null) return null;
- const v = value as Record;
- return {
- uuid,
- name: typeof v.name === "string" ? v.name : "",
- email: typeof v.email === "string" ? v.email : "",
- github_login: typeof v.github_login === "string" ? v.github_login : "",
- };
- })
- .filter((r): r is AvailableSuggestedReviewer => r !== null);
-
- return { results, count: results.length };
-}
-
-export async function getSignalReportArtefacts(
- reportId: string,
-): Promise {
- const baseUrl = getBaseUrl();
- const projectId = getProjectId();
-
- const response = await authedFetch(
- `${baseUrl}/api/projects/${projectId}/signals/reports/${reportId}/artefacts/`,
- );
-
- if (!response.ok) {
- const body = await response.text().catch(() => "");
- log.warn("Failed to fetch report artefacts", {
- reportId,
- status: response.status,
- body: body.slice(0, 500),
- });
- return { results: [], count: 0 };
- }
-
- const data = await response.json();
- const results: ReportArtefact[] = data.results ?? [];
- return { results, count: data.count ?? results.length };
-}
-
-/** Fetch a commit artefact's diff against its parent (lazily, on demand). */
-export async function getCommitDiff(
- reportId: string,
- artefactId: string,
-): Promise {
- const baseUrl = getBaseUrl();
- const projectId = getProjectId();
-
- const response = await authedFetch(
- `${baseUrl}/api/projects/${projectId}/signals/reports/${reportId}/artefacts/${artefactId}/diff/`,
- );
-
- if (!response.ok) {
- throw new HttpError(
- response.status,
- response.statusText,
- "Couldn’t load the diff",
- );
- }
-
- const data = await response.json();
- return {
- diff: typeof data.diff === "string" ? data.diff : "",
- truncated: data.truncated === true,
- };
-}
-
-/** Replace the content of a report artefact (full PUT, not a partial update). */
-export async function updateSignalReportArtefact(
- reportId: string,
- artefactId: string,
- content: SuggestedReviewerWriteEntry[],
-): Promise {
- const baseUrl = getBaseUrl();
- const projectId = getProjectId();
-
- const response = await authedFetch(
- `${baseUrl}/api/projects/${projectId}/signals/reports/${reportId}/artefacts/${artefactId}/`,
- {
- method: "PUT",
- body: JSON.stringify({ content }),
- },
- );
-
- if (!response.ok) {
- const errorText = await response.text().catch(() => "");
- throw new HttpError(
- response.status,
- response.statusText,
- errorText || "Failed to update suggested reviewers",
- );
- }
-}
-
-export async function getSignalReportSignals(
- reportId: string,
-): Promise {
- const baseUrl = getBaseUrl();
- const projectId = getProjectId();
-
- const response = await authedFetch(
- `${baseUrl}/api/projects/${projectId}/signals/reports/${reportId}/signals/`,
- );
-
- if (!response.ok) {
- log.warn("Failed to fetch report signals", {
- reportId,
- status: response.status,
- });
- return { signals: [] };
- }
-
- const data = await response.json();
- return { signals: data.signals ?? [] };
-}
+import { extractRepoSelectionRepository } from "@posthog/core/inbox/artefacts";
+import { getPostHogApiClient } from "@/lib/posthogApiClient";
/** Resolve the repository associated with a signal report via its repo_selection artefact. */
export async function getReportRepository(
reportId: string,
): Promise {
- const { results } = await getSignalReportArtefacts(reportId);
- const repoArtefact = results.find((a) => a.type === "repo_selection");
- if (!repoArtefact) return null;
-
- let parsed: unknown = repoArtefact.content;
- if (typeof parsed === "string") {
- try {
- parsed = JSON.parse(parsed);
- } catch {
- return (parsed as string).toLowerCase();
- }
- }
-
- if (typeof parsed === "object" && parsed !== null) {
- const repo =
- (parsed as Record).repository ??
- (parsed as Record).repo;
- if (typeof repo === "string") return repo.toLowerCase();
- }
-
- return null;
-}
-
-export interface DismissSignalReportInput {
- reason: DismissalReasonOptionValue;
- note?: string;
-}
-
-export async function dismissSignalReport(
- reportId: string,
- input: DismissSignalReportInput,
-): Promise {
- const baseUrl = getBaseUrl();
- const projectId = getProjectId();
-
- const response = await authedFetch(
- `${baseUrl}/api/projects/${projectId}/signals/reports/${reportId}/state/`,
- {
- method: "POST",
- body: JSON.stringify({
- state: "suppressed",
- dismissal_reason: input.reason,
- ...(input.note?.trim() ? { dismissal_note: input.note.trim() } : {}),
- }),
- },
- );
-
- if (!response.ok) {
- const errorText = await response.text().catch(() => "");
- throw new HttpError(
- response.status,
- response.statusText,
- errorText || "Failed to dismiss signal report",
- );
- }
-
- return await response.json();
-}
-
-/** Re-queue a dismissed report into the inbox via the `potential` transition. */
-export async function restoreSignalReport(
- reportId: string,
-): Promise {
- const baseUrl = getBaseUrl();
- const projectId = getProjectId();
-
- const response = await authedFetch(
- `${baseUrl}/api/projects/${projectId}/signals/reports/${reportId}/state/`,
- {
- method: "POST",
- body: JSON.stringify({ state: "potential" }),
- },
- );
-
- if (!response.ok) {
- const errorText = await response.text().catch(() => "");
- throw new HttpError(
- response.status,
- response.statusText,
- errorText || "Failed to restore signal report",
- );
- }
-
- return await response.json();
+ const { results } =
+ await getPostHogApiClient().getSignalReportArtefacts(reportId);
+ return extractRepoSelectionRepository(results);
}
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 11ed58e5af..47a22b36d8 100644
--- a/apps/mobile/src/features/inbox/components/FilterSheet.tsx
+++ b/apps/mobile/src/features/inbox/components/FilterSheet.tsx
@@ -1,15 +1,13 @@
import { Text } from "@components/text";
-import { EXTERNAL_INBOX_SOURCES } from "@posthog/shared";
+import { INBOX_PIPELINE_STATUSES } from "@posthog/core/inbox/reportFiltering";
+import { inboxStatusLabel } from "@posthog/core/inbox/reportPresentation";
+import { EXTERNAL_INBOX_SOURCES, 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;
@@ -29,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 {
@@ -74,9 +63,11 @@ export const SOURCE_PRODUCT_OPTIONS: { value: SourceProduct; label: string }[] =
{ value: "session_replay", label: "Session replay" },
{ value: "error_tracking", label: "Error tracking" },
{ value: "llm_analytics", label: "AI observability" },
+ { value: "github", label: "GitHub" },
+ { value: "linear", label: "Linear" },
+ { value: "zendesk", label: "Zendesk" },
{ value: "conversations", label: "Conversations" },
{ value: "signals_scout", label: "Scout" },
- { value: "health_checks", label: "Health checks" },
...EXTERNAL_INBOX_SOURCES.map((source) => ({
value: source.product,
label: source.label,
@@ -141,7 +132,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..542886a407 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";
@@ -11,12 +15,13 @@ const getAvailableSuggestedReviewers = vi.fn(async (_query?: string) => ({
results: [],
count: 0,
}));
-vi.mock("../api", () => ({
- getAvailableSuggestedReviewers: (query?: string) =>
- getAvailableSuggestedReviewers(query),
+vi.mock("@/lib/posthogApiClient", () => ({
+ getPostHogApiClient: () => ({
+ getAvailableSuggestedReviewers: (query?: string) =>
+ 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..e262300bf9 100644
--- a/apps/mobile/src/features/inbox/hooks/useInboxReports.ts
+++ b/apps/mobile/src/features/inbox/hooks/useInboxReports.ts
@@ -7,28 +7,7 @@ import {
INBOX_DISMISSED_STATUS_FILTER,
INBOX_REFETCH_INTERVAL_MS,
} from "@posthog/core/inbox/reportFiltering";
-import {
- useInfiniteQuery,
- useMutation,
- useQuery,
- useQueryClient,
-} from "@tanstack/react-query";
-import { useMemo } from "react";
-import { useAuthStore } from "@/features/auth";
-import {
- type DismissSignalReportInput,
- dismissSignalReport,
- getAvailableSuggestedReviewers,
- getCommitDiff,
- getSignalProcessingState,
- getSignalReport,
- getSignalReportArtefacts,
- getSignalReportSignals,
- getSignalReports,
- restoreSignalReport,
- updateSignalReportArtefact,
-} from "../api";
-import { useInboxFilterStore } from "../stores/inboxFilterStore";
+import type { DismissalReasonOptionValue } from "@posthog/shared";
import type {
AvailableSuggestedReviewersResponse,
CommitDiffResponse,
@@ -39,8 +18,19 @@ import type {
SignalReportsQueryParams,
SignalReportsResponse,
SuggestedReviewer,
+ SuggestedReviewersArtefact,
SuggestedReviewerWriteEntry,
-} from "../types";
+} from "@posthog/shared/domain-types";
+import {
+ useInfiniteQuery,
+ useMutation,
+ useQuery,
+ useQueryClient,
+} from "@tanstack/react-query";
+import { useMemo } from "react";
+import { useAuthStore } from "@/features/auth";
+import { getPostHogApiClient } from "@/lib/posthogApiClient";
+import { useInboxFilterStore } from "../stores/inboxFilterStore";
import { isRestorableReport } from "../utils";
export const inboxKeys = {
@@ -97,7 +87,7 @@ export function useInboxReports(options?: { enabled?: boolean }) {
const query = useInfiniteQuery({
queryKey: inboxKeys.list(params),
queryFn: ({ pageParam }) =>
- getSignalReports({
+ getPostHogApiClient().getSignalReports({
...params,
limit: REPORTS_PAGE_SIZE,
offset: pageParam,
@@ -136,7 +126,7 @@ export function useArchivedReports(options?: { enabled?: boolean }) {
const query = useQuery({
queryKey: inboxKeys.archived(params),
- queryFn: () => getSignalReports(params),
+ queryFn: () => getPostHogApiClient().getSignalReports(params),
enabled: !!projectId && !!oauthAccessToken && (options?.enabled ?? true),
});
@@ -157,7 +147,7 @@ export function useInboxReport(reportId: string | null) {
queryKey: inboxKeys.detail(reportId ?? ""),
queryFn: () => {
if (!reportId) throw new Error("reportId is required");
- return getSignalReport(reportId);
+ return getPostHogApiClient().getSignalReport(reportId);
},
enabled: !!projectId && !!oauthAccessToken && !!reportId,
});
@@ -168,7 +158,7 @@ export function useSignalProcessingState(options?: { enabled?: boolean }) {
return useQuery({
queryKey: inboxKeys.processingState,
- queryFn: () => getSignalProcessingState(),
+ queryFn: () => getPostHogApiClient().getSignalProcessingState(),
enabled: !!projectId && !!oauthAccessToken && (options?.enabled ?? true),
refetchInterval: INBOX_REFETCH_INTERVAL_MS,
});
@@ -183,7 +173,8 @@ export function useAvailableSuggestedReviewers(options?: {
return useQuery({
queryKey: [...inboxKeys.all, "available-reviewers", query] as const,
- queryFn: () => getAvailableSuggestedReviewers(query || undefined),
+ queryFn: () =>
+ getPostHogApiClient().getAvailableSuggestedReviewers(query || undefined),
enabled: !!projectId && !!oauthAccessToken && (options?.enabled ?? true),
staleTime: 5 * 60 * 1000,
// Only poll the unfiltered list; search terms are transient and each one
@@ -199,7 +190,7 @@ export function useInboxReportArtefacts(reportId: string | null) {
queryKey: inboxKeys.artefacts(reportId ?? ""),
queryFn: () => {
if (!reportId) throw new Error("reportId is required");
- return getSignalReportArtefacts(reportId);
+ return getPostHogApiClient().getSignalReportArtefacts(reportId);
},
enabled: !!projectId && !!oauthAccessToken && !!reportId,
// The log is a live work record — agents append artefacts while a report
@@ -218,7 +209,7 @@ export function useCommitDiff(
return useQuery({
queryKey: inboxKeys.commitDiff(reportId, artefactId),
- queryFn: () => getCommitDiff(reportId, artefactId),
+ queryFn: () => getPostHogApiClient().getCommitDiff(reportId, artefactId),
// A commit's diff is immutable, so only fetch once expanded and never retry.
enabled: enabled && !!projectId && !!oauthAccessToken,
staleTime: 5 * 60_000,
@@ -233,7 +224,7 @@ export function useInboxReportSignals(reportId: string | null) {
queryKey: inboxKeys.signals(reportId ?? ""),
queryFn: () => {
if (!reportId) throw new Error("reportId is required");
- return getSignalReportSignals(reportId);
+ return getPostHogApiClient().getSignalReportSignals(reportId);
},
enabled: !!projectId && !!oauthAccessToken && !!reportId,
});
@@ -256,7 +247,9 @@ export function useUpdateSuggestedReviewers(reportId: string) {
{ previous: SignalReportArtefactsResponse | undefined }
>({
mutationFn: ({ artefactId, content }) =>
- updateSignalReportArtefact(reportId, artefactId, content),
+ getPostHogApiClient()
+ .updateSignalReportArtefact(reportId, artefactId, content)
+ .then(() => undefined),
onMutate: async ({ artefactId, optimisticReviewers }) => {
await queryClient.cancelQueries({ queryKey });
const previous =
@@ -264,12 +257,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 };
@@ -288,8 +289,17 @@ export function useUpdateSuggestedReviewers(reportId: string) {
export function useDismissReport(reportId: string) {
const queryClient = useQueryClient();
- return useMutation({
- mutationFn: (input) => dismissSignalReport(reportId, input),
+ return useMutation<
+ SignalReport,
+ Error,
+ { reason: DismissalReasonOptionValue; note?: string }
+ >({
+ mutationFn: (input) =>
+ getPostHogApiClient().updateSignalReportState(reportId, {
+ state: "suppressed",
+ dismissal_reason: input.reason,
+ ...(input.note?.trim() ? { dismissal_note: input.note.trim() } : {}),
+ }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: inboxKeys.detail(reportId) });
queryClient.invalidateQueries({ queryKey: inboxKeys.all });
@@ -305,11 +315,12 @@ export function useRestoreReport() {
// report.
return useMutation({
mutationFn: async (reportId) => {
- const current = await getSignalReport(reportId);
+ const client = getPostHogApiClient();
+ const current = await client.getSignalReport(reportId);
if (current && !isRestorableReport(current)) {
return false;
}
- await restoreSignalReport(reportId);
+ await client.updateSignalReportState(reportId, { state: "potential" });
return true;
},
onSuccess: () => {
diff --git a/apps/mobile/src/features/inbox/stores/inboxFilterStore.test.ts b/apps/mobile/src/features/inbox/stores/inboxFilterStore.test.ts
index 935407c640..3bfc12271b 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 a0536417da..1f779d7dd7 100644
--- a/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts
+++ b/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts
@@ -1,13 +1,13 @@
import { INBOX_PIPELINE_STATUSES } from "@posthog/core/inbox/reportFiltering";
import type { SourceProduct } from "@posthog/shared";
-import AsyncStorage from "@react-native-async-storage/async-storage";
-import { create } from "zustand";
-import { createJSONStorage, persist } from "zustand/middleware";
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,
@@ -16,12 +16,6 @@ type SortField = Extract<
type SortDirection = "asc" | "desc";
-export type { SourceProduct };
-
-export const DEFAULT_STATUS_FILTER: SignalReportStatus[] = [
- ...INBOX_PIPELINE_STATUSES,
-];
-
interface InboxFilterState {
sortField: SortField;
sortDirection: SortDirection;
@@ -51,7 +45,7 @@ export const useInboxFilterStore = create()(
(set) => ({
sortField: "priority",
sortDirection: "asc",
- statusFilter: DEFAULT_STATUS_FILTER,
+ statusFilter: [...INBOX_PIPELINE_STATUSES],
sourceProductFilter: [],
suggestedReviewerFilter: [],
priorityFilter: [],
@@ -100,7 +94,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 ad56b19305..ce8b2b43af 100644
--- a/apps/mobile/src/features/inbox/utils.test.ts
+++ b/apps/mobile/src/features/inbox/utils.test.ts
@@ -1,9 +1,20 @@
+import {
+ 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 {
+ Signal,
+ SignalReport,
+ SignalReportOrderingField,
+ SignalReportStatus,
+} from "@posthog/shared/domain-types";
import { describe, expect, it } from "vitest";
-import type { Signal, SignalReport, SignalReportStatus } from "./types";
import {
buildInboxViewedProperties,
- dismissalReasonLabel,
- formatSignalReportSummaryMarkdown,
isRestorableReport,
sourceLine,
} from "./utils";
@@ -21,15 +32,6 @@ function signal(source_product: string, source_type: string): Signal {
};
}
-const DEFAULT_STATUS_FILTER: SignalReportStatus[] = [
- "ready",
- "pending_input",
- "in_progress",
- "failed",
- "candidate",
- "potential",
-];
-
function makeReport(
partial: Partial & Pick,
): SignalReport {
@@ -88,10 +90,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,
@@ -137,10 +139,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);
@@ -161,36 +163,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);
});
@@ -198,15 +200,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 b0040ddcef..13a8e4c80b 100644
--- a/apps/mobile/src/features/inbox/utils.ts
+++ b/apps/mobile/src/features/inbox/utils.ts
@@ -2,15 +2,14 @@ import {
EXTERNAL_INBOX_SOURCE_BY_PRODUCT,
type SourceProduct,
} from "@posthog/shared";
-import { differenceInHours, format, formatDistanceToNow } from "date-fns";
-import type { InboxViewedProperties } from "@/lib/analytics";
-import { DISMISSAL_REASON_OPTIONS } from "./constants";
import type {
Signal,
SignalReport,
SignalReportPriority,
SignalReportStatus,
-} from "./types";
+} from "@posthog/shared/domain-types";
+import { differenceInHours, format, formatDistanceToNow } from "date-fns";
+import type { InboxViewedProperties } from "@/lib/analytics";
const ERROR_TRACKING_TYPE_LABELS: Record = {
issue_created: "New issue",
@@ -46,34 +45,7 @@ export function sourceLine(signal: Signal): string {
const warehouseSource =
EXTERNAL_INBOX_SOURCE_BY_PRODUCT[source_product as SourceProduct];
const product = warehouseSource?.label ?? source_product.replace(/_/g, " ");
- const type = source_type.replace(/_/g, " ");
- return `${product} · ${type}`;
-}
-
-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;
+ return `${product} · ${source_type.replace(/_/g, " ")}`;
}
/** Relative time for the last day, absolute "MMM d" beyond it. */
@@ -93,38 +65,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.
@@ -140,11 +80,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/composer/RepositoryPickerInline.tsx b/apps/mobile/src/features/tasks/composer/RepositoryPickerInline.tsx
index e953df43b9..6e39aead06 100644
--- a/apps/mobile/src/features/tasks/composer/RepositoryPickerInline.tsx
+++ b/apps/mobile/src/features/tasks/composer/RepositoryPickerInline.tsx
@@ -15,8 +15,8 @@ import Animated, {
useSharedValue,
withTiming,
} from "react-native-reanimated";
-import type { RepositoryOption } from "@/features/tasks/types";
import { useThemeColors } from "@/lib/theme";
+import type { RepositoryOption } from "../types";
// Tuning for the nested (ScrollView) path's progressive mount. The first
// chunk needs to cover the rows the user can actually see (~5 with the
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 572fff4a35..1db34b57a1 100644
--- a/apps/mobile/src/features/tasks/composer/options.ts
+++ b/apps/mobile/src/features/tasks/composer/options.ts
@@ -1,107 +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-5",
- label: "Claude Opus 5",
- description: "Most capable, slower",
- supportsReasoning: true,
- },
- {
- value: "claude-opus-4-8",
- label: "Claude Opus 4.8",
- description: "Previous Opus generation",
- 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;
-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..25f909f6f9 100644
--- a/apps/mobile/src/features/tasks/hooks/useIntegrations.ts
+++ b/apps/mobile/src/features/tasks/hooks/useIntegrations.ts
@@ -1,34 +1,14 @@
+import { combineGithubRepositories } from "@posthog/core/integrations/repositories";
import { useQuery } from "@tanstack/react-query";
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 { buildRepositoryOptions } from "../utils/repositorySelection";
-
-/** Cheap content-equality check for repository option lists. Lets the cache
- * write effect skip no-op updates, which is what kept retriggering renders
- * before — `buildRepositoryOptions` always returns a fresh array, so the
- * effect's dep array churned every render. */
-function repositoryOptionsEqual(
- a: RepositoryOption[],
- b: RepositoryOption[],
-): boolean {
- if (a === b) return true;
- if (a.length !== b.length) return false;
- for (let i = 0; i < a.length; i++) {
- const left = a[i];
- const right = b[i];
- if (
- left.integrationId !== right.integrationId ||
- left.repository !== right.repository ||
- left.integrationLabel !== right.integrationLabel
- ) {
- return false;
- }
- }
- return true;
-}
+import {
+ buildRepositoryOptions,
+ repositoryLoadWarning,
+ repositoryOptionsEqual,
+} from "../utils/repositorySelection";
export const integrationKeys = {
all: ["integrations"] as const,
@@ -60,26 +40,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 +58,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(Boolean),
})),
);
@@ -117,12 +80,10 @@ export function useIntegrations(options: UseIntegrationsOptions = {}) {
return {
repositoriesByIntegration,
- partialError:
- failedCount === 0
- ? null
- : failedCount === githubIntegrations.length
- ? "Could not load GitHub repositories. Pull to retry."
- : "Some GitHub repositories could not be loaded. Pull to retry.",
+ partialError: repositoryLoadWarning(
+ failedCount,
+ githubIntegrations.length,
+ ),
};
},
enabled: enabled && githubIntegrations.length > 0,
@@ -130,7 +91,19 @@ export function useIntegrations(options: UseIntegrationsOptions = {}) {
const repositoriesByIntegration =
repositoriesQuery.data?.repositoriesByIntegration ?? {};
- const repositories = Object.values(repositoriesByIntegration).flat().sort();
+ const repositories = Object.keys(
+ combineGithubRepositories(
+ githubIntegrations.map((integration) => ({
+ data: {
+ integrationId: integration.id,
+ repos: repositoriesByIntegration[integration.id] ?? [],
+ },
+ isPending: repositoriesQuery.isPending,
+ isError: false,
+ isRefetching: repositoriesQuery.isRefetching,
+ })),
+ ).repositoryMap,
+ ).sort();
// Memoize the derived options list keyed on the underlying query data so
// its reference is stable across renders when the data hasn't actually
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..c951950409 100644
--- a/apps/mobile/src/features/tasks/hooks/useUserIntegrations.ts
+++ b/apps/mobile/src/features/tasks/hooks/useUserIntegrations.ts
@@ -1,8 +1,13 @@
+import { combineUserGithubRepositories } from "@posthog/core/integrations/repositories";
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 {
+ buildUserRepositoryOptions,
+ repositoryLoadWarning,
+} from "../utils/repositorySelection";
/**
* User-scoped sibling of {@link useIntegrations}. Reads the authenticated
@@ -29,20 +34,25 @@ interface UseUserIntegrationsOptions {
enabled?: boolean;
}
-function integrationLabel(integration: {
- installation_id: string;
- account?: { name?: string | null } | null;
-}): string {
- return integration.account?.name ?? `GitHub ${integration.installation_id}`;
-}
-
export function useUserIntegrations(options: UseUserIntegrationsOptions = {}) {
const { enabled = true } = options;
const { oauthAccessToken } = useAuthStore();
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,
});
@@ -53,53 +63,57 @@ export function useUserIntegrations(options: UseUserIntegrationsOptions = {}) {
integrations.map((i) => i.installation_id),
),
queryFn: async () => {
- const byInstallation: Record = {};
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(Boolean),
})),
);
- let failedCount = 0;
- for (const result of results) {
- if (result.status === "fulfilled") {
- byInstallation[result.value.installationId] =
- result.value.repositories;
- } else {
- failedCount += 1;
- }
- }
+ const combined = combineUserGithubRepositories(
+ results.map((result) => ({
+ data:
+ result.status === "fulfilled"
+ ? {
+ userIntegrationId:
+ integrations.find(
+ (integration) =>
+ integration.installation_id ===
+ result.value.installationId,
+ )?.id ?? "",
+ installationId: result.value.installationId,
+ repos: result.value.repositories,
+ }
+ : undefined,
+ isPending: false,
+ isError: result.status === "rejected",
+ isRefetching: false,
+ })),
+ integrations.map((integration) => integration.installation_id),
+ );
return {
- byInstallation,
- partialError:
- failedCount === 0
- ? null
- : failedCount === integrations.length
- ? "Could not load GitHub repositories. Pull to retry."
- : "Some GitHub repositories could not be loaded. Pull to retry.",
+ byInstallation: combined.reposByInstallationId,
+ partialError: repositoryLoadWarning(
+ combined.failedInstallationIds.length,
+ integrations.length,
+ ),
};
},
enabled: enabled && integrations.length > 0,
});
const repositoryOptions = useMemo(() => {
- const byInstallation = repositoriesQuery.data?.byInstallation ?? {};
- return integrations
- .flatMap((integration) => {
- const repositories = byInstallation[integration.installation_id] ?? [];
- return repositories.map((repository) => ({
- // GitHub installation ids fit in a JS number; use it as the numeric
- // key the picker/RepositoryOption already expect.
- integrationId: Number(integration.installation_id),
- integrationLabel: integrationLabel(integration),
- repository,
- }));
- })
- .sort((left, right) => left.repository.localeCompare(right.repository));
+ return buildUserRepositoryOptions(
+ integrations,
+ repositoriesQuery.data?.byInstallation ?? {},
+ );
}, [integrations, repositoriesQuery.data]);
/** Resolve the `UserIntegration` UUID for a selected installation id, to send
diff --git a/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx b/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx
index 175a77c276..4985ffeaee 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 7da4db747e..2bcbcc35a7 100644
--- a/apps/mobile/src/features/tasks/index.ts
+++ b/apps/mobile/src/features/tasks/index.ts
@@ -24,9 +24,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/stores/taskSessionStore.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts
index 462589a58e..0c57cc6d1a 100644
--- a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts
+++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts
@@ -1,7 +1,9 @@
+import { convertStoredEntriesToPortableSessionEvents } from "@posthog/core/sessions/portableSessionEvents";
import {
type CloudTaskUpdatePayload,
isTerminalStatus,
type StoredLogEntry,
+ serializeCloudPrompt,
type Task,
} from "@posthog/shared";
import * as Haptics from "expo-haptics";
@@ -18,7 +20,6 @@ import {
sendCloudCommand,
} from "../api";
import { buildCloudPromptBlocks } from "../composer/attachments/buildCloudPrompt";
-import { serializeCloudPrompt } from "../composer/attachments/cloudPrompt";
import type { PendingAttachment } from "../composer/attachments/types";
import {
type WatchCloudTaskHandle,
@@ -31,7 +32,6 @@ import type {
SessionNotificationAttachment,
TerminalStatus,
} from "../types";
-import { convertStoredEntriesToEvents } from "../utils/parseSessionLogs";
import { playbackRateForTaskDuration } from "../utils/playbackRate";
import { reinjectPromptAttachments } from "../utils/promptAttachments";
import { playCompletionSound } from "../utils/sounds";
@@ -991,7 +991,8 @@ export const useTaskSessionStore = create((set, get) => ({
? update.newEntries
: dedupAgainstLocalEchoes(update.newEntries, echoSet);
- const events = convertStoredEntriesToEvents(dedupedEntries);
+ const events =
+ convertStoredEntriesToPortableSessionEvents(dedupedEntries);
// Snapshots are S3-backed and replay user turns as text-only chunks;
// reattach the images from the `session/prompt` entries in the same log.
if (isSnapshot) {
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/apps/mobile/src/features/tasks/utils/repositorySelection.test.ts b/apps/mobile/src/features/tasks/utils/repositorySelection.test.ts
index 820ef73caf..b7177617e3 100644
--- a/apps/mobile/src/features/tasks/utils/repositorySelection.test.ts
+++ b/apps/mobile/src/features/tasks/utils/repositorySelection.test.ts
@@ -1,36 +1,31 @@
import { describe, expect, it } from "vitest";
import {
buildRepositoryOptions,
+ buildUserRepositoryOptions,
findRepositoryOption,
isRepositorySelectionComplete,
+ repositoryLoadWarning,
+ repositoryOptionsEqual,
toRepositorySelection,
} from "./repositorySelection";
describe("repositorySelection", () => {
const integrations = [
- {
- id: 7,
- kind: "github",
- display_name: "Personal GitHub",
- },
+ { id: 7, kind: "github", display_name: "Personal GitHub" },
{
id: 11,
kind: "github",
- config: {
- account: {
- login: "posthog",
- },
- },
+ config: { account: { login: "posthog" } },
},
];
it("preserves integration identity for each repository option", () => {
- const options = buildRepositoryOptions(integrations, {
- 7: ["annika/mobile-app"],
- 11: ["posthog/posthog", "posthog/code"],
- });
-
- expect(options).toEqual([
+ expect(
+ buildRepositoryOptions(integrations, {
+ 7: ["annika/mobile-app"],
+ 11: ["posthog/posthog", "posthog/code"],
+ }),
+ ).toEqual([
{
integrationId: 7,
integrationLabel: "Personal GitHub",
@@ -49,41 +44,81 @@ describe("repositorySelection", () => {
]);
});
- it("finds the exact repository option when multiple integrations expose the same repository", () => {
+ it("finds an exact repository option", () => {
const options = buildRepositoryOptions(integrations, {
7: ["posthog/posthog"],
11: ["posthog/posthog"],
});
- const selected = findRepositoryOption(options, {
+ expect(
+ findRepositoryOption(options, {
+ integrationId: 11,
+ repository: "posthog/posthog",
+ }),
+ ).toEqual({
integrationId: 11,
+ integrationLabel: "posthog",
repository: "posthog/posthog",
});
+ });
+
+ it.each([
+ [{ installation_id: "42", account: { name: "PostHog" } }, "PostHog"],
+ [{ installation_id: "43" }, "GitHub 43"],
+ ])(
+ "builds user integration options with the expected label",
+ (integration, integrationLabel) => {
+ expect(
+ buildUserRepositoryOptions([integration], {
+ [integration.installation_id]: ["posthog/code"],
+ }),
+ ).toEqual([
+ {
+ integrationId: Number(integration.installation_id),
+ integrationLabel,
+ repository: "posthog/code",
+ },
+ ]);
+ },
+ );
- expect(selected).toEqual({
+ it("treats a changed label as a different repository option", () => {
+ const option = {
integrationId: 11,
integrationLabel: "posthog",
- repository: "posthog/posthog",
- });
+ repository: "posthog/code",
+ };
+
+ expect(
+ repositoryOptionsEqual(
+ [option],
+ [{ ...option, integrationLabel: "PostHog GitHub" }],
+ ),
+ ).toBe(false);
});
- it("converts an option into a reusable repository selection payload", () => {
- const options = buildRepositoryOptions(integrations, {
- 11: ["posthog/code"],
- });
+ it.each([
+ [0, 2, null],
+ [1, 2, "Some GitHub repositories could not be loaded. Pull to retry."],
+ [2, 2, "Could not load GitHub repositories. Pull to retry."],
+ ])(
+ "maps repository failures to the expected warning",
+ (failedCount, totalCount, expected) => {
+ expect(repositoryLoadWarning(failedCount, totalCount)).toBe(expected);
+ },
+ );
- const selection = toRepositorySelection(options[0] ?? null);
+ it("converts an option into a repository selection", () => {
+ const selection = toRepositorySelection({
+ integrationId: 11,
+ integrationLabel: "posthog",
+ repository: "posthog/code",
+ });
expect(selection).toEqual({
integrationId: 11,
repository: "posthog/code",
});
expect(isRepositorySelectionComplete(selection)).toBe(true);
- expect(
- isRepositorySelectionComplete({
- integrationId: null,
- repository: "posthog/code",
- }),
- ).toBe(false);
});
});
diff --git a/apps/mobile/src/features/tasks/utils/repositorySelection.ts b/apps/mobile/src/features/tasks/utils/repositorySelection.ts
index 9e7b2cc8a8..bff8441a6a 100644
--- a/apps/mobile/src/features/tasks/utils/repositorySelection.ts
+++ b/apps/mobile/src/features/tasks/utils/repositorySelection.ts
@@ -2,6 +2,7 @@ import type {
Integration,
RepositoryOption,
RepositorySelection,
+ UserGithubIntegration,
} from "../types";
function getIntegrationLabel(integration: Integration): string {
@@ -17,26 +18,67 @@ export function buildRepositoryOptions(
repositoriesByIntegration: Record,
): RepositoryOption[] {
return integrations
- .flatMap((integration) => {
- const repositories = repositoriesByIntegration[integration.id] ?? [];
-
- return repositories.map((repository) => ({
+ .flatMap((integration) =>
+ (repositoriesByIntegration[integration.id] ?? []).map((repository) => ({
integrationId: integration.id,
integrationLabel: getIntegrationLabel(integration),
repository,
- }));
- })
+ })),
+ )
+ .sort((left, right) => left.repository.localeCompare(right.repository));
+}
+
+export function buildUserRepositoryOptions(
+ integrations: UserGithubIntegration[],
+ repositoriesByInstallation: Record,
+): RepositoryOption[] {
+ return integrations
+ .flatMap((integration) =>
+ (repositoriesByInstallation[integration.installation_id] ?? []).map(
+ (repository) => ({
+ integrationId: Number(integration.installation_id),
+ integrationLabel:
+ integration.account?.name ??
+ `GitHub ${integration.installation_id}`,
+ repository,
+ }),
+ ),
+ )
.sort((left, right) => left.repository.localeCompare(right.repository));
}
+export function repositoryOptionsEqual(
+ left: RepositoryOption[],
+ right: RepositoryOption[],
+): boolean {
+ return (
+ left.length === right.length &&
+ left.every((option, index) => {
+ const other = right[index];
+ return (
+ other?.integrationId === option.integrationId &&
+ other.integrationLabel === option.integrationLabel &&
+ other.repository === option.repository
+ );
+ })
+ );
+}
+
+export function repositoryLoadWarning(
+ failedCount: number,
+ totalCount: number,
+): string | null {
+ if (failedCount === 0) return null;
+ return failedCount === totalCount
+ ? "Could not load GitHub repositories. Pull to retry."
+ : "Some GitHub repositories could not be loaded. Pull to retry.";
+}
+
export function findRepositoryOption(
options: RepositoryOption[],
selection: RepositorySelection,
): RepositoryOption | null {
- if (!selection.integrationId || !selection.repository) {
- return null;
- }
-
+ if (!selection.integrationId || !selection.repository) return null;
return (
options.find(
(option) =>