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 fe73a847f3..31dc3d6e5d 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -1,10 +1,19 @@ import { Text } from "@components/text"; +import { DEFAULT_CLAUDE_EXECUTION_MODE } from "@posthog/core/sessions/executionModes"; import { countUserMessages, getSessionActivityPhase, } from "@posthog/core/sessions/sessionActivity"; import { isTaskRunning } from "@posthog/core/tasks/taskArchive"; -import type { Task } from "@posthog/shared"; +import { + DEFAULT_GATEWAY_MODEL, + DEFAULT_REASONING_EFFORT, + type ExecutionMode, + getReasoningEffortOptions, + type SupportedReasoningEffort, + serializeCloudPrompt, + type Task, +} from "@posthog/shared"; import { useQueryClient } from "@tanstack/react-query"; import * as Haptics from "expo-haptics"; import { useLocalSearchParams, useRouter } from "expo-router"; @@ -20,7 +29,6 @@ import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller import Animated, { useAnimatedStyle } from "react-native-reanimated"; import { FloatingBackButton } from "@/components/FloatingBackButton"; import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; -import { runTaskInCloud } from "@/features/tasks/api"; import { CustomImageBadge } from "@/features/tasks/components/CustomImageBadge"; import { FloatingTaskHeader } from "@/features/tasks/components/FloatingTaskHeader"; import { PrDiffStatsBadge } from "@/features/tasks/components/PrDiffStatsBadge"; @@ -28,16 +36,7 @@ import { PrStatusBadge } from "@/features/tasks/components/PrStatusBadge"; import { StopRunButton } from "@/features/tasks/components/StopRunButton"; import { TaskSessionView } from "@/features/tasks/components/TaskSessionView"; import { buildCloudPromptBlocks } from "@/features/tasks/composer/attachments/buildCloudPrompt"; -import { serializeCloudPrompt } from "@/features/tasks/composer/attachments/cloudPrompt"; import type { PendingAttachment } from "@/features/tasks/composer/attachments/types"; -import { - DEFAULT_EXECUTION_MODE, - DEFAULT_MODEL, - DEFAULT_REASONING, - type ExecutionMode, - modelSupportsReasoning, - type ReasoningEffort, -} from "@/features/tasks/composer/options"; import { QueuedMessagesDock } from "@/features/tasks/composer/QueuedMessagesDock"; import { TaskChatComposer } from "@/features/tasks/composer/TaskChatComposer"; import { @@ -169,10 +168,10 @@ export default function TaskDetailScreen() { string | undefined >(); const composerMode: ExecutionMode = - composerConfig?.mode ?? DEFAULT_EXECUTION_MODE; - const composerModel = composerConfig?.model ?? DEFAULT_MODEL; - const composerReasoning: ReasoningEffort = - composerConfig?.reasoning ?? DEFAULT_REASONING; + composerConfig?.mode ?? DEFAULT_CLAUDE_EXECUTION_MODE; + const composerModel = composerConfig?.model ?? DEFAULT_GATEWAY_MODEL; + const composerReasoning: SupportedReasoningEffort = + composerConfig?.reasoning ?? DEFAULT_REASONING_EFFORT; const messagingMode = useMessagingMode(taskId); const queuedCount = useQueuedCount(taskId); @@ -315,16 +314,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 +517,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 +570,14 @@ export default function TaskDetailScreen() { setRetrying(true); disconnectFromTask(taskId); - const updatedTask = await runTaskInCloud(taskId, { - resumeFromRunId: task.latest_run?.id, - rtkEnabled: usePreferencesStore.getState().rtkEnabledCloud, - }); + const updatedTask = await getPostHogApiClient().runTaskInCloud( + taskId, + undefined, + { + resumeFromRunId: task.latest_run?.id, + rtkEnabled: usePreferencesStore.getState().rtkEnabledCloud, + }, + ); setTask(updatedTask); await connectToTask(updatedTask); updateTaskInCache(updatedTask); diff --git a/apps/mobile/src/app/task/index.tsx b/apps/mobile/src/app/task/index.tsx index 012b206dcc..9df757f9de 100644 --- a/apps/mobile/src/app/task/index.tsx +++ b/apps/mobile/src/app/task/index.tsx @@ -1,4 +1,17 @@ import { Text } from "@components/text"; +import { + DEFAULT_CLAUDE_EXECUTION_MODE, + getAvailableModes, +} from "@posthog/core/sessions/executionModes"; +import { + DEFAULT_GATEWAY_MODEL, + DEFAULT_REASONING_EFFORT, + type ExecutionMode, + getReasoningEffortOptions, + isSupportedReasoningEffort, + type SupportedReasoningEffort, + serializeCloudPrompt, +} from "@posthog/shared"; import { LinearGradient } from "expo-linear-gradient"; import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import { @@ -15,7 +28,7 @@ import { Sparkle, StopIcon, } from "phosphor-react-native"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { ActivityIndicator, Pressable, @@ -30,13 +43,11 @@ import { import Animated, { runOnJS, useAnimatedStyle } from "react-native-reanimated"; import { useVoiceRecording } from "@/features/chat"; import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; -import { runTaskInCloud } from "@/features/tasks/api"; import { GitHubConnectionPrompt } from "@/features/tasks/components/GitHubConnectionPrompt"; import { GitHubLoadNotice } from "@/features/tasks/components/GitHubLoadNotice"; import { AttachmentSheet } from "@/features/tasks/composer/attachments/AttachmentSheet"; import { AttachmentsBar } from "@/features/tasks/composer/attachments/AttachmentsBar"; import { buildCloudPromptBlocks } from "@/features/tasks/composer/attachments/buildCloudPrompt"; -import { serializeCloudPrompt } from "@/features/tasks/composer/attachments/cloudPrompt"; import { captureFromCamera, pickDocument, @@ -45,22 +56,15 @@ import { import type { PendingAttachment } from "@/features/tasks/composer/attachments/types"; import { DotBackground } from "@/features/tasks/composer/DotBackground"; import { - DEFAULT_EXECUTION_MODE, - DEFAULT_MODEL, - DEFAULT_REASONING, - EXECUTION_MODES, - type ExecutionMode, - MODELS, - modeLabel, - modelLabel, - modelSupportsReasoning, - REASONING_LEVELS, - type ReasoningEffort, - reasoningLabel, + getMobileModelOptions, + getModelConfigOption, + getModelLabel, + resolveAvailableModel, } from "@/features/tasks/composer/options"; import { Pill } from "@/features/tasks/composer/Pill"; import { RepositoryPickerInline } from "@/features/tasks/composer/RepositoryPickerInline"; import { SelectSheet } from "@/features/tasks/composer/SelectSheet"; +import { useCloudTaskConfigOptions } from "@/features/tasks/hooks/useCloudTaskConfigOptions"; import { useUserIntegrations } from "@/features/tasks/hooks/useUserIntegrations"; import { useWarmTask } from "@/features/tasks/hooks/useWarmTask"; import { pendingPromptRecoveryStoreApi } from "@/features/tasks/stores/pendingPromptRecoveryStore"; @@ -84,6 +88,7 @@ import { getPostHogApiClient } from "@/lib/posthogApiClient"; import { toRgba, useThemeColors } from "@/lib/theme"; const log = logger.scope("task-create"); +const EXECUTION_MODES = getAvailableModes(); const SUGGESTIONS = [ "Create or update my CLAUDE.md file", @@ -99,6 +104,11 @@ function modeIcon(mode: ExecutionMode, color: string, size = 14) { return ; case "acceptEdits": return ; + case "bypassPermissions": + case "full-access": + return ; + case "read-only": + return ; case "auto": return ; } @@ -119,6 +129,9 @@ export default function NewTaskScreen() { const { insets, bottom } = useScreenInsets(); const keyboard = useReanimatedKeyboardAnimation(); const restingBottom = bottom("compact"); + const { configOptions, hasLiveConfig } = useCloudTaskConfigOptions("claude"); + const modelConfigOption = getModelConfigOption(configOptions); + const mobileModelOptions = getMobileModelOptions(modelConfigOption); const { error, hasGithubIntegration, @@ -182,22 +195,32 @@ export default function NewTaskScreen() { const prefs = usePreferencesStore.getState(); if (prefs.defaultInitialTaskMode === "last_used") { const last = prefs.lastNewTaskMode; - const isValidMode = EXECUTION_MODES.some((m) => m.value === last); + const isValidMode = EXECUTION_MODES.some((mode) => mode.id === last); if (isValidMode) return last as ExecutionMode; } - return DEFAULT_EXECUTION_MODE; + return DEFAULT_CLAUDE_EXECUTION_MODE; }); - const [model, setModel] = useState(DEFAULT_MODEL); - const [reasoning, setReasoning] = useState(() => { + const [model, setModel] = useState(DEFAULT_GATEWAY_MODEL); + const [reasoning, setReasoning] = useState(() => { const prefs = usePreferencesStore.getState(); - const isValidReasoning = (v: string): v is ReasoningEffort => - REASONING_LEVELS.some((r) => r.value === v); const desired = prefs.defaultReasoningEffort === "last_used" ? prefs.lastUsedReasoningEffort : prefs.defaultReasoningEffort; - return isValidReasoning(desired) ? desired : DEFAULT_REASONING; + return isSupportedReasoningEffort("claude", DEFAULT_GATEWAY_MODEL, desired) + ? desired + : DEFAULT_REASONING_EFFORT; }); + + useEffect(() => { + if (!hasLiveConfig) return; + const availableModel = resolveAvailableModel(modelConfigOption, model); + if (availableModel === model) return; + setModel(availableModel); + if (!isSupportedReasoningEffort("claude", availableModel, reasoning)) { + setReasoning(DEFAULT_REASONING_EFFORT); + } + }, [hasLiveConfig, model, modelConfigOption, reasoning]); const [creating, setCreating] = useState(false); const [repoSheetOpen, setRepoSheetOpen] = useState(false); const [modeSheetOpen, setModeSheetOpen] = useState(false); @@ -309,7 +332,8 @@ export default function NewTaskScreen() { ? `Attached: ${attachments[0].fileName}` : `Attached ${attachments.length} files`); - const task = await getPostHogApiClient().createTask({ + const client = getPostHogApiClient(); + const task = await client.createTask({ description: descriptionText, title: descriptionText.slice(0, 100), repository: selection.repository ?? undefined, @@ -334,7 +358,7 @@ export default function NewTaskScreen() { // Seed the per-task composer config with the mode/model/reasoning the // user picked here, so the task detail screen reflects them and every // subsequent run (resume-after-terminal) reuses the selected mode rather - // than falling back to DEFAULT_EXECUTION_MODE ("plan"). + // than falling back to the default plan mode. setComposerConfig(task.id, { mode, model, reasoning }); const pendingUserMessage = @@ -344,13 +368,14 @@ export default function NewTaskScreen() { ) : trimmedPrompt; - const supportsReasoning = modelSupportsReasoning(model); + const supportsReasoning = + getReasoningEffortOptions("claude", model) !== null; - await runTaskInCloud(task.id, { + await client.runTaskInCloud(task.id, undefined, { pendingUserMessage, - runtimeAdapter: "claude", + adapter: "claude", model, - reasoningEffort: supportsReasoning ? reasoning : undefined, + reasoningLevel: supportsReasoning ? reasoning : undefined, initialPermissionMode: mode, autoPublish: usePreferencesStore.getState().autoPublishCloudRuns, rtkEnabled: usePreferencesStore.getState().rtkEnabledCloud, @@ -386,8 +411,12 @@ export default function NewTaskScreen() { const hasContent = !!prompt.trim() || attachments.length > 0; const canSubmit = - hasContent && isRepositorySelectionComplete(selection) && !creating; - const showReasoningPill = modelSupportsReasoning(model); + hasLiveConfig && + hasContent && + isRepositorySelectionComplete(selection) && + !creating; + const reasoningOptions = getReasoningEffortOptions("claude", model) ?? []; + const showReasoningPill = reasoningOptions.length > 0; // Best-effort prewarm; failures are swallowed. `selection.integrationId` is // the GitHub installation id, not a PostHog integration id — the backend @@ -395,7 +424,7 @@ export default function NewTaskScreen() { useWarmTask({ repository: selection.repository, githubIntegrationId: selection.integrationId, - composerIsEmpty: !hasContent, + composerIsEmpty: !hasContent || !hasLiveConfig, runtimeAdapter: "claude", model, reasoningEffort: showReasoningPill ? reasoning : null, @@ -610,14 +639,17 @@ export default function NewTaskScreen() { ? themeColors.accent[11] : themeColors.gray[11], )} - label={modeLabel(mode)} + label={ + EXECUTION_MODES.find((option) => option.id === mode) + ?.name ?? mode + } accent={mode === "plan"} onPress={() => setModeSheetOpen(true)} /> } - label={modelLabel(model)} + label={getModelLabel(modelConfigOption, model)} onPress={() => setModelSheetOpen(true)} /> @@ -626,7 +658,11 @@ export default function NewTaskScreen() { icon={ } - label={reasoningLabel(reasoning)} + label={ + reasoningOptions.find( + (option) => option.value === reasoning, + )?.name ?? reasoning + } onPress={() => setReasoningSheetOpen(true)} /> ) : null} @@ -715,12 +751,12 @@ export default function NewTaskScreen() { }} onClose={() => setModeSheetOpen(false)} options={EXECUTION_MODES.map((executionMode) => ({ - value: executionMode.value, - label: executionMode.label, + value: executionMode.id, + label: executionMode.name, description: executionMode.description, icon: modeIcon( - executionMode.value, - executionMode.value === "plan" + executionMode.id as ExecutionMode, + executionMode.id === "plan" ? themeColors.accent[11] : themeColors.gray[11], 16, @@ -734,15 +770,16 @@ export default function NewTaskScreen() { value={model} onChange={(value) => { setModel(value); - if (!modelSupportsReasoning(value)) { - setReasoning(DEFAULT_REASONING); + if (!isSupportedReasoningEffort("claude", value, reasoning)) { + setReasoning(DEFAULT_REASONING_EFFORT); } }} onClose={() => setModelSheetOpen(false)} - options={MODELS.map((modelOption) => ({ + options={mobileModelOptions.map((modelOption) => ({ value: modelOption.value, label: modelOption.label, description: modelOption.description, + disabled: modelOption.disabled, icon: , }))} /> @@ -752,14 +789,14 @@ export default function NewTaskScreen() { title="Reasoning" value={reasoning} onChange={(value) => { - const next = value as ReasoningEffort; + const next = value as SupportedReasoningEffort; setReasoning(next); usePreferencesStore.getState().setLastUsedReasoningEffort(next); }} onClose={() => setReasoningSheetOpen(false)} - options={REASONING_LEVELS.map((reasoningLevel) => ({ + options={reasoningOptions.map((reasoningLevel) => ({ value: reasoningLevel.value, - label: reasoningLevel.label, + label: reasoningLevel.name, icon: , }))} /> diff --git a/apps/mobile/src/features/inbox/activityLog.test.ts b/apps/mobile/src/features/inbox/activityLog.test.ts index 8b791296be..8ba0a681fe 100644 --- a/apps/mobile/src/features/inbox/activityLog.test.ts +++ b/apps/mobile/src/features/inbox/activityLog.test.ts @@ -1,3 +1,4 @@ +import type { AnySignalReportArtefact } from "@posthog/shared/domain-types"; import { describe, expect, it } from "vitest"; import { attributionLabel, @@ -6,9 +7,8 @@ import { shortSha, taskRunLabel, } from "./activityLog"; -import type { ReportArtefact } from "./types"; -function commit(id: string, createdAt: string): ReportArtefact { +function commit(id: string, createdAt: string): AnySignalReportArtefact { return { id, type: "commit", @@ -22,7 +22,7 @@ function commit(id: string, createdAt: string): ReportArtefact { }; } -function taskRun(id: string, createdAt: string): ReportArtefact { +function taskRun(id: string, createdAt: string): AnySignalReportArtefact { return { id, type: "task_run", @@ -33,13 +33,13 @@ function taskRun(id: string, createdAt: string): ReportArtefact { describe("selectActivityArtefacts", () => { it("keeps only commit and task_run, sorted oldest-first", () => { - const artefacts: ReportArtefact[] = [ + const artefacts: AnySignalReportArtefact[] = [ taskRun("b", "2026-01-02T00:00:00Z"), { id: "x", type: "note", created_at: "2026-01-03T00:00:00Z", - content: {}, + content: { note: "" }, }, commit("a", "2026-01-01T00:00:00Z"), ]; @@ -51,12 +51,12 @@ describe("selectActivityArtefacts", () => { }); it("returns an empty list when there is no activity", () => { - const artefacts: ReportArtefact[] = [ + const artefacts: AnySignalReportArtefact[] = [ { id: "x", type: "note", created_at: "2026-01-01T00:00:00Z", - content: {}, + content: { note: "" }, }, ]; expect(selectActivityArtefacts(artefacts)).toEqual([]); diff --git a/apps/mobile/src/features/inbox/activityLog.ts b/apps/mobile/src/features/inbox/activityLog.ts index 053b84f04b..cb11aefa22 100644 --- a/apps/mobile/src/features/inbox/activityLog.ts +++ b/apps/mobile/src/features/inbox/activityLog.ts @@ -1,12 +1,12 @@ -import type { ReportArtefact } from "./types"; +import type { AnySignalReportArtefact } from "@posthog/shared/domain-types"; export type ActivityArtefact = Extract< - ReportArtefact, + AnySignalReportArtefact, { type: "commit" | "task_run" } >; export function selectActivityArtefacts( - artefacts: ReportArtefact[], + artefacts: AnySignalReportArtefact[], ): ActivityArtefact[] { return artefacts .filter( diff --git a/apps/mobile/src/features/inbox/api.ts b/apps/mobile/src/features/inbox/api.ts index 9b37b82725..1afbcb3c8b 100644 --- a/apps/mobile/src/features/inbox/api.ts +++ b/apps/mobile/src/features/inbox/api.ts @@ -1,343 +1,11 @@ -import { authedFetch, getBaseUrl, getProjectId, HttpError } from "@/lib/api"; -import { logger } from "@/lib/logger"; -import type { DismissalReasonOptionValue } from "./constants"; - -const log = logger.scope("inbox-api"); - -import type { - AvailableSuggestedReviewer, - AvailableSuggestedReviewersResponse, - CommitDiffResponse, - ReportArtefact, - SignalProcessingStateResponse, - SignalReport, - SignalReportArtefactsResponse, - SignalReportSignalsResponse, - SignalReportsQueryParams, - SignalReportsResponse, - SuggestedReviewerWriteEntry, -} from "./types"; - -export async function getSignalReports( - params?: SignalReportsQueryParams, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const url = new URL(`${baseUrl}/api/projects/${projectId}/signals/reports/`); - - if (params?.limit != null) { - url.searchParams.set("limit", String(params.limit)); - } - if (params?.offset != null) { - url.searchParams.set("offset", String(params.offset)); - } - if (params?.status) { - url.searchParams.set("status", params.status); - } - if (params?.ordering) { - url.searchParams.set("ordering", params.ordering); - } - if (params?.source_product) { - url.searchParams.set("source_product", params.source_product); - } - if (params?.suggested_reviewers) { - url.searchParams.set("suggested_reviewers", params.suggested_reviewers); - } - if (params?.priority) { - url.searchParams.set("priority", params.priority); - } - - const response = await authedFetch(url.toString()); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to fetch signal reports", - ); - } - - const data = await response.json(); - return { - results: data.results ?? [], - count: data.count ?? data.results?.length ?? 0, - }; -} - -export async function getSignalReport( - reportId: string, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/signals/reports/${reportId}/`, - ); - - if (response.status === 404 || response.status === 403) { - return null; - } - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to fetch signal report", - ); - } - - return await response.json(); -} - -export async function getSignalProcessingState(): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/signals/processing_state/`, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to fetch signal processing state", - ); - } - - return await response.json(); -} - -export async function getAvailableSuggestedReviewers( - query?: string, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const url = new URL( - `${baseUrl}/api/projects/${projectId}/signals/reports/available_reviewers/`, - ); - - if (query?.trim()) { - url.searchParams.set("query", query.trim()); - } - - const response = await authedFetch(url.toString()); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to fetch available suggested reviewers", - ); - } - - // API returns a dict keyed by UUID: { "uuid": { name, email, github_login } } - const data = await response.json(); - const results = Object.entries(data) - .map(([uuid, value]) => { - if (typeof value !== "object" || value === null) return null; - const v = value as Record; - return { - uuid, - name: typeof v.name === "string" ? v.name : "", - email: typeof v.email === "string" ? v.email : "", - github_login: typeof v.github_login === "string" ? v.github_login : "", - }; - }) - .filter((r): r is AvailableSuggestedReviewer => r !== null); - - return { results, count: results.length }; -} - -export async function getSignalReportArtefacts( - reportId: string, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/signals/reports/${reportId}/artefacts/`, - ); - - if (!response.ok) { - const body = await response.text().catch(() => ""); - log.warn("Failed to fetch report artefacts", { - reportId, - status: response.status, - body: body.slice(0, 500), - }); - return { results: [], count: 0 }; - } - - const data = await response.json(); - const results: ReportArtefact[] = data.results ?? []; - return { results, count: data.count ?? results.length }; -} - -/** Fetch a commit artefact's diff against its parent (lazily, on demand). */ -export async function getCommitDiff( - reportId: string, - artefactId: string, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/signals/reports/${reportId}/artefacts/${artefactId}/diff/`, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Couldn’t load the diff", - ); - } - - const data = await response.json(); - return { - diff: typeof data.diff === "string" ? data.diff : "", - truncated: data.truncated === true, - }; -} - -/** Replace the content of a report artefact (full PUT, not a partial update). */ -export async function updateSignalReportArtefact( - reportId: string, - artefactId: string, - content: SuggestedReviewerWriteEntry[], -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/signals/reports/${reportId}/artefacts/${artefactId}/`, - { - method: "PUT", - body: JSON.stringify({ content }), - }, - ); - - if (!response.ok) { - const errorText = await response.text().catch(() => ""); - throw new HttpError( - response.status, - response.statusText, - errorText || "Failed to update suggested reviewers", - ); - } -} - -export async function getSignalReportSignals( - reportId: string, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/signals/reports/${reportId}/signals/`, - ); - - if (!response.ok) { - log.warn("Failed to fetch report signals", { - reportId, - status: response.status, - }); - return { signals: [] }; - } - - const data = await response.json(); - return { signals: data.signals ?? [] }; -} +import { extractRepoSelectionRepository } from "@posthog/core/inbox/artefacts"; +import { getPostHogApiClient } from "@/lib/posthogApiClient"; /** Resolve the repository associated with a signal report via its repo_selection artefact. */ export async function getReportRepository( reportId: string, ): Promise { - const { results } = await getSignalReportArtefacts(reportId); - const repoArtefact = results.find((a) => a.type === "repo_selection"); - if (!repoArtefact) return null; - - let parsed: unknown = repoArtefact.content; - if (typeof parsed === "string") { - try { - parsed = JSON.parse(parsed); - } catch { - return (parsed as string).toLowerCase(); - } - } - - if (typeof parsed === "object" && parsed !== null) { - const repo = - (parsed as Record).repository ?? - (parsed as Record).repo; - if (typeof repo === "string") return repo.toLowerCase(); - } - - return null; -} - -export interface DismissSignalReportInput { - reason: DismissalReasonOptionValue; - note?: string; -} - -export async function dismissSignalReport( - reportId: string, - input: DismissSignalReportInput, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/signals/reports/${reportId}/state/`, - { - method: "POST", - body: JSON.stringify({ - state: "suppressed", - dismissal_reason: input.reason, - ...(input.note?.trim() ? { dismissal_note: input.note.trim() } : {}), - }), - }, - ); - - if (!response.ok) { - const errorText = await response.text().catch(() => ""); - throw new HttpError( - response.status, - response.statusText, - errorText || "Failed to dismiss signal report", - ); - } - - return await response.json(); -} - -/** Re-queue a dismissed report into the inbox via the `potential` transition. */ -export async function restoreSignalReport( - reportId: string, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/signals/reports/${reportId}/state/`, - { - method: "POST", - body: JSON.stringify({ state: "potential" }), - }, - ); - - if (!response.ok) { - const errorText = await response.text().catch(() => ""); - throw new HttpError( - response.status, - response.statusText, - errorText || "Failed to restore signal report", - ); - } - - return await response.json(); + const { results } = + await getPostHogApiClient().getSignalReportArtefacts(reportId); + return extractRepoSelectionRepository(results); } diff --git a/apps/mobile/src/features/inbox/components/ArchivedReportList.tsx b/apps/mobile/src/features/inbox/components/ArchivedReportList.tsx index abe60b8a46..a0aed9bc98 100644 --- a/apps/mobile/src/features/inbox/components/ArchivedReportList.tsx +++ b/apps/mobile/src/features/inbox/components/ArchivedReportList.tsx @@ -1,4 +1,7 @@ import { Text } from "@components/text"; +import { inboxStatusLabel } from "@posthog/core/inbox/reportPresentation"; +import { dismissalReasonLabel } from "@posthog/shared"; +import type { SignalReport } from "@posthog/shared/domain-types"; import * as Haptics from "expo-haptics"; import { ArrowCounterClockwise, Tray } from "phosphor-react-native"; import { memo, useCallback, useEffect, useRef, useState } from "react"; @@ -11,13 +14,7 @@ import { } from "react-native"; import { useThemeColors } from "@/lib/theme"; import { useArchivedReports, useRestoreReport } from "../hooks/useInboxReports"; -import type { SignalReport } from "../types"; -import { - dismissalReasonLabel, - formatReportTimestamp, - inboxStatusLabel, - isRestorableReport, -} from "../utils"; +import { formatReportTimestamp, isRestorableReport } from "../utils"; interface ArchivedReportListProps { onReportPress?: (report: SignalReport) => void; diff --git a/apps/mobile/src/features/inbox/components/ArtefactCommit.tsx b/apps/mobile/src/features/inbox/components/ArtefactCommit.tsx index fb176374a6..19f4cc2510 100644 --- a/apps/mobile/src/features/inbox/components/ArtefactCommit.tsx +++ b/apps/mobile/src/features/inbox/components/ArtefactCommit.tsx @@ -1,11 +1,11 @@ import { Text } from "@components/text"; +import type { CommitContent } from "@posthog/shared/domain-types"; import { CaretDown, CaretRight } from "phosphor-react-native"; import { useState } from "react"; import { ActivityIndicator, Pressable, View } from "react-native"; import { useThemeColors } from "@/lib/theme"; import { shortSha } from "../activityLog"; import { useCommitDiff } from "../hooks/useInboxReports"; -import type { CommitContent } from "../types"; import { DiffBlock } from "./DiffBlock"; export function ArtefactCommit({ diff --git a/apps/mobile/src/features/inbox/components/ArtefactTaskRun.tsx b/apps/mobile/src/features/inbox/components/ArtefactTaskRun.tsx index bea2436c62..2c2b004a58 100644 --- a/apps/mobile/src/features/inbox/components/ArtefactTaskRun.tsx +++ b/apps/mobile/src/features/inbox/components/ArtefactTaskRun.tsx @@ -1,11 +1,11 @@ import { Text } from "@components/text"; +import type { TaskRunArtefactContent } from "@posthog/shared/domain-types"; import { useRouter } from "expo-router"; import { CaretRight } from "phosphor-react-native"; import { Pressable, View } from "react-native"; import { useTask } from "@/features/tasks"; import { useThemeColors } from "@/lib/theme"; import { taskRunLabel } from "../activityLog"; -import type { TaskRunArtefactContent } from "../types"; export function ArtefactTaskRun({ content, diff --git a/apps/mobile/src/features/inbox/components/DismissReportSheet.tsx b/apps/mobile/src/features/inbox/components/DismissReportSheet.tsx index 65fc913399..56253e4a2c 100644 --- a/apps/mobile/src/features/inbox/components/DismissReportSheet.tsx +++ b/apps/mobile/src/features/inbox/components/DismissReportSheet.tsx @@ -1,4 +1,8 @@ import { Text } from "@components/text"; +import { + DISMISSAL_REASON_OPTIONS, + type DismissalReasonOptionValue, +} from "@posthog/shared"; import * as Haptics from "expo-haptics"; import { Check } from "phosphor-react-native"; import { useEffect, useState } from "react"; @@ -14,10 +18,6 @@ import { } from "react-native"; import { useScreenInsets } from "@/hooks/useScreenInsets"; import { useThemeColors } from "@/lib/theme"; -import { - DISMISSAL_REASON_OPTIONS, - type DismissalReasonOptionValue, -} from "../constants"; import { useDismissReport } from "../hooks/useInboxReports"; export interface DismissReportResult { diff --git a/apps/mobile/src/features/inbox/components/EditReviewersSheet.tsx b/apps/mobile/src/features/inbox/components/EditReviewersSheet.tsx index 2e30695576..ad1d1e013c 100644 --- a/apps/mobile/src/features/inbox/components/EditReviewersSheet.tsx +++ b/apps/mobile/src/features/inbox/components/EditReviewersSheet.tsx @@ -3,6 +3,10 @@ import { buildReviewerOptions, reviewerMatchesAvailable, } from "@posthog/core/inbox/artefacts"; +import type { + AvailableSuggestedReviewer, + SuggestedReviewer, +} from "@posthog/shared/domain-types"; import { MagnifyingGlass } from "phosphor-react-native"; import { useMemo, useState } from "react"; import { @@ -17,7 +21,6 @@ import { useDebouncedValue } from "@/hooks/useDebouncedValue"; import { useScreenInsets } from "@/hooks/useScreenInsets"; import { useThemeColors } from "@/lib/theme"; import { useAvailableSuggestedReviewers } from "../hooks/useInboxReports"; -import type { AvailableSuggestedReviewer, SuggestedReviewer } from "../types"; import { ReviewerOptionRow } from "./ReviewerOptionRow"; interface EditReviewersSheetProps { diff --git a/apps/mobile/src/features/inbox/components/FilterSheet.tsx b/apps/mobile/src/features/inbox/components/FilterSheet.tsx index 11ed58e5af..47a22b36d8 100644 --- a/apps/mobile/src/features/inbox/components/FilterSheet.tsx +++ b/apps/mobile/src/features/inbox/components/FilterSheet.tsx @@ -1,15 +1,13 @@ import { Text } from "@components/text"; -import { EXTERNAL_INBOX_SOURCES } from "@posthog/shared"; +import { INBOX_PIPELINE_STATUSES } from "@posthog/core/inbox/reportFiltering"; +import { inboxStatusLabel } from "@posthog/core/inbox/reportPresentation"; +import { EXTERNAL_INBOX_SOURCES, type SourceProduct } from "@posthog/shared"; +import type { SignalReportPriority } from "@posthog/shared/domain-types"; import { Check } from "phosphor-react-native"; import { Modal, Pressable, ScrollView, View } from "react-native"; import { useScreenInsets } from "@/hooks/useScreenInsets"; import { useThemeColors } from "@/lib/theme"; -import { - type SourceProduct, - useInboxFilterStore, -} from "../stores/inboxFilterStore"; -import type { SignalReportPriority, SignalReportStatus } from "../types"; -import { inboxStatusLabel } from "../utils"; +import { useInboxFilterStore } from "../stores/inboxFilterStore"; interface FilterSheetProps { visible: boolean; @@ -29,15 +27,6 @@ const SORT_OPTIONS: SortOption[] = [ { label: "Oldest first", field: "created_at", direction: "asc" }, ]; -const FILTERABLE_STATUSES: SignalReportStatus[] = [ - "ready", - "pending_input", - "in_progress", - "failed", - "candidate", - "potential", -]; - function useStatusDotColors(): Record { const themeColors = useThemeColors(); return { @@ -74,9 +63,11 @@ export const SOURCE_PRODUCT_OPTIONS: { value: SourceProduct; label: string }[] = { value: "session_replay", label: "Session replay" }, { value: "error_tracking", label: "Error tracking" }, { value: "llm_analytics", label: "AI observability" }, + { value: "github", label: "GitHub" }, + { value: "linear", label: "Linear" }, + { value: "zendesk", label: "Zendesk" }, { value: "conversations", label: "Conversations" }, { value: "signals_scout", label: "Scout" }, - { value: "health_checks", label: "Health checks" }, ...EXTERNAL_INBOX_SOURCES.map((source) => ({ value: source.product, label: source.label, @@ -141,7 +132,7 @@ export function FilterSheet({ visible, onClose }: FilterSheetProps) { const hasActiveFilters = sourceProductFilter.length > 0 || priorityFilter.length > 0 || - statusFilter.length < FILTERABLE_STATUSES.length; + statusFilter.length < INBOX_PIPELINE_STATUSES.length; return ( - {FILTERABLE_STATUSES.map((status) => ( + {INBOX_PIPELINE_STATUSES.map((status) => ( s.currentIndex); @@ -240,7 +245,8 @@ export function TinderView({ // 3. Create the task const prompt = `Act on this signal report. Investigate the root cause, implement the fix, and open a PR if appropriate.\n\n${report.summary ?? ""}`; - const task = await getPostHogApiClient().createTask({ + const client = getPostHogApiClient(); + const task = await client.createTask({ description: prompt, title: prompt.slice(0, 255), repository: match?.repository ?? repo ?? undefined, @@ -251,10 +257,10 @@ export function TinderView({ } as CreateTaskOptions); // 4. Run it - await runTaskInCloud(task.id, { + await client.runTaskInCloud(task.id, undefined, { pendingUserMessage: prompt, - runtimeAdapter: "claude", - model: DEFAULT_MODEL, + adapter: "claude", + model, initialPermissionMode: "plan", runSource: "signal_report", signalReportId: report.id, @@ -276,6 +282,7 @@ export function TinderView({ }, [ repositoryOptions, + model, showToastPending, showToastDone, acceptReport, @@ -489,7 +496,7 @@ export function TinderView({ setExpandedReport(null); }} className="h-16 w-16 items-center justify-center rounded-full border-2 border-status-success bg-status-success/10 active:bg-status-success/20" - disabled={creating} + disabled={creating || !hasLiveConfig} hitSlop={8} > {creating ? ( diff --git a/apps/mobile/src/features/inbox/constants.ts b/apps/mobile/src/features/inbox/constants.ts deleted file mode 100644 index f15aca7c5b..0000000000 --- a/apps/mobile/src/features/inbox/constants.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Reasons offered when the user dismisses a signal report. - * Mirrors apps/code/src/shared/dismissalReasons.ts. - */ -export const DISMISSAL_REASON_OPTIONS = [ - { - value: "already_fixed", - label: "Already fixed", - snoozesInsteadOfDismiss: true, - }, - { value: "report_unclear", label: "Report is unclear to me" }, - { value: "analysis_wrong", label: "Agent's analysis is wrong" }, - { value: "wontfix_intentional", label: "Won't fix — intentional behavior" }, - { - value: "wontfix_irrelevant", - label: "Won't fix — issue is real but insignificant", - }, - { value: "other", label: "Something else…" }, -] as const; - -export type DismissalReasonOptionValue = - (typeof DISMISSAL_REASON_OPTIONS)[number]["value"]; diff --git a/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.test.ts b/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.test.ts index 2e970def6b..16cd3b1f64 100644 --- a/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.test.ts +++ b/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.test.ts @@ -9,8 +9,8 @@ vi.mock("posthog-react-native", () => ({ usePostHog: () => null, })); +import type { SignalReport } from "@posthog/shared/domain-types"; import { ANALYTICS_EVENTS, type Analytics } from "@/lib/analytics"; -import type { SignalReport } from "../types"; import { type InboxEngagementTracker, type UseInboxEngagementTrackerOptions, diff --git a/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.ts b/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.ts index 67ac03e03a..323f03a938 100644 --- a/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.ts +++ b/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.ts @@ -1,3 +1,4 @@ +import type { SignalReport } from "@posthog/shared/domain-types"; import { useCallback, useEffect, useRef } from "react"; import { ANALYTICS_EVENTS, @@ -7,7 +8,6 @@ import { type InboxReportCloseMethod, type InboxReportOpenMethod, } from "@/lib/analytics"; -import type { SignalReport } from "../types"; interface OpenInfo { reportId: string; diff --git a/apps/mobile/src/features/inbox/hooks/useInboxReports.test.ts b/apps/mobile/src/features/inbox/hooks/useInboxReports.test.ts index 407730a054..542886a407 100644 --- a/apps/mobile/src/features/inbox/hooks/useInboxReports.test.ts +++ b/apps/mobile/src/features/inbox/hooks/useInboxReports.test.ts @@ -1,3 +1,7 @@ +import type { + SignalReport, + SignalReportsResponse, +} from "@posthog/shared/domain-types"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createElement } from "react"; import { act, create } from "react-test-renderer"; @@ -11,12 +15,13 @@ const getAvailableSuggestedReviewers = vi.fn(async (_query?: string) => ({ results: [], count: 0, })); -vi.mock("../api", () => ({ - getAvailableSuggestedReviewers: (query?: string) => - getAvailableSuggestedReviewers(query), +vi.mock("@/lib/posthogApiClient", () => ({ + getPostHogApiClient: () => ({ + getAvailableSuggestedReviewers: (query?: string) => + getAvailableSuggestedReviewers(query), + }), })); -import type { SignalReport, SignalReportsResponse } from "../types"; import { getReportsNextPageParam, useAvailableSuggestedReviewers, diff --git a/apps/mobile/src/features/inbox/hooks/useInboxReports.ts b/apps/mobile/src/features/inbox/hooks/useInboxReports.ts index ac37c738d1..e262300bf9 100644 --- a/apps/mobile/src/features/inbox/hooks/useInboxReports.ts +++ b/apps/mobile/src/features/inbox/hooks/useInboxReports.ts @@ -7,28 +7,7 @@ import { INBOX_DISMISSED_STATUS_FILTER, INBOX_REFETCH_INTERVAL_MS, } from "@posthog/core/inbox/reportFiltering"; -import { - useInfiniteQuery, - useMutation, - useQuery, - useQueryClient, -} from "@tanstack/react-query"; -import { useMemo } from "react"; -import { useAuthStore } from "@/features/auth"; -import { - type DismissSignalReportInput, - dismissSignalReport, - getAvailableSuggestedReviewers, - getCommitDiff, - getSignalProcessingState, - getSignalReport, - getSignalReportArtefacts, - getSignalReportSignals, - getSignalReports, - restoreSignalReport, - updateSignalReportArtefact, -} from "../api"; -import { useInboxFilterStore } from "../stores/inboxFilterStore"; +import type { DismissalReasonOptionValue } from "@posthog/shared"; import type { AvailableSuggestedReviewersResponse, CommitDiffResponse, @@ -39,8 +18,19 @@ import type { SignalReportsQueryParams, SignalReportsResponse, SuggestedReviewer, + SuggestedReviewersArtefact, SuggestedReviewerWriteEntry, -} from "../types"; +} from "@posthog/shared/domain-types"; +import { + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import { useMemo } from "react"; +import { useAuthStore } from "@/features/auth"; +import { getPostHogApiClient } from "@/lib/posthogApiClient"; +import { useInboxFilterStore } from "../stores/inboxFilterStore"; import { isRestorableReport } from "../utils"; export const inboxKeys = { @@ -97,7 +87,7 @@ export function useInboxReports(options?: { enabled?: boolean }) { const query = useInfiniteQuery({ queryKey: inboxKeys.list(params), queryFn: ({ pageParam }) => - getSignalReports({ + getPostHogApiClient().getSignalReports({ ...params, limit: REPORTS_PAGE_SIZE, offset: pageParam, @@ -136,7 +126,7 @@ export function useArchivedReports(options?: { enabled?: boolean }) { const query = useQuery({ queryKey: inboxKeys.archived(params), - queryFn: () => getSignalReports(params), + queryFn: () => getPostHogApiClient().getSignalReports(params), enabled: !!projectId && !!oauthAccessToken && (options?.enabled ?? true), }); @@ -157,7 +147,7 @@ export function useInboxReport(reportId: string | null) { queryKey: inboxKeys.detail(reportId ?? ""), queryFn: () => { if (!reportId) throw new Error("reportId is required"); - return getSignalReport(reportId); + return getPostHogApiClient().getSignalReport(reportId); }, enabled: !!projectId && !!oauthAccessToken && !!reportId, }); @@ -168,7 +158,7 @@ export function useSignalProcessingState(options?: { enabled?: boolean }) { return useQuery({ queryKey: inboxKeys.processingState, - queryFn: () => getSignalProcessingState(), + queryFn: () => getPostHogApiClient().getSignalProcessingState(), enabled: !!projectId && !!oauthAccessToken && (options?.enabled ?? true), refetchInterval: INBOX_REFETCH_INTERVAL_MS, }); @@ -183,7 +173,8 @@ export function useAvailableSuggestedReviewers(options?: { return useQuery({ queryKey: [...inboxKeys.all, "available-reviewers", query] as const, - queryFn: () => getAvailableSuggestedReviewers(query || undefined), + queryFn: () => + getPostHogApiClient().getAvailableSuggestedReviewers(query || undefined), enabled: !!projectId && !!oauthAccessToken && (options?.enabled ?? true), staleTime: 5 * 60 * 1000, // Only poll the unfiltered list; search terms are transient and each one @@ -199,7 +190,7 @@ export function useInboxReportArtefacts(reportId: string | null) { queryKey: inboxKeys.artefacts(reportId ?? ""), queryFn: () => { if (!reportId) throw new Error("reportId is required"); - return getSignalReportArtefacts(reportId); + return getPostHogApiClient().getSignalReportArtefacts(reportId); }, enabled: !!projectId && !!oauthAccessToken && !!reportId, // The log is a live work record — agents append artefacts while a report @@ -218,7 +209,7 @@ export function useCommitDiff( return useQuery({ queryKey: inboxKeys.commitDiff(reportId, artefactId), - queryFn: () => getCommitDiff(reportId, artefactId), + queryFn: () => getPostHogApiClient().getCommitDiff(reportId, artefactId), // A commit's diff is immutable, so only fetch once expanded and never retry. enabled: enabled && !!projectId && !!oauthAccessToken, staleTime: 5 * 60_000, @@ -233,7 +224,7 @@ export function useInboxReportSignals(reportId: string | null) { queryKey: inboxKeys.signals(reportId ?? ""), queryFn: () => { if (!reportId) throw new Error("reportId is required"); - return getSignalReportSignals(reportId); + return getPostHogApiClient().getSignalReportSignals(reportId); }, enabled: !!projectId && !!oauthAccessToken && !!reportId, }); @@ -256,7 +247,9 @@ export function useUpdateSuggestedReviewers(reportId: string) { { previous: SignalReportArtefactsResponse | undefined } >({ mutationFn: ({ artefactId, content }) => - updateSignalReportArtefact(reportId, artefactId, content), + getPostHogApiClient() + .updateSignalReportArtefact(reportId, artefactId, content) + .then(() => undefined), onMutate: async ({ artefactId, optimisticReviewers }) => { await queryClient.cancelQueries({ queryKey }); const previous = @@ -264,12 +257,20 @@ export function useUpdateSuggestedReviewers(reportId: string) { if (previous) { queryClient.setQueryData(queryKey, { ...previous, - results: previous.results.map((artefact) => - artefact.id === artefactId && - artefact.type === "suggested_reviewers" - ? { ...artefact, content: optimisticReviewers } - : artefact, - ), + results: previous.results.map((artefact) => { + if ( + artefact.id === artefactId && + artefact.type === "suggested_reviewers" + ) { + const updatedArtefact: SuggestedReviewersArtefact = { + ...artefact, + type: "suggested_reviewers", + content: optimisticReviewers, + }; + return updatedArtefact; + } + return artefact; + }), }); } return { previous }; @@ -288,8 +289,17 @@ export function useUpdateSuggestedReviewers(reportId: string) { export function useDismissReport(reportId: string) { const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (input) => dismissSignalReport(reportId, input), + return useMutation< + SignalReport, + Error, + { reason: DismissalReasonOptionValue; note?: string } + >({ + mutationFn: (input) => + getPostHogApiClient().updateSignalReportState(reportId, { + state: "suppressed", + dismissal_reason: input.reason, + ...(input.note?.trim() ? { dismissal_note: input.note.trim() } : {}), + }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: inboxKeys.detail(reportId) }); queryClient.invalidateQueries({ queryKey: inboxKeys.all }); @@ -305,11 +315,12 @@ export function useRestoreReport() { // report. return useMutation({ mutationFn: async (reportId) => { - const current = await getSignalReport(reportId); + const client = getPostHogApiClient(); + const current = await client.getSignalReport(reportId); if (current && !isRestorableReport(current)) { return false; } - await restoreSignalReport(reportId); + await client.updateSignalReportState(reportId, { state: "potential" }); return true; }, onSuccess: () => { diff --git a/apps/mobile/src/features/inbox/stores/inboxFilterStore.test.ts b/apps/mobile/src/features/inbox/stores/inboxFilterStore.test.ts index 935407c640..3bfc12271b 100644 --- a/apps/mobile/src/features/inbox/stores/inboxFilterStore.test.ts +++ b/apps/mobile/src/features/inbox/stores/inboxFilterStore.test.ts @@ -1,3 +1,4 @@ +import type { SourceProduct } from "@posthog/shared"; import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("@react-native-async-storage/async-storage", () => ({ @@ -8,7 +9,7 @@ vi.mock("@react-native-async-storage/async-storage", () => ({ }, })); -import { type SourceProduct, useInboxFilterStore } from "./inboxFilterStore"; +import { useInboxFilterStore } from "./inboxFilterStore"; describe("inboxFilterStore", () => { beforeEach(() => { diff --git a/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts b/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts index a0536417da..1f779d7dd7 100644 --- a/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts +++ b/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts @@ -1,13 +1,13 @@ import { INBOX_PIPELINE_STATUSES } from "@posthog/core/inbox/reportFiltering"; import type { SourceProduct } from "@posthog/shared"; -import AsyncStorage from "@react-native-async-storage/async-storage"; -import { create } from "zustand"; -import { createJSONStorage, persist } from "zustand/middleware"; import type { SignalReportOrderingField, SignalReportPriority, SignalReportStatus, -} from "../types"; +} from "@posthog/shared/domain-types"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; type SortField = Extract< SignalReportOrderingField, @@ -16,12 +16,6 @@ type SortField = Extract< type SortDirection = "asc" | "desc"; -export type { SourceProduct }; - -export const DEFAULT_STATUS_FILTER: SignalReportStatus[] = [ - ...INBOX_PIPELINE_STATUSES, -]; - interface InboxFilterState { sortField: SortField; sortDirection: SortDirection; @@ -51,7 +45,7 @@ export const useInboxFilterStore = create()( (set) => ({ sortField: "priority", sortDirection: "asc", - statusFilter: DEFAULT_STATUS_FILTER, + statusFilter: [...INBOX_PIPELINE_STATUSES], sourceProductFilter: [], suggestedReviewerFilter: [], priorityFilter: [], @@ -100,7 +94,7 @@ export const useInboxFilterStore = create()( set({ priorityFilter: Array.from(new Set(priorities)) }), resetFilters: () => set({ - statusFilter: DEFAULT_STATUS_FILTER, + statusFilter: [...INBOX_PIPELINE_STATUSES], sourceProductFilter: [], suggestedReviewerFilter: [], priorityFilter: [], diff --git a/apps/mobile/src/features/inbox/stores/inboxStore.ts b/apps/mobile/src/features/inbox/stores/inboxStore.ts index 7bdb2ec206..32120c07a2 100644 --- a/apps/mobile/src/features/inbox/stores/inboxStore.ts +++ b/apps/mobile/src/features/inbox/stores/inboxStore.ts @@ -1,5 +1,5 @@ +import type { SignalReportOrderingField } from "@posthog/shared/domain-types"; import { create } from "zustand"; -import type { SignalReportOrderingField } from "../types"; type OrderDirection = "asc" | "desc"; diff --git a/apps/mobile/src/features/inbox/types.ts b/apps/mobile/src/features/inbox/types.ts deleted file mode 100644 index 248ab5f86b..0000000000 --- a/apps/mobile/src/features/inbox/types.ts +++ /dev/null @@ -1,209 +0,0 @@ -import type { DismissalReasonOptionValue } from "./constants"; - -export type SignalReportStatus = - | "potential" - | "candidate" - | "in_progress" - | "ready" - | "failed" - | "pending_input" - | "resolved" - | "suppressed" - | "deleted"; - -export type SignalReportPriority = "P0" | "P1" | "P2" | "P3" | "P4"; - -export type SignalReportActionability = - | "immediately_actionable" - | "requires_human_input" - | "not_actionable"; - -export interface SignalReport { - id: string; - title: string | null; - summary: string | null; - status: SignalReportStatus; - total_weight: number; - signal_count: number; - signals_at_run?: number; - created_at: string; - updated_at: string; - artefact_count: number; - priority?: SignalReportPriority | null; - actionability?: SignalReportActionability | null; - already_addressed?: boolean | null; - dismissal_reason?: DismissalReasonOptionValue | null; - dismissal_note?: string | null; - is_suggested_reviewer?: boolean; - source_products?: string[]; - implementation_pr_url?: string | null; -} - -export interface SignalReportsResponse { - results: SignalReport[]; - count: number; -} - -export type SignalReportOrderingField = - | "priority" - | "signal_count" - | "total_weight" - | "created_at" - | "updated_at"; - -export interface SignalReportsQueryParams { - limit?: number; - offset?: number; - status?: string; - ordering?: string; - source_product?: string; - suggested_reviewers?: string; - priority?: string; -} - -export interface SignalProcessingStateResponse { - paused_until: string | null; -} - -export interface AvailableSuggestedReviewer { - uuid: string; - name: string; - email: string; - github_login: string; -} - -export interface AvailableSuggestedReviewersResponse { - results: AvailableSuggestedReviewer[]; - count: number; -} - -export interface Signal { - signal_id: string; - content: string; - source_product: string; - source_type: string; - source_id: string; - weight: number; - timestamp: string; - extra: Record; -} - -export interface SignalFindingContent { - signal_id: string; - relevant_code_paths: string[]; - relevant_commit_hashes: Record; - data_queried: string; - verified: boolean; -} - -export interface PriorityJudgmentContent { - explanation: string; - priority: SignalReportPriority; -} - -export interface ActionabilityJudgmentContent { - explanation: string; - actionability: SignalReportActionability; - already_addressed: boolean; -} - -export interface SuggestedReviewerCommit { - sha: string; - url: string; - reason: string; -} - -export interface SuggestedReviewerUser { - id: number; - uuid: string; - email: string; - first_name: string; - last_name: string; -} - -export interface SuggestedReviewer { - github_login: string; - github_name: string | null; - relevant_commits: SuggestedReviewerCommit[]; - user: SuggestedReviewerUser | null; -} - -export interface SuggestedReviewersArtefact { - id: string; - type: "suggested_reviewers"; - created_at: string; - content: SuggestedReviewer[]; -} - -/** - * Write shape for replacing the suggested_reviewers artefact. The server - * canonicalizes to a lowercase `github_login`, with `user_uuid` winning when - * both are supplied. - */ -export interface SuggestedReviewerWriteEntry { - github_login?: string; - user_uuid?: string; - github_name?: string; -} - -export interface ArtefactUser { - uuid?: string; - email: string; - first_name?: string; - last_name?: string; -} - -export interface CommitContent { - repository: string; - branch: string; - commit_sha: string; - message: string; - note?: string | null; -} - -export interface TaskRunArtefactContent { - task_id: string; - product: string; - type: string; -} - -export interface CommitDiffResponse { - diff: string; - truncated: boolean; -} - -/** - * Fields shared by every artefact row. `created_by` / `task_id` carry - * attribution: at most one is set — `created_by` for user writes, `task_id` - * for agent writes, neither for system writes. - */ -interface BaseArtefact { - id: string; - created_at: string; - created_by?: ArtefactUser | null; - task_id?: string | null; -} - -export type ReportArtefact = - | (BaseArtefact & { - type: "priority_judgment"; - content: PriorityJudgmentContent; - }) - | (BaseArtefact & { - type: "actionability_judgment"; - content: ActionabilityJudgmentContent; - }) - | (BaseArtefact & { type: "signal_finding"; content: SignalFindingContent }) - | (BaseArtefact & { type: "commit"; content: CommitContent }) - | (BaseArtefact & { type: "task_run"; content: TaskRunArtefactContent }) - | (BaseArtefact & SuggestedReviewersArtefact) - | (BaseArtefact & { type: string; content: unknown }); - -export interface SignalReportArtefactsResponse { - results: ReportArtefact[]; - count: number; -} - -export interface SignalReportSignalsResponse { - signals: Signal[]; -} diff --git a/apps/mobile/src/features/inbox/utils.test.ts b/apps/mobile/src/features/inbox/utils.test.ts index ad56b19305..ce8b2b43af 100644 --- a/apps/mobile/src/features/inbox/utils.test.ts +++ b/apps/mobile/src/features/inbox/utils.test.ts @@ -1,9 +1,20 @@ +import { + buildArchiveListOrdering, + buildPriorityFilterParam, + buildSignalReportListOrdering, + INBOX_PIPELINE_STATUSES, +} from "@posthog/core/inbox/reportFiltering"; +import { formatSignalReportSummaryMarkdown } from "@posthog/core/inbox/reportPresentation"; +import { dismissalReasonLabel } from "@posthog/shared"; +import type { + Signal, + SignalReport, + SignalReportOrderingField, + SignalReportStatus, +} from "@posthog/shared/domain-types"; import { describe, expect, it } from "vitest"; -import type { Signal, SignalReport, SignalReportStatus } from "./types"; import { buildInboxViewedProperties, - dismissalReasonLabel, - formatSignalReportSummaryMarkdown, isRestorableReport, sourceLine, } from "./utils"; @@ -21,15 +32,6 @@ function signal(source_product: string, source_type: string): Signal { }; } -const DEFAULT_STATUS_FILTER: SignalReportStatus[] = [ - "ready", - "pending_input", - "in_progress", - "failed", - "candidate", - "potential", -]; - function makeReport( partial: Partial & Pick, ): SignalReport { @@ -88,10 +90,10 @@ describe("buildInboxViewedProperties", () => { it("emits zero counts for an empty list", () => { const props = buildInboxViewedProperties([], 0, { sourceProductFilter: [], - statusFilter: DEFAULT_STATUS_FILTER, + statusFilter: INBOX_PIPELINE_STATUSES, suggestedReviewerFilter: [], priorityFilter: [], - defaultStatusFilter: DEFAULT_STATUS_FILTER, + defaultStatusFilter: INBOX_PIPELINE_STATUSES, }); expect(props).toMatchObject({ report_count: 0, @@ -137,10 +139,10 @@ describe("buildInboxViewedProperties", () => { const props = buildInboxViewedProperties(reports, 4, { sourceProductFilter: [], - statusFilter: DEFAULT_STATUS_FILTER, + statusFilter: INBOX_PIPELINE_STATUSES, suggestedReviewerFilter: [], priorityFilter: [], - defaultStatusFilter: DEFAULT_STATUS_FILTER, + defaultStatusFilter: INBOX_PIPELINE_STATUSES, }); expect(props.report_count).toBe(4); @@ -161,36 +163,36 @@ describe("buildInboxViewedProperties", () => { statusFilter: ["ready"], suggestedReviewerFilter: [], priorityFilter: [], - defaultStatusFilter: DEFAULT_STATUS_FILTER, + defaultStatusFilter: INBOX_PIPELINE_STATUSES, }); expect(narrowed.has_active_filters).toBe(true); expect(narrowed.status_filter_count).toBe(1); const sourced = buildInboxViewedProperties([], 0, { sourceProductFilter: ["error_tracking"], - statusFilter: DEFAULT_STATUS_FILTER, + statusFilter: INBOX_PIPELINE_STATUSES, suggestedReviewerFilter: [], priorityFilter: [], - defaultStatusFilter: DEFAULT_STATUS_FILTER, + defaultStatusFilter: INBOX_PIPELINE_STATUSES, }); expect(sourced.has_active_filters).toBe(true); expect(sourced.source_product_filter).toEqual(["error_tracking"]); const reviewer = buildInboxViewedProperties([], 0, { sourceProductFilter: [], - statusFilter: DEFAULT_STATUS_FILTER, + statusFilter: INBOX_PIPELINE_STATUSES, suggestedReviewerFilter: ["uuid-1"], priorityFilter: [], - defaultStatusFilter: DEFAULT_STATUS_FILTER, + defaultStatusFilter: INBOX_PIPELINE_STATUSES, }); expect(reviewer.has_active_filters).toBe(true); const prioritized = buildInboxViewedProperties([], 0, { sourceProductFilter: [], - statusFilter: DEFAULT_STATUS_FILTER, + statusFilter: INBOX_PIPELINE_STATUSES, suggestedReviewerFilter: [], priorityFilter: ["P0"], - defaultStatusFilter: DEFAULT_STATUS_FILTER, + defaultStatusFilter: INBOX_PIPELINE_STATUSES, }); expect(prioritized.has_active_filters).toBe(true); }); @@ -198,15 +200,89 @@ describe("buildInboxViewedProperties", () => { it("treats a reordered default status set as not filtered", () => { const props = buildInboxViewedProperties([], 0, { sourceProductFilter: [], - statusFilter: [...DEFAULT_STATUS_FILTER].reverse(), + statusFilter: [...INBOX_PIPELINE_STATUSES].reverse(), suggestedReviewerFilter: [], priorityFilter: [], - defaultStatusFilter: DEFAULT_STATUS_FILTER, + defaultStatusFilter: INBOX_PIPELINE_STATUSES, }); expect(props.has_active_filters).toBe(false); }); }); +describe("buildSignalReportListOrdering", () => { + it.each([ + { + field: "priority" as SignalReportOrderingField, + direction: "desc" as const, + expected: "status,-priority,-created_at", + }, + { + field: "priority" as SignalReportOrderingField, + direction: "asc" as const, + expected: "status,priority,-created_at", + }, + { + field: "signal_count" as SignalReportOrderingField, + direction: "desc" as const, + expected: "status,-signal_count,priority", + }, + { + field: "total_weight" as SignalReportOrderingField, + direction: "asc" as const, + expected: "status,total_weight,priority", + }, + { + field: "created_at" as SignalReportOrderingField, + direction: "desc" as const, + expected: "status,-created_at,priority", + }, + { + field: "updated_at" as SignalReportOrderingField, + direction: "asc" as const, + expected: "status,updated_at,priority", + }, + ])( + "orders $field $direction as $expected", + ({ field, direction, expected }) => { + expect(buildSignalReportListOrdering(field, direction)).toBe(expected); + }, + ); +}); + +describe("buildPriorityFilterParam", () => { + it.each([ + { + name: "returns undefined for an empty selection", + input: [], + expected: undefined, + }, + { + name: "joins selected priorities with commas", + input: ["P0", "P2"] as const, + expected: "P0,P2", + }, + { + name: "dedupes repeated priorities", + input: ["P1", "P1", "P3"] as const, + expected: "P1,P3", + }, + ])("$name", ({ input, expected }) => { + expect(buildPriorityFilterParam([...input])).toBe(expected); + }); +}); + +describe("buildArchiveListOrdering", () => { + it.each([ + { direction: "desc" as const, expected: "-updated_at" }, + { direction: "asc" as const, expected: "updated_at" }, + ])( + "sorts by field without a status prefix ($direction)", + ({ direction, expected }) => { + expect(buildArchiveListOrdering("updated_at", direction)).toBe(expected); + }, + ); +}); + describe("isRestorableReport", () => { it.each([ { status: "suppressed" as SignalReportStatus, expected: true }, diff --git a/apps/mobile/src/features/inbox/utils.ts b/apps/mobile/src/features/inbox/utils.ts index b0040ddcef..13a8e4c80b 100644 --- a/apps/mobile/src/features/inbox/utils.ts +++ b/apps/mobile/src/features/inbox/utils.ts @@ -2,15 +2,14 @@ import { EXTERNAL_INBOX_SOURCE_BY_PRODUCT, type SourceProduct, } from "@posthog/shared"; -import { differenceInHours, format, formatDistanceToNow } from "date-fns"; -import type { InboxViewedProperties } from "@/lib/analytics"; -import { DISMISSAL_REASON_OPTIONS } from "./constants"; import type { Signal, SignalReport, SignalReportPriority, SignalReportStatus, -} from "./types"; +} from "@posthog/shared/domain-types"; +import { differenceInHours, format, formatDistanceToNow } from "date-fns"; +import type { InboxViewedProperties } from "@/lib/analytics"; const ERROR_TRACKING_TYPE_LABELS: Record = { issue_created: "New issue", @@ -46,34 +45,7 @@ export function sourceLine(signal: Signal): string { const warehouseSource = EXTERNAL_INBOX_SOURCE_BY_PRODUCT[source_product as SourceProduct]; const product = warehouseSource?.label ?? source_product.replace(/_/g, " "); - const type = source_type.replace(/_/g, " "); - return `${product} · ${type}`; -} - -const SIGNAL_SUMMARY_SECTION_HEADERS = [ - "What's happening", - "Root cause", - "How to resolve", -] as const; - -/** - * Inserts blank lines around signal report summary section headers so each - * label and its body render on their own line (agent output often packs them - * together, e.g. `**What's happening:** text **Root cause:** ...`). - */ -export function formatSignalReportSummaryMarkdown(content: string): string { - let result = content; - - for (const header of SIGNAL_SUMMARY_SECTION_HEADERS) { - const boldHeader = `\\*\\*${header}:\\*\\*`; - result = result.replace( - new RegExp(`([^\\n])\\s*(${boldHeader})`, "gi"), - "$1\n\n$2", - ); - result = result.replace(new RegExp(`(${boldHeader})\\s+`, "gi"), "$1\n\n"); - } - - return result; + return `${product} · ${source_type.replace(/_/g, " ")}`; } /** Relative time for the last day, absolute "MMM d" beyond it. */ @@ -93,38 +65,6 @@ export function isRestorableReport( return report.status === "suppressed"; } -/** Human label for a persisted dismissal reason, falling back to the raw code. */ -export function dismissalReasonLabel(value: string): string { - return ( - DISMISSAL_REASON_OPTIONS.find((o) => o.value === value)?.label ?? value - ); -} - -export function inboxStatusLabel(status: SignalReportStatus): string { - switch (status) { - case "ready": - return "Ready"; - case "resolved": - return "Resolved"; - case "pending_input": - return "Needs input"; - case "in_progress": - return "Researching"; - case "candidate": - return "Queued"; - case "potential": - return "Gathering"; - case "failed": - return "Failed"; - case "suppressed": - return "Suppressed"; - case "deleted": - return "Deleted"; - default: - return status; - } -} - /** * Returns only reports that are actionable for the tinder-like card deck: * ready, immediately actionable, not already addressed. @@ -140,11 +80,11 @@ export function getActionableReports(reports: SignalReport[]): SignalReport[] { interface InboxViewedFilterState { sourceProductFilter: string[]; - statusFilter: SignalReportStatus[]; + statusFilter: readonly SignalReportStatus[]; suggestedReviewerFilter: string[]; priorityFilter: SignalReportPriority[]; /** Default status filter as defined in the filter store, used to detect whether the user has narrowed it. */ - defaultStatusFilter: SignalReportStatus[]; + defaultStatusFilter: readonly SignalReportStatus[]; } /** diff --git a/apps/mobile/src/features/tasks/composer/RepositoryPickerInline.tsx b/apps/mobile/src/features/tasks/composer/RepositoryPickerInline.tsx index e953df43b9..6e39aead06 100644 --- a/apps/mobile/src/features/tasks/composer/RepositoryPickerInline.tsx +++ b/apps/mobile/src/features/tasks/composer/RepositoryPickerInline.tsx @@ -15,8 +15,8 @@ import Animated, { useSharedValue, withTiming, } from "react-native-reanimated"; -import type { RepositoryOption } from "@/features/tasks/types"; import { useThemeColors } from "@/lib/theme"; +import type { RepositoryOption } from "../types"; // Tuning for the nested (ScrollView) path's progressive mount. The first // chunk needs to cover the rows the user can actually see (~5 with the diff --git a/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx b/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx index 4297268370..b61e6f732e 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,18 +57,10 @@ 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"; @@ -66,6 +71,7 @@ import { } from "./submitComposerMessage"; const log = logger.scope("task-chat-composer"); +const EXECUTION_MODES = getAvailableModes(); interface TaskChatComposerProps { onSend: ( @@ -80,10 +86,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; @@ -103,6 +109,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 ; } @@ -183,6 +194,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); @@ -206,6 +220,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)); }, []); @@ -220,7 +251,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 = !isComposerEmpty({ text: message, attachments }); const canSend = hasContent && !disabled && !isRecording; @@ -399,21 +431,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} @@ -462,12 +501,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, ), }))} @@ -479,18 +518,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: , }))} /> @@ -499,11 +536,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: , }))} /> @@ -520,7 +557,7 @@ export function TaskChatComposer({ } export const TASK_CHAT_DEFAULTS = { - mode: DEFAULT_EXECUTION_MODE, - model: DEFAULT_MODEL, - reasoning: DEFAULT_REASONING, + mode: DEFAULT_CLAUDE_EXECUTION_MODE, + model: DEFAULT_GATEWAY_MODEL, + reasoning: DEFAULT_REASONING_EFFORT, } as const; diff --git a/apps/mobile/src/features/tasks/composer/attachments/cloudPrompt.ts b/apps/mobile/src/features/tasks/composer/attachments/cloudPrompt.ts deleted file mode 100644 index 35f885cdc7..0000000000 --- a/apps/mobile/src/features/tasks/composer/attachments/cloudPrompt.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { CloudPromptBlock } from "./types"; - -/** - * Wire format prefix shared with `packages/shared/src/cloud-prompt.ts`. The - * backend's `deserializeCloudPrompt` looks for this prefix and decodes the - * trailing JSON as `{ blocks: ContentBlock[] }`. Plain-text prompts without - * attachments are sent as strings (no prefix) so chat echoes stay readable. - */ -export const CLOUD_PROMPT_PREFIX = "__twig_cloud_prompt_v1__:"; - -export function serializeCloudPrompt(blocks: CloudPromptBlock[]): string { - if (blocks.length === 1 && blocks[0].type === "text") { - return blocks[0].text.trim(); - } - return `${CLOUD_PROMPT_PREFIX}${JSON.stringify({ blocks })}`; -} diff --git a/apps/mobile/src/features/tasks/composer/options.test.ts b/apps/mobile/src/features/tasks/composer/options.test.ts index 75328ebf04..c155d51849 100644 --- a/apps/mobile/src/features/tasks/composer/options.test.ts +++ b/apps/mobile/src/features/tasks/composer/options.test.ts @@ -1,27 +1,68 @@ +import { + type CloudTaskConfigOption, + DEFAULT_GATEWAY_MODEL, + restrictedModelMeta, +} from "@posthog/shared"; import { describe, expect, it } from "vitest"; import { - DEFAULT_MODEL, - DEFAULT_REASONING, - modelSupportsReasoning, - REASONING_LEVELS, + getMobileModelOptions, + getModelConfigOption, + getModelLabel, + resolveAvailableModel, } from "./options"; -describe("task composer options", () => { - it("uses an eligible non-premium default model", () => { - expect(DEFAULT_MODEL).toBe("claude-opus-4-8"); - expect(DEFAULT_MODEL).not.toContain("fable"); - }); +const modelOption: CloudTaskConfigOption = { + id: "model", + name: "Model", + type: "select", + currentValue: DEFAULT_GATEWAY_MODEL, + options: [ + { + value: DEFAULT_GATEWAY_MODEL, + name: "Claude Opus 4.8", + description: "Default", + }, + { + value: "claude-fable-5", + name: "Claude Fable 5", + _meta: restrictedModelMeta(), + }, + ], + category: "model", + description: "Choose a model", +}; - it("derives reasoning defaults and options from shared policy", () => { - expect(DEFAULT_REASONING).toBe("high"); - expect(REASONING_LEVELS.map((option) => option.value)).toEqual([ - "low", - "medium", - "high", - "xhigh", - "max", +describe("mobile cloud task model options", () => { + it("adapts live model options and disables restricted entries", () => { + expect(getMobileModelOptions(modelOption)).toEqual([ + { + value: DEFAULT_GATEWAY_MODEL, + label: "Claude Opus 4.8", + description: "Default", + disabled: false, + }, + { + value: "claude-fable-5", + label: "Claude Fable 5", + description: undefined, + disabled: true, + }, ]); - expect(modelSupportsReasoning("claude-opus-4-8")).toBe(true); - expect(modelSupportsReasoning("claude-haiku-4-5")).toBe(false); + }); + + it("falls back from restricted or missing selections", () => { + expect(resolveAvailableModel(modelOption, "claude-fable-5")).toBe( + DEFAULT_GATEWAY_MODEL, + ); + expect(resolveAvailableModel(modelOption, "missing-model")).toBe( + DEFAULT_GATEWAY_MODEL, + ); + }); + + it("reads the live model label and config option", () => { + expect(getModelConfigOption([modelOption])).toBe(modelOption); + expect(getModelLabel(modelOption, DEFAULT_GATEWAY_MODEL)).toBe( + "Claude Opus 4.8", + ); }); }); diff --git a/apps/mobile/src/features/tasks/composer/options.ts b/apps/mobile/src/features/tasks/composer/options.ts index 572fff4a35..1db34b57a1 100644 --- a/apps/mobile/src/features/tasks/composer/options.ts +++ b/apps/mobile/src/features/tasks/composer/options.ts @@ -1,107 +1,56 @@ import { - DEFAULT_CLAUDE_EXECUTION_MODE, - getAvailableModes, -} from "@posthog/core/sessions/executionModes"; -import { - DEFAULT_GATEWAY_MODEL, - DEFAULT_REASONING_EFFORT, - defaultEligibleModel, - getReasoningEffortOptions, - type ExecutionMode as SharedExecutionMode, - type SupportedReasoningEffort, + type CloudTaskConfigOption, + isRestrictedModelOption, } from "@posthog/shared"; -export type ExecutionMode = Extract< - SharedExecutionMode, - "default" | "acceptEdits" | "plan" | "auto" ->; -export type ReasoningEffort = SupportedReasoningEffort; - -export const EXECUTION_MODES: { - value: ExecutionMode; - label: string; - description: string; -}[] = getAvailableModes() - .filter( - (mode): mode is typeof mode & { id: ExecutionMode } => - mode.id === "default" || - mode.id === "acceptEdits" || - mode.id === "plan" || - mode.id === "auto", - ) - .map((mode) => ({ - value: mode.id, - label: mode.name, - description: mode.description, - })); - -export interface ModelOption { +export interface MobileModelOption { value: string; label: string; description?: string; - supportsReasoning: boolean; + disabled: boolean; } -export const MODELS: ModelOption[] = [ - { - value: "claude-fable-5", - label: "Claude Fable 5", - description: "Newest, most capable", - supportsReasoning: true, - }, - { - value: "claude-opus-5", - label: "Claude Opus 5", - description: "Most capable, slower", - supportsReasoning: true, - }, - { - value: "claude-opus-4-8", - label: "Claude Opus 4.8", - description: "Previous Opus generation", - supportsReasoning: true, - }, - { - value: "claude-sonnet-5", - label: "Claude Sonnet 5", - description: "Balanced, fast", - supportsReasoning: true, - }, - { - value: "claude-sonnet-4-6", - label: "Claude Sonnet 4.6", - description: "Balanced", - supportsReasoning: true, - }, -]; - -export const DEFAULT_EXECUTION_MODE: ExecutionMode = - DEFAULT_CLAUDE_EXECUTION_MODE; -export const DEFAULT_MODEL = - defaultEligibleModel(DEFAULT_GATEWAY_MODEL) ?? - MODELS.find((model) => defaultEligibleModel(model.value))?.value ?? - DEFAULT_GATEWAY_MODEL; -export const DEFAULT_REASONING: ReasoningEffort = DEFAULT_REASONING_EFFORT; - -export const REASONING_LEVELS: { - value: ReasoningEffort; - label: string; -}[] = (getReasoningEffortOptions("claude", DEFAULT_MODEL) ?? []).map( - (option) => ({ value: option.value, label: option.name }), -); - -export function modelLabel(value: string): string { - return MODELS.find((m) => m.value === value)?.label ?? value; +export function getModelConfigOption( + configOptions: readonly CloudTaskConfigOption[], +): CloudTaskConfigOption { + const modelOption = configOptions.find( + (option) => option.category === "model", + ); + if (!modelOption) { + throw new Error("Cloud task model configuration is unavailable"); + } + return modelOption; } -export function modeLabel(value: ExecutionMode): string { - return EXECUTION_MODES.find((m) => m.value === value)?.label ?? value; +export function getMobileModelOptions( + modelOption: CloudTaskConfigOption, +): MobileModelOption[] { + return modelOption.options.map((option) => ({ + value: option.value, + label: option.name, + description: option.description, + disabled: isRestrictedModelOption(option._meta), + })); } -export function reasoningLabel(value: ReasoningEffort): string { - return REASONING_LEVELS.find((r) => r.value === value)?.label ?? value; +export function getModelLabel( + modelOption: CloudTaskConfigOption, + value: string, +): string { + return ( + modelOption.options.find((option) => option.value === value)?.name ?? value + ); } -export function modelSupportsReasoning(value: string): boolean { - return getReasoningEffortOptions("claude", value) !== null; +export function resolveAvailableModel( + modelOption: CloudTaskConfigOption, + value: string, +): string { + const selectedOption = modelOption.options.find( + (option) => option.value === value, + ); + if (selectedOption && !isRestrictedModelOption(selectedOption._meta)) { + return value; + } + return modelOption.currentValue; } diff --git a/apps/mobile/src/features/tasks/hooks/useAutomations.test.ts b/apps/mobile/src/features/tasks/hooks/useAutomations.test.ts index 772d9a4f69..fb47817a8c 100644 --- a/apps/mobile/src/features/tasks/hooks/useAutomations.test.ts +++ b/apps/mobile/src/features/tasks/hooks/useAutomations.test.ts @@ -8,36 +8,28 @@ const { mockGetTaskAutomations, mockCreateTaskAutomation, mockUpdateTaskAutomation, - mockApiClient, } = vi.hoisted(() => ({ mockUseAuthStore: vi.fn(), mockGetTaskAutomations: vi.fn(), mockCreateTaskAutomation: vi.fn(), mockUpdateTaskAutomation: vi.fn(), - mockApiClient: { - listTaskAutomations: vi.fn(), - getTaskAutomation: vi.fn(), - createTaskAutomation: vi.fn(), - updateTaskAutomation: vi.fn(), - deleteTaskAutomation: vi.fn(), - runTaskAutomation: vi.fn(), - }, })); vi.mock("@/features/auth", () => ({ useAuthStore: mockUseAuthStore, })); -vi.mock("../api", () => ({ runTaskInCloud: vi.fn() })); - vi.mock("@/lib/posthogApiClient", () => ({ - getPostHogApiClient: () => mockApiClient, + getPostHogApiClient: vi.fn(() => ({ + listTaskAutomations: mockGetTaskAutomations, + getTaskAutomation: vi.fn(), + createTaskAutomation: mockCreateTaskAutomation, + updateTaskAutomation: mockUpdateTaskAutomation, + deleteTaskAutomation: vi.fn(), + runTaskAutomation: vi.fn(), + })), })); -mockApiClient.listTaskAutomations = mockGetTaskAutomations; -mockApiClient.createTaskAutomation = mockCreateTaskAutomation; -mockApiClient.updateTaskAutomation = mockUpdateTaskAutomation; - import { automationKeys, getAutomationPollingInterval, diff --git a/apps/mobile/src/features/tasks/hooks/useCloudTaskConfigOptions.test.ts b/apps/mobile/src/features/tasks/hooks/useCloudTaskConfigOptions.test.ts new file mode 100644 index 0000000000..4b516f1445 --- /dev/null +++ b/apps/mobile/src/features/tasks/hooks/useCloudTaskConfigOptions.test.ts @@ -0,0 +1,121 @@ +import { + type CloudTaskConfigOption, + DEFAULT_GATEWAY_MODEL, +} from "@posthog/shared"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createElement, type PropsWithChildren } from "react"; +import { act, create } from "react-test-renderer"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockGetCloudTaskConfigOptions, mockUseAuthStore } = vi.hoisted(() => ({ + mockGetCloudTaskConfigOptions: vi.fn(), + mockUseAuthStore: vi.fn(), +})); + +vi.mock("posthog-react-native", () => ({ + useFeatureFlag: () => false, +})); + +vi.mock("@/features/auth", () => ({ + useAuthStore: mockUseAuthStore, +})); + +vi.mock("@/lib/posthogApiClient", () => ({ + getPostHogApiClient: () => ({ + getCloudTaskConfigOptions: mockGetCloudTaskConfigOptions, + }), +})); + +import { getModelConfigOption } from "../composer/options"; +import { useCloudTaskConfigOptions } from "./useCloudTaskConfigOptions"; + +function createWrapper(queryClient: QueryClient) { + return function Wrapper({ children }: PropsWithChildren) { + return createElement( + QueryClientProvider, + { client: queryClient }, + children, + ); + }; +} + +async function renderHook() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + let currentResult: ReturnType; + + function HookProbe() { + currentResult = useCloudTaskConfigOptions("claude"); + return null; + } + + const Wrapper = createWrapper(queryClient); + await act(async () => { + create(createElement(Wrapper, null, createElement(HookProbe))); + await Promise.resolve(); + }); + + return { + get current() { + return currentResult; + }, + }; +} + +async function waitForAssertion(assertion: () => void): Promise { + const timeoutAt = Date.now() + 2_000; + while (Date.now() < timeoutAt) { + try { + assertion(); + return; + } catch (error) { + await new Promise((resolve) => setTimeout(resolve, 10)); + if (Date.now() >= timeoutAt) throw error; + } + } +} + +describe("useCloudTaskConfigOptions", () => { + beforeEach(() => { + mockGetCloudTaskConfigOptions.mockReset(); + mockUseAuthStore.mockImplementation((selector) => + selector({ oauthAccessToken: "token" }), + ); + }); + + it("uses the authenticated live Claude catalog", async () => { + const liveOptions: CloudTaskConfigOption[] = [ + { + id: "model", + name: "Model", + type: "select", + currentValue: "claude-sonnet-5", + options: [{ value: "claude-sonnet-5", name: "Claude Sonnet 5" }], + category: "model", + description: "Choose a model", + }, + ]; + mockGetCloudTaskConfigOptions.mockResolvedValue(liveOptions); + + const result = await renderHook(); + await waitForAssertion(() => { + expect(result.current.configOptions).toEqual(liveOptions); + expect(result.current.hasLiveConfig).toBe(true); + }); + expect(mockGetCloudTaskConfigOptions).toHaveBeenCalledWith("claude"); + }); + + it("keeps the shared fallback when unauthenticated", async () => { + mockUseAuthStore.mockImplementation((selector) => + selector({ oauthAccessToken: null }), + ); + + const result = await renderHook(); + + expect( + getModelConfigOption(result.current.configOptions).currentValue, + ).toBe(DEFAULT_GATEWAY_MODEL); + expect(mockGetCloudTaskConfigOptions).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/features/tasks/hooks/useCloudTaskConfigOptions.ts b/apps/mobile/src/features/tasks/hooks/useCloudTaskConfigOptions.ts new file mode 100644 index 0000000000..aaedf3375d --- /dev/null +++ b/apps/mobile/src/features/tasks/hooks/useCloudTaskConfigOptions.ts @@ -0,0 +1,52 @@ +import { + type Adapter, + buildCloudTaskConfigOptions, + type CloudTaskConfigOption, + GLM_MODEL_FLAG, + isGlmModelId, +} from "@posthog/shared"; +import { useQuery } from "@tanstack/react-query"; +import { useFeatureFlag } from "posthog-react-native"; +import { useAuthStore } from "@/features/auth"; +import { getPostHogApiClient } from "@/lib/posthogApiClient"; + +export const cloudTaskConfigOptionKeys = { + all: ["cloud-task-config-options"] as const, + adapter: (adapter: Adapter) => + [...cloudTaskConfigOptionKeys.all, adapter] as const, +}; + +const fallbackOptionsByAdapter: Record = { + claude: buildCloudTaskConfigOptions([], "claude"), + codex: buildCloudTaskConfigOptions([], "codex"), +}; + +export function useCloudTaskConfigOptions(adapter: Adapter = "claude") { + const oauthAccessToken = useAuthStore((state) => state.oauthAccessToken); + const glmEnabled = useFeatureFlag(GLM_MODEL_FLAG); + const query = useQuery({ + queryKey: cloudTaskConfigOptionKeys.adapter(adapter), + queryFn: () => getPostHogApiClient().getCloudTaskConfigOptions(adapter), + enabled: !!oauthAccessToken, + staleTime: 5 * 60 * 1000, + }); + const configOptions = query.data ?? fallbackOptionsByAdapter[adapter]; + const visibleConfigOptions = glmEnabled + ? configOptions + : configOptions.map((option) => + option.category === "model" + ? { + ...option, + options: option.options.filter( + (model) => !isGlmModelId(model.value), + ), + } + : option, + ); + + return { + ...query, + configOptions: visibleConfigOptions, + hasLiveConfig: query.data !== undefined, + }; +} diff --git a/apps/mobile/src/features/tasks/hooks/useIntegrations.test.ts b/apps/mobile/src/features/tasks/hooks/useIntegrations.test.ts index 45040a2df6..68394d2aba 100644 --- a/apps/mobile/src/features/tasks/hooks/useIntegrations.test.ts +++ b/apps/mobile/src/features/tasks/hooks/useIntegrations.test.ts @@ -15,10 +15,10 @@ vi.mock("@/features/auth", () => ({ })); vi.mock("@/lib/posthogApiClient", () => ({ - getPostHogApiClient: () => ({ + getPostHogApiClient: vi.fn(() => ({ getGithubRepositories: mockGetGithubRepositories, getIntegrations: mockGetIntegrations, - }), + })), })); import { useRepositoryCacheStore } from "../stores/repositoryCacheStore"; @@ -123,7 +123,7 @@ describe("useIntegrations", () => { }, ]); mockGetGithubRepositories - .mockResolvedValueOnce(["annika/mobile-app"]) + .mockResolvedValueOnce(["Annika/Mobile-App", ""]) .mockRejectedValueOnce(new Error("GitHub repos failed")); const queryClient = new QueryClient({ diff --git a/apps/mobile/src/features/tasks/hooks/useIntegrations.ts b/apps/mobile/src/features/tasks/hooks/useIntegrations.ts index bf2aab3c77..25f909f6f9 100644 --- a/apps/mobile/src/features/tasks/hooks/useIntegrations.ts +++ b/apps/mobile/src/features/tasks/hooks/useIntegrations.ts @@ -1,34 +1,14 @@ +import { combineGithubRepositories } from "@posthog/core/integrations/repositories"; import { useQuery } from "@tanstack/react-query"; import { useEffect, useMemo } from "react"; import { useAuthStore } from "@/features/auth"; import { getPostHogApiClient } from "@/lib/posthogApiClient"; import { useRepositoryCacheStore } from "../stores/repositoryCacheStore"; -import type { Integration, RepositoryOption } from "../types"; -import { buildRepositoryOptions } from "../utils/repositorySelection"; - -/** Cheap content-equality check for repository option lists. Lets the cache - * write effect skip no-op updates, which is what kept retriggering renders - * before — `buildRepositoryOptions` always returns a fresh array, so the - * effect's dep array churned every render. */ -function repositoryOptionsEqual( - a: RepositoryOption[], - b: RepositoryOption[], -): boolean { - if (a === b) return true; - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) { - const left = a[i]; - const right = b[i]; - if ( - left.integrationId !== right.integrationId || - left.repository !== right.repository || - left.integrationLabel !== right.integrationLabel - ) { - return false; - } - } - return true; -} +import { + buildRepositoryOptions, + repositoryLoadWarning, + repositoryOptionsEqual, +} from "../utils/repositorySelection"; export const integrationKeys = { all: ["integrations"] as const, @@ -60,26 +40,7 @@ export function useIntegrations(options: UseIntegrationsOptions = {}) { queryKey: integrationKeys.github(), queryFn: async () => { const data = await getPostHogApiClient().getIntegrations(); - return data.flatMap((integration): Integration[] => { - if ( - integration.kind !== "github" || - typeof integration.id !== "number" - ) { - return []; - } - - return [ - { - id: integration.id, - kind: integration.kind, - display_name: - typeof integration.display_name === "string" - ? integration.display_name - : undefined, - config: integration.config as Integration["config"], - }, - ]; - }); + return data.filter((i) => i.kind === "github"); }, enabled: enabled && !!projectId && !!oauthAccessToken, }); @@ -97,9 +58,11 @@ export function useIntegrations(options: UseIntegrationsOptions = {}) { const results = await Promise.allSettled( githubIntegrations.map(async (integration) => ({ integrationId: integration.id, - repositories: await getPostHogApiClient().getGithubRepositories( - integration.id, - ), + repositories: ( + await getPostHogApiClient().getGithubRepositories(integration.id) + ) + .map((repository) => repository.toLowerCase()) + .filter(Boolean), })), ); @@ -117,12 +80,10 @@ export function useIntegrations(options: UseIntegrationsOptions = {}) { return { repositoriesByIntegration, - partialError: - failedCount === 0 - ? null - : failedCount === githubIntegrations.length - ? "Could not load GitHub repositories. Pull to retry." - : "Some GitHub repositories could not be loaded. Pull to retry.", + partialError: repositoryLoadWarning( + failedCount, + githubIntegrations.length, + ), }; }, enabled: enabled && githubIntegrations.length > 0, @@ -130,7 +91,19 @@ export function useIntegrations(options: UseIntegrationsOptions = {}) { const repositoriesByIntegration = repositoriesQuery.data?.repositoriesByIntegration ?? {}; - const repositories = Object.values(repositoriesByIntegration).flat().sort(); + const repositories = Object.keys( + combineGithubRepositories( + githubIntegrations.map((integration) => ({ + data: { + integrationId: integration.id, + repos: repositoriesByIntegration[integration.id] ?? [], + }, + isPending: repositoriesQuery.isPending, + isError: false, + isRefetching: repositoriesQuery.isRefetching, + })), + ).repositoryMap, + ).sort(); // Memoize the derived options list keyed on the underlying query data so // its reference is stable across renders when the data hasn't actually diff --git a/apps/mobile/src/features/tasks/hooks/useTasks.test.ts b/apps/mobile/src/features/tasks/hooks/useTasks.test.ts index 579ec4bce2..1395c283e8 100644 --- a/apps/mobile/src/features/tasks/hooks/useTasks.test.ts +++ b/apps/mobile/src/features/tasks/hooks/useTasks.test.ts @@ -27,18 +27,15 @@ vi.mock("@/lib/logger", () => { }; }); -vi.mock("../api", () => ({ - runTaskInCloud: vi.fn(), -})); - vi.mock("@/lib/posthogApiClient", () => ({ - getPostHogApiClient: () => ({ + getPostHogApiClient: vi.fn(() => ({ createTask: vi.fn(), deleteTask: vi.fn(), getTask: vi.fn(), getTasks: vi.fn(), + runTaskInCloud: vi.fn(), updateTask: vi.fn(), - }), + })), })); vi.mock("../stores/taskStore", () => ({ diff --git a/apps/mobile/src/features/tasks/hooks/useTasks.ts b/apps/mobile/src/features/tasks/hooks/useTasks.ts index 95b5d4725f..862459708e 100644 --- a/apps/mobile/src/features/tasks/hooks/useTasks.ts +++ b/apps/mobile/src/features/tasks/hooks/useTasks.ts @@ -4,7 +4,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useAuthStore, useUserQuery } from "@/features/auth"; import { logger } from "@/lib/logger"; import { getPostHogApiClient } from "@/lib/posthogApiClient"; -import { runTaskInCloud } from "../api"; import { useTaskStore } from "../stores/taskStore"; import type { CreateTaskOptions } from "../types"; @@ -136,13 +135,13 @@ export function useUpdateTask() { }: { taskId: string; updates: Partial; - }) => - getPostHogApiClient().updateTask( + }) => { + const client = getPostHogApiClient(); + return client.updateTask( taskId, - updates as Parameters< - ReturnType["updateTask"] - >[1], - ), + updates as Parameters[1], + ); + }, onSuccess: (updatedTask, { taskId }) => { // Update the detail cache immediately queryClient.setQueryData(taskKeys.detail(taskId), updatedTask); @@ -174,7 +173,8 @@ export function useRunTask() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (taskId: string) => runTaskInCloud(taskId), + mutationFn: (taskId: string) => + getPostHogApiClient().runTaskInCloud(taskId), onSuccess: (updatedTask, taskId) => { queryClient.setQueryData(taskKeys.detail(taskId), updatedTask); queryClient.invalidateQueries({ queryKey: taskKeys.lists() }); diff --git a/apps/mobile/src/features/tasks/hooks/useUserIntegrations.ts b/apps/mobile/src/features/tasks/hooks/useUserIntegrations.ts index 1ba6655cf2..c951950409 100644 --- a/apps/mobile/src/features/tasks/hooks/useUserIntegrations.ts +++ b/apps/mobile/src/features/tasks/hooks/useUserIntegrations.ts @@ -1,8 +1,13 @@ +import { combineUserGithubRepositories } from "@posthog/core/integrations/repositories"; import { useQuery } from "@tanstack/react-query"; import { useCallback, useMemo } from "react"; import { useAuthStore } from "@/features/auth"; import { getPostHogApiClient } from "@/lib/posthogApiClient"; import type { RepositoryOption } from "../types"; +import { + buildUserRepositoryOptions, + repositoryLoadWarning, +} from "../utils/repositorySelection"; /** * User-scoped sibling of {@link useIntegrations}. Reads the authenticated @@ -29,20 +34,25 @@ interface UseUserIntegrationsOptions { enabled?: boolean; } -function integrationLabel(integration: { - installation_id: string; - account?: { name?: string | null } | null; -}): string { - return integration.account?.name ?? `GitHub ${integration.installation_id}`; -} - export function useUserIntegrations(options: UseUserIntegrationsOptions = {}) { const { enabled = true } = options; const { oauthAccessToken } = useAuthStore(); const integrationsQuery = useQuery({ queryKey: userIntegrationKeys.github(), - queryFn: () => getPostHogApiClient().getGithubUserIntegrations(), + queryFn: async () => { + const integrations = + await getPostHogApiClient().getGithubUserIntegrations(); + return integrations.map(({ account, ...integration }) => ({ + ...integration, + account: account + ? { + name: account.name ?? undefined, + type: account.type ?? undefined, + } + : undefined, + })); + }, enabled: enabled && !!oauthAccessToken, }); @@ -53,53 +63,57 @@ export function useUserIntegrations(options: UseUserIntegrationsOptions = {}) { integrations.map((i) => i.installation_id), ), queryFn: async () => { - const byInstallation: Record = {}; const results = await Promise.allSettled( integrations.map(async (integration) => ({ installationId: integration.installation_id, - repositories: await getPostHogApiClient().getGithubUserRepositories( - integration.installation_id, - ), + repositories: ( + await getPostHogApiClient().getGithubUserRepositories( + integration.installation_id, + ) + ) + .map((repository) => repository.toLowerCase()) + .filter(Boolean), })), ); - let failedCount = 0; - for (const result of results) { - if (result.status === "fulfilled") { - byInstallation[result.value.installationId] = - result.value.repositories; - } else { - failedCount += 1; - } - } + const combined = combineUserGithubRepositories( + results.map((result) => ({ + data: + result.status === "fulfilled" + ? { + userIntegrationId: + integrations.find( + (integration) => + integration.installation_id === + result.value.installationId, + )?.id ?? "", + installationId: result.value.installationId, + repos: result.value.repositories, + } + : undefined, + isPending: false, + isError: result.status === "rejected", + isRefetching: false, + })), + integrations.map((integration) => integration.installation_id), + ); return { - byInstallation, - partialError: - failedCount === 0 - ? null - : failedCount === integrations.length - ? "Could not load GitHub repositories. Pull to retry." - : "Some GitHub repositories could not be loaded. Pull to retry.", + byInstallation: combined.reposByInstallationId, + partialError: repositoryLoadWarning( + combined.failedInstallationIds.length, + integrations.length, + ), }; }, enabled: enabled && integrations.length > 0, }); const repositoryOptions = useMemo(() => { - const byInstallation = repositoriesQuery.data?.byInstallation ?? {}; - return integrations - .flatMap((integration) => { - const repositories = byInstallation[integration.installation_id] ?? []; - return repositories.map((repository) => ({ - // GitHub installation ids fit in a JS number; use it as the numeric - // key the picker/RepositoryOption already expect. - integrationId: Number(integration.installation_id), - integrationLabel: integrationLabel(integration), - repository, - })); - }) - .sort((left, right) => left.repository.localeCompare(right.repository)); + return buildUserRepositoryOptions( + integrations, + repositoriesQuery.data?.byInstallation ?? {}, + ); }, [integrations, repositoriesQuery.data]); /** Resolve the `UserIntegration` UUID for a selected installation id, to send diff --git a/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx b/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx index 175a77c276..4985ffeaee 100644 --- a/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx +++ b/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx @@ -9,7 +9,9 @@ vi.mock("posthog-react-native", () => ({ useFeatureFlag: () => flagState.enabled, })); vi.mock("@/lib/posthogApiClient", () => ({ - getPostHogApiClient: () => ({ warmTask: mockWarmTask }), + getPostHogApiClient: vi.fn(() => ({ + warmTask: mockWarmTask, + })), })); vi.mock("@/lib/logger", () => { const mockLogger = { diff --git a/apps/mobile/src/features/tasks/index.ts b/apps/mobile/src/features/tasks/index.ts index 7da4db747e..2bcbcc35a7 100644 --- a/apps/mobile/src/features/tasks/index.ts +++ b/apps/mobile/src/features/tasks/index.ts @@ -24,9 +24,3 @@ export { useTaskStore } from "./stores/taskStore"; // Types export * from "./types"; - -// Utils -export { - convertStoredEntriesToEvents, - parseSessionLogs, -} from "./utils/parseSessionLogs"; diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts index 462589a58e..0c57cc6d1a 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts @@ -1,7 +1,9 @@ +import { convertStoredEntriesToPortableSessionEvents } from "@posthog/core/sessions/portableSessionEvents"; import { type CloudTaskUpdatePayload, isTerminalStatus, type StoredLogEntry, + serializeCloudPrompt, type Task, } from "@posthog/shared"; import * as Haptics from "expo-haptics"; @@ -18,7 +20,6 @@ import { sendCloudCommand, } from "../api"; import { buildCloudPromptBlocks } from "../composer/attachments/buildCloudPrompt"; -import { serializeCloudPrompt } from "../composer/attachments/cloudPrompt"; import type { PendingAttachment } from "../composer/attachments/types"; import { type WatchCloudTaskHandle, @@ -31,7 +32,6 @@ import type { SessionNotificationAttachment, TerminalStatus, } from "../types"; -import { convertStoredEntriesToEvents } from "../utils/parseSessionLogs"; import { playbackRateForTaskDuration } from "../utils/playbackRate"; import { reinjectPromptAttachments } from "../utils/promptAttachments"; import { playCompletionSound } from "../utils/sounds"; @@ -991,7 +991,8 @@ export const useTaskSessionStore = create((set, get) => ({ ? update.newEntries : dedupAgainstLocalEchoes(update.newEntries, echoSet); - const events = convertStoredEntriesToEvents(dedupedEntries); + const events = + convertStoredEntriesToPortableSessionEvents(dedupedEntries); // Snapshots are S3-backed and replay user turns as text-only chunks; // reattach the images from the `session/prompt` entries in the same log. if (isSnapshot) { diff --git a/apps/mobile/src/features/tasks/stores/taskStore.ts b/apps/mobile/src/features/tasks/stores/taskStore.ts index 456eae37a1..39276a7eeb 100644 --- a/apps/mobile/src/features/tasks/stores/taskStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskStore.ts @@ -1,11 +1,12 @@ +import type { TaskActivitySortMode } from "@posthog/core/tasks/taskActivity"; +import type { ExecutionMode, SupportedReasoningEffort } from "@posthog/shared"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; -import type { ExecutionMode, ReasoningEffort } from "../composer/options"; import type { RepositorySelection } from "../types"; export type OrganizeMode = "by-project" | "chronological"; -export type SortMode = "created" | "updated"; +export type SortMode = TaskActivitySortMode; const EMPTY_REPOSITORY_SELECTION: RepositorySelection = { integrationId: null, @@ -17,7 +18,7 @@ const EMPTY_REPOSITORY_SELECTION: RepositorySelection = { export interface TaskComposerConfig { mode?: ExecutionMode; model?: string; - reasoning?: ReasoningEffort; + reasoning?: SupportedReasoningEffort; } interface TaskUIState { diff --git a/apps/mobile/src/features/tasks/types.ts b/apps/mobile/src/features/tasks/types.ts index 29a2754d7f..ad45e83576 100644 --- a/apps/mobile/src/features/tasks/types.ts +++ b/apps/mobile/src/features/tasks/types.ts @@ -1,14 +1,8 @@ import type { CloudPermissionOption, CloudTaskPermissionRequestUpdate, - StoredLogEntry as SharedStoredLogEntry, - TaskRunStatus, } from "@posthog/shared"; -export interface MobileStoredLogEntry extends SharedStoredLogEntry { - direction?: "client" | "agent"; -} - export interface SessionNotificationAttachment { kind: "image" | "document"; uri: string; @@ -16,51 +10,12 @@ export interface SessionNotificationAttachment { mimeType?: string; } -export interface SessionNotification { - update?: { - sessionUpdate?: string; - content?: { type: string; text: string }; - // Sidecar carrying user-uploaded attachments on user_message_chunk events. - // The wire format embeds the bytes themselves in a separate serialized - // cloud-prompt payload sent to the agent; this field exists only so the - // local feed can render the attachments alongside the echoed text. - attachments?: SessionNotificationAttachment[]; - title?: string; - toolCallId?: string; - status?: "pending" | "in_progress" | "completed" | "failed" | null; - rawInput?: Record; - rawOutput?: unknown; - entries?: PlanEntry[]; - _meta?: { - claudeCode?: { - toolName?: string; - parentToolCallId?: string; - }; - }; - }; -} - export interface PlanEntry { content: string; status: "pending" | "in_progress" | "completed" | "failed"; priority: string; } -export interface AcpMessage { - type: "acp_message"; - direction: "client" | "agent"; - ts: number; - message: unknown; -} - -export interface SessionUpdateEvent { - type: "session_update"; - ts: number; - notification: SessionNotification; -} - -export type SessionEvent = AcpMessage | SessionUpdateEvent; - export interface CloudPermissionResponseSelection { optionId: string; displayText: string; @@ -75,64 +30,6 @@ export interface CloudPendingPermissionRequest { response?: CloudPermissionResponseSelection; } -export interface TaskRunStateEvent { - type: "task_run_state"; - status?: TaskRunStatus; - stage?: string | null; - output?: Record | null; - error_message?: string | null; - branch?: string | null; - updated_at?: string | null; - completed_at?: string | null; -} - -export interface PermissionRequestEventData { - type: "permission_request"; - requestId: string; - toolCall: CloudTaskPermissionRequestUpdate["toolCall"]; - options: CloudPermissionOption[]; -} - -export interface SseErrorEventData { - error: string; -} - -export function isTaskRunStateEvent(data: unknown): data is TaskRunStateEvent { - return ( - typeof data === "object" && - data !== null && - (data as { type?: string }).type === "task_run_state" - ); -} - -export function isPermissionRequestEvent( - data: unknown, -): data is PermissionRequestEventData { - return ( - typeof data === "object" && - data !== null && - (data as { type?: string }).type === "permission_request" && - typeof (data as { requestId?: string }).requestId === "string" - ); -} - -export function isKeepaliveEvent(data: unknown): boolean { - return ( - typeof data === "object" && - data !== null && - (data as { type?: string }).type === "keepalive" - ); -} - -export function isSseErrorEvent(data: unknown): data is SseErrorEventData { - return ( - typeof data === "object" && - data !== null && - "error" in data && - typeof (data as SseErrorEventData).error === "string" - ); -} - export interface Integration { id: number; kind: string; diff --git a/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts b/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts deleted file mode 100644 index a6d512d59d..0000000000 --- a/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { convertStoredEntriesToPortableSessionEvents } from "@posthog/core/sessions/portableSessionEvents"; -import type { MobileStoredLogEntry, SessionNotification } from "../types"; - -export interface ParsedSessionLogs { - notifications: SessionNotification[]; - rawEntries: MobileStoredLogEntry[]; -} - -export function parseSessionLogs(content: string): ParsedSessionLogs { - if (!content?.trim()) { - return { notifications: [], rawEntries: [] }; - } - - const notifications: SessionNotification[] = []; - const rawEntries: MobileStoredLogEntry[] = []; - - for (const line of content.trim().split("\n")) { - try { - const stored = JSON.parse(line) as MobileStoredLogEntry; - - const msg = stored.notification; - if (msg) { - const hasId = msg.id !== undefined; - const hasMethod = msg.method !== undefined; - const hasResult = msg.result !== undefined || msg.error !== undefined; - - if (hasId && hasMethod) { - stored.direction = "client"; - } else if (hasId && hasResult) { - stored.direction = "agent"; - } else if (hasMethod && !hasId) { - stored.direction = "agent"; - } - } - - rawEntries.push(stored); - - if ( - stored.type === "notification" && - stored.notification?.method === "session/update" && - stored.notification?.params - ) { - notifications.push(stored.notification.params as SessionNotification); - } - } catch { - // Skip malformed lines - } - } - - return { notifications, rawEntries }; -} - -export const convertStoredEntriesToEvents = - convertStoredEntriesToPortableSessionEvents; diff --git a/apps/mobile/src/features/tasks/utils/repositorySelection.test.ts b/apps/mobile/src/features/tasks/utils/repositorySelection.test.ts index 820ef73caf..b7177617e3 100644 --- a/apps/mobile/src/features/tasks/utils/repositorySelection.test.ts +++ b/apps/mobile/src/features/tasks/utils/repositorySelection.test.ts @@ -1,36 +1,31 @@ import { describe, expect, it } from "vitest"; import { buildRepositoryOptions, + buildUserRepositoryOptions, findRepositoryOption, isRepositorySelectionComplete, + repositoryLoadWarning, + repositoryOptionsEqual, toRepositorySelection, } from "./repositorySelection"; describe("repositorySelection", () => { const integrations = [ - { - id: 7, - kind: "github", - display_name: "Personal GitHub", - }, + { id: 7, kind: "github", display_name: "Personal GitHub" }, { id: 11, kind: "github", - config: { - account: { - login: "posthog", - }, - }, + config: { account: { login: "posthog" } }, }, ]; it("preserves integration identity for each repository option", () => { - const options = buildRepositoryOptions(integrations, { - 7: ["annika/mobile-app"], - 11: ["posthog/posthog", "posthog/code"], - }); - - expect(options).toEqual([ + expect( + buildRepositoryOptions(integrations, { + 7: ["annika/mobile-app"], + 11: ["posthog/posthog", "posthog/code"], + }), + ).toEqual([ { integrationId: 7, integrationLabel: "Personal GitHub", @@ -49,41 +44,81 @@ describe("repositorySelection", () => { ]); }); - it("finds the exact repository option when multiple integrations expose the same repository", () => { + it("finds an exact repository option", () => { const options = buildRepositoryOptions(integrations, { 7: ["posthog/posthog"], 11: ["posthog/posthog"], }); - const selected = findRepositoryOption(options, { + expect( + findRepositoryOption(options, { + integrationId: 11, + repository: "posthog/posthog", + }), + ).toEqual({ integrationId: 11, + integrationLabel: "posthog", repository: "posthog/posthog", }); + }); + + it.each([ + [{ installation_id: "42", account: { name: "PostHog" } }, "PostHog"], + [{ installation_id: "43" }, "GitHub 43"], + ])( + "builds user integration options with the expected label", + (integration, integrationLabel) => { + expect( + buildUserRepositoryOptions([integration], { + [integration.installation_id]: ["posthog/code"], + }), + ).toEqual([ + { + integrationId: Number(integration.installation_id), + integrationLabel, + repository: "posthog/code", + }, + ]); + }, + ); - expect(selected).toEqual({ + it("treats a changed label as a different repository option", () => { + const option = { integrationId: 11, integrationLabel: "posthog", - repository: "posthog/posthog", - }); + repository: "posthog/code", + }; + + expect( + repositoryOptionsEqual( + [option], + [{ ...option, integrationLabel: "PostHog GitHub" }], + ), + ).toBe(false); }); - it("converts an option into a reusable repository selection payload", () => { - const options = buildRepositoryOptions(integrations, { - 11: ["posthog/code"], - }); + it.each([ + [0, 2, null], + [1, 2, "Some GitHub repositories could not be loaded. Pull to retry."], + [2, 2, "Could not load GitHub repositories. Pull to retry."], + ])( + "maps repository failures to the expected warning", + (failedCount, totalCount, expected) => { + expect(repositoryLoadWarning(failedCount, totalCount)).toBe(expected); + }, + ); - const selection = toRepositorySelection(options[0] ?? null); + it("converts an option into a repository selection", () => { + const selection = toRepositorySelection({ + integrationId: 11, + integrationLabel: "posthog", + repository: "posthog/code", + }); expect(selection).toEqual({ integrationId: 11, repository: "posthog/code", }); expect(isRepositorySelectionComplete(selection)).toBe(true); - expect( - isRepositorySelectionComplete({ - integrationId: null, - repository: "posthog/code", - }), - ).toBe(false); }); }); diff --git a/apps/mobile/src/features/tasks/utils/repositorySelection.ts b/apps/mobile/src/features/tasks/utils/repositorySelection.ts index 9e7b2cc8a8..bff8441a6a 100644 --- a/apps/mobile/src/features/tasks/utils/repositorySelection.ts +++ b/apps/mobile/src/features/tasks/utils/repositorySelection.ts @@ -2,6 +2,7 @@ import type { Integration, RepositoryOption, RepositorySelection, + UserGithubIntegration, } from "../types"; function getIntegrationLabel(integration: Integration): string { @@ -17,26 +18,67 @@ export function buildRepositoryOptions( repositoriesByIntegration: Record, ): RepositoryOption[] { return integrations - .flatMap((integration) => { - const repositories = repositoriesByIntegration[integration.id] ?? []; - - return repositories.map((repository) => ({ + .flatMap((integration) => + (repositoriesByIntegration[integration.id] ?? []).map((repository) => ({ integrationId: integration.id, integrationLabel: getIntegrationLabel(integration), repository, - })); - }) + })), + ) + .sort((left, right) => left.repository.localeCompare(right.repository)); +} + +export function buildUserRepositoryOptions( + integrations: UserGithubIntegration[], + repositoriesByInstallation: Record, +): RepositoryOption[] { + return integrations + .flatMap((integration) => + (repositoriesByInstallation[integration.installation_id] ?? []).map( + (repository) => ({ + integrationId: Number(integration.installation_id), + integrationLabel: + integration.account?.name ?? + `GitHub ${integration.installation_id}`, + repository, + }), + ), + ) .sort((left, right) => left.repository.localeCompare(right.repository)); } +export function repositoryOptionsEqual( + left: RepositoryOption[], + right: RepositoryOption[], +): boolean { + return ( + left.length === right.length && + left.every((option, index) => { + const other = right[index]; + return ( + other?.integrationId === option.integrationId && + other.integrationLabel === option.integrationLabel && + other.repository === option.repository + ); + }) + ); +} + +export function repositoryLoadWarning( + failedCount: number, + totalCount: number, +): string | null { + if (failedCount === 0) return null; + return failedCount === totalCount + ? "Could not load GitHub repositories. Pull to retry." + : "Some GitHub repositories could not be loaded. Pull to retry."; +} + export function findRepositoryOption( options: RepositoryOption[], selection: RepositorySelection, ): RepositoryOption | null { - if (!selection.integrationId || !selection.repository) { - return null; - } - + if (!selection.integrationId || !selection.repository) return null; return ( options.find( (option) => diff --git a/packages/core/src/inbox/reportMembership.ts b/packages/core/src/inbox/reportMembership.ts index e31621fba3..5ad2188929 100644 --- a/packages/core/src/inbox/reportMembership.ts +++ b/packages/core/src/inbox/reportMembership.ts @@ -141,6 +141,22 @@ export function isInboxDetailPath(pathname: string): boolean { return INBOX_DETAIL_PATH_RE.test(pathname); } +/** Which tab a list pathname belongs to; anything unrecognised reads as Pulls. */ +export function inboxTabFromPath(pathname: string): InboxTabKey { + if (pathname.startsWith(INBOX_TAB_LIST_ROUTE.reports)) return "reports"; + if (pathname.startsWith(INBOX_TAB_LIST_ROUTE.runs)) return "runs"; + if (pathname.startsWith(INBOX_TAB_LIST_ROUTE.dismissed)) return "dismissed"; + return "pulls"; +} + +/** + * Whether the reviewer-scope control means anything on this tab: Runs is + * unscoped and the Archive is a terminal list, so neither filters by reviewer. + */ +export function inboxScopeApplies(tab: InboxTabKey): boolean { + return tab !== "runs" && tab !== "dismissed"; +} + /** * PR tab membership: Responder shipped a draft PR and it is `ready` for review. * PRs that have already been merged/closed (`resolved`) or are still running diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index 83f6edc458..2da7a31ed6 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -918,7 +918,8 @@ export type ChannelActionType = | "view_activity" | "open_mention" | "canvas_mode_toggle" - | "activity_tab_change"; + | "activity_tab_change" + | "artifacts_view_change"; export interface ChannelActionProperties { action_type: ChannelActionType; @@ -939,6 +940,7 @@ export interface ChannelActionProperties { armed?: boolean; /** For activity_tab_change: the tab landed on. */ tab?: string; + view_mode?: "list" | "grid" | "masonry"; /** Whether the underlying mutation resolved successfully. */ success?: boolean; } @@ -947,6 +949,8 @@ export type DashboardActionType = | "open" | "create" | "delete" + /** The delete was undone inside its undo window, so nothing was removed. */ + | "delete_undo" | "rename" | "save" | "fork" diff --git a/packages/ui/src/features/canvas/components/ActivityView.tsx b/packages/ui/src/features/canvas/components/ActivityView.tsx index 136489b667..cb3e01eba2 100644 --- a/packages/ui/src/features/canvas/components/ActivityView.tsx +++ b/packages/ui/src/features/canvas/components/ActivityView.tsx @@ -31,6 +31,15 @@ import { useMarkTaskActivityRead } from "@posthog/ui/features/canvas/hooks/useMa import { useTaskActivity } from "@posthog/ui/features/canvas/hooks/useTaskActivity"; import { copyChannelLink } from "@posthog/ui/features/canvas/utils/copyChannelLink"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; +import { + PageHeader, + PageHeaderActions, + PageHeaderChip, + PageHeaderDescription, + PageHeaderHeading, + PageHeaderTitle, + PageHeaderTitleRow, +} from "@posthog/ui/primitives/PageHeader"; import { navigateToChannelTask, navigateToTaskDetail, @@ -38,7 +47,7 @@ import { import { track } from "@posthog/ui/shell/analytics"; import { Text } from "@radix-ui/themes"; import type { ReactNode } from "react"; -import { useCallback, useEffect, useMemo } from "react"; +import { memo, useCallback, useEffect, useMemo } from "react"; import { activityReadPayload, channelIdForName, @@ -278,8 +287,11 @@ export function ActivityView() { () => createChannelIdByName(folderChannels), [folderChannels], ); - const folderChannelIdFor = (channelName: string | null): string | null => - channelIdForName(folderIdByName, channelName); + const folderChannelIdFor = useCallback( + (channelName: string | null): string | null => + channelIdForName(folderIdByName, channelName), + [folderIdByName], + ); useEffect(() => { track(ANALYTICS_EVENTS.CHANNEL_ACTION, { action_type: "view_activity", @@ -287,77 +299,164 @@ export function ActivityView() { }); }, []); - return ( -
-
-
-
- - Activity - - - Tasks you're involved in across{" "} - {spacesLayout ? "spaces" : "channels"}. - -
- {unreadCount > 0 && ( - - )} -
-
- {isLoading && items.length === 0 ? ( -
- -
- ) : items.length === 0 ? ( - - - - - - No activity yet - - Tasks you create, get tagged in, or reply to across{" "} - {spacesLayout ? "spaces" : "channels"} land here. - - - - ) : ( -
- {items.map((item) => ( - - ))} - {hasNextPage && ( - - )} + const markAllReadButton = useMemo( + () => + unreadCount > 0 ? ( + + ) : null, + [unreadCount, unreadItems.length, isMarkingRead, markAllRead], + ); + + const feed = ( + + ); + + // The shared page header ships with the spaces layout; without it the page + // keeps the in-container title it has always had. Delete the legacy branch + // when the layout flag graduates. + if (!spacesLayout) { + return ( +
+
+
+
+ + Activity + + + Tasks you're involved in across{" "} + {spacesLayout ? "spaces" : "channels"}. +
- )} + {markAllReadButton} +
+
{feed}
+ ); + } + + return ( +
+ + + + Activity + {unreadCount > 0 && ( + }> + {unreadCount} unread + + )} + {markAllReadButton && ( + {markAllReadButton} + )} + + + Tasks you're involved in across{" "} + {spacesLayout ? "spaces" : "channels"}. + + + +
+
{feed}
+
); } + +/** + * The feed body. A memo'd child rather than JSX built in the parent: the parent + * picks between two page shells and returns early, and this way the branch it + * doesn't take costs nothing. + */ +const ActivityFeed = memo(function ActivityFeed({ + items, + isLoading, + spacesLayout, + folderChannelIdFor, + markRead, + currentUser, + hasNextPage, + isFetchingNextPage, + fetchNextPage, +}: { + items: TaskActivityItem[]; + isLoading: boolean; + spacesLayout: boolean; + folderChannelIdFor: (channelName: string | null) => string | null; + markRead: (item: TaskActivityItem) => void; + currentUser?: UserBasic | null; + hasNextPage: boolean; + isFetchingNextPage: boolean; + fetchNextPage: () => void; +}) { + if (isLoading && items.length === 0) { + return ( +
+ +
+ ); + } + + if (items.length === 0) { + return ( + + + + + + No activity yet + + Tasks you create, get tagged in, or reply to across{" "} + {spacesLayout ? "spaces" : "channels"} land here. + + + + ); + } + + return ( +
+ {items.map((item) => ( + + ))} + {hasNextPage && ( + + )} +
+ ); +}); diff --git a/packages/ui/src/features/canvas/components/ArtifactsViewToggle.tsx b/packages/ui/src/features/canvas/components/ArtifactsViewToggle.tsx new file mode 100644 index 0000000000..c2b91d3aae --- /dev/null +++ b/packages/ui/src/features/canvas/components/ArtifactsViewToggle.tsx @@ -0,0 +1,68 @@ +import { Kanban, ListIcon, SquaresFourIcon } from "@phosphor-icons/react"; +import { + ToggleGroup, + ToggleGroupItem, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@posthog/quill"; +import { + ARTIFACTS_VIEW_MODES, + type ArtifactsViewMode, + useArtifactsViewStore, +} from "@posthog/ui/features/canvas/stores/artifactsViewStore"; +import type { ComponentType } from "react"; + +const OPTIONS: { + mode: ArtifactsViewMode; + label: string; + Icon: ComponentType<{ size?: number; weight?: "bold" }>; +}[] = [ + { mode: "list", label: "List", Icon: ListIcon }, + { mode: "grid", label: "Grid", Icon: SquaresFourIcon }, + { mode: "masonry", label: "Masonry", Icon: Kanban }, +]; + +function isViewMode(value: string | undefined): value is ArtifactsViewMode { + return ARTIFACTS_VIEW_MODES.some((mode) => mode === value); +} + +// Layout switcher for the artifacts list. A quill ToggleGroup carries the +// pressed state itself, so there's no hand-rolled active styling here. +export function ArtifactsViewToggle({ channelId }: { channelId?: string }) { + const view = useArtifactsViewStore((s) => s.view); + const setView = useArtifactsViewStore((s) => s.setView); + + return ( + + { + // Pressing the active item would otherwise clear the group — a view + // is always on, so ignore the empty result. + const mode = next[0]; + if (isViewMode(mode)) setView(mode, channelId); + }} + > + {OPTIONS.map(({ mode, label, Icon }) => ( + + + + + } + /> + {label} + + ))} + + + ); +} diff --git a/packages/ui/src/features/canvas/components/ChannelBreadcrumb.test.tsx b/packages/ui/src/features/canvas/components/ChannelBreadcrumb.test.tsx index f7124726ee..0353b144bf 100644 --- a/packages/ui/src/features/canvas/components/ChannelBreadcrumb.test.tsx +++ b/packages/ui/src/features/canvas/components/ChannelBreadcrumb.test.tsx @@ -1,9 +1,21 @@ import { Theme } from "@radix-ui/themes"; import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Hoisted so the module factory below can read them, and each test can steer +// the current route / assert navigations. +const router = vi.hoisted(() => ({ + pathname: "/website/team/artifacts", + navigate: vi.fn(), +})); vi.mock("@tanstack/react-router", () => ({ - useNavigate: () => vi.fn(), + useNavigate: () => router.navigate, + useRouterState: ({ + select, + }: { + select: (state: { location: { pathname: string } }) => T; + }) => select({ location: { pathname: router.pathname } }), })); vi.mock("@posthog/ui/features/canvas/hooks/useChannelsLayout", () => ({ useChannelsLayout: () => true, @@ -12,6 +24,11 @@ vi.mock("@posthog/ui/features/canvas/hooks/useChannelsLayout", () => ({ import { ChannelBreadcrumb } from "./ChannelBreadcrumb"; describe("ChannelBreadcrumb", () => { + beforeEach(() => { + router.pathname = "/website/team/artifacts"; + router.navigate.mockClear(); + }); + it("closes title editing when the editable leaf changes", () => { const onRename = vi.fn(); const { rerender } = render( @@ -25,7 +42,14 @@ describe("ChannelBreadcrumb", () => { , ); - fireEvent.doubleClick(screen.getByText("Task A")); + // A renamable leaf stays a live control, so it isn't marked disabled. + expect(screen.getByRole("button", { name: "Task A" })).not.toHaveAttribute( + "aria-disabled", + ); + + // One click opens the editor — the leaf never navigates, so a click has + // nothing else to mean. + fireEvent.click(screen.getByRole("button", { name: "Task A" })); expect(screen.getByRole("textbox")).toHaveValue("Task A"); rerender( @@ -43,4 +67,65 @@ describe("ChannelBreadcrumb", () => { expect(screen.getByText("Task B")).toBeInTheDocument(); expect(onRename).not.toHaveBeenCalled(); }); + + it("navigates home from the root segment on a sub-page", () => { + render( + + + , + ); + + const root = screen.getByRole("button", { name: /Team/ }); + expect(root).not.toHaveAttribute("aria-disabled", "true"); + fireEvent.click(root); + expect(router.navigate).toHaveBeenCalledWith({ + to: "/website/$channelId", + params: { channelId: "team" }, + }); + }); + + it("links the middle segment to its section", () => { + const onMiddleClick = vi.fn(); + render( + + + , + ); + + // Every segment is a Button so they share padding and height; the leaf is + // the current page, so it's the disabled one. + expect(screen.getAllByRole("button")).toHaveLength(3); + fireEvent.click(screen.getByRole("button", { name: "Loops" })); + expect(onMiddleClick).toHaveBeenCalledTimes(1); + expect( + screen.getByRole("button", { name: "CI failure summary" }), + ).toHaveAttribute("aria-disabled", "true"); + }); + + it("disables the root segment on the space's own index", () => { + router.pathname = "/website/team"; + render( + + + , + ); + + const root = screen.getByRole("button", { name: /Team/ }); + expect(root).toHaveAttribute("aria-disabled", "true"); + fireEvent.click(root); + expect(router.navigate).not.toHaveBeenCalled(); + }); }); diff --git a/packages/ui/src/features/canvas/components/ChannelBreadcrumb.tsx b/packages/ui/src/features/canvas/components/ChannelBreadcrumb.tsx index e47614aa05..670f071518 100644 --- a/packages/ui/src/features/canvas/components/ChannelBreadcrumb.tsx +++ b/packages/ui/src/features/canvas/components/ChannelBreadcrumb.tsx @@ -1,5 +1,6 @@ import { Button, + cn, Tooltip, TooltipContent, TooltipTrigger, @@ -8,7 +9,7 @@ import { channelGlyph } from "@posthog/ui/features/canvas/components/channelGlyp import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { HeaderTitleEditor } from "@posthog/ui/features/task-detail/HeaderTitleEditor"; import { Flex, Text } from "@radix-ui/themes"; -import { useNavigate } from "@tanstack/react-router"; +import { useNavigate, useRouterState } from "@tanstack/react-router"; import { type ReactNode, useState } from "react"; interface ChannelBreadcrumbProps { @@ -19,14 +20,23 @@ interface ChannelBreadcrumbProps { * sidebar channel row and the channel-view header. */ channelId?: string; + /** + * An optional segment between the space and the leaf — the section a scene + * belongs to, e.g. "{space} / Loops / {loop}". `onClick` links it; without + * one it reads as a plain step. + */ + middle?: { icon?: ReactNode; label: string; onClick?: () => void }; /** Optional leading icon for the leaf segment (e.g. a canvas's tier icon). */ leafIcon?: ReactNode; - /** The trailing (current page) segment label. */ - leafLabel: string; + /** + * The trailing (current page) segment label. Omitted at a space's root, which + * renders the channel segment alone — same size and styling either way. + */ + leafLabel?: string; editScopeKey?: string; /** - * When provided, the leaf becomes inline-editable: double-click to rename, - * Enter or blur to submit, Escape to cancel. Receives the trimmed new value. + * When provided, the leaf becomes inline-editable: click to rename, Enter or + * blur to submit, Escape to cancel. Receives the trimmed new value. */ onRename?: (next: string) => void; /** Right-aligned slot pushed to the far end of the bar (e.g. an opener). */ @@ -35,11 +45,13 @@ interface ChannelBreadcrumbProps { // "# channel / leaf" header breadcrumb shared across channel scenes (CONTEXT.md, // new + existing tasks, canvases). The leaf can carry a tier icon and, when -// onRename is given, edits inline using the same editor as task titles. When -// channelId is given, the "# channel" segment links back to the channel home. +// onRename is given, edits inline on a single click using the same editor as +// task titles. When channelId is given, the "# channel" segment links back to +// the channel home. export function ChannelBreadcrumb({ channelName, channelId, + middle, leafIcon, leafLabel, editScopeKey, @@ -47,84 +59,164 @@ export function ChannelBreadcrumb({ trailing, }: ChannelBreadcrumbProps) { const spacesLayout = useChannelsLayout(); - const currentEditScope = editScopeKey ?? leafLabel; + // Only a leaf is renamable, so the scope key falls back to its label. + const currentEditScope = editScopeKey ?? leafLabel ?? ""; const [editingScope, setEditingScope] = useState(null); const editing = editingScope === currentEditScope; const navigate = useNavigate(); - - const channelSegment = ( - <> - {channelGlyph(channelName, { - size: 12, - space: spacesLayout, - className: "mt-px shrink-0 text-muted-foreground/80", - })} - - {channelName} - - - ); + const pathname = useRouterState({ select: (s) => s.location.pathname }); + const atChannelHome = channelId + ? pathname === `/website/${channelId}` + : false; return ( - - {channelId ? ( - - ) : ( -
{channelSegment}
- )} - / -
- {leafIcon && ( - {leafIcon} - )} - {editing && onRename ? ( - { - setEditingScope(null); - onRename(next); - }} - onCancel={() => setEditingScope(null)} + {/* flex-1 so the inline editor can stretch across the row; the trailing + slot still sits at the far end. */} + + + void navigate({ + to: "/website/$channelId", + params: { channelId }, + }) + : undefined + } + /> + {middle && ( + <> + + - ) : ( - - setEditingScope(currentEditScope) - : undefined - } + + )} + {leafLabel !== undefined && ( + <> + + {editing && onRename ? ( + // Matches the segment it replaces — same height, padding and type + // scale as a `size="sm"` button — so opening the editor doesn't + // jump the row. It takes the rest of the row, since a long name is + // exactly what you're most likely to be editing. + { + setEditingScope(null); + onRename(next); + }} + onCancel={() => setEditingScope(null)} + className="h-6 px-2 font-normal text-[13px]" + /> + ) : onRename ? ( + // Only a renamable leaf gets a tooltip: it carries a user-authored + // name that can be long enough to truncate. Fixed section labels + // never overflow, so a tooltip there is just noise. + + }> + {/* A renamable leaf is a live control — a click opens the + editor — so it reads as one: full-strength text, pointer + cursor, hover fill. */} + setEditingScope(currentEditScope)} /> - } - > - {leafLabel} - - {leafLabel} - - )} -
+ + {leafLabel} + + ) : ( + + )} + + )}
{trailing}
); } + +/** + * One segment of the breadcrumb. Always a Button, so every segment carries the + * same padding, height and icon gap whether or not it goes anywhere — the leaf + * used to be bare text, which left it visually adrift from its siblings. + * + * Without `onClick` the segment is genuinely inert: `aria-disabled` (so quill + * drops the hover fill and assistive tech reads it as unavailable) plus + * `pointer-events-none`, and out of the tab order. The disabled dimming is + * overridden — a breadcrumb has to stay readable. + */ +function BreadcrumbSegment({ + icon, + label, + strong, + muted, + onClick, + ...rest +}: { + icon?: ReactNode; + label: string; + /** The root segment carries the space name, which reads heavier. */ + strong?: boolean; + /** The leaf is the current page, so it sits back from the linked segments. */ + muted?: boolean; + /** Navigates, or (on a renamable leaf) opens the inline editor. */ + onClick?: () => void; +}) { + const interactive = Boolean(onClick); + + return ( + + ); +} + +function BreadcrumbSeparator() { + return ( + / + ); +} diff --git a/packages/ui/src/features/canvas/components/ChannelHeader.tsx b/packages/ui/src/features/canvas/components/ChannelHeader.tsx index 289cb5b713..40f65fb29e 100644 --- a/packages/ui/src/features/canvas/components/ChannelHeader.tsx +++ b/packages/ui/src/features/canvas/components/ChannelHeader.tsx @@ -1,25 +1,64 @@ import { Button, cn } from "@posthog/quill"; +import { ChannelBreadcrumb } from "@posthog/ui/features/canvas/components/ChannelBreadcrumb"; import { ChannelTabs } from "@posthog/ui/features/canvas/components/ChannelTabs"; import { channelGlyph } from "@posthog/ui/features/canvas/components/channelGlyph"; +import { + type ChannelPageKey, + channelPageIcon, + channelPageLabel, +} from "@posthog/ui/features/canvas/components/channelPages"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { useMarkChannelSeen } from "@posthog/ui/features/canvas/hooks/useMarkChannelSeen"; import { Text } from "@radix-ui/themes"; import { useNavigate, useRouterState } from "@tanstack/react-router"; -// The shared channel header. The new layout drops the section tab strip — the -// channel sidebar carries those entries — while flag off keeps it. Starring -// lives on the sidebar back row and the channel list, not here. -export function ChannelHeader({ channelId }: { channelId: string }) { - const navigate = useNavigate(); +// The shared channel header. Every space scene renders the same breadcrumb — +// the root segment is identical whether or not there's a leaf, so the space +// name doesn't change size between the space home and its sub-pages. The new +// layout drops the section tab strip (the channel sidebar carries those +// entries); flag off keeps it. Starring lives on the sidebar back row and the +// channel list, not here. +export function ChannelHeader({ + channelId, + page, +}: { + channelId: string; + /** + * Which space page this is — supplies the leaf's label and icon. Every space + * page names itself, the feed included ("{space} / Feed"); omitting it leaves + * the root segment alone, for scenes that carry no page of their own. + */ + page?: ChannelPageKey; +}) { const channelsLayout = useChannelsLayout(); const { channels } = useChannels(); const channelName = channels.find((c) => c.id === channelId)?.name; - const pathname = useRouterState({ select: (s) => s.location.pathname }); - const isHome = pathname === `/website/${channelId}`; // Every channel surface renders this header, so mark the channel read here. useMarkChannelSeen(channelName); + // Channels-layout off keeps the header it has always had: the channel pill + // plus the section tab strip, no breadcrumb. Delete this branch when the + // layout flag graduates. + if (!channelsLayout) return ; + + return ( + + ); +} + +function LegacyChannelHeader({ channelId }: { channelId: string }) { + const navigate = useNavigate(); + const { channels } = useChannels(); + const channelName = channels.find((c) => c.id === channelId)?.name; + const pathname = useRouterState({ select: (s) => s.location.pathname }); + const isHome = pathname === `/website/${channelId}`; + return (
- {!channelsLayout && } +
); } diff --git a/packages/ui/src/features/canvas/components/ChannelNav.tsx b/packages/ui/src/features/canvas/components/ChannelNav.tsx index 23f0a6fe81..9c324c7676 100644 --- a/packages/ui/src/features/canvas/components/ChannelNav.tsx +++ b/packages/ui/src/features/canvas/components/ChannelNav.tsx @@ -98,6 +98,10 @@ interface NavButtonProps extends ComponentPropsWithRef<"button"> { badge?: ReactNode; } +// Same quill Button as NavIcon above — this variant only exists because the +// Activity entry is a Popover trigger, so it needs to forward the trigger's +// props and ref. Hand-rolling the button here left it a size larger than its +// neighbours. function NavButton({ icon, label, @@ -109,23 +113,23 @@ function NavButton({ ...buttonProps }: NavButtonProps) { return ( - + ); } diff --git a/packages/ui/src/features/canvas/components/ChannelSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelSidebar.tsx index e1589e2a1f..c069e1ac7d 100644 --- a/packages/ui/src/features/canvas/components/ChannelSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ChannelSidebar.tsx @@ -1,10 +1,8 @@ import { - BookOpenTextIcon, ChatsCircleIcon, FunnelSimple as FunnelSimpleIcon, MagnifyingGlass, PackageIcon, - RepeatIcon, } from "@phosphor-icons/react"; import type { CreatedByFilter } from "@posthog/core/canvas/channelItems"; import { filterChannelItems } from "@posthog/core/canvas/channelItems"; @@ -32,6 +30,11 @@ import type { TaskRunStatus } from "@posthog/shared/domain-types"; import { ChannelBackRow } from "@posthog/ui/features/canvas/components/ChannelBackRow"; import { ChannelItemRow } from "@posthog/ui/features/canvas/components/ChannelItemRow"; import { ChannelsFab } from "@posthog/ui/features/canvas/components/ChannelsFab"; +import { + type ChannelPageKey, + channelPageIcon, + channelPageLabel, +} from "@posthog/ui/features/canvas/components/channelPages"; import { useChannelItems } from "@posthog/ui/features/canvas/hooks/useChannelItems"; import { useCommandCenterStore } from "@posthog/ui/features/command-center/commandCenterStore"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; @@ -42,7 +45,7 @@ import { useTasks } from "@posthog/ui/features/tasks/useTasks"; import { navigateToCommandCenter } from "@posthog/ui/router/navigationBridge"; import { logger } from "@posthog/ui/shell/logger"; import { useNavigate, useRouterState } from "@tanstack/react-router"; -import { type ReactNode, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; const CREATED_BY_OPTIONS: readonly { value: CreatedByFilter; label: string }[] = [ @@ -338,16 +341,17 @@ export function ChannelSidebar({ channelId }: { channelId: string }) { /> ); + // Label and icon come from the shared space-page table, so a sidebar row and + // the header breadcrumb for the same page can never disagree. const sectionRow = ( - label: string, - icon: ReactNode, + page: ChannelPageKey, to: string, onClick: () => void, ) => ( @@ -359,15 +363,13 @@ export function ChannelSidebar({ channelId }: { channelId: string }) {
{sectionRow( - "Feed", - , + "home", base, () => void navigate({ to: "/website/$channelId", params: { channelId } }), )} {sectionRow( - "Context", - , + "context", `${base}/context`, () => void navigate({ @@ -377,8 +379,7 @@ export function ChannelSidebar({ channelId }: { channelId: string }) { )} {loopsEnabled && sectionRow( - "Loops", - , + "loops", `${base}/loops`, () => void navigate({ @@ -387,8 +388,7 @@ export function ChannelSidebar({ channelId }: { channelId: string }) { }), )} {sectionRow( - "Artifacts", - , + "artifacts", `${base}/artifacts`, () => void navigate({ diff --git a/packages/ui/src/features/canvas/components/FreeformPreview.tsx b/packages/ui/src/features/canvas/components/FreeformPreview.tsx new file mode 100644 index 0000000000..37b1f62bfc --- /dev/null +++ b/packages/ui/src/features/canvas/components/FreeformPreview.tsx @@ -0,0 +1,137 @@ +import { ShapesIcon, WarningIcon } from "@phosphor-icons/react"; +import { cn, Skeleton, Text } from "@posthog/quill"; +import { FreeformCanvas } from "@posthog/ui/features/canvas/freeform/FreeformCanvas"; +import { handleFreeformDataRequest } from "@posthog/ui/features/canvas/freeform/freeformDataBridge"; +import { useInView } from "@posthog/ui/primitives/hooks/useInView"; +import { ErrorBoundary } from "@posthog/ui/shell/ErrorBoundary"; +import { Box, Flex } from "@radix-ui/themes"; +import { useQueryClient } from "@tanstack/react-query"; +import { type ReactNode, useCallback } from "react"; + +// Render each canvas's live app at 1/SCALE of the card width, then shrink so it +// fits inside the preview frame as a thumbnail. +const PREVIEW_SCALE = 0.4; + +// Mount a preview only while it's near the viewport, and UNMOUNT it once it +// scrolls away (once: false). This caps how many full preview trees / sandbox +// iframes are live at any time, so a channel with many large canvases doesn't +// accumulate pages of off-screen DOM. The margin pre-mounts a little early so +// scrolling doesn't flash an empty frame. The fixed-height frame keeps the +// layout stable across mount/unmount (no scroll jump). +const PREVIEW_VIEWPORT = { once: false, rootMargin: "400px 0px" } as const; + +// A freeform (React-in-iframe) canvas preview: the app rendered at PREVIEW_SCALE +// in a clipped frame. Deferred until near the viewport, and runs with NO +// analytics so it fires no events. +export function FreeformPreview({ + code, + height = 176, + className, +}: { + code?: string; + /** Frame height in px. Taller frames simply reveal more of the app. */ + height?: number; + className?: string; +}) { + const [ref, inView] = useInView(PREVIEW_VIEWPORT); + + // Preview data handler: swallow captures so a thumbnail never emits analytics + // events, but let reads through (cached, shared with the full view) so the + // preview shows real-ish content. (posthog-js itself is never booted — no + // `analytics` prop — so there's no autocapture/pageview/replay either.) + const queryClient = useQueryClient(); + const onDataRequest = useCallback( + (method: string, payload: unknown) => + method === "capture" + ? Promise.resolve({ ok: true }) + : handleFreeformDataRequest(method, payload, queryClient), + [queryClient], + ); + + return ( + + {code ? ( + inView ? ( + + } + label="Preview unavailable" + /> + } + > + + + + ) : ( + // Deferred, not broken: a shimmer reads as "coming", where a line of + // text reads as the final state. + + ) + ) : ( + } + label="Nothing built yet" + /> + )} + + ); +} + +function PreviewPlaceholder({ + icon, + label, +}: { + icon?: ReactNode; + label: string; +}) { + return ( + + {icon} + + {label} + + + ); +} + +/** Stand-in for a preview that hasn't mounted yet — the shape of a small app: + * a title bar, a chart block, a couple of rows. */ +function PreviewSkeleton() { + return ( +
+ + +
+ + +
+
+ ); +} diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx index 4ea4783e1c..d9c9569358 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx @@ -1,23 +1,52 @@ -import { CaretRightIcon } from "@phosphor-icons/react"; +import { CaretRightIcon, FilesIcon, TrashIcon } from "@phosphor-icons/react"; import type { ChannelTaskRecord } from "@posthog/core/canvas/channelTaskSchemas"; import type { DashboardSummary } from "@posthog/core/canvas/dashboardSchemas"; +import { + Badge, + Card, + CardContent, + cn, + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, + Text, +} from "@posthog/quill"; import { formatRelativeTimeShort } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { useArchivedTaskIds } from "@posthog/ui/features/archive/useArchivedTaskIds"; +import { ArtifactsViewToggle } from "@posthog/ui/features/canvas/components/ArtifactsViewToggle"; import { ChannelHeader } from "@posthog/ui/features/canvas/components/ChannelHeader"; import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon"; +import { FreeformPreview } from "@posthog/ui/features/canvas/components/FreeformPreview"; import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { useChannelTasks } from "@posthog/ui/features/canvas/hooks/useChannelTasks"; import { useDashboards } from "@posthog/ui/features/canvas/hooks/useDashboards"; +import { useArtifactsViewStore } from "@posthog/ui/features/canvas/stores/artifactsViewStore"; +import { useIsCanvasPendingDelete } from "@posthog/ui/features/canvas/stores/pendingCanvasDeleteStore"; +import { masonryPreviewHeight } from "@posthog/ui/features/canvas/utils/masonryPreviewHeight"; import { usePrArtifact } from "@posthog/ui/features/git-interaction/usePrArtifact"; import { useTasks } from "@posthog/ui/features/tasks/useTasks"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; +import { + PageHeader, + PageHeaderActions, + PageHeaderChip, + PageHeaderDescription, + PageHeaderHeading, + PageHeaderTitle, + PageHeaderTitleRow, +} from "@posthog/ui/primitives/PageHeader"; import { track } from "@posthog/ui/shell/analytics"; import { openExternalUrl } from "@posthog/ui/shell/openExternal"; -import { Text } from "@radix-ui/themes"; import { useNavigate } from "@tanstack/react-router"; import { type ReactNode, useCallback, useEffect, useMemo } from "react"; +// Uniform media height for the grid: cards line up row to row, and a PR tile +// (which has nothing to preview) fills the same band as a canvas thumbnail. +const GRID_PREVIEW_HEIGHT = 176; + // Artifacts are the durable outputs of a channel's work. Canvases for now; PRs // are surfaced from each filed task's latest run output. More kinds (reports, // files, …) slot into this union later. @@ -29,6 +58,8 @@ type ArtifactItem = ts: number; templateId: string; dashboardId: string; + /** Live React source, along for the ride so cards preview without a get(). */ + code?: string; } | { kind: "pr"; @@ -40,10 +71,12 @@ type ArtifactItem = // A channel's artifacts: canvases and the pull requests produced by its tasks, // most recent first. Sibling of the History tab, but scoped to outputs rather -// than the full activity stream. +// than the full activity stream. The view toggle switches between a dense row +// list and card layouts that preview each canvas live. export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) { const spacesLayout = useChannelsLayout(); const navigate = useNavigate(); + const view = useArtifactsViewStore((s) => s.view); useEffect(() => { track(ANALYTICS_EVENTS.CHANNEL_ACTION, { @@ -54,7 +87,10 @@ export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) { }, [channelId]); useSetHeaderContent( - useMemo(() => , [channelId]), + useMemo( + () => , + [channelId], + ), ); const { dashboards } = useDashboards(channelId); @@ -71,6 +107,7 @@ export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) { ts: d.updatedAt, templateId: d.templateId, dashboardId: d.id, + code: d.code, }), ); @@ -124,50 +161,212 @@ export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) { ); return ( -
-
- {items.length === 0 ? ( -
- - No artifacts yet - - +
+ {/* Full-bleed header over a container-width body — the Inbox shape. Ships + behind the spaces layout like every other space page header; off, + the view switcher rides above the list instead. */} + {spacesLayout ? ( + + + + Artifacts + {items.length > 0 && ( + }> + {items.length} item{items.length === 1 ? "" : "s"} + + )} + + + + + Canvases and pull requests from this{" "} - {spacesLayout ? "space's" : "channel's"} tasks show up here. - -
- ) : ( -
- {items.map((item) => - item.kind === "canvas" ? ( - + + + ) : null} + +
+ {/* Full width, flush with the page header above it — every layout + here (rows and cards alike) is a scannable list, not prose, so a + measure cap just strands whitespace on wide windows. */} +
+ {!spacesLayout && ( +
+ + {items.length === 0 + ? "Artifacts" + : `${items.length} artifact${items.length === 1 ? "" : "s"}`} + + +
+ )} + {items.length === 0 ? ( + + + + + + No artifacts yet + + Canvases and pull requests from this{" "} + {spacesLayout ? "space's" : "channel's"} tasks show up here. + + + + ) : view === "list" ? ( +
+ {items.map((item) => ( + openCanvas(item.dashboardId)} + item={item} + onOpenCanvas={openCanvas} + onOpenPr={openPr} /> - ) : ( - + ) : view === "grid" ? ( + // items-stretch + a full-height card: a PR tile (no preview to + // show) matches the canvas cards in its row instead of ending + // short. +
+ {items.map((item) => ( + - ), - )} -
- )} + ))} +
+ ) : ( + // CSS columns rather than a JS masonry: cards are self-contained and + // never reflow into each other, so break-inside-avoid is enough. The + // trade-off is column-major order — newest runs down column one, not + // across the row — which is fine for a browse-y wall of previews. +
+ {items.map((item) => ( +
+ +
+ ))} +
+ )} +
); } +function ArtifactListItem({ + item, + onOpenCanvas, + onOpenPr, +}: { + item: ArtifactItem; + onOpenCanvas: (dashboardId: string) => void; + onOpenPr: (safeUrl: string) => void; +}) { + return item.kind === "canvas" ? ( + + ) : ( + + ); +} + +function ArtifactCard({ + item, + previewHeight, + fillHeight, + onOpenCanvas, + onOpenPr, +}: { + item: ArtifactItem; + previewHeight: number; + /** Grid only: stretch to the tallest card in the row. */ + fillHeight?: boolean; + onOpenCanvas: (dashboardId: string) => void; + onOpenPr: (safeUrl: string) => void; +}) { + return item.kind === "canvas" ? ( + + ) : ( + + ); +} + +// A canvas artifact row. While the canvas is inside its delete-undo window the +// row stays put — its template icon becomes a pulsing trash can and the row +// stops opening — so undoing puts it back exactly where it was. +function CanvasArtifactRow({ + dashboardId, + templateId, + title, + ts, + onClick, +}: { + dashboardId: string; + templateId: string; + title: string; + ts: number; + onClick: (dashboardId: string) => void; +}) { + const deleting = useIsCanvasPendingDelete(dashboardId); + + return ( + + ) : ( + iconForTemplate(templateId, { size: 15, className: "text-violet-9" }) + ) + } + title={title} + subtitle={ + deleting ? "Deleting…" : `Canvas · ${formatRelativeTimeShort(ts)}` + } + onClick={deleting ? undefined : () => onClick(dashboardId)} + /> + ); +} + // A PR artifact row. The PR's lifecycle state (open / draft / merged / closed) // comes from usePrArtifact, which also gates the URL — PR links come from run // output, so a row must not fetch from whatever host that names. @@ -225,7 +424,7 @@ function ArtifactRow({ type="button" onClick={onClick} disabled={!onClick} - className="group flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-left transition-colors enabled:hover:bg-gray-3" + className="group flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-left transition-colors enabled:hover:bg-fill-hover" > {title} - + {subtitle} ); } + +// The card form of a canvas artifact: a live preview of the canvas above its +// title. Same delete-undo behaviour as the row — the card stays in place, +// dimmed, until the undo window closes. +function CanvasArtifactCard({ + dashboardId, + templateId, + title, + ts, + code, + previewHeight, + fillHeight, + onClick, +}: { + dashboardId: string; + templateId: string; + title: string; + ts: number; + code?: string; + previewHeight: number; + fillHeight?: boolean; + onClick: (dashboardId: string) => void; +}) { + const deleting = useIsCanvasPendingDelete(dashboardId); + + return ( + + + {deleting && ( +
+ + + Deleting… + +
+ )} + + } + icon={iconForTemplate(templateId, { + size: 14, + className: "text-violet-9", + })} + title={title} + badge="Canvas" + subtitle={ + deleting ? "Deleting…" : `Updated ${formatRelativeTimeShort(ts)}` + } + dimmed={deleting} + fillHeight={fillHeight} + onClick={deleting ? undefined : () => onClick(dashboardId)} + /> + ); +} + +// The card form of a PR artifact. A PR has nothing to preview, so its media +// slot is a short tinted band carrying the lifecycle icon — which also keeps PR +// cards visibly shorter than canvas cards in the masonry layout. +function PrArtifactCard({ + title, + prUrl, + ts, + mediaHeight, + fillHeight, + onClick, +}: { + title: string; + prUrl: string; + ts: number; + /** Grid only: match the canvas thumbnails' band instead of a short strip. */ + mediaHeight?: number; + fillHeight?: boolean; + onClick: (safeUrl: string) => void; +}) { + const { + safeUrl, + title: prTitle, + stateLabel, + Icon, + iconColor, + accentColor, + } = usePrArtifact(prUrl); + + const subtitle = [prTitle, formatRelativeTimeShort(ts)] + .filter(Boolean) + .join(" · "); + + return ( + + +
+ } + icon={} + title={title} + badge={stateLabel || "Pull request"} + subtitle={subtitle} + fillHeight={fillHeight} + onClick={safeUrl ? () => onClick(safeUrl) : undefined} + /> + ); +} + +function ArtifactCardShell({ + media, + icon, + title, + badge, + subtitle, + dimmed, + fillHeight, + onClick, +}: { + media: ReactNode; + icon: ReactNode; + title: string; + badge: string; + subtitle: string; + dimmed?: boolean; + /** Grid only: fill the row so neighbouring cards end at the same line. */ + fillHeight?: boolean; + /** Absent for a card with nowhere safe to go — a non-github PR link. */ + onClick?: () => void; +}) { + return ( + + ); +} diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelHistory.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHistory.tsx index 4780ce51b0..2dfe4a6106 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelHistory.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelHistory.tsx @@ -42,7 +42,10 @@ export function WebsiteChannelHistory({ channelId }: { channelId: string }) { }, [channelId]); useSetHeaderContent( - useMemo(() => , [channelId]), + useMemo( + () => , + [channelId], + ), ); const { dashboards } = useDashboards(channelId); diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx index bee6dd19a9..ba9c092d2c 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx @@ -96,7 +96,10 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { }, [backendChannel, feedMessages]); useSetHeaderContent( - useMemo(() => , [channelId]), + useMemo( + () => , + [channelId], + ), ); const composerRef = useRef(null); diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelLoops.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelLoops.tsx index 38231afa59..44efad88aa 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelLoops.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelLoops.tsx @@ -1,7 +1,17 @@ import { CloudIcon, PlusIcon } from "@phosphor-icons/react"; import { ChannelHeader } from "@posthog/ui/features/canvas/components/ChannelHeader"; +import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; import { Button } from "@posthog/ui/primitives/Button"; +import { + PageHeader, + PageHeaderActions, + PageHeaderChip, + PageHeaderDescription, + PageHeaderHeading, + PageHeaderTitle, + PageHeaderTitleRow, +} from "@posthog/ui/primitives/PageHeader"; import { navigateToNewLoop } from "@posthog/ui/router/navigationBridge"; import { Flex, Heading, Text } from "@radix-ui/themes"; import { useMemo } from "react"; @@ -47,6 +57,7 @@ function contextQuickStarts(name: string): { label: string; prompt: string }[] { * this context. `channelId` is the desktop folder id, matching `context_target.folder_id`. */ export function WebsiteChannelLoops({ channelId }: { channelId: string }) { const { data: loops, isLoading, isError } = useLoops(); + const spacesLayout = useChannelsLayout(); const limits = useLoopLimits(); const limitReason = limits?.atLimit === true @@ -58,7 +69,10 @@ export function WebsiteChannelLoops({ channelId }: { channelId: string }) { const isPersonal = contextName === PERSONAL_CHANNEL_NAME; useSetHeaderContent( - useMemo(() => , [channelId]), + useMemo( + () => , + [channelId], + ), ); const attachedLoops = useMemo( @@ -99,56 +113,78 @@ export function WebsiteChannelLoops({ channelId }: { channelId: string }) { navigateToNewLoop(); }; + const title = isPersonal ? "Loops" : `Automate #${contextName}`; + const description = + "Put your work on autopilot. Loops run on a schedule, on an API call, or when something happens on GitHub. You can finally close the laptop!"; + const createButton = ( + + ); + return ( + {/* The shared page header ships with the spaces layout; without it the + in-container title block below is used. Delete that branch when the + layout flag graduates. */} + {spacesLayout && ( + + + + {title} + }> + Runs entirely in the cloud + + {createButton} + + {description} + + + )}
-
- - - - {isPersonal ? "Loops" : `Automate #${contextName}`} - - - - - Runs entirely in the cloud - + {!spacesLayout && ( +
+ + + {title} + + + + Runs entirely in the cloud + + + + {description} + - - Put your work on autopilot. Loops run on a schedule, on an API - call, or when something happens on GitHub. You can finally close - the laptop! - - - -
+ {createButton} +
+ )} {isLoading ? ( diff --git a/packages/ui/src/features/canvas/components/WebsiteContext.tsx b/packages/ui/src/features/canvas/components/WebsiteContext.tsx index 3c815483b2..4b8f783b37 100644 --- a/packages/ui/src/features/canvas/components/WebsiteContext.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteContext.tsx @@ -13,6 +13,7 @@ import { import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { ChannelHeader } from "@posthog/ui/features/canvas/components/ChannelHeader"; import { CreateChannelModal } from "@posthog/ui/features/canvas/components/CreateChannelModal"; +import { channelPageIcon } from "@posthog/ui/features/canvas/components/channelPages"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { @@ -22,6 +23,14 @@ import { } from "@posthog/ui/features/canvas/hooks/useFolderInstructions"; import { MarkdownRenderer } from "@posthog/ui/features/editor/components/MarkdownRenderer"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; +import { + PageHeader, + PageHeaderChip, + PageHeaderDescription, + PageHeaderHeading, + PageHeaderTitle, + PageHeaderTitleRow, +} from "@posthog/ui/primitives/PageHeader"; import { track } from "@posthog/ui/shell/analytics"; import { Box, @@ -90,7 +99,7 @@ export function WebsiteContext({ channelId }: WebsiteContextProps) { }, [latest?.content, hasDraft]); const headerContent = useMemo( - () => , + () => , [channelId], ); useSetHeaderContent(headerContent); @@ -163,6 +172,27 @@ export function WebsiteContext({ channelId }: WebsiteContextProps) { return ( + {/* The shared page header ships with the spaces layout; without it the + page opens straight onto its mode toolbar as it always has. */} + {spacesLayout && ( + + + + Context + {latest?.version != null && ( + + v{latest.version} + + )} + + + Background every agent working in this{" "} + {spacesLayout ? "space" : "channel"} reads before it starts — what + lives here, who cares about it, and how to work on it. + + + + )} + - + + + {deleting && ( + + + + Deleting… + + + )} + @@ -155,64 +176,6 @@ const DashboardCard = memo(function DashboardCard({ ); }); -// A freeform (React-in-iframe) canvas preview: the app rendered at PREVIEW_SCALE -// in a clipped frame, the same shape as DashboardPreview. Deferred until near -// the viewport, and runs with NO analytics so it fires no events. -function FreeformPreview({ code }: { code?: string }) { - const [ref, inView] = useInView(PREVIEW_VIEWPORT); - - // Preview data handler: swallow captures so a thumbnail never emits analytics - // events, but let reads through (cached, shared with the full view) so the - // preview shows real-ish content. (posthog-js itself is never booted — no - // `analytics` prop — so there's no autocapture/pageview/replay either.) - const queryClient = useQueryClient(); - const onDataRequest = useCallback( - (method: string, payload: unknown) => - method === "capture" - ? Promise.resolve({ ok: true }) - : handleFreeformDataRequest(method, payload, queryClient), - [queryClient], - ); - - return ( - - {code ? ( - inView ? ( - - } - > - - - - ) : ( - - ) - ) : ( - - )} - - ); -} - function DashboardCardMenu({ id, name, @@ -223,31 +186,24 @@ function DashboardCardMenu({ channelId: string; }) { const [open, setOpen] = useState(false); - const { deleteDashboard, isDeleting } = useDashboardMutations(); + const spacesLayout = useChannelsLayout(); + const containerNoun = spacesLayout ? "space" : "channel"; + // "Delete…" opens a confirmation rather than deleting inline — the canvas and + // its version history go away for everyone in the space. + const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); + const { invalidateDashboards } = useDashboardMutations(); - const onDelete = () => { - deleteDashboard(id) - .then(() => { - track(ANALYTICS_EVENTS.DASHBOARD_ACTION, { - action_type: "delete", - surface: "dashboards_grid", - channel_id: channelId, - dashboard_id: id, - success: true, - }); - }) - .catch((error) => { - track(ANALYTICS_EVENTS.DASHBOARD_ACTION, { - action_type: "delete", - surface: "dashboards_grid", - channel_id: channelId, - dashboard_id: id, - success: false, - }); - toast.error("Couldn't delete canvas", { - description: error instanceof Error ? error.message : String(error), - }); - }); + // The card disappears immediately, but the delete isn't sent until the undo + // toast's timer runs out — Undo simply cancels it. + const confirmDelete = () => { + setConfirmDeleteOpen(false); + deleteCanvasWithUndo({ + dashboardId: id, + channelId, + name, + surface: "dashboards_grid", + invalidate: invalidateDashboards, + }); }; return ( @@ -280,28 +236,38 @@ function DashboardCardMenu({ setConfirmDeleteOpen(true)} > - Delete + Delete… + {/* Destructive confirm for "Delete…" — the canvas goes for everyone. */} + + + + Delete canvas + + Delete {name}? Its code and + version history go for everyone in the {containerNoun}. You get a + few seconds to undo, then it's permanent. + + + + + Cancel + + } + /> + + + + ); } - -function PreviewPlaceholder({ label }: { label: string }) { - return ( - - - {label} - - - ); -} diff --git a/packages/ui/src/features/canvas/components/WebsiteLayout.tsx b/packages/ui/src/features/canvas/components/WebsiteLayout.tsx index 70105c1e05..a98cd7256d 100644 --- a/packages/ui/src/features/canvas/components/WebsiteLayout.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteLayout.tsx @@ -5,9 +5,17 @@ import { LinkIcon, PencilSimpleIcon, PushPinIcon, + TrashIcon, XIcon, } from "@phosphor-icons/react"; import { + AlertDialog, + AlertDialogClose, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, Button, DropdownMenu, DropdownMenuContent, @@ -17,7 +25,12 @@ import { import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { ChannelBreadcrumb } from "@posthog/ui/features/canvas/components/ChannelBreadcrumb"; import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon"; +import { + channelPageIcon, + channelPageLabel, +} from "@posthog/ui/features/canvas/components/channelPages"; import { NewCanvasMenu } from "@posthog/ui/features/canvas/components/NewCanvasMenu"; +import { deleteCanvasWithUndo } from "@posthog/ui/features/canvas/deleteCanvasWithUndo"; import { CanvasFrameHost } from "@posthog/ui/features/canvas/freeform/CanvasFrameHost"; import { useCanvasFrameStore } from "@posthog/ui/features/canvas/freeform/canvasFrameStore"; import { CANVAS_QUERY_KEY } from "@posthog/ui/features/canvas/freeform/freeformDataBridge"; @@ -50,7 +63,7 @@ import { useParams, useRouterState, } from "@tanstack/react-router"; -import type { ReactNode } from "react"; +import { type ReactNode, useState } from "react"; function threadIdFor(dashboardId: string): string { return `dashboard:${dashboardId}`; @@ -69,11 +82,37 @@ function FreeformEditControls({ dashboardId: string; }) { const navigate = useNavigate(); + // Pinning is scoped to whatever holds the canvas; the new layout calls that a + // space, the old one a channel. + const spacesLayout = useChannelsLayout(); + const containerNoun = spacesLayout ? "space" : "channel"; const editing = useIsDashboardEditing(dashboardId); const setEditing = useDashboardEditStore((s) => s.setEditing); const { dashboard } = useDashboard(dashboardId); - const { forkFreeform, isCreating, setPinned } = useDashboardMutations(); + const { forkFreeform, isCreating, setPinned, invalidateDashboards } = + useDashboardMutations(); const isPinned = dashboard?.pinnedAt != null; + // "Delete…" opens a confirmation rather than deleting inline — the canvas and + // its version history go away for everyone in the space. + const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); + + // Once confirmed the canvas vanishes from every list and we leave for the + // space's artifacts list, but the delete isn't sent until the undo toast's + // timer runs out — Undo simply cancels it. + const confirmDelete = () => { + setConfirmDeleteOpen(false); + deleteCanvasWithUndo({ + dashboardId, + channelId, + name: dashboard?.name ?? "Canvas", + surface: "canvas", + invalidate: invalidateDashboards, + }); + void navigate({ + to: "/website/$channelId/artifacts", + params: { channelId }, + }); + }; const onTogglePin = () => { void setPinned(dashboardId, !isPinned) @@ -233,7 +272,14 @@ function FreeformEditControls({ } /> - + {/* Sized to its longest item — the default width clipped "Unpin from + space". Same treatment as the channel-list menus. */} + Refresh @@ -248,10 +294,46 @@ function FreeformEditControls({ - {isPinned ? "Unpin from channel" : "Pin to channel"} + {isPinned + ? `Unpin from ${containerNoun}` + : `Pin to ${containerNoun}`} + + setConfirmDeleteOpen(true)} + > + + Delete… + {/* Destructive confirm for "Delete…" — the canvas goes for everyone. */} + + + + Delete canvas + + Delete{" "} + {dashboard?.name ?? "Canvas"} + ? Its code and version history go for everyone in the{" "} + {containerNoun}. You get a few seconds to undo, then it's + permanent. + + + + + Cancel + + } + /> + + + + + ); + + // Only the loaded, non-empty list has tabs to show — the skeleton, the error + // notice and the empty state all render without them. + const hasTabs = !isLoading && !error && loops.length > 0; + + const body = ( + <>
-
- - - Loops - - - - Runs entirely in the cloud - + {!sharedPageHeader && ( +
+ + + Loops + + + + Runs entirely in the cloud + + + + Put your work on autopilot. Loops run on a schedule, on an + API call, or when something happens on GitHub. You can + finally close the laptop! + - - Put your work on autopilot. Loops run on a schedule, on an API - call, or when something happens on GitHub. You can finally - close the laptop! - - - -
+ {createButton} +
+ )} {isLoading ? ( @@ -268,14 +280,26 @@ export function LoopsListViewPresentation({ } /> ) : loops.length > 0 ? ( - + sharedPageHeader ? ( + // Triggers live in the page header; only the panels sit here. + + ) : ( + + ) ) : ( )} @@ -302,7 +326,50 @@ export function LoopsListViewPresentation({
-
+ + ); + + if (!sharedPageHeader) { + return ( + + {body} + + ); + } + + // One Tabs root spanning header and body: the trigger strip sits in the + // header's sub-nav, its panels stay down in the scrolling body. + return ( + + + + + Loops + }> + Runs entirely in the cloud + + {createButton} + + + Put your work on autopilot. Loops run on a schedule, on an API call, + or when something happens on GitHub. You can finally close the + laptop! + + + {hasTabs && ( + + + + )} + + {body} + ); } @@ -323,18 +390,64 @@ function LoopListTabs({ }) { return ( - - - - My loops ({personalLoops.length}) - - - - - Team loops ({teamLoops.length}) - - - + + + + ); +} + +/** The trigger strip. Rendered inside the page header when one is present. */ +function LoopTabsList({ + personalCount, + teamCount, +}: { + personalCount: number; + teamCount: number; +}) { + return ( + + + + My loops ({personalCount}) + + + + + Team loops ({teamCount}) + + + + ); +} + +/** The panels. Always in the scrolling body, wherever the triggers live. */ +function LoopTabPanels({ + personalLoops, + teamLoops, + members, + membersLoading, + membersError, + membersComplete, +}: { + personalLoops: LoopSchemas.Loop[]; + teamLoops: LoopSchemas.Loop[]; + members: UserBasic[]; + membersLoading: boolean; + membersError: boolean; + membersComplete: boolean; +}) { + return ( + <> {personalLoops.length > 0 ? ( )} - + ); } diff --git a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx index 6a80be72ab..fbe795c71e 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx +++ b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx @@ -8,6 +8,7 @@ import { import { WorkerPoolContextProvider } from "@pierre/diffs/react"; import { useService } from "@posthog/di/react"; import { + Button, ChatBubble, ChatBubbleContent, ChatMarker, @@ -22,7 +23,15 @@ import { ChatMessageScrollerItem, ChatMessageScrollerProvider, ChatMessageScrollerViewport, + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, cn, + Tooltip, + TooltipContent, + TooltipTrigger, useChatMessageScroller, useChatMessageScrollerScrollable, useChatMessageScrollerVisibility, @@ -62,8 +71,10 @@ import { type ThreadScrollResume, type TurnRow, } from "@posthog/ui/features/sessions/components/chat-thread/threadVirtualization"; +import { buildTurnCopyText } from "@posthog/ui/features/sessions/components/chat-thread/turnCopyText"; import { usePromptRecallSource } from "@posthog/ui/features/sessions/components/chat-thread/usePromptRecallSource"; import { VirtualThreadScrollBody } from "@posthog/ui/features/sessions/components/chat-thread/VirtualThreadScrollBody"; +import { copyFromContextMenu } from "@posthog/ui/features/sessions/components/copyContextTarget"; import { GitActionMessage } from "@posthog/ui/features/sessions/components/GitActionMessage"; import { GitActionResult } from "@posthog/ui/features/sessions/components/GitActionResult"; import { isUserInitiatedConversationItem } from "@posthog/ui/features/sessions/components/isUserInitiatedConversationItem"; @@ -90,6 +101,10 @@ import { useOptimisticItemsForTask, useSessionIsCloud, } from "@posthog/ui/features/sessions/sessionStore"; +import { + useSessionViewActions, + useShowRawLogs, +} from "@posthog/ui/features/sessions/sessionViewStore"; import { useThreadScrollRequest } from "@posthog/ui/features/sessions/threadNavigationStore"; import type { UserMessageAttachment } from "@posthog/ui/features/sessions/userMessageTypes"; import { @@ -98,14 +113,15 @@ import { } from "@posthog/ui/features/sessions/useSessionTaskId"; import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; import { SkillButtonActionMessage } from "@posthog/ui/features/skill-buttons/components/SkillButtonActionMessage"; +import { toast } from "@posthog/ui/primitives/toast"; import { useCopy } from "@posthog/ui/primitives/useCopy"; import { DIFF_WORKER_FACTORY, type DiffWorkerFactory, } from "@posthog/ui/shell/diffWorkerHost"; -import { IconButton, Tooltip } from "@radix-ui/themes"; import { memo, + type ReactElement, type ReactNode, type RefObject, useCallback, @@ -199,14 +215,16 @@ function groupToolRuns(items: ConversationItem[]): ThreadItem[] { * Collapse each contiguous run of non-user rows into one {@link AgentTurn}, broken only by a * user-initiated row (which stays standalone so it remains the scroll anchor for the sticky header * and auto-follow). The turn block renders as a single muted card, tightening the spacing between - * the agent's successive replies and tool calls. + * the agent's successive replies and tool calls. Each turn records the user-initiated row that + * opened it, so "Copy turn" can lead with the prompt the turn answered. */ function groupIntoTurns(rows: ThreadItem[]): TurnRow[] { const out: TurnRow[] = []; let buffer: ThreadItem[] = []; + let prompt: ThreadItem | undefined; const flush = () => { if (buffer.length > 0) { - out.push({ type: "agent_turn", id: buffer[0].id, items: buffer }); + out.push({ type: "agent_turn", id: buffer[0].id, items: buffer, prompt }); buffer = []; } }; @@ -218,6 +236,7 @@ function groupIntoTurns(rows: ThreadItem[]): TurnRow[] { if (isUserInitiatedConversationItem(row)) { flush(); out.push(row); + prompt = row; } else { buffer.push(row); } @@ -238,22 +257,59 @@ function formatTimestamp(ts: number): string { } /** - * Hover-revealed timestamp rendered right-aligned under agent-side content (the end-aligned user - * bubble keeps its own right-aligned footer). Sits inside a `group` container so it fades in only - * while that container is hovered. Shown once per completed agent turn (under the turn card) - * rather than on every message — per-row it was too noisy. + * Hover-revealed footer under a completed agent turn: the turn's timestamp plus a button copying + * the whole turn. Rendered right-aligned under agent-side content — the end-aligned user bubble + * keeps its own footer — inside a `group` container, so it fades in only while that turn is + * hovered. Once per turn rather than per row, which was too noisy. */ -function RowTimestamp({ timestamp }: { timestamp?: number }) { +function TurnFooter({ + timestamp, + copyText, +}: { + timestamp?: number; + copyText?: string; +}) { if (timestamp == null) return null; return ( {formatTimestamp(timestamp)} + {copyText && } ); } +/** + * Shared copy affordance for the message and turn footers. Stays muted whether idle or just-copied — + * the icon swap is the confirmation, so the row never lights up in a colour the thread doesn't use + * elsewhere. + */ +function CopyButton({ value, label }: { value: string; label: string }) { + const { copied, copy } = useCopy(); + const [hovered, setHovered] = useState(false); + return ( + // Held open for the life of the `copied` window so the confirmation lands even when the click + // moves the pointer off the button; hover drives it the rest of the time. + + copy(value)} + className="text-muted-foreground hover:text-foreground" + > + {copied ? : } + + } + /> + {copied ? "Copied!" : label} + + ); +} + /** * End-aligned user bubble. The text is clamped to five lines (`max-height: 5lh` + `overflow-hidden`, * which — unlike `-webkit-line-clamp` — reliably clamps markdown's block `

` children); a "Show @@ -337,135 +393,149 @@ function UserBubble({ }, [displayContent, isExpanded]); return ( - - - {showHeaderChips && ( - - {showChannelContextTag && channelContext && ( - } - label={`${ - channelContext.mention.name - ? `#${channelContext.mention.name} ` - : "" - }CONTEXT.md`} - onClick={ - taskId - ? () => - openChannelContextInSplit(taskId, { - channelName: channelContext.mention.name, - body: channelContext.mention.body, - }) - : undefined - } - /> - )} - {showCanvasInstructionsTag && canvasInstructions && ( - } - label="Canvas instructions" - onClick={ - taskId - ? () => - openCanvasInstructionsInSplit(taskId, { - body: canvasInstructions.body, - }) - : undefined - } - /> - )} - - )} - - -

+ + + {showHeaderChips && ( + + {showChannelContextTag && channelContext && ( + } + label={`${ + channelContext.mention.name + ? `#${channelContext.mention.name} ` + : "" + }CONTEXT.md`} + onClick={ + taskId + ? () => + openChannelContextInSplit(taskId, { + channelName: channelContext.mention.name, + body: channelContext.mention.body, + }) + : undefined + } + /> )} - > - {containsFileMentions ? ( - parseFileMentions(displayContent) - ) : ( - + {showCanvasInstructionsTag && canvasInstructions && ( + } + label="Canvas instructions" + onClick={ + taskId + ? () => + openCanvasInstructionsInSplit(taskId, { + body: canvasInstructions.body, + }) + : undefined + } + /> )} -
- {attachments.length > 0 && !containsFileMentions && ( -
- -
+ + )} + setIsExpanded((v) => !v)} - className="mt-1 flex items-center gap-0.5 text-muted-foreground text-sm hover:text-foreground" + > + +
- Show {isExpanded ? "less" : "more"} - - - )} - - - {timestamp != null && ( - - {formatTimestamp(timestamp)} - - )} - - - + {containsFileMentions ? ( + parseFileMentions(displayContent) + ) : ( + + )} +
+ {attachments.length > 0 && !containsFileMentions && ( +
+ +
+ )} + {isOverflowing && ( + + )} +
+
+ {timestamp != null && ( + + {formatTimestamp(timestamp)} + + + )} + + + ); } /** - * Copy icon that floats into a message's right rail on hover. The hover-group qualifier differs by - * message type (`group` for user bubbles, `group/msg` for agent prose), so callers pass their own - * `revealClassName` (the `group-hover*:opacity-100` utility). + * Right-click a message to copy it. Replaces the per-message copy button that used to float in the + * message's right rail — the turn footer covers the common case, so a single message's copy lives + * here instead of costing every row a hover affordance. + * + * This menu sits inside `SessionView`'s own context menu and wins the event over it, so it also + * carries that menu's raw-logs toggle; without it, right-clicking a message would be the one spot + * in the session where the toggle went missing. + * + * The write goes through {@link copyFromContextMenu}: a synchronous write from a closing menu + * rejects while focus is still being restored, and both outcomes surface as toasts — a silent + * failure would leave the clipboard's previous contents where the user believes the message is. */ -function MessageCopyButton({ +function MessageContextMenu({ value, - revealClassName, + children, }: { value: string; - revealClassName: string; + children: ReactElement; }) { - const { copied, copy } = useCopy(); + const showRawLogs = useShowRawLogs(); + const { setShowRawLogs } = useSessionViewActions(); return ( - - copy(value)} - className={cn( - "absolute top-1 right-1 cursor-pointer opacity-0 transition-opacity", - revealClassName, - )} - aria-label="Copy message" - > - {copied ? : } - - + + + + + copyFromContextMenu(value, { + onSuccess: () => toast.success("Copied"), + onError: () => toast.error("Couldn't copy"), + }) + } + > + + Copy message + + + setShowRawLogs(!showRawLogs)}> + + {showRawLogs ? "Back to conversation" : "Show raw logs"} + + + ); } @@ -488,25 +558,21 @@ const AgentProse = memo(function AgentProse({ const smoothed = useSmoothedText(text); return ( - - - - - {isStreaming ? ( - - ) : ( - - )} - - - - {isStreaming ? null : ( - - )} - + + + + + + {isStreaming ? ( + + ) : ( + + )} + + + + + ); }); @@ -591,7 +657,14 @@ const ThreadRow = memo(function ThreadRow({
))}
- + ); } @@ -599,7 +672,7 @@ const ThreadRow = memo(function ThreadRow({ {row.turnTimestamp != null && ( - + )} ); diff --git a/packages/ui/src/features/sessions/components/chat-thread/threadVirtualization.test.ts b/packages/ui/src/features/sessions/components/chat-thread/threadVirtualization.test.ts index 15db845688..73178cf470 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/threadVirtualization.test.ts +++ b/packages/ui/src/features/sessions/components/chat-thread/threadVirtualization.test.ts @@ -21,14 +21,15 @@ function sessionUpdate( { turnComplete = false, timestamp, - }: { turnComplete?: boolean; timestamp?: number } = {}, + text, + }: { turnComplete?: boolean; timestamp?: number; text?: string } = {}, ): SessionUpdateItem { return { type: "session_update", id, update: { sessionUpdate: "agent_message_chunk", - content: { type: "text", text: `text ${id}` }, + content: { type: "text", text: text ?? `text ${id}` }, } as SessionUpdateItem["update"], turnContext: { toolCalls: new Map(), @@ -44,8 +45,12 @@ function toolGroup(id: string, tools: SessionUpdateItem[]): ToolGroupItem { return { type: "tool_group", id, tools }; } -function agentTurn(id: string, items: TurnRow[]): AgentTurn { - return { type: "agent_turn", id, items: items as AgentTurn["items"] }; +function agentTurn( + id: string, + items: TurnRow[], + prompt?: ConversationItem, +): AgentTurn { + return { type: "agent_turn", id, items: items as AgentTurn["items"], prompt }; } describe("flattenTurnRows", () => { @@ -91,6 +96,46 @@ describe("flattenTurnRows", () => { expect(flat.map((r) => r.turnTimestamp)).toEqual([undefined, 1234]); }); + it("carries the turn's copy text on the same row as its timestamp", () => { + const done = agentTurn("d", [ + sessionUpdate("d1", { text: "first" }), + sessionUpdate("d2", { + turnComplete: true, + timestamp: 1234, + text: "last", + }), + ]); + const flat = flattenTurnRows([done]); + expect(flat.map((r) => r.turnCopyText)).toEqual([ + undefined, + "first\n\nlast", + ]); + }); + + it("leads the copy text with the prompt that opened the turn", () => { + const done = agentTurn( + "d", + [ + sessionUpdate("d1", { + turnComplete: true, + timestamp: 1, + text: "reply", + }), + ], + userMessage("u1"), + ); + expect(flattenTurnRows([done]).at(-1)?.turnCopyText).toBe( + "msg u1\n\nreply", + ); + }); + + it("leaves copy text off a turn that is still streaming", () => { + const streaming = agentTurn("s", [ + sessionUpdate("s1", { text: "partial" }), + ]); + expect(flattenTurnRows([streaming])[0].turnCopyText).toBeUndefined(); + }); + it("reads a trailing tool group's timestamp from its last tool", () => { const turn = agentTurn("t", [ sessionUpdate("t1"), diff --git a/packages/ui/src/features/sessions/components/chat-thread/threadVirtualization.ts b/packages/ui/src/features/sessions/components/chat-thread/threadVirtualization.ts index 92248198cd..5375b512e9 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/threadVirtualization.ts +++ b/packages/ui/src/features/sessions/components/chat-thread/threadVirtualization.ts @@ -1,5 +1,6 @@ import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems"; import type { ToolGroupItem } from "@posthog/ui/features/sessions/components/chat-thread/ToolGroup"; +import { buildTurnCopyText } from "@posthog/ui/features/sessions/components/chat-thread/turnCopyText"; /** A row is either a parsed conversation item or a synthesized group of tool calls. */ export type ThreadItem = ConversationItem | ToolGroupItem; @@ -8,7 +9,16 @@ export type ThreadItem = ConversationItem | ToolGroupItem; * A contiguous run of non-user rows (assistant prose, tools, git actions, ...) shown as one * block with tight internal spacing. Broken only by a user message. */ -export type AgentTurn = { type: "agent_turn"; id: string; items: ThreadItem[] }; +export type AgentTurn = { + type: "agent_turn"; + id: string; + items: ThreadItem[]; + /** + * The user-initiated row that opened this turn — grouping emits it as a standalone row, so + * without it "Copy turn" would carry the agent's prose but not the prompt it answers. + */ + prompt?: ThreadItem; +}; /** Top-level row: a standalone user message, or a grouped agent turn. */ export type TurnRow = ThreadItem | AgentTurn; @@ -74,6 +84,8 @@ export interface FlatThreadRow { isTrailingInTurn: boolean; /** Set on the last row of a completed turn; renders the turn's hover timestamp under it. */ turnTimestamp?: number; + /** Set alongside {@link turnTimestamp}: the whole turn as plain text, for its copy button. */ + turnCopyText?: string; } /** @@ -98,6 +110,12 @@ export function flattenTurnRows(rows: TurnRow[]): FlatThreadRow[] { for (const row of rows) { if (row.type === "agent_turn") { const timestamp = completedTurnTimestamp(row); + const copyText = + timestamp == null + ? undefined + : (buildTurnCopyText( + row.prompt ? [row.prompt, ...row.items] : row.items, + ) ?? undefined); for (let i = 0; i < row.items.length; i++) { const item = row.items[i]; const isTrailing = i === row.items.length - 1; @@ -107,6 +125,7 @@ export function flattenTurnRows(rows: TurnRow[]): FlatThreadRow[] { inTurn: true, isTrailingInTurn: isTrailing, turnTimestamp: isTrailing ? timestamp : undefined, + turnCopyText: isTrailing ? copyText : undefined, }); } continue; diff --git a/packages/ui/src/features/sessions/components/chat-thread/turnCopyText.test.ts b/packages/ui/src/features/sessions/components/chat-thread/turnCopyText.test.ts new file mode 100644 index 0000000000..01ead18162 --- /dev/null +++ b/packages/ui/src/features/sessions/components/chat-thread/turnCopyText.test.ts @@ -0,0 +1,78 @@ +import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems"; +import type { ToolGroupItem } from "@posthog/ui/features/sessions/components/chat-thread/ToolGroup"; +import { describe, expect, it } from "vitest"; +import { buildTurnCopyText } from "./turnCopyText"; + +function userMessage(id: string, content: string): ConversationItem { + return { type: "user_message", id, content, timestamp: 0 }; +} + +function agentText(id: string, text: string): ConversationItem { + return { + type: "session_update", + id, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text }, + }, + turnContext: { + toolCalls: new Map(), + childItems: new Map(), + turnCancelled: false, + turnComplete: true, + }, + } as ConversationItem; +} + +function toolCall(id: string): ConversationItem { + return { + type: "session_update", + id, + update: { + sessionUpdate: "tool_call", + toolCallId: id, + title: "Read file", + status: "completed", + }, + turnContext: { + toolCalls: new Map(), + childItems: new Map(), + turnCancelled: false, + turnComplete: true, + }, + } as ConversationItem; +} + +function toolGroup(id: string): ToolGroupItem { + return { type: "tool_group", id, tools: [] } as unknown as ToolGroupItem; +} + +describe("buildTurnCopyText", () => { + it("joins the rows' prose in order", () => { + const text = buildTurnCopyText([ + agentText("a1", "first paragraph"), + agentText("a2", "second paragraph"), + ]); + + expect(text).toBe("first paragraph\n\nsecond paragraph"); + }); + + it("skips tool calls, tool groups and other non-prose rows", () => { + const text = buildTurnCopyText([ + userMessage("u1", "do the thing"), + toolCall("t1"), + toolGroup("g1"), + agentText("a1", "done"), + ]); + + expect(text).toBe("do the thing\n\ndone"); + }); + + it.each([ + ["no items", [] as ConversationItem[]], + ["tools only", [toolCall("t1"), toolGroup("g1")]], + ["blank prose", [userMessage("u1", " "), agentText("a1", "\n")]], + ])("returns null when there is nothing to copy: %s", (_label, items) => { + expect(buildTurnCopyText(items)).toBeNull(); + }); +}); diff --git a/packages/ui/src/features/sessions/components/chat-thread/turnCopyText.ts b/packages/ui/src/features/sessions/components/chat-thread/turnCopyText.ts new file mode 100644 index 0000000000..31c11715bf --- /dev/null +++ b/packages/ui/src/features/sessions/components/chat-thread/turnCopyText.ts @@ -0,0 +1,31 @@ +import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems"; +import type { ToolGroupItem } from "@posthog/ui/features/sessions/components/chat-thread/ToolGroup"; + +/** + * Plain-text transcript of a turn's rows: user prompts and agent prose, in order. + * + * Tool calls, thoughts and status rows are left out — this is for pasting an answer somewhere else, + * not for reproducing the run. Returns null when the rows carry no prose. + */ +export function buildTurnCopyText( + items: Array, +): string | null { + const parts: string[] = []; + + for (const item of items) { + if (item.type === "user_message") { + const content = item.content.trim(); + if (content) parts.push(content); + continue; + } + if (item.type !== "session_update") continue; + const update = item.update; + if (update.sessionUpdate !== "agent_message_chunk") continue; + if (update.content.type !== "text") continue; + const text = update.content.text.trim(); + if (text) parts.push(text); + } + + if (parts.length === 0) return null; + return parts.join("\n\n"); +} diff --git a/packages/ui/src/features/task-detail/HeaderTitleEditor.tsx b/packages/ui/src/features/task-detail/HeaderTitleEditor.tsx index 51d425fd8c..c5f15aba09 100644 --- a/packages/ui/src/features/task-detail/HeaderTitleEditor.tsx +++ b/packages/ui/src/features/task-detail/HeaderTitleEditor.tsx @@ -1,15 +1,23 @@ +import { cn } from "@posthog/quill"; import { useEffect, useRef, useState } from "react"; interface HeaderTitleEditorProps { initialTitle: string; onSubmit: (newTitle: string) => void; onCancel: () => void; + /** + * Extends the base styling — callers match the input to whatever it replaces + * (e.g. a breadcrumb segment's type scale and height) so opening the editor + * doesn't resize the row. + */ + className?: string; } export function HeaderTitleEditor({ initialTitle, onSubmit, onCancel, + className, }: HeaderTitleEditorProps) { const [editValue, setEditValue] = useState(initialTitle); const inputRef = useRef(null); @@ -53,7 +61,10 @@ export function HeaderTitleEditor({ onChange={(e) => setEditValue(e.target.value)} onKeyDown={handleKeyDown} onBlur={handleSubmit} - className="no-drag h-5 min-w-0 flex-1 rounded-sm border border-accent-8 bg-gray-2 px-1 font-medium text-[12px] text-gray-12 outline-none" + className={cn( + "no-drag h-5 min-w-0 flex-1 rounded-sm border border-accent-8 bg-gray-2 px-1 font-medium text-[12px] text-gray-12 outline-none", + className, + )} /> ); } diff --git a/packages/ui/src/primitives/PageHeader.stories.tsx b/packages/ui/src/primitives/PageHeader.stories.tsx new file mode 100644 index 0000000000..328d466b3e --- /dev/null +++ b/packages/ui/src/primitives/PageHeader.stories.tsx @@ -0,0 +1,108 @@ +import { CloudIcon, FilesIcon } from "@phosphor-icons/react"; +import { + Button, + ButtonGroup, + Tabs, + TabsList, + TabsTrigger, +} from "@posthog/quill"; +import { + PageHeader, + PageHeaderActions, + PageHeaderChip, + PageHeaderDescription, + PageHeaderFilters, + PageHeaderHeading, + PageHeaderNav, + PageHeaderTitle, + PageHeaderTitleRow, +} from "@posthog/ui/primitives/PageHeader"; +import type { Meta, StoryObj } from "@storybook/react-vite"; + +const meta = { + title: "Primitives/PageHeader", + component: PageHeader, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** The Inbox shape: title, description, tab strip with a filter on the right. */ +export const WithTabsAndFilters: Story = { + args: { + children: ( + <> + + + Inbox + + + Work done by your agents – pull requests, reports, and live runs. + + + + + + + Pull requests + + + Reports + + + + + + + + + ), + }, +}; + +/** The Artifacts shape: a count chip beside the title, view switcher on the right. */ +export const WithChipAndActions: Story = { + args: { + children: ( + + + Artifacts + }> + 12 items + + + + + + + + + + Canvases and pull requests from this space's tasks. + + + ), + }, +}; + +/** Title only — the minimum a page has to spend. */ +export const TitleOnly: Story = { + args: { + children: ( + + + Loops + }> + Runs entirely in the cloud + + + + ), + }, +}; diff --git a/packages/ui/src/primitives/PageHeader.tsx b/packages/ui/src/primitives/PageHeader.tsx new file mode 100644 index 0000000000..2580511ff1 --- /dev/null +++ b/packages/ui/src/primitives/PageHeader.tsx @@ -0,0 +1,189 @@ +import { cn } from "@posthog/quill"; +import type { ReactNode } from "react"; + +/** + * The shared page header section. Full-bleed (the page body below it keeps its + * own container), bordered off from the content, and composed from parts so + * each surface takes only what it needs: + * + * + * + * + * Inbox + * Runs in the cloud + * + * + * + * + * + * + * + * + * + * + * Layout base is the Inbox header (full width, title + description + tab bar); + * the chip comes from Loops. + */ +export function PageHeader({ + className, + children, +}: { + className?: string; + children: ReactNode; +}) { + return ( +
+ {children} +
+ ); +} + +/** Title row + description, tight against each other. */ +export function PageHeaderHeading({ + className, + children, +}: { + className?: string; + children: ReactNode; +}) { + return ( +
+ {children} +
+ ); +} + +/** The title line: title, any chips, and (pushed right) actions. */ +export function PageHeaderTitleRow({ + className, + children, +}: { + className?: string; + children: ReactNode; +}) { + return ( +
+ {children} +
+ ); +} + +export function PageHeaderTitle({ + className, + children, +}: { + className?: string; + children: ReactNode; +}) { + return ( +

+ {children} +

+ ); +} + +/** A pill next to the title — a count, a mode, "Runs entirely in the cloud". */ +export function PageHeaderChip({ + icon, + className, + children, +}: { + icon?: ReactNode; + className?: string; + children: ReactNode; +}) { + return ( + + {icon} + {children} + + ); +} + +export function PageHeaderDescription({ + className, + children, +}: { + className?: string; + children: ReactNode; +}) { + return ( +

+ {children} +

+ ); +} + +/** Trailing controls on the title line (create buttons, view switchers). */ +export function PageHeaderActions({ + className, + children, +}: { + className?: string; + children: ReactNode; +}) { + return ( +
+ {children} +
+ ); +} + +/** + * The sub-nav row: a tab strip, with filters pushed to the right. Cancels the + * header's bottom padding so an underlined tab strip sits on the header border + * the way the Inbox tabs do; the tabs' own padding keeps the breathing room. + */ +export function PageHeaderNav({ + className, + children, +}: { + className?: string; + children: ReactNode; +}) { + return ( +
+ {children} +
+ ); +} + +/** Filters/controls sitting to the right of the sub-nav. */ +export function PageHeaderFilters({ + className, + children, +}: { + className?: string; + children: ReactNode; +}) { + return ( +
+ {children} +
+ ); +} diff --git a/packages/ui/src/shell/ContentHeader.tsx b/packages/ui/src/shell/ContentHeader.tsx index 74f34c089d..741a785734 100644 --- a/packages/ui/src/shell/ContentHeader.tsx +++ b/packages/ui/src/shell/ContentHeader.tsx @@ -10,10 +10,17 @@ import { Flex } from "@radix-ui/themes"; // review-panel toggle, cloud/local handoff, skill buttons and task actions that // used to live in the Code header bar. // -// This breadcrumb row is now scoped to the task-detail view only: every other -// page drops it (the title bar search carries wayfinding instead). The /website -// (Channels) space keeps its own header (WebsiteLayout), so it's unaffected — -// this is mounted only outside it. +// This breadcrumb row is scoped to views that have somewhere to walk back to: +// task detail, and the loop scenes (list / detail / form), which live outside +// the space routes but can belong to a space. Every other page drops it (the +// title bar search carries wayfinding instead). The /website (Channels) space +// keeps its own header (WebsiteLayout), so it's unaffected — this is mounted +// only outside it. +// +// A loop with no space pushes null, so the row collapses for it too: what a +// view puts in the header store decides, this only says who may. +const BREADCRUMB_VIEWS = new Set(["task-detail", "loops"]); + export function ContentHeader() { const content = useHeaderStore((state) => state.content); const view = useAppView(); @@ -25,8 +32,7 @@ export function ContentHeader() { : undefined; const showTaskSection = view.type === "task-detail" && Boolean(activeTask); - // Only the task-detail view keeps the breadcrumb row. - if (view.type !== "task-detail") return null; + if (!BREADCRUMB_VIEWS.has(view.type)) return null; if (!content && !showTaskSection) return null;