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