From b1bb240e7f67cdc321fdbd0be6ad679c13171df6 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Thu, 28 May 2026 13:03:16 +0100 Subject: [PATCH 1/8] feat(mobile): bring inbox analytics to parity with desktop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror desktop's inbox event names + property shapes on the mobile app so both clients are comparable in PostHog dashboards. Events wired: - `Inbox viewed` — fired once per inbox-tab focus, with priority/actionability breakdown counts (per #2295). - `Inbox report opened` / `closed` / `scrolled` — fired from the report detail screen via a new `useInboxEngagementTracker` hook, with priority + actionability snapshotted at open time. - `Inbox report action` — fired for: - Dismiss / snooze via the dismiss sheet (with `dismissal_reason` and `dismissal_note`, truncated to 1000 chars per #2287). - "Start task" on the detail screen → `create_pr`, surface `detail_pane`. - Tinder swipe-right (accept) → `create_pr`, surface `list_row`. - Tinder swipe-left (dismiss) → `dismiss`, surface `list_row`. - Expanding the Signals list on the detail screen → `expand_signal`. For #2369 (tagging discuss-launched task events with `signal_report_id`): the task detail screen registers a `signal_report_id` PostHog super-property for the duration of the screen when the task carries one, matching the desktop super-property behaviour. #2380 (`Signal source connected`) is intentionally skipped: there is no signal-source connect / data-source-setup flow in the mobile app. We do not fire any "Task created" event on mobile yet, so the desktop-side `Task created` tagging in #2369 also doesn't apply here — only the super-property half does, and that's what we wired. Architecture: analytics types and the `useAnalytics()` / `useActiveTaskAnalyticsContext()` hooks live in `apps/mobile/src/lib/analytics.ts`. Event names and property shapes are literal mirrors of `apps/code/src/shared/types/analytics.ts` — no shared package extraction since the desktop types live inside `apps/code`, not in a shared workspace. Tests: - `apps/mobile/src/lib/analytics.test.ts` — `computeReportAgeHours` and the `useActiveTaskAnalyticsContext` super-property lifecycle. - `apps/mobile/src/features/inbox/utils.test.ts` — `buildInboxViewedProperties` (priority/actionability counts, has_active_filters detection). - `apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.test.ts` — OPENED/CLOSED/SCROLLED lifecycle, scroll-once semantics, and signalAction inheritance vs. overrides. `pnpm --filter @posthog/mobile test` → 119 passed (14 new). `pnpm lint` clean. No new TypeScript errors. Generated-By: PostHog Code Task-Id: 2e652fec-af01-4476-b6cf-ab9b4de015db --- apps/mobile/src/app/(tabs)/inbox.tsx | 66 ++++- apps/mobile/src/app/inbox/[id].tsx | 125 +++++++++- apps/mobile/src/app/task/[id].tsx | 6 + .../inbox/components/DismissReportSheet.tsx | 21 +- .../features/inbox/components/TinderView.tsx | 56 ++++- .../hooks/useInboxEngagementTracker.test.ts | 235 ++++++++++++++++++ .../inbox/hooks/useInboxEngagementTracker.ts | 174 +++++++++++++ .../features/inbox/stores/inboxFilterStore.ts | 2 +- .../src/features/inbox/stores/inboxStore.ts | 16 ++ apps/mobile/src/features/inbox/utils.test.ts | 137 ++++++++++ apps/mobile/src/features/inbox/utils.ts | 87 +++++++ apps/mobile/src/features/tasks/types.ts | 2 + apps/mobile/src/lib/analytics.test.ts | 96 +++++++ apps/mobile/src/lib/analytics.ts | 198 +++++++++++++++ 14 files changed, 1202 insertions(+), 19 deletions(-) create mode 100644 apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.test.ts create mode 100644 apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.ts create mode 100644 apps/mobile/src/features/inbox/utils.test.ts create mode 100644 apps/mobile/src/lib/analytics.test.ts create mode 100644 apps/mobile/src/lib/analytics.ts diff --git a/apps/mobile/src/app/(tabs)/inbox.tsx b/apps/mobile/src/app/(tabs)/inbox.tsx index 7279820b7a..14c30e8354 100644 --- a/apps/mobile/src/app/(tabs)/inbox.tsx +++ b/apps/mobile/src/app/(tabs)/inbox.tsx @@ -1,5 +1,5 @@ -import { useRouter } from "expo-router"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useFocusEffect, useRouter } from "expo-router"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { FilterSheet } from "@/features/inbox/components/FilterSheet"; @@ -13,29 +13,87 @@ import { decidedIds, useDismissedReportsStore, } from "@/features/inbox/stores/dismissedReportsStore"; -import { useInboxFilterStore } from "@/features/inbox/stores/inboxFilterStore"; +import { + DEFAULT_STATUS_FILTER, + 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"; type InboxViewMode = "list" | "tinder"; export default function InboxScreen() { const insets = useSafeAreaInsets(); const router = useRouter(); - const { reports, isFetching, isLoading, error } = useInboxReports(); + const { reports, totalCount, isFetching, isLoading, error } = + useInboxReports(); const [filterOpen, setFilterOpen] = useState(false); const [reviewerOpen, setReviewerOpen] = useState(false); const [viewMode, setViewMode] = useState("list"); const reviewerFilterCount = useInboxFilterStore( (s) => s.suggestedReviewerFilter.length, ); + const sourceProductFilter = useInboxFilterStore((s) => s.sourceProductFilter); + const statusFilter = useInboxFilterStore((s) => s.statusFilter); + const suggestedReviewerFilter = useInboxFilterStore( + (s) => s.suggestedReviewerFilter, + ); + + const analytics = useAnalytics(); + // Fire INBOX_VIEWED once per focus when the report list has settled. + const viewedFiredRef = useRef(false); + useFocusEffect( + useCallback(() => { + return () => { + viewedFiredRef.current = false; + }; + }, []), + ); + useEffect(() => { + if (isLoading) return; + if (viewedFiredRef.current) return; + viewedFiredRef.current = true; + analytics.track( + ANALYTICS_EVENTS.INBOX_VIEWED, + buildInboxViewedProperties(reports, totalCount, { + sourceProductFilter, + statusFilter, + suggestedReviewerFilter, + defaultStatusFilter: DEFAULT_STATUS_FILTER, + }), + ); + }, [ + analytics, + isLoading, + reports, + totalCount, + sourceProductFilter, + statusFilter, + suggestedReviewerFilter, + ]); // ── Tinder mode data ────────────────────────────────────────────────────── const decided = useDismissedReportsStore(decidedIds); const setCurrentIndex = useInboxStore((s) => s.setCurrentIndex); + const setLastVisibleReportIds = useInboxStore( + (s) => s.setLastVisibleReportIds, + ); const { repositoryOptions } = useIntegrations(); + // Snapshot the visible-list IDs into the store so the detail screen can + // record rank/list_size on OPENED. Only the list view exposes a rank — the + // tinder card stack swaps cards in place. + useEffect(() => { + if (viewMode === "list") { + setLastVisibleReportIds(reports.map((r) => r.id)); + } else { + setLastVisibleReportIds([]); + } + }, [viewMode, reports, setLastVisibleReportIds]); + // Same data as the list view, excluding already-decided reports. const tinderReports = useMemo( () => reports.filter((r) => !decided.includes(r.id)), diff --git a/apps/mobile/src/app/inbox/[id].tsx b/apps/mobile/src/app/inbox/[id].tsx index 0a119d0eb0..6eeb9fdd43 100644 --- a/apps/mobile/src/app/inbox/[id].tsx +++ b/apps/mobile/src/app/inbox/[id].tsx @@ -12,18 +12,31 @@ import { Warning, } from "phosphor-react-native"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { ActivityIndicator, Pressable, ScrollView, View } from "react-native"; +import { + ActivityIndicator, + type NativeScrollEvent, + type NativeSyntheticEvent, + Pressable, + ScrollView, + View, +} from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { MarkdownText } from "@/features/chat/components/MarkdownText"; import { getReportRepository } from "@/features/inbox/api"; -import { DismissReportSheet } from "@/features/inbox/components/DismissReportSheet"; +import { + type DismissReportResult, + DismissReportSheet, +} from "@/features/inbox/components/DismissReportSheet"; import { SignalCard } from "@/features/inbox/components/SignalCard"; import { SuggestedReviewers } from "@/features/inbox/components/SuggestedReviewers"; +import { DISMISSAL_REASON_OPTIONS } from "@/features/inbox/constants"; +import { useInboxEngagementTracker } from "@/features/inbox/hooks/useInboxEngagementTracker"; import { useInboxReport, useInboxReportArtefacts, useInboxReportSignals, } from "@/features/inbox/hooks/useInboxReports"; +import { useInboxStore } from "@/features/inbox/stores/inboxStore"; import type { ActionabilityJudgmentContent, SignalFindingContent, @@ -32,6 +45,7 @@ import type { SuggestedReviewer, } from "@/features/inbox/types"; import { inboxStatusLabel } from "@/features/inbox/utils"; +import { computeReportAgeHours, useAnalytics } from "@/lib/analytics"; import { useThemeColors } from "@/lib/theme"; const statusColorMap: Record = { @@ -117,6 +131,59 @@ export default function ReportDetailScreen() { const artefactsQuery = useInboxReportArtefacts(reportId ?? null); const signalsQuery = useInboxReportSignals(reportId ?? null); + // ── Engagement analytics ──────────────────────────────────────────────── + const analytics = useAnalytics(); + const lastVisibleReportIds = useInboxStore((s) => s.lastVisibleReportIds); + const previousOpenedReportId = useInboxStore((s) => s.previousOpenedReportId); + const setPreviousOpenedReportId = useInboxStore( + (s) => s.setPreviousOpenedReportId, + ); + const rank = useMemo(() => { + if (!reportId) return -1; + const idx = lastVisibleReportIds.indexOf(reportId); + return idx; + }, [reportId, lastVisibleReportIds]); + const listSize = lastVisibleReportIds.length; + const tracker = useInboxEngagementTracker({ + analytics, + report: report ?? null, + rank, + listSize, + openMethod: "click", + previousReportId: previousOpenedReportId, + }); + // Remember this report as the "previous" once it's been opened so the next + // OPENED event can chain to it. + useEffect(() => { + if (!reportId) return; + setPreviousOpenedReportId(reportId); + }, [reportId, setPreviousOpenedReportId]); + + const handleScroll = useCallback( + (_event: NativeSyntheticEvent) => { + tracker.signalScroll(); + }, + [tracker], + ); + + const handleToggleSignals = useCallback(() => { + setSignalsExpanded((v) => { + const next = !v; + if (next && report) { + tracker.signalAction({ + report_id: report.id, + report_title: report.title ?? null, + report_age_hours: computeReportAgeHours(report.created_at), + action_type: "expand_signal", + surface: "detail_pane", + is_bulk: false, + bulk_size: 1, + }); + } + return next; + }); + }, [report, tracker]); + useEffect(() => { if (!reportId) return; let cancelled = false; @@ -176,6 +243,15 @@ export default function ReportDetailScreen() { const handleStartTask = useCallback(() => { if (!report) return; Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + tracker.signalAction({ + report_id: report.id, + report_title: report.title ?? null, + report_age_hours: computeReportAgeHours(report.created_at), + action_type: "create_pr", + surface: "detail_pane", + is_bulk: false, + bulk_size: 1, + }); const prompt = `Act on this signal report. Investigate the root cause, implement the fix, and open a PR if appropriate.\n\n${report.summary ?? ""}`; router.push({ pathname: "/task", @@ -185,12 +261,41 @@ export default function ReportDetailScreen() { signalReport: report.id, }, }); - }, [report, router, reportRepo]); - - const handleDismissed = useCallback(() => { - setDismissOpen(false); - if (router.canGoBack()) router.back(); - }, [router]); + }, [report, router, reportRepo, tracker]); + + const handleDismissed = useCallback( + (result: DismissReportResult) => { + setDismissOpen(false); + if (report) { + const reasonOption = DISMISSAL_REASON_OPTIONS.find( + (o) => o.value === result.reason, + ); + const isSnooze = + reasonOption !== undefined && + "snoozesInsteadOfDismiss" in reasonOption && + reasonOption.snoozesInsteadOfDismiss === true; + tracker.signalAction({ + report_id: report.id, + report_title: report.title ?? null, + report_age_hours: computeReportAgeHours(report.created_at), + action_type: isSnooze ? "snooze" : "dismiss", + surface: "detail_pane", + is_bulk: false, + bulk_size: 1, + ...(isSnooze + ? {} + : { + dismissal_reason: result.reason, + ...(result.note + ? { dismissal_note: result.note.slice(0, 1000) } + : {}), + }), + }); + } + if (router.canGoBack()) router.back(); + }, + [router, report, tracker], + ); if (error) { return ( @@ -254,6 +359,8 @@ export default function ReportDetailScreen() { paddingTop: 16, paddingBottom: insets.bottom + 100, }} + onScroll={handleScroll} + scrollEventThrottle={250} > {/* Badges row */} @@ -331,7 +438,7 @@ export default function ReportDetailScreen() { {signals.length > 0 && ( setSignalsExpanded((v) => !v)} + onPress={handleToggleSignals} hitSlop={6} accessibilityRole="button" accessibilityState={{ expanded: signalsExpanded }} diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index 2d0c494ddb..a74e9ff1eb 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -36,6 +36,7 @@ import { useTaskStore } from "@/features/tasks/stores/taskStore"; import type { Task } from "@/features/tasks/types"; import { getSessionActivityPhase } from "@/features/tasks/utils/sessionActivity"; import { useScreenInsets } from "@/hooks/useScreenInsets"; +import { useActiveTaskAnalyticsContext } from "@/lib/analytics"; import { logger } from "@/lib/logger"; import { useThemeColors } from "@/lib/theme"; @@ -89,6 +90,11 @@ export default function TaskDetailScreen() { }; }, [taskId, setFocusedTaskId]); + // Tag every PostHog event fired while this task is open with the originating + // inbox report id, so a discuss-launched run can be filtered down in PostHog. + // Cleared when the screen unmounts. Matches the desktop super-property. + useActiveTaskAnalyticsContext(task?.signal_report ?? null); + const session = taskId ? getSessionForTask(taskId) : undefined; // Per-task composer pill values. Persisted in taskStore so reopening the diff --git a/apps/mobile/src/features/inbox/components/DismissReportSheet.tsx b/apps/mobile/src/features/inbox/components/DismissReportSheet.tsx index ade712ab78..65fc913399 100644 --- a/apps/mobile/src/features/inbox/components/DismissReportSheet.tsx +++ b/apps/mobile/src/features/inbox/components/DismissReportSheet.tsx @@ -20,12 +20,23 @@ import { } from "../constants"; import { useDismissReport } from "../hooks/useInboxReports"; +export interface DismissReportResult { + reason: DismissalReasonOptionValue; + /** Trimmed note text the user provided, if any. Empty/whitespace-only notes become null. */ + note: string | null; +} + interface DismissReportSheetProps { visible: boolean; reportId: string; reportTitle: string; onClose: () => void; - onDismissed: () => void; + /** + * Fires after the API confirms the dismissal. The result is passed back so + * callers can route the reason/note through their own analytics — keeping + * this sheet stateless about the surface it was launched from. + */ + onDismissed: (result: DismissReportResult) => void; } export function DismissReportSheet({ @@ -53,10 +64,14 @@ export function DismissReportSheet({ const handleConfirm = async () => { if (!reason || dismiss.isPending) return; setError(null); + const trimmedNote = note.trim(); try { - await dismiss.mutateAsync({ reason, note: note.trim() || undefined }); + await dismiss.mutateAsync({ + reason, + note: trimmedNote || undefined, + }); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); - onDismissed(); + onDismissed({ reason, note: trimmedNote || null }); } catch (err) { Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); setError( diff --git a/apps/mobile/src/features/inbox/components/TinderView.tsx b/apps/mobile/src/features/inbox/components/TinderView.tsx index 7eb2b5fd9d..6c0430d883 100644 --- a/apps/mobile/src/features/inbox/components/TinderView.tsx +++ b/apps/mobile/src/features/inbox/components/TinderView.tsx @@ -21,6 +21,11 @@ import type { CreateTaskOptions, RepositoryOption, } from "@/features/tasks/types"; +import { + ANALYTICS_EVENTS, + computeReportAgeHours, + useAnalytics, +} from "@/lib/analytics"; import { logger } from "@/lib/logger"; import { useThemeColors } from "@/lib/theme"; import { getReportRepository } from "../api"; @@ -137,6 +142,34 @@ export function TinderView({ const dismissReport = useDismissedReportsStore((s) => s.dismissReport); const acceptReport = useDismissedReportsStore((s) => s.acceptReport); + const analytics = useAnalytics(); + + const trackReportAction = useCallback( + ( + report: SignalReport, + actionType: "dismiss" | "create_pr", + position: number, + total: number, + ) => { + analytics.track(ANALYTICS_EVENTS.INBOX_REPORT_ACTION, { + report_id: report.id, + report_title: report.title ?? null, + report_age_hours: computeReportAgeHours(report.created_at), + priority: report.priority ?? null, + actionability: report.actionability ?? null, + action_type: actionType, + // Tinder cards stack like a list of rows the user is acting on + // without opening a detail view — closest desktop analogue. + surface: "list_row", + is_bulk: false, + bulk_size: 1, + rank: position, + list_size: total, + }); + }, + [analytics], + ); + // Local state const [expandedReport, setExpandedReport] = useState( null, @@ -161,15 +194,22 @@ export function TinderView({ toastTimer.current = setTimeout(() => setToast(null), 10_000); }, []); + const reportsRef = useRef(reports); + reportsRef.current = reports; + const handleDismiss = useCallback( (reportId: string) => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + const visible = reportsRef.current; + const idx = visible.findIndex((r) => r.id === reportId); + const target = idx >= 0 ? visible[idx] : null; + if (target) trackReportAction(target, "dismiss", idx, visible.length); dismissReport(reportId); // Don't advanceCard() — the parent filters dismissed IDs from the // reports array, so removing the report shifts the next one into // the current index position automatically. }, - [dismissReport], + [dismissReport, trackReportAction], ); const handleAccept = useCallback( @@ -177,6 +217,11 @@ export function TinderView({ setCreating(true); setError(null); showToastPending(report.title ?? "Untitled report"); + // Snapshot rank/list_size before the swipe completes — accepting filters + // the report out of the visible deck. + const visibleBefore = reportsRef.current; + const acceptedRank = visibleBefore.findIndex((r) => r.id === report.id); + const acceptedListSize = visibleBefore.length; try { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); @@ -213,6 +258,7 @@ export function TinderView({ }); acceptReport(report.id); + trackReportAction(report, "create_pr", acceptedRank, acceptedListSize); showToastDone(task.id, report.title ?? "Untitled report"); } catch (e) { const message = @@ -224,7 +270,13 @@ export function TinderView({ setCreating(false); } }, - [repositoryOptions, showToastPending, showToastDone, acceptReport], + [ + repositoryOptions, + showToastPending, + showToastDone, + acceptReport, + trackReportAction, + ], ); const currentReport = diff --git a/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.test.ts b/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.test.ts new file mode 100644 index 0000000000..d99d736698 --- /dev/null +++ b/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.test.ts @@ -0,0 +1,235 @@ +import { createElement } from "react"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// posthog-react-native pulls in real react-native at import time, which vitest +// can't parse. The hook only uses `Analytics.track`, which is passed in by the +// caller — so mocking the module to a no-op keeps the import graph quiet. +vi.mock("posthog-react-native", () => ({ + usePostHog: () => null, +})); + +import { ANALYTICS_EVENTS, type Analytics } from "@/lib/analytics"; +import type { SignalReport } from "../types"; +import { + type InboxEngagementTracker, + type UseInboxEngagementTrackerOptions, + useInboxEngagementTracker, +} from "./useInboxEngagementTracker"; + +function makeReport(overrides: Partial = {}): SignalReport { + return { + id: "r1", + title: "Report 1", + summary: null, + status: "ready", + total_weight: 0, + signal_count: 0, + created_at: "2026-01-01T12:00:00Z", + updated_at: "2026-01-01T12:00:00Z", + artefact_count: 0, + priority: "P1", + actionability: "immediately_actionable", + source_products: ["error_tracking"], + ...overrides, + }; +} + +function renderTracker(initial: UseInboxEngagementTrackerOptions) { + const trackerRef: { current: InboxEngagementTracker | null } = { + current: null, + }; + let currentOptions = initial; + function Wrapper() { + trackerRef.current = useInboxEngagementTracker(currentOptions); + return null; + } + let renderer: ReactTestRenderer | null = null; + act(() => { + renderer = create(createElement(Wrapper)); + }); + return { + tracker: () => { + if (!trackerRef.current) throw new Error("tracker not initialised"); + return trackerRef.current; + }, + rerender: (next: UseInboxEngagementTrackerOptions) => { + currentOptions = next; + act(() => { + renderer?.update(createElement(Wrapper)); + }); + }, + unmount: () => { + act(() => { + renderer?.unmount(); + }); + }, + }; +} + +describe("useInboxEngagementTracker", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T13:00:00Z")); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("fires OPENED on mount with the right snapshot of report fields", () => { + const track = vi.fn(); + const analytics: Analytics = { track }; + const report = makeReport(); + renderTracker({ + analytics, + report, + rank: 2, + listSize: 5, + openMethod: "click", + previousReportId: "prev-1", + }); + expect(track).toHaveBeenCalledWith( + ANALYTICS_EVENTS.INBOX_REPORT_OPENED, + expect.objectContaining({ + report_id: "r1", + report_title: "Report 1", + report_age_hours: 1, + status: "ready", + priority: "P1", + actionability: "immediately_actionable", + source_products: ["error_tracking"], + rank: 2, + list_size: 5, + open_method: "click", + previous_report_id: "prev-1", + }), + ); + }); + + it("fires CLOSED on unmount with time_spent, scrolled, close_method", () => { + const track = vi.fn(); + const report = makeReport(); + const hook = renderTracker({ + analytics: { track }, + report, + rank: 0, + listSize: 1, + openMethod: "click", + previousReportId: null, + }); + act(() => { + hook.tracker().signalScroll(); + }); + vi.advanceTimersByTime(2500); + hook.unmount(); + const closeCall = track.mock.calls.find( + ([name]) => name === ANALYTICS_EVENTS.INBOX_REPORT_CLOSED, + ); + expect(closeCall).toBeDefined(); + expect(closeCall?.[1]).toMatchObject({ + report_id: "r1", + scrolled: true, + close_method: "deselected", + priority: "P1", + actionability: "immediately_actionable", + }); + expect((closeCall?.[1] as { time_spent_ms: number }).time_spent_ms).toBe( + 2500, + ); + }); + + it("fires SCROLLED at most once per open", () => { + const track = vi.fn(); + const hook = renderTracker({ + analytics: { track }, + report: makeReport(), + rank: 0, + listSize: 1, + openMethod: "click", + previousReportId: null, + }); + act(() => { + hook.tracker().signalScroll(); + hook.tracker().signalScroll(); + hook.tracker().signalScroll(); + }); + const scrollCalls = track.mock.calls.filter( + ([name]) => name === ANALYTICS_EVENTS.INBOX_REPORT_SCROLLED, + ); + expect(scrollCalls).toHaveLength(1); + }); + + it("signalAction inherits rank/list_size/priority/actionability from the current open", () => { + const track = vi.fn(); + const report = makeReport({ priority: "P0" }); + const hook = renderTracker({ + analytics: { track }, + report, + rank: 3, + listSize: 7, + openMethod: "click", + previousReportId: null, + }); + act(() => { + hook.tracker().signalAction({ + report_id: "r1", + report_title: "Report 1", + report_age_hours: 1, + action_type: "create_pr", + surface: "detail_pane", + is_bulk: false, + bulk_size: 1, + }); + }); + const actionCall = track.mock.calls.find( + ([name]) => name === ANALYTICS_EVENTS.INBOX_REPORT_ACTION, + ); + expect(actionCall?.[1]).toMatchObject({ + rank: 3, + list_size: 7, + priority: "P0", + actionability: "immediately_actionable", + }); + }); + + it("signalAction lets explicit overrides win for a different report", () => { + const track = vi.fn(); + const hook = renderTracker({ + analytics: { track }, + report: makeReport(), + rank: 0, + listSize: 1, + openMethod: "click", + previousReportId: null, + }); + act(() => { + hook.tracker().signalAction({ + report_id: "other-report", + report_title: "Other", + report_age_hours: 0, + action_type: "dismiss", + surface: "toolbar", + is_bulk: false, + bulk_size: 1, + rank: 9, + list_size: 12, + priority: "P4", + actionability: "not_actionable", + dismissal_reason: "other", + dismissal_note: "junk", + }); + }); + const actionCall = track.mock.calls.find( + ([name]) => name === ANALYTICS_EVENTS.INBOX_REPORT_ACTION, + ); + expect(actionCall?.[1]).toMatchObject({ + report_id: "other-report", + rank: 9, + list_size: 12, + priority: "P4", + actionability: "not_actionable", + dismissal_reason: "other", + dismissal_note: "junk", + }); + }); +}); diff --git a/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.ts b/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.ts new file mode 100644 index 0000000000..9b032fbe43 --- /dev/null +++ b/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.ts @@ -0,0 +1,174 @@ +import { useCallback, useEffect, useRef } from "react"; +import { + ANALYTICS_EVENTS, + type Analytics, + computeReportAgeHours, + type InboxReportActionProperties, + type InboxReportCloseMethod, + type InboxReportOpenMethod, +} from "@/lib/analytics"; +import type { SignalReport } from "../types"; + +interface OpenInfo { + reportId: string; + reportTitle: string | null; + reportCreatedAt: string | null; + reportPriority: string | null; + reportActionability: string | null; + openedAt: number; + rank: number; + listSize: number; + hasScrolled: boolean; +} + +export interface InboxEngagementTracker { + signalScroll(): void; + signalAction( + action: Omit< + InboxReportActionProperties, + "rank" | "list_size" | "priority" | "actionability" + > & { + rank?: number; + list_size?: number; + priority?: string | null; + actionability?: string | null; + }, + ): void; +} + +export interface UseInboxEngagementTrackerOptions { + analytics: Analytics; + report: SignalReport | null; + /** Rank of the report in the visible inbox list, or -1 if not in a list view. */ + rank: number; + /** Size of the visible inbox list, or 0 if not in a list view. */ + listSize: number; + /** Method that brought the user to this report. */ + openMethod: InboxReportOpenMethod; + /** Previously-opened report id; null on the first open of a session. */ + previousReportId: string | null; +} + +export function useInboxEngagementTracker( + options: UseInboxEngagementTrackerOptions, +): InboxEngagementTracker { + const { analytics, report, rank, listSize, openMethod, previousReportId } = + options; + + const openInfoRef = useRef(null); + + const analyticsRef = useRef(analytics); + analyticsRef.current = analytics; + + const fireClose = useCallback((closeMethod: InboxReportCloseMethod) => { + const info = openInfoRef.current; + if (!info) return; + analyticsRef.current.track(ANALYTICS_EVENTS.INBOX_REPORT_CLOSED, { + report_id: info.reportId, + report_title: info.reportTitle, + report_age_hours: computeReportAgeHours(info.reportCreatedAt), + priority: info.reportPriority, + actionability: info.reportActionability, + time_spent_ms: Date.now() - info.openedAt, + scrolled: info.hasScrolled, + close_method: closeMethod, + }); + openInfoRef.current = null; + }, []); + + const reportId = report?.id ?? null; + + useEffect(() => { + if (!reportId || !report) return; + + const info: OpenInfo = { + reportId, + reportTitle: report.title ?? null, + reportCreatedAt: report.created_at ?? null, + reportPriority: report.priority ?? null, + reportActionability: report.actionability ?? null, + openedAt: Date.now(), + rank, + listSize, + hasScrolled: false, + }; + openInfoRef.current = info; + + analyticsRef.current.track(ANALYTICS_EVENTS.INBOX_REPORT_OPENED, { + report_id: info.reportId, + report_title: info.reportTitle, + report_age_hours: computeReportAgeHours(info.reportCreatedAt), + status: report.status ?? null, + priority: info.reportPriority, + actionability: info.reportActionability, + source_products: report.source_products ?? [], + rank: info.rank, + list_size: info.listSize, + open_method: openMethod, + previous_report_id: previousReportId, + }); + + return () => { + fireClose("deselected"); + }; + }, [ + reportId, + report, + rank, + listSize, + openMethod, + previousReportId, + fireClose, + ]); + + const signalScroll = useCallback(() => { + const info = openInfoRef.current; + if (!info || info.hasScrolled) return; + info.hasScrolled = true; + analyticsRef.current.track(ANALYTICS_EVENTS.INBOX_REPORT_SCROLLED, { + report_id: info.reportId, + report_title: info.reportTitle, + report_age_hours: computeReportAgeHours(info.reportCreatedAt), + priority: info.reportPriority, + actionability: info.reportActionability, + rank: info.rank, + list_size: info.listSize, + time_since_open_ms: Date.now() - info.openedAt, + }); + }, []); + + const signalAction = useCallback( + (action) => { + const info = openInfoRef.current; + const currentInfo = + info && info.reportId === action.report_id ? info : null; + const { + rank: rankOverride, + list_size: listSizeOverride, + priority: priorityOverride, + actionability: actionabilityOverride, + ...rest + } = action; + analyticsRef.current.track(ANALYTICS_EVENTS.INBOX_REPORT_ACTION, { + ...rest, + rank: + rankOverride !== undefined ? rankOverride : (currentInfo?.rank ?? -1), + list_size: + listSizeOverride !== undefined + ? listSizeOverride + : (currentInfo?.listSize ?? 0), + priority: + priorityOverride !== undefined + ? priorityOverride + : (currentInfo?.reportPriority ?? null), + actionability: + actionabilityOverride !== undefined + ? actionabilityOverride + : (currentInfo?.reportActionability ?? null), + }); + }, + [], + ); + + return { signalScroll, signalAction }; +} diff --git a/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts b/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts index f8efc7baf5..0f2f3391e1 100644 --- a/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts +++ b/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts @@ -19,7 +19,7 @@ export type SourceProduct = | "zendesk" | "conversations"; -const DEFAULT_STATUS_FILTER: SignalReportStatus[] = [ +export const DEFAULT_STATUS_FILTER: SignalReportStatus[] = [ "ready", "pending_input", "in_progress", diff --git a/apps/mobile/src/features/inbox/stores/inboxStore.ts b/apps/mobile/src/features/inbox/stores/inboxStore.ts index 76dded4d93..7bdb2ec206 100644 --- a/apps/mobile/src/features/inbox/stores/inboxStore.ts +++ b/apps/mobile/src/features/inbox/stores/inboxStore.ts @@ -15,6 +15,14 @@ interface InboxStoreState { skippedIds: SkippedSet; /** Index of the currently visible card in the deck */ currentIndex: number; + /** + * Snapshot of the report IDs visible in the last-rendered list view, used + * for analytics (rank + list_size) when a detail screen is opened by tapping + * a list row. + */ + lastVisibleReportIds: string[]; + /** Most recently opened report ID, used for `previous_report_id` on OPENED events. */ + previousOpenedReportId: string | null; } interface InboxStoreActions { @@ -24,6 +32,8 @@ interface InboxStoreActions { resetSkipped: () => void; setCurrentIndex: (index: number) => void; advanceCard: () => void; + setLastVisibleReportIds: (ids: string[]) => void; + setPreviousOpenedReportId: (id: string | null) => void; } type InboxStore = InboxStoreState & InboxStoreActions; @@ -33,6 +43,8 @@ export const useInboxStore = create((set) => ({ orderDirection: "desc", skippedIds: new Set(), currentIndex: 0, + lastVisibleReportIds: [], + previousOpenedReportId: null, setOrderByField: (orderByField) => set({ orderByField }), setOrderDirection: (orderDirection) => set({ orderDirection }), @@ -45,4 +57,8 @@ export const useInboxStore = create((set) => ({ resetSkipped: () => set({ skippedIds: new Set(), currentIndex: 0 }), setCurrentIndex: (currentIndex) => set({ currentIndex }), advanceCard: () => set((state) => ({ currentIndex: state.currentIndex + 1 })), + setLastVisibleReportIds: (lastVisibleReportIds) => + set({ lastVisibleReportIds }), + setPreviousOpenedReportId: (previousOpenedReportId) => + set({ previousOpenedReportId }), })); diff --git a/apps/mobile/src/features/inbox/utils.test.ts b/apps/mobile/src/features/inbox/utils.test.ts new file mode 100644 index 0000000000..56c53cb951 --- /dev/null +++ b/apps/mobile/src/features/inbox/utils.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "vitest"; +import type { SignalReport, SignalReportStatus } from "./types"; +import { buildInboxViewedProperties } from "./utils"; + +const DEFAULT_STATUS_FILTER: SignalReportStatus[] = [ + "ready", + "pending_input", + "in_progress", + "failed", + "candidate", + "potential", +]; + +function makeReport( + partial: Partial & Pick, +): SignalReport { + return { + title: null, + summary: null, + status: "ready", + total_weight: 0, + signal_count: 0, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + artefact_count: 0, + ...partial, + }; +} + +describe("buildInboxViewedProperties", () => { + it("emits zero counts for an empty list", () => { + const props = buildInboxViewedProperties([], 0, { + sourceProductFilter: [], + statusFilter: DEFAULT_STATUS_FILTER, + suggestedReviewerFilter: [], + defaultStatusFilter: DEFAULT_STATUS_FILTER, + }); + expect(props).toMatchObject({ + report_count: 0, + total_count: 0, + ready_count: 0, + has_active_filters: false, + is_empty: true, + is_gated_due_to_scale: false, + priority_p0_count: 0, + priority_p1_count: 0, + priority_p2_count: 0, + priority_p3_count: 0, + priority_p4_count: 0, + priority_unknown_count: 0, + actionability_immediately_actionable_count: 0, + actionability_requires_human_input_count: 0, + actionability_not_actionable_count: 0, + actionability_unknown_count: 0, + }); + }); + + it("breaks visible reports down by priority and actionability", () => { + const reports: SignalReport[] = [ + makeReport({ + id: "1", + priority: "P0", + actionability: "immediately_actionable", + status: "ready", + }), + makeReport({ + id: "2", + priority: "P2", + actionability: "requires_human_input", + status: "ready", + }), + makeReport({ + id: "3", + priority: "P2", + actionability: "not_actionable", + status: "potential", + }), + makeReport({ id: "4", status: "failed" }), + ]; + + const props = buildInboxViewedProperties(reports, 4, { + sourceProductFilter: [], + statusFilter: DEFAULT_STATUS_FILTER, + suggestedReviewerFilter: [], + defaultStatusFilter: DEFAULT_STATUS_FILTER, + }); + + expect(props.report_count).toBe(4); + expect(props.total_count).toBe(4); + expect(props.ready_count).toBe(2); + expect(props.priority_p0_count).toBe(1); + expect(props.priority_p2_count).toBe(2); + expect(props.priority_unknown_count).toBe(1); + expect(props.actionability_immediately_actionable_count).toBe(1); + expect(props.actionability_requires_human_input_count).toBe(1); + expect(props.actionability_not_actionable_count).toBe(1); + expect(props.actionability_unknown_count).toBe(1); + }); + + it("marks filters active when any of status/source/reviewer differs from defaults", () => { + const narrowed = buildInboxViewedProperties([], 0, { + sourceProductFilter: [], + statusFilter: ["ready"], + suggestedReviewerFilter: [], + defaultStatusFilter: DEFAULT_STATUS_FILTER, + }); + 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, + suggestedReviewerFilter: [], + defaultStatusFilter: DEFAULT_STATUS_FILTER, + }); + expect(sourced.has_active_filters).toBe(true); + expect(sourced.source_product_filter).toEqual(["error_tracking"]); + + const reviewer = buildInboxViewedProperties([], 0, { + sourceProductFilter: [], + statusFilter: DEFAULT_STATUS_FILTER, + suggestedReviewerFilter: ["uuid-1"], + defaultStatusFilter: DEFAULT_STATUS_FILTER, + }); + expect(reviewer.has_active_filters).toBe(true); + }); + + it("treats a reordered default status set as not filtered", () => { + const props = buildInboxViewedProperties([], 0, { + sourceProductFilter: [], + statusFilter: [...DEFAULT_STATUS_FILTER].reverse(), + suggestedReviewerFilter: [], + defaultStatusFilter: DEFAULT_STATUS_FILTER, + }); + expect(props.has_active_filters).toBe(false); + }); +}); diff --git a/apps/mobile/src/features/inbox/utils.ts b/apps/mobile/src/features/inbox/utils.ts index 588978b49f..2ac3de63ee 100644 --- a/apps/mobile/src/features/inbox/utils.ts +++ b/apps/mobile/src/features/inbox/utils.ts @@ -1,3 +1,4 @@ +import type { InboxViewedProperties } from "@/lib/analytics"; import type { SignalReport, SignalReportOrderingField, @@ -87,3 +88,89 @@ export function getActionableReports(reports: SignalReport[]): SignalReport[] { !r.already_addressed, ); } + +interface InboxViewedFilterState { + sourceProductFilter: string[]; + statusFilter: SignalReportStatus[]; + suggestedReviewerFilter: string[]; + /** Default status filter as defined in the filter store, used to detect whether the user has narrowed it. */ + defaultStatusFilter: SignalReportStatus[]; +} + +/** + * Build the property payload for the `Inbox viewed` analytics event. + * + * Mirrors apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx so + * desktop and mobile send the same shape into PostHog. + */ +export function buildInboxViewedProperties( + reports: SignalReport[], + totalCount: number, + filters: InboxViewedFilterState, +): InboxViewedProperties { + const priorityCounts = { + P0: 0, + P1: 0, + P2: 0, + P3: 0, + P4: 0, + unknown: 0, + }; + const actionabilityCounts = { + immediately_actionable: 0, + requires_human_input: 0, + not_actionable: 0, + unknown: 0, + }; + let readyCount = 0; + for (const r of reports) { + if (r.status === "ready") readyCount += 1; + const p = r.priority; + if (p === "P0" || p === "P1" || p === "P2" || p === "P3" || p === "P4") { + priorityCounts[p] += 1; + } else { + priorityCounts.unknown += 1; + } + const a = r.actionability; + if ( + a === "immediately_actionable" || + a === "requires_human_input" || + a === "not_actionable" + ) { + actionabilityCounts[a] += 1; + } else { + actionabilityCounts.unknown += 1; + } + } + + const statusFiltered = + filters.statusFilter.length !== filters.defaultStatusFilter.length || + filters.statusFilter.some((s) => !filters.defaultStatusFilter.includes(s)); + const hasActiveFilters = + statusFiltered || + filters.sourceProductFilter.length > 0 || + filters.suggestedReviewerFilter.length > 0; + + return { + report_count: reports.length, + total_count: totalCount, + ready_count: readyCount, + has_active_filters: hasActiveFilters, + source_product_filter: filters.sourceProductFilter, + status_filter_count: filters.statusFilter.length, + is_empty: totalCount === 0, + is_gated_due_to_scale: false, + priority_p0_count: priorityCounts.P0, + priority_p1_count: priorityCounts.P1, + priority_p2_count: priorityCounts.P2, + priority_p3_count: priorityCounts.P3, + priority_p4_count: priorityCounts.P4, + priority_unknown_count: priorityCounts.unknown, + actionability_immediately_actionable_count: + actionabilityCounts.immediately_actionable, + actionability_requires_human_input_count: + actionabilityCounts.requires_human_input, + actionability_not_actionable_count: actionabilityCounts.not_actionable, + actionability_unknown_count: actionabilityCounts.unknown, + }; +} diff --git a/apps/mobile/src/features/tasks/types.ts b/apps/mobile/src/features/tasks/types.ts index 9e7ec5a54a..2cb96dba56 100644 --- a/apps/mobile/src/features/tasks/types.ts +++ b/apps/mobile/src/features/tasks/types.ts @@ -7,6 +7,8 @@ export interface Task { created_at: string; updated_at: string; origin_product: string; + /** Inbox report UUID when origin_product is "signal_report". */ + signal_report?: string | null; repository?: string | null; github_integration?: number | null; internal?: boolean; diff --git a/apps/mobile/src/lib/analytics.test.ts b/apps/mobile/src/lib/analytics.test.ts new file mode 100644 index 0000000000..f0f1e104cc --- /dev/null +++ b/apps/mobile/src/lib/analytics.test.ts @@ -0,0 +1,96 @@ +import { createElement } from "react"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + computeReportAgeHours, + useActiveTaskAnalyticsContext, +} from "./analytics"; + +const mockPosthog = { + register: vi.fn(), + unregister: vi.fn(), + capture: vi.fn(), +}; + +vi.mock("posthog-react-native", () => ({ + usePostHog: () => mockPosthog, +})); + +describe("computeReportAgeHours", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T12:00:00Z")); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns 0 for null/undefined input", () => { + expect(computeReportAgeHours(null)).toBe(0); + expect(computeReportAgeHours(undefined)).toBe(0); + }); + + it("rounds to one decimal", () => { + // 1h 35m before "now" → 1.6h + expect(computeReportAgeHours("2026-01-01T10:25:00Z")).toBe(1.6); + }); + + it("clamps at 0 when clock skew puts createdAt in the future", () => { + expect(computeReportAgeHours("2026-01-02T00:00:00Z")).toBe(0); + }); +}); + +function renderActiveTaskHook(initial: string | null) { + let currentId: string | null = initial; + function Wrapper() { + useActiveTaskAnalyticsContext(currentId); + return null; + } + let renderer: ReactTestRenderer | null = null; + act(() => { + renderer = create(createElement(Wrapper)); + }); + return { + rerender: (id: string | null) => { + currentId = id; + act(() => { + renderer?.update(createElement(Wrapper)); + }); + }, + unmount: () => { + act(() => { + renderer?.unmount(); + }); + }, + }; +} + +describe("useActiveTaskAnalyticsContext", () => { + beforeEach(() => { + mockPosthog.register.mockClear(); + mockPosthog.unregister.mockClear(); + }); + + it("registers and unregisters signal_report_id as the prop changes", () => { + const hook = renderActiveTaskHook("report-1"); + expect(mockPosthog.register).toHaveBeenCalledWith({ + signal_report_id: "report-1", + }); + + hook.rerender("report-2"); + expect(mockPosthog.unregister).toHaveBeenCalledWith("signal_report_id"); + expect(mockPosthog.register).toHaveBeenLastCalledWith({ + signal_report_id: "report-2", + }); + + hook.unmount(); + expect(mockPosthog.unregister).toHaveBeenLastCalledWith("signal_report_id"); + }); + + it("never registers when the id is null", () => { + const hook = renderActiveTaskHook(null); + expect(mockPosthog.register).not.toHaveBeenCalled(); + expect(mockPosthog.unregister).not.toHaveBeenCalled(); + hook.unmount(); + }); +}); diff --git a/apps/mobile/src/lib/analytics.ts b/apps/mobile/src/lib/analytics.ts new file mode 100644 index 0000000000..e9ccebdca2 --- /dev/null +++ b/apps/mobile/src/lib/analytics.ts @@ -0,0 +1,198 @@ +import type { PostHogEventProperties } from "@posthog/core"; +import { usePostHog } from "posthog-react-native"; +import { useEffect, useMemo } from "react"; + +/** + * Event names mirror apps/code/src/shared/types/analytics.ts so PostHog reports + * funnel the same events from desktop and mobile into a single bucket. + */ +export const ANALYTICS_EVENTS = { + INBOX_VIEWED: "Inbox viewed", + INBOX_REPORT_OPENED: "Inbox report opened", + INBOX_REPORT_CLOSED: "Inbox report closed", + INBOX_REPORT_SCROLLED: "Inbox report scrolled", + INBOX_REPORT_ACTION: "Inbox report action", +} as const; + +export type InboxReportOpenMethod = + | "click" + | "click_cmd" + | "click_shift" + | "keyboard" + | "deeplink" + | "unknown"; + +export type InboxReportCloseMethod = + | "next_report" + | "deselected" + | "navigated_away" + | "unmount"; + +export type InboxReportActionType = + | "dismiss" + | "snooze" + | "delete" + | "reingest" + | "create_pr" + | "open_pr" + | "copy_link" + | "discuss" + | "expand_signal" + | "collapse_signal" + | "expand_signal_section" + | "view_signal_external" + | "expand_why" + | "click_suggested_reviewer" + | "expand_task_section" + | "play_session_recording"; + +export type InboxReportActionSurface = + | "detail_pane" + | "toolbar" + | "keyboard" + | "list_row"; + +export interface InboxViewedProperties { + report_count: number; + total_count: number; + ready_count: number; + has_active_filters: boolean; + source_product_filter: string[]; + status_filter_count: number; + is_empty: boolean; + is_gated_due_to_scale: boolean; + priority_p0_count: number; + priority_p1_count: number; + priority_p2_count: number; + priority_p3_count: number; + priority_p4_count: number; + priority_unknown_count: number; + actionability_immediately_actionable_count: number; + actionability_requires_human_input_count: number; + actionability_not_actionable_count: number; + actionability_unknown_count: number; +} + +export interface InboxReportOpenedProperties { + report_id: string; + report_title: string | null; + report_age_hours: number; + status: string | null; + priority: string | null; + actionability: string | null; + source_products: string[]; + rank: number; + list_size: number; + open_method: InboxReportOpenMethod; + previous_report_id: string | null; +} + +export interface InboxReportClosedProperties { + report_id: string; + report_title: string | null; + report_age_hours: number; + priority: string | null; + actionability: string | null; + time_spent_ms: number; + scrolled: boolean; + close_method: InboxReportCloseMethod; +} + +export interface InboxReportScrolledProperties { + report_id: string; + report_title: string | null; + report_age_hours: number; + priority: string | null; + actionability: string | null; + rank: number; + list_size: number; + time_since_open_ms: number; +} + +export interface InboxReportActionProperties { + report_id: string; + report_title: string | null; + report_age_hours: number; + priority: string | null; + actionability: string | null; + action_type: InboxReportActionType; + surface: InboxReportActionSurface; + is_bulk: boolean; + bulk_size: number; + rank: number; + list_size: number; + dismissal_reason?: string; + dismissal_note?: string; + signal_id?: string; + signal_source_product?: string; + signal_source_type?: string; + signal_section?: "relevant_code" | "data_queried"; + why_field?: "priority" | "actionability"; + task_section?: "research" | "implementation"; + has_question?: boolean; + question_text?: string; +} + +export type EventPropertyMap = { + [ANALYTICS_EVENTS.INBOX_VIEWED]: InboxViewedProperties; + [ANALYTICS_EVENTS.INBOX_REPORT_OPENED]: InboxReportOpenedProperties; + [ANALYTICS_EVENTS.INBOX_REPORT_CLOSED]: InboxReportClosedProperties; + [ANALYTICS_EVENTS.INBOX_REPORT_SCROLLED]: InboxReportScrolledProperties; + [ANALYTICS_EVENTS.INBOX_REPORT_ACTION]: InboxReportActionProperties; +}; + +export interface Analytics { + track( + eventName: K, + properties: EventPropertyMap[K], + ): void; +} + +export function useAnalytics(): Analytics { + const posthog = usePostHog(); + return useMemo( + () => ({ + track: (eventName, properties) => { + // Our typed property interfaces don't carry an index signature; cast + // to the wider PostHog event-properties shape without losing the + // narrower call-site type-check. + posthog?.capture( + eventName, + properties as unknown as PostHogEventProperties, + ); + }, + }), + [posthog], + ); +} + +/** + * Tag every subsequent PostHog event with `signal_report_id` for as long as + * the calling screen is mounted with a non-null `signalReportId`. Clears the + * super-property on unmount or when `signalReportId` becomes null. Mirrors the + * desktop `setActiveTaskAnalyticsContext` super-property behaviour so events + * fired while inside a discuss-launched task can be filtered down to a single + * inbox report. + */ +export function useActiveTaskAnalyticsContext( + signalReportId: string | null | undefined, +): void { + const posthog = usePostHog(); + useEffect(() => { + if (!posthog || !signalReportId) return; + posthog.register({ signal_report_id: signalReportId }); + return () => { + posthog.unregister("signal_report_id"); + }; + }, [posthog, signalReportId]); +} + +/** Report age at fire time in hours, rounded to one decimal. Clamped at 0 to guard against clock skew. */ +export function computeReportAgeHours( + createdAt: string | null | undefined, +): number { + if (!createdAt) return 0; + const ageMs = Date.now() - new Date(createdAt).getTime(); + if (!Number.isFinite(ageMs)) return 0; + return Math.max(0, Math.round((ageMs / 3_600_000) * 10) / 10); +} From 7613045f722d4938a05309c332d638351e0019f4 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Thu, 28 May 2026 13:21:18 +0100 Subject: [PATCH 2/8] fix(mobile): address Greptile review on inbox analytics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes from the Greptile bot review: 1. **`Inbox viewed` was silently skipped on re-focus when data was cached** (P1). The `useFocusEffect` cleanup reset `viewedFiredRef` to `false`, but the firing `useEffect` only re-runs when its deps change — and on a re-focus with cached data, none of the deps had changed, so the event never fired again. Switched to a `focusVersion` state counter that bumps on every focus (so it appears in the effect's dep list) and changed the guard ref to remember which focus-version we last fired for, so we still only fire once per focus. 2. **`expand_signal` analytics fired inside a `setState` updater** (P2). React Strict Mode double-invokes updaters in development, which would double-fire the event. Read the next value outside the updater, fire the analytics, then call `setSignalsExpanded(next)`. No behaviour change beyond the bug fixes. `pnpm --filter @posthog/mobile lint` clean, all 119 tests still passing, no new TypeScript errors. Generated-By: PostHog Code Task-Id: 2e652fec-af01-4476-b6cf-ab9b4de015db --- apps/mobile/src/app/(tabs)/inbox.tsx | 19 +++++++++++------ apps/mobile/src/app/inbox/[id].tsx | 32 ++++++++++++++-------------- 2 files changed, 28 insertions(+), 23 deletions(-) diff --git a/apps/mobile/src/app/(tabs)/inbox.tsx b/apps/mobile/src/app/(tabs)/inbox.tsx index 14c30e8354..0c892acafc 100644 --- a/apps/mobile/src/app/(tabs)/inbox.tsx +++ b/apps/mobile/src/app/(tabs)/inbox.tsx @@ -43,19 +43,23 @@ export default function InboxScreen() { ); const analytics = useAnalytics(); - // Fire INBOX_VIEWED once per focus when the report list has settled. - const viewedFiredRef = useRef(false); + // Fire INBOX_VIEWED once per focus when the report list has settled. We + // bump a focus counter on every focus so the useEffect re-runs even when + // the data is already cached (no loading/filter/list change to trigger it + // on its own), then guard against double-fires within the same focus via + // a ref keyed on the focus-version we last fired for. + const [focusVersion, setFocusVersion] = useState(0); useFocusEffect( useCallback(() => { - return () => { - viewedFiredRef.current = false; - }; + setFocusVersion((v) => v + 1); }, []), ); + const viewedFiredForFocusRef = useRef(null); useEffect(() => { + if (focusVersion === 0) return; if (isLoading) return; - if (viewedFiredRef.current) return; - viewedFiredRef.current = true; + if (viewedFiredForFocusRef.current === focusVersion) return; + viewedFiredForFocusRef.current = focusVersion; analytics.track( ANALYTICS_EVENTS.INBOX_VIEWED, buildInboxViewedProperties(reports, totalCount, { @@ -67,6 +71,7 @@ export default function InboxScreen() { ); }, [ analytics, + focusVersion, isLoading, reports, totalCount, diff --git a/apps/mobile/src/app/inbox/[id].tsx b/apps/mobile/src/app/inbox/[id].tsx index 6eeb9fdd43..8d8c243c5a 100644 --- a/apps/mobile/src/app/inbox/[id].tsx +++ b/apps/mobile/src/app/inbox/[id].tsx @@ -167,22 +167,22 @@ export default function ReportDetailScreen() { ); const handleToggleSignals = useCallback(() => { - setSignalsExpanded((v) => { - const next = !v; - if (next && report) { - tracker.signalAction({ - report_id: report.id, - report_title: report.title ?? null, - report_age_hours: computeReportAgeHours(report.created_at), - action_type: "expand_signal", - surface: "detail_pane", - is_bulk: false, - bulk_size: 1, - }); - } - return next; - }); - }, [report, tracker]); + // Fire analytics outside the state updater — Strict Mode double-invokes + // updaters in development, which would double-fire the event. + const next = !signalsExpanded; + if (next && report) { + tracker.signalAction({ + report_id: report.id, + report_title: report.title ?? null, + report_age_hours: computeReportAgeHours(report.created_at), + action_type: "expand_signal", + surface: "detail_pane", + is_bulk: false, + bulk_size: 1, + }); + } + setSignalsExpanded(next); + }, [report, tracker, signalsExpanded]); useEffect(() => { if (!reportId) return; From 621f3dae398a1edaa79f8b998c3db0abe3d83c8b Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Thu, 28 May 2026 13:38:29 +0100 Subject: [PATCH 3/8] fix(mobile): guard inbox tracker against background-refetch CLOSED+OPENED spikes Greptile P1 (Comments Outside Diff): `useInboxEngagementTracker`'s lifecycle `useEffect` had `report`, `rank`, `listSize`, `openMethod`, and `previousReportId` in its dep list. When the inbox list refetched in the background, any change to those values (e.g. the same report shifting rank, or the report's priority/actionability shape changing) would fire a spurious `INBOX_REPORT_CLOSED` with near-zero `time_spent_ms` followed immediately by a new `INBOX_REPORT_OPENED`. Snapshot those inputs through refs so only `reportId` gates open/close. The OPENED event still records rank / listSize / open method as-of mount, but a same-report shape change can no longer churn the lifecycle. Added a regression test asserting that rerendering with a new rank + listSize + report shape (same `id`) does not produce any additional OPENED or CLOSED events. Generated-By: PostHog Code Task-Id: 2e652fec-af01-4476-b6cf-ab9b4de015db --- .../hooks/useInboxEngagementTracker.test.ts | 38 ++++++++++++++ .../inbox/hooks/useInboxEngagementTracker.ts | 49 +++++++++++-------- 2 files changed, 67 insertions(+), 20 deletions(-) diff --git a/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.test.ts b/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.test.ts index d99d736698..2e970def6b 100644 --- a/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.test.ts +++ b/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.test.ts @@ -192,6 +192,44 @@ describe("useInboxEngagementTracker", () => { }); }); + it("does not re-fire OPENED/CLOSED when rank/listSize/report change while the same report stays open", () => { + // Regression for a background-refetch spike: rank, listSize, and the + // report shape are inputs to OPENED but only `reportId` should gate the + // open/close lifecycle. + const track = vi.fn(); + const report = makeReport(); + const hook = renderTracker({ + analytics: { track }, + report, + rank: 2, + listSize: 5, + openMethod: "click", + previousReportId: null, + }); + const openedBefore = track.mock.calls.filter( + ([name]) => name === ANALYTICS_EVENTS.INBOX_REPORT_OPENED, + ).length; + const closedBefore = track.mock.calls.filter( + ([name]) => name === ANALYTICS_EVENTS.INBOX_REPORT_CLOSED, + ).length; + hook.rerender({ + analytics: { track }, + report: makeReport({ priority: "P2", actionability: "not_actionable" }), + rank: 4, + listSize: 6, + openMethod: "click", + previousReportId: null, + }); + const openedAfter = track.mock.calls.filter( + ([name]) => name === ANALYTICS_EVENTS.INBOX_REPORT_OPENED, + ).length; + const closedAfter = track.mock.calls.filter( + ([name]) => name === ANALYTICS_EVENTS.INBOX_REPORT_CLOSED, + ).length; + expect(openedAfter).toBe(openedBefore); + expect(closedAfter).toBe(closedBefore); + }); + it("signalAction lets explicit overrides win for a different report", () => { const track = vi.fn(); const hook = renderTracker({ diff --git a/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.ts b/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.ts index 9b032fbe43..67ac03e03a 100644 --- a/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.ts +++ b/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.ts @@ -60,6 +60,21 @@ export function useInboxEngagementTracker( const analyticsRef = useRef(analytics); analyticsRef.current = analytics; + // Snapshot the inputs through refs so the OPENED/CLOSED lifecycle effect + // can read them without being a dep — a background list refetch (rank / + // listSize / report shape changing while the user is reading) would + // otherwise fire spurious CLOSED+OPENED pairs. + const reportRef = useRef(report); + reportRef.current = report; + const rankRef = useRef(rank); + rankRef.current = rank; + const listSizeRef = useRef(listSize); + listSizeRef.current = listSize; + const openMethodRef = useRef(openMethod); + openMethodRef.current = openMethod; + const previousReportIdRef = useRef(previousReportId); + previousReportIdRef.current = previousReportId; + const fireClose = useCallback((closeMethod: InboxReportCloseMethod) => { const info = openInfoRef.current; if (!info) return; @@ -79,17 +94,19 @@ export function useInboxEngagementTracker( const reportId = report?.id ?? null; useEffect(() => { - if (!reportId || !report) return; + if (!reportId) return; + const snapshotReport = reportRef.current; + if (!snapshotReport) return; const info: OpenInfo = { reportId, - reportTitle: report.title ?? null, - reportCreatedAt: report.created_at ?? null, - reportPriority: report.priority ?? null, - reportActionability: report.actionability ?? null, + reportTitle: snapshotReport.title ?? null, + reportCreatedAt: snapshotReport.created_at ?? null, + reportPriority: snapshotReport.priority ?? null, + reportActionability: snapshotReport.actionability ?? null, openedAt: Date.now(), - rank, - listSize, + rank: rankRef.current, + listSize: listSizeRef.current, hasScrolled: false, }; openInfoRef.current = info; @@ -98,28 +115,20 @@ export function useInboxEngagementTracker( report_id: info.reportId, report_title: info.reportTitle, report_age_hours: computeReportAgeHours(info.reportCreatedAt), - status: report.status ?? null, + status: snapshotReport.status ?? null, priority: info.reportPriority, actionability: info.reportActionability, - source_products: report.source_products ?? [], + source_products: snapshotReport.source_products ?? [], rank: info.rank, list_size: info.listSize, - open_method: openMethod, - previous_report_id: previousReportId, + open_method: openMethodRef.current, + previous_report_id: previousReportIdRef.current, }); return () => { fireClose("deselected"); }; - }, [ - reportId, - report, - rank, - listSize, - openMethod, - previousReportId, - fireClose, - ]); + }, [reportId, fireClose]); const signalScroll = useCallback(() => { const info = openInfoRef.current; From b953586829f97b01d8bd62e47ed3aa2cd5e3600a Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Tue, 2 Jun 2026 14:47:30 +0100 Subject: [PATCH 4/8] Merge origin/main into posthog-code/mobile-inbox-analytics-parity Resolve rename conflict in apps/mobile/src/app/inbox: main renamed [id].tsx to the catch-all [...id].tsx and added the Discuss feature; this branch added inbox engagement analytics. Combined both import sets. Generated-By: PostHog Code Task-Id: 3c668694-a211-41db-8646-068c350d22bb --- .github/pull_request_template.md | 9 +- .github/workflows/code-release.yml | 126 ++++- .github/workflows/stale.yml | 2 +- .npmrc | 1 + apps/code/forge.config.ts | 91 +++- apps/code/package.json | 3 + apps/code/scripts/build-linux-docker.sh | 65 +++ .../src/main/services/agent/service.test.ts | 7 +- apps/code/src/main/services/agent/service.ts | 14 +- .../services/app-lifecycle/service.test.ts | 6 + .../main/services/app-lifecycle/service.ts | 4 + .../src/main/services/cloud-task/service.ts | 17 + .../main/services/inbox-link/service.test.ts | 9 + .../src/main/services/llm-gateway/schemas.ts | 3 +- .../src/main/services/llm-gateway/service.ts | 3 +- .../code/src/main/services/updates/schemas.ts | 19 +- .../src/main/services/updates/service.test.ts | 289 ++++++++--- .../code/src/main/services/updates/service.ts | 280 ++++++++--- .../src/main/trpc/routers/updates.test.ts | 56 +++ apps/code/src/main/trpc/routers/updates.ts | 6 + apps/code/src/main/window.ts | 27 +- .../src/renderer/api/posthogClient.test.ts | 4 +- .../components/ui/SafeImagePreview.tsx | 34 +- .../components/detail/ReportDetailPane.tsx | 56 ++- .../features/inbox/hooks/useCreatePrReport.ts | 1 + .../features/inbox/hooks/useDiscussReport.ts | 1 + .../utils/buildDiscussReportPrompt.test.ts | 18 + .../inbox/utils/buildDiscussReportPrompt.ts | 16 +- .../components/AttachmentsBar.tsx | 20 +- .../components/PromptInput.stories.tsx | 7 +- .../message-editor/components/PromptInput.tsx | 4 +- .../components/ContextUsageIndicator.tsx | 2 +- .../sessions/components/ConversationView.tsx | 30 +- .../sessions/components/SessionFooter.tsx | 19 +- .../sessions/components/SessionView.tsx | 10 +- .../sessions/components/VirtualizedList.tsx | 261 ++++++---- .../session-update/AgentMessage.tsx | 2 +- .../session-update/ToolCallView.tsx | 35 +- .../components/session-update/UserMessage.tsx | 2 +- .../hooks/useChatTitleGenerator.test.ts | 178 ++++++- .../sessions/hooks/useChatTitleGenerator.ts | 102 ++-- .../sessions/hooks/useSessionConnection.ts | 2 +- .../sessions/hooks/useSessionViewState.ts | 2 +- .../features/sessions/service/service.ts | 2 +- .../components/sections/SlackSettings.tsx | 3 + .../sidebar/components/SidebarMenu.tsx | 48 +- .../sidebar/components/TaskListView.tsx | 14 +- .../sidebar/components/items/TaskIcon.tsx | 40 +- .../task-detail/components/TaskDetail.tsx | 28 +- .../renderer/features/tasks/hooks/taskKeys.ts | 15 + .../features/tasks/hooks/useTasks.test.tsx | 262 ++++++++++ .../renderer/features/tasks/hooks/useTasks.ts | 151 +++++- .../workspace/hooks/useIsCloudTask.ts | 6 +- .../hooks/useImagePanAndZoom.test.tsx | 308 ++++++++++++ .../src/renderer/hooks/useImagePanAndZoom.ts | 154 ++++++ .../src/renderer/hooks/useTaskContextMenu.ts | 2 +- .../renderer/sagas/task/task-creation.test.ts | 104 +--- .../src/renderer/sagas/task/task-creation.ts | 3 - .../src/renderer/stores/updateStore.test.ts | 225 +++++++++ apps/code/src/renderer/stores/updateStore.ts | 103 ++-- apps/code/src/shared/deeplink.test.ts | 71 +++ apps/code/src/shared/deeplink.ts | 35 ++ apps/code/src/shared/test/setup.ts | 30 ++ apps/code/src/shared/types/analytics.ts | 18 + apps/code/tests/e2e/fixtures/electron.ts | 11 + apps/code/vite.main.config.mts | 195 +++++--- apps/mobile/src/app/_layout.tsx | 12 +- .../src/app/inbox/{[id].tsx => [...id].tsx} | 69 ++- apps/mobile/src/app/settings/index.tsx | 63 ++- apps/mobile/src/app/task/[id].tsx | 58 ++- apps/mobile/src/app/task/index.tsx | 48 +- .../features/auth/hooks/useProjectsQuery.ts | 13 +- .../src/features/auth/hooks/useUserQuery.ts | 12 +- apps/mobile/src/features/inbox/api.ts | 33 +- .../inbox/components/DiscussReportSheet.tsx | 99 ++++ .../features/inbox/components/SignalCard.tsx | 15 +- .../features/inbox/components/TinderView.tsx | 3 +- apps/mobile/src/features/mcp/api.ts | 33 +- .../stores/preferencesStore.test.ts | 52 ++ .../preferences/stores/preferencesStore.ts | 24 + .../features/tasks/api.automations.test.ts | 13 +- apps/mobile/src/features/tasks/api.ts | 117 ++--- .../tasks/components/TaskSessionView.test.tsx | 77 +++ .../tasks/components/TaskSessionView.tsx | 42 +- .../src/features/tasks/composer/options.ts | 12 +- .../src/features/tasks/skills/api.test.ts | 13 +- apps/mobile/src/features/tasks/skills/api.ts | 12 +- .../stores/pendingTaskPromptStore.test.ts | 71 +++ .../tasks/stores/pendingTaskPromptStore.ts | 86 ++++ .../features/tasks/stores/taskSessionStore.ts | 58 ++- apps/mobile/src/lib/api.test.ts | 216 +++++++++ apps/mobile/src/lib/api.ts | 107 ++++- apps/mobile/src/lib/deep-links.test.ts | 139 ++++++ apps/mobile/src/lib/deep-links.ts | 83 +++- apps/mobile/src/lib/posthog.test.ts | 116 +++++ apps/mobile/src/lib/posthog.ts | 112 +++++ packages/agent/build/native-binary.mjs | 86 ++++ packages/agent/package.json | 6 +- .../agent/src/adapters/claude/UPSTREAM.md | 97 +++- .../claude/claude-agent.refresh.test.ts | 23 + .../claude/claude-agent.slash-command.test.ts | 24 +- .../agent/src/adapters/claude/claude-agent.ts | 270 +++++++++-- .../adapters/claude/conversion/sdk-to-acp.ts | 198 +++++++- .../claude/conversion/task-state.test.ts | 338 +++++++++++++ .../adapters/claude/conversion/task-state.ts | 178 +++++++ .../claude/conversion/tool-use-to-acp.ts | 48 +- .../agent/src/adapters/claude/hooks.test.ts | 162 +++++++ packages/agent/src/adapters/claude/hooks.ts | 47 +- .../claude/permissions/permission-options.ts | 9 +- .../src/adapters/claude/session/commands.ts | 1 + .../claude/session/jsonl-hydration.test.ts | 8 +- .../claude/session/jsonl-hydration.ts | 3 +- .../src/adapters/claude/session/mcp-config.ts | 30 +- .../claude/session/model-config.test.ts | 60 +++ .../adapters/claude/session/model-config.ts | 56 +++ .../adapters/claude/session/models.test.ts | 129 +++++ .../src/adapters/claude/session/models.ts | 41 +- .../adapters/claude/session/options.test.ts | 66 ++- .../src/adapters/claude/session/options.ts | 58 ++- .../adapters/claude/session/settings.test.ts | 103 +++- .../src/adapters/claude/session/settings.ts | 39 +- packages/agent/src/adapters/claude/tools.ts | 6 +- packages/agent/src/adapters/claude/types.ts | 15 + .../agent/src/adapters/codex/codex-agent.ts | 7 +- .../agent/src/adapters/codex/models.test.ts | 8 + packages/agent/src/adapters/codex/models.ts | 15 +- .../local-tools/tools/signed-commit.test.ts | 96 ++++ .../local-tools/tools/signed-commit.ts | 20 +- .../src/adapters/signed-commit-shared.ts | 8 + packages/agent/src/agent.ts | 6 +- packages/agent/src/gateway-models.test.ts | 57 +++ packages/agent/src/gateway-models.ts | 47 +- ...agent-server.configure-environment.test.ts | 50 +- .../agent/src/server/agent-server.test.ts | 173 +++++++ packages/agent/src/server/agent-server.ts | 61 ++- packages/agent/src/server/bin.ts | 21 + packages/agent/src/test/mocks/claude-sdk.ts | 6 +- packages/agent/src/test/mocks/msw-handlers.ts | 4 +- packages/agent/src/test/native-binary.test.ts | 27 ++ packages/agent/src/types.ts | 3 +- packages/agent/src/utils/common.ts | 6 - packages/agent/src/utils/gateway.ts | 9 +- packages/agent/src/utils/github-token.test.ts | 76 +++ packages/agent/src/utils/github-token.ts | 44 ++ packages/agent/tsup.config.ts | 70 ++- packages/shared/src/inbox-prompts.ts | 20 + packages/shared/src/index.ts | 1 + pnpm-lock.yaml | 450 +++++++++--------- pnpm-workspace.yaml | 11 + 149 files changed, 7604 insertions(+), 1272 deletions(-) create mode 100644 apps/code/scripts/build-linux-docker.sh create mode 100644 apps/code/src/main/trpc/routers/updates.test.ts create mode 100644 apps/code/src/renderer/features/tasks/hooks/taskKeys.ts create mode 100644 apps/code/src/renderer/features/tasks/hooks/useTasks.test.tsx create mode 100644 apps/code/src/renderer/hooks/useImagePanAndZoom.test.tsx create mode 100644 apps/code/src/renderer/hooks/useImagePanAndZoom.ts create mode 100644 apps/code/src/renderer/stores/updateStore.test.ts create mode 100644 apps/code/src/shared/deeplink.test.ts rename apps/mobile/src/app/inbox/{[id].tsx => [...id].tsx} (87%) create mode 100644 apps/mobile/src/features/inbox/components/DiscussReportSheet.tsx create mode 100644 apps/mobile/src/features/preferences/stores/preferencesStore.test.ts create mode 100644 apps/mobile/src/features/tasks/stores/pendingTaskPromptStore.test.ts create mode 100644 apps/mobile/src/features/tasks/stores/pendingTaskPromptStore.ts create mode 100644 apps/mobile/src/lib/api.test.ts create mode 100644 apps/mobile/src/lib/deep-links.test.ts create mode 100644 apps/mobile/src/lib/posthog.test.ts create mode 100644 packages/agent/build/native-binary.mjs create mode 100644 packages/agent/src/adapters/claude/conversion/task-state.test.ts create mode 100644 packages/agent/src/adapters/claude/conversion/task-state.ts create mode 100644 packages/agent/src/adapters/claude/session/model-config.test.ts create mode 100644 packages/agent/src/adapters/claude/session/model-config.ts create mode 100644 packages/agent/src/adapters/claude/session/models.test.ts create mode 100644 packages/agent/src/adapters/codex/models.test.ts create mode 100644 packages/agent/src/adapters/local-tools/tools/signed-commit.test.ts create mode 100644 packages/agent/src/gateway-models.test.ts create mode 100644 packages/agent/src/test/native-binary.test.ts create mode 100644 packages/agent/src/utils/github-token.test.ts create mode 100644 packages/agent/src/utils/github-token.ts create mode 100644 packages/shared/src/inbox-prompts.ts diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 768d40795b..fde54cfafc 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -14,10 +14,7 @@ -## Publish to changelog? +## Automatic notifications - - - - - +- [ ] Publish to changelog? +- [ ] Alert Sales and Marketing teams? diff --git a/.github/workflows/code-release.yml b/.github/workflows/code-release.yml index ce5543bb2f..ae3317bf15 100644 --- a/.github/workflows/code-release.yml +++ b/.github/workflows/code-release.yml @@ -11,13 +11,23 @@ concurrency: jobs: publish-macos: - runs-on: macos-latest + strategy: + fail-fast: false + matrix: + include: + - arch: arm64 + runner: macos-15 + - arch: x64 + runner: macos-15-intel + runs-on: ${{ matrix.runner }} permissions: id-token: write contents: write env: NODE_OPTIONS: "--max-old-space-size=8192" NODE_ENV: production + npm_config_arch: ${{ matrix.arch }} + npm_config_platform: darwin VITE_POSTHOG_API_KEY: ${{ secrets.VITE_POSTHOG_API_KEY }} VITE_POSTHOG_API_HOST: ${{ secrets.VITE_POSTHOG_API_HOST }} POSTHOG_SOURCEMAP_API_KEY: ${{ secrets.POSTHOG_SOURCEMAP_API_KEY }} @@ -124,11 +134,34 @@ jobs: - name: Build native modules run: pnpm --filter code run build-native - - name: Publish with Electron Forge + - name: Build release artifacts env: APP_VERSION: ${{ steps.version.outputs.version }} GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} - run: pnpm --filter code run publish + run: pnpm --filter code exec electron-forge publish --dry-run --arch=${{ matrix.arch }} --platform=darwin + + - name: Install Playwright + run: pnpm --filter code exec playwright install + + - name: Smoke test packaged app + env: + CI: true + E2E_APP_ARCH: ${{ matrix.arch }} + run: pnpm --filter code exec playwright test --config=tests/e2e/playwright.config.ts tests/e2e/tests/smoke.spec.ts + + - name: Upload Playwright report + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + if: failure() + with: + name: release-playwright-report-macos-${{ matrix.arch }} + path: apps/code/playwright-report/ + retention-days: 7 + + - name: Publish release artifacts + env: + APP_VERSION: ${{ steps.version.outputs.version }} + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + run: pnpm --filter code exec electron-forge publish --from-dry-run publish-windows: runs-on: windows-latest @@ -211,8 +244,93 @@ jobs: GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} run: pnpm --filter code run publish + publish-linux: + strategy: + fail-fast: false + matrix: + runner: [ubuntu-24.04, ubuntu-24.04-arm] + runs-on: ${{ matrix.runner }} + permissions: + id-token: write + contents: write + env: + NODE_OPTIONS: "--max-old-space-size=8192" + NODE_ENV: production + VITE_POSTHOG_API_KEY: ${{ secrets.VITE_POSTHOG_API_KEY }} + VITE_POSTHOG_API_HOST: ${{ secrets.VITE_POSTHOG_API_HOST }} + steps: + - name: Get app token + id: app-token + uses: getsentry/action-github-app-token@d4b5da6c5e37703f8c3b3e43abb5705b46e159cc # v3 + with: + app_id: ${{ secrets.GH_APP_ARRAY_RELEASER_APP_ID }} + private_key: ${{ secrets.GH_APP_ARRAY_RELEASER_PRIVATE_KEY }} + + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} + persist-credentials: false + + - name: Install AppImage build tooling + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + squashfs-tools zsync libfuse2t64 + + - name: Setup pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + cache: "pnpm" + + - name: Extract version from tag + id: version + run: | + TAG_VERSION="${GITHUB_REF#refs/tags/v}" + echo "Version: $TAG_VERSION" + echo "version=$TAG_VERSION" >> "$GITHUB_OUTPUT" + + - name: Set version in package.json + env: + APP_VERSION: ${{ steps.version.outputs.version }} + run: | + jq --arg v "$APP_VERSION" '.version = $v' apps/code/package.json > tmp.json && mv tmp.json apps/code/package.json + echo "Set apps/code/package.json version to $APP_VERSION" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build electron-trpc package + run: pnpm --filter @posthog/electron-trpc run build + + - name: Build platform package + run: pnpm --filter @posthog/platform run build + + - name: Build shared package + run: pnpm --filter @posthog/shared run build + + - name: Build git package + run: pnpm --filter @posthog/git run build + + - name: Build enricher package + run: pnpm --filter @posthog/enricher run build + + - name: Build agent package + run: pnpm --filter @posthog/agent run build + + - name: Publish with Electron Forge + env: + APP_VERSION: ${{ steps.version.outputs.version }} + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + run: pnpm --filter code run publish + finalize-release: - needs: [publish-macos, publish-windows] + needs: [publish-macos, publish-windows, publish-linux] runs-on: ubuntu-latest permissions: contents: write diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 495f9b988f..3715b9da77 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -15,7 +15,7 @@ jobs: permissions: pull-requests: write steps: - - uses: actions/stale@v9 + - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9 with: days-before-pr-stale: 30 days-before-pr-close: 7 diff --git a/.npmrc b/.npmrc index 8e5a554b41..81bbe90c96 100644 --- a/.npmrc +++ b/.npmrc @@ -1,2 +1,3 @@ node-linker=hoisted shamefully-hoist=true +min-release-age=7 diff --git a/apps/code/forge.config.ts b/apps/code/forge.config.ts index f52736bf2e..e64e4f57a9 100644 --- a/apps/code/forge.config.ts +++ b/apps/code/forge.config.ts @@ -8,6 +8,7 @@ import { MakerZIP } from "@electron-forge/maker-zip"; import { VitePlugin } from "@electron-forge/plugin-vite"; import { PublisherGithub } from "@electron-forge/publisher-github"; import type { ForgeConfig } from "@electron-forge/shared-types"; +import { MakerAppImage } from "@reforged/maker-appimage"; const appleCodesignIdentity = process.env.APPLE_CODESIGN_IDENTITY; const appleTeamId = process.env.APPLE_TEAM_ID; @@ -81,7 +82,7 @@ const osxSignConfig = shouldSignMacApp && appleCodesignIdentity ? ({ identity: appleCodesignIdentity, - optionsForFile: (_filePath) => { + optionsForFile: () => { // Entitlements for all binaries/frameworks return { hardenedRuntime: true, @@ -94,20 +95,20 @@ const osxSignConfig = function copyNativeDependency( dependency: string, destinationRoot: string, -): void { +): boolean { const source = path.resolve("../../node_modules", dependency); if (!existsSync(source)) { // Fallback to local node_modules const localSource = path.resolve("node_modules", dependency); if (existsSync(localSource)) { copySync(dependency, destinationRoot, localSource); - return; + return true; } console.warn( `[forge] Native dependency "${dependency}" not found, skipping copy`, ); - return; + return false; } const nodeModulesDir = path.join(destinationRoot, "node_modules"); @@ -122,6 +123,7 @@ function copyNativeDependency( destination, )}`, ); + return true; } function copySync(dependency: string, destinationRoot: string, source: string) { @@ -139,6 +141,8 @@ function copySync(dependency: string, destinationRoot: string, source: string) { ); } +const hasAssetsCar = existsSync("build/Assets.car"); + const config: ForgeConfig = { packagerConfig: { asar: { @@ -151,8 +155,10 @@ const config: ForgeConfig = { icon: "./build/app-icon", // Forge adds .icns/.ico/.png based on platform appBundleId: "com.posthog.array", appCategoryType: "public.app-category.productivity", - extraResource: existsSync("build/Assets.car") ? ["build/Assets.car"] : [], - extendInfo: existsSync("build/Assets.car") + extraResource: hasAssetsCar + ? ["build/Assets.car", "build/app-icon.png"] + : ["build/app-icon.png"], + extendInfo: hasAssetsCar ? { CFBundleIconName: "Icon", } @@ -193,6 +199,13 @@ const config: ForgeConfig = { name: "PostHogCode", setupIcon: "./build/app-icon.ico", }), + new MakerAppImage({ + options: { + icon: "./build/app-icon.png", + categories: ["Development"], + bin: "PostHog Code", + }, + }), new MakerZIP({}, ["darwin", "linux"]), ], hooks: { @@ -213,8 +226,16 @@ const config: ForgeConfig = { prePackage: async () => { if (process.platform !== "darwin") return; - // Build native modules for DMG maker on Node.js 22 + // Build native modules for DMG maker on Node.js 22. These run on the + // build host (DMG creation is host-side), so we force npm to target the + // host arch even when the rest of the build is cross-targeting (e.g. + // building darwin-x64 on an arm64 runner). const modules = ["macos-alias", "fs-xattr"]; + const hostBuildEnv = { + ...process.env, + npm_config_arch: process.arch, + npm_config_platform: process.platform, + }; for (const mod of modules) { const candidates = [ @@ -225,29 +246,61 @@ const config: ForgeConfig = { if (modulePath) { console.log(`Building native module: ${mod} (${modulePath})`); - execSync("npm install", { cwd: modulePath, stdio: "inherit" }); + execSync("npm install", { + cwd: modulePath, + stdio: "inherit", + env: hostBuildEnv, + }); } } }, postStart: async (_forgeConfig, child) => { electronChild = child; }, - packageAfterCopy: async (_forgeConfig, buildPath) => { + packageAfterCopy: async ( + _forgeConfig, + buildPath, + _electronVersion, + platform, + targetArch, + ) => { copyNativeDependency("node-pty", buildPath); copyNativeDependency("node-addon-api", buildPath); copyNativeDependency("@parcel/watcher", buildPath); // Platform-specific native dependencies - if (process.platform === "darwin") { - copyNativeDependency("@parcel/watcher-darwin-arm64", buildPath); + if (platform === "darwin") { + const watcherPkg = + targetArch === "x64" + ? "@parcel/watcher-darwin-x64" + : "@parcel/watcher-darwin-arm64"; + if (!copyNativeDependency(watcherPkg, buildPath)) { + throw new Error( + `[forge] Missing required native dependency "${watcherPkg}" for darwin-${targetArch}`, + ); + } copyNativeDependency("file-icon", buildPath); copyNativeDependency("p-map", buildPath); - } else if (process.platform === "win32") { + } else if (platform === "win32") { const watcherPkg = - process.arch === "arm64" + targetArch === "arm64" ? "@parcel/watcher-win32-arm64" : "@parcel/watcher-win32-x64"; - copyNativeDependency(watcherPkg, buildPath); + if (!copyNativeDependency(watcherPkg, buildPath)) { + throw new Error( + `[forge] Missing required native dependency "${watcherPkg}" for win32-${targetArch}`, + ); + } + } else if (platform === "linux") { + const watcherPkg = + targetArch === "arm64" + ? "@parcel/watcher-linux-arm64-glibc" + : "@parcel/watcher-linux-x64-glibc"; + if (!copyNativeDependency(watcherPkg, buildPath)) { + throw new Error( + `[forge] Missing required native dependency "${watcherPkg}" for linux-${targetArch}`, + ); + } } // Copy @parcel/watcher's hoisted dependencies @@ -266,6 +319,16 @@ const config: ForgeConfig = { copyNativeDependency("file-uri-to-path", buildPath); copyNativeDependency("prebuild-install", buildPath); }, + packageAfterPrune: async (_forgeConfig, buildPath) => { + // @parcel/watcher tries @parcel/watcher-{platform}-{arch} first, then + // falls back to build/Release/watcher.node. Remove that fallback from + // release bundles so a host-compiled binary cannot shadow the required + // target-specific optional dependency. + rmSync(path.join(buildPath, "node_modules/@parcel/watcher/build"), { + recursive: true, + force: true, + }); + }, }, publishers: [ new PublisherGithub({ diff --git a/apps/code/package.json b/apps/code/package.json index 3c1f3d09bb..dee944027f 100644 --- a/apps/code/package.json +++ b/apps/code/package.json @@ -16,6 +16,7 @@ "package": "electron-forge package", "package:dev": "FORCE_DEV_MODE=1 SKIP_NOTARIZE=1 electron-forge package", "make": "electron-forge make", + "make:linux": "bash scripts/build-linux-docker.sh", "publish": "electron-forge publish", "build": "pnpm package", "build-native": "bash scripts/build-native-modules.sh", @@ -50,6 +51,7 @@ "@electron-forge/plugin-vite": "^7.11.1", "@electron-forge/publisher-github": "^7.11.1", "@electron-forge/shared-types": "^7.11.1", + "@reforged/maker-appimage": "^5.2.0", "@electron/rebuild": "^4.0.3", "@playwright/test": "^1.42.0", "@posthog/rollup-plugin": "^1.4.0", @@ -141,6 +143,7 @@ "@radix-ui/themes": "^3.2.1", "@tailwindcss/vite": "^4.2.2", "@tanstack/react-query": "^5.90.2", + "@tanstack/react-virtual": "^3.13.26", "@tiptap/core": "^3.13.0", "@tiptap/extension-mention": "^3.13.0", "@tiptap/extension-placeholder": "^3.13.0", diff --git a/apps/code/scripts/build-linux-docker.sh b/apps/code/scripts/build-linux-docker.sh new file mode 100644 index 0000000000..7497d53c1f --- /dev/null +++ b/apps/code/scripts/build-linux-docker.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +ARCH="${ARCH:-x64}" +case "$ARCH" in + x64) DOCKER_PLATFORM="linux/amd64" ;; + arm64) DOCKER_PLATFORM="linux/arm64" ;; + *) echo "Unsupported ARCH=$ARCH (expected x64 or arm64)" >&2; exit 1 ;; +esac + +REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +OUT_DIR="$REPO_ROOT/apps/code/out" +mkdir -p "$OUT_DIR" + +# Capture host commit so the in-container build reflects the real source revision, +# not the throwaway commit we synthesize below for postinstall scripts. +HOST_COMMIT="$(git -C "$REPO_ROOT" rev-parse --short HEAD 2>/dev/null || echo unknown)" + +# Stream the repo source (no node_modules / build artifacts) into the container +# so node_modules lives on the container's overlayfs, not a slow FUSE bind mount. +# Only the output dir is bind-mounted so artifacts come back to the host. +cd "$REPO_ROOT" +# COPYFILE_DISABLE stops bsdtar from embedding macOS extended attrs as ._ files. +COPYFILE_DISABLE=1 tar -cf - \ + --exclude='./.git' \ + --exclude='./.pnpm-store' \ + --exclude='node_modules' \ + --exclude='.turbo' \ + --exclude='.vite' \ + --exclude='dist' \ + --exclude='out' \ + --exclude='playwright-results' \ + --exclude='._*' \ + --exclude='.DS_Store' \ + . | exec docker run --rm -i \ + --platform "$DOCKER_PLATFORM" \ + --name build-linux \ + -e CI=true \ + -e NODE_OPTIONS="--max-old-space-size=8192" \ + -e NODE_ENV=production \ + -e ARCH="$ARCH" \ + -e BUILD_COMMIT="$HOST_COMMIT" \ + -v "$OUT_DIR":/out \ + node:22-bookworm bash -lc ' + set -euo pipefail + trap "rc=\$?; echo >&2; echo \"[build-linux-docker] FAILED (exit \$rc) at line \$LINENO: \$BASH_COMMAND\" >&2; exit \$rc" ERR + mkdir -p /work && cd /work && tar -xf - + corepack enable + apt-get update && apt-get install -y --no-install-recommends \ + libsecret-1-dev fuse libfuse2 ca-certificates git squashfs-tools zsync zip + # Tarball arrived owned by the host uid; tell git not to refuse on uid mismatch. + git config --global --add safe.directory /work + # Postinstall scripts call `git rev-parse` — give them a repo to find. + git init -q && git add -A && git -c user.email=x@x -c user.name=x commit -q -m init + pnpm install --frozen-lockfile + pnpm --filter @posthog/electron-trpc build + pnpm --filter @posthog/platform build + pnpm --filter @posthog/shared build + pnpm --filter @posthog/git build + pnpm --filter @posthog/enricher build + pnpm --filter @posthog/agent build + pnpm --filter code make --platform=linux --arch="$ARCH" + mkdir -p /out + cp -r apps/code/out/make /out/ + ' diff --git a/apps/code/src/main/services/agent/service.test.ts b/apps/code/src/main/services/agent/service.test.ts index 8507cc6075..5e277e6ad7 100644 --- a/apps/code/src/main/services/agent/service.test.ts +++ b/apps/code/src/main/services/agent/service.test.ts @@ -21,9 +21,7 @@ const mockClientSideConnection = vi.hoisted(() => this.initialize = vi.fn().mockResolvedValue({}); this.newSession = mockNewSession; this.loadSession = vi.fn().mockResolvedValue({ configOptions: [] }); - this.unstable_resumeSession = vi - .fn() - .mockResolvedValue({ configOptions: [] }); + this.resumeSession = vi.fn().mockResolvedValue({ configOptions: [] }); }), ); @@ -91,9 +89,12 @@ vi.mock("@posthog/agent/posthog-api", () => ({ })); vi.mock("@posthog/agent/gateway-models", () => ({ + DEFAULT_GATEWAY_MODEL: "claude-opus-4-8", + DEFAULT_CODEX_MODEL: "gpt-5.5", fetchGatewayModels: vi.fn().mockResolvedValue([]), formatGatewayModelName: vi.fn(), getProviderName: vi.fn(), + isBlockedModelId: vi.fn().mockReturnValue(false), })); vi.mock("@posthog/agent/adapters/claude/session/jsonl-hydration", () => ({ diff --git a/apps/code/src/main/services/agent/service.ts b/apps/code/src/main/services/agent/service.ts index 1596f9ff5b..f6348191ad 100644 --- a/apps/code/src/main/services/agent/service.ts +++ b/apps/code/src/main/services/agent/service.ts @@ -336,11 +336,15 @@ export class AgentService extends TypedEventEmitter { } private getClaudeCliPath(): string { - return this.bundledResources.resolve(".vite/build/claude-cli/cli.js"); + // Keep in sync with the destDir in apps/code/vite.main.config.mts + // (copyClaudeExecutable plugin). + const binary = process.platform === "win32" ? "claude.exe" : "claude"; + return this.bundledResources.resolve(`.vite/build/claude-cli/${binary}`); } private getCodexBinaryPath(): string { - return this.bundledResources.resolve(".vite/build/codex-acp/codex-acp"); + const binary = process.platform === "win32" ? "codex-acp.exe" : "codex-acp"; + return this.bundledResources.resolve(`.vite/build/codex-acp/${binary}`); } /** @@ -747,7 +751,7 @@ When creating pull requests, add the following footer at the end of the PR descr // Claude-specific: hydrate session JSONL from PostHog before resuming. // If hydration finds no conversation to restore, skip the resume and // fall through to creating a new session. This avoids a doomed - // unstable_resumeSession that would fail with "Resource not found" + // resumeSession that would fail with "Resource not found" if (isReconnect && config.sessionId) { const existingSessionId = config.sessionId; @@ -777,10 +781,10 @@ When creating pull requests, add the following footer at the end of the PR descr if (isReconnect && config.sessionId) { const existingSessionId = config.sessionId; - // Both adapters implement unstable_resumeSession: + // Both adapters implement resumeSession: // - Claude: delegates to SDK's resumeSession with JSONL hydration // - Codex: delegates to codex-acp's loadSession internally - const resumeResponse = await connection.unstable_resumeSession({ + const resumeResponse = await connection.resumeSession({ sessionId: existingSessionId, cwd: repoPath, mcpServers, diff --git a/apps/code/src/main/services/app-lifecycle/service.test.ts b/apps/code/src/main/services/app-lifecycle/service.test.ts index 9d90e28706..ff200c023d 100644 --- a/apps/code/src/main/services/app-lifecycle/service.test.ts +++ b/apps/code/src/main/services/app-lifecycle/service.test.ts @@ -91,6 +91,12 @@ describe("AppLifecycleService", () => { service.setQuittingForUpdate(); expect(service.isQuittingForUpdate).toBe(true); }); + + it("returns false after clearQuittingForUpdate is called", () => { + service.setQuittingForUpdate(); + service.clearQuittingForUpdate(); + expect(service.isQuittingForUpdate).toBe(false); + }); }); describe("isShuttingDown", () => { diff --git a/apps/code/src/main/services/app-lifecycle/service.ts b/apps/code/src/main/services/app-lifecycle/service.ts index 53f9c4f1dd..18dcc9f9cd 100644 --- a/apps/code/src/main/services/app-lifecycle/service.ts +++ b/apps/code/src/main/services/app-lifecycle/service.ts @@ -38,6 +38,10 @@ export class AppLifecycleService { this._isQuittingForUpdate = true; } + clearQuittingForUpdate(): void { + this._isQuittingForUpdate = false; + } + /** * Immediately kills the process. Used when shutdown is stuck or re-entrant. */ diff --git a/apps/code/src/main/services/cloud-task/service.ts b/apps/code/src/main/services/cloud-task/service.ts index 59716b068c..db99fc923f 100644 --- a/apps/code/src/main/services/cloud-task/service.ts +++ b/apps/code/src/main/services/cloud-task/service.ts @@ -1,10 +1,12 @@ import type { CloudTaskPermissionRequestUpdate } from "@shared/types"; +import { ANALYTICS_EVENTS } from "@shared/types/analytics"; import type { StoredLogEntry } from "@shared/types/session-events"; import { inject, injectable, preDestroy } from "inversify"; import { MAIN_TOKENS } from "../../di/tokens"; import { logger } from "../../utils/logger"; import { TypedEventEmitter } from "../../utils/typed-event-emitter"; import type { AuthService } from "../auth/service"; +import { trackAppEvent } from "../posthog-analytics"; import { CloudTaskEvent, type CloudTaskEvents, @@ -979,6 +981,21 @@ export class CloudTaskService extends TypedEventEmitter { const watcher = this.watchers.get(key); if (!watcher) return; + // Track every terminal give-up so cloud-run stream failures are visible in + // PostHog. error_title distinguishes the cause; the budget counts separate an + // idle Envoy cut from a genuine outage. Best-effort — never block teardown. + trackAppEvent(ANALYTICS_EVENTS.CLOUD_STREAM_DISCONNECTED, { + task_id: watcher.taskId, + run_id: watcher.runId, + team_id: watcher.teamId, + error_title: error.title, + retryable: error.retryable, + reconnect_attempts: watcher.reconnectAttempts, + stream_error_attempts: watcher.streamErrorAttempts, + cumulative_reconnect_attempts: watcher.cumulativeReconnectAttempts, + was_bootstrapping: watcher.isBootstrapping, + }); + watcher.failed = true; watcher.isBootstrapping = false; watcher.pendingLogEntries = []; diff --git a/apps/code/src/main/services/inbox-link/service.test.ts b/apps/code/src/main/services/inbox-link/service.test.ts index 844a47eaa1..747b23e5ff 100644 --- a/apps/code/src/main/services/inbox-link/service.test.ts +++ b/apps/code/src/main/services/inbox-link/service.test.ts @@ -91,6 +91,15 @@ describe("InboxLinkService", () => { expect(listener).toHaveBeenCalledWith({ reportId: "abc-123" }); }); + it("ignores a trailing slug segment after the report id", () => { + const listener = vi.fn(); + service.on(InboxLinkEvent.OpenReport, listener); + + deepLinkService.trigger("inbox", "abc-123/fix-inbox--Add-foo"); + + expect(listener).toHaveBeenCalledWith({ reportId: "abc-123" }); + }); + it("returns false and does not emit when the path is empty", () => { const listener = vi.fn(); service.on(InboxLinkEvent.OpenReport, listener); diff --git a/apps/code/src/main/services/llm-gateway/schemas.ts b/apps/code/src/main/services/llm-gateway/schemas.ts index b14fffd254..7c569c8953 100644 --- a/apps/code/src/main/services/llm-gateway/schemas.ts +++ b/apps/code/src/main/services/llm-gateway/schemas.ts @@ -1,3 +1,4 @@ +import { DEFAULT_GATEWAY_MODEL } from "@posthog/agent/gateway-models"; import { z } from "zod"; export const llmMessageSchema = z.object({ @@ -11,7 +12,7 @@ export const promptInput = z.object({ system: z.string().optional(), messages: z.array(llmMessageSchema), maxTokens: z.number().optional(), - model: z.string().default("claude-haiku-4-5"), + model: z.string().default(DEFAULT_GATEWAY_MODEL), }); export type PromptInput = z.infer; diff --git a/apps/code/src/main/services/llm-gateway/service.ts b/apps/code/src/main/services/llm-gateway/service.ts index 2a6ac5a266..11813e474f 100644 --- a/apps/code/src/main/services/llm-gateway/service.ts +++ b/apps/code/src/main/services/llm-gateway/service.ts @@ -1,3 +1,4 @@ +import { DEFAULT_GATEWAY_MODEL } from "@posthog/agent/gateway-models"; import { getGatewayInvalidatePlanCacheUrl, getGatewayUsageUrl, @@ -51,7 +52,7 @@ export class LlmGatewayService { const { system, maxTokens, - model = "claude-haiku-4-5", + model = DEFAULT_GATEWAY_MODEL, signal, timeoutMs = 60_000, } = options; diff --git a/apps/code/src/main/services/updates/schemas.ts b/apps/code/src/main/services/updates/schemas.ts index dbdef957a8..b4dd973948 100644 --- a/apps/code/src/main/services/updates/schemas.ts +++ b/apps/code/src/main/services/updates/schemas.ts @@ -13,6 +13,16 @@ export const checkForUpdatesOutput = z.object({ errorCode: checkErrorCode.optional(), }); +export const updatesStatusOutput = z.object({ + checking: z.boolean(), + downloading: z.boolean().optional(), + upToDate: z.boolean().optional(), + updateReady: z.boolean().optional(), + installing: z.boolean().optional(), + version: z.string().optional(), + error: z.string().optional(), +}); + export const installUpdateOutput = z.object({ installed: z.boolean(), }); @@ -28,14 +38,7 @@ export const UpdatesEvent = { CheckFromMenu: "check-from-menu", } as const; -export type UpdatesStatusPayload = { - checking: boolean; - downloading?: boolean; - upToDate?: boolean; - updateReady?: boolean; - version?: string; - error?: string; -}; +export type UpdatesStatusPayload = z.infer; export type UpdateReadyPayload = { version: string | null; diff --git a/apps/code/src/main/services/updates/service.test.ts b/apps/code/src/main/services/updates/service.test.ts index a91bae898f..f21cbd874f 100644 --- a/apps/code/src/main/services/updates/service.test.ts +++ b/apps/code/src/main/services/updates/service.test.ts @@ -8,6 +8,7 @@ const { mockAppMeta, mockMainWindow, mockLifecycleService, + mockLog, updaterHandlers, } = vi.hoisted(() => { const updaterHandlers: { @@ -79,18 +80,20 @@ const { shutdown: vi.fn(() => Promise.resolve()), shutdownWithoutContainer: vi.fn(() => Promise.resolve()), setQuittingForUpdate: vi.fn(), + clearQuittingForUpdate: vi.fn(), + }, + mockLog: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), }, }; }); vi.mock("../../utils/logger.js", () => ({ logger: { - scope: () => ({ - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - debug: vi.fn(), - }), + scope: () => mockLog, }, })); @@ -134,6 +137,10 @@ describe("UpdatesService", () => { mockAppMeta.isProduction = true; mockAppMeta.version = "1.0.0"; mockUpdater.isSupported.mockReturnValue(true); + mockUpdater.quitAndInstall.mockImplementation(() => undefined); + mockLifecycleService.shutdownWithoutContainer.mockImplementation(() => + Promise.resolve(), + ); mockAppLifecycle.whenReady.mockResolvedValue(undefined); // Set default platform to darwin (macOS) @@ -428,11 +435,9 @@ describe("UpdatesService", () => { // Verify setQuittingForUpdate is called first expect(mockLifecycleService.setQuittingForUpdate).toHaveBeenCalled(); - // Verify shutdownWithoutContainer is called (not full shutdown) expect(mockLifecycleService.shutdownWithoutContainer).toHaveBeenCalled(); expect(mockLifecycleService.shutdown).not.toHaveBeenCalled(); - // Verify quitAndInstall is called after cleanup expect(mockUpdater.quitAndInstall).toHaveBeenCalled(); // Verify order: setQuittingForUpdate -> shutdownWithoutContainer -> quitAndInstall @@ -448,6 +453,29 @@ describe("UpdatesService", () => { expect(cleanupOrder).toBeLessThan(quitAndInstallOrder); }); + it("continues to quitAndInstall if partial shutdown times out", async () => { + await initializeService(service); + + updaterHandlers.updateDownloaded?.("v2.0.0"); + + mockLifecycleService.shutdownWithoutContainer.mockReturnValue( + new Promise(() => {}), + ); + + const resultPromise = service.installUpdate(); + await vi.advanceTimersByTimeAsync(3000); + + await expect(resultPromise).resolves.toEqual({ installed: true }); + expect(mockUpdater.quitAndInstall).toHaveBeenCalled(); + expect(mockLog.warn).toHaveBeenCalledWith( + "Partial shutdown timed out before update install", + expect.objectContaining({ + timeoutMs: 3000, + downloadedVersion: "v2.0.0", + }), + ); + }); + it("returns false if quitAndInstall throws", async () => { await initializeService(service); @@ -467,6 +495,70 @@ describe("UpdatesService", () => { const result = await resultPromise; expect(result).toEqual({ installed: false }); }); + + it("clears the quitting-for-update lifecycle flag when install handoff fails", async () => { + await initializeService(service); + updaterHandlers.updateDownloaded?.("v2.0.0"); + + mockUpdater.quitAndInstall.mockImplementation(() => { + throw new Error("Failed to install"); + }); + + await service.installUpdate(); + + expect(mockLifecycleService.clearQuittingForUpdate).toHaveBeenCalled(); + const setOrder = + mockLifecycleService.setQuittingForUpdate.mock.invocationCallOrder[0]; + const clearOrder = + mockLifecycleService.clearQuittingForUpdate.mock.invocationCallOrder[0]; + expect(setOrder).toBeLessThan(clearOrder); + }); + + it("rolls back to a re-installable ready state when install handoff fails", async () => { + await initializeService(service); + updaterHandlers.updateDownloaded?.("v2.0.0"); + + mockUpdater.quitAndInstall.mockImplementation(() => { + throw new Error("Failed to install"); + }); + + const statusHandler = vi.fn(); + service.on(UpdatesEvent.Status, statusHandler); + + const first = await service.installUpdate(); + expect(first).toEqual({ installed: false }); + expect(service.hasUpdateReady).toBe(true); + expect(statusHandler).toHaveBeenLastCalledWith({ + checking: false, + updateReady: true, + installing: false, + version: "v2.0.0", + }); + + mockUpdater.quitAndInstall.mockImplementationOnce(() => undefined); + const second = await service.installUpdate(); + expect(second).toEqual({ installed: true }); + }); + + it("is idempotent when install is already in progress", async () => { + await initializeService(service); + + updaterHandlers.updateDownloaded?.("v2.0.0"); + + await expect(service.installUpdate()).resolves.toEqual({ + installed: true, + }); + expect(mockUpdater.quitAndInstall).toHaveBeenCalledTimes(1); + + await expect(service.installUpdate()).resolves.toEqual({ + installed: true, + }); + expect(mockUpdater.quitAndInstall).toHaveBeenCalledTimes(1); + expect(mockLog.warn).not.toHaveBeenCalledWith( + "installUpdate called but no update is ready", + expect.anything(), + ); + }); }); describe("triggerMenuCheck", () => { @@ -515,7 +607,7 @@ describe("UpdatesService", () => { }); }); - it("shows update-ready notification instead of up-to-date when update is already downloaded", () => { + it("ignores later update events once an update is already downloaded", () => { // Simulate update already downloaded const downloadedHandler = updaterHandlers.updateDownloaded; if (downloadedHandler) { @@ -527,24 +619,22 @@ describe("UpdatesService", () => { service.on(UpdatesEvent.Status, statusHandler); service.on(UpdatesEvent.Ready, readyHandler); - // Start a periodic re-check + mockUpdater.check.mockClear(); + + // Periodic checks should be suppressed once an update is staged. service.checkForUpdates("periodic"); - statusHandler.mockClear(); + expect(mockUpdater.check).not.toHaveBeenCalled(); - // Server says no new update available const notAvailableHandler = updaterHandlers.noUpdate; if (notAvailableHandler) { notAvailableHandler(); } - // Should emit checking: false (not upToDate) - expect(statusHandler).toHaveBeenCalledWith({ checking: false }); + expect(statusHandler).not.toHaveBeenCalledWith({ checking: false }); expect(statusHandler).not.toHaveBeenCalledWith( expect.objectContaining({ upToDate: true }), ); - - // Should re-surface the downloaded update notification - expect(readyHandler).toHaveBeenCalledWith({ version: "v2.0.0" }); + expect(readyHandler).not.toHaveBeenCalled(); }); it("handles update-downloaded event with version info", () => { @@ -561,6 +651,20 @@ describe("UpdatesService", () => { expect(readyHandler).toHaveBeenCalledWith({ version: "v2.0.0" }); }); + it("emits a complete staged payload when an update is downloaded", () => { + const statusHandler = vi.fn(); + service.on(UpdatesEvent.Status, statusHandler); + + updaterHandlers.updateDownloaded?.("v2.0.0"); + + expect(statusHandler).toHaveBeenCalledWith({ + checking: false, + updateReady: true, + installing: false, + version: "v2.0.0", + }); + }); + it("handles error event and emits status with error", () => { const statusHandler = vi.fn(); service.on(UpdatesEvent.Status, statusHandler); @@ -606,6 +710,52 @@ describe("UpdatesService", () => { }); }); + describe("status snapshots", () => { + it("returns update-ready status for a staged update", async () => { + await initializeService(service); + + updaterHandlers.updateDownloaded?.("v2.0.0"); + + expect(service.getStatus()).toEqual({ + checking: false, + updateReady: true, + installing: false, + version: "v2.0.0", + }); + }); + + it("flags installing in the staged status payload while install is in flight", async () => { + await initializeService(service); + + updaterHandlers.updateDownloaded?.("v2.0.0"); + mockLifecycleService.shutdownWithoutContainer.mockReturnValue( + new Promise(() => {}), + ); + + void service.installUpdate(); + // Allow the synchronous part of installUpdate to run. + await Promise.resolve(); + + expect(service.getStatus()).toEqual({ + checking: false, + updateReady: true, + installing: true, + version: "v2.0.0", + }); + }); + + it("returns downloading status while an update is downloading", async () => { + await initializeService(service); + + updaterHandlers.updateAvailable?.(); + + expect(service.getStatus()).toEqual({ + checking: true, + downloading: true, + }); + }); + }); + describe("check timeout", () => { beforeEach(async () => { await initializeService(service); @@ -728,10 +878,24 @@ describe("UpdatesService", () => { expect(mockUpdater.check.mock.calls.length).toBe(initialCallCount + 2); }); + + it("stops the periodic interval once an update is staged", async () => { + await initializeService(service); + + updaterHandlers.updateDownloaded?.("v2.0.0"); + + const baselineCallCount = mockUpdater.check.mock.calls.length; + + // The interval would normally fire every hour; with the update staged it + // should be cleared so no further wake-ups occur. + await vi.advanceTimersByTimeAsync(60 * 60 * 1000 * 3); + + expect(mockUpdater.check.mock.calls.length).toBe(baselineCallCount); + }); }); - describe("periodic check re-checks when update already downloaded", () => { - it("re-checks for newer versions on periodic check when update is ready", async () => { + describe("staged update guards", () => { + it("does not re-check on periodic checks when update is ready", async () => { await initializeService(service); // Simulate update downloaded @@ -743,10 +907,10 @@ describe("UpdatesService", () => { // Clear the checkForUpdates calls from initialization mockUpdater.check.mockClear(); - // Periodic check should re-check without resetting existing update state + // Periodic check should not overwrite or refresh the staged update. const result = service.checkForUpdates("periodic"); expect(result).toEqual({ success: true }); - expect(mockUpdater.check).toHaveBeenCalled(); + expect(mockUpdater.check).not.toHaveBeenCalled(); // Update should still be ready (state not reset) expect(service.hasUpdateReady).toBe(true); }); @@ -771,7 +935,7 @@ describe("UpdatesService", () => { expect(readyHandler).toHaveBeenCalledWith({ version: "v2.0.0" }); }); - it("preserves downloaded update when periodic re-check errors", async () => { + it("preserves downloaded update when later updater errors fire", async () => { await initializeService(service); // Simulate update downloaded @@ -780,10 +944,11 @@ describe("UpdatesService", () => { downloadedHandler("v2.0.0"); } - // Periodic check proceeds + mockUpdater.check.mockClear(); service.checkForUpdates("periodic"); + expect(mockUpdater.check).not.toHaveBeenCalled(); - // Simulate error during re-check + // Simulate a stale updater error after staging. const errorHandler = updaterHandlers.error; if (errorHandler) { errorHandler(new Error("Network error")); @@ -793,7 +958,7 @@ describe("UpdatesService", () => { expect(service.hasUpdateReady).toBe(true); }); - it("does not re-notify when same version is re-downloaded after periodic check", async () => { + it("does not re-notify when same version is re-downloaded after staging", async () => { await initializeService(service); const readyHandler = vi.fn(); @@ -806,8 +971,6 @@ describe("UpdatesService", () => { } expect(readyHandler).toHaveBeenCalledTimes(1); - // Periodic check resets and re-downloads same version - service.checkForUpdates("periodic"); readyHandler.mockClear(); if (downloadedHandler) { @@ -818,53 +981,67 @@ describe("UpdatesService", () => { expect(readyHandler).not.toHaveBeenCalled(); }); - it("returns already_checking when periodic check fires during in-flight check", async () => { + it("does not overwrite staged version when a later download event arrives", async () => { await initializeService(service); + const readyHandler = vi.fn(); + service.on(UpdatesEvent.Ready, readyHandler); + // Simulate update downloaded const downloadedHandler = updaterHandlers.updateDownloaded; if (downloadedHandler) { downloadedHandler("v2.0.0"); } + expect(readyHandler).toHaveBeenCalledWith({ version: "v2.0.0" }); - // First periodic check starts (sets checkingForUpdates = true) - service.checkForUpdates("periodic"); + readyHandler.mockClear(); - // Second periodic check while first is still in-flight - const result = service.checkForUpdates("periodic"); - expect(result).toEqual({ - success: false, - errorMessage: "Already checking for updates", - errorCode: "already_checking", - }); + if (downloadedHandler) { + downloadedHandler("v3.0.0"); + } + + // User checks should still surface the originally staged update. + service.checkForUpdates("user"); + expect(readyHandler).toHaveBeenCalledWith({ version: "v2.0.0" }); // Update should still be ready (state not corrupted) expect(service.hasUpdateReady).toBe(true); }); + }); - it("notifies when a newer version is downloaded after periodic check", async () => { - await initializeService(service); - - const readyHandler = vi.fn(); - service.on(UpdatesEvent.Ready, readyHandler); + describe("transition logging", () => { + it("logs state transitions with source and state metadata", () => { + service.checkForUpdates("user"); + + expect(mockLog.info).toHaveBeenCalledWith( + "Update state transition", + expect.objectContaining({ + source: "user", + fromState: "idle", + toState: "checking", + downloadedVersion: null, + skippedBecauseUpdateStaged: false, + }), + ); + }); - // First download of v2.0.0 - const downloadedHandler = updaterHandlers.updateDownloaded; - if (downloadedHandler) { - downloadedHandler("v2.0.0"); - } - expect(readyHandler).toHaveBeenCalledTimes(1); + it("logs skipped checks after an update is staged", async () => { + await initializeService(service); + updaterHandlers.updateDownloaded?.("v2.0.0"); - // Periodic check resets and downloads newer v3.0.0 + mockLog.info.mockClear(); service.checkForUpdates("periodic"); - readyHandler.mockClear(); - - if (downloadedHandler) { - downloadedHandler("v3.0.0"); - } - // Should notify since different version - expect(readyHandler).toHaveBeenCalledWith({ version: "v3.0.0" }); + expect(mockLog.info).toHaveBeenCalledWith( + "Update state transition", + expect.objectContaining({ + source: "periodic", + fromState: "ready", + toState: "ready", + downloadedVersion: "v2.0.0", + skippedBecauseUpdateStaged: true, + }), + ); }); }); diff --git a/apps/code/src/main/services/updates/service.ts b/apps/code/src/main/services/updates/service.ts index fdbec5d1d7..76d1c4c504 100644 --- a/apps/code/src/main/services/updates/service.ts +++ b/apps/code/src/main/services/updates/service.ts @@ -4,6 +4,7 @@ import type { IMainWindow } from "@posthog/platform/main-window"; import type { IUpdater } from "@posthog/platform/updater"; import { inject, injectable, postConstruct, preDestroy } from "inversify"; import { MAIN_TOKENS } from "../../di/tokens"; +import { withTimeout } from "../../utils/async"; import { isDevBuild } from "../../utils/env"; import { logger } from "../../utils/logger"; import { TypedEventEmitter } from "../../utils/typed-event-emitter"; @@ -17,6 +18,20 @@ import { } from "./schemas"; type CheckSource = "user" | "periodic"; +type UpdateState = + | "idle" + | "checking" + | "downloading" + | "ready" + | "installing" + | "error"; +type TransitionContext = { + source?: CheckSource; + skippedBecauseUpdateStaged?: boolean; + reason?: string; + incomingVersion?: string | null; + error?: string; +}; const log = logger.scope("updates"); @@ -27,6 +42,7 @@ export class UpdatesService extends TypedEventEmitter { private static readonly REPO_NAME = "code"; private static readonly CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1 hour private static readonly CHECK_TIMEOUT_MS = 60 * 1000; // 1 minute timeout for checks + private static readonly INSTALL_SHUTDOWN_TIMEOUT_MS = 3000; private static readonly DISABLE_ENV_FLAG = "ELECTRON_DISABLE_AUTO_UPDATE"; private static readonly SUPPORTED_PLATFORMS = ["darwin", "win32"]; @@ -45,18 +61,22 @@ export class UpdatesService extends TypedEventEmitter { @inject(MAIN_TOKENS.MainWindow) private mainWindow!: IMainWindow; - private updateReady = false; + private state: UpdateState = "idle"; private pendingNotification = false; - private checkingForUpdates = false; private checkTimeoutId: ReturnType | null = null; private checkIntervalId: ReturnType | null = null; private downloadedVersion: string | null = null; private notifiedVersion: string | null = null; + private lastError: string | null = null; private initialized = false; private unsubscribes: Array<() => void> = []; get hasUpdateReady(): boolean { - return this.updateReady; + return this.isUpdateStaged(); + } + + private isUpdateStaged(): boolean { + return this.state === "ready" || this.state === "installing"; } get isEnabled(): boolean { @@ -95,6 +115,29 @@ export class UpdatesService extends TypedEventEmitter { this.emit(UpdatesEvent.CheckFromMenu, true); } + getStatus(): UpdatesStatusPayload { + if (this.state === "checking") { + return { checking: true }; + } + + if (this.state === "downloading") { + return { checking: true, downloading: true }; + } + + if (this.isUpdateStaged()) { + return this.stagedStatusPayload(); + } + + if (this.state === "error") { + return { + checking: false, + error: this.lastError ?? "Update check failed. Please try again.", + }; + } + + return { checking: false }; + } + checkForUpdates(source: CheckSource = "user"): CheckForUpdatesOutput { if (!this.isEnabled) { const reason = isDevBuild() @@ -103,7 +146,23 @@ export class UpdatesService extends TypedEventEmitter { return { success: false, errorMessage: reason, errorCode: "disabled" }; } - if (this.checkingForUpdates) { + if (this.isUpdateStaged()) { + this.logStateTransition(this.state, { + source, + skippedBecauseUpdateStaged: true, + reason: "check skipped because update is already staged", + }); + + if (source === "user") { + this.pendingNotification = true; + this.flushPendingNotification(); + this.emitStatus(this.stagedStatusPayload()); + } + + return { success: true }; + } + + if (this.state === "checking" || this.state === "downloading") { return { success: false, errorMessage: "Already checking for updates", @@ -111,22 +170,7 @@ export class UpdatesService extends TypedEventEmitter { }; } - if (this.updateReady && source !== "periodic") { - // User check: show the existing downloaded update notification - log.info("Update already downloaded, showing prompt again", { - downloadedVersion: this.downloadedVersion, - }); - this.pendingNotification = true; - this.flushPendingNotification(); - this.emitStatus({ - checking: false, - updateReady: true, - version: this.downloadedVersion ?? undefined, - }); - return { success: true }; - } - - this.checkingForUpdates = true; + this.transitionTo("checking", { source }); this.emitStatus({ checking: true }); this.performCheck(); @@ -134,8 +178,18 @@ export class UpdatesService extends TypedEventEmitter { } async installUpdate(): Promise { - if (!this.updateReady) { - log.warn("installUpdate called but no update is ready"); + if (this.state === "installing") { + this.logStateTransition("installing", { + skippedBecauseUpdateStaged: true, + reason: "install already in progress", + }); + return { installed: true }; + } + + if (this.state !== "ready") { + log.warn("installUpdate called but no update is ready", { + state: this.state, + }); return { installed: false }; } @@ -144,17 +198,29 @@ export class UpdatesService extends TypedEventEmitter { }); try { - // Set the flag FIRST so before-quit handler won't prevent quit + this.transitionTo("installing", { reason: "install requested" }); + this.emitStatus(this.stagedStatusPayload()); this.lifecycleService.setQuittingForUpdate(); - - // Do lightweight cleanup: kill processes, shut down watchers - // Skip container teardown so before-quit handler can still access services - await this.lifecycleService.shutdownWithoutContainer(); - + const cleanupResult = await withTimeout( + this.lifecycleService.shutdownWithoutContainer(), + UpdatesService.INSTALL_SHUTDOWN_TIMEOUT_MS, + ); + if (cleanupResult.result === "timeout") { + log.warn("Partial shutdown timed out before update install", { + timeoutMs: UpdatesService.INSTALL_SHUTDOWN_TIMEOUT_MS, + downloadedVersion: this.downloadedVersion, + }); + } this.updater.quitAndInstall(); return { installed: true }; } catch (error) { log.error("Failed to quit and install update", error); + this.lifecycleService.clearQuittingForUpdate(); + this.transitionTo("ready", { + reason: "install handoff failed", + error: error instanceof Error ? error.message : String(error), + }); + this.emitStatus(this.stagedStatusPayload()); return { installed: false }; } } @@ -183,7 +249,7 @@ export class UpdatesService extends TypedEventEmitter { this.unsubscribes.push( this.updater.onError((error) => this.handleError(error)), - this.updater.onCheckStart(() => this.handleCheckingForUpdate()), + this.updater.onCheckStart(() => log.info("Checking for updates...")), this.updater.onUpdateAvailable(() => this.handleUpdateAvailable()), this.updater.onNoUpdate(() => this.handleNoUpdate()), this.updater.onUpdateDownloaded((releaseName) => @@ -191,27 +257,44 @@ export class UpdatesService extends TypedEventEmitter { ), ); - // Perform initial check (periodic source — not user-initiated) this.checkForUpdates("periodic"); - // Set up periodic checks this.checkIntervalId = setInterval( () => this.checkForUpdates("periodic"), UpdatesService.CHECK_INTERVAL_MS, ); } + private stagedStatusPayload(): UpdatesStatusPayload { + return { + checking: false, + updateReady: true, + installing: this.state === "installing", + version: this.downloadedVersion ?? undefined, + }; + } + private handleError(error: Error): void { this.clearCheckTimeout(); log.error("Auto update error", { message: error.message, stack: error.stack, feedUrl: this.feedUrl, + state: this.state, }); - // Reset checking state on error so user can retry - if (this.checkingForUpdates) { - this.checkingForUpdates = false; + if (this.isUpdateStaged()) { + this.logStateTransition(this.state, { + skippedBecauseUpdateStaged: true, + reason: "updater error ignored because update is staged", + error: error.message, + }); + return; + } + + if (this.state === "checking" || this.state === "downloading") { + this.lastError = error.message; + this.transitionTo("error", { error: error.message }); this.emitStatus({ checking: false, error: error.message, @@ -219,67 +302,80 @@ export class UpdatesService extends TypedEventEmitter { } } - private handleCheckingForUpdate(): void { - log.info("Checking for updates..."); - } - private handleUpdateAvailable(): void { + if (this.isUpdateStaged()) { + log.info( + "Ignoring update-available because an update is already staged", + { + downloadedVersion: this.downloadedVersion, + }, + ); + return; + } + this.clearCheckTimeout(); + this.transitionTo("downloading", { reason: "update available" }); log.info("Update available, downloading..."); - // Keep checkingForUpdates true while downloading this.emitStatus({ checking: true, downloading: true }); } private handleNoUpdate(): void { this.clearCheckTimeout(); - log.info("No updates available", { currentVersion: this.appMeta.version }); - if (this.checkingForUpdates) { - this.checkingForUpdates = false; - if (this.updateReady) { - this.emitStatus({ checking: false }); - this.pendingNotification = true; - this.flushPendingNotification(); - } else { - this.emitStatus({ - checking: false, - upToDate: true, - version: this.appMeta.version, - }); - } + if (this.isUpdateStaged()) { + log.info("Ignoring update-not-available because update is staged", { + downloadedVersion: this.downloadedVersion, + }); + return; + } + + log.info("No updates available", { currentVersion: this.appMeta.version }); + if (this.state === "checking" || this.state === "downloading") { + this.transitionTo("idle", { reason: "no update available" }); + this.emitStatus({ + checking: false, + upToDate: true, + version: this.appMeta.version, + }); } } private handleUpdateDownloaded(releaseName?: string): void { this.clearCheckTimeout(); - const wasChecking = this.checkingForUpdates; - this.checkingForUpdates = false; - this.downloadedVersion = releaseName ?? null; - if (wasChecking) { - this.emitStatus({ checking: false }); + if (this.isUpdateStaged()) { + log.info("Ignoring duplicate update-downloaded event", { + existingVersion: this.downloadedVersion, + incomingVersion: releaseName, + }); + return; } + this.downloadedVersion = releaseName ?? null; + this.transitionTo("ready", { + reason: "update downloaded", + incomingVersion: releaseName ?? null, + }); + this.clearCheckInterval(); + this.emitStatus(this.stagedStatusPayload()); + log.info("Update downloaded, awaiting user confirmation", { currentVersion: this.appMeta.version, downloadedVersion: this.downloadedVersion, }); - this.updateReady = true; - - // Only show notification if this is a different version than already notified if (this.notifiedVersion !== this.downloadedVersion) { this.pendingNotification = true; this.flushPendingNotification(); } else { - log.info("Skipping notification — same version already notified", { + log.info("Skipping notification - same version already notified", { version: this.downloadedVersion, }); } } private flushPendingNotification(): void { - if (this.updateReady && this.pendingNotification) { + if (this.state === "ready" && this.pendingNotification) { log.info("Notifying user that update is ready", { downloadedVersion: this.downloadedVersion, }); @@ -294,18 +390,16 @@ export class UpdatesService extends TypedEventEmitter { } private performCheck(): void { - // Clear any existing timeout this.clearCheckTimeout(); - // Set a timeout to reset the checking state if the check takes too long this.checkTimeoutId = setTimeout(() => { - if (this.checkingForUpdates) { - log.warn("Update check timed out after 60 seconds"); - this.checkingForUpdates = false; - this.emitStatus({ - checking: false, - error: "Update check timed out. Please try again.", - }); + if (this.state === "checking" || this.state === "downloading") { + const timeoutSeconds = UpdatesService.CHECK_TIMEOUT_MS / 1000; + const message = "Update check timed out. Please try again."; + log.warn(`Update check timed out after ${timeoutSeconds} seconds`); + this.lastError = message; + this.transitionTo("error", { error: message }); + this.emitStatus({ checking: false, error: message }); } }, UpdatesService.CHECK_TIMEOUT_MS); @@ -314,7 +408,10 @@ export class UpdatesService extends TypedEventEmitter { } catch (error) { this.clearCheckTimeout(); log.error("Failed to check for updates", error); - this.checkingForUpdates = false; + this.lastError = "Failed to check for updates. Please try again."; + this.transitionTo("error", { + error: error instanceof Error ? error.message : String(error), + }); this.emitStatus({ checking: false, error: "Failed to check for updates. Please try again.", @@ -322,6 +419,33 @@ export class UpdatesService extends TypedEventEmitter { } } + private transitionTo( + state: UpdateState, + context: TransitionContext = {}, + ): void { + this.logStateTransition(state, context); + this.state = state; + if (state !== "error") { + this.lastError = null; + } + } + + private logStateTransition( + toState: UpdateState, + context: TransitionContext = {}, + ): void { + log.info("Update state transition", { + source: context.source, + fromState: this.state, + toState, + downloadedVersion: this.downloadedVersion, + skippedBecauseUpdateStaged: context.skippedBecauseUpdateStaged ?? false, + reason: context.reason, + incomingVersion: context.incomingVersion, + error: context.error, + }); + } + private clearCheckTimeout(): void { if (this.checkTimeoutId) { clearTimeout(this.checkTimeoutId); @@ -329,13 +453,17 @@ export class UpdatesService extends TypedEventEmitter { } } - @preDestroy() - shutdown(): void { - this.clearCheckTimeout(); + private clearCheckInterval(): void { if (this.checkIntervalId) { clearInterval(this.checkIntervalId); this.checkIntervalId = null; } + } + + @preDestroy() + shutdown(): void { + this.clearCheckTimeout(); + this.clearCheckInterval(); for (const unsub of this.unsubscribes) unsub(); this.unsubscribes = []; } diff --git a/apps/code/src/main/trpc/routers/updates.test.ts b/apps/code/src/main/trpc/routers/updates.test.ts new file mode 100644 index 0000000000..b36e223d1a --- /dev/null +++ b/apps/code/src/main/trpc/routers/updates.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from "vitest"; + +const { mockUpdatesService } = vi.hoisted(() => ({ + mockUpdatesService: { + isEnabled: true, + checkForUpdates: vi.fn(() => ({ success: true })), + getStatus: vi.fn(() => ({ + checking: false, + updateReady: true, + version: "v2.0.0", + })), + installUpdate: vi.fn(() => Promise.resolve({ installed: true })), + toIterable: vi.fn(), + }, +})); + +vi.mock("../../di/container", () => ({ + container: { + get: vi.fn(() => mockUpdatesService), + }, +})); + +import { updatesRouter } from "./updates"; + +describe("updatesRouter", () => { + it("returns the current update status snapshot", async () => { + const caller = updatesRouter.createCaller({}); + + await expect(caller.getStatus()).resolves.toEqual({ + checking: false, + updateReady: true, + version: "v2.0.0", + }); + expect(mockUpdatesService.getStatus).toHaveBeenCalled(); + }); + + it("delegates menu/user checks to the updates service", async () => { + const caller = updatesRouter.createCaller({}); + + await expect(caller.check()).resolves.toEqual({ success: true }); + expect(mockUpdatesService.checkForUpdates).toHaveBeenCalled(); + }); + + it("reports whether updates are enabled", async () => { + const caller = updatesRouter.createCaller({}); + + await expect(caller.isEnabled()).resolves.toEqual({ enabled: true }); + }); + + it("delegates install to the updates service", async () => { + const caller = updatesRouter.createCaller({}); + + await expect(caller.install()).resolves.toEqual({ installed: true }); + expect(mockUpdatesService.installUpdate).toHaveBeenCalled(); + }); +}); diff --git a/apps/code/src/main/trpc/routers/updates.ts b/apps/code/src/main/trpc/routers/updates.ts index 7cabefb5e9..6931e3e214 100644 --- a/apps/code/src/main/trpc/routers/updates.ts +++ b/apps/code/src/main/trpc/routers/updates.ts @@ -6,6 +6,7 @@ import { isEnabledOutput, UpdatesEvent, type UpdatesEvents, + updatesStatusOutput, } from "../../services/updates/schemas"; import type { UpdatesService } from "../../services/updates/service"; import { publicProcedure, router } from "../trpc"; @@ -34,6 +35,11 @@ export const updatesRouter = router({ return service.checkForUpdates(); }), + getStatus: publicProcedure.output(updatesStatusOutput).query(() => { + const service = getService(); + return service.getStatus(); + }), + install: publicProcedure.output(installUpdateOutput).mutation(() => { const service = getService(); return service.installUpdate(); diff --git a/apps/code/src/main/window.ts b/apps/code/src/main/window.ts index d5796c939e..10aa4699d0 100644 --- a/apps/code/src/main/window.ts +++ b/apps/code/src/main/window.ts @@ -168,14 +168,24 @@ export function createWindow(): void { titleBarStyle: "hiddenInset" as const, trafficLightPosition: { x: 12, y: 9 }, } - : { - titleBarStyle: "hidden" as const, - titleBarOverlay: { - color: "#0a0a0a", - symbolColor: "#ffffff", - height: 36, - }, - }; + : process.platform === "win32" + ? { + titleBarStyle: "hidden" as const, + titleBarOverlay: { + color: "#0a0a0a", + symbolColor: "#ffffff", + height: 36, + }, + } + : {}; + + // macOS uses the .app bundle icon, but Linux/Windows need an explicit icon + const windowIcon = + process.platform !== "darwin" + ? app.isPackaged + ? path.join(process.resourcesPath, "app-icon.png") + : path.join(app.getAppPath(), "build/app-icon.png") + : undefined; mainWindow = new BrowserWindow({ ...(savedState.x !== undefined && { x: savedState.x }), @@ -185,6 +195,7 @@ export function createWindow(): void { minWidth: 800, minHeight: 600, backgroundColor: "#0a0a0a", + ...(windowIcon ? { icon: windowIcon } : {}), ...platformWindowConfig, show: false, webPreferences: { diff --git a/apps/code/src/renderer/api/posthogClient.test.ts b/apps/code/src/renderer/api/posthogClient.test.ts index f684aa266c..2e0f299643 100644 --- a/apps/code/src/renderer/api/posthogClient.test.ts +++ b/apps/code/src/renderer/api/posthogClient.test.ts @@ -115,11 +115,11 @@ describe("PostHogAPIClient", () => { await expect( client.runTaskInCloud("task-123", "feature/legacy-effort", { adapter: "claude", - model: "claude-opus-4-6", + model: "claude-opus-4-8", reasoningLevel: "minimal", }), ).rejects.toThrow( - "Reasoning effort 'minimal' is not supported for claude model 'claude-opus-4-6'.", + "Reasoning effort 'minimal' is not supported for claude model 'claude-opus-4-8'.", ); expect(post).not.toHaveBeenCalled(); diff --git a/apps/code/src/renderer/components/ui/SafeImagePreview.tsx b/apps/code/src/renderer/components/ui/SafeImagePreview.tsx index 2e5aed70c7..3dee082413 100644 --- a/apps/code/src/renderer/components/ui/SafeImagePreview.tsx +++ b/apps/code/src/renderer/components/ui/SafeImagePreview.tsx @@ -1,3 +1,4 @@ +import { useImagePanAndZoom } from "@hooks/useImagePanAndZoom"; import { buildImageDataUrl, isAllowedImageMimeType, @@ -39,6 +40,7 @@ export function SafeImagePreview({ }: SafeImagePreviewProps) { const [hasError, setHasError] = useState(false); const [lastSource, setLastSource] = useState({ base64, mimeType }); + const zoom = useImagePanAndZoom(); if (lastSource.base64 !== base64 || lastSource.mimeType !== mimeType) { setLastSource({ base64, mimeType }); @@ -55,12 +57,30 @@ export function SafeImagePreview({ } return ( - {alt setHasError(true)} - /> +
+ {alt setHasError(true)} + /> +
); } diff --git a/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx b/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx index 3117997257..94e4c8c1fd 100644 --- a/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx +++ b/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx @@ -17,6 +17,7 @@ import { CaretRightIcon, ChatCircleIcon, EyeIcon, + InfoIcon, LinkSimpleIcon, Plus, ThumbsDownIcon, @@ -36,7 +37,7 @@ import { } from "@radix-ui/themes"; import { useTRPC } from "@renderer/trpc"; import { EXTERNAL_LINKS } from "@renderer/utils/links"; -import { getDeeplinkProtocol } from "@shared/deeplink"; +import { buildInboxDeeplink } from "@shared/deeplink"; import type { ActionabilityJudgmentArtefact, ActionabilityJudgmentContent, @@ -211,8 +212,12 @@ export function ReportDetailPane({ const reviewerArtefact = allArtefacts.find( (a): a is SuggestedReviewersArtefact => a.type === "suggested_reviewers", ); - return reviewerArtefact?.content ?? []; - }, [allArtefacts]); + const reviewers = reviewerArtefact?.content ?? []; + if (!me?.uuid) return reviewers; + const meIndex = reviewers.findIndex((r) => r.user?.uuid === me.uuid); + if (meIndex <= 0) return reviewers; + return [reviewers[meIndex], ...reviewers.filter((_, i) => i !== meIndex)]; + }, [allArtefacts, me?.uuid]); const signalFindings = useMemo(() => { const map = new Map(); @@ -477,7 +482,9 @@ export function ReportDetailPane({ onClick={async () => { try { await navigator.clipboard.writeText( - `${getDeeplinkProtocol(import.meta.env.DEV)}://inbox/${report.id}`, + buildInboxDeeplink(report.id, report.title, { + isDevBuild: import.meta.env.DEV, + }), ); fireDetailAction("copy_link"); toast.success("Link copied"); @@ -816,15 +823,38 @@ export function ReportDetailPane({ {reviewer.relevant_commits.map((commit, i) => ( {i > 0 && ", "} - - - {commit.sha.slice(0, 7)} - + + + Why was I assigned? + + + {commit.reason} + + + ) : ( + commit.reason || undefined + ) + } + > + + + {commit.sha.slice(0, 7)} + + {isMe && commit.reason && ( + + )} + ))} diff --git a/apps/code/src/renderer/features/inbox/hooks/useCreatePrReport.ts b/apps/code/src/renderer/features/inbox/hooks/useCreatePrReport.ts index 5ebf3f564c..77203e8bfe 100644 --- a/apps/code/src/renderer/features/inbox/hooks/useCreatePrReport.ts +++ b/apps/code/src/renderer/features/inbox/hooks/useCreatePrReport.ts @@ -132,6 +132,7 @@ export function useCreatePrReport({ has_branch: false, cloud_run_source: "signal_report", cloud_pr_authorship_mode: "user", + signal_report_id: reportId, adapter, }); } else { diff --git a/apps/code/src/renderer/features/inbox/hooks/useDiscussReport.ts b/apps/code/src/renderer/features/inbox/hooks/useDiscussReport.ts index 56880a55bd..2b660a1681 100644 --- a/apps/code/src/renderer/features/inbox/hooks/useDiscussReport.ts +++ b/apps/code/src/renderer/features/inbox/hooks/useDiscussReport.ts @@ -80,6 +80,7 @@ export function useDiscussReport({ const prompt = buildDiscussReportPrompt({ reportId, + reportTitle, question, isDevBuild: import.meta.env.DEV, }); diff --git a/apps/code/src/renderer/features/inbox/utils/buildDiscussReportPrompt.test.ts b/apps/code/src/renderer/features/inbox/utils/buildDiscussReportPrompt.test.ts index a6e078a37b..f0ae48cac5 100644 --- a/apps/code/src/renderer/features/inbox/utils/buildDiscussReportPrompt.test.ts +++ b/apps/code/src/renderer/features/inbox/utils/buildDiscussReportPrompt.test.ts @@ -45,6 +45,24 @@ describe("buildDiscussReportPrompt", () => { expect(prompt).toContain("brief readout"); }); + it("appends a slugified title suffix to the deep link", () => { + const prompt = buildDiscussReportPrompt({ + reportId: "abc123", + reportTitle: "fix(inbox): Add foo", + isDevBuild: false, + }); + expect(prompt).toContain("posthog-code://inbox/abc123/fix-inbox--Add-foo"); + }); + + it("omits the slug suffix when the title is blank", () => { + const prompt = buildDiscussReportPrompt({ + reportId: "abc123", + reportTitle: " ", + isDevBuild: false, + }); + expect(prompt).toContain("posthog-code://inbox/abc123)"); + }); + it("tells the agent to say so rather than guess if the report can't be fetched", () => { const withQuestion = buildDiscussReportPrompt({ reportId: "abc123", diff --git a/apps/code/src/renderer/features/inbox/utils/buildDiscussReportPrompt.ts b/apps/code/src/renderer/features/inbox/utils/buildDiscussReportPrompt.ts index fe815ca8f1..e36118c4c3 100644 --- a/apps/code/src/renderer/features/inbox/utils/buildDiscussReportPrompt.ts +++ b/apps/code/src/renderer/features/inbox/utils/buildDiscussReportPrompt.ts @@ -1,23 +1,19 @@ -import { getDeeplinkProtocol } from "@shared/deeplink"; +import { buildDiscussReportPrompt as buildSharedDiscussReportPrompt } from "@posthog/shared"; +import { buildInboxDeeplink } from "@shared/deeplink"; interface BuildDiscussReportPromptOptions { reportId: string; + reportTitle?: string | null; question?: string; isDevBuild: boolean; } export function buildDiscussReportPrompt({ reportId, + reportTitle, question, isDevBuild, }: BuildDiscussReportPromptOptions): string { - const trimmedQuestion = question?.trim(); - const reportLink = `${getDeeplinkProtocol(isDevBuild)}://inbox/${reportId}`; - const intro = `Discuss PostHog inbox report ${reportId} ([inbox item](${reportLink})). Use the inbox MCP tools to fetch the report,`; - const guard = - " If you can't fetch the report, say so instead of guessing what it contains."; - const body = trimmedQuestion - ? `${intro} then answer this first: ${trimmedQuestion}` - : `${intro} then give me a brief readout and ask what I want to dig into.`; - return `${body}${guard}`; + const reportLink = buildInboxDeeplink(reportId, reportTitle, { isDevBuild }); + return buildSharedDiscussReportPrompt({ reportId, reportLink, question }); } diff --git a/apps/code/src/renderer/features/message-editor/components/AttachmentsBar.tsx b/apps/code/src/renderer/features/message-editor/components/AttachmentsBar.tsx index dfba7350c5..5ca7265333 100644 --- a/apps/code/src/renderer/features/message-editor/components/AttachmentsBar.tsx +++ b/apps/code/src/renderer/features/message-editor/components/AttachmentsBar.tsx @@ -1,5 +1,10 @@ +import { SafeImagePreview } from "@components/ui/SafeImagePreview"; import { File, X } from "@phosphor-icons/react"; -import { isGifFile, isRasterImageFile } from "@posthog/shared"; +import { + isGifFile, + isRasterImageFile, + parseImageDataUrl, +} from "@posthog/shared"; import { Dialog, Flex, IconButton, Text } from "@radix-ui/themes"; import { useTRPC } from "@renderer/trpc/client"; import { useQuery } from "@tanstack/react-query"; @@ -48,6 +53,7 @@ function ImageThumbnail({ ); const isGif = isGifFile(attachment.label); + const parsedImage = dataUrl ? parseImageDataUrl(dataUrl) : null; return ( @@ -90,14 +96,12 @@ function ImageThumbnail({ {attachment.label} - {dataUrl ? ( - {attachment.label} ) : ( diff --git a/apps/code/src/renderer/features/message-editor/components/PromptInput.stories.tsx b/apps/code/src/renderer/features/message-editor/components/PromptInput.stories.tsx index 9fd2f9938a..bc67302db8 100644 --- a/apps/code/src/renderer/features/message-editor/components/PromptInput.stories.tsx +++ b/apps/code/src/renderer/features/message-editor/components/PromptInput.stories.tsx @@ -15,13 +15,13 @@ const mockModelOption = { id: "model", name: "Model", type: "select" as const, - currentValue: "gpt-5.4", + currentValue: "gpt-5.5", options: [ { group: "recommended", name: "Recommended", options: [ - { value: "gpt-5.4", name: "GPT 5.4" }, + { value: "gpt-5.5", name: "gpt-5.5" }, { value: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, ], }, @@ -29,9 +29,8 @@ const mockModelOption = { group: "other", name: "Other", options: [ - { value: "claude-opus-4-6", name: "Claude Opus 4.6" }, + { value: "claude-opus-4-8", name: "Claude Opus 4.8" }, { value: "o3-pro", name: "o3-pro" }, - { value: "claude-haiku-4-5", name: "Claude Haiku 4.5" }, ], }, ], diff --git a/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx b/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx index 4e54828720..bbf9cb2e25 100644 --- a/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx +++ b/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx @@ -285,7 +285,7 @@ export const PromptInput = forwardRef( ( > - + - + {formatTokensCompact(used)}/{formatTokensCompact(size)} ·{" "} {percentage}% diff --git a/apps/code/src/renderer/features/sessions/components/ConversationView.tsx b/apps/code/src/renderer/features/sessions/components/ConversationView.tsx index 4afb50fd67..85f72405da 100644 --- a/apps/code/src/renderer/features/sessions/components/ConversationView.tsx +++ b/apps/code/src/renderer/features/sessions/components/ConversationView.tsx @@ -14,7 +14,13 @@ import { SkillButtonActionMessage } from "@features/skill-buttons/components/Ski import { ArrowDown, XCircle } from "@phosphor-icons/react"; import { WorkerPoolContextProvider } from "@pierre/diffs/react"; import WorkerUrl from "@pierre/diffs/worker/worker.js?worker&url"; -import { Box, Button, Flex, Text } from "@radix-ui/themes"; +import { + Button, + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@posthog/quill"; +import { Box, Flex, Text } from "@radix-ui/themes"; import type { Task } from "@shared/types"; import type { AcpMessage } from "@shared/types/session-events"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -250,7 +256,7 @@ export function ConversationView({ poolOptions={DIFFS_POOL_OPTIONS} highlighterOptions={DIFFS_HIGHLIGHTER_OPTIONS} > -
+
{showScrollButton && ( - - + + + + + + } + /> + Scroll to bottom + )}
diff --git a/apps/code/src/renderer/features/sessions/components/SessionFooter.tsx b/apps/code/src/renderer/features/sessions/components/SessionFooter.tsx index 6b988222b4..7910864837 100644 --- a/apps/code/src/renderer/features/sessions/components/SessionFooter.tsx +++ b/apps/code/src/renderer/features/sessions/components/SessionFooter.tsx @@ -33,7 +33,7 @@ export function SessionFooter({ usage, }: SessionFooterProps) { const rightSide = ( - + {task && } @@ -41,16 +41,16 @@ export function SessionFooter({ if (isPromptPending && !isCompacting) { if (hasPendingPermission) { return ( - + - + Awaiting permission... @@ -61,7 +61,7 @@ export function SessionFooter({ } return ( - + {queuedCount > 0 && ( - + ({queuedCount} queued) )} @@ -89,19 +89,18 @@ export function SessionFooter({ !wasCancelled; return ( - + {showDuration && ( Generated in {formatDuration(lastGenerationDuration)} diff --git a/apps/code/src/renderer/features/sessions/components/SessionView.tsx b/apps/code/src/renderer/features/sessions/components/SessionView.tsx index 49b9cdfa95..9175280bf1 100644 --- a/apps/code/src/renderer/features/sessions/components/SessionView.tsx +++ b/apps/code/src/renderer/features/sessions/components/SessionView.tsx @@ -476,7 +476,7 @@ export function SessionView({ /> ) : hideInput ? null : firstPendingPermission ? ( - + ) : ( - + { items: T[]; @@ -28,6 +29,9 @@ export interface VirtualizedListHandle { } const AT_BOTTOM_THRESHOLD = 50; +const ESTIMATED_ROW_SIZE = 80; +const OVERSCAN = 6; +const FOOTER_KEY = "__virtualized_footer__"; function VirtualizedListInner( { @@ -43,106 +47,203 @@ function VirtualizedListInner( }: VirtualizedListProps, ref: React.ForwardedRef, ) { - const listRef = useRef(null); - const isAtBottomRef = useRef(true); + const parentRef = useRef(null); const initializedRef = useRef(false); + const isAtBottomRef = useRef(true); + const settlingRef = useRef(false); + const settleRafRef = useRef(null); const onScrollStateChangeRef = useRef(onScrollStateChange); onScrollStateChangeRef.current = onScrollStateChange; - const itemCountRef = useRef(items.length); - itemCountRef.current = items.length; + + const hasFooter = footer != null; + const totalCount = items.length + (hasFooter ? 1 : 0); + + const virtualizer = useVirtualizer({ + count: totalCount, + getScrollElement: () => parentRef.current, + estimateSize: () => ESTIMATED_ROW_SIZE, + overscan: OVERSCAN, + anchorTo: "end", + followOnAppend: true, + scrollEndThreshold: AT_BOTTOM_THRESHOLD, + getItemKey: (index) => { + if (hasFooter && index === items.length) return FOOTER_KEY; + const item = items[index]; + return getItemKey ? getItemKey(item, index) : index; + }, + }); + + const settleAtEnd = useCallback(() => { + if (settleRafRef.current !== null) { + cancelAnimationFrame(settleRafRef.current); + settleRafRef.current = null; + } + settlingRef.current = true; + isAtBottomRef.current = true; + let attempts = 0; + const step = () => { + virtualizer.scrollToEnd(); + if (virtualizer.isAtEnd(AT_BOTTOM_THRESHOLD)) { + settlingRef.current = false; + settleRafRef.current = null; + if (initializedRef.current) { + onScrollStateChangeRef.current?.(true); + } + return; + } + if (++attempts > 12) { + settlingRef.current = false; + settleRafRef.current = null; + return; + } + settleRafRef.current = requestAnimationFrame(step); + }; + step(); + }, [virtualizer]); useImperativeHandle( ref, () => ({ - scrollToBottom: () => { - const handle = listRef.current; - if (handle) { - handle.scrollTo(handle.scrollSize); - isAtBottomRef.current = true; - } - }, + scrollToBottom: settleAtEnd, scrollToIndex: (index: number) => { - const handle = listRef.current; - if (handle) { - isAtBottomRef.current = false; - handle.scrollToIndex(index, { align: "center" }); + if (settleRafRef.current !== null) { + cancelAnimationFrame(settleRafRef.current); + settleRafRef.current = null; + settlingRef.current = false; } + isAtBottomRef.current = false; + virtualizer.scrollToIndex(index, { align: "center" }); }, }), - [], + [virtualizer, settleAtEnd], ); + useEffect(() => { + return () => { + if (settleRafRef.current !== null) { + cancelAnimationFrame(settleRafRef.current); + } + }; + }, []); + useLayoutEffect(() => { - const handle = listRef.current; - if (!handle) return; + if (initializedRef.current || totalCount === 0) return; + virtualizer.scrollToEnd(); + requestAnimationFrame(() => { + initializedRef.current = true; + }); + }, [totalCount, virtualizer]); - if (items.length > 0 && !initializedRef.current) { - handle.scrollToIndex(items.length - 1, { align: "end" }); + // Safety net: streaming tokens grow an existing row in place; neither + // followOnAppend (count-based) nor anchorTo='end' (above-viewport-resize) + // covers in-place growth of the last row. Re-pin to end when at-bottom. + // biome-ignore lint/correctness/useExhaustiveDependencies: re-run on items mutation, including streaming text updates + useEffect(() => { + if (!initializedRef.current) return; + if (!isAtBottomRef.current) return; + virtualizer.scrollToEnd(); + }, [items, virtualizer]); - requestAnimationFrame(() => { - initializedRef.current = true; - }); - } - }, [items.length]); + const handleScroll = useCallback(() => { + const atBottom = virtualizer.isAtEnd(AT_BOTTOM_THRESHOLD); + isAtBottomRef.current = atBottom; + if (!initializedRef.current) return; + // Suppress intermediate "not at bottom" pings while a programmatic + // scrollToEnd is still settling after row remeasure. + if (settlingRef.current && !atBottom) return; + onScrollStateChangeRef.current?.(atBottom); + }, [virtualizer]); - // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally re-run when items change for streaming scroll - useEffect(() => { - if (isAtBottomRef.current) { - const handle = listRef.current; - if (handle) { - // Use scrollToIndex for reliable positioning after measurements settle - const totalChildren = itemCountRef.current + (footer ? 1 : 0); - if (totalChildren > 0) { - handle.scrollToIndex(totalChildren - 1, { align: "end" }); - } - } - } - }, [items, footer]); - - const handleScroll = useCallback((offset: number) => { - const handle = listRef.current; - if (!handle) return; - const distanceFromBottom = handle.scrollSize - offset - handle.viewportSize; - const atBottom = distanceFromBottom < AT_BOTTOM_THRESHOLD; - if (isAtBottomRef.current !== atBottom) { - isAtBottomRef.current = atBottom; - } - // Skip reporting during initialization to avoid flashing the - // scroll-to-bottom button before measurements settle. - if (initializedRef.current) { - onScrollStateChangeRef.current?.(atBottom); - } - }, []); + const virtualItems = virtualizer.getVirtualItems(); + + const renderedIndices = useMemo(() => { + const set = new Set(); + for (const v of virtualItems) set.add(v.index); + return set; + }, [virtualItems]); + + const orphanKeepIndices = useMemo(() => { + if (!keepMounted || keepMounted.length === 0) return []; + return keepMounted.filter( + (i) => i >= 0 && i < items.length && !renderedIndices.has(i), + ); + }, [keepMounted, renderedIndices, items.length]); return ( -
- +
- {items.map((item, index) => { - const key = getItemKey ? getItemKey(item, index) : index; - return ( -
- {renderItem(item, index)} -
- ); - })} - {footer && ( -
- {footer} -
- )} - +
+ {virtualItems.map((virtualItem) => { + const isFooter = hasFooter && virtualItem.index === items.length; + const item = isFooter ? null : items[virtualItem.index]; + const itemKey = isFooter + ? FOOTER_KEY + : getItemKey + ? getItemKey(item as T, virtualItem.index) + : virtualItem.index; + return ( +
+
+ {isFooter ? footer : renderItem(item as T, virtualItem.index)} +
+
+ ); + })} + {orphanKeepIndices.map((index) => { + const item = items[index]; + const k = getItemKey ? getItemKey(item, index) : index; + return ( +
+
+ {renderItem(item, index)} +
+
+ ); + })} +
+
); } diff --git a/apps/code/src/renderer/features/sessions/components/session-update/AgentMessage.tsx b/apps/code/src/renderer/features/sessions/components/session-update/AgentMessage.tsx index 0cf7d00083..ff11486e3e 100644 --- a/apps/code/src/renderer/features/sessions/components/session-update/AgentMessage.tsx +++ b/apps/code/src/renderer/features/sessions/components/session-update/AgentMessage.tsx @@ -149,7 +149,7 @@ export const AgentMessage = memo(function AgentMessage({ }, [content]); return ( - + = { Skill: Command, }; +// Tools that render a friendly " `` " line instead of +// the raw JSON input preview. `inputKey` is the rawInput field to highlight. +const toolNameDisplays: Record< + string, + { prefix: string; suffix: string; inputKey: string } +> = { + Skill: { prefix: "Reading", suffix: "skill", inputKey: "skill" }, + ToolSearch: { prefix: "Searching", suffix: "tools", inputKey: "query" }, +}; + interface ToolCallViewProps extends ToolViewProps { agentToolName?: string; } @@ -74,13 +84,27 @@ export function ToolCallView({ Wrench; const filePath = kind === "read" && locations?.[0]?.path; - const displayText = filePath - ? `Read ${getFilename(filePath)}` - : title - ? compactHomePath(title) + const toolDisplay = agentToolName + ? toolNameDisplays[agentToolName] + : undefined; + const highlightValue = + toolDisplay && rawInput && typeof rawInput === "object" + ? (rawInput as Record)[toolDisplay.inputKey] : undefined; + const specialDisplay = + toolDisplay && typeof highlightValue === "string" + ? { ...toolDisplay, value: highlightValue } + : undefined; + + const displayText = specialDisplay + ? specialDisplay.prefix + : filePath + ? `Read ${getFilename(filePath)}` + : title + ? compactHomePath(title) + : undefined; - const inputPreview = compactInput(rawInput); + const inputPreview = specialDisplay?.value ?? compactInput(rawInput); const fullInput = formatInput(rawInput); const output = stripCodeFences(getContentText(content) ?? ""); @@ -115,6 +139,7 @@ export function ToolCallView({ {inputPreview} )} + {specialDisplay && {specialDisplay.suffix}}
diff --git a/apps/code/src/renderer/features/sessions/components/session-update/UserMessage.tsx b/apps/code/src/renderer/features/sessions/components/session-update/UserMessage.tsx index aeb82a09b1..536bebff19 100644 --- a/apps/code/src/renderer/features/sessions/components/session-update/UserMessage.tsx +++ b/apps/code/src/renderer/features/sessions/components/session-update/UserMessage.tsx @@ -89,7 +89,7 @@ export function UserMessage({ > const mockGenerateTitle = vi.hoisted(() => vi.fn()); const mockGetAuthenticatedClient = vi.hoisted(() => vi.fn()); const mockGetCachedTask = vi.hoisted(() => vi.fn()); +const mockIsAuthenticated = vi.hoisted(() => ({ value: true })); const mockUpdateTask = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); const mockSetQueriesData = vi.hoisted(() => vi.fn()); +const mockSetQueryData = vi.hoisted(() => vi.fn()); const mockUpdateSessionTaskTitle = vi.hoisted(() => vi.fn()); const mockPrompts = vi.hoisted(() => ({ value: [] as string[] })); const mockSessionStoreSetters = vi.hoisted(() => ({ @@ -24,9 +27,26 @@ vi.mock("@features/auth/hooks/authClient", () => ({ getAuthenticatedClient: mockGetAuthenticatedClient, })); +vi.mock("@features/auth/hooks/authQueries", () => ({ + useAuthStateValue: ( + selector: (state: { + status: string; + cloudRegion: string | null; + }) => unknown, + ) => + selector( + mockIsAuthenticated.value + ? { status: "authenticated", cloudRegion: "us-east-1" } + : { status: "anonymous", cloudRegion: null }, + ), +})); + vi.mock("@utils/queryClient", () => ({ getCachedTask: mockGetCachedTask, - queryClient: { setQueriesData: mockSetQueriesData }, + queryClient: { + setQueriesData: mockSetQueriesData, + setQueryData: mockSetQueryData, + }, })); vi.mock("@utils/session", () => ({ @@ -69,24 +89,110 @@ import { useChatTitleGenerator } from "./useChatTitleGenerator"; const TASK_ID = "task-1"; +function createTask(overrides: Partial = {}): Task { + return { + id: TASK_ID, + task_number: 1, + slug: "task-1", + title: "Fix the login bug", + description: "Fix the login bug", + created_at: "2026-05-28T00:00:00.000Z", + updated_at: "2026-05-28T00:00:00.000Z", + origin_product: "user_created", + ...overrides, + }; +} + describe("useChatTitleGenerator", () => { beforeEach(() => { vi.clearAllMocks(); + mockIsAuthenticated.value = true; mockPrompts.value = []; mockEnrichDescription.mockImplementation((desc: string) => Promise.resolve(desc), ); + mockGetCachedTask.mockReturnValue(undefined); mockGetAuthenticatedClient.mockResolvedValue({ updateTask: mockUpdateTask, }); - mockGetCachedTask.mockReturnValue(undefined); }); - it("does not generate when promptCount is 0", () => { - renderHook(() => useChatTitleGenerator(TASK_ID)); + it("does not generate when promptCount is 0 and the task already has a custom title", () => { + renderHook(() => + useChatTitleGenerator( + createTask({ + title: "Custom task title", + }), + ), + ); expect(mockGenerateTitle).not.toHaveBeenCalled(); }); + it("generates title from the saved task description before prompt events arrive", async () => { + mockGenerateTitle.mockResolvedValue({ + title: "Fix login bug", + summary: "User is fixing a login issue", + }); + + renderHook(() => useChatTitleGenerator(createTask())); + + await waitFor(() => { + expect(mockEnrichDescription).toHaveBeenCalledWith("Fix the login bug"); + }); + await waitFor(() => { + expect(mockUpdateTask).toHaveBeenCalledWith(TASK_ID, { + title: "Fix login bug", + }); + }); + }); + + it("generates title when the task has no title yet", async () => { + mockGenerateTitle.mockResolvedValue({ + title: "Fix login bug", + summary: "User is fixing a login issue", + }); + + renderHook(() => + useChatTitleGenerator( + createTask({ + title: "", + }), + ), + ); + + await waitFor(() => { + expect(mockUpdateTask).toHaveBeenCalledWith(TASK_ID, { + title: "Fix login bug", + }); + }); + }); + + it("regenerates title when title_manually_set is true but the title still matches the fallback", async () => { + mockGenerateTitle.mockResolvedValue({ + title: "Fix login bug", + summary: "User is fixing a login issue", + }); + mockGetCachedTask.mockReturnValue( + createTask({ + title_manually_set: true, + }), + ); + + renderHook(() => + useChatTitleGenerator( + createTask({ + title_manually_set: true, + }), + ), + ); + + await waitFor(() => { + expect(mockUpdateTask).toHaveBeenCalledWith(TASK_ID, { + title: "Fix login bug", + }); + }); + }); + it("generates title on first prompt", async () => { mockGenerateTitle.mockResolvedValue({ title: "Fix login bug", @@ -94,7 +200,13 @@ describe("useChatTitleGenerator", () => { }); mockPrompts.value = ["Fix the login bug"]; - renderHook(() => useChatTitleGenerator(TASK_ID)); + renderHook(() => + useChatTitleGenerator( + createTask({ + title: "Raw prompt title", + }), + ), + ); await waitFor(() => { expect(mockUpdateTask).toHaveBeenCalledWith(TASK_ID, { @@ -121,17 +233,28 @@ describe("useChatTitleGenerator", () => { ])( "skips title update when title_manually_set ($name)", async ({ summary, expectsSummaryUpdate }) => { - mockGetCachedTask.mockReturnValue({ - id: TASK_ID, - title_manually_set: true, - }); + mockGetCachedTask.mockReturnValue( + createTask({ + title: "Custom auth title", + description: "fix auth", + title_manually_set: true, + }), + ); mockGenerateTitle.mockResolvedValue({ title: "Auto title", summary, }); mockPrompts.value = ["fix auth"]; - renderHook(() => useChatTitleGenerator(TASK_ID)); + renderHook(() => + useChatTitleGenerator( + createTask({ + title: "Custom auth title", + description: "fix auth", + title_manually_set: true, + }), + ), + ); await waitFor(() => { expect(mockGenerateTitle).toHaveBeenCalled(); @@ -159,7 +282,14 @@ describe("useChatTitleGenerator", () => { }); mockPrompts.value = ['']; - renderHook(() => useChatTitleGenerator(TASK_ID)); + renderHook(() => + useChatTitleGenerator( + createTask({ + title: "Code file prompt", + description: "Code file prompt", + }), + ), + ); await waitFor(() => { expect(mockEnrichDescription).toHaveBeenCalledWith( @@ -176,7 +306,14 @@ describe("useChatTitleGenerator", () => { }); mockPrompts.value = ["fix auth"]; - renderHook(() => useChatTitleGenerator(TASK_ID)); + renderHook(() => + useChatTitleGenerator( + createTask({ + title: "Auth prompt", + description: "fix auth", + }), + ), + ); await waitFor(() => { expect(mockSessionStoreSetters.updateSession).toHaveBeenCalledWith( @@ -190,11 +327,26 @@ describe("useChatTitleGenerator", () => { mockGenerateTitle.mockResolvedValue(null); mockPrompts.value = ["some prompt"]; - renderHook(() => useChatTitleGenerator(TASK_ID)); + renderHook(() => + useChatTitleGenerator( + createTask({ + title: "Some prompt", + description: "some prompt", + }), + ), + ); await waitFor(() => { expect(mockGenerateTitle).toHaveBeenCalled(); }); expect(mockUpdateTask).not.toHaveBeenCalled(); }); + + it("waits for authentication before generating", () => { + mockIsAuthenticated.value = false; + + renderHook(() => useChatTitleGenerator(createTask())); + + expect(mockGenerateTitle).not.toHaveBeenCalled(); + }); }); diff --git a/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts b/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts index 22f748019a..6d797512f4 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts +++ b/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts @@ -1,9 +1,12 @@ import { getAuthenticatedClient } from "@features/auth/hooks/authClient"; +import { useAuthStateValue } from "@features/auth/hooks/authQueries"; +import { xmlToPlainText } from "@features/message-editor/utils/content"; import { getSessionService } from "@features/sessions/service/service"; import { sessionStoreSetters, useSessionStore, } from "@features/sessions/stores/sessionStore"; +import { taskKeys } from "@features/tasks/hooks/taskKeys"; import type { Schemas } from "@renderer/api/generated"; import type { Task } from "@shared/types"; import { @@ -19,9 +22,38 @@ const log = logger.scope("chat-title-generator"); const REGENERATE_INTERVAL = 7; -export function useChatTitleGenerator(taskId: string): void { - const lastGeneratedAtCount = useRef(null); +function getFallbackTaskTitle(description: string): string { + const plainText = xmlToPlainText(description).trim(); + return (plainText || "Untitled").slice(0, 255); +} + +function isPlaceholderTaskTitle( + task: Pick, +): boolean { + if (task.title.trim().length === 0) { + return true; + } + + const fallbackTitle = getFallbackTaskTitle(task.description); + return task.title === fallbackTitle; +} + +function isAutoTitleLocked(task: Task | undefined): boolean { + if (!task?.title_manually_set) { + return false; + } + + return !isPlaceholderTaskTitle(task); +} + +export function useChatTitleGenerator(task: Task): void { + const taskId = task.id; + const lastGeneratedAtCount = useRef(0); + const initialDescriptionHandled = useRef(false); const isGenerating = useRef(false); + const isAuthenticated = useAuthStateValue( + (state) => state.status === "authenticated" && !!state.cloudRegion, + ); const promptCount = useSessionStore((state) => { const taskRunId = state.taskIdIndex[taskId]; @@ -32,41 +64,43 @@ export function useChatTitleGenerator(taskId: string): void { }); useEffect(() => { - if (promptCount === 0) return; + if (!isAuthenticated) return; if (isGenerating.current) return; - if (lastGeneratedAtCount.current === null) { - lastGeneratedAtCount.current = 0; - } - - const shouldGenerate = + const shouldGenerateFromPrompts = (promptCount === 1 && lastGeneratedAtCount.current === 0) || (promptCount > 1 && promptCount - lastGeneratedAtCount.current >= REGENERATE_INTERVAL); - if (!shouldGenerate) return; + const shouldGenerateFromTaskDescription = + promptCount === 0 && + !initialDescriptionHandled.current && + task.description.trim().length > 0 && + isPlaceholderTaskTitle(task); + + if (!shouldGenerateFromPrompts && !shouldGenerateFromTaskDescription) { + return; + } isGenerating.current = true; const state = useSessionStore.getState(); const taskRunId = state.taskIdIndex[taskId]; - if (!taskRunId) { - isGenerating.current = false; - return; - } - const session = state.sessions[taskRunId]; - if (!session?.events) { - isGenerating.current = false; - return; - } + const session = taskRunId ? state.sessions[taskRunId] : undefined; + let rawContent = task.description; + + if (shouldGenerateFromPrompts) { + if (!session?.events) { + isGenerating.current = false; + return; + } - const allPrompts = extractUserPromptsFromEvents(session.events); - const promptsForTitle = - promptCount === 1 ? allPrompts : allPrompts.slice(-REGENERATE_INTERVAL); + const allPrompts = extractUserPromptsFromEvents(session.events); + const promptsForTitle = + promptCount === 1 ? allPrompts : allPrompts.slice(-REGENERATE_INTERVAL); - const rawContent = promptsForTitle - .map((p, i) => `${i + 1}. ${p}`) - .join("\n"); + rawContent = promptsForTitle.map((p, i) => `${i + 1}. ${p}`).join("\n"); + } const run = async () => { try { @@ -74,7 +108,7 @@ export function useChatTitleGenerator(taskId: string): void { const result = await generateTitleAndSummary(content); if (result) { const { title, summary } = result; - const titleLocked = !!getCachedTask(taskId)?.title_manually_set; + const titleLocked = isAutoTitleLocked(getCachedTask(taskId) ?? task); if (title && titleLocked) { log.debug("Skipping auto-title, user renamed task", { taskId }); @@ -83,19 +117,22 @@ export function useChatTitleGenerator(taskId: string): void { if (client) { await client.updateTask(taskId, { title }); queryClient.setQueriesData( - { queryKey: ["tasks", "list"] }, + { queryKey: taskKeys.lists() }, (old) => old?.map((task) => task.id === taskId ? { ...task, title } : task, ), ); queryClient.setQueriesData( - { queryKey: ["tasks", "summaries"] }, + { queryKey: taskKeys.allSummaries() }, (old) => old?.map((task) => task.id === taskId ? { ...task, title } : task, ), ); + queryClient.setQueryData(taskKeys.detail(taskId), (old) => + old ? { ...old, title } : old, + ); getSessionService().updateSessionTaskTitle(taskId, title); log.debug("Updated task title from conversation", { taskId, @@ -104,7 +141,7 @@ export function useChatTitleGenerator(taskId: string): void { } } - if (summary) { + if (summary && taskRunId) { sessionStoreSetters.updateSession(taskRunId, { conversationSummary: result.summary, }); @@ -118,11 +155,16 @@ export function useChatTitleGenerator(taskId: string): void { } catch (error) { log.error("Failed to update task title", { taskId, error }); } finally { - lastGeneratedAtCount.current = promptCount; + if (shouldGenerateFromPrompts) { + lastGeneratedAtCount.current = promptCount; + } + if (shouldGenerateFromTaskDescription) { + initialDescriptionHandled.current = true; + } isGenerating.current = false; } }; run(); - }, [promptCount, taskId]); + }, [isAuthenticated, promptCount, taskId, task]); } diff --git a/apps/code/src/renderer/features/sessions/hooks/useSessionConnection.ts b/apps/code/src/renderer/features/sessions/hooks/useSessionConnection.ts index 7de8a5f2ad..764c2af788 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useSessionConnection.ts +++ b/apps/code/src/renderer/features/sessions/hooks/useSessionConnection.ts @@ -36,7 +36,7 @@ export function useSessionConnection({ const { isOnline } = useConnectivity(); const cloudAuthState = useAuthStateValue((state) => state); - useChatTitleGenerator(taskId); + useChatTitleGenerator(task); useEffect(() => { const taskRunId = session?.taskRunId; diff --git a/apps/code/src/renderer/features/sessions/hooks/useSessionViewState.ts b/apps/code/src/renderer/features/sessions/hooks/useSessionViewState.ts index 19bbdf26cb..9429bccb2b 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useSessionViewState.ts +++ b/apps/code/src/renderer/features/sessions/hooks/useSessionViewState.ts @@ -8,7 +8,7 @@ export function useSessionViewState(taskId: string, task: Task) { const session = useSessionForTask(taskId); const repoPath = useCwd(taskId) ?? null; const workspace = useWorkspace(taskId); - const isCloud = useIsCloudTask(taskId); + const isCloud = useIsCloudTask(taskId, task); const cloudStatus = session?.cloudStatus ?? null; const isCloudRunNotTerminal = diff --git a/apps/code/src/renderer/features/sessions/service/service.ts b/apps/code/src/renderer/features/sessions/service/service.ts index f7617e9fed..c0903429bd 100644 --- a/apps/code/src/renderer/features/sessions/service/service.ts +++ b/apps/code/src/renderer/features/sessions/service/service.ts @@ -764,7 +764,7 @@ export class SessionService { * The main process already cleaned up the agent, so we only need to * unsubscribe from the channel and mark the session as errored. * Preserves events, logUrl, configOptions and adapter so that Retry - * can reconnect with full context via unstable_resumeSession. + * can reconnect with full context via resumeSession. */ private handleIdleKill(taskRunId: string): void { this.unsubscribeFromChannel(taskRunId); diff --git a/apps/code/src/renderer/features/settings/components/sections/SlackSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/SlackSettings.tsx index 658595ccf9..bd75a0934d 100644 --- a/apps/code/src/renderer/features/settings/components/sections/SlackSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/SlackSettings.tsx @@ -9,6 +9,7 @@ import { Box, Button, Flex, Spinner, Text, Tooltip } from "@radix-ui/themes"; import { formatRelativeTimeLong } from "@renderer/utils/time"; import { openUrlInBrowser } from "@utils/browser"; import { getPostHogUrl } from "@utils/urls"; +import { SignalSlackNotificationsSettings } from "./SignalSlackNotificationsSettings"; export function SlackSettings() { const projectId = useAuthStateValue((s) => s.projectId); @@ -77,6 +78,8 @@ export function SlackSettings() {
{manageButtonWithTooltip} + +
); } diff --git a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx index 7ef074b320..89ff09e1c4 100644 --- a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx +++ b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx @@ -6,17 +6,15 @@ import { INBOX_PIPELINE_STATUS_FILTER, INBOX_REFETCH_INTERVAL_MS, } from "@features/inbox/utils/inboxConstants"; -import { getSessionService } from "@features/sessions/service/service"; import { archiveTasksImperative, useArchiveTask, } from "@features/tasks/hooks/useArchiveTask"; -import { useTasks, useUpdateTask } from "@features/tasks/hooks/useTasks"; +import { useRenameTask, useTasks } from "@features/tasks/hooks/useTasks"; import { useWorkspaces } from "@features/workspace/hooks/useWorkspace"; import { useTaskContextMenu } from "@hooks/useTaskContextMenu"; import { ScrollArea, Separator } from "@posthog/quill"; import { Box, Flex } from "@radix-ui/themes"; -import type { Schemas } from "@renderer/api/generated"; import { trpcClient } from "@renderer/trpc/client"; import type { Task } from "@shared/types"; import { useCommandMenuStore } from "@stores/commandMenuStore"; @@ -39,6 +37,8 @@ import { SkillsItem } from "./items/SkillsItem"; import { SidebarItem } from "./SidebarItem"; import { TaskListView } from "./TaskListView"; +const log = logger.scope("sidebar-menu"); + function SidebarMenuComponent() { const { view, @@ -62,6 +62,7 @@ function SidebarMenuComponent() { const { showContextMenu, editingTaskId, setEditingTaskId } = useTaskContextMenu(); const { archiveTask } = useArchiveTask(); + const { renameTask } = useRenameTask(); const { togglePin } = usePinnedTasks(); const sidebarData = useSidebarData({ @@ -239,9 +240,7 @@ function SidebarMenuComponent() { } } } catch (error) { - logger - .scope("sidebar-menu") - .error("Failed to show bulk context menu", error); + log.error("Failed to show bulk context menu", error); } }, [queryClient, clearSelection], @@ -300,8 +299,6 @@ function SidebarMenuComponent() { await archiveTask({ taskId }); }; - const updateTask = useUpdateTask(); - const handleArchivePrior = useCallback( async (taskId: string) => { const allVisible = [...sidebarData.pinnedTasks, ...sidebarData.flatTasks]; @@ -333,8 +330,6 @@ function SidebarMenuComponent() { }, [sidebarData.pinnedTasks, sidebarData.flatTasks, queryClient], ); - const log = logger.scope("sidebar-menu"); - const handleTaskDoubleClick = useCallback( (taskId: string) => { setEditingTaskId(taskId); @@ -343,43 +338,20 @@ function SidebarMenuComponent() { ); const handleTaskEditSubmit = useCallback( - async (taskId: string, newTitle: string) => { + async (taskId: string, currentTitle: string, newTitle: string) => { setEditingTaskId(null); - // Optimistically update task title in all cached task lists - queryClient.setQueriesData( - { queryKey: ["tasks", "list"] }, - (old) => - old?.map((task) => - task.id === taskId - ? { ...task, title: newTitle, title_manually_set: true } - : task, - ), - ); - queryClient.setQueriesData( - { queryKey: ["tasks", "summaries"] }, - (old) => - old?.map((task) => - task.id === taskId ? { ...task, title: newTitle } : task, - ), - ); - - // Sync to session store so notifications use the updated title - getSessionService().updateSessionTaskTitle(taskId, newTitle); - try { - await updateTask.mutateAsync({ + await renameTask({ taskId, - updates: { title: newTitle, title_manually_set: true }, + currentTitle, + newTitle, }); } catch (error) { log.error("Failed to rename task", error); - // Refetch to revert optimistic update on failure - queryClient.invalidateQueries({ queryKey: ["tasks", "list"] }); - queryClient.invalidateQueries({ queryKey: ["tasks", "summaries"] }); } }, - [setEditingTaskId, updateTask, queryClient, log], + [renameTask, setEditingTaskId], ); const handleTaskEditCancel = useCallback(() => { diff --git a/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx b/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx index 7fea8db2c5..9a3b17f17a 100644 --- a/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx +++ b/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx @@ -50,7 +50,11 @@ interface TaskListViewProps { ) => void; onTaskArchive: (taskId: string) => void; onTaskTogglePin: (taskId: string) => void; - onTaskEditSubmit: (taskId: string, newTitle: string) => void; + onTaskEditSubmit: ( + taskId: string, + currentTitle: string, + newTitle: string, + ) => void; onTaskEditCancel: () => void; hasMore: boolean; } @@ -346,7 +350,9 @@ export function TaskListView({ } onArchive={() => onTaskArchive(task.id)} onTogglePin={() => onTaskTogglePin(task.id)} - onEditSubmit={(newTitle) => onTaskEditSubmit(task.id, newTitle)} + onEditSubmit={(newTitle) => + onTaskEditSubmit(task.id, task.title, newTitle) + } onEditCancel={onTaskEditCancel} timestamp={task[timestampKey]} /> @@ -460,7 +466,7 @@ export function TaskListView({ onArchive={() => onTaskArchive(task.id)} onTogglePin={() => onTaskTogglePin(task.id)} onEditSubmit={(newTitle) => - onTaskEditSubmit(task.id, newTitle) + onTaskEditSubmit(task.id, task.title, newTitle) } onEditCancel={onTaskEditCancel} timestamp={task[timestampKey]} @@ -494,7 +500,7 @@ export function TaskListView({ onArchive={() => onTaskArchive(task.id)} onTogglePin={() => onTaskTogglePin(task.id)} onEditSubmit={(newTitle) => - onTaskEditSubmit(task.id, newTitle) + onTaskEditSubmit(task.id, task.title, newTitle) } onEditCancel={onTaskEditCancel} timestamp={task[timestampKey]} diff --git a/apps/code/src/renderer/features/sidebar/components/items/TaskIcon.tsx b/apps/code/src/renderer/features/sidebar/components/items/TaskIcon.tsx index de44afcd4c..562e24059a 100644 --- a/apps/code/src/renderer/features/sidebar/components/items/TaskIcon.tsx +++ b/apps/code/src/renderer/features/sidebar/components/items/TaskIcon.tsx @@ -104,11 +104,11 @@ function CloudStatusIcon({ const link = meta && threadUrl ? threadUrl : undefined; const ariaLabel = link ? `Open ${sourceLabel} thread` : undefined; - if (taskRunStatus === "queued" || taskRunStatus === "in_progress") { + if (taskRunStatus === "queued") { return ( @@ -120,6 +120,22 @@ function CloudStatusIcon({ ); } + if (taskRunStatus === "in_progress") { + return ( + + {renderIconSpan({ + icon: , + link, + ariaLabel, + })} + + ); + } if (taskRunStatus === "completed") { return ( ; } - if (isCloudTask) { - return ( - - ); - } if (isSuspended) { return ( @@ -320,6 +326,16 @@ export function TaskIcon({ if (isPinned) { return ; } + if (isCloudTask) { + return ( + + ); + } if (originProductMeta) { const { Icon, label } = originProductMeta; const link = slackThreadUrl; diff --git a/apps/code/src/renderer/features/task-detail/components/TaskDetail.tsx b/apps/code/src/renderer/features/task-detail/components/TaskDetail.tsx index 45f17ada1d..9231408c40 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskDetail.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskDetail.tsx @@ -10,10 +10,9 @@ import { parseTabId, } from "@features/panels/store/panelStoreHelpers"; import { MIN_CHAT_WIDTH } from "@features/sessions/constants"; -import { getSessionService } from "@features/sessions/service/service"; import { useCwd } from "@features/sidebar/hooks/useCwd"; import { useTaskData } from "@features/task-detail/hooks/useTaskData"; -import { useUpdateTask } from "@features/tasks/hooks/useTasks"; +import { useRenameTask } from "@features/tasks/hooks/useTasks"; import { useWorkspaceEvents } from "@features/workspace/hooks"; import { useWorkspace } from "@features/workspace/hooks/useWorkspace"; import { useBlurOnEscape } from "@hooks/useBlurOnEscape"; @@ -21,7 +20,6 @@ import { useFileWatcher } from "@hooks/useFileWatcher"; import { useSetHeaderContent } from "@hooks/useSetHeaderContent"; import { Box, Flex, Text, Tooltip } from "@radix-ui/themes"; import type { Task } from "@shared/types"; -import { useQueryClient } from "@tanstack/react-query"; import { logger } from "@utils/logger"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useHotkeys, useHotkeysContext } from "react-hotkeys-hook"; @@ -88,37 +86,23 @@ export function TaskDetail({ task: initialTask }: TaskDetailProps) { useWorkspaceEvents(taskId); const [isEditingTitle, setIsEditingTitle] = useState(false); - const updateTask = useUpdateTask(); - const queryClient = useQueryClient(); + const { renameTask } = useRenameTask(); const handleTitleEditSubmit = useCallback( async (newTitle: string) => { setIsEditingTitle(false); - queryClient.setQueriesData( - { queryKey: ["tasks", "list"] }, - (old) => - old?.map((t) => - t.id === taskId - ? { ...t, title: newTitle, title_manually_set: true } - : t, - ), - ); - - getSessionService().updateSessionTaskTitle(taskId, newTitle); - try { - await updateTask.mutateAsync({ + await renameTask({ taskId, - updates: { title: newTitle, title_manually_set: true }, + currentTitle: task.title, + newTitle, }); } catch (error) { log.error("Failed to rename task", error); - getSessionService().updateSessionTaskTitle(taskId, task.title); - queryClient.invalidateQueries({ queryKey: ["tasks", "list"] }); } }, - [taskId, task.title, updateTask, queryClient], + [renameTask, task.title, taskId], ); const handleTitleEditCancel = useCallback(() => { diff --git a/apps/code/src/renderer/features/tasks/hooks/taskKeys.ts b/apps/code/src/renderer/features/tasks/hooks/taskKeys.ts new file mode 100644 index 0000000000..894907235e --- /dev/null +++ b/apps/code/src/renderer/features/tasks/hooks/taskKeys.ts @@ -0,0 +1,15 @@ +export const taskKeys = { + all: ["tasks"] as const, + lists: () => [...taskKeys.all, "list"] as const, + list: (filters?: { + repository?: string; + createdBy?: number; + originProduct?: string; + internal?: boolean; + }) => [...taskKeys.lists(), filters] as const, + allSummaries: () => [...taskKeys.all, "summaries"] as const, + summaries: (ids: string[]) => + [...taskKeys.allSummaries(), [...ids].sort()] as const, + details: () => [...taskKeys.all, "detail"] as const, + detail: (id: string) => [...taskKeys.details(), id] as const, +}; diff --git a/apps/code/src/renderer/features/tasks/hooks/useTasks.test.tsx b/apps/code/src/renderer/features/tasks/hooks/useTasks.test.tsx new file mode 100644 index 0000000000..12181d777c --- /dev/null +++ b/apps/code/src/renderer/features/tasks/hooks/useTasks.test.tsx @@ -0,0 +1,262 @@ +import type { Schemas } from "@renderer/api/generated"; +import type { Task } from "@shared/types"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook } from "@testing-library/react"; +import { act, type ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockUpdateTask = vi.hoisted(() => vi.fn()); +const mockClient = vi.hoisted(() => ({ updateTask: mockUpdateTask })); +const mockUpdateSessionTaskTitle = vi.hoisted(() => vi.fn()); + +vi.mock("@features/auth/hooks/authClient", () => ({ + useOptionalAuthenticatedClient: () => mockClient, +})); + +vi.mock("@features/sessions/service/service", () => ({ + getSessionService: () => ({ + updateSessionTaskTitle: mockUpdateSessionTaskTitle, + }), +})); + +vi.mock("@renderer/trpc/client", () => ({ + trpcClient: {}, +})); + +vi.mock("@utils/logger", () => ({ + logger: { + scope: () => ({ + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), + }, +})); + +import { taskKeys } from "./taskKeys"; +import { useRenameTask } from "./useTasks"; + +const TASK_ID = "task-1"; +const OTHER_TASK_ID = "task-2"; + +function createTask(overrides: Partial = {}): Task { + return { + id: TASK_ID, + task_number: 1, + slug: "task-1", + title: "Original title", + description: "Original description", + created_at: "2026-05-28T00:00:00.000Z", + updated_at: "2026-05-28T00:00:00.000Z", + origin_product: "user_created", + ...overrides, + }; +} + +function createSummary(overrides: Partial = {}) { + return { + id: TASK_ID, + title: "Original title", + ...overrides, + } as Schemas.TaskSummary; +} + +function renderRenameHook() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const result = renderHook(() => useRenameTask(), { wrapper }); + return { ...result, queryClient }; +} + +describe("useRenameTask", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("applies the new title optimistically to list, summaries, and detail caches", async () => { + mockUpdateTask.mockResolvedValue(undefined); + const { result, queryClient } = renderRenameHook(); + + const listKey = taskKeys.list(); + const summaryKey = taskKeys.summaries([TASK_ID]); + const detailKey = taskKeys.detail(TASK_ID); + queryClient.setQueryData(listKey, [ + createTask(), + createTask({ id: OTHER_TASK_ID, title: "Other" }), + ]); + queryClient.setQueryData(summaryKey, [ + createSummary(), + createSummary({ id: OTHER_TASK_ID, title: "Other" }), + ]); + queryClient.setQueryData(detailKey, createTask()); + + await act(async () => { + await result.current.renameTask({ + taskId: TASK_ID, + currentTitle: "Original title", + newTitle: "Renamed", + }); + }); + + const list = queryClient.getQueryData(listKey); + expect(list?.find((t) => t.id === TASK_ID)).toMatchObject({ + title: "Renamed", + title_manually_set: true, + }); + expect(list?.find((t) => t.id === OTHER_TASK_ID)).toMatchObject({ + title: "Other", + }); + + const summaries = + queryClient.getQueryData(summaryKey); + expect(summaries?.find((t) => t.id === TASK_ID)?.title).toBe("Renamed"); + expect(summaries?.find((t) => t.id === OTHER_TASK_ID)?.title).toBe("Other"); + + const detail = queryClient.getQueryData(detailKey); + expect(detail).toMatchObject({ + title: "Renamed", + title_manually_set: true, + }); + + expect(mockUpdateTask).toHaveBeenCalledWith(TASK_ID, { + title: "Renamed", + title_manually_set: true, + }); + expect(mockUpdateSessionTaskTitle).toHaveBeenCalledWith(TASK_ID, "Renamed"); + }); + + it("rolls back all caches and notifies the session service with the original title on failure", async () => { + const failure = new Error("network down"); + mockUpdateTask.mockRejectedValue(failure); + const { result, queryClient } = renderRenameHook(); + + const listKey = taskKeys.list(); + const summaryKey = taskKeys.summaries([TASK_ID]); + const detailKey = taskKeys.detail(TASK_ID); + queryClient.setQueryData(listKey, [createTask()]); + queryClient.setQueryData(summaryKey, [ + createSummary(), + ]); + queryClient.setQueryData(detailKey, createTask()); + + let caught: unknown; + await act(async () => { + try { + await result.current.renameTask({ + taskId: TASK_ID, + currentTitle: "Original title", + newTitle: "Renamed", + }); + } catch (error) { + caught = error; + } + }); + expect(caught).toBe(failure); + + expect(queryClient.getQueryData(listKey)?.[0].title).toBe( + "Original title", + ); + expect( + queryClient.getQueryData(listKey)?.[0].title_manually_set, + ).toBeUndefined(); + expect( + queryClient.getQueryData(summaryKey)?.[0].title, + ).toBe("Original title"); + expect(queryClient.getQueryData(detailKey)?.title).toBe( + "Original title", + ); + + expect(mockUpdateSessionTaskTitle).toHaveBeenNthCalledWith( + 1, + TASK_ID, + "Renamed", + ); + expect(mockUpdateSessionTaskTitle).toHaveBeenNthCalledWith( + 2, + TASK_ID, + "Original title", + ); + }); + + it("skips rollback when a newer rename has advanced the title past ours", async () => { + const failure = new Error("network down"); + mockUpdateTask.mockRejectedValue(failure); + const { result, queryClient } = renderRenameHook(); + + const listKey = taskKeys.list(); + const summaryKey = taskKeys.summaries([TASK_ID]); + const detailKey = taskKeys.detail(TASK_ID); + queryClient.setQueryData(listKey, [createTask()]); + queryClient.setQueryData(summaryKey, [ + createSummary(), + ]); + queryClient.setQueryData(detailKey, createTask()); + + const renamePromise = result.current.renameTask({ + taskId: TASK_ID, + currentTitle: "Original title", + newTitle: "First rename", + }); + + queryClient.setQueryData(listKey, [ + createTask({ title: "Second rename", title_manually_set: true }), + ]); + queryClient.setQueryData(summaryKey, [ + createSummary({ title: "Second rename" }), + ]); + queryClient.setQueryData( + detailKey, + createTask({ title: "Second rename", title_manually_set: true }), + ); + + let caught: unknown; + await act(async () => { + try { + await renamePromise; + } catch (error) { + caught = error; + } + }); + expect(caught).toBe(failure); + + expect(queryClient.getQueryData(listKey)?.[0].title).toBe( + "Second rename", + ); + expect( + queryClient.getQueryData(summaryKey)?.[0].title, + ).toBe("Second rename"); + expect(queryClient.getQueryData(detailKey)?.title).toBe( + "Second rename", + ); + + expect(mockUpdateSessionTaskTitle).not.toHaveBeenCalledWith( + TASK_ID, + "Original title", + ); + }); + + it("does not write to the detail cache when no detail entry exists", async () => { + mockUpdateTask.mockResolvedValue(undefined); + const { result, queryClient } = renderRenameHook(); + + queryClient.setQueryData(taskKeys.list(), [createTask()]); + + await act(async () => { + await result.current.renameTask({ + taskId: TASK_ID, + currentTitle: "Original title", + newTitle: "Renamed", + }); + }); + + expect(queryClient.getQueryData(taskKeys.detail(TASK_ID))).toBeUndefined(); + expect(queryClient.getQueryData(taskKeys.list())?.[0].title).toBe( + "Renamed", + ); + }); +}); diff --git a/apps/code/src/renderer/features/tasks/hooks/useTasks.ts b/apps/code/src/renderer/features/tasks/hooks/useTasks.ts index 9643f8ccfc..d598060dfa 100644 --- a/apps/code/src/renderer/features/tasks/hooks/useTasks.ts +++ b/apps/code/src/renderer/features/tasks/hooks/useTasks.ts @@ -1,4 +1,6 @@ +import { getSessionService } from "@features/sessions/service/service"; import { pinnedTasksApi } from "@features/sidebar/hooks/usePinnedTasks"; +import { taskKeys } from "@features/tasks/hooks/taskKeys"; import { workspaceApi } from "@features/workspace/hooks/useWorkspace"; import { useAuthenticatedMutation } from "@hooks/useAuthenticatedMutation"; import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; @@ -16,20 +18,19 @@ const log = logger.scope("tasks"); const TASK_LIST_POLL_INTERVAL_MS = 30_000; -const taskKeys = { - all: ["tasks"] as const, - lists: () => [...taskKeys.all, "list"] as const, - list: (filters?: { - repository?: string; - createdBy?: number; - originProduct?: string; - internal?: boolean; - }) => [...taskKeys.lists(), filters] as const, - summaries: (ids: string[]) => - [...taskKeys.all, "summaries", [...ids].sort()] as const, - details: () => [...taskKeys.all, "detail"] as const, - detail: (id: string) => [...taskKeys.details(), id] as const, -}; +function getTaskTitle( + tasks: Task[] | undefined, + taskId: string, +): string | undefined { + return tasks?.find((task) => task.id === taskId)?.title; +} + +function getTaskSummaryTitle( + summaries: Schemas.TaskSummary[] | undefined, + taskId: string, +): string | undefined { + return summaries?.find((summary) => summary.id === taskId)?.title; +} export function useTasks( filters?: { @@ -159,14 +160,130 @@ export function useUpdateTask() { onSuccess: (_, { taskId }) => { queryClient.invalidateQueries({ queryKey: taskKeys.lists() }); queryClient.invalidateQueries({ queryKey: taskKeys.detail(taskId) }); - queryClient.invalidateQueries({ - queryKey: [...taskKeys.all, "summaries"], - }); + queryClient.invalidateQueries({ queryKey: taskKeys.allSummaries() }); }, }, ); } +export function useRenameTask() { + const queryClient = useQueryClient(); + const updateTask = useUpdateTask(); + + const renameTask = useCallback( + async ({ + taskId, + currentTitle, + newTitle, + }: { + taskId: string; + currentTitle: string; + newTitle: string; + }) => { + const previousListQueries = queryClient.getQueriesData({ + queryKey: taskKeys.lists(), + }); + const previousSummaryQueries = queryClient.getQueriesData< + Schemas.TaskSummary[] + >({ + queryKey: taskKeys.allSummaries(), + }); + const previousDetail = queryClient.getQueryData( + taskKeys.detail(taskId), + ); + + queryClient.setQueriesData( + { queryKey: taskKeys.lists() }, + (old) => + old?.map((task) => + task.id === taskId + ? { ...task, title: newTitle, title_manually_set: true } + : task, + ), + ); + queryClient.setQueriesData( + { queryKey: taskKeys.allSummaries() }, + (old) => + old?.map((task) => + task.id === taskId ? { ...task, title: newTitle } : task, + ), + ); + + if (previousDetail) { + queryClient.setQueryData(taskKeys.detail(taskId), { + ...previousDetail, + title: newTitle, + title_manually_set: true, + }); + } + + getSessionService().updateSessionTaskTitle(taskId, newTitle); + + try { + await updateTask.mutateAsync({ + taskId, + updates: { title: newTitle, title_manually_set: true }, + }); + } catch (error) { + const shouldRollbackSessionTitle = + queryClient.getQueryData(taskKeys.detail(taskId))?.title === + newTitle || + queryClient + .getQueriesData({ + queryKey: taskKeys.lists(), + }) + .some(([, tasks]) => getTaskTitle(tasks, taskId) === newTitle); + + for (const [queryKey, data] of previousListQueries) { + queryClient.setQueryData(queryKey, (current) => { + if (!current) { + return data; + } + + return getTaskTitle(current, taskId) === newTitle ? data : current; + }); + } + for (const [queryKey, data] of previousSummaryQueries) { + queryClient.setQueryData( + queryKey, + (current) => { + if (!current) { + return data; + } + + return getTaskSummaryTitle(current, taskId) === newTitle + ? data + : current; + }, + ); + } + if (previousDetail) { + queryClient.setQueryData( + taskKeys.detail(taskId), + (current) => { + if (!current) { + return previousDetail; + } + + return current.title === newTitle ? previousDetail : current; + }, + ); + } + if (shouldRollbackSessionTitle) { + getSessionService().updateSessionTaskTitle(taskId, currentTitle); + } + throw error; + } + }, + [queryClient, updateTask], + ); + + return { + renameTask, + isPending: updateTask.isPending, + }; +} + interface DeleteTaskOptions { taskId: string; taskTitle: string; diff --git a/apps/code/src/renderer/features/workspace/hooks/useIsCloudTask.ts b/apps/code/src/renderer/features/workspace/hooks/useIsCloudTask.ts index 438bf2d45c..41fe6a4d47 100644 --- a/apps/code/src/renderer/features/workspace/hooks/useIsCloudTask.ts +++ b/apps/code/src/renderer/features/workspace/hooks/useIsCloudTask.ts @@ -1,6 +1,8 @@ +import type { Task } from "@shared/types"; import { useWorkspace } from "./useWorkspace"; -export function useIsCloudTask(taskId: string): boolean { +export function useIsCloudTask(taskId: string, task?: Task): boolean { const workspace = useWorkspace(taskId); - return workspace?.mode === "cloud"; + if (workspace?.mode === "cloud") return true; + return task?.latest_run?.environment === "cloud"; } diff --git a/apps/code/src/renderer/hooks/useImagePanAndZoom.test.tsx b/apps/code/src/renderer/hooks/useImagePanAndZoom.test.tsx new file mode 100644 index 0000000000..9b74917ea1 --- /dev/null +++ b/apps/code/src/renderer/hooks/useImagePanAndZoom.test.tsx @@ -0,0 +1,308 @@ +import { act, fireEvent, render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { useImagePanAndZoom } from "./useImagePanAndZoom"; + +type HookResult = ReturnType; + +interface HarnessProps { + onRender: (result: HookResult) => void; + options?: Parameters[0]; +} + +function Harness({ onRender, options }: HarnessProps) { + const result = useImagePanAndZoom(options); + onRender(result); + return ( +
+ ); +} + +function setupHarness(options?: Parameters[0]) { + let latest: HookResult | null = null; + const view = render( + { + latest = result; + }} + options={options} + />, + ); + const container = view.getByTestId("container"); + container.getBoundingClientRect = () => + ({ + x: 0, + y: 0, + left: 0, + top: 0, + right: 200, + bottom: 200, + width: 200, + height: 200, + toJSON: () => ({}), + }) as DOMRect; + container.setPointerCapture = () => {}; + container.releasePointerCapture = () => {}; + container.hasPointerCapture = () => true; + return { + container, + get current() { + if (!latest) throw new Error("hook did not render"); + return latest; + }, + }; +} + +function parseTransform(transform: string) { + const match = transform.match( + /translate\(([-0-9.]+)px, ([-0-9.]+)px\) scale\(([-0-9.]+)\)/, + ); + if (!match) throw new Error(`unexpected transform: ${transform}`); + return { + tx: Number.parseFloat(match[1]), + ty: Number.parseFloat(match[2]), + scale: Number.parseFloat(match[3]), + }; +} + +describe("useImagePanAndZoom", () => { + it("starts at identity transform and not zoomed", () => { + const harness = setupHarness(); + expect(parseTransform(harness.current.transform)).toEqual({ + tx: 0, + ty: 0, + scale: 1, + }); + expect(harness.current.isZoomed).toBe(false); + }); + + it.each([ + { modifier: "ctrlKey", label: "trackpad pinch" }, + { modifier: "metaKey", label: "Cmd + wheel" }, + ] as const)("zooms in via $label", ({ modifier }) => { + const harness = setupHarness(); + act(() => { + fireEvent.wheel(harness.container, { + [modifier]: true, + deltaY: -100, + clientX: 100, + clientY: 100, + }); + }); + expect(harness.current.isZoomed).toBe(true); + expect(parseTransform(harness.current.transform).scale).toBeGreaterThan(1); + }); + + it("ignores wheel without modifier when not zoomed", () => { + const harness = setupHarness(); + act(() => { + fireEvent.wheel(harness.container, { + deltaY: 200, + clientX: 100, + clientY: 100, + }); + }); + expect(parseTransform(harness.current.transform)).toEqual({ + tx: 0, + ty: 0, + scale: 1, + }); + }); + + it("pans on wheel without modifier when zoomed in", () => { + const harness = setupHarness(); + act(() => { + fireEvent.wheel(harness.container, { + ctrlKey: true, + deltaY: -200, + clientX: 100, + clientY: 100, + }); + }); + const beforePan = parseTransform(harness.current.transform); + act(() => { + fireEvent.wheel(harness.container, { + deltaX: 30, + deltaY: 40, + clientX: 100, + clientY: 100, + }); + }); + const afterPan = parseTransform(harness.current.transform); + expect(afterPan.tx).toBeCloseTo(beforePan.tx - 30); + expect(afterPan.ty).toBeCloseTo(beforePan.ty - 40); + expect(afterPan.scale).toBe(beforePan.scale); + }); + + it.each([ + { direction: "in", deltaY: -2000, expected: 4 }, + { direction: "out", deltaY: 2000, expected: 1 }, + ])("clamps scale at the $direction limit", ({ deltaY, expected }) => { + const harness = setupHarness({ minScale: 1, maxScale: 4 }); + for (let i = 0; i < 50; i++) { + act(() => { + fireEvent.wheel(harness.container, { + ctrlKey: true, + deltaY, + clientX: 100, + clientY: 100, + }); + }); + } + expect(parseTransform(harness.current.transform).scale).toBe(expected); + }); + + it("snaps to identity when zooming all the way back to scale 1", () => { + const harness = setupHarness(); + act(() => { + fireEvent.wheel(harness.container, { + ctrlKey: true, + deltaY: -300, + clientX: 150, + clientY: 150, + }); + }); + expect(harness.current.isZoomed).toBe(true); + for (let i = 0; i < 50; i++) { + act(() => { + fireEvent.wheel(harness.container, { + ctrlKey: true, + deltaY: 200, + clientX: 0, + clientY: 0, + }); + }); + } + expect(parseTransform(harness.current.transform)).toEqual({ + tx: 0, + ty: 0, + scale: 1, + }); + }); + + it("double-click resets to identity", () => { + const harness = setupHarness(); + act(() => { + fireEvent.wheel(harness.container, { + ctrlKey: true, + deltaY: -300, + clientX: 100, + clientY: 100, + }); + }); + expect(harness.current.isZoomed).toBe(true); + act(() => { + fireEvent.dblClick(harness.container); + }); + expect(parseTransform(harness.current.transform)).toEqual({ + tx: 0, + ty: 0, + scale: 1, + }); + expect(harness.current.isZoomed).toBe(false); + }); + + it("reset() returns to identity", () => { + const harness = setupHarness(); + act(() => { + fireEvent.wheel(harness.container, { + ctrlKey: true, + deltaY: -300, + clientX: 80, + clientY: 80, + }); + }); + act(() => { + harness.current.reset(); + }); + expect(parseTransform(harness.current.transform)).toEqual({ + tx: 0, + ty: 0, + scale: 1, + }); + }); + + it("ignores pointer drag when not zoomed", () => { + const harness = setupHarness(); + act(() => { + fireEvent.pointerDown(harness.container, { + pointerId: 1, + button: 0, + clientX: 50, + clientY: 50, + }); + fireEvent.pointerMove(harness.container, { + pointerId: 1, + clientX: 120, + clientY: 90, + }); + fireEvent.pointerUp(harness.container, { pointerId: 1 }); + }); + expect(parseTransform(harness.current.transform)).toEqual({ + tx: 0, + ty: 0, + scale: 1, + }); + }); + + it("pans on pointer drag when zoomed", () => { + const harness = setupHarness(); + act(() => { + fireEvent.wheel(harness.container, { + ctrlKey: true, + deltaY: -300, + clientX: 100, + clientY: 100, + }); + }); + const before = parseTransform(harness.current.transform); + act(() => { + fireEvent.pointerDown(harness.container, { + pointerId: 1, + button: 0, + clientX: 50, + clientY: 50, + }); + fireEvent.pointerMove(harness.container, { + pointerId: 1, + clientX: 80, + clientY: 30, + }); + }); + const dragging = parseTransform(harness.current.transform); + expect(dragging.tx).toBeCloseTo(before.tx + 30); + expect(dragging.ty).toBeCloseTo(before.ty - 20); + expect(dragging.scale).toBe(before.scale); + expect(harness.current.isDragging).toBe(true); + act(() => { + fireEvent.pointerUp(harness.container, { pointerId: 1 }); + fireEvent.pointerMove(harness.container, { + pointerId: 1, + clientX: 999, + clientY: 999, + }); + }); + expect(parseTransform(harness.current.transform)).toEqual(dragging); + expect(harness.current.isDragging).toBe(false); + }); + + it("zooms toward the cursor position", () => { + const harness = setupHarness(); + act(() => { + fireEvent.wheel(harness.container, { + ctrlKey: true, + deltaY: -100, + clientX: 150, + clientY: 100, + }); + }); + const { tx, ty, scale } = parseTransform(harness.current.transform); + const cursorOffsetX = 150 - 100; + const cursorOffsetY = 100 - 100; + expect(tx).toBeCloseTo(cursorOffsetX - cursorOffsetX * scale); + expect(ty).toBeCloseTo(cursorOffsetY - cursorOffsetY * scale); + }); +}); diff --git a/apps/code/src/renderer/hooks/useImagePanAndZoom.ts b/apps/code/src/renderer/hooks/useImagePanAndZoom.ts new file mode 100644 index 0000000000..942bae42a5 --- /dev/null +++ b/apps/code/src/renderer/hooks/useImagePanAndZoom.ts @@ -0,0 +1,154 @@ +import { + type RefObject, + useCallback, + useEffect, + useRef, + useState, +} from "react"; + +interface UseImagePanAndZoomOptions { + minScale?: number; + maxScale?: number; +} + +interface UseImagePanAndZoomResult { + containerRef: RefObject; + transform: string; + isZoomed: boolean; + isDragging: boolean; + reset: () => void; +} + +interface ZoomState { + scale: number; + tx: number; + ty: number; +} + +const IDENTITY: ZoomState = { scale: 1, tx: 0, ty: 0 }; + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +export function useImagePanAndZoom( + options: UseImagePanAndZoomOptions = {}, +): UseImagePanAndZoomResult { + const minScale = options.minScale ?? 1; + const maxScale = options.maxScale ?? 8; + + const containerRef = useRef(null); + const [state, setState] = useState(IDENTITY); + const [isDragging, setIsDragging] = useState(false); + const stateRef = useRef(state); + stateRef.current = state; + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + + let drag: { + pointerId: number; + startX: number; + startY: number; + startTx: number; + startTy: number; + } | null = null; + + const handleWheel = (event: WheelEvent) => { + if (event.ctrlKey || event.metaKey) { + event.preventDefault(); + const rect = el.getBoundingClientRect(); + const cursorX = event.clientX - (rect.left + rect.width / 2); + const cursorY = event.clientY - (rect.top + rect.height / 2); + setState((prev) => { + const nextScale = clamp( + prev.scale * Math.exp(-event.deltaY * 0.01), + minScale, + maxScale, + ); + if (nextScale === prev.scale) return prev; + if (nextScale === 1) return IDENTITY; + const ratio = nextScale / prev.scale; + return { + scale: nextScale, + tx: cursorX - (cursorX - prev.tx) * ratio, + ty: cursorY - (cursorY - prev.ty) * ratio, + }; + }); + return; + } + if (stateRef.current.scale <= 1) return; + event.preventDefault(); + setState((prev) => ({ + scale: prev.scale, + tx: prev.tx - event.deltaX, + ty: prev.ty - event.deltaY, + })); + }; + + const handlePointerDown = (event: PointerEvent) => { + if (event.button !== 0) return; + if (stateRef.current.scale <= 1) return; + el.setPointerCapture(event.pointerId); + drag = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + startTx: stateRef.current.tx, + startTy: stateRef.current.ty, + }; + setIsDragging(true); + }; + + const handlePointerMove = (event: PointerEvent) => { + if (!drag || drag.pointerId !== event.pointerId) return; + const dx = event.clientX - drag.startX; + const dy = event.clientY - drag.startY; + const startTx = drag.startTx; + const startTy = drag.startTy; + setState((prev) => ({ + scale: prev.scale, + tx: startTx + dx, + ty: startTy + dy, + })); + }; + + const handlePointerUp = (event: PointerEvent) => { + if (!drag || drag.pointerId !== event.pointerId) return; + if (el.hasPointerCapture(event.pointerId)) { + el.releasePointerCapture(event.pointerId); + } + drag = null; + setIsDragging(false); + }; + + const handleDoubleClick = () => setState(IDENTITY); + + el.addEventListener("wheel", handleWheel, { passive: false }); + el.addEventListener("pointerdown", handlePointerDown); + el.addEventListener("pointermove", handlePointerMove); + el.addEventListener("pointerup", handlePointerUp); + el.addEventListener("pointercancel", handlePointerUp); + el.addEventListener("dblclick", handleDoubleClick); + + return () => { + el.removeEventListener("wheel", handleWheel); + el.removeEventListener("pointerdown", handlePointerDown); + el.removeEventListener("pointermove", handlePointerMove); + el.removeEventListener("pointerup", handlePointerUp); + el.removeEventListener("pointercancel", handlePointerUp); + el.removeEventListener("dblclick", handleDoubleClick); + }; + }, [minScale, maxScale]); + + const reset = useCallback(() => setState(IDENTITY), []); + + return { + containerRef, + transform: `translate(${state.tx}px, ${state.ty}px) scale(${state.scale})`, + isZoomed: state.scale > 1, + isDragging, + reset, + }; +} diff --git a/apps/code/src/renderer/hooks/useTaskContextMenu.ts b/apps/code/src/renderer/hooks/useTaskContextMenu.ts index c57a2725d5..31c48107a5 100644 --- a/apps/code/src/renderer/hooks/useTaskContextMenu.ts +++ b/apps/code/src/renderer/hooks/useTaskContextMenu.ts @@ -113,7 +113,7 @@ export function useTaskContextMenu() { log.error("Failed to show context menu", error); } }, - [deleteWithConfirm, archiveTask, suspendTask, restoreTask], + [archiveTask, deleteWithConfirm, restoreTask, suspendTask], ); return { diff --git a/apps/code/src/renderer/sagas/task/task-creation.test.ts b/apps/code/src/renderer/sagas/task/task-creation.test.ts index 79961b7a13..d31e22c147 100644 --- a/apps/code/src/renderer/sagas/task/task-creation.test.ts +++ b/apps/code/src/renderer/sagas/task/task-creation.test.ts @@ -371,7 +371,7 @@ describe("TaskCreationSaga", () => { ); }); - it("sets title from plain text when description has text", async () => { + it("does not prefill a task title from the prompt", async () => { const createdTask = createTask(); const startedTask = createTask({ latest_run: createRun() }); const createTaskMock = vi.fn().mockResolvedValue(createdTask); @@ -399,13 +399,13 @@ describe("TaskCreationSaga", () => { expect(createTaskMock).toHaveBeenCalledWith( expect.objectContaining({ - title: "Ship the fix", description: "Ship the fix", }), ); + expect(createTaskMock.mock.calls[0]?.[0]).not.toHaveProperty("title"); }); - it("renders attachment-only description as @mention title", async () => { + it("does not prefill a task title for attachment-only prompts", async () => { const createdTask = createTask(); const startedTask = createTask({ latest_run: createRun() }); const createTaskMock = vi.fn().mockResolvedValue(createdTask); @@ -433,106 +433,10 @@ describe("TaskCreationSaga", () => { expect(createTaskMock).toHaveBeenCalledWith( expect.objectContaining({ - title: "@tmp/code.ts", description: '', }), ); - }); - - it("falls back to Untitled when description is empty", async () => { - const createdTask = createTask(); - const startedTask = createTask({ latest_run: createRun() }); - const createTaskMock = vi.fn().mockResolvedValue(createdTask); - const createTaskRunMock = vi.fn().mockResolvedValue(createRun()); - const startTaskRunMock = vi.fn().mockResolvedValue(startedTask); - - const saga = new TaskCreationSaga({ - posthogClient: { - createTask: createTaskMock, - deleteTask: vi.fn(), - getTask: vi.fn(), - createTaskRun: createTaskRunMock, - startTaskRun: startTaskRunMock, - sendRunCommand: vi.fn(), - updateTask: vi.fn(), - } as never, - }); - - await saga.run({ - content: " ", - repository: "posthog/posthog", - workspaceMode: "cloud", - branch: "main", - }); - - expect(createTaskMock).toHaveBeenCalledWith( - expect.objectContaining({ title: "Untitled" }), - ); - }); - - it("renders folder mentions as readable @mention in title", async () => { - const createdTask = createTask(); - const startedTask = createTask({ latest_run: createRun() }); - const createTaskMock = vi.fn().mockResolvedValue(createdTask); - const createTaskRunMock = vi.fn().mockResolvedValue(createRun()); - const startTaskRunMock = vi.fn().mockResolvedValue(startedTask); - - const saga = new TaskCreationSaga({ - posthogClient: { - createTask: createTaskMock, - deleteTask: vi.fn(), - getTask: vi.fn(), - createTaskRun: createTaskRunMock, - startTaskRun: startTaskRunMock, - sendRunCommand: vi.fn(), - updateTask: vi.fn(), - } as never, - }); - - await saga.run({ - content: - 'look at and tell me what you see', - repository: "posthog/posthog", - workspaceMode: "cloud", - branch: "main", - }); - - expect(createTaskMock).toHaveBeenCalledWith( - expect.objectContaining({ - title: "look at @products/agentic_tests and tell me what you see", - }), - ); - }); - - it("truncates title to 255 chars", async () => { - const longText = "x".repeat(300); - const createdTask = createTask(); - const startedTask = createTask({ latest_run: createRun() }); - const createTaskMock = vi.fn().mockResolvedValue(createdTask); - const createTaskRunMock = vi.fn().mockResolvedValue(createRun()); - const startTaskRunMock = vi.fn().mockResolvedValue(startedTask); - - const saga = new TaskCreationSaga({ - posthogClient: { - createTask: createTaskMock, - deleteTask: vi.fn(), - getTask: vi.fn(), - createTaskRun: createTaskRunMock, - startTaskRun: startTaskRunMock, - sendRunCommand: vi.fn(), - updateTask: vi.fn(), - } as never, - }); - - await saga.run({ - content: longText, - repository: "posthog/posthog", - workspaceMode: "cloud", - branch: "main", - }); - - const calledTitle = createTaskMock.mock.calls[0][0].title; - expect(calledTitle).toHaveLength(255); + expect(createTaskMock.mock.calls[0]?.[0]).not.toHaveProperty("title"); }); it("uses user authorship for repo-less cloud tasks with a selected user GitHub integration", async () => { diff --git a/apps/code/src/renderer/sagas/task/task-creation.ts b/apps/code/src/renderer/sagas/task/task-creation.ts index 582c0b94ad..3b7ad84d15 100644 --- a/apps/code/src/renderer/sagas/task/task-creation.ts +++ b/apps/code/src/renderer/sagas/task/task-creation.ts @@ -1,5 +1,4 @@ import { buildPromptBlocks } from "@features/editor/utils/prompt-builder"; -import { xmlToPlainText } from "@features/message-editor/utils/content"; import { DEFAULT_PANEL_IDS } from "@features/panels/constants/panelConstants"; import { usePanelLayoutStore } from "@features/panels/store/panelLayoutStore"; import { useProvisioningStore } from "@features/provisioning/stores/provisioningStore"; @@ -401,9 +400,7 @@ export class TaskCreationSaga extends Saga< name: "task_creation", execute: async () => { const description = input.taskDescription ?? input.content ?? ""; - const plainText = xmlToPlainText(description).trim(); const result = await this.deps.posthogClient.createTask({ - title: (plainText || "Untitled").slice(0, 255), description, repository: repository ?? undefined, github_integration: diff --git a/apps/code/src/renderer/stores/updateStore.test.ts b/apps/code/src/renderer/stores/updateStore.test.ts new file mode 100644 index 0000000000..f556a86c17 --- /dev/null +++ b/apps/code/src/renderer/stores/updateStore.test.ts @@ -0,0 +1,225 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + checkMutate, + getStatusQuery, + installMutate, + isEnabledQuery, + subscriptions, + toast, +} = vi.hoisted(() => ({ + checkMutate: vi.fn(), + getStatusQuery: vi.fn(), + installMutate: vi.fn(), + isEnabledQuery: vi.fn(), + subscriptions: { + onStatus: null as + | null + | ((status: { + checking: boolean; + downloading?: boolean; + upToDate?: boolean; + updateReady?: boolean; + version?: string; + error?: string; + }) => void), + onReady: null as null | ((data: { version: string | null }) => void), + onCheckFromMenu: null as null | (() => void), + }, + toast: { + error: vi.fn(), + success: vi.fn(), + }, +})); + +vi.mock("@renderer/trpc/client", () => ({ + trpcClient: { + updates: { + isEnabled: { query: isEnabledQuery }, + getStatus: { query: getStatusQuery }, + check: { mutate: checkMutate }, + install: { mutate: installMutate }, + onStatus: { + subscribe: vi.fn((_input, handlers) => { + subscriptions.onStatus = handlers.onData; + return { unsubscribe: vi.fn() }; + }), + }, + onReady: { + subscribe: vi.fn((_input, handlers) => { + subscriptions.onReady = handlers.onData; + return { unsubscribe: vi.fn() }; + }), + }, + onCheckFromMenu: { + subscribe: vi.fn((_input, handlers) => { + subscriptions.onCheckFromMenu = handlers.onData; + return { unsubscribe: vi.fn() }; + }), + }, + }, + }, +})); + +vi.mock("@utils/logger", () => ({ + logger: { + scope: () => ({ + error: vi.fn(), + }), + }, +})); + +vi.mock("@utils/toast", () => ({ + toast, +})); + +import { initializeUpdateStore, useUpdateStore } from "./updateStore"; + +async function flushPromises(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +describe("updateStore", () => { + beforeEach(() => { + vi.clearAllMocks(); + subscriptions.onStatus = null; + subscriptions.onReady = null; + subscriptions.onCheckFromMenu = null; + isEnabledQuery.mockResolvedValue({ enabled: true }); + getStatusQuery.mockResolvedValue({ checking: false }); + checkMutate.mockResolvedValue({ success: true }); + installMutate.mockResolvedValue({ installed: true }); + useUpdateStore.setState({ + status: "idle", + version: null, + isEnabled: false, + menuCheckPending: false, + }); + }); + + it("hydrates an already-ready update from the main status snapshot", async () => { + getStatusQuery.mockResolvedValue({ + checking: false, + updateReady: true, + version: "v2.0.0", + }); + + const dispose = initializeUpdateStore(); + await flushPromises(); + + expect(getStatusQuery).toHaveBeenCalled(); + expect(useUpdateStore.getState()).toMatchObject({ + isEnabled: true, + status: "ready", + version: "v2.0.0", + }); + + dispose(); + }); + + it("surfaces an already-staged update from a menu check replay", async () => { + const dispose = initializeUpdateStore(); + await flushPromises(); + + subscriptions.onCheckFromMenu?.(); + await flushPromises(); + + expect(checkMutate).toHaveBeenCalled(); + + subscriptions.onReady?.({ version: "v2.0.0" }); + expect(useUpdateStore.getState()).toMatchObject({ + status: "ready", + version: "v2.0.0", + }); + + subscriptions.onStatus?.({ checking: false }); + dispose(); + }); + + it("hydrates an installing update so the renderer keeps the restart spinner", async () => { + getStatusQuery.mockResolvedValue({ + checking: false, + updateReady: true, + installing: true, + version: "v2.0.0", + }); + + const dispose = initializeUpdateStore(); + await flushPromises(); + + expect(useUpdateStore.getState()).toMatchObject({ + status: "installing", + version: "v2.0.0", + }); + + dispose(); + }); + + it("does not reset a ready update when a stale upToDate status arrives", async () => { + getStatusQuery.mockResolvedValue({ + checking: false, + updateReady: true, + version: "v2.0.0", + }); + + const dispose = initializeUpdateStore(); + await flushPromises(); + + subscriptions.onStatus?.({ checking: false, upToDate: true }); + + expect(useUpdateStore.getState().status).toBe("ready"); + dispose(); + }); + + it("shows the success toast when a menu check resolves with upToDate", async () => { + const dispose = initializeUpdateStore(); + await flushPromises(); + + subscriptions.onCheckFromMenu?.(); + await flushPromises(); + expect(useUpdateStore.getState().menuCheckPending).toBe(true); + + subscriptions.onStatus?.({ checking: false, upToDate: true }); + + expect(toast.success).toHaveBeenCalledWith("You're on the latest version"); + expect(useUpdateStore.getState().menuCheckPending).toBe(false); + dispose(); + }); + + it("clears the menu-check flag on disabled errors and shows the error toast", async () => { + checkMutate.mockResolvedValue({ + success: false, + errorCode: "disabled", + errorMessage: "Updates only available in packaged builds", + }); + + const dispose = initializeUpdateStore(); + await flushPromises(); + + subscriptions.onCheckFromMenu?.(); + await flushPromises(); + + expect(useUpdateStore.getState().menuCheckPending).toBe(false); + expect(toast.error).toHaveBeenCalledWith( + "Updates only available in packaged builds", + ); + dispose(); + }); + + it("keeps the menu-check flag when an in-flight check is already running", async () => { + checkMutate.mockResolvedValue({ + success: false, + errorCode: "already_checking", + }); + + const dispose = initializeUpdateStore(); + await flushPromises(); + + subscriptions.onCheckFromMenu?.(); + await flushPromises(); + + expect(useUpdateStore.getState().menuCheckPending).toBe(true); + dispose(); + }); +}); diff --git a/apps/code/src/renderer/stores/updateStore.ts b/apps/code/src/renderer/stores/updateStore.ts index 23b589bcf0..145b49544e 100644 --- a/apps/code/src/renderer/stores/updateStore.ts +++ b/apps/code/src/renderer/stores/updateStore.ts @@ -5,8 +5,6 @@ import { create } from "zustand"; const log = logger.scope("update-store"); -let menuCheckPending = false; - type UpdateStatus = | "idle" | "checking" @@ -14,10 +12,21 @@ type UpdateStatus = | "ready" | "installing"; +interface StatusPayload { + checking: boolean; + downloading?: boolean; + upToDate?: boolean; + updateReady?: boolean; + installing?: boolean; + version?: string; + error?: string; +} + interface UpdateState { status: UpdateStatus; version: string | null; isEnabled: boolean; + menuCheckPending: boolean; installUpdate: () => Promise; checkForUpdates: () => void; @@ -27,6 +36,7 @@ export const useUpdateStore = create()((set, get) => ({ status: "idle", version: null, isEnabled: false, + menuCheckPending: false, installUpdate: async () => { if (get().status === "installing") return; @@ -62,42 +72,44 @@ export function initializeUpdateStore() { log.error("Failed to get update enabled status", { error }); }); + trpcClient.updates.getStatus + .query() + .then((status) => { + applyStatus(status); + }) + .catch((error: unknown) => { + log.error("Failed to get update status", { error }); + }); + const statusSub = trpcClient.updates.onStatus.subscribe(undefined, { onData: (status) => { - if (status.checking && status.downloading) { - useUpdateStore.setState({ status: "downloading" }); - } else if (status.checking) { - useUpdateStore.setState({ status: "checking" }); - } else if (status.upToDate) { - const current = useUpdateStore.getState().status; - if (current === "checking" || current === "downloading") { - useUpdateStore.setState({ status: "idle" }); - } - if (menuCheckPending) { - menuCheckPending = false; + applyStatus(status); + + if (status.upToDate) { + if (useUpdateStore.getState().menuCheckPending) { + useUpdateStore.setState({ menuCheckPending: false }); toast.success("You're on the latest version"); } } else if (status.error) { log.error("Update check failed", { error: status.error }); - const current = useUpdateStore.getState().status; - if (current === "checking" || current === "downloading") { - useUpdateStore.setState({ status: "idle" }); - } - if (menuCheckPending) { - menuCheckPending = false; + if (useUpdateStore.getState().menuCheckPending) { + useUpdateStore.setState({ menuCheckPending: false }); toast.error("Failed to check for updates", { description: status.error, }); } - } else if (status.checking === false && menuCheckPending) { - // Check finished and an update was found (download in progress / ready) — - // the UpdateBanner will surface it, so suppress the menu-check toast. - menuCheckPending = false; + } else if ( + status.checking === false && + useUpdateStore.getState().menuCheckPending + ) { + // Check finished and an update was found (download in progress / ready) + // — the UpdateBanner will surface it, so suppress the menu-check toast. + useUpdateStore.setState({ menuCheckPending: false }); } }, onError: (error) => { log.error("Update status subscription error", { error }); - menuCheckPending = false; + useUpdateStore.setState({ menuCheckPending: false }); }, }); @@ -115,24 +127,24 @@ export function initializeUpdateStore() { const menuCheckSub = trpcClient.updates.onCheckFromMenu.subscribe(undefined, { onData: () => { - menuCheckPending = true; + useUpdateStore.setState({ menuCheckPending: true }); trpcClient.updates.check .mutate() .then((result) => { if (!result.success) { if (result.errorCode === "disabled") { - menuCheckPending = false; + useUpdateStore.setState({ menuCheckPending: false }); toast.error(result.errorMessage ?? "Updates not available"); } else if (result.errorCode !== "already_checking") { // Unknown/future error code — reset the flag so it never gets stuck. - menuCheckPending = false; + useUpdateStore.setState({ menuCheckPending: false }); } // For "already_checking", keep the flag so the in-flight check // surfaces the toast when it resolves. } }) .catch((error: unknown) => { - menuCheckPending = false; + useUpdateStore.setState({ menuCheckPending: false }); log.error("Failed to check for updates", { error }); toast.error("Failed to check for updates"); }); @@ -148,3 +160,38 @@ export function initializeUpdateStore() { menuCheckSub.unsubscribe(); }; } + +function applyStatus(status: StatusPayload): void { + if (status.installing) { + useUpdateStore.setState({ + status: "installing", + version: status.version ?? null, + }); + return; + } + + if (status.updateReady) { + useUpdateStore.setState({ + status: "ready", + version: status.version ?? null, + }); + return; + } + + if (status.checking && status.downloading) { + useUpdateStore.setState({ status: "downloading" }); + return; + } + + if (status.checking) { + useUpdateStore.setState({ status: "checking" }); + return; + } + + if (status.upToDate || status.error) { + const current = useUpdateStore.getState().status; + if (current !== "ready" && current !== "installing") { + useUpdateStore.setState({ status: "idle" }); + } + } +} diff --git a/apps/code/src/shared/deeplink.test.ts b/apps/code/src/shared/deeplink.test.ts new file mode 100644 index 0000000000..dfd168f84f --- /dev/null +++ b/apps/code/src/shared/deeplink.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { buildInboxDeeplink } from "./deeplink"; + +describe("buildInboxDeeplink", () => { + it("returns just the UUID when no title is given", () => { + expect(buildInboxDeeplink("abc-123", null, { isDevBuild: false })).toBe( + "posthog-code://inbox/abc-123", + ); + expect( + buildInboxDeeplink("abc-123", undefined, { isDevBuild: false }), + ).toBe("posthog-code://inbox/abc-123"); + expect(buildInboxDeeplink("abc-123", "", { isDevBuild: false })).toBe( + "posthog-code://inbox/abc-123", + ); + }); + + it("emits `--` for runs that mix a colon with other unsafe chars", () => { + expect( + buildInboxDeeplink("abc-123", "fix(inbox): Add foo", { + isDevBuild: false, + }), + ).toBe("posthog-code://inbox/abc-123/fix-inbox--Add-foo"); + }); + + it("emits a single `-` for a colon-only run", () => { + expect( + buildInboxDeeplink("abc-123", "feat:bar", { isDevBuild: false }), + ).toBe("posthog-code://inbox/abc-123/feat-bar"); + }); + + it("omits the slug when the title slugifies to empty", () => { + expect(buildInboxDeeplink("abc-123", ":::", { isDevBuild: false })).toBe( + "posthog-code://inbox/abc-123", + ); + expect(buildInboxDeeplink("abc-123", " ", { isDevBuild: false })).toBe( + "posthog-code://inbox/abc-123", + ); + }); + + it("uses the dev scheme when isDevBuild is true", () => { + expect( + buildInboxDeeplink("abc-123", "Hello World", { isDevBuild: true }), + ).toBe("posthog-code-dev://inbox/abc-123/Hello-World"); + }); + + it("preserves URL-unreserved punctuation (- _ . ~)", () => { + expect( + buildInboxDeeplink("abc-123", "v1.2.3_final~ish", { isDevBuild: false }), + ).toBe("posthog-code://inbox/abc-123/v1.2.3_final~ish"); + }); + + it("collapses runs of unsafe punctuation into a single hyphen", () => { + expect( + buildInboxDeeplink("abc-123", "Cost $5, 50% off!", { isDevBuild: false }), + ).toBe("posthog-code://inbox/abc-123/Cost-5-50-off"); + }); + + it("folds accented Latin letters to their ASCII base", () => { + expect( + buildInboxDeeplink("abc-123", "café résumé naïve", { isDevBuild: false }), + ).toBe("posthog-code://inbox/abc-123/cafe-resume-naive"); + }); + + it("hyphenizes non-Latin scripts that have no ASCII fold", () => { + expect( + buildInboxDeeplink("abc-123", "Hello Привет world", { + isDevBuild: false, + }), + ).toBe("posthog-code://inbox/abc-123/Hello-world"); + }); +}); diff --git a/apps/code/src/shared/deeplink.ts b/apps/code/src/shared/deeplink.ts index 00a81f603d..9b0787f8d2 100644 --- a/apps/code/src/shared/deeplink.ts +++ b/apps/code/src/shared/deeplink.ts @@ -23,3 +23,38 @@ export function isPostHogCodeDeeplink( return false; } } + +/** + * Build the deep link URL for an inbox report. The optional title is slugified + * and appended as a trailing path segment for human-readable sharing; the + * receiver only reads the UUID, so the slug is purely cosmetic. + * + * Slug rules: + * - Accented Latin letters are folded to their ASCII base (`café` → `cafe`) + * via NFD decomposition + combining-mark stripping. + * - Letters, digits, and the URL-unreserved punctuation `_ . ~` are kept + * verbatim (case preserved). + * - Any run of other characters collapses to a single `-`, except runs that + * mix a colon with other unsafe chars collapse to `--`. This preserves the + * title-like break in `fix(inbox): Add foo` → `fix-inbox--Add-foo` while + * keeping standalone colons compact (`feat:bar` → `feat-bar`) and unrelated + * runs single (`Cost $5, 50% off` → `Cost-5-50-off`). + * - Leading and trailing hyphens are stripped. + */ +export function buildInboxDeeplink( + reportId: string, + title: string | null | undefined, + { isDevBuild }: { isDevBuild: boolean }, +): string { + const base = `${getDeeplinkProtocol(isDevBuild)}://inbox/${reportId}`; + const slug = title + ? title + .normalize("NFD") + .replace(/\p{M}/gu, "") + .replace(/[^a-zA-Z0-9_.~]+/g, (run) => + run.includes(":") && /[^:]/.test(run) ? "--" : "-", + ) + .replace(/^-+|-+$/g, "") + : ""; + return slug ? `${base}/${slug}` : base; +} diff --git a/apps/code/src/shared/test/setup.ts b/apps/code/src/shared/test/setup.ts index 530c377ffa..7978c69794 100644 --- a/apps/code/src/shared/test/setup.ts +++ b/apps/code/src/shared/test/setup.ts @@ -81,6 +81,36 @@ globalThis.ResizeObserver = class ResizeObserver { disconnect = vi.fn(); }; +if (typeof globalThis.PointerEvent === "undefined") { + class JsdomPointerEvent extends MouseEvent { + pointerId: number; + pointerType: string; + width: number; + height: number; + pressure: number; + tangentialPressure: number; + tiltX: number; + tiltY: number; + twist: number; + isPrimary: boolean; + + constructor(type: string, init: PointerEventInit = {}) { + super(type, init); + this.pointerId = init.pointerId ?? 0; + this.pointerType = init.pointerType ?? ""; + this.width = init.width ?? 1; + this.height = init.height ?? 1; + this.pressure = init.pressure ?? 0; + this.tangentialPressure = init.tangentialPressure ?? 0; + this.tiltX = init.tiltX ?? 0; + this.tiltY = init.tiltY ?? 0; + this.twist = init.twist ?? 0; + this.isPrimary = init.isPrimary ?? false; + } + } + globalThis.PointerEvent = JsdomPointerEvent as unknown as typeof PointerEvent; +} + HTMLCanvasElement.prototype.getContext = vi.fn(); Element.prototype.scrollIntoView = vi.fn(); diff --git a/apps/code/src/shared/types/analytics.ts b/apps/code/src/shared/types/analytics.ts index 23053a9b8b..a5e701366b 100644 --- a/apps/code/src/shared/types/analytics.ts +++ b/apps/code/src/shared/types/analytics.ts @@ -229,6 +229,22 @@ export interface AgentSessionErrorProperties { error_type: string; } +export interface CloudStreamDisconnectedProperties { + task_id: string; + run_id: string; + team_id: number; + // The error surfaced to the user (e.g. "Cloud stream disconnected", + // "Cloud run unreachable"), so give-up causes can be told apart. + error_title: string; + retryable: boolean; + // Which reconnect budget was exhausted, to separate idle Envoy cuts from + // genuine outages: transport reconnects, backend error frames, cumulative. + reconnect_attempts: number; + stream_error_attempts: number; + cumulative_reconnect_attempts: number; + was_bootstrapping: boolean; +} + // Permission events export interface PermissionRespondedProperties { task_id: string; @@ -744,6 +760,7 @@ export const ANALYTICS_EVENTS = { // Error events TASK_CREATION_FAILED: "Task creation failed", AGENT_SESSION_ERROR: "Agent session error", + CLOUD_STREAM_DISCONNECTED: "Cloud stream disconnected", // Inbox events INBOX_INTEREST_REGISTERED: "Inbox interest registered", @@ -864,6 +881,7 @@ export type EventPropertyMap = { // Error events [ANALYTICS_EVENTS.TASK_CREATION_FAILED]: TaskCreationFailedProperties; [ANALYTICS_EVENTS.AGENT_SESSION_ERROR]: AgentSessionErrorProperties; + [ANALYTICS_EVENTS.CLOUD_STREAM_DISCONNECTED]: CloudStreamDisconnectedProperties; // Inbox events [ANALYTICS_EVENTS.INBOX_INTEREST_REGISTERED]: never; diff --git a/apps/code/tests/e2e/fixtures/electron.ts b/apps/code/tests/e2e/fixtures/electron.ts index 245f3c6e5f..19f93290f9 100644 --- a/apps/code/tests/e2e/fixtures/electron.ts +++ b/apps/code/tests/e2e/fixtures/electron.ts @@ -9,6 +9,7 @@ import { function getAppPath(): string { const outDir = path.join(__dirname, "../../../out"); + const requestedArch = process.env.E2E_APP_ARCH; if (process.platform === "darwin") { const arm64Path = path.join( @@ -20,6 +21,16 @@ function getAppPath(): string { "PostHog Code-darwin-x64/PostHog Code.app/Contents/MacOS/PostHog Code", ); + if (requestedArch === "arm64") { + if (existsSync(arm64Path)) return arm64Path; + throw new Error(`No darwin-arm64 packaged app found at ${arm64Path}.`); + } + + if (requestedArch === "x64") { + if (existsSync(x64Path)) return x64Path; + throw new Error(`No darwin-x64 packaged app found at ${x64Path}.`); + } + if (existsSync(arm64Path)) return arm64Path; if (existsSync(x64Path)) return x64Path; diff --git a/apps/code/vite.main.config.mts b/apps/code/vite.main.config.mts index 596433f67d..4e0f4b0368 100644 --- a/apps/code/vite.main.config.mts +++ b/apps/code/vite.main.config.mts @@ -1,11 +1,14 @@ import { execFile, execSync } from "node:child_process"; import { + closeSync, copyFileSync, cpSync, existsSync, mkdirSync, + openSync, readdirSync, readFileSync, + readSync, statSync, } from "node:fs"; import { cp, mkdir, readdir, rm, writeFile } from "node:fs/promises"; @@ -16,6 +19,15 @@ import { promisify } from "node:util"; import { unzipSync } from "fflate"; import { defineConfig, loadEnv, type Plugin } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; +// @ts-expect-error - plain ESM helper shared with packages/agent/tsup.config.ts +import { + CLAUDE_CLI_SUPPORT_DIRS, + CLAUDE_CLI_SUPPORT_FILES, + claudeBinName, + claudeExecutableCandidates as sdkClaudeExecutableCandidates, + targetArch, + targetPlatform, +} from "../../packages/agent/build/native-binary.mjs"; import { createForceDevModeDefine, createPosthogPlugin, @@ -24,6 +36,7 @@ import { import { autoServicesPlugin } from "./vite-plugin-auto-services"; function getGitCommit(): string { + if (process.env.BUILD_COMMIT) return process.env.BUILD_COMMIT; try { return execSync("git rev-parse --short HEAD", { encoding: "utf-8" }).trim(); } catch { @@ -57,14 +70,111 @@ function fixFilenameCircularRef(): Plugin { let claudeCliCopied = false; +function verifyBinaryArch(destPath: string): void { + // Best-effort: parse the binary's magic bytes and confirm the embedded arch + // matches what we believe we're packaging for. `file(1)` is more portable + // but adds a subprocess; reading 20 bytes is enough for Mach-O / ELF / PE. + let header: Buffer; + try { + const fd = openSync(destPath, "r"); + try { + header = Buffer.alloc(20); + readSync(fd, header, 0, 20, 0); + } finally { + closeSync(fd); + } + } catch (err) { + console.warn("[copy-claude-executable] Could not inspect binary:", err); + return; + } + + const arch = targetArch(); + const platform = targetPlatform(); + const actual = detectBinaryArch(header, platform); + if (actual && actual !== arch) { + throw new Error( + `[copy-claude-executable] Architecture mismatch: copied binary is ${actual} but target is ${arch} (platform=${platform}). ` + + `Reinstall @anthropic-ai/claude-agent-sdk optional deps for the target arch, or set npm_config_arch=${arch} before building.`, + ); + } +} + +function detectBinaryArch( + header: Buffer, + platform: string, +): "arm64" | "x64" | "ia32" | null { + // Mach-O 64-bit LE: magic 0xFEEDFACF, then cputype at offset 4 (LE). + if (platform === "darwin" && header.readUInt32LE(0) === 0xfeedfacf) { + const cpuType = header.readUInt32LE(4); + if (cpuType === 0x0100000c) return "arm64"; + if (cpuType === 0x01000007) return "x64"; + } + // ELF: \x7FELF, then e_machine at offset 18 (LE). + if ( + platform === "linux" && + header[0] === 0x7f && + header[1] === 0x45 && + header[2] === 0x4c && + header[3] === 0x46 + ) { + const eMachine = header.readUInt16LE(18); + if (eMachine === 0x3e) return "x64"; + if (eMachine === 0xb7) return "arm64"; + if (eMachine === 0x03) return "ia32"; + } + // PE: MZ at 0, PE header offset at 0x3C — too long to inline; skip. + return null; +} + +function signClaudeBinary(destPath: string): void { + if (targetPlatform() !== "darwin") return; + if (process.platform !== "darwin") { + // Can't ad-hoc sign a darwin binary from a non-darwin build host; the + // resulting app won't launch on Apple Silicon. Fail loud rather than + // shipping an unrunnable bundle. + throw new Error( + "[copy-claude-executable] Cannot ad-hoc sign darwin binary from non-darwin host. Build on macOS.", + ); + } + try { + execSync(`xattr -cr "${destPath}"`, { stdio: "inherit" }); + execSync(`codesign --force --sign - "${destPath}"`, { stdio: "inherit" }); + } catch (err) { + console.warn( + "[copy-claude-executable] FAILED to ad-hoc sign binary; macOS will reject the bundled app:", + err, + ); + } +} + +function copyClaudeSupportAssets(sourcePath: string, destDir: string): void { + const sourceDir = dirname(sourcePath); + + for (const file of CLAUDE_CLI_SUPPORT_FILES) { + const source = join(sourceDir, file); + if (existsSync(source)) { + copyFileSync(source, join(destDir, file)); + } + } + + for (const dir of CLAUDE_CLI_SUPPORT_DIRS) { + const source = join(sourceDir, dir); + if (existsSync(source)) { + cpSync(source, join(destDir, dir), { recursive: true }); + } + } +} + function copyClaudeExecutable(): Plugin { return { name: "copy-claude-executable", writeBundle() { + const binName = claudeBinName(); const destDir = join(__dirname, ".vite/build/claude-cli"); + const destBinary = join(destDir, binName); // Skip re-copying on subsequent HMR rebuilds - if (claudeCliCopied && existsSync(join(destDir, "cli.js"))) { + if (claudeCliCopied && existsSync(destBinary)) { return; } @@ -72,72 +182,35 @@ function copyClaudeExecutable(): Plugin { mkdirSync(destDir, { recursive: true }); } - const candidates = [ - { - path: join(__dirname, "node_modules/@posthog/agent/dist/claude-cli"), - type: "package", - }, - { - path: join( - __dirname, - "../../node_modules/@posthog/agent/dist/claude-cli", - ), - type: "package", - }, - { - path: join(__dirname, "../../packages/agent/dist/claude-cli"), - type: "package", - }, + const packageCandidates = [ + join(__dirname, "node_modules/@posthog/agent/dist/claude-cli", binName), + join( + __dirname, + "../../node_modules/@posthog/agent/dist/claude-cli", + binName, + ), + join(__dirname, "../../packages/agent/dist/claude-cli", binName), + ...sdkClaudeExecutableCandidates(join(__dirname, "node_modules")), + ...sdkClaudeExecutableCandidates(join(__dirname, "../../node_modules")), ]; - for (const candidate of candidates) { - if ( - existsSync(join(candidate.path, "cli.js")) && - existsSync(join(candidate.path, "yoga.wasm")) - ) { - const files = ["cli.js", "package.json", "yoga.wasm"]; - for (const file of files) { - copyFileSync(join(candidate.path, file), join(destDir, file)); - } - const vendorDir = join(candidate.path, "vendor"); - if (existsSync(vendorDir)) { - cpSync(vendorDir, join(destDir, "vendor"), { recursive: true }); - } - claudeCliCopied = true; - return; - } - } - - const rootNodeModules = join(__dirname, "../../node_modules"); - const sdkDir = join(rootNodeModules, "@anthropic-ai/claude-agent-sdk"); - const yogaDir = join(rootNodeModules, "yoga-wasm-web/dist"); - - if ( - existsSync(join(sdkDir, "cli.js")) && - existsSync(join(yogaDir, "yoga.wasm")) - ) { - copyFileSync(join(sdkDir, "cli.js"), join(destDir, "cli.js")); - copyFileSync( - join(sdkDir, "package.json"), - join(destDir, "package.json"), - ); - copyFileSync(join(yogaDir, "yoga.wasm"), join(destDir, "yoga.wasm")); - const vendorDir = join(sdkDir, "vendor"); - if (existsSync(vendorDir)) { - cpSync(vendorDir, join(destDir, "vendor"), { recursive: true }); - } - console.log( - "Assembled Claude CLI from workspace sources in claude-cli/ subdirectory", + const source = packageCandidates.find((p: string) => existsSync(p)); + if (!source) { + console.warn( + `[copy-claude-executable] FAILED to find native Claude binary for ${targetPlatform()}-${targetArch()}. Agent execution may fail.`, ); - claudeCliCopied = true; + console.warn(`Checked paths:\n ${packageCandidates.join("\n ")}`); return; } - console.warn( - "[copy-claude-executable] FAILED to find Claude CLI artifacts. Agent execution may fail.", - ); - console.warn("Checked paths:", candidates.map((c) => c.path).join(", ")); - console.warn("Checked workspace sources:", sdkDir); + copyFileSync(source, destBinary); + if (targetPlatform() !== "win32") { + execSync(`chmod +x "${destBinary}"`); + } + copyClaudeSupportAssets(source, destDir); + verifyBinaryArch(destBinary); + signClaudeBinary(destBinary); + claudeCliCopied = true; }, }; } diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 64ede25131..344a4fe189 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -21,6 +21,8 @@ import { useNetworkStatus } from "@/hooks/useNetworkStatus"; import { POSTHOG_API_KEY, POSTHOG_OPTIONS, + useIdentifyUser, + useRegisterAppVersion, useScreenTracking, } from "@/lib/posthog"; import { queryClient } from "@/lib/queryClient"; @@ -36,6 +38,8 @@ function RootLayoutNav({ isConnected }: RootLayoutNavProps) { const pathname = usePathname(); useScreenTracking(); + useIdentifyUser(); + useRegisterAppVersion(); useEffect(() => { initializeAuth(); @@ -104,10 +108,12 @@ function RootLayoutNav({ isConnected }: RootLayoutNavProps) { /> {/* Inbox report detail - modal presentation, no native header - (the in-content title block is the canonical header). Path mirrors - the desktop app's `posthog-code://inbox/` deep-link shape. */} + (the in-content title block is the canonical header). Catch-all + segment so the route also tolerates the cosmetic slug suffix on + shared links: `posthog://inbox/` and + `posthog://inbox//` both resolve here. */} diff --git a/apps/mobile/src/app/inbox/[id].tsx b/apps/mobile/src/app/inbox/[...id].tsx similarity index 87% rename from apps/mobile/src/app/inbox/[id].tsx rename to apps/mobile/src/app/inbox/[...id].tsx index 8d8c243c5a..2438b71813 100644 --- a/apps/mobile/src/app/inbox/[id].tsx +++ b/apps/mobile/src/app/inbox/[...id].tsx @@ -5,12 +5,14 @@ import { useLocalSearchParams, useRouter } from "expo-router"; import { CaretDown, CaretRight, + ChatCircle, Lightning, Play, Plus, ThumbsDown, Warning, } from "phosphor-react-native"; +import { usePostHog } from "posthog-react-native"; import { useCallback, useEffect, useMemo, useState } from "react"; import { ActivityIndicator, @@ -23,6 +25,7 @@ import { import { useSafeAreaInsets } from "react-native-safe-area-context"; import { MarkdownText } from "@/features/chat/components/MarkdownText"; import { getReportRepository } from "@/features/inbox/api"; +import { DiscussReportSheet } from "@/features/inbox/components/DiscussReportSheet"; import { type DismissReportResult, DismissReportSheet, @@ -119,13 +122,21 @@ function ActionabilityBadge({ value }: { value: string }) { } export default function ReportDetailScreen() { - const { id: reportId } = useLocalSearchParams<{ id: string }>(); + // Catch-all route: `id` arrives as string[] for `/inbox//` and + // we only read the first segment (the UUID). The slug is purely cosmetic; + // receivers ignore everything past the UUID, matching the desktop contract + // in `apps/code/src/shared/deeplink.ts`. Expo-router can hand us either a + // string or string[] depending on the URL shape, so tolerate both. + const { id: idParam } = useLocalSearchParams<{ id: string | string[] }>(); + const reportId = Array.isArray(idParam) ? idParam[0] : idParam; const router = useRouter(); const themeColors = useThemeColors(); const insets = useSafeAreaInsets(); + const posthog = usePostHog(); const { data: report, isLoading, error } = useInboxReport(reportId ?? null); const [reportRepo, setReportRepo] = useState(null); const [dismissOpen, setDismissOpen] = useState(false); + const [discussOpen, setDiscussOpen] = useState(false); const [signalsExpanded, setSignalsExpanded] = useState(false); const artefactsQuery = useInboxReportArtefacts(reportId ?? null); @@ -297,6 +308,34 @@ export default function ReportDetailScreen() { [router, report, tracker], ); + const handleDiscussSubmit = useCallback( + ({ prompt, question }: { prompt: string; question: string }) => { + setDiscussOpen(false); + if (!report) return; + posthog?.capture("Inbox report action", { + report_id: report.id, + report_title: report.title ?? null, + action_type: "discuss", + surface: "detail_pane", + is_bulk: false, + bulk_size: 1, + has_question: question.length > 0, + ...(question.length > 0 + ? { question_text: question.slice(0, 500) } + : {}), + }); + router.push({ + pathname: "/task", + params: { + prompt, + ...(reportRepo ? { repo: reportRepo } : {}), + signalReport: report.id, + }, + }); + }, + [report, router, reportRepo, posthog], + ); + if (error) { return ( @@ -472,14 +511,14 @@ export default function ReportDetailScreen() { setDismissOpen(true)} accessibilityLabel="Dismiss report" - className="flex-row items-center gap-2 rounded-full border border-gray-6 bg-background px-5 py-3.5 shadow-lg active:opacity-80" + className="flex-row items-center gap-2 rounded-full border border-gray-6 bg-background px-4 py-3.5 shadow-lg active:opacity-80" > @@ -487,10 +526,24 @@ export default function ReportDetailScreen() { + { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + setDiscussOpen(true); + }} + accessibilityLabel="Discuss report" + className="flex-row items-center gap-2 rounded-full border border-gray-6 bg-background px-4 py-3.5 shadow-lg active:opacity-80" + > + + + Discuss + + + {canStartTask && ( {isAwaitingInput ? ( @@ -511,6 +564,14 @@ export default function ReportDetailScreen() { onClose={() => setDismissOpen(false)} onDismissed={handleDismissed} /> + + setDiscussOpen(false)} + onSubmit={handleDiscussSubmit} + /> ); } diff --git a/apps/mobile/src/app/settings/index.tsx b/apps/mobile/src/app/settings/index.tsx index 076a2b91a6..0215389533 100644 --- a/apps/mobile/src/app/settings/index.tsx +++ b/apps/mobile/src/app/settings/index.tsx @@ -8,6 +8,7 @@ import { useDismissedReportsStore } from "@/features/inbox/stores/dismissedRepor import { usePushTokenStore } from "@/features/notifications/stores/pushTokenStore"; import { type CompletionSound, + type DefaultReasoningEffort, type InitialTaskMode, type ThemePreference, usePreferencesStore, @@ -59,6 +60,23 @@ const TASK_MODE_OPTIONS = [ }, ] as const; +const REASONING_EFFORT_OPTIONS: ReadonlyArray<{ + value: DefaultReasoningEffort; + label: string; + description?: string; +}> = [ + { + value: "last_used", + label: "Last used", + description: "Remember the effort level you picked last time", + }, + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High" }, + { value: "xhigh", label: "Extra High" }, + { value: "max", label: "Max" }, +]; + function themeLabel(theme: ThemePreference): string { return THEME_OPTIONS.find((o) => o.value === theme)?.label ?? "Match system"; } @@ -78,6 +96,13 @@ function taskModeLabel(mode: InitialTaskMode): string { return TASK_MODE_OPTIONS.find((o) => o.value === mode)?.label ?? "Plan"; } +function reasoningEffortLabel(effort: DefaultReasoningEffort): string { + return ( + REASONING_EFFORT_OPTIONS.find((o) => o.value === effort)?.label ?? + "Last used" + ); +} + export default function SettingsScreen() { const themeColors = useThemeColors(); const { insets, bottom } = useScreenInsets(); @@ -113,6 +138,12 @@ export default function SettingsScreen() { const setDefaultInitialTaskMode = usePreferencesStore( (s) => s.setDefaultInitialTaskMode, ); + const defaultReasoningEffort = usePreferencesStore( + (s) => s.defaultReasoningEffort, + ); + const setDefaultReasoningEffort = usePreferencesStore( + (s) => s.setDefaultReasoningEffort, + ); const decidedCount = useDismissedReportsStore( (s) => s.dismissedIds.length + s.acceptedIds.length, ); @@ -122,6 +153,8 @@ export default function SettingsScreen() { const [soundSheetOpen, setSoundSheetOpen] = useState(false); const [volumeSheetOpen, setVolumeSheetOpen] = useState(false); const [taskModeSheetOpen, setTaskModeSheetOpen] = useState(false); + const [reasoningEffortSheetOpen, setReasoningEffortSheetOpen] = + useState(false); const [projectSheetOpen, setProjectSheetOpen] = useState(false); // The selected project's name. Prefer the names fetched for the scoped teams @@ -272,7 +305,6 @@ export default function SettingsScreen() { label="Initial task mode" description="What mode new tasks start in" onPress={() => setTaskModeSheetOpen(true)} - showDivider={false} rightSlot={ <> @@ -282,6 +314,20 @@ export default function SettingsScreen() { } /> + setReasoningEffortSheetOpen(true)} + showDivider={false} + rightSlot={ + <> + + {reasoningEffortLabel(defaultReasoningEffort)} + + + + } + /> {/* Integrations */} @@ -508,6 +554,21 @@ export default function SettingsScreen() { }))} /> + + setDefaultReasoningEffort(value as DefaultReasoningEffort) + } + onClose={() => setReasoningEffortSheetOpen(false)} + options={REASONING_EFFORT_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + description: option.description, + }))} + /> + = setAt` + // qualify so a text-identical historical turn (e.g. resubmitting + // "Continue") doesn't drop the echo before the real copy lands. + useEffect(() => { + if (!taskId || !optimisticPrompt) return; + const matched = session?.events.some( + (e) => + e.type === "session_update" && + e.notification?.update?.sessionUpdate === "user_message_chunk" && + e.notification.update.content?.text === optimisticPrompt.promptText && + (e.ts ?? 0) >= optimisticPrompt.setAt, + ); + if (matched) { + pendingTaskPromptStoreApi.clear(taskId); + } + }, [taskId, optimisticPrompt, session?.events]); + // Per-task composer pill values. Persisted in taskStore so reopening the // task keeps the user's choices; defaults fall back to the same constants // the new-task composer uses. @@ -222,6 +251,19 @@ export default function TaskDetailScreen() { const handleSendAfterTerminal = useCallback( async (text: string, attachments: PendingAttachment[]) => { if (!taskId || !task) return; + // Optimistically echo into the chat before tearing down the old session + // and waiting for the resume run's SSE stream to come up. + const echoAttachments = attachments.map((a) => ({ + kind: a.kind, + uri: a.uri, + fileName: a.fileName, + mimeType: a.mimeType, + })); + pendingTaskPromptStoreApi.set(taskId, { + promptText: text, + attachments: echoAttachments.length > 0 ? echoAttachments : undefined, + setAt: Date.now(), + }); try { setRetrying(true); disconnectFromTask(taskId); @@ -247,6 +289,7 @@ export default function TaskDetailScreen() { updateTaskInCache(updatedTask); } catch (err) { log.error("Failed to send after terminal", err); + pendingTaskPromptStoreApi.clear(taskId); setRetrying(false); Alert.alert( "Failed to send", @@ -313,6 +356,7 @@ export default function TaskDetailScreen() { if (!taskId) return; setComposerConfig(taskId, { reasoning: value }); setConfigOption(taskId, "effort", value).catch(() => {}); + usePreferencesStore.getState().setLastUsedReasoningEffort(value); }, [taskId, setComposerConfig, setConfigOption], ); @@ -394,7 +438,10 @@ export default function TaskDetailScreen() { !!session && session.status === "connecting" && session.events.length === 0; - const showLoading = loading || isHistoryLoading; + // Suppress the full-screen overlay when we have an optimistic prompt to + // show — the user just submitted and seeing their own text + a connecting + // indicator is friendlier than a blank spinner. + const showLoading = (loading || isHistoryLoading) && !optimisticPrompt; const showAutomationContext = fromAutomation === "1" || task?.origin_product === "automation"; const automationContextLabel = @@ -477,6 +524,15 @@ export default function TaskDetailScreen() { } onOpenTask={handleOpenTask} onSendPermissionResponse={handleSendPermissionResponse} + optimisticUserMessage={ + optimisticPrompt + ? { + text: optimisticPrompt.promptText, + attachments: optimisticPrompt.attachments, + setAt: optimisticPrompt.setAt, + } + : undefined + } contentContainerStyle={{ paddingTop: 8, paddingBottom: diff --git a/apps/mobile/src/app/task/index.tsx b/apps/mobile/src/app/task/index.tsx index 62aab442ec..5bd3d0706e 100644 --- a/apps/mobile/src/app/task/index.tsx +++ b/apps/mobile/src/app/task/index.tsx @@ -62,6 +62,10 @@ import { Pill } from "@/features/tasks/composer/Pill"; import { RepositoryPickerInline } from "@/features/tasks/composer/RepositoryPickerInline"; import { SelectSheet } from "@/features/tasks/composer/SelectSheet"; import { useUserIntegrations } from "@/features/tasks/hooks/useUserIntegrations"; +import { + generatePendingTaskKey, + pendingTaskPromptStoreApi, +} from "@/features/tasks/stores/pendingTaskPromptStore"; import { useTaskStore } from "@/features/tasks/stores/taskStore"; import type { CreateTaskOptions, @@ -180,8 +184,16 @@ export default function NewTaskScreen() { return DEFAULT_EXECUTION_MODE; }); const [model, setModel] = useState(DEFAULT_MODEL); - const [reasoning, setReasoning] = - useState(DEFAULT_REASONING); + 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; + }); const [creating, setCreating] = useState(false); const [repoSheetOpen, setRepoSheetOpen] = useState(false); const [modeSheetOpen, setModeSheetOpen] = useState(false); @@ -255,8 +267,28 @@ export default function NewTaskScreen() { setCreating(true); + // Echo the prompt into the chat thread the moment the user taps send. + // The key is transient until `createTask` returns the real task id, at + // which point we `move` it so the detail screen can pick it up. + const pendingKey = generatePendingTaskKey(); + const trimmedPrompt = prompt.trim(); + const echoAttachments = attachments.map((a) => ({ + kind: a.kind, + uri: a.uri, + fileName: a.fileName, + mimeType: a.mimeType, + })); + pendingTaskPromptStoreApi.set(pendingKey, { + promptText: trimmedPrompt, + attachments: echoAttachments.length > 0 ? echoAttachments : undefined, + setAt: Date.now(), + }); + + // Tracks where the optimistic echo currently lives so the catch block + // can clear the correct key regardless of how far the flow got. + let currentPendingKey = pendingKey; + try { - const trimmedPrompt = prompt.trim(); // The task description is plain text (it shows up as the task title and // in metadata). Attachments only enter the agent prompt via the cloud // payload below. @@ -284,6 +316,9 @@ export default function NewTaskScreen() { : {}), } as CreateTaskOptions); + pendingTaskPromptStoreApi.move(pendingKey, task.id); + currentPendingKey = task.id; + const pendingUserMessage = attachments.length > 0 ? serializeCloudPrompt( @@ -310,6 +345,7 @@ export default function NewTaskScreen() { router.replace(`/task/${task.id}`); } catch (creationError) { log.error("Failed to create task", creationError); + pendingTaskPromptStoreApi.clear(currentPendingKey); } finally { setCreating(false); } @@ -681,7 +717,11 @@ export default function NewTaskScreen() { open={reasoningSheetOpen} title="Reasoning" value={reasoning} - onChange={(value) => setReasoning(value as ReasoningEffort)} + onChange={(value) => { + const next = value as ReasoningEffort; + setReasoning(next); + usePreferencesStore.getState().setLastUsedReasoningEffort(next); + }} onClose={() => setReasoningSheetOpen(false)} options={REASONING_LEVELS.map((reasoningLevel) => ({ value: reasoningLevel.value, diff --git a/apps/mobile/src/features/auth/hooks/useProjectsQuery.ts b/apps/mobile/src/features/auth/hooks/useProjectsQuery.ts index 999d9a26fc..43a1aac114 100644 --- a/apps/mobile/src/features/auth/hooks/useProjectsQuery.ts +++ b/apps/mobile/src/features/auth/hooks/useProjectsQuery.ts @@ -1,4 +1,5 @@ import { useQuery } from "@tanstack/react-query"; +import { authedFetch, getBaseUrl } from "@/lib/api"; import { useAuthStore } from "../stores/authStore"; export interface ProjectSummary { @@ -14,21 +15,19 @@ export interface ProjectSummary { * rather than dropping the project from the list. */ export function useProjectsQuery() { - const { cloudRegion, oauthAccessToken, scopedTeams, getCloudUrlFromRegion } = - useAuthStore(); + const { cloudRegion, oauthAccessToken, scopedTeams } = useAuthStore(); return useQuery({ queryKey: ["projects", cloudRegion, scopedTeams], queryFn: async (): Promise => { - if (!cloudRegion) throw new Error("No cloud region"); - const baseUrl = getCloudUrlFromRegion(cloudRegion); + const baseUrl = getBaseUrl(); return Promise.all( scopedTeams.map(async (id): Promise => { try { - const response = await fetch(`${baseUrl}/api/projects/${id}/`, { - headers: { Authorization: `Bearer ${oauthAccessToken}` }, - }); + const response = await authedFetch( + `${baseUrl}/api/projects/${id}/`, + ); if (!response.ok) return { id, name: `Project ${id}` }; const data: { name?: string } = await response.json(); return { id, name: data.name || `Project ${id}` }; diff --git a/apps/mobile/src/features/auth/hooks/useUserQuery.ts b/apps/mobile/src/features/auth/hooks/useUserQuery.ts index 5640928548..167d8ab45e 100644 --- a/apps/mobile/src/features/auth/hooks/useUserQuery.ts +++ b/apps/mobile/src/features/auth/hooks/useUserQuery.ts @@ -1,4 +1,5 @@ import { useQuery } from "@tanstack/react-query"; +import { authedFetch, getBaseUrl } from "@/lib/api"; import { useAuthStore } from "../stores/authStore"; export interface UserData { @@ -19,19 +20,12 @@ export interface UserData { } export function useUserQuery() { - const { cloudRegion, oauthAccessToken, getCloudUrlFromRegion } = - useAuthStore(); + const { cloudRegion, oauthAccessToken } = useAuthStore(); return useQuery({ queryKey: ["user", "me"], queryFn: async (): Promise => { - if (!cloudRegion) throw new Error("No cloud region"); - const baseUrl = getCloudUrlFromRegion(cloudRegion); - const response = await fetch(`${baseUrl}/api/users/@me/`, { - headers: { - Authorization: `Bearer ${oauthAccessToken}`, - }, - }); + const response = await authedFetch(`${getBaseUrl()}/api/users/@me/`); if (!response.ok) { throw new Error(`Failed to fetch user: ${response.statusText}`); diff --git a/apps/mobile/src/features/inbox/api.ts b/apps/mobile/src/features/inbox/api.ts index 7838f6dc96..880253e15f 100644 --- a/apps/mobile/src/features/inbox/api.ts +++ b/apps/mobile/src/features/inbox/api.ts @@ -1,6 +1,5 @@ -import { fetch } from "expo/fetch"; import { HttpError } from "@/features/tasks/api"; -import { getBaseUrl, getHeaders, getProjectId } from "@/lib/api"; +import { authedFetch, getBaseUrl, getProjectId } from "@/lib/api"; import { logger } from "@/lib/logger"; import type { DismissalReasonOptionValue } from "./constants"; @@ -24,7 +23,6 @@ export async function getSignalReports( ): Promise { const baseUrl = getBaseUrl(); const projectId = getProjectId(); - const headers = getHeaders(); const url = new URL(`${baseUrl}/api/projects/${projectId}/signals/reports/`); @@ -47,7 +45,7 @@ export async function getSignalReports( url.searchParams.set("suggested_reviewers", params.suggested_reviewers); } - const response = await fetch(url.toString(), { headers }); + const response = await authedFetch(url.toString()); if (!response.ok) { throw new HttpError( @@ -69,11 +67,9 @@ export async function getSignalReport( ): Promise { const baseUrl = getBaseUrl(); const projectId = getProjectId(); - const headers = getHeaders(); - const response = await fetch( + const response = await authedFetch( `${baseUrl}/api/projects/${projectId}/signals/reports/${reportId}/`, - { headers }, ); if (response.status === 404 || response.status === 403) { @@ -94,11 +90,9 @@ export async function getSignalReport( export async function getSignalProcessingState(): Promise { const baseUrl = getBaseUrl(); const projectId = getProjectId(); - const headers = getHeaders(); - const response = await fetch( + const response = await authedFetch( `${baseUrl}/api/projects/${projectId}/signals/processing_state/`, - { headers }, ); if (!response.ok) { @@ -117,7 +111,6 @@ export async function getAvailableSuggestedReviewers( ): Promise { const baseUrl = getBaseUrl(); const projectId = getProjectId(); - const headers = getHeaders(); const url = new URL( `${baseUrl}/api/projects/${projectId}/signals/reports/available_reviewers/`, @@ -127,7 +120,7 @@ export async function getAvailableSuggestedReviewers( url.searchParams.set("query", query.trim()); } - const response = await fetch(url.toString(), { headers }); + const response = await authedFetch(url.toString()); if (!response.ok) { throw new HttpError( @@ -160,11 +153,9 @@ export async function getSignalReportTasks( ): Promise { const baseUrl = getBaseUrl(); const projectId = getProjectId(); - const headers = getHeaders(); - const response = await fetch( + const response = await authedFetch( `${baseUrl}/api/projects/${projectId}/signals/reports/${reportId}/tasks/`, - { headers }, ); if (!response.ok) { @@ -184,11 +175,9 @@ export async function getSignalReportArtefacts( ): Promise { const baseUrl = getBaseUrl(); const projectId = getProjectId(); - const headers = getHeaders(); - const response = await fetch( + const response = await authedFetch( `${baseUrl}/api/projects/${projectId}/signals/reports/${reportId}/artefacts/`, - { headers }, ); if (!response.ok) { @@ -211,11 +200,9 @@ export async function getSignalReportSignals( ): Promise { const baseUrl = getBaseUrl(); const projectId = getProjectId(); - const headers = getHeaders(); - const response = await fetch( + const response = await authedFetch( `${baseUrl}/api/projects/${projectId}/signals/reports/${reportId}/signals/`, - { headers }, ); if (!response.ok) { @@ -268,13 +255,11 @@ export async function dismissSignalReport( ): Promise { const baseUrl = getBaseUrl(); const projectId = getProjectId(); - const headers = getHeaders(); - const response = await fetch( + const response = await authedFetch( `${baseUrl}/api/projects/${projectId}/signals/reports/${reportId}/state/`, { method: "POST", - headers: { ...headers, "Content-Type": "application/json" }, body: JSON.stringify({ state: "suppressed", dismissal_reason: input.reason, diff --git a/apps/mobile/src/features/inbox/components/DiscussReportSheet.tsx b/apps/mobile/src/features/inbox/components/DiscussReportSheet.tsx new file mode 100644 index 0000000000..350bf194ea --- /dev/null +++ b/apps/mobile/src/features/inbox/components/DiscussReportSheet.tsx @@ -0,0 +1,99 @@ +import { Text } from "@components/text"; +import { buildDiscussReportPrompt } from "@posthog/shared"; +import * as Haptics from "expo-haptics"; +import { ChatCircle } from "phosphor-react-native"; +import { useEffect, useState } from "react"; +import { + KeyboardAvoidingView, + Platform, + Pressable, + TextInput, + View, +} from "react-native"; +import { SheetContainer } from "@/components/SheetContainer"; +import { inboxReportShareUrl } from "@/lib/deep-links"; +import { useThemeColors } from "@/lib/theme"; + +interface DiscussReportSheetProps { + visible: boolean; + reportId: string; + reportTitle?: string | null; + onClose: () => void; + onSubmit: (params: { prompt: string; question: string }) => void; +} + +export function DiscussReportSheet({ + visible, + reportId, + reportTitle, + onClose, + onSubmit, +}: DiscussReportSheetProps) { + const themeColors = useThemeColors(); + const [question, setQuestion] = useState(""); + + useEffect(() => { + if (visible) setQuestion(""); + }, [visible]); + + const handleSubmit = () => { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + const trimmed = question.trim(); + const reportLink = inboxReportShareUrl(reportId, reportTitle); + const prompt = buildDiscussReportPrompt({ + reportId, + reportLink, + question: trimmed || undefined, + }); + onSubmit({ prompt, question: trimmed }); + }; + + return ( + + + + + Discuss this report + + + Ask a question, or leave it blank for a brief readout from the + agent. + + + + + Cancel + + + + + Discuss + + + + + + + ); +} diff --git a/apps/mobile/src/features/inbox/components/SignalCard.tsx b/apps/mobile/src/features/inbox/components/SignalCard.tsx index 6ed9845419..ba8521a566 100644 --- a/apps/mobile/src/features/inbox/components/SignalCard.tsx +++ b/apps/mobile/src/features/inbox/components/SignalCard.tsx @@ -16,6 +16,7 @@ import { import { useState } from "react"; import { Linking, Pressable, View } from "react-native"; import { MarkdownText } from "@/features/chat/components/MarkdownText"; +import { formatRelativeTime } from "@/lib/format"; import { useThemeColors } from "@/lib/theme"; import type { Signal, SignalFindingContent } from "../types"; @@ -207,6 +208,9 @@ export function SignalCard({ signal, finding }: SignalCardProps) { const externalUrl = issueUrl ?? ticketUrl ?? null; + const timestampMs = signal.timestamp ? Date.parse(signal.timestamp) : NaN; + const hasTimestamp = !Number.isNaN(timestampMs) && timestampMs <= Date.now(); + return ( {/* Header */} @@ -215,9 +219,18 @@ export function SignalCard({ signal, finding }: SignalCardProps) { product={signal.source_product} color={themeColors.gray[10]} /> - + {sourceLine(signal)} + + {hasTimestamp && ( + + {formatRelativeTime(timestampMs)} + + )} {verified !== undefined && } diff --git a/apps/mobile/src/features/inbox/components/TinderView.tsx b/apps/mobile/src/features/inbox/components/TinderView.tsx index 6c0430d883..5f8928555a 100644 --- a/apps/mobile/src/features/inbox/components/TinderView.tsx +++ b/apps/mobile/src/features/inbox/components/TinderView.tsx @@ -17,6 +17,7 @@ import { } from "react-native-safe-area-context"; import { MarkdownText } from "@/features/chat/components/MarkdownText"; import { createTask, runTaskInCloud } from "@/features/tasks/api"; +import { DEFAULT_MODEL } from "@/features/tasks/composer/options"; import type { CreateTaskOptions, RepositoryOption, @@ -251,7 +252,7 @@ export function TinderView({ await runTaskInCloud(task.id, { pendingUserMessage: prompt, runtimeAdapter: "claude", - model: "claude-opus-4-7", + model: DEFAULT_MODEL, initialPermissionMode: "plan", runSource: "signal_report", signalReportId: report.id, diff --git a/apps/mobile/src/features/mcp/api.ts b/apps/mobile/src/features/mcp/api.ts index ee783827b5..f7fa0a828c 100644 --- a/apps/mobile/src/features/mcp/api.ts +++ b/apps/mobile/src/features/mcp/api.ts @@ -1,5 +1,4 @@ -import { fetch } from "expo/fetch"; -import { getBaseUrl, getHeaders, getProjectId } from "@/lib/api"; +import { authedFetch, getBaseUrl, getProjectId } from "@/lib/api"; import type { InstallCustomMcpServerOptions, InstallMcpTemplateOptions, @@ -37,9 +36,8 @@ export async function getMcpRecommendedServers(): Promise< > { const base = getBaseUrl(); const projectId = getProjectId(); - const response = await fetch( + const response = await authedFetch( `${base}/api/environments/${projectId}/mcp_servers/`, - { headers: getHeaders() }, ); const data = await readJsonOrThrow< McpRecommendedServer[] | { results?: McpRecommendedServer[] } @@ -51,7 +49,7 @@ export async function getMcpRecommendedServers(): Promise< export async function getMcpServerInstallations(): Promise< McpServerInstallation[] > { - const response = await fetch(`${mcpBaseUrl()}/`, { headers: getHeaders() }); + const response = await authedFetch(`${mcpBaseUrl()}/`); const data = await readJsonOrThrow< McpServerInstallation[] | { results?: McpServerInstallation[] } >(response, "Failed to fetch MCP server installations"); @@ -62,9 +60,8 @@ export async function getMcpServerInstallations(): Promise< export async function installCustomMcpServer( options: InstallCustomMcpServerOptions, ): Promise { - const response = await fetch(`${mcpBaseUrl()}/install_custom/`, { + const response = await authedFetch(`${mcpBaseUrl()}/install_custom/`, { method: "POST", - headers: getHeaders(), body: JSON.stringify(options), }); return readJsonOrThrow( @@ -77,9 +74,8 @@ export async function installCustomMcpServer( export async function installMcpTemplate( options: InstallMcpTemplateOptions, ): Promise { - const response = await fetch(`${mcpBaseUrl()}/install_template/`, { + const response = await authedFetch(`${mcpBaseUrl()}/install_template/`, { method: "POST", - headers: getHeaders(), body: JSON.stringify(options), }); return readJsonOrThrow( @@ -93,9 +89,8 @@ export async function updateMcpServerInstallation( installationId: string, updates: UpdateMcpServerInstallationOptions, ): Promise { - const response = await fetch(`${mcpBaseUrl()}/${installationId}/`, { + const response = await authedFetch(`${mcpBaseUrl()}/${installationId}/`, { method: "PATCH", - headers: getHeaders(), body: JSON.stringify(updates), }); return readJsonOrThrow( @@ -108,9 +103,8 @@ export async function updateMcpServerInstallation( export async function uninstallMcpServer( installationId: string, ): Promise { - const response = await fetch(`${mcpBaseUrl()}/${installationId}/`, { + const response = await authedFetch(`${mcpBaseUrl()}/${installationId}/`, { method: "DELETE", - headers: getHeaders(), }); if (!response.ok && response.status !== 204) { throw new Error(`Failed to uninstall MCP server: ${response.statusText}`); @@ -131,9 +125,8 @@ export async function authorizeMcpInstallation(options: { if (options.posthog_code_callback_url) { params.set("posthog_code_callback_url", options.posthog_code_callback_url); } - const response = await fetch( + const response = await authedFetch( `${mcpBaseUrl()}/authorize/?${params.toString()}`, - { headers: getHeaders() }, ); return readJsonOrThrow( response, @@ -149,9 +142,8 @@ export async function getMcpInstallationTools( const params = new URLSearchParams(); if (options.includeRemoved) params.set("include_removed", "1"); const query = params.toString(); - const response = await fetch( + const response = await authedFetch( `${mcpBaseUrl()}/${installationId}/tools/${query ? `?${query}` : ""}`, - { headers: getHeaders() }, ); const data = await readJsonOrThrow< McpInstallationTool[] | { results?: McpInstallationTool[] } @@ -165,11 +157,10 @@ export async function updateMcpToolApproval( toolName: string, approval_state: McpApprovalState, ): Promise { - const response = await fetch( + const response = await authedFetch( `${mcpBaseUrl()}/${installationId}/tools/${encodeURIComponent(toolName)}/`, { method: "PATCH", - headers: getHeaders(), body: JSON.stringify({ approval_state }), }, ); @@ -183,9 +174,9 @@ export async function updateMcpToolApproval( export async function refreshMcpInstallationTools( installationId: string, ): Promise { - const response = await fetch( + const response = await authedFetch( `${mcpBaseUrl()}/${installationId}/tools/refresh/`, - { method: "POST", headers: getHeaders() }, + { method: "POST" }, ); const data = await readJsonOrThrow< McpInstallationTool[] | { results?: McpInstallationTool[] } diff --git a/apps/mobile/src/features/preferences/stores/preferencesStore.test.ts b/apps/mobile/src/features/preferences/stores/preferencesStore.test.ts new file mode 100644 index 0000000000..cdd9071887 --- /dev/null +++ b/apps/mobile/src/features/preferences/stores/preferencesStore.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { usePreferencesStore } from "./preferencesStore"; + +const INITIAL_STATE = usePreferencesStore.getState(); + +beforeEach(() => { + // Reset to the store's defined defaults between tests so persisted state from + // earlier cases doesn't leak in. + usePreferencesStore.setState(INITIAL_STATE, true); +}); + +describe("preferencesStore reasoning effort", () => { + it("defaults defaultReasoningEffort to last_used", () => { + expect(usePreferencesStore.getState().defaultReasoningEffort).toBe( + "last_used", + ); + }); + + it("defaults lastUsedReasoningEffort to high", () => { + expect(usePreferencesStore.getState().lastUsedReasoningEffort).toBe("high"); + }); + + it.each(["low", "medium", "high", "xhigh", "max", "last_used"] as const)( + "updates defaultReasoningEffort to %s via setter", + (effort) => { + usePreferencesStore.getState().setDefaultReasoningEffort(effort); + expect(usePreferencesStore.getState().defaultReasoningEffort).toBe( + effort, + ); + }, + ); + + it.each(["low", "medium", "high", "xhigh", "max"] as const)( + "updates lastUsedReasoningEffort to %s via setter", + (effort) => { + usePreferencesStore.getState().setLastUsedReasoningEffort(effort); + expect(usePreferencesStore.getState().lastUsedReasoningEffort).toBe( + effort, + ); + }, + ); + + it("keeps lastUsedReasoningEffort independent of defaultReasoningEffort", () => { + usePreferencesStore.getState().setDefaultReasoningEffort("low"); + usePreferencesStore.getState().setLastUsedReasoningEffort("max"); + + const state = usePreferencesStore.getState(); + expect(state.defaultReasoningEffort).toBe("low"); + expect(state.lastUsedReasoningEffort).toBe("max"); + }); +}); diff --git a/apps/mobile/src/features/preferences/stores/preferencesStore.ts b/apps/mobile/src/features/preferences/stores/preferencesStore.ts index bdacce0319..440c824358 100644 --- a/apps/mobile/src/features/preferences/stores/preferencesStore.ts +++ b/apps/mobile/src/features/preferences/stores/preferencesStore.ts @@ -15,6 +15,14 @@ export type CompletionSound = export type InitialTaskMode = "plan" | "last_used"; +export type DefaultReasoningEffort = + | "low" + | "medium" + | "high" + | "xhigh" + | "max" + | "last_used"; + interface PreferencesState { pingsEnabled: boolean; setPingsEnabled: (enabled: boolean) => void; @@ -35,6 +43,13 @@ interface PreferencesState { * `defaultInitialTaskMode === "last_used"` can pre-fill it next time. */ lastNewTaskMode: string; setLastNewTaskMode: (mode: string) => void; + + defaultReasoningEffort: DefaultReasoningEffort; + setDefaultReasoningEffort: (effort: DefaultReasoningEffort) => void; + /** Most recent reasoning effort the user picked. Persisted so + * `defaultReasoningEffort === "last_used"` can pre-fill it next time. */ + lastUsedReasoningEffort: string; + setLastUsedReasoningEffort: (effort: string) => void; } export const usePreferencesStore = create()( @@ -62,6 +77,13 @@ export const usePreferencesStore = create()( set({ defaultInitialTaskMode: mode }), lastNewTaskMode: "plan", setLastNewTaskMode: (mode) => set({ lastNewTaskMode: mode }), + + defaultReasoningEffort: "last_used", + setDefaultReasoningEffort: (effort) => + set({ defaultReasoningEffort: effort }), + lastUsedReasoningEffort: "high", + setLastUsedReasoningEffort: (effort) => + set({ lastUsedReasoningEffort: effort }), }), { name: "posthog-preferences", @@ -74,6 +96,8 @@ export const usePreferencesStore = create()( completionVolume: state.completionVolume, defaultInitialTaskMode: state.defaultInitialTaskMode, lastNewTaskMode: state.lastNewTaskMode, + defaultReasoningEffort: state.defaultReasoningEffort, + lastUsedReasoningEffort: state.lastUsedReasoningEffort, }), }, ), diff --git a/apps/mobile/src/features/tasks/api.automations.test.ts b/apps/mobile/src/features/tasks/api.automations.test.ts index ea9a06834d..c4390a5d14 100644 --- a/apps/mobile/src/features/tasks/api.automations.test.ts +++ b/apps/mobile/src/features/tasks/api.automations.test.ts @@ -10,11 +10,16 @@ vi.mock("expo/fetch", () => ({ vi.mock("@/lib/api", () => ({ getBaseUrl: () => "https://app.posthog.test", - getHeaders: () => ({ - Authorization: "Bearer token", - "Content-Type": "application/json", - }), getProjectId: () => 42, + authedFetch: (url: string, init?: RequestInit) => + mockFetch(url, { + ...init, + headers: { + Authorization: "Bearer token", + "Content-Type": "application/json", + ...((init?.headers as Record | undefined) ?? {}), + }, + }), })); import { diff --git a/apps/mobile/src/features/tasks/api.ts b/apps/mobile/src/features/tasks/api.ts index d09d2afc50..c8f6b06997 100644 --- a/apps/mobile/src/features/tasks/api.ts +++ b/apps/mobile/src/features/tasks/api.ts @@ -1,9 +1,9 @@ import { fetch } from "expo/fetch"; import { + authedFetch, createTimeoutSignal, getAccessToken, getBaseUrl, - getHeaders, getProjectId, } from "@/lib/api"; import { logger } from "@/lib/logger"; @@ -127,7 +127,6 @@ export async function getTasks(filters?: { }): Promise { const baseUrl = getBaseUrl(); const projectId = getProjectId(); - const headers = getHeaders(); const params = new URLSearchParams({ limit: "500" }); if (filters?.repository) { @@ -140,9 +139,8 @@ export async function getTasks(filters?: { params.set("origin_product", filters.originProduct); } - const response = await fetch( + const response = await authedFetch( `${baseUrl}/api/projects/${projectId}/tasks/?${params}`, - { headers }, ); if (!response.ok) { @@ -160,11 +158,9 @@ export async function getTasks(filters?: { export async function getTask(taskId: string): Promise { const baseUrl = getBaseUrl(); const projectId = getProjectId(); - const headers = getHeaders(); - const response = await fetch( + const response = await authedFetch( `${baseUrl}/api/projects/${projectId}/tasks/${taskId}/`, - { headers }, ); if (!response.ok) { @@ -181,11 +177,9 @@ export async function getTask(taskId: string): Promise { export async function getTaskAutomations(): Promise { const baseUrl = getBaseUrl(); const projectId = getProjectId(); - const headers = getHeaders(); - const response = await fetch( + const response = await authedFetch( `${baseUrl}/api/projects/${projectId}/task_automations/?limit=500`, - { headers }, ); if (!response.ok) { @@ -207,11 +201,9 @@ export async function getTaskAutomation( ): Promise { const baseUrl = getBaseUrl(); const projectId = getProjectId(); - const headers = getHeaders(); - const response = await fetch( + const response = await authedFetch( `${baseUrl}/api/projects/${projectId}/task_automations/${automationId}/`, - { headers }, ); if (!response.ok) { @@ -230,13 +222,11 @@ export async function createTaskAutomation( ): Promise { const baseUrl = getBaseUrl(); const projectId = getProjectId(); - const headers = getHeaders(); - const response = await fetch( + const response = await authedFetch( `${baseUrl}/api/projects/${projectId}/task_automations/`, { method: "POST", - headers, body: JSON.stringify(options), }, ); @@ -254,13 +244,11 @@ export async function updateTaskAutomation( ): Promise { const baseUrl = getBaseUrl(); const projectId = getProjectId(); - const headers = getHeaders(); - const response = await fetch( + const response = await authedFetch( `${baseUrl}/api/projects/${projectId}/task_automations/${automationId}/`, { method: "PATCH", - headers, body: JSON.stringify(updates), }, ); @@ -277,14 +265,10 @@ export async function deleteTaskAutomation( ): Promise { const baseUrl = getBaseUrl(); const projectId = getProjectId(); - const headers = getHeaders(); - const response = await fetch( + const response = await authedFetch( `${baseUrl}/api/projects/${projectId}/task_automations/${automationId}/`, - { - method: "DELETE", - headers, - }, + { method: "DELETE" }, ); if (!response.ok) { @@ -301,14 +285,10 @@ export async function runTaskAutomation( ): Promise { const baseUrl = getBaseUrl(); const projectId = getProjectId(); - const headers = getHeaders(); - const response = await fetch( + const response = await authedFetch( `${baseUrl}/api/projects/${projectId}/task_automations/${automationId}/run/`, - { - method: "POST", - headers, - }, + { method: "POST" }, ); if (!response.ok) { @@ -325,16 +305,17 @@ export async function runTaskAutomation( export async function createTask(options: CreateTaskOptions): Promise { const baseUrl = getBaseUrl(); const projectId = getProjectId(); - const headers = getHeaders(); - const response = await fetch(`${baseUrl}/api/projects/${projectId}/tasks/`, { - method: "POST", - headers, - body: JSON.stringify({ - origin_product: "user_created", - ...options, - }), - }); + const response = await authedFetch( + `${baseUrl}/api/projects/${projectId}/tasks/`, + { + method: "POST", + body: JSON.stringify({ + origin_product: "user_created", + ...options, + }), + }, + ); if (!response.ok) { const errorText = await response.text(); @@ -355,13 +336,11 @@ export async function updateTask( ): Promise { const baseUrl = getBaseUrl(); const projectId = getProjectId(); - const headers = getHeaders(); - const response = await fetch( + const response = await authedFetch( `${baseUrl}/api/projects/${projectId}/tasks/${taskId}/`, { method: "PATCH", - headers, body: JSON.stringify(updates), }, ); @@ -380,14 +359,10 @@ export async function updateTask( export async function deleteTask(taskId: string): Promise { const baseUrl = getBaseUrl(); const projectId = getProjectId(); - const headers = getHeaders(); - const response = await fetch( + const response = await authedFetch( `${baseUrl}/api/projects/${projectId}/tasks/${taskId}/`, - { - method: "DELETE", - headers, - }, + { method: "DELETE" }, ); if (!response.ok) { @@ -406,7 +381,7 @@ export interface RunTaskInCloudOptions { mode?: "interactive" | "background"; /** Adapter to use on the cloud runner. Currently only "claude" on mobile. */ runtimeAdapter?: "claude" | "codex"; - /** Gateway model ID, e.g. "claude-opus-4-7". */ + /** Gateway model ID, e.g. "claude-opus-4-8". */ model?: string; /** Reasoning effort: "low" | "medium" | "high" (model-dependent). */ reasoningEffort?: string; @@ -424,7 +399,6 @@ export async function runTaskInCloud( ): Promise { const baseUrl = getBaseUrl(); const projectId = getProjectId(); - const headers = getHeaders(); // Only serialize a body when we have options to send. Sending an empty // or minimal body on the initial run historically changed backend @@ -470,11 +444,10 @@ export async function runTaskInCloud( body = JSON.stringify(payload); } - const response = await fetch( + const response = await authedFetch( `${baseUrl}/api/projects/${projectId}/tasks/${taskId}/run/`, { method: "POST", - headers, body, }, ); @@ -496,11 +469,9 @@ export async function getTaskRun( ): Promise { const baseUrl = getBaseUrl(); const projectId = getProjectId(); - const headers = getHeaders(); - const response = await fetch( + const response = await authedFetch( `${baseUrl}/api/projects/${projectId}/tasks/${taskId}/runs/${runId}/`, - { headers }, ); if (!response.ok) { @@ -523,13 +494,11 @@ export async function appendTaskRunLog( async () => { const baseUrl = getBaseUrl(); const projectId = getProjectId(); - const headers = getHeaders(); - const response = await fetch( + const response = await authedFetch( `${baseUrl}/api/projects/${projectId}/tasks/${taskId}/runs/${runId}/append_log/`, { method: "POST", - headers, body: JSON.stringify({ entries }), }, ); @@ -593,7 +562,6 @@ export async function sendCloudCommand( ): Promise { const baseUrl = getBaseUrl(); const projectId = getProjectId(); - const headers = getHeaders(); const body = { jsonrpc: "2.0", @@ -602,11 +570,10 @@ export async function sendCloudCommand( id: `posthog-mobile-${Date.now()}`, }; - const response = await fetch( + const response = await authedFetch( `${baseUrl}/api/projects/${projectId}/tasks/${taskId}/runs/${runId}/command/`, { method: "POST", - headers, body: JSON.stringify(body), }, ); @@ -661,16 +628,15 @@ export async function fetchSessionLogs( async () => { const baseUrl = getBaseUrl(); const projectId = getProjectId(); - const headers = getHeaders(); const params = new URLSearchParams({ limit: String(options.limit ?? 5000), offset: String(options.offset ?? 0), }); - const response = await fetch( + const response = await authedFetch( `${baseUrl}/api/projects/${projectId}/tasks/${taskId}/runs/${runId}/session_logs/?${params}`, - { headers, signal: createTimeoutSignal(10_000) }, + { signal: createTimeoutSignal(10_000) }, ); if (!response.ok) { @@ -731,11 +697,9 @@ export async function streamCloudTask( export async function getIntegrations(): Promise { const baseUrl = getBaseUrl(); const projectId = getProjectId(); - const headers = getHeaders(); - const response = await fetch( + const response = await authedFetch( `${baseUrl}/api/environments/${projectId}/integrations/`, - { headers }, ); if (!response.ok) { @@ -762,7 +726,6 @@ export async function getGithubRepositories( ): Promise { const baseUrl = getBaseUrl(); const projectId = getProjectId(); - const headers = getHeaders(); const allRepos: string[] = []; let offset = 0; @@ -772,9 +735,8 @@ export async function getGithubRepositories( limit: String(GITHUB_REPOS_PAGE_SIZE), offset: String(offset), }); - const response = await fetch( + const response = await authedFetch( `${baseUrl}/api/environments/${projectId}/integrations/${integrationId}/github_repos/?${params}`, - { headers }, ); if (!response.ok) { @@ -822,13 +784,11 @@ export interface GithubUserConnectResult { export async function startGithubUserIntegrationConnect(): Promise { const baseUrl = getBaseUrl(); const projectId = getProjectId(); - const headers = getHeaders(); - const response = await fetch( + const response = await authedFetch( `${baseUrl}/api/users/@me/integrations/github/start/`, { method: "POST", - headers, body: JSON.stringify({ team_id: projectId, connect_from: "posthog_mobile", @@ -854,11 +814,8 @@ export async function getUserGithubIntegrations(): Promise< UserGithubIntegration[] > { const baseUrl = getBaseUrl(); - const headers = getHeaders(); - const response = await fetch(`${baseUrl}/api/users/@me/integrations/`, { - headers, - }); + const response = await authedFetch(`${baseUrl}/api/users/@me/integrations/`); if (!response.ok) { throw new HttpError( @@ -878,7 +835,6 @@ export async function getUserGithubRepositories( installationId: string, ): Promise { const baseUrl = getBaseUrl(); - const headers = getHeaders(); const allRepos: string[] = []; let offset = 0; @@ -888,9 +844,8 @@ export async function getUserGithubRepositories( limit: String(GITHUB_REPOS_PAGE_SIZE), offset: String(offset), }); - const response = await fetch( + const response = await authedFetch( `${baseUrl}/api/users/@me/integrations/github/${installationId}/repos/?${params}`, - { headers }, ); if (!response.ok) { diff --git a/apps/mobile/src/features/tasks/components/TaskSessionView.test.tsx b/apps/mobile/src/features/tasks/components/TaskSessionView.test.tsx index ad35c3aa95..b89b8ceb7a 100644 --- a/apps/mobile/src/features/tasks/components/TaskSessionView.test.tsx +++ b/apps/mobile/src/features/tasks/components/TaskSessionView.test.tsx @@ -51,7 +51,84 @@ vi.mock("./PlanApprovalCard", () => ({ createElement("PlanApprovalCard", props), })); +function renderTaskSessionView( + props: Parameters[0], +): ReturnType { + let renderer!: ReturnType; + act(() => { + renderer = create(createElement(TaskSessionView, props)); + }); + return renderer; +} + +function findHumanMessages(renderer: ReturnType) { + // vi.mock'd `HumanMessage` is rendered as the literal string `"HumanMessage"` + // (an intrinsic), so node.type is a string at runtime even though the type + // says ElementType. + return renderer.root.findAll( + (node) => (node.type as unknown as string) === "HumanMessage", + ); +} + describe("TaskSessionView", () => { + function userMessageEvent(text: string, ts: number) { + return { + type: "session_update" as const, + ts, + notification: { + update: { + sessionUpdate: "user_message_chunk", + content: { type: "text", text }, + }, + }, + }; + } + + const SUBMIT_TS = 1000; + + it.each([ + { + name: "no SSE echo yet → optimistic renders", + events: [], + expectedCount: 1, + }, + { + name: "matching SSE chunk after submit → optimistic suppressed", + events: [userMessageEvent("Ship it", SUBMIT_TS + 5)], + expectedCount: 1, + }, + { + name: "text-identical historical turn → optimistic still renders", + // Same text but ts predates submit — a prior "Ship it" message shouldn't + // cause the new optimistic echo to be deduped. + events: [userMessageEvent("Ship it", SUBMIT_TS - 1000)], + expectedCount: 2, + }, + { + name: "non-matching SSE text → optimistic still renders", + events: [userMessageEvent("Different text", SUBMIT_TS + 5)], + expectedCount: 2, + }, + ])("optimistic echo: $name", ({ events, expectedCount }) => { + const renderer = renderTaskSessionView({ + events, + optimisticUserMessage: { text: "Ship it", setAt: SUBMIT_TS }, + }); + + expect(findHumanMessages(renderer)).toHaveLength(expectedCount); + }); + + it("optimistic echo carries the submitted text into the rendered bubble", () => { + const renderer = renderTaskSessionView({ + events: [], + optimisticUserMessage: { text: "Ship it", setAt: SUBMIT_TS }, + }); + + const humans = findHumanMessages(renderer); + expect(humans).toHaveLength(1); + expect(humans[0].props.content).toBe("Ship it"); + }); + it("keeps question tools pending after the run goes idle", () => { const events = [ { diff --git a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx index 1b0050333d..fb4c23ed13 100644 --- a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx +++ b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx @@ -41,6 +41,15 @@ interface PermissionResponseArgs { displayText: string; } +interface OptimisticUserMessage { + text: string; + attachments?: SessionNotificationAttachment[]; + // Submit-time epoch ms. Dedup only fires against user messages whose `ts` + // is at or after this — protects against a text-identical historical turn + // suppressing the new optimistic echo. + setAt: number; +} + interface TaskSessionViewProps { events: SessionEvent[]; pendingPermissions?: Record; @@ -52,6 +61,11 @@ interface TaskSessionViewProps { onOpenTask?: (taskId: string) => void; onSendPermissionResponse?: (args: PermissionResponseArgs) => void; contentContainerStyle?: object; + // Renders a user message at the bottom of the thread before the SSE echo + // arrives — for the gap between submit and the live session catching up. + // Suppressed automatically once a real user_message_chunk with matching + // text appears in `events`. + optimisticUserMessage?: OptimisticUserMessage; } interface ToolData { @@ -792,6 +806,7 @@ export function TaskSessionView({ onOpenTask, onSendPermissionResponse, contentContainerStyle, + optimisticUserMessage, }: TaskSessionViewProps) { const processorRef = useRef(createProcessorState()); const prevEventsRef = useRef(events); @@ -838,9 +853,34 @@ export function TaskSessionView({ } prevAgentActive.current = agentActive; + // Append the optimistic user echo (if any) as the newest message, unless a + // real `user` message with matching text *and a ts at or after submit time* + // has already arrived via SSE. Gating on `ts` prevents a text-identical + // historical turn from suppressing a freshly-submitted echo. + const messagesWithOptimistic = useMemo(() => { + if (!optimisticUserMessage) return messages; + const alreadyEchoed = messages.some( + (m) => + m.type === "user" && + m.content === optimisticUserMessage.text && + (m.ts ?? 0) >= optimisticUserMessage.setAt, + ); + if (alreadyEchoed) return messages; + const optimistic: ParsedMessage = { + id: "optimistic-user", + type: "user", + content: optimisticUserMessage.text, + attachments: optimisticUserMessage.attachments, + }; + return [...messages, optimistic]; + }, [messages, optimisticUserMessage]); + // Inverted FlatList renders data[0] at the visual bottom. // Reverse so newest messages are at index 0 = bottom. - const reversedMessages = useMemo(() => [...messages].reverse(), [messages]); + const reversedMessages = useMemo( + () => [...messagesWithOptimistic].reverse(), + [messagesWithOptimistic], + ); const themeColors = useThemeColors(); const flatListRef = useRef(null); const hasPendingQuestion = useMemo( diff --git a/apps/mobile/src/features/tasks/composer/options.ts b/apps/mobile/src/features/tasks/composer/options.ts index 885554cf3a..6e40ead38b 100644 --- a/apps/mobile/src/features/tasks/composer/options.ts +++ b/apps/mobile/src/features/tasks/composer/options.ts @@ -37,8 +37,8 @@ export interface ModelOption { export const MODELS: ModelOption[] = [ { - value: "claude-opus-4-7", - label: "Claude Opus 4.7", + value: "claude-opus-4-8", + label: "Claude Opus 4.8", description: "Most capable, slower", supportsReasoning: true, }, @@ -48,12 +48,6 @@ export const MODELS: ModelOption[] = [ description: "Balanced", supportsReasoning: true, }, - { - value: "claude-haiku-4-5", - label: "Claude Haiku 4.5", - description: "Fastest", - supportsReasoning: false, - }, ]; export const REASONING_LEVELS: { @@ -68,7 +62,7 @@ export const REASONING_LEVELS: { ]; export const DEFAULT_EXECUTION_MODE: ExecutionMode = "plan"; -export const DEFAULT_MODEL = "claude-opus-4-7"; +export const DEFAULT_MODEL = "claude-opus-4-8"; export const DEFAULT_REASONING: ReasoningEffort = "high"; export function modelLabel(value: string): string { diff --git a/apps/mobile/src/features/tasks/skills/api.test.ts b/apps/mobile/src/features/tasks/skills/api.test.ts index 6c1338ae5a..9c6a13f2a2 100644 --- a/apps/mobile/src/features/tasks/skills/api.test.ts +++ b/apps/mobile/src/features/tasks/skills/api.test.ts @@ -10,11 +10,16 @@ vi.mock("expo/fetch", () => ({ vi.mock("@/lib/api", () => ({ getBaseUrl: () => "https://app.posthog.test", - getHeaders: () => ({ - Authorization: "Bearer token", - "Content-Type": "application/json", - }), getProjectId: () => 42, + authedFetch: (url: string, init?: RequestInit) => + mockFetch(url, { + ...init, + headers: { + Authorization: "Bearer token", + "Content-Type": "application/json", + ...((init?.headers as Record | undefined) ?? {}), + }, + }), })); import { getSkillStoreSkill, getSkillStoreSkills } from "./api"; diff --git a/apps/mobile/src/features/tasks/skills/api.ts b/apps/mobile/src/features/tasks/skills/api.ts index 0fc2e1b02e..46584a4241 100644 --- a/apps/mobile/src/features/tasks/skills/api.ts +++ b/apps/mobile/src/features/tasks/skills/api.ts @@ -1,5 +1,4 @@ -import { fetch } from "expo/fetch"; -import { getBaseUrl, getHeaders, getProjectId } from "@/lib/api"; +import { authedFetch, getBaseUrl, getProjectId } from "@/lib/api"; import type { SkillStoreListEntry, SkillStoreSkill } from "./types"; function skillStoreBaseUrl(): string { @@ -23,9 +22,7 @@ async function readJsonOrThrow( } export async function getSkillStoreSkills(): Promise { - const response = await fetch(`${skillStoreBaseUrl()}/`, { - headers: getHeaders(), - }); + const response = await authedFetch(`${skillStoreBaseUrl()}/`); const data = await readJsonOrThrow< SkillStoreListEntry[] | { results?: SkillStoreListEntry[] } @@ -37,11 +34,8 @@ export async function getSkillStoreSkills(): Promise { export async function getSkillStoreSkill( skillName: string, ): Promise { - const response = await fetch( + const response = await authedFetch( `${skillStoreBaseUrl()}/name/${encodeURIComponent(skillName)}/`, - { - headers: getHeaders(), - }, ); return readJsonOrThrow(response, "Failed to fetch skill"); diff --git a/apps/mobile/src/features/tasks/stores/pendingTaskPromptStore.test.ts b/apps/mobile/src/features/tasks/stores/pendingTaskPromptStore.test.ts new file mode 100644 index 0000000000..fa4cab91fe --- /dev/null +++ b/apps/mobile/src/features/tasks/stores/pendingTaskPromptStore.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + pendingTaskPromptStoreApi, + usePendingTaskPromptStore, +} from "./pendingTaskPromptStore"; + +describe("pendingTaskPromptStore", () => { + beforeEach(() => { + usePendingTaskPromptStore.setState({ byKey: {} }); + }); + + it("stores prompts keyed by an arbitrary id", () => { + pendingTaskPromptStoreApi.set("uuid-1", { + promptText: "Fix the login bug", + setAt: 1000, + }); + + expect(pendingTaskPromptStoreApi.get("uuid-1")).toEqual({ + promptText: "Fix the login bug", + setAt: 1000, + }); + }); + + it("moves a prompt from a transient key to the real task id", () => { + pendingTaskPromptStoreApi.set("uuid-1", { + promptText: "Do the thing", + setAt: 1000, + }); + pendingTaskPromptStoreApi.move("uuid-1", "task-123"); + + expect(pendingTaskPromptStoreApi.get("uuid-1")).toBeUndefined(); + expect(pendingTaskPromptStoreApi.get("task-123")).toEqual({ + promptText: "Do the thing", + setAt: 1000, + }); + }); + + it("ignores move when the source key has no prompt", () => { + pendingTaskPromptStoreApi.move("missing", "task-999"); + expect(pendingTaskPromptStoreApi.get("task-999")).toBeUndefined(); + }); + + it("clears prompts", () => { + pendingTaskPromptStoreApi.set("task-42", { + promptText: "Hi", + setAt: 1000, + }); + pendingTaskPromptStoreApi.clear("task-42"); + expect(pendingTaskPromptStoreApi.get("task-42")).toBeUndefined(); + }); + + it("preserves attachments through move", () => { + pendingTaskPromptStoreApi.set("uuid-1", { + promptText: "Look at this", + setAt: 1000, + attachments: [ + { + kind: "image", + uri: "file://x.png", + fileName: "x.png", + mimeType: "image/png", + }, + ], + }); + pendingTaskPromptStoreApi.move("uuid-1", "task-7"); + + expect(pendingTaskPromptStoreApi.get("task-7")?.attachments).toHaveLength( + 1, + ); + }); +}); diff --git a/apps/mobile/src/features/tasks/stores/pendingTaskPromptStore.ts b/apps/mobile/src/features/tasks/stores/pendingTaskPromptStore.ts new file mode 100644 index 0000000000..e7face63f3 --- /dev/null +++ b/apps/mobile/src/features/tasks/stores/pendingTaskPromptStore.ts @@ -0,0 +1,86 @@ +import { create } from "zustand"; +import type { SessionNotificationAttachment } from "../types"; + +/** + * Optimistic chat-thread echo for a prompt the user just submitted but whose + * canonical SSE copy hasn't landed yet. Keyed first by a transient UUID (set + * the instant the user taps send, before any task ID is known) and then + * `move`d onto the real task ID once `createTask` returns. + * + * Pure UI state — not persisted. Cleared as soon as the live session echoes + * the matching `user_message_chunk` back. + */ +export interface PendingTaskPrompt { + promptText: string; + attachments?: SessionNotificationAttachment[]; + // Submit-time epoch ms. Consumers compare event `ts` against this so the + // echo is only deduped against `user_message_chunk`s that arrived *after* + // submit — protects against text-identical historical turns (e.g. a user + // submitting "Continue" twice in a row) hiding the new optimistic echo. + setAt: number; +} + +interface PendingTaskPromptState { + byKey: Record; + set: (key: string, prompt: PendingTaskPrompt) => void; + move: (fromKey: string, toKey: string) => void; + clear: (key: string) => void; +} + +export const usePendingTaskPromptStore = create( + (set) => ({ + byKey: {}, + set: (key, prompt) => + set((state) => ({ byKey: { ...state.byKey, [key]: prompt } })), + move: (fromKey, toKey) => + set((state) => { + const value = state.byKey[fromKey]; + if (!value) return state; + const { [fromKey]: _removed, ...rest } = state.byKey; + return { byKey: { ...rest, [toKey]: value } }; + }), + clear: (key) => + set((state) => { + if (!(key in state.byKey)) return state; + const { [key]: _removed, ...rest } = state.byKey; + return { byKey: rest }; + }), + }), +); + +export function usePendingTaskPrompt( + key: string | undefined | null, +): PendingTaskPrompt | undefined { + return usePendingTaskPromptStore((s) => (key ? s.byKey[key] : undefined)); +} + +/** + * Non-reactive accessors so non-component code (screens, async flows) can + * mutate the store without going through hooks. Mirrors the desktop + * `pendingTaskPromptStoreApi` shape. + */ +export const pendingTaskPromptStoreApi = { + set(key: string, prompt: PendingTaskPrompt): void { + usePendingTaskPromptStore.getState().set(key, prompt); + }, + get(key: string): PendingTaskPrompt | undefined { + return usePendingTaskPromptStore.getState().byKey[key]; + }, + move(fromKey: string, toKey: string): void { + usePendingTaskPromptStore.getState().move(fromKey, toKey); + }, + clear(key: string): void { + usePendingTaskPromptStore.getState().clear(key); + }, +}; + +export function generatePendingTaskKey(): string { + const cryptoObj = + typeof globalThis !== "undefined" + ? (globalThis as { crypto?: { randomUUID?: () => string } }).crypto + : undefined; + if (cryptoObj?.randomUUID) { + return cryptoObj.randomUUID(); + } + return `pending-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +} diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts index fa3c84a298..7e43ee26db 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts @@ -151,6 +151,8 @@ const TURN_END_METHODS = new Set([ interface BatchAnalysis { hasTurnEnd: boolean; hasAwaitingUserInput: boolean; + hasTurnCompleted: boolean; + hasTurnFailed: boolean; hasVisibleAgentOutput: boolean; externalUserMessageCount: number; agentMessageFinalized: boolean; @@ -162,6 +164,8 @@ function analyzeEntries( ): BatchAnalysis { let hasTurnEnd = false; let hasAwaitingUserInput = false; + let hasTurnCompleted = false; + let hasTurnFailed = false; let hasVisibleAgentOutput = false; let externalUserMessageCount = 0; let agentMessageFinalized = false; @@ -173,6 +177,15 @@ function analyzeEntries( if (method === "_posthog/awaiting_user_input") { hasAwaitingUserInput = true; } + if ( + method === "_posthog/turn_complete" || + method === "_posthog/task_complete" + ) { + hasTurnCompleted = true; + } + if (method === "_posthog/error") { + hasTurnFailed = true; + } } if ( @@ -200,6 +213,8 @@ function analyzeEntries( return { hasTurnEnd, hasAwaitingUserInput, + hasTurnCompleted, + hasTurnFailed, hasVisibleAgentOutput, externalUserMessageCount, agentMessageFinalized, @@ -897,21 +912,52 @@ export const useTaskSessionStore = create((set, get) => ({ }; }); - // Live `logs` deltas only fire pings for the "agent is blocked on the - // user" case. Terminal completion / failure is handled by the status - // block below, so we don't double-fire on every intermediate turn. - // Snapshots are historical replay — never ping for those. - const shouldPingNow = + // Live `logs` deltas fire pings for three turn-boundary cases: + // * agent is blocked on the user (_posthog/awaiting_user_input) + // * agent finished its turn (_posthog/turn_complete / task_complete) + // * agent errored out the turn (_posthog/error) + // The terminal-status block below can't be relied on for these: the + // turn-end log entry arrives first and clears `awaitingPing`, so by + // the time status terminal fires its `preState.awaitingPing` is + // already false. Status-only termination (sandbox killed without a + // turn-end log) still falls through to the status block. Snapshots + // are historical replay — never ping for those. + const shouldPingForAwaitingInput = !isSnapshot && wasAwaitingPing && analysis.hasAwaitingUserInput; + const shouldPingForTurnComplete = + !isSnapshot && + wasAwaitingPing && + analysis.hasTurnCompleted && + !analysis.hasAwaitingUserInput; + const shouldPingForTurnFailed = + !isSnapshot && + wasAwaitingPing && + analysis.hasTurnFailed && + !analysis.hasAwaitingUserInput && + !analysis.hasTurnCompleted; + const shouldPingNow = + shouldPingForAwaitingInput || + shouldPingForTurnComplete || + shouldPingForTurnFailed; if (shouldPingNow && usePreferencesStore.getState().pingsEnabled) { playMeepSound().catch(() => {}); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); } - if (shouldPingNow) { + if (shouldPingForAwaitingInput) { maybePresentLocalNotification({ taskRunId, kind: "awaiting_user_input", }); + } else if (shouldPingForTurnComplete) { + maybePresentLocalNotification({ + taskRunId, + kind: "turn_complete", + }); + } else if (shouldPingForTurnFailed) { + maybePresentLocalNotification({ + taskRunId, + kind: "task_failed", + }); } } diff --git a/apps/mobile/src/lib/api.test.ts b/apps/mobile/src/lib/api.test.ts new file mode 100644 index 0000000000..5cd7823ff5 --- /dev/null +++ b/apps/mobile/src/lib/api.test.ts @@ -0,0 +1,216 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockFetch, mockRefreshAccessToken, mockGetState } = vi.hoisted(() => ({ + mockFetch: vi.fn(), + mockRefreshAccessToken: vi.fn(), + mockGetState: vi.fn(), +})); + +vi.mock("expo/fetch", () => ({ + fetch: mockFetch, +})); + +vi.mock("expo-constants", () => ({ + default: { expoConfig: { version: "0.0.0-test" } }, +})); + +vi.mock("@/features/auth", () => ({ + useAuthStore: { + getState: mockGetState, + }, +})); + +import { authedFetch } from "./api"; + +const url = "https://app.posthog.test/api/projects/1/tasks/"; +const ok = (data: unknown = {}) => + new Response(JSON.stringify(data), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +const err = (status: number, body: unknown = { error: status }) => + new Response(JSON.stringify(body), { + status, + statusText: `Error ${status}`, + headers: { "Content-Type": "application/json" }, + }); + +function setupTokens(initial = "old-token", refreshed = "new-token") { + let current = initial; + mockRefreshAccessToken.mockImplementation(async () => { + current = refreshed; + }); + mockGetState.mockImplementation(() => ({ + oauthAccessToken: current, + refreshAccessToken: mockRefreshAccessToken, + })); +} + +describe("authedFetch", () => { + beforeEach(() => { + mockFetch.mockReset(); + mockRefreshAccessToken.mockReset(); + mockGetState.mockReset(); + }); + + it("attaches the bearer token from the auth store", async () => { + setupTokens("my-token"); + mockFetch.mockResolvedValueOnce(ok()); + + await authedFetch(url); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockRefreshAccessToken).not.toHaveBeenCalled(); + expect(mockFetch.mock.calls[0][1].headers.Authorization).toBe( + "Bearer my-token", + ); + }); + + it.each([ + { + name: "401", + failure: () => err(401), + }, + { + name: "403 with authentication_failed body", + failure: () => + err(403, { + type: "authentication_error", + code: "authentication_failed", + detail: "Invalid access token.", + }), + }, + ])( + "retries once with a freshly fetched token on $name", + async ({ failure }) => { + setupTokens("old-token", "new-token"); + mockFetch.mockResolvedValueOnce(failure()).mockResolvedValueOnce(ok()); + + const response = await authedFetch(url); + + expect(response.ok).toBe(true); + expect(mockRefreshAccessToken).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch.mock.calls[0][1].headers.Authorization).toBe( + "Bearer old-token", + ); + expect(mockFetch.mock.calls[1][1].headers.Authorization).toBe( + "Bearer new-token", + ); + }, + ); + + it.each([ + { + name: "403 without authentication_failed body", + response: () => err(403, { detail: "Permission denied." }), + expectedStatus: 403, + }, + { + name: "400 bad request", + response: () => err(400, { detail: "Bad request." }), + expectedStatus: 400, + }, + ])( + "does not retry on $name", + async ({ response: makeResponse, expectedStatus }) => { + setupTokens("token"); + mockFetch.mockResolvedValueOnce(makeResponse()); + + const response = await authedFetch(url); + + expect(response.status).toBe(expectedStatus); + expect(mockRefreshAccessToken).not.toHaveBeenCalled(); + expect(mockFetch).toHaveBeenCalledTimes(1); + }, + ); + + it("returns the failed response when the retry still 401s", async () => { + setupTokens("token-1", "token-2"); + mockFetch.mockResolvedValueOnce(err(401)).mockResolvedValueOnce(err(401)); + + const response = await authedFetch(url); + + expect(response.status).toBe(401); + expect(mockRefreshAccessToken).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it("falls through with the original 401 when token refresh itself fails", async () => { + mockGetState.mockReturnValue({ + oauthAccessToken: "token", + refreshAccessToken: mockRefreshAccessToken, + }); + mockRefreshAccessToken.mockRejectedValueOnce(new Error("refresh failed")); + mockFetch.mockResolvedValueOnce(err(401)); + + const response = await authedFetch(url); + + expect(response.status).toBe(401); + expect(mockRefreshAccessToken).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("propagates network errors from the underlying fetch", async () => { + setupTokens("token"); + mockFetch.mockRejectedValueOnce(new Error("Network failure")); + + await expect(authedFetch(url)).rejects.toThrow("Network failure"); + }); + + it("merges caller-provided headers with the auth headers", async () => { + setupTokens("my-token"); + mockFetch.mockResolvedValueOnce(ok()); + + await authedFetch(url, { + method: "POST", + headers: { "X-Custom": "value" }, + body: "{}", + }); + + const init = mockFetch.mock.calls[0][1]; + expect(init.method).toBe("POST"); + expect(init.body).toBe("{}"); + expect(init.headers.Authorization).toBe("Bearer my-token"); + expect(init.headers["X-Custom"]).toBe("value"); + }); + + it("dedups concurrent refreshes so only one fires on a 401 stampede", async () => { + let current = "old-token"; + let resolveRefresh: () => void = () => {}; + const refreshPromise = new Promise((resolve) => { + resolveRefresh = () => { + current = "new-token"; + resolve(); + }; + }); + mockRefreshAccessToken.mockImplementation(() => refreshPromise); + mockGetState.mockImplementation(() => ({ + oauthAccessToken: current, + refreshAccessToken: mockRefreshAccessToken, + })); + + mockFetch + .mockResolvedValueOnce(err(401)) + .mockResolvedValueOnce(err(401)) + .mockResolvedValueOnce(err(401)) + .mockResolvedValueOnce(ok({ n: 1 })) + .mockResolvedValueOnce(ok({ n: 2 })) + .mockResolvedValueOnce(ok({ n: 3 })); + + const pending = Promise.all([ + authedFetch(url), + authedFetch(url), + authedFetch(url), + ]); + + // Drain microtasks until all three callers have parked on the shared + // refresh, then release it and let the retries complete. + for (let i = 0; i < 20; i++) await Promise.resolve(); + resolveRefresh(); + const responses = await pending; + + expect(responses.every((r) => r.ok)).toBe(true); + expect(mockRefreshAccessToken).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/mobile/src/lib/api.ts b/apps/mobile/src/lib/api.ts index 0aaa6d5dc7..58603788d3 100644 --- a/apps/mobile/src/lib/api.ts +++ b/apps/mobile/src/lib/api.ts @@ -3,6 +3,10 @@ import Constants from "expo-constants"; import { useAuthStore } from "@/features/auth"; import { logger } from "@/lib/logger"; +// Derive the init shape directly from expo/fetch so we don't import from +// expo's internal build output (which can move between versions). +type FetchInit = NonNullable[1]>; + const log = logger.scope("api"); const USER_AGENT = `posthog/mobile.hog.dev; version: ${Constants.expoConfig?.version ?? "unknown"}`; @@ -57,19 +61,112 @@ export function createTimeoutSignal(ms: number): AbortSignal { return controller.signal; } +// Concurrent 401s would otherwise stampede the refresh endpoint and have the +// in-flight responses invalidate each other's new tokens. Share a single +// pending refresh across all callers and reset it once it settles. +let pendingRefresh: Promise | null = null; + +async function refreshAccessTokenOnce(): Promise { + if (pendingRefresh) return pendingRefresh; + const promise = useAuthStore + .getState() + .refreshAccessToken() + .finally(() => { + if (pendingRefresh === promise) { + pendingRefresh = null; + } + }); + pendingRefresh = promise; + return promise; +} + +async function isAuthFailureResponse(response: Response): Promise { + if (response.status === 401) return true; + if (response.status !== 403) return false; + try { + const body = await response.clone().json(); + return ( + body?.code === "authentication_failed" || + body?.type === "authentication_error" + ); + } catch { + return false; + } +} + +function mergeHeaders( + base: Record, + override: HeadersInit | undefined, +): Record { + if (!override) return base; + const merged: Record = { ...base }; + if (override instanceof Headers) { + override.forEach((value, key) => { + merged[key] = value; + }); + return merged; + } + if (Array.isArray(override)) { + for (const [key, value] of override) { + merged[key] = value; + } + return merged; + } + for (const [key, value] of Object.entries(override)) { + merged[key] = value; + } + return merged; +} + +/** + * `fetch` against the PostHog API with automatic token refresh on auth + * failure. On a 401 — or a 403 whose JSON body looks like an authentication + * failure (`code: "authentication_failed"` / `type: "authentication_error"`) — + * triggers a single shared token refresh and retries the request once. If the + * refresh itself fails, the original response is returned so callers fall + * through to their existing error-handling and sign-out flows. + * + * Mirrors the desktop fetcher's retry semantics + * (apps/code/src/renderer/api/fetcher.ts). + */ +export async function authedFetch( + url: string, + init: FetchInit = {}, +): Promise { + const headers = mergeHeaders(getHeaders(), init.headers); + let response: Response = await fetch(url, { ...init, headers }); + + if (response.ok || !(await isAuthFailureResponse(response))) { + return response; + } + + try { + await refreshAccessTokenOnce(); + } catch (err) { + log.warn("Token refresh on auth failure failed", { + url, + status: response.status, + error: err instanceof Error ? err.message : String(err), + }); + return response; + } + + const retryHeaders = mergeHeaders(getHeaders(), init.headers); + response = await fetch(url, { ...init, headers: retryHeaders }); + return response; +} + export async function registerPushToken(args: { token: string; platform: string; }): Promise { const baseUrl = getBaseUrl(); - const headers = getHeaders(); // Push tokens are per-user, not per-project — endpoint lives under // /api/users/@me/ alongside the other user-scoped APIs. const url = `${baseUrl}/api/users/@me/push_tokens/`; - const response = await fetch(url, { + const response = await authedFetch(url, { method: "POST", - headers, body: JSON.stringify(args), }); @@ -89,15 +186,13 @@ export async function registerPushToken(args: { export async function deletePushToken(args: { token: string }): Promise { const baseUrl = getBaseUrl(); - const headers = getHeaders(); // Unregister is a POST sub-action (not DELETE) because some clients and // proxies strip request bodies on DELETE. - const response = await fetch( + const response = await authedFetch( `${baseUrl}/api/users/@me/push_tokens/unregister/`, { method: "POST", - headers, body: JSON.stringify(args), }, ); diff --git a/apps/mobile/src/lib/deep-links.test.ts b/apps/mobile/src/lib/deep-links.test.ts new file mode 100644 index 0000000000..d2455383dd --- /dev/null +++ b/apps/mobile/src/lib/deep-links.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; +import { + externalUrlToAppPath, + inboxReportShareUrl, + slugifyTitle, +} from "./deep-links"; + +describe("slugifyTitle", () => { + it.each([ + ["null", null], + ["undefined", undefined], + ["empty string", ""], + ["whitespace only", " "], + ["unsafe-only characters", ":::"], + ])("returns an empty string when the title is %s", (_label, input) => { + expect(slugifyTitle(input)).toBe(""); + }); + + it("emits `--` for runs that mix a colon with other unsafe chars", () => { + expect(slugifyTitle("fix(inbox): Add foo")).toBe("fix-inbox--Add-foo"); + }); + + it("emits a single `-` for a colon-only run", () => { + expect(slugifyTitle("feat:bar")).toBe("feat-bar"); + }); + + it("preserves URL-unreserved punctuation (- _ . ~)", () => { + expect(slugifyTitle("v1.2.3_final~ish")).toBe("v1.2.3_final~ish"); + }); + + it("collapses runs of unsafe punctuation into a single hyphen", () => { + expect(slugifyTitle("Cost $5, 50% off!")).toBe("Cost-5-50-off"); + }); + + it("folds accented Latin letters to their ASCII base", () => { + expect(slugifyTitle("café résumé naïve")).toBe("cafe-resume-naive"); + }); + + it("hyphenizes non-Latin scripts that have no ASCII fold", () => { + expect(slugifyTitle("Hello Привет world")).toBe("Hello-world"); + }); + + it("preserves case", () => { + expect(slugifyTitle("Hello World")).toBe("Hello-World"); + }); +}); + +describe("inboxReportShareUrl", () => { + it("returns just the UUID when no title argument is passed", () => { + expect(inboxReportShareUrl("abc-123")).toBe("posthog://inbox/abc-123"); + }); + + it.each([ + ["null", null], + ["undefined", undefined], + ["empty string", ""], + ])( + "returns just the UUID when the title is %s", + (_label, input: string | null | undefined) => { + expect(inboxReportShareUrl("abc-123", input)).toBe( + "posthog://inbox/abc-123", + ); + }, + ); + + it("appends a slug derived from the title", () => { + expect(inboxReportShareUrl("abc-123", "Hello World")).toBe( + "posthog://inbox/abc-123/Hello-World", + ); + }); + + it.each([ + ["unsafe-only characters", ":::"], + ["whitespace only", " "], + ])("omits the slug when the title is %s", (_label, input) => { + expect(inboxReportShareUrl("abc-123", input)).toBe( + "posthog://inbox/abc-123", + ); + }); + + it("preserves the desktop colon-run convention", () => { + expect(inboxReportShareUrl("abc-123", "fix(inbox): Add foo")).toBe( + "posthog://inbox/abc-123/fix-inbox--Add-foo", + ); + }); +}); + +describe("externalUrlToAppPath", () => { + it("returns the router path for a custom-scheme URL", () => { + expect(externalUrlToAppPath("posthog://task/task-123")).toBe( + "/task/task-123", + ); + }); + + it("returns the router path for a universal-link URL", () => { + expect(externalUrlToAppPath("https://code.posthog.com/task/task-123")).toBe( + "/task/task-123", + ); + }); + + it("preserves the query string", () => { + expect(externalUrlToAppPath("posthog://task/task-123?foo=bar")).toBe( + "/task/task-123?foo=bar", + ); + }); + + it("strips a trailing slug segment from inbox custom-scheme URLs", () => { + expect( + externalUrlToAppPath("posthog://inbox/report-abc/fix-inbox--Add-foo"), + ).toBe("/inbox/report-abc"); + }); + + it("strips a trailing slug segment from inbox universal links", () => { + expect( + externalUrlToAppPath( + "https://code.posthog.com/inbox/report-abc/Hello-World", + ), + ).toBe("/inbox/report-abc"); + }); + + it("preserves the query string when stripping an inbox slug", () => { + expect( + externalUrlToAppPath("posthog://inbox/report-abc/Hello-World?x=1"), + ).toBe("/inbox/report-abc?x=1"); + }); + + it("leaves a bare inbox URL untouched", () => { + expect(externalUrlToAppPath("posthog://inbox/report-abc")).toBe( + "/inbox/report-abc", + ); + }); + + it.each([ + ["unrelated https host", "https://example.com/inbox/x"], + ["unparseable string", "not a url"], + ])("ignores URLs from %s", (_label, input) => { + expect(externalUrlToAppPath(input)).toBe(null); + }); +}); diff --git a/apps/mobile/src/lib/deep-links.ts b/apps/mobile/src/lib/deep-links.ts index 50be15f6ac..0e1d01aa95 100644 --- a/apps/mobile/src/lib/deep-links.ts +++ b/apps/mobile/src/lib/deep-links.ts @@ -7,6 +7,7 @@ * posthog://task/ * posthog://task//run/ * posthog://inbox/ + * posthog://inbox// (slug is cosmetic, ignored on receive) * * Mobile uses the `posthog://` custom scheme (registered in app.json) and * https://code.posthog.com as the universal-link host. Both share the same @@ -16,7 +17,8 @@ * For in-app navigation, prefer the `paths.*` helpers — they return the * router-relative path that `router.push()` expects. For external/shareable * links (push notifications, Slack messages, copy-link buttons), use - * `universalUrl()` or `customSchemeUrl()`. + * `universalUrl()` or `customSchemeUrl()`. To produce a human-readable share + * link for an inbox report, use `inboxReportShareUrl(reportId, title)`. */ export const MOBILE_SCHEME = "posthog"; @@ -56,11 +58,61 @@ export function universalUrl(path: AppPath): string { return `${UNIVERSAL_LINK_PREFIX}${normalized}`; } +/** + * Slugify a free-form title for use as a trailing path segment on a shareable + * deep link. Mirrors `buildInboxDeeplink`'s slug rules in the desktop app + * (apps/code/src/shared/deeplink.ts) exactly: + * + * - Accented Latin letters are folded to their ASCII base (`café` → `cafe`) + * via NFD decomposition + combining-mark stripping. + * - Letters, digits, and the URL-unreserved punctuation `_ . ~` are kept + * verbatim (case preserved). + * - Any run of other characters collapses to a single `-`, except runs that + * mix a colon with other unsafe chars collapse to `--`. This preserves the + * title-like break in `fix(inbox): Add foo` → `fix-inbox--Add-foo` while + * keeping standalone colons compact (`feat:bar` → `feat-bar`) and unrelated + * runs single (`Cost $5, 50% off` → `Cost-5-50-off`). + * - Leading and trailing hyphens are stripped. + * + * Returns the empty string when the input is null/undefined/empty or + * slugifies to nothing. + */ +export function slugifyTitle(title: string | null | undefined): string { + if (!title) return ""; + return title + .normalize("NFD") + .replace(/\p{M}/gu, "") + .replace(/[^a-zA-Z0-9_.~]+/g, (run) => + run.includes(":") && /[^:]/.test(run) ? "--" : "-", + ) + .replace(/^-+|-+$/g, ""); +} + +/** + * Build a shareable `posthog://inbox/` URL, optionally with a + * cosmetic slug suffix derived from the title. The slug is purely cosmetic; + * receivers must ignore everything after the UUID. See + * `externalUrlToAppPath` for the corresponding inbound tolerance. + */ +export function inboxReportShareUrl( + reportId: string, + title?: string | null, +): string { + const slug = slugifyTitle(title); + const path = slug ? `/inbox/${reportId}/${slug}` : `/inbox/${reportId}`; + return customSchemeUrl(path); +} + /** * Convert an incoming external URL (custom scheme or universal link) to the * router-relative path expo-router uses. Returns null if the URL doesn't * belong to us. * + * A `posthog://inbox//` link (or the universal-link equivalent) is + * normalized to `/inbox/` — the slug is decorative and the route only + * cares about the UUID. Mirrors the desktop receiver, which also ignores the + * slug. + * * Used by the auth gate to round-trip the originally-requested URL through * the sign-in flow. */ @@ -68,26 +120,41 @@ export function externalUrlToAppPath(url: string): AppPath | null { try { const parsed = new URL(url); + let path: AppPath | null = null; if (parsed.protocol === `${MOBILE_SCHEME}:`) { // posthog://task/abc → /task/abc const host = parsed.hostname; if (!host) return null; const rest = parsed.pathname || ""; const search = parsed.search || ""; - return `/${host}${rest}${search}`; - } - - if ( + path = `/${host}${rest}${search}`; + } else if ( (parsed.protocol === "https:" || parsed.protocol === "http:") && parsed.hostname === UNIVERSAL_LINK_HOST ) { // https://code.posthog.com/task/abc → /task/abc - const path = parsed.pathname || "/"; - return `${path}${parsed.search || ""}`; + const pathname = parsed.pathname || "/"; + path = `${pathname}${parsed.search || ""}`; } - return null; + if (path === null) return null; + return stripInboxSlugSuffix(path); } catch { return null; } } + +/** + * Collapse `/inbox//[?query]` to `/inbox/[?query]`. No-op for + * any path that isn't an inbox-report deep link with a trailing segment. + */ +function stripInboxSlugSuffix(path: AppPath): AppPath { + const queryStart = path.indexOf("?"); + const pathOnly = queryStart === -1 ? path : path.slice(0, queryStart); + const query = queryStart === -1 ? "" : path.slice(queryStart); + const segments = pathOnly.split("/").filter(Boolean); + if (segments.length >= 3 && segments[0] === "inbox") { + return `/inbox/${segments[1]}${query}`; + } + return path; +} diff --git a/apps/mobile/src/lib/posthog.test.ts b/apps/mobile/src/lib/posthog.test.ts new file mode 100644 index 0000000000..01c186b0cb --- /dev/null +++ b/apps/mobile/src/lib/posthog.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const expoApplication = { + nativeApplicationVersion: null as string | null, +}; + +const expoConstants = { + expoConfig: null as { version?: string } | null, +}; + +vi.mock("posthog-react-native", () => ({ + usePostHog: () => null, +})); + +vi.mock("expo-router", () => ({ + usePathname: () => "/", + useSegments: () => [] as string[], +})); + +vi.mock("expo-application", () => ({ + get nativeApplicationVersion() { + return expoApplication.nativeApplicationVersion; + }, +})); + +vi.mock("expo-constants", () => ({ + default: { + get expoConfig() { + return expoConstants.expoConfig; + }, + }, +})); + +// posthog.ts imports the auth store and user query for useIdentifyUser. Their +// real modules transitively pull in native expo modules (expo-secure-store, +// expo-auth-session) that can't load under the node test environment, so mock +// them — these app-version tests don't exercise identification. +vi.mock("@/features/auth/stores/authStore", () => ({ + useAuthStore: () => false, +})); + +vi.mock("@/features/auth/hooks/useUserQuery", () => ({ + useUserQuery: () => ({ data: undefined }), +})); + +beforeEach(() => { + vi.clearAllMocks(); + expoApplication.nativeApplicationVersion = null; + expoConstants.expoConfig = { version: "0.0.0-test" }; +}); + +describe("getAppVersion", () => { + it("prefers the native application version when present", async () => { + expoApplication.nativeApplicationVersion = "9.8.7"; + expoConstants.expoConfig = { version: "0.0.0-test" }; + + const { getAppVersion } = await import("./posthog"); + + expect(getAppVersion()).toBe("9.8.7"); + }); + + it("falls back to the Expo config version when no native version is available", async () => { + expoApplication.nativeApplicationVersion = null; + expoConstants.expoConfig = { version: "1.2.3" }; + + const { getAppVersion } = await import("./posthog"); + + expect(getAppVersion()).toBe("1.2.3"); + }); + + it("returns null when neither source has a version", async () => { + expoApplication.nativeApplicationVersion = null; + expoConstants.expoConfig = null; + + const { getAppVersion } = await import("./posthog"); + + expect(getAppVersion()).toBeNull(); + }); +}); + +describe("registerAppVersion", () => { + it("registers app_version as a super property on the PostHog client", async () => { + const register = vi.fn(); + const { registerAppVersion } = await import("./posthog"); + + registerAppVersion({ register }, "1.2.3"); + + expect(register).toHaveBeenCalledTimes(1); + expect(register).toHaveBeenCalledWith({ app_version: "1.2.3" }); + }); + + it("does nothing when the PostHog client is not yet available", async () => { + const { registerAppVersion } = await import("./posthog"); + + expect(() => registerAppVersion(null, "1.2.3")).not.toThrow(); + }); + + it("does nothing when no app version can be resolved", async () => { + const register = vi.fn(); + const { registerAppVersion } = await import("./posthog"); + + registerAppVersion({ register }, null); + + expect(register).not.toHaveBeenCalled(); + }); + + it("resolves the version from getAppVersion when none is provided", async () => { + expoApplication.nativeApplicationVersion = "4.5.6"; + const register = vi.fn(); + const { registerAppVersion } = await import("./posthog"); + + registerAppVersion({ register }); + + expect(register).toHaveBeenCalledWith({ app_version: "4.5.6" }); + }); +}); diff --git a/apps/mobile/src/lib/posthog.ts b/apps/mobile/src/lib/posthog.ts index 11fb741436..4813aabebd 100644 --- a/apps/mobile/src/lib/posthog.ts +++ b/apps/mobile/src/lib/posthog.ts @@ -1,6 +1,10 @@ +import * as Application from "expo-application"; +import Constants from "expo-constants"; import { usePathname, useSegments } from "expo-router"; import { usePostHog } from "posthog-react-native"; import { useEffect, useRef } from "react"; +import { useUserQuery } from "@/features/auth/hooks/useUserQuery"; +import { useAuthStore } from "@/features/auth/stores/authStore"; /** * PostHog configuration - used by PostHogProvider in _layout.tsx @@ -16,8 +20,57 @@ export const POSTHOG_OPTIONS = { captureLog: true, captureNetworkTelemetry: true, }, + errorTracking: { + autocapture: { + uncaughtExceptions: true, + unhandledRejections: true, + }, + }, }; +/** + * Resolve the app version that should ride along on every custom event. Prefer + * the native runtime value so OTA-updated binaries still report their actual + * shipped version; fall back to the Expo config (app.json) when running where + * expo-application has no native value (e.g. Expo Go, web preview). + */ +export function getAppVersion(): string | null { + return ( + Application.nativeApplicationVersion ?? + Constants.expoConfig?.version ?? + null + ); +} + +type PostHogRegisterClient = { + register: (properties: { app_version: string }) => unknown; +}; + +/** + * Register the app version as a PostHog super property so it is attached to + * every event the client emits. No-op if the client is not yet ready or no + * version is available. + */ +export function registerAppVersion( + client: PostHogRegisterClient | null | undefined, + version: string | null = getAppVersion(), +) { + if (!client || version === null) return; + client.register({ app_version: version }); +} + +/** + * Hook variant of `registerAppVersion`. Runs once per client instance so the + * super property is re-applied if the PostHog client is recreated. + */ +export function useRegisterAppVersion() { + const posthog = usePostHog(); + + useEffect(() => { + registerAppVersion(posthog); + }, [posthog]); +} + /** * Screen tracking hook for expo-router. * Must be used inside PostHogProvider. @@ -43,3 +96,62 @@ export function useScreenTracking() { } }, [pathname, segments, posthog]); } + +/** + * Associates captured events (and session replays) with the signed-in user. + * Re-identifies whenever the user's identifying properties change (email, name, + * staff status, organization) so mid-session updates are forwarded, and resets + * on logout so the next session starts anonymous and events don't bleed across + * accounts. Must be used inside PostHogProvider. + */ +export function useIdentifyUser() { + const posthog = usePostHog(); + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const { data: user } = useUserQuery(); + // Signature of the last forwarded payload, so we re-identify on real changes + // but don't spam identify()/group() on every render with identical data. + const lastIdentity = useRef(null); + + useEffect(() => { + if (!posthog) return; + + if (!isAuthenticated) { + // Reset only if we previously identified, otherwise we'd churn the + // anonymous distinct id on every render before sign-in. + if (lastIdentity.current) { + posthog.reset(); + lastIdentity.current = null; + } + return; + } + + if (!user) return; + + const name = [user.first_name, user.last_name].filter(Boolean).join(" "); + const isStaff = Boolean(user.is_staff); + const signature = JSON.stringify([ + user.uuid, + user.email, + name, + isStaff, + user.organization?.id ?? null, + user.organization?.name ?? null, + ]); + + if (lastIdentity.current === signature) return; + + posthog.identify(user.uuid, { + email: user.email, + name, + is_staff: isStaff, + }); + + if (user.organization) { + posthog.group("organization", user.organization.id, { + name: user.organization.name, + }); + } + + lastIdentity.current = signature; + }, [posthog, isAuthenticated, user]); +} diff --git a/packages/agent/build/native-binary.mjs b/packages/agent/build/native-binary.mjs new file mode 100644 index 0000000000..b60e06afdc --- /dev/null +++ b/packages/agent/build/native-binary.mjs @@ -0,0 +1,86 @@ +import { join } from "node:path"; + +/** + * Shared build-time helpers for resolving the Claude native binary that ships + * via `@anthropic-ai/claude-agent-sdk-${platform}-${arch}` optional deps. + * + * Used by both `packages/agent/tsup.config.ts` (bundles the binary into the + * agent package's `dist/claude-cli/`) and `apps/code/vite.main.config.mts` + * (copies it into the Electron app's `.vite/build/claude-cli/`). + * + * The runtime equivalent of this lives upstream in `acp-agent.ts` as + * `claudeCliPath()` + `isMuslLibc()`. Keep behavior in sync if the upstream + * resolution logic changes. + */ + +/** Cross-compile aware platform — electron-forge sets npm_config_platform when packaging for a target. */ +export function targetPlatform() { + return process.env.npm_config_platform ?? process.platform; +} + +/** Cross-compile aware arch — same story as targetPlatform. */ +export function targetArch() { + return process.env.npm_config_arch ?? process.arch; +} + +export function claudeBinName(platform = targetPlatform()) { + return platform === "win32" ? "claude.exe" : "claude"; +} + +export const CLAUDE_CLI_SUPPORT_FILES = [ + "package.json", + "manifest.json", + "manifest.zst.json", + "yoga.wasm", +]; + +export const CLAUDE_CLI_SUPPORT_DIRS = ["vendor"]; + +/** + * Detect whether the *current* Node was built against musl libc (not glibc). + * Only meaningful when targetPlatform() === "linux" and we're running on + * linux — cross-host packaging defaults to glibc ordering since we have no + * way to know the target's libc. + */ +export function isMuslLibc() { + if (process.platform !== "linux") return false; + const report = process.report?.getReport(); + const header = report?.header; + return !header?.glibcVersionRuntime; +} + +/** + * Ordered list of candidate paths to a Claude native binary inside a given + * node_modules root. First entry that exists should be preferred. + */ +export function nativeBinaryCandidates(rootNodeModules) { + const platform = targetPlatform(); + const arch = targetArch(); + const binary = claudeBinName(platform); + const slugs = + platform === "linux" + ? isMuslLibc() + ? [`linux-${arch}-musl`, `linux-${arch}`] + : [`linux-${arch}`, `linux-${arch}-musl`] + : [`${platform}-${arch}`]; + return slugs.map((slug) => + join(rootNodeModules, `@anthropic-ai/claude-agent-sdk-${slug}`, binary), + ); +} + +/** + * SDK 0.3.x is in the middle of transitioning from a monolithic `cli.js` + * package layout to platform-specific native executables. Keep the legacy + * entrypoint as a fallback until the optional native packages are universally + * available across our build environments. + */ +export function legacyCliCandidates(rootNodeModules) { + return [join(rootNodeModules, "@anthropic-ai/claude-agent-sdk", "cli.js")]; +} + +export function claudeExecutableCandidates(rootNodeModules) { + return [ + ...nativeBinaryCandidates(rootNodeModules), + ...legacyCliCandidates(rootNodeModules), + ]; +} diff --git a/packages/agent/package.json b/packages/agent/package.json index d836afe266..858be1e41a 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -120,9 +120,9 @@ "vitest": "^2.1.8" }, "dependencies": { - "@agentclientprotocol/sdk": "0.19.0", - "@anthropic-ai/claude-agent-sdk": "0.2.112", - "@anthropic-ai/sdk": "0.89.0", + "@agentclientprotocol/sdk": "0.22.1", + "@anthropic-ai/claude-agent-sdk": "0.3.156", + "@anthropic-ai/sdk": "0.100.1", "@hono/node-server": "^1.19.9", "@opentelemetry/api-logs": "^0.208.0", "@opentelemetry/exporter-logs-otlp-http": "^0.208.0", diff --git a/packages/agent/src/adapters/claude/UPSTREAM.md b/packages/agent/src/adapters/claude/UPSTREAM.md index d8c4108321..46ab27ccc3 100644 --- a/packages/agent/src/adapters/claude/UPSTREAM.md +++ b/packages/agent/src/adapters/claude/UPSTREAM.md @@ -5,8 +5,8 @@ Fork of `@anthropic-ai/claude-agent-acp`. Upstream repo: https://github.com/anth ## Fork Point - **Forked**: v0.10.9, commit `5411e0f4`, Dec 2 2025 -- **Last sync**: v0.30.0, commit `e9dd452`, April 20 2026 -- **SDK**: `@anthropic-ai/claude-agent-sdk` 0.2.112 (0.2.114 breaks session init, see agentclientprotocol/claude-agent-acp#575), `@agentclientprotocol/sdk` 0.19.0 +- **Last sync**: v0.39.0, commit `51a370e`, May 29 2026 +- **SDK**: `@anthropic-ai/claude-agent-sdk` 0.3.156, `@agentclientprotocol/sdk` 0.22.1, `@anthropic-ai/sdk` 0.100.1 ## File Mapping @@ -53,6 +53,7 @@ Fork of `@anthropic-ai/claude-agent-acp`. Upstream repo: https://github.com/anth | Auth methods | `claude-ai-login` + `console-login` | Returns empty `authMethods` | Auth handled externally | | Session fingerprinting | Implicit teardown on cwd/mcp change | Explicit `refreshSession()` | Caller-initiated is more predictable | | Shutdown on ACP close | Process exits | No standalone process | Agent is embedded in server | +| Unsupported slash commands | Loops silently on early idle | Emits "Unsupported slash command" chunk, gated on `initializationResult().commands` so plugin/skill commands (e.g. `/skills-store`) whose echoes use a fresh uuid are not false-flagged | The SDK consumes some slash commands without producing output (e.g. `/plugin` in non-interactive mode); without this we hang. The known-commands gate avoids racing plugin/skill loads where idle can arrive before the transformed user-message echo. | ## Changes Ported in v0.30.0 Sync @@ -66,6 +67,96 @@ Fork of `@anthropic-ai/claude-agent-acp`. Upstream repo: https://github.com/anth - **Effort level sync** (v0.25.x): `xhigh` level added, `applyFlagSettings` on effort change - **Auto permission mode** (v0.25.0): Added to `CODE_EXECUTION_MODES`, available modes, ExitPlanMode options +## Changes Ported in v0.39.0 Sync + +- **SDK bumps**: claude-agent-sdk 0.3.154 -> 0.3.156, anthropic SDK 0.100.0 -> 0.100.1 (ACP SDK + unchanged at 0.22.1). v0.3.155 was not published to npm; the fix lives in 0.3.156. +- **Opus 4.8 thinking-blocks fix** (upstream v2.1.156): The SDK was modifying thinking blocks in a + way that produced the legacy `thinking: { type: "enabled", budget_tokens: N }` request shape, + which `claude-opus-4-8` rejects with HTTP 400 (`thinking.type.enabled is not supported for this + model. Use thinking.type.adaptive and output_config.effort`). 0.3.156 now emits + `thinking: { type: "adaptive" }` + `output_config: { effort }` for Opus 4.8 while keeping the + legacy shape for Opus 4.7 / Sonnet 4.6 where the API still accepts it. No in-repo code change + needed; `options.effort` in `session/options.ts` and `query.applyFlagSettings({ effortLevel })` + in `claude-agent.ts` keep their current call sites. + +## Changes Ported in v0.38.0 Sync + +- **SDK bumps**: claude-agent-sdk 0.3.144 -> 0.3.154, anthropic SDK 0.96.0 -> 0.100.0 (ACP SDK + unchanged at 0.22.1). +- **Compaction state-flag fix** (#716, a172885): SDK 0.3.154 emits the terminal `status` carrying + `compact_result` twice for failed compactions. Added a per-turn `compactionInProgress` flag in + `prompt()` so the user sees a single `Compacting completed.` / `Compacting failed: ` + chunk. Manual `/compact` outcomes now surface here rather than via `compact_boundary` (which only + fires when there's content to compact). +- **System-role guard on user/assistant handler** (#716, a172885): Added an early return in + `handleUserAssistantMessage` for `message.message.role === "system"`, covering both upstream's + `` strip branch guard and the broader assistant-handler guard. Avoids + rendering SDK-injected system reminders as user-visible chunks. +- **New no-op content block types** (#716, a172885): Added `advisor_tool_result` and + `mid_conv_system` cases to `processContentChunk` so unknown content blocks don't trip the + `unreachable` default. +- **Opus 4.8 model entries** (#718, 98b54a0): Added `claude-opus-4-8` to gateway model maps with + 1M context, effort and xhigh-effort support. MCP injection auto-included (Haiku exclusion only). + +## Skipped in v0.38.0 Sync + +- **Remove hide Claude auth flag** (#707, 7ed1daf): Our fork already returns `authMethods: []` + unconditionally; no flag to remove. +- **`thinking_tokens` status case** (#716, a172885): Our `handleSystemMessage` switch on + `status === "compacting"` is non-exhaustive (no default `unreachable`), so unknown status values + already no-op harmlessly. +- **Empty CI-retry commit** (#718, 98b54a0): No code change in the commit itself; the model entries + it carried are ported above. +- **`MessageDisplay` hook + `SessionStart` reloadSkills/sessionTitle** (SDK 0.3.152): Available in + the bumped SDK but not wired into our fork; upstream doesn't consume them in #716 either. Defer + to a focused PR if we want the capability. + +## Changes Ported in v0.37.0 Sync + +- **SDK bumps**: claude-agent-sdk 0.2.114 -> 0.3.144, ACP SDK 0.19.0 -> 0.22.1, anthropic SDK 0.89.0 -> 0.96.0 +- **TodoWrite -> Task tools migration** (SDK 0.3.142): Replaced TodoWrite snapshot tool with incremental + TaskCreate/TaskUpdate/TaskGet/TaskList. Added `conversion/task-state.ts` and `createTaskHook` to mirror the + SDK `TaskCreated`/`TaskCompleted` hook events into a per-session task map; plan entries are derived from + Map insertion order (preserves upstream ordering semantics). +- **MCP_CONNECTION_NONBLOCKING=0** (SDK 0.3.142): SDK changed MCP servers to background-connect by default; + set env to restore blocking-connect behavior so MCP tools are available on first prompt. +- **ACP SDK 0.22 breaking changes**: Renamed `unstable_resumeSession` -> `resumeSession`; new + `McpSdkServerConfig` variant (`type: "sdk"`) in the `McpServerConfig` union. Our + `parseMcpServers` only accepts `http`/`sse`/stdio entries, so `sdk` falls through and is + implicitly dropped (no explicit filter needed). +- **Skills option** (SDK 0.2.133): `'Skill'` in `allowedTools` deprecated; replaced with `skills` option. +- **Memory recall tool calls** (#703, a0bfb98): Emit a `tool_call` for SDK `memory_recall` events so the + UI shows what memories were surfaced; addresses phantom MEMORY.md read attempts. +- **Write diff fix** (#618, 8d7e220): `toolUpdateFromEditToolResponse` now also processes `Write` tool + responses so overwrites show real diffs instead of optimistic "creation" diffs. +- **Local-command-stdout render** (#649, 3b9b7d5): Strip marker tags from `` content + and render remaining prose so custom slash commands and skill expansions reach the UI. +- **Cancelled vs end_turn** (#694, 2414a6f): `session_state_changed: idle` handler now reports + `stopReason: "cancelled"` when the session was interrupted. +- **Recover prompt stream** (#706, 2711f50): After a failed turn, drain the trailing + `session_state_changed: idle` so the next prompt's first `query.next()` doesn't short-circuit. +- **additionalDirectories field** (#684, f37e9a0): Accept the official ACP field on session lifecycle + requests; advertise via `sessionCapabilities.additionalDirectories`. Legacy `_meta.additionalRoots` still + honored as fallback. +- **availableModels allowlist** (#637, 867a3a0): `ClaudeCodeSettings.availableModels` array merged-and-deduped + across settings sources, then applied to gateway model options via `applyAvailableModelsAllowlist`. +- **Model alias version match** (#702, e1e1c69): Refuse cross-version alias matches in `resolveModelPreference` + so `claude-opus-4-6` doesn't get copied onto the `opus` alias when it resolves to 4.7. +- **Hide /clear** (#705, cfce130): `/clear` removed from advertised commands; clients should use + `session/new` for the same effect. +- **No-op ping events** (#698, 694221a): `streamEventToAcpNotifications` no-ops `ping` keep-alive events + instead of falling through to `unreachable` and spamming stderr. + +## Skipped in v0.37.0 Sync + +- **Avoid redundant initial model sync** (#704, b275f6f): Our flow already guards `setModel` behind + `!isResume && resolvedSdkModel !== DEFAULT_MODEL`, so the upstream optimization is redundant. +- **Default effort option** (#701, 9e259d1): Our effort options are model-class-based rather than + SDK-supplied; the implicit no-override path already covers the "let SDK decide" case. +- **Gate auto mode on model support** (#604, ec47d34): Our `auto` mode is gated behind `ALLOW_BYPASS`, + not per-model `supportsAutoMode`. Per-model gating would be a larger refactor. + ## Skipped in v0.30.0 Sync - **Separate auth methods** (v0.25.0): PostHog returns empty authMethods @@ -74,7 +165,7 @@ Fork of `@anthropic-ai/claude-agent-acp`. Upstream repo: https://github.com/anth ## Next Sync -1. Check upstream changelog since v0.30.0 +1. Check upstream changelog since v0.37.0 2. Diff upstream source against PostHog Code using the file mapping above 3. Port in phases: bug fixes first, then features 4. After each phase: `pnpm --filter agent typecheck && pnpm --filter agent build && pnpm lint` diff --git a/packages/agent/src/adapters/claude/claude-agent.refresh.test.ts b/packages/agent/src/adapters/claude/claude-agent.refresh.test.ts index e279949236..0671f6c3c0 100644 --- a/packages/agent/src/adapters/claude/claude-agent.refresh.test.ts +++ b/packages/agent/src/adapters/claude/claude-agent.refresh.test.ts @@ -278,6 +278,29 @@ describe("ClaudeAcpAgent.extMethod refresh_session", () => { ); }); + it("recovers when interrupting the old query throws Operation aborted", async () => { + const agent = makeAgent(); + const { session, oldQuery, endSpy } = installFakeSession( + agent, + "s-interrupt-throws", + ); + oldQuery.interrupt.mockRejectedValue(new Error("Operation aborted")); + + const result = await agent.extMethod(POSTHOG_METHODS.REFRESH_SESSION, { + mcpServers: freshMcpServers, + }); + + expect(result).toEqual({ refreshed: true }); + expect(endSpy).toHaveBeenCalledTimes(1); + const updated = session as unknown as { + query: SdkQueryHandle; + abortController: AbortController; + }; + expect(updated.query).toBe(createdQueries[0]); + expect(updated.query).not.toBe(oldQuery); + expect(updated.abortController.signal.aborted).toBe(false); + }); + it("re-fetches MCP tool metadata for the new query", async () => { const agent = makeAgent(); installFakeSession(agent, "s-metadata"); diff --git a/packages/agent/src/adapters/claude/claude-agent.slash-command.test.ts b/packages/agent/src/adapters/claude/claude-agent.slash-command.test.ts index 1c4bb0fd36..2ab70025cb 100644 --- a/packages/agent/src/adapters/claude/claude-agent.slash-command.test.ts +++ b/packages/agent/src/adapters/claude/claude-agent.slash-command.test.ts @@ -34,7 +34,11 @@ function makeAgent(): { agent: Agent; client: ClientMocks } { return { agent, client }; } -function installFakeSession(agent: Agent, sessionId: string): MockQuery { +function installFakeSession( + agent: Agent, + sessionId: string, + knownSlashCommands?: Set, +): MockQuery { const query = createMockQuery(); const input = new Pushable(); const abortController = new AbortController(); @@ -63,6 +67,7 @@ function installFakeSession(agent: Agent, sessionId: string): MockQuery { taskRunId: "run-1", lastContextWindowSize: 200_000, modelId: "claude-sonnet-4-6", + knownSlashCommands, }; (agent as unknown as { session: typeof session }).session = session; @@ -99,6 +104,7 @@ describe("ClaudeAcpAgent.prompt — early idle handling", () => { label: "unsupported slash command surfaces error and ends turn", sessionId: "s-slash", prompt: "/plugin install slack", + knownCommands: undefined, expectsUnsupportedChunk: true, commandInMessage: "/plugin", }, @@ -106,6 +112,16 @@ describe("ClaudeAcpAgent.prompt — early idle handling", () => { label: "non-slash prompt with early idle is silently skipped", sessionId: "s-regular", prompt: "hello", + knownCommands: undefined, + expectsUnsupportedChunk: false, + commandInMessage: null, + }, + { + label: + "known plugin/skill command with early idle is not flagged as unsupported", + sessionId: "s-skill", + prompt: "/skills-store use my address pr review skill", + knownCommands: new Set(["skills-store"]), expectsUnsupportedChunk: false, commandInMessage: null, }, @@ -113,7 +129,11 @@ describe("ClaudeAcpAgent.prompt — early idle handling", () => { it.each(cases)("$label", async (tc) => { const { agent, client } = makeAgent(); - const query = installFakeSession(agent, tc.sessionId); + const query = installFakeSession( + agent, + tc.sessionId, + tc.knownCommands as Set | undefined, + ); const promptPromise = agent.prompt({ sessionId: tc.sessionId, diff --git a/packages/agent/src/adapters/claude/claude-agent.ts b/packages/agent/src/adapters/claude/claude-agent.ts index 1ff657452d..b7d7971a52 100644 --- a/packages/agent/src/adapters/claude/claude-agent.ts +++ b/packages/agent/src/adapters/claude/claude-agent.ts @@ -43,6 +43,7 @@ import { type Query, query, type SDKUserMessage, + type SlashCommand, } from "@anthropic-ai/claude-agent-sdk"; import { v7 as uuidv7 } from "uuid"; import packageJson from "../../../package.json" with { type: "json" }; @@ -57,12 +58,8 @@ import { type FileEnrichmentDeps, } from "../../enrichment/file-enricher"; import type { PostHogAPIConfig } from "../../types"; -import { - isCloudRun, - resolveGithubToken, - unreachable, - withTimeout, -} from "../../utils/common"; +import { isCloudRun, unreachable, withTimeout } from "../../utils/common"; +import { resolveGithubToken } from "../../utils/github-token"; import { Logger } from "../../utils/logger"; import { Pushable } from "../../utils/streams"; import { BaseAcpAgent } from "../base-acp-agent"; @@ -83,6 +80,11 @@ import { handleSystemMessage, handleUserAssistantMessage, } from "./conversion/sdk-to-acp"; +import { + rehydrateTaskState, + type TaskState, + taskStateToPlanEntries, +} from "./conversion/task-state"; import type { EnrichedReadCache } from "./hooks"; import { createLocalToolsMcpServer } from "./mcp/local-tools"; import { @@ -94,6 +96,10 @@ import { import { canUseTool } from "./permissions/permission-handlers"; import { getAvailableSlashCommands } from "./session/commands"; import { parseMcpServers } from "./session/mcp-config"; +import { + applyAvailableModelsAllowlist, + resolveInitialModelId, +} from "./session/model-config"; import { DEFAULT_MODEL, getEffortOptions, @@ -143,6 +149,17 @@ function readClaudeMdQuietly(cwd: string, logger: Logger): string | undefined { } } +function collectKnownSlashCommands( + commands: SlashCommand[] | undefined, +): Set { + const names = new Set(); + if (!commands) return names; + for (const cmd of commands) { + if (cmd.name) names.add(cmd.name); + } + return names; +} + function sanitizeTitle(text: string): string { const sanitized = text .replace(/[\r\n]+/g, " ") @@ -225,6 +242,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { }, loadSession: true, sessionCapabilities: { + additionalDirectories: {}, list: {}, fork: {}, resume: {}, @@ -257,11 +275,19 @@ export class ClaudeAcpAgent extends BaseAcpAgent { throw RequestError.authRequired(); } - const response = await this.createSession(params, { - // Revisit these meta values once we support resume - resume: (params._meta as NewSessionMeta | undefined)?.claudeCode?.options - ?.resume as string | undefined, - }); + const response = await this.createSession( + { + cwd: params.cwd, + mcpServers: params.mcpServers ?? [], + additionalDirectories: params.additionalDirectories, + _meta: params._meta, + }, + { + // Revisit these meta values once we support resume + resume: (params._meta as NewSessionMeta | undefined)?.claudeCode + ?.options?.resume as string | undefined, + }, + ); return response; } @@ -273,13 +299,14 @@ export class ClaudeAcpAgent extends BaseAcpAgent { { cwd: params.cwd, mcpServers: params.mcpServers ?? [], + additionalDirectories: params.additionalDirectories, _meta: params._meta, }, { resume: params.sessionId, forkSession: true }, ); } - async unstable_resumeSession( + async resumeSession( params: ResumeSessionRequest, ): Promise { // Reuse existing session if it matches @@ -290,6 +317,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { { cwd: params.cwd, mcpServers: params.mcpServers ?? [], + additionalDirectories: params.additionalDirectories, _meta: params._meta, }, { @@ -297,6 +325,8 @@ export class ClaudeAcpAgent extends BaseAcpAgent { }, ); + await this.rehydrateTaskStateFromJsonl(params.sessionId); + return response; } @@ -309,6 +339,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { { cwd: params.cwd, mcpServers: params.mcpServers ?? [], + additionalDirectories: params.additionalDirectories, _meta: params._meta, }, { resume: params.sessionId, skipBackgroundFetches: true }, @@ -409,6 +440,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { this.session.promptRunning = true; let handedOff = false; + let errored = false; let lastAssistantTotalUsage: number | null = null; let lastStreamUsage = { input_tokens: 0, @@ -416,6 +448,13 @@ export class ClaudeAcpAgent extends BaseAcpAgent { cache_read_input_tokens: 0, cache_creation_input_tokens: 0, }; + // Tracks whether we're inside a compaction. The SDK emits the terminal + // `status` (compact_result success/failed) twice for a single failed + // compaction, and the two messages are indistinguishable, so we report the + // outcome only while a compaction is in progress, then clear this. A fresh + // `compacting` status sets it again, so every distinct compaction (e.g. + // repeated auto-compactions in a long turn) is still shown. + let compactionInProgress = false; if (this.session.lastContextWindowSize == null) { this.session.lastContextWindowSize = this.getContextWindowForModel( this.session.modelId ?? "", @@ -491,6 +530,54 @@ export class ClaudeAcpAgent extends BaseAcpAgent { if (message.subtype === "local_command_output") { promptReplayed = true; } + if (message.subtype === "status") { + // The SDK signals manual `/compact` completion with a status + // message carrying `compact_result`, not the `compact_boundary` + // message (which only fires when there's content to compact). + // Gate the user-facing outcome on `compactionInProgress` to + // dedupe the duplicate terminal status the SDK emits for failed + // compactions. + if (message.status === "compacting") { + compactionInProgress = true; + // Fall through to handleSystemMessage so the COMPACTING + // extNotification still fires. + } else if ( + message.compact_result === "success" && + compactionInProgress + ) { + compactionInProgress = false; + await this.client.sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: "\n\nCompacting completed.", + }, + }, + }); + break; + } else if ( + message.compact_result === "failed" && + compactionInProgress + ) { + compactionInProgress = false; + const reason = message.compact_error + ? `: ${message.compact_error}` + : "."; + await this.client.sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: `\n\nCompacting failed${reason}`, + }, + }, + }); + break; + } + } if ( message.subtype === "session_state_changed" && (message as Record).state === "idle" @@ -500,7 +587,17 @@ export class ClaudeAcpAgent extends BaseAcpAgent { // and produced no output (e.g. /plugin in a non-interactive // context). Without this branch we would loop forever waiting // for an echo that never comes; surface a clear error instead. - if (commandMatch) { + // + // Only fire for commands the SDK does NOT recognize. Plugin + // and skill commands (e.g. /skills-store) produce a fresh + // user-message echo with a new uuid that our replay check + // can't match, so an early idle here is a race, not a real + // "unsupported" — fall through and let the loop continue. + const cmdName = commandMatch?.[1].slice(1); + const known = + cmdName !== undefined && + this.session.knownSlashCommands?.has(cmdName) === true; + if (commandMatch && !known) { const cmd = commandMatch[1]; this.logger.warn( "Slash command produced no output; treating as unsupported", @@ -520,6 +617,8 @@ export class ClaudeAcpAgent extends BaseAcpAgent { } this.logger.debug("Skipping idle state before prompt replay", { sessionId: params.sessionId, + command: commandMatch?.[1], + known, }); break; } @@ -540,7 +639,9 @@ export class ClaudeAcpAgent extends BaseAcpAgent { }, }); - return { stopReason: "end_turn" }; + return { + stopReason: this.session.cancelled ? "cancelled" : "end_turn", + }; } await handleSystemMessage(message, context); break; @@ -814,6 +915,37 @@ export class ClaudeAcpAgent extends BaseAcpAgent { } throw new Error("Session did not end in result"); } catch (error) { + errored = true; + // A failed turn typically leaves a trailing `session_state_changed: idle` + // (and possibly more) in the query iterator. If we don't drain it here, + // the next prompt's first `query.next()` consumes that stale idle and + // short-circuits to end_turn with zero usage. + try { + await this.session.query.interrupt(); + const MAX_DRAIN = 100; + for (let i = 0; i < MAX_DRAIN; i++) { + const { value: m, done } = await this.session.query.next(); + if (done || !m) break; + if ( + m.type === "system" && + m.subtype === "session_state_changed" && + (m as Record).state === "idle" + ) { + break; + } + if (i === MAX_DRAIN - 1) { + this.logger.error( + `Session ${params.sessionId}: drained ${MAX_DRAIN} messages after error without observing idle`, + ); + } + } + } catch (drainErr) { + this.logger.error( + `Session ${params.sessionId}: failed to drain query after prompt error`, + { error: drainErr }, + ); + } + if (error instanceof RequestError || !(error instanceof Error)) { throw error; } @@ -844,10 +976,25 @@ export class ClaudeAcpAgent extends BaseAcpAgent { this.toolUseStreamCache.clear(); if (!handedOff) { this.session.promptRunning = false; - // Resolve all remaining pending prompts so no callers get stuck. - for (const [key, pending] of this.session.pendingMessages) { - pending.resolve(true); - this.session.pendingMessages.delete(key); + if (errored) { + // The query stream was just drained — handing pending prompts off + // onto it would let them race with the recovery. Cancel them so + // each waiting prompt() returns stopReason "cancelled" and the + // client can decide whether to retry. + for (const pending of this.session.pendingMessages.values()) { + pending.resolve(true); + } + this.session.pendingMessages.clear(); + } else if (this.session.pendingMessages.size > 0) { + // Clean exit with queued prompts: hand off the lowest-order one + // so it can proceed. The rest stay queued for their own turn. + const next = [...this.session.pendingMessages.entries()].sort( + (a, b) => a[1].order - b[1].order, + )[0]; + if (next) { + next[1].resolve(false); + this.session.pendingMessages.delete(next[0]); + } } } } @@ -909,6 +1056,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { const mcpServers = parseMcpServers( params as Pick, + this.logger, ); await this.refreshSession(mcpServers); return { refreshed: true }; @@ -941,7 +1089,14 @@ export class ClaudeAcpAgent extends BaseAcpAgent { // We allocate a fresh controller for the new Query below so aborting // the old one doesn't poison it. prev.abortController.abort(); - await prev.query.interrupt(); + try { + await prev.query.interrupt(); + } catch (error) { + this.logger.debug("Ignoring interrupt error during session refresh", { + sessionId: this.sessionId, + error: error instanceof Error ? error.message : String(error), + }); + } prev.input.end(); // Reuse every option from the running session; swap mcpServers, re-root @@ -1030,7 +1185,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { // For model options, fall back to alias resolution when exact match fails. // This lets callers use human-friendly aliases like "opus" or "sonnet" - // instead of full model IDs like "claude-opus-4-6". + // instead of full model IDs like "claude-opus-4-8". if (!validValue && params.configId === "model") { const resolved = resolveModelPreference(params.value, allValues); if (resolved) { @@ -1142,6 +1297,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { params: { cwd: string; mcpServers: NewSessionRequest["mcpServers"]; + additionalDirectories?: NewSessionRequest["additionalDirectories"]; _meta?: unknown; }, creationOpts: { @@ -1181,7 +1337,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { const earlyModelId = settingsManager.getSettings().model || meta?.model || ""; const mcpServers = supportsMcpInjection(earlyModelId) - ? parseMcpServers(params) + ? parseMcpServers(params, this.logger) : {}; // Register the in-process general local-tools MCP server. Tools self-gate @@ -1226,6 +1382,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { ? (meta.permissionMode as CodeExecutionMode) : "default"; + const taskState: TaskState = new Map(); const options = buildSessionOptions({ cwd, mcpServers, @@ -1239,7 +1396,10 @@ export class ClaudeAcpAgent extends BaseAcpAgent { forkSession, additionalDirectories: [ ...(meta?.claudeCode?.options?.additionalDirectories ?? []), - ...(meta?.additionalRoots ?? []), + // Prefer the official ACP `additionalDirectories` field. Fall back + // to the legacy `_meta.additionalRoots` extension for clients that + // haven't been updated yet. + ...(params.additionalDirectories ?? meta?.additionalRoots ?? []), ], disableBuiltInTools: meta?.disableBuiltInTools, outputFormat, @@ -1251,6 +1411,16 @@ export class ClaudeAcpAgent extends BaseAcpAgent { enrichmentDeps: this.enrichment?.deps, enrichedReadCache: this.enrichedReadCache, cloudMode: cloudRun, + taskState, + onTaskStateChange: async () => { + await this.client.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "plan", + entries: taskStateToPlanEntries(taskState), + }, + }); + }, }); // Use the same abort controller that buildSessionOptions gave to the query @@ -1283,6 +1453,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { systemPrompt: estimateSystemPrompt(systemPrompt), rules: estimateRulesTokens(readClaudeMdQuietly(cwd, this.logger)), }, + taskState, // Custom properties cwd, @@ -1305,6 +1476,9 @@ export class ClaudeAcpAgent extends BaseAcpAgent { `Session ${forkSession ? "fork" : "resumption"} timed out for sessionId=${sessionId}`, ); } + session.knownSlashCommands = collectKnownSlashCommands( + result.value.commands, + ); } catch (err) { settingsManager.dispose(); if ( @@ -1332,7 +1506,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { ? withTimeout(q.initializationResult(), SESSION_VALIDATION_TIMEOUT_MS) : undefined; - const [modelOptions] = await Promise.all([ + const [rawModelOptions] = await Promise.all([ this.getModelConfigOptions( settingsManager.getSettings().model || meta?.model || undefined, ), @@ -1347,6 +1521,16 @@ export class ClaudeAcpAgent extends BaseAcpAgent { : []), ]); + // Restrict the model list to the user's `availableModels` allowlist + // from settings.json so config UI and downstream resolution stay + // consistent with what the user configured. The Default option is + // always preserved per the Claude Code docs. + const settingsAvailableModels = + settingsManager.getSettings().availableModels; + const modelOptions = Array.isArray(settingsAvailableModels) + ? applyAvailableModelsAllowlist(rawModelOptions, settingsAvailableModels) + : rawModelOptions; + if (initPromise) { try { const initResult = await initPromise; @@ -1356,6 +1540,9 @@ export class ClaudeAcpAgent extends BaseAcpAgent { `Session initialization timed out for sessionId=${sessionId}`, ); } + session.knownSlashCommands = collectKnownSlashCommands( + initResult.value.commands, + ); } catch (err) { settingsManager.dispose(); this.logger.error("Session initialization failed", { @@ -1368,10 +1555,10 @@ export class ClaudeAcpAgent extends BaseAcpAgent { } } - const settingsModel = settingsManager.getSettings().model; - const metaModel = meta?.model; - const resolvedModelId = - settingsModel || metaModel || modelOptions.currentModelId; + const resolvedModelId = resolveInitialModelId(modelOptions, [ + settingsManager.getSettings().model, + meta?.model, + ]); session.modelId = resolvedModelId; session.lastContextWindowSize = this.getContextWindowForModel(resolvedModelId); @@ -1635,6 +1822,35 @@ export class ClaudeAcpAgent extends BaseAcpAgent { }; } + /** + * Rebuild the in-memory taskState from JSONL and push a plan update so the + * client's plan panel reflects pre-resume tasks. `loadSession` already covers + * this via the full `replaySessionHistory` notification stream; resume + * deliberately stays quiet (the client keeps its own message history) so we + * walk the transcript here for state only. + */ + private async rehydrateTaskStateFromJsonl(sessionId: string): Promise { + try { + const messages = await getSessionMessages(sessionId, { + dir: this.session.cwd, + }); + rehydrateTaskState(messages, this.session.taskState); + if (this.session.taskState.size === 0) return; + await this.client.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "plan", + entries: taskStateToPlanEntries(this.session.taskState), + }, + }); + } catch (err) { + this.logger.warn("Failed to rehydrate task state", { + sessionId, + error: err instanceof Error ? err.message : String(err), + }); + } + } + private async replaySessionHistory(sessionId: string): Promise { try { const messages = await getSessionMessages(sessionId, { diff --git a/packages/agent/src/adapters/claude/conversion/sdk-to-acp.ts b/packages/agent/src/adapters/claude/conversion/sdk-to-acp.ts index 43defc3c2d..633aa20ae9 100644 --- a/packages/agent/src/adapters/claude/conversion/sdk-to-acp.ts +++ b/packages/agent/src/adapters/claude/conversion/sdk-to-acp.ts @@ -12,6 +12,10 @@ import type { SDKResultMessage, SDKUserMessage, } from "@anthropic-ai/claude-agent-sdk"; +import type { + TaskCreateInput, + TaskUpdateInput, +} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js"; import type { ContentBlockParam } from "@anthropic-ai/sdk/resources"; import type { BetaContentBlock, @@ -31,8 +35,13 @@ import type { ToolUseStreamCache, } from "../types"; import { - type ClaudePlanEntry, - planEntries, + applyTaskCreate, + applyTaskUpdate, + parseTaskCreateOutput, + type TaskState, + taskStateToPlanEntries, +} from "./task-state"; +import { toolInfoFromToolUse, toolUpdateFromEditToolResponse, toolUpdateFromToolResult, @@ -49,7 +58,6 @@ interface AnthropicMessageWithContent { type: Role; message: { content: AnthropicMessageContent; - role?: Role; model?: string; }; } @@ -67,6 +75,8 @@ type ChunkHandlerContext = { cwd?: string; /** Raw MCP tool result from SDKUserMessage.tool_use_result (contains content, structuredContent, _meta) */ mcpToolUseResult?: Record; + /** Per-session task list (populated by createTaskHook + tool_result handler) */ + taskState?: TaskState; }; export interface MessageHandlerContext { @@ -168,14 +178,14 @@ function handleToolUseChunk( const alreadyCached = chunk.id in ctx.toolUseCache; ctx.toolUseCache[chunk.id] = chunk; - if (chunk.name === "TodoWrite") { - const input = chunk.input as { todos?: unknown[] }; - if (Array.isArray(input.todos)) { - return { - sessionUpdate: "plan", - entries: planEntries(chunk.input as { todos: ClaudePlanEntry[] }), - }; - } + // Suppress Task* tool_calls — plan updates are emitted from the matching + // tool_result handler instead, after taskState has been mutated. + if ( + chunk.name === "TaskCreate" || + chunk.name === "TaskUpdate" || + chunk.name === "TaskList" || + chunk.name === "TaskGet" + ) { return null; } @@ -185,7 +195,7 @@ function handleToolUseChunk( const toolUse = ctx.toolUseCache[toolUseId]; if (toolUse) { const editUpdate = - toolUse.name === "Edit" + toolUse.name === "Edit" || toolUse.name === "Write" ? toolUpdateFromEditToolResponse(toolResponse) : null; @@ -334,7 +344,33 @@ function handleToolResultChunk( return []; } - if (toolUse.name === "TodoWrite") { + if ( + toolUse.name === "TaskCreate" || + toolUse.name === "TaskUpdate" || + toolUse.name === "TaskList" || + toolUse.name === "TaskGet" + ) { + if (chunk.is_error || !ctx.taskState) return []; + if (toolUse.name === "TaskCreate") { + applyTaskCreate( + ctx.taskState, + toolUse.input as TaskCreateInput | undefined, + parseTaskCreateOutput(chunk.content), + ); + } else if (toolUse.name === "TaskUpdate") { + applyTaskUpdate( + ctx.taskState, + toolUse.input as TaskUpdateInput | undefined, + ); + } + if (toolUse.name === "TaskCreate" || toolUse.name === "TaskUpdate") { + return [ + { + sessionUpdate: "plan", + entries: taskStateToPlanEntries(ctx.taskState), + }, + ]; + } return []; } @@ -460,6 +496,8 @@ function processContentChunk( case "container_upload": case "compaction": case "compaction_delta": + case "advisor_tool_result": + case "mid_conv_system": return []; default: @@ -486,6 +524,7 @@ function toAcpNotifications( cwd?: string, mcpToolUseResult?: Record, enrichedReadCache?: EnrichedReadCache, + taskState?: TaskState, ): SessionNotification[] { if (typeof content === "string") { const update: SessionUpdate = { @@ -514,6 +553,7 @@ function toAcpNotifications( supportsTerminalOutput, cwd, mcpToolUseResult, + taskState, }; const output: SessionNotification[] = []; @@ -539,6 +579,7 @@ function streamEventToAcpNotifications( supportsTerminalOutput?: boolean, cwd?: string, enrichedReadCache?: EnrichedReadCache, + taskState?: TaskState, ): SessionNotification[] { const event = message.event; switch (event.type) { @@ -564,6 +605,7 @@ function streamEventToAcpNotifications( cwd, undefined, enrichedReadCache, + taskState, ); } case "content_block_delta": { @@ -589,11 +631,17 @@ function streamEventToAcpNotifications( cwd, undefined, enrichedReadCache, + taskState, ); } case "content_block_stop": toolUseStreamCache.delete(event.index); return []; + // `ping` is a Messages-API keep-alive event that the SDK's + // `BetaRawMessageStreamEvent` union doesn't include even though the + // wire format emits it; the `as never` cast lets us no-op it here + // instead of falling through to `unreachable`. + case "ping" as never: case "message_start": case "message_delta": case "message_stop": @@ -678,6 +726,52 @@ export async function handleSystemMessage( }); break; } + case "memory_recall": { + const isSynthesis = message.mode === "synthesize"; + // Skip empty recalls — they're the dominant source of UI clutter on + // memory-heavy turns and carry no signal (no paths, no content). + if (!isSynthesis && message.memories.length === 0) break; + const locations = isSynthesis + ? [] + : message.memories.map((m) => ({ path: m.path })); + const content = isSynthesis + ? message.memories + .filter( + ( + m, + ): m is (typeof message.memories)[number] & { + content: string; + } => typeof m.content === "string", + ) + .map((m) => ({ + type: "content" as const, + content: { type: "text" as const, text: m.content }, + })) + : []; + const count = message.memories.length; + const title = isSynthesis + ? "Recalled synthesized memory" + : `Recalled ${count} ${count === 1 ? "memory" : "memories"}`; + await client.sessionUpdate({ + sessionId: message.session_id, + update: { + sessionUpdate: "tool_call", + toolCallId: message.uuid, + title, + kind: "read", + status: "completed", + ...(locations.length > 0 && { locations }), + ...(content.length > 0 && { content }), + _meta: { + claudeCode: { + toolName: "memory_recall", + toolResponse: { mode: message.mode }, + }, + } satisfies ToolUpdateMeta, + }, + }); + break; + } default: break; } @@ -819,6 +913,7 @@ export async function handleStreamEvent( context.supportsTerminalOutput, context.session.cwd, context.enrichedReadCache, + context.session.taskState, )) { await client.sessionUpdate(notification); context.session.notificationHistory.push(notification); @@ -837,6 +932,41 @@ function hasLocalCommandStderr(content: AnthropicMessageContent): boolean { ); } +// SDK-persisted slash command invocations always lead with ``. +// Requiring that anchor keeps user-typed prompts that happen to contain a +// literal `` tag from being scrubbed on session reload. +function isSdkLocalCommandMessage(content: AnthropicMessageContent): boolean { + return ( + typeof content === "string" && + content.includes("") && + (content.includes("") || + content.includes("")) + ); +} + +// The Claude SDK persists local slash command invocations (e.g. `/model`) and +// their output as user messages wrapping the payload in these XML-like markers +// that the CLI uses for its own display. The live prompt loop must strip them +// so they don't leak into the UI, while preserving any real prose mixed in +// alongside. +const LOCAL_COMMAND_TAG_PATTERN = + /<(command-name|command-message|command-args|local-command-stdout|local-command-stderr)>[\s\S]*?<\/\1>/g; + +function stripMarkerTags(text: string): string { + return text.replace(LOCAL_COMMAND_TAG_PATTERN, ""); +} + +/** + * Returns the string with local-command marker tags removed, or `null` if + * nothing renderable remains. Used to surface custom slash commands and + * skill expansions whose bodies arrive wrapped in marker tags, while + * still no-op'ing for pure-marker payloads like /compact. + */ +function stripLocalCommandMetadata(content: string): string | null { + const stripped = stripMarkerTags(content); + return stripped.trim() === "" ? null : stripped; +} + function isLoginRequiredMessage(message: AnthropicMessageWithContent): boolean { return ( message.type === "assistant" && @@ -863,8 +993,7 @@ function shouldSkipUserAssistantMessage( message: AnthropicMessageWithContent, ): boolean { return ( - hasLocalCommandStdout(message.message.content) || - hasLocalCommandStderr(message.message.content) || + isSdkLocalCommandMessage(message.message.content) || isLoginRequiredMessage(message) ); } @@ -900,12 +1029,48 @@ export async function handleUserAssistantMessage( const { session, sessionId, client, toolUseCache, fileContentCache, logger } = context; + // System-role payloads (e.g. SDK-injected reminders) reach the user/assistant + // switch but are never user-visible content; skip rendering them entirely. + if (message.message.role === "system") { + return {}; + } + if (shouldSkipUserAssistantMessage(message)) { logSpecialMessages(message, logger); if (isLoginRequiredMessage(message)) { return { shouldStop: true, error: RequestError.authRequired() }; } + + // Strip local-command marker tags and render whatever real prose remains + // so that custom slash commands and skill expansions (whose bodies arrive + // wrapped in / markers) reach the UI. + // Pure-marker payloads (e.g. /compact) still no-op via the `null` branch. + const rawContent = message.message.content; + if (typeof rawContent === "string") { + const stripped = stripLocalCommandMetadata(rawContent); + if (stripped !== null) { + for (const notification of toAcpNotifications( + stripped, + message.message.role as Role, + sessionId, + toolUseCache, + fileContentCache, + client, + logger, + undefined, + context.registerHooks, + context.supportsTerminalOutput, + session.cwd, + undefined, + context.enrichedReadCache, + session.taskState, + )) { + await client.sessionUpdate(notification); + session.notificationHistory.push(notification); + } + } + } return {}; } @@ -931,7 +1096,7 @@ export async function handleUserAssistantMessage( for (const notification of toAcpNotifications( contentToProcess as typeof content, - message.message.role, + message.message.role as Role, sessionId, toolUseCache, fileContentCache, @@ -943,6 +1108,7 @@ export async function handleUserAssistantMessage( session.cwd, mcpToolUseResult, context.enrichedReadCache, + session.taskState, )) { await client.sessionUpdate(notification); session.notificationHistory.push(notification); diff --git a/packages/agent/src/adapters/claude/conversion/task-state.test.ts b/packages/agent/src/adapters/claude/conversion/task-state.test.ts new file mode 100644 index 0000000000..d440b99ea9 --- /dev/null +++ b/packages/agent/src/adapters/claude/conversion/task-state.test.ts @@ -0,0 +1,338 @@ +import type { SessionMessage } from "@anthropic-ai/claude-agent-sdk"; +import { describe, expect, it } from "vitest"; +import { + applyTaskCreate, + applyTaskUpdate, + parseTaskCreateOutput, + rehydrateTaskState, + type TaskState, + taskStateToPlanEntries, +} from "./task-state"; + +function assistantMsg(blocks: unknown[]): SessionMessage { + return { + type: "assistant", + uuid: "u", + session_id: "s", + parent_tool_use_id: null, + message: { role: "assistant", content: blocks }, + } as SessionMessage; +} + +function userMsg(blocks: unknown[]): SessionMessage { + return { + type: "user", + uuid: "u", + session_id: "s", + parent_tool_use_id: null, + message: { role: "user", content: blocks }, + } as SessionMessage; +} + +describe("parseTaskCreateOutput", () => { + it("parses a JSON string with task.id", () => { + const out = parseTaskCreateOutput('{"task":{"id":"t1"}}'); + expect(out?.task?.id).toBe("t1"); + }); + + it("returns undefined for invalid JSON", () => { + expect(parseTaskCreateOutput("not json")).toBeUndefined(); + }); + + it("returns undefined when task.id is missing", () => { + expect(parseTaskCreateOutput("{}")).toBeUndefined(); + expect(parseTaskCreateOutput('{"task":{}}')).toBeUndefined(); + }); + + it("walks array of text blocks and returns the first parseable one", () => { + const out = parseTaskCreateOutput([ + { type: "text", text: "garbage" }, + { type: "text", text: '{"task":{"id":"t2"}}' }, + ]); + expect(out?.task?.id).toBe("t2"); + }); + + it("ignores non-text blocks", () => { + const out = parseTaskCreateOutput([ + { type: "image", text: '{"task":{"id":"t3"}}' }, + ]); + expect(out).toBeUndefined(); + }); + + it("returns undefined for null/undefined/non-string content", () => { + expect(parseTaskCreateOutput(null)).toBeUndefined(); + expect(parseTaskCreateOutput(undefined)).toBeUndefined(); + expect(parseTaskCreateOutput(42)).toBeUndefined(); + }); +}); + +describe("applyTaskCreate", () => { + it("inserts a new entry keyed by output task id", () => { + const state: TaskState = new Map(); + applyTaskCreate( + state, + { + subject: "Fix bug", + description: "details", + activeForm: "Fixing bug", + }, + { task: { id: "t1", subject: "Fix bug" } }, + ); + expect(state.get("t1")).toEqual({ + subject: "Fix bug", + status: "pending", + activeForm: "Fixing bug", + description: "details", + }); + }); + + it("is a no-op when output has no task id", () => { + const state: TaskState = new Map(); + applyTaskCreate(state, { subject: "x", description: "y" }, undefined); + expect(state.size).toBe(0); + }); + + it("is a no-op when input is undefined", () => { + const state: TaskState = new Map(); + applyTaskCreate(state, undefined, { task: { id: "t1", subject: "x" } }); + expect(state.size).toBe(0); + }); +}); + +describe("applyTaskUpdate", () => { + it("removes the entry when status is deleted", () => { + const state: TaskState = new Map([ + ["t1", { subject: "x", status: "pending" as const }], + ]); + applyTaskUpdate(state, { taskId: "t1", status: "deleted" }); + expect(state.has("t1")).toBe(false); + }); + + it("merges partial fields, preserving existing values", () => { + const state: TaskState = new Map([ + [ + "t1", + { + subject: "Existing subject", + status: "pending" as const, + activeForm: "Doing", + description: "Existing description", + }, + ], + ]); + applyTaskUpdate(state, { taskId: "t1", status: "in_progress" }); + expect(state.get("t1")).toEqual({ + subject: "Existing subject", + status: "in_progress", + activeForm: "Doing", + description: "Existing description", + }); + }); + + it("is a no-op when no existing entry and no subject in input", () => { + const state: TaskState = new Map(); + applyTaskUpdate(state, { taskId: "t1", status: "completed" }); + expect(state.size).toBe(0); + }); + + it("creates a new entry when input provides a subject", () => { + const state: TaskState = new Map(); + applyTaskUpdate(state, { + taskId: "t1", + subject: "Brand new", + status: "in_progress", + }); + expect(state.get("t1")?.subject).toBe("Brand new"); + expect(state.get("t1")?.status).toBe("in_progress"); + }); + + it("is a no-op when input has no taskId", () => { + const state: TaskState = new Map(); + applyTaskUpdate(state, undefined); + expect(state.size).toBe(0); + }); +}); + +describe("rehydrateTaskState", () => { + it("rebuilds the map from TaskCreate + TaskUpdate transcripts", () => { + const state: TaskState = new Map(); + rehydrateTaskState( + [ + assistantMsg([ + { + type: "tool_use", + id: "u1", + name: "TaskCreate", + input: { subject: "First", activeForm: "Doing first" }, + }, + ]), + userMsg([ + { + type: "tool_result", + tool_use_id: "u1", + content: '{"task":{"id":"t1","subject":"First"}}', + }, + ]), + assistantMsg([ + { + type: "tool_use", + id: "u2", + name: "TaskUpdate", + input: { taskId: "t1", status: "in_progress" }, + }, + ]), + userMsg([ + { + type: "tool_result", + tool_use_id: "u2", + content: "ok", + }, + ]), + ], + state, + ); + expect(state.get("t1")).toEqual({ + subject: "First", + status: "in_progress", + activeForm: "Doing first", + description: undefined, + }); + }); + + it("ignores tool_result blocks for non-Task tools", () => { + const state: TaskState = new Map(); + rehydrateTaskState( + [ + assistantMsg([ + { type: "tool_use", id: "r1", name: "Read", input: { file: "a" } }, + ]), + userMsg([ + { type: "tool_result", tool_use_id: "r1", content: "file contents" }, + ]), + ], + state, + ); + expect(state.size).toBe(0); + }); + + it("skips errored Task tool results", () => { + const state: TaskState = new Map(); + rehydrateTaskState( + [ + assistantMsg([ + { + type: "tool_use", + id: "u1", + name: "TaskCreate", + input: { subject: "x" }, + }, + ]), + userMsg([ + { + type: "tool_result", + tool_use_id: "u1", + content: '{"task":{"id":"t1","subject":"x"}}', + is_error: true, + }, + ]), + ], + state, + ); + expect(state.size).toBe(0); + }); + + it("honors deletes from TaskUpdate", () => { + const state: TaskState = new Map(); + rehydrateTaskState( + [ + assistantMsg([ + { + type: "tool_use", + id: "u1", + name: "TaskCreate", + input: { subject: "x" }, + }, + ]), + userMsg([ + { + type: "tool_result", + tool_use_id: "u1", + content: '{"task":{"id":"t1","subject":"x"}}', + }, + ]), + assistantMsg([ + { + type: "tool_use", + id: "u2", + name: "TaskUpdate", + input: { taskId: "t1", status: "deleted" }, + }, + ]), + userMsg([{ type: "tool_result", tool_use_id: "u2", content: "ok" }]), + ], + state, + ); + expect(state.has("t1")).toBe(false); + }); + + it("ignores tool_result without a matching tool_use", () => { + const state: TaskState = new Map(); + rehydrateTaskState( + [ + userMsg([ + { + type: "tool_result", + tool_use_id: "orphan", + content: '{"task":{"id":"t9","subject":"x"}}', + }, + ]), + ], + state, + ); + expect(state.size).toBe(0); + }); + + it("ignores messages with non-array content", () => { + const state: TaskState = new Map(); + rehydrateTaskState( + [ + { + type: "user", + uuid: "u", + session_id: "s", + parent_tool_use_id: null, + message: { role: "user", content: "plain string" }, + } as SessionMessage, + ], + state, + ); + expect(state.size).toBe(0); + }); +}); + +describe("taskStateToPlanEntries", () => { + it("returns an empty array for an empty state", () => { + expect(taskStateToPlanEntries(new Map())).toEqual([]); + }); + + it("preserves Map insertion order", () => { + const state: TaskState = new Map(); + state.set("c", { subject: "third", status: "pending" }); + state.set("a", { subject: "first", status: "in_progress" }); + state.set("b", { subject: "second", status: "completed" }); + const entries = taskStateToPlanEntries(state); + expect(entries.map((e) => e.content)).toEqual(["third", "first", "second"]); + expect(entries.map((e) => e.status)).toEqual([ + "pending", + "in_progress", + "completed", + ]); + }); + + it("hardcodes priority to medium", () => { + const state: TaskState = new Map([ + ["t1", { subject: "x", status: "pending" }], + ]); + expect(taskStateToPlanEntries(state)[0].priority).toBe("medium"); + }); +}); diff --git a/packages/agent/src/adapters/claude/conversion/task-state.ts b/packages/agent/src/adapters/claude/conversion/task-state.ts new file mode 100644 index 0000000000..b5366714b9 --- /dev/null +++ b/packages/agent/src/adapters/claude/conversion/task-state.ts @@ -0,0 +1,178 @@ +import type { PlanEntry } from "@agentclientprotocol/sdk"; +import type { SessionMessage } from "@anthropic-ai/claude-agent-sdk"; +import type { + TaskCreateInput, + TaskCreateOutput, + TaskUpdateInput, +} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js"; + +export type TaskEntry = { + subject: string; + status: "pending" | "in_progress" | "completed"; + activeForm?: string; + description?: string; +}; + +export type TaskState = Map; + +export function parseTaskCreateOutput( + content: unknown, +): TaskCreateOutput | undefined { + const tryParse = (text: string): TaskCreateOutput | undefined => { + try { + const parsed = JSON.parse(text); + if ( + parsed && + typeof parsed === "object" && + parsed.task && + typeof parsed.task.id === "string" + ) { + return parsed as TaskCreateOutput; + } + } catch { + // ignore + } + return undefined; + }; + + if (typeof content === "string") { + return tryParse(content); + } + if (Array.isArray(content)) { + for (const block of content) { + if ( + block && + typeof block === "object" && + "type" in block && + block.type === "text" + ) { + const text = (block as { text?: unknown }).text; + if (typeof text === "string") { + const parsed = tryParse(text); + if (parsed) return parsed; + } + } + } + } + return undefined; +} + +export function applyTaskCreate( + state: TaskState, + input: TaskCreateInput | undefined, + output: TaskCreateOutput | undefined, +): void { + const taskId = output?.task?.id; + if (!taskId || !input) return; + state.set(taskId, { + subject: input.subject, + status: "pending", + activeForm: input.activeForm, + description: input.description, + }); +} + +export function applyTaskUpdate( + state: TaskState, + input: TaskUpdateInput | undefined, +): void { + if (!input?.taskId) return; + if (input.status === "deleted") { + state.delete(input.taskId); + return; + } + const existing = state.get(input.taskId); + const subject = input.subject ?? existing?.subject; + if (!subject) return; + state.set(input.taskId, { + subject, + status: input.status ?? existing?.status ?? "pending", + activeForm: input.activeForm ?? existing?.activeForm, + description: input.description ?? existing?.description, + }); +} + +export function taskStateToPlanEntries(state: TaskState): PlanEntry[] { + return Array.from(state.values()).map((task) => ({ + content: task.subject, + status: task.status, + priority: "medium", + })); +} + +type ToolUseBlock = { + type: "tool_use"; + id: string; + name: string; + input?: unknown; +}; + +type ToolResultBlock = { + type: "tool_result"; + tool_use_id: string; + content?: unknown; + is_error?: boolean; +}; + +function isToolUseBlock(block: unknown): block is ToolUseBlock { + return ( + !!block && + typeof block === "object" && + (block as { type?: unknown }).type === "tool_use" && + typeof (block as { id?: unknown }).id === "string" && + typeof (block as { name?: unknown }).name === "string" + ); +} + +function isToolResultBlock(block: unknown): block is ToolResultBlock { + return ( + !!block && + typeof block === "object" && + (block as { type?: unknown }).type === "tool_result" && + typeof (block as { tool_use_id?: unknown }).tool_use_id === "string" + ); +} + +/** + * Rebuild `state` from a JSONL message transcript by replaying Task tool + * inputs/outputs. Used by `resumeSession` to recover the plan panel when the + * agent restarts mid-conversation; `loadSession` covers the same ground via + * the full notification replay in `replaySessionHistory`. + */ +export function rehydrateTaskState( + messages: ReadonlyArray, + state: TaskState, +): void { + const pendingInputs = new Map(); + for (const msg of messages) { + const content = (msg.message as { content?: unknown } | null | undefined) + ?.content; + if (!Array.isArray(content)) continue; + if (msg.type === "assistant") { + for (const block of content) { + if ( + isToolUseBlock(block) && + (block.name === "TaskCreate" || block.name === "TaskUpdate") + ) { + pendingInputs.set(block.id, { name: block.name, input: block.input }); + } + } + } else if (msg.type === "user") { + for (const block of content) { + if (!isToolResultBlock(block) || block.is_error) continue; + const cached = pendingInputs.get(block.tool_use_id); + if (!cached) continue; + if (cached.name === "TaskCreate") { + applyTaskCreate( + state, + cached.input as TaskCreateInput | undefined, + parseTaskCreateOutput(block.content), + ); + } else if (cached.name === "TaskUpdate") { + applyTaskUpdate(state, cached.input as TaskUpdateInput | undefined); + } + pendingInputs.delete(block.tool_use_id); + } + } + } +} diff --git a/packages/agent/src/adapters/claude/conversion/tool-use-to-acp.ts b/packages/agent/src/adapters/claude/conversion/tool-use-to-acp.ts index e960bd467d..0587403e2e 100644 --- a/packages/agent/src/adapters/claude/conversion/tool-use-to-acp.ts +++ b/packages/agent/src/adapters/claude/conversion/tool-use-to-acp.ts @@ -1,7 +1,6 @@ import fs from "node:fs"; import path from "node:path"; import type { - PlanEntry, ToolCall, ToolCallContent, ToolCallLocation, @@ -371,11 +370,36 @@ export function toolInfoFromToolUse( }; } - case "TodoWrite": + case "TaskCreate": { + const subject = + typeof input?.subject === "string" ? input.subject : undefined; return { - title: Array.isArray(input?.todos) - ? `Update TODOs: ${input.todos.map((todo: { content?: string }) => todo.content).join(", ")}` - : "Update TODOs", + title: subject ? `Create task: ${subject}` : "Create task", + kind: "think", + content: [], + }; + } + + case "TaskUpdate": { + const subject = + typeof input?.subject === "string" ? input.subject : undefined; + return { + title: subject ? `Update task: ${subject}` : "Update task", + kind: "think", + content: [], + }; + } + + case "TaskList": + return { + title: "List tasks", + kind: "think", + content: [], + }; + + case "TaskGet": + return { + title: "Get task", kind: "think", content: [], }; @@ -775,20 +799,6 @@ function toAcpContentUpdate( return {}; } -export type ClaudePlanEntry = { - content: string; - status: "pending" | "in_progress" | "completed"; - activeForm: string; -}; - -export function planEntries(input: { todos: ClaudePlanEntry[] }): PlanEntry[] { - return input.todos.map((input) => ({ - content: input.content, - status: input.status, - priority: "medium", - })); -} - /** * attempt to resolve full file contents for diff generation * diff --git a/packages/agent/src/adapters/claude/hooks.test.ts b/packages/agent/src/adapters/claude/hooks.test.ts index 4bcc7e2e4a..2e3dc5ab4e 100644 --- a/packages/agent/src/adapters/claude/hooks.test.ts +++ b/packages/agent/src/adapters/claude/hooks.test.ts @@ -8,10 +8,12 @@ vi.mock("../../enrichment/file-enricher", () => ({ })); import { Logger } from "../../utils/logger"; +import type { TaskState } from "./conversion/task-state"; import { createPreToolUseHook, createReadEnrichmentHook, createSignedCommitGuardHook, + createTaskHook, type EnrichedReadCache, } from "./hooks"; import type { @@ -365,3 +367,163 @@ describe("createSignedCommitGuardHook", () => { expect(result).toEqual({ continue: true }); }); }); + +describe("createTaskHook", () => { + const baseInput = { + session_id: "s", + transcript_path: "/tmp/t", + cwd: "/tmp", + }; + + test("ignores hook events without a task_id", async () => { + const state: TaskState = new Map(); + const onChange = vi.fn(async () => {}); + const hook = createTaskHook(state, onChange); + const result = await hook( + { ...baseInput, hook_event_name: "PostToolUse" } as HookInput, + undefined, + { signal: new AbortController().signal }, + ); + expect(result).toEqual({ continue: true }); + expect(state.size).toBe(0); + expect(onChange).not.toHaveBeenCalled(); + }); + + test("TaskCreated inserts a pending entry and fires onChange", async () => { + const state: TaskState = new Map(); + const onChange = vi.fn(async () => {}); + const hook = createTaskHook(state, onChange); + const result = await hook( + { + ...baseInput, + hook_event_name: "TaskCreated", + task_id: "t1", + task_subject: "Fix bug", + task_description: "details", + } as unknown as HookInput, + undefined, + { signal: new AbortController().signal }, + ); + expect(result).toEqual({ continue: true }); + expect(state.get("t1")).toEqual({ + subject: "Fix bug", + status: "pending", + description: "details", + }); + expect(onChange).toHaveBeenCalledOnce(); + }); + + test("TaskCreated is idempotent for an existing task_id", async () => { + const state: TaskState = new Map([ + [ + "t1", + { + subject: "Original", + status: "in_progress" as const, + }, + ], + ]); + const onChange = vi.fn(async () => {}); + const hook = createTaskHook(state, onChange); + await hook( + { + ...baseInput, + hook_event_name: "TaskCreated", + task_id: "t1", + task_subject: "Overwrite attempt", + } as unknown as HookInput, + undefined, + { signal: new AbortController().signal }, + ); + expect(state.get("t1")?.subject).toBe("Original"); + expect(state.get("t1")?.status).toBe("in_progress"); + expect(onChange).not.toHaveBeenCalled(); + }); + + test("TaskCreated without task_subject is a no-op", async () => { + const state: TaskState = new Map(); + const onChange = vi.fn(async () => {}); + const hook = createTaskHook(state, onChange); + await hook( + { + ...baseInput, + hook_event_name: "TaskCreated", + task_id: "t1", + } as unknown as HookInput, + undefined, + { signal: new AbortController().signal }, + ); + expect(state.size).toBe(0); + expect(onChange).not.toHaveBeenCalled(); + }); + + test("TaskCompleted flips status and fires onChange", async () => { + const state: TaskState = new Map([ + ["t1", { subject: "Existing", status: "in_progress" as const }], + ]); + const onChange = vi.fn(async () => {}); + const hook = createTaskHook(state, onChange); + await hook( + { + ...baseInput, + hook_event_name: "TaskCompleted", + task_id: "t1", + } as unknown as HookInput, + undefined, + { signal: new AbortController().signal }, + ); + expect(state.get("t1")?.status).toBe("completed"); + expect(onChange).toHaveBeenCalledOnce(); + }); + + test("TaskCompleted is a no-op for unknown task_id", async () => { + const state: TaskState = new Map(); + const onChange = vi.fn(async () => {}); + const hook = createTaskHook(state, onChange); + await hook( + { + ...baseInput, + hook_event_name: "TaskCompleted", + task_id: "unknown", + } as unknown as HookInput, + undefined, + { signal: new AbortController().signal }, + ); + expect(state.size).toBe(0); + expect(onChange).not.toHaveBeenCalled(); + }); + + test("TaskCompleted is a no-op for already-completed task", async () => { + const state: TaskState = new Map([ + ["t1", { subject: "Existing", status: "completed" as const }], + ]); + const onChange = vi.fn(async () => {}); + const hook = createTaskHook(state, onChange); + await hook( + { + ...baseInput, + hook_event_name: "TaskCompleted", + task_id: "t1", + } as unknown as HookInput, + undefined, + { signal: new AbortController().signal }, + ); + expect(onChange).not.toHaveBeenCalled(); + }); + + test("works without an onChange callback", async () => { + const state: TaskState = new Map(); + const hook = createTaskHook(state); + await hook( + { + ...baseInput, + hook_event_name: "TaskCreated", + task_id: "t1", + task_subject: "Fix bug", + } as unknown as HookInput, + undefined, + { signal: new AbortController().signal }, + ); + expect(state.get("t1")?.subject).toBe("Fix bug"); + }); +}); diff --git a/packages/agent/src/adapters/claude/hooks.ts b/packages/agent/src/adapters/claude/hooks.ts index 1df94d720e..391d0ee2c9 100644 --- a/packages/agent/src/adapters/claude/hooks.ts +++ b/packages/agent/src/adapters/claude/hooks.ts @@ -6,6 +6,7 @@ import { import type { Logger } from "../../utils/logger"; import { SIGNED_COMMIT_QUALIFIED_TOOL_NAME } from "../signed-commit-shared"; import { stripCatLineNumbers } from "./conversion/sdk-to-acp"; +import type { TaskState } from "./conversion/task-state"; import { extractPostHogSubTool, isPostHogDestructiveSubTool, @@ -129,6 +130,48 @@ export const registerHookCallback = ( }; }; +/** + * Pre-populate the per-session task list from SDK TaskCreated/TaskCompleted + * hook events. These fire before the matching tool_result chunk arrives, so + * by the time TaskUpdate runs (which only carries taskId + status) the entry + * already exists with a real subject — no placeholder with empty content. + * + * Plan-update emission happens in the tool_result handler, which mirrors the + * old TodoWrite suppress-tool-call + emit-plan flow. + */ +export const createTaskHook = + (taskState: TaskState, onChange?: () => Promise): HookCallback => + async (input: HookInput): Promise<{ continue: boolean }> => { + const taskId = + "task_id" in input && typeof input.task_id === "string" + ? input.task_id + : undefined; + if (!taskId) return { continue: true }; + + let mutated = false; + if (input.hook_event_name === "TaskCreated") { + if (!input.task_subject) return { continue: true }; + // Guard against the SDK firing TaskCreated twice for the same id — + // re-entry would clobber any TaskUpdate that landed in between. + if (taskState.has(taskId)) return { continue: true }; + taskState.set(taskId, { + subject: input.task_subject, + status: "pending", + description: input.task_description, + }); + mutated = true; + } else if (input.hook_event_name === "TaskCompleted") { + const existing = taskState.get(taskId); + if (!existing || existing.status === "completed") { + return { continue: true }; + } + taskState.set(taskId, { ...existing, status: "completed" }); + mutated = true; + } + if (mutated && onChange) await onChange(); + return { continue: true }; + }; + export type OnModeChange = (mode: CodeExecutionMode) => Promise; interface CreatePostToolUseHookParams { @@ -157,10 +200,8 @@ export const createPostToolUseHook = input.tool_input, input.tool_response, ); - delete toolUseCallbacks[toolUseID]; - } else { - delete toolUseCallbacks[toolUseID]; } + delete toolUseCallbacks[toolUseID]; } } return { continue: true }; diff --git a/packages/agent/src/adapters/claude/permissions/permission-options.ts b/packages/agent/src/adapters/claude/permissions/permission-options.ts index ff658b5368..844d0163f3 100644 --- a/packages/agent/src/adapters/claude/permissions/permission-options.ts +++ b/packages/agent/src/adapters/claude/permissions/permission-options.ts @@ -85,8 +85,13 @@ export function buildPermissionOptions( return permissionOptions("Yes, allow all sub-tasks"); } - if (toolName === "TodoWrite") { - return permissionOptions("Yes, allow all todo updates"); + if ( + toolName === "TaskCreate" || + toolName === "TaskUpdate" || + toolName === "TaskGet" || + toolName === "TaskList" + ) { + return permissionOptions("Yes, allow all task updates"); } return permissionOptions("Yes, always allow"); diff --git a/packages/agent/src/adapters/claude/session/commands.ts b/packages/agent/src/adapters/claude/session/commands.ts index 889fc81668..47037142cf 100644 --- a/packages/agent/src/adapters/claude/session/commands.ts +++ b/packages/agent/src/adapters/claude/session/commands.ts @@ -2,6 +2,7 @@ import type { AvailableCommand } from "@agentclientprotocol/sdk"; import type { SlashCommand } from "@anthropic-ai/claude-agent-sdk"; const UNSUPPORTED_COMMANDS = [ + "clear", "context", "cost", "keybindings-help", diff --git a/packages/agent/src/adapters/claude/session/jsonl-hydration.test.ts b/packages/agent/src/adapters/claude/session/jsonl-hydration.test.ts index 3fad3f7800..9d42cfe2ac 100644 --- a/packages/agent/src/adapters/claude/session/jsonl-hydration.test.ts +++ b/packages/agent/src/adapters/claude/session/jsonl-hydration.test.ts @@ -376,7 +376,7 @@ describe("conversationTurnsToJsonlEntries", () => { { type: "text", text: "running" }, ]); expect(conv[0].message.stop_reason).toBeNull(); - expect(conv[0].message.model).toBe("claude-opus-4-6"); + expect(conv[0].message.model).toBe("claude-opus-4-8"); expect(conv[0].message.id).toMatch(/^msg_01[A-Za-z0-9]{24}$/); expect(conv[1].type).toBe("assistant"); @@ -490,13 +490,13 @@ describe("conversationTurnsToJsonlEntries", () => { { role: "user", content: [{ type: "text", text: "hi" }] }, { role: "assistant", content: [{ type: "text", text: "hello" }] }, ], - { sessionId: "s", cwd: "/", model: "claude-opus-4-6", version: "3.0.0" }, + { sessionId: "s", cwd: "/", model: "claude-opus-4-7", version: "3.0.0" }, ); const conv = parseConversationEntries(lines); expect(conv[0].version).toBe("3.0.0"); expect(conv[1].version).toBe("3.0.0"); - expect(conv[1].message.model).toBe("claude-opus-4-6"); + expect(conv[1].message.model).toBe("claude-opus-4-7"); }); it("passes gitBranch, slug and permissionMode from config", () => { @@ -728,7 +728,7 @@ describe("end-to-end: S3 log entries -> JSONL output", () => { // All assistant blocks in same turn share message.id expect(msg1.id).toBe(msg2.id); expect(msg2.id).toBe(msg3.id); - expect(msg3.model).toBe("claude-opus-4-6"); + expect(msg3.model).toBe("claude-opus-4-8"); expect(msg3.id).toMatch(/^msg_01[A-Za-z0-9]{24}$/); // Verify Bash tool_result entry diff --git a/packages/agent/src/adapters/claude/session/jsonl-hydration.ts b/packages/agent/src/adapters/claude/session/jsonl-hydration.ts index 16d1a54cee..14b39a71b6 100644 --- a/packages/agent/src/adapters/claude/session/jsonl-hydration.ts +++ b/packages/agent/src/adapters/claude/session/jsonl-hydration.ts @@ -3,6 +3,7 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import type { ContentBlock } from "@agentclientprotocol/sdk"; +import { DEFAULT_GATEWAY_MODEL } from "../../../gateway-models"; import type { PostHogAPIClient } from "../../../posthog-api"; import type { StoredEntry } from "../../../types"; import { supports1MContext } from "./models"; @@ -312,7 +313,7 @@ export function conversationTurnsToJsonlEntries( ): string[] { const lines: string[] = []; let parentUuid: string | null = null; - const model = config.model ?? "claude-opus-4-6"; + const model = config.model ?? DEFAULT_GATEWAY_MODEL; const version = config.version ?? "2.1.63"; const gitBranch = config.gitBranch ?? ""; const slug = config.slug ?? generateSlug(); diff --git a/packages/agent/src/adapters/claude/session/mcp-config.ts b/packages/agent/src/adapters/claude/session/mcp-config.ts index 1c169f9b01..b6b1d3a83c 100644 --- a/packages/agent/src/adapters/claude/session/mcp-config.ts +++ b/packages/agent/src/adapters/claude/session/mcp-config.ts @@ -48,6 +48,7 @@ export function loadUserClaudeJsonMcpServers( export function parseMcpServers( params: Pick, + logger?: Logger, ): Record { const mcpServers: Record = {}; if (!Array.isArray(params.mcpServers)) { @@ -56,13 +57,28 @@ export function parseMcpServers( for (const server of params.mcpServers) { if ("type" in server) { - mcpServers[server.name] = { - type: server.type, - url: server.url, - headers: server.headers - ? Object.fromEntries(server.headers.map((e) => [e.name, e.value])) - : undefined, - }; + if (server.type === "http" || server.type === "sse") { + mcpServers[server.name] = { + type: server.type, + url: server.url, + headers: server.headers + ? Object.fromEntries( + server.headers.map((e: { name: string; value: string }) => [ + e.name, + e.value, + ]), + ) + : undefined, + }; + } else { + // ACP 0.22 introduced the `sdk` McpServerConfig variant; the SDK + // adapter doesn't construct in-process servers, so surface a warning + // rather than silently dropping the entry. + logger?.warn("parseMcpServers: dropping unsupported MCP server type", { + name: server.name, + type: (server as { type: string }).type, + }); + } } else { mcpServers[server.name] = { type: "stdio", diff --git a/packages/agent/src/adapters/claude/session/model-config.test.ts b/packages/agent/src/adapters/claude/session/model-config.test.ts new file mode 100644 index 0000000000..98b0b2ddf0 --- /dev/null +++ b/packages/agent/src/adapters/claude/session/model-config.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { + applyAvailableModelsAllowlist, + resolveInitialModelId, +} from "./model-config"; + +const rawModelOptions = { + currentModelId: "claude-opus-4-8", + options: [ + { value: "claude-opus-4-8", name: "Claude Opus 4.8" }, + { value: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + ], +}; + +describe("applyAvailableModelsAllowlist", () => { + it("falls back to the unfiltered gateway list when every allowlisted model is unknown", () => { + expect( + applyAvailableModelsAllowlist(rawModelOptions, ["claude-unknown-model"]), + ).toEqual(rawModelOptions); + }); + + it("switches the current model when the previous one is filtered out", () => { + expect( + applyAvailableModelsAllowlist(rawModelOptions, ["claude-sonnet-4-6"]), + ).toEqual({ + currentModelId: "claude-sonnet-4-6", + options: [{ value: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }], + }); + }); +}); + +describe("resolveInitialModelId", () => { + it("keeps a preferred model when it survives filtering", () => { + const filteredModelOptions = applyAvailableModelsAllowlist( + rawModelOptions, + ["claude-opus-4-8", "claude-sonnet-4-6"], + ); + + expect( + resolveInitialModelId(filteredModelOptions, [ + "claude-opus-4-8", + "claude-sonnet-4-6", + ]), + ).toBe("claude-opus-4-8"); + }); + + it("falls back to the filtered current model when the preferred one is disallowed", () => { + const filteredModelOptions = applyAvailableModelsAllowlist( + rawModelOptions, + ["claude-sonnet-4-6"], + ); + + expect( + resolveInitialModelId(filteredModelOptions, [ + "claude-opus-4-8", + "claude-sonnet-4-6", + ]), + ).toBe("claude-sonnet-4-6"); + }); +}); diff --git a/packages/agent/src/adapters/claude/session/model-config.ts b/packages/agent/src/adapters/claude/session/model-config.ts new file mode 100644 index 0000000000..f83ed1b717 --- /dev/null +++ b/packages/agent/src/adapters/claude/session/model-config.ts @@ -0,0 +1,56 @@ +import type { SessionConfigSelectOption } from "@agentclientprotocol/sdk"; + +export interface ModelConfigOptions { + currentModelId: string; + options: SessionConfigSelectOption[]; +} + +/** + * Restrict gateway model options to the user's `availableModels` allowlist + * from settings.json. Unknown allowlist entries are dropped; if every entry + * is unknown we fall back to the gateway list as a safety net. + */ +export function applyAvailableModelsAllowlist( + modelOptions: ModelConfigOptions, + allowlist: string[], +): ModelConfigOptions { + const filtered: SessionConfigSelectOption[] = []; + const seen = new Set(); + + for (const entry of allowlist) { + const trimmed = entry.trim(); + if (!trimmed || seen.has(trimmed)) continue; + + const match = modelOptions.options.find((o) => o.value === trimmed); + if (match) { + filtered.push(match); + seen.add(trimmed); + } + } + + if (filtered.length === 0) return modelOptions; + + const currentModelId = filtered.some( + (o) => o.value === modelOptions.currentModelId, + ) + ? modelOptions.currentModelId + : filtered[0].value; + + return { currentModelId, options: filtered }; +} + +export function resolveInitialModelId( + modelOptions: ModelConfigOptions, + preferredModelIds: Array, +): string { + const allowedModelIds = new Set(modelOptions.options.map((opt) => opt.value)); + + for (const candidate of preferredModelIds) { + const trimmed = candidate?.trim(); + if (trimmed && allowedModelIds.has(trimmed)) { + return trimmed; + } + } + + return modelOptions.currentModelId; +} diff --git a/packages/agent/src/adapters/claude/session/models.test.ts b/packages/agent/src/adapters/claude/session/models.test.ts new file mode 100644 index 0000000000..73a271721b --- /dev/null +++ b/packages/agent/src/adapters/claude/session/models.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "vitest"; +import { + getEffortOptions, + resolveModelPreference, + supports1MContext, + supportsEffort, + supportsMcpInjection, + supportsXhighEffort, + toSdkModelId, +} from "./models"; + +describe("toSdkModelId", () => { + it("maps known gateway IDs to SDK aliases", () => { + expect(toSdkModelId("claude-opus-4-7")).toBe("opus"); + expect(toSdkModelId("claude-opus-4-8")).toBe("opus"); + expect(toSdkModelId("claude-sonnet-4-6")).toBe("sonnet"); + }); + + it("passes unknown IDs through unchanged", () => { + expect(toSdkModelId("custom-model")).toBe("custom-model"); + }); + + it("passes deprecated gateway IDs through unchanged", () => { + expect(toSdkModelId("claude-opus-4-6")).toBe("claude-opus-4-6"); + expect(toSdkModelId("claude-sonnet-4-5")).toBe("claude-sonnet-4-5"); + expect(toSdkModelId("claude-haiku-4-5")).toBe("claude-haiku-4-5"); + }); +}); + +describe("model capability flags", () => { + it("flags 1M context support", () => { + expect(supports1MContext("claude-opus-4-6")).toBe(false); + expect(supports1MContext("claude-opus-4-7")).toBe(true); + expect(supports1MContext("claude-sonnet-4-6")).toBe(true); + expect(supports1MContext("claude-haiku-4-5")).toBe(false); + }); + + it("flags effort support and xhigh-effort support", () => { + expect(supportsEffort("claude-opus-4-5")).toBe(false); + expect(supportsEffort("claude-opus-4-6")).toBe(false); + expect(supportsXhighEffort("claude-opus-4-7")).toBe(true); + expect(supportsXhighEffort("claude-opus-4-6")).toBe(false); + expect(supportsEffort("claude-haiku-4-5")).toBe(false); + }); + + it("allows MCP injection for supported Claude models", () => { + expect(supportsMcpInjection("claude-opus-4-7")).toBe(true); + expect(supportsMcpInjection("claude-sonnet-4-6")).toBe(true); + }); + + it("keeps deprecated Haiku sessions excluded from MCP injection", () => { + expect(supportsMcpInjection("claude-haiku-4-5")).toBe(false); + }); +}); + +describe("getEffortOptions", () => { + it("returns null for models without effort support", () => { + expect(getEffortOptions("claude-haiku-4-5")).toBeNull(); + expect(getEffortOptions("claude-opus-4-6")).toBeNull(); + }); + + it("returns low/medium/high for effort-supporting models", () => { + const opts = getEffortOptions("claude-sonnet-4-6"); + expect(opts?.map((o) => o.value)).toEqual(["low", "medium", "high"]); + }); + + it("appends xhigh and max for xhigh-supporting models", () => { + const opts = getEffortOptions("claude-opus-4-7"); + expect(opts?.map((o) => o.value)).toEqual([ + "low", + "medium", + "high", + "xhigh", + "max", + ]); + }); +}); + +describe("resolveModelPreference", () => { + const options = [ + { value: "claude-opus-4-8", name: "Claude Opus 4.8" }, + { value: "claude-opus-4-7", name: "Claude Opus 4.7" }, + { value: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + ]; + + it("returns null for empty preference", () => { + expect(resolveModelPreference("", options)).toBeNull(); + expect(resolveModelPreference(" ", options)).toBeNull(); + }); + + it("matches an exact value", () => { + expect(resolveModelPreference("claude-opus-4-7", options)).toBe( + "claude-opus-4-7", + ); + }); + + it("matches case-insensitively on display name", () => { + expect(resolveModelPreference("claude sonnet 4.6", options)).toBe( + "claude-sonnet-4-6", + ); + }); + + it("matches by substring", () => { + expect(resolveModelPreference("sonnet", options)).toBe("claude-sonnet-4-6"); + }); + + it("matches by token alias", () => { + expect(resolveModelPreference("opus[1m]", options)).toBe("claude-opus-4-8"); + }); + + it("refuses cross-version alias matches", () => { + const optionsWithAlias = [ + { value: "opus", name: "Claude Opus 4.8" }, + { value: "claude-opus-4-7", name: "Claude Opus 4.7" }, + ]; + expect(resolveModelPreference("claude-opus-4-7", optionsWithAlias)).toBe( + "claude-opus-4-7", + ); + }); + + it("returns null when nothing matches", () => { + expect(resolveModelPreference("gpt-5", options)).toBeNull(); + }); + + it("treats `best` and `default` as wildcards (no tokens contribute)", () => { + expect(resolveModelPreference("best", options)).toBeNull(); + expect(resolveModelPreference("default", options)).toBeNull(); + }); +}); diff --git a/packages/agent/src/adapters/claude/session/models.ts b/packages/agent/src/adapters/claude/session/models.ts index 4fae2001de..e98ce08b4b 100644 --- a/packages/agent/src/adapters/claude/session/models.ts +++ b/packages/agent/src/adapters/claude/session/models.ts @@ -1,12 +1,9 @@ export const DEFAULT_MODEL = "opus"; const GATEWAY_TO_SDK_MODEL: Record = { - "claude-opus-4-5": "opus", - "claude-opus-4-6": "opus", "claude-opus-4-7": "opus", - "claude-sonnet-4-5": "sonnet", + "claude-opus-4-8": "opus", "claude-sonnet-4-6": "sonnet", - "claude-haiku-4-5": "haiku", }; export function toSdkModelId(modelId: string): string { @@ -14,8 +11,8 @@ export function toSdkModelId(modelId: string): string { } const MODELS_WITH_1M_CONTEXT = new Set([ - "claude-opus-4-6", "claude-opus-4-7", + "claude-opus-4-8", "claude-sonnet-4-6", ]); @@ -24,15 +21,14 @@ export function supports1MContext(modelId: string): boolean { } const MODELS_WITH_EFFORT = new Set([ - "claude-opus-4-5", - "claude-opus-4-6", "claude-opus-4-7", + "claude-opus-4-8", "claude-sonnet-4-6", ]); const MODELS_WITH_XHIGH_EFFORT = new Set([ - "claude-opus-4-6", "claude-opus-4-7", + "claude-opus-4-8", ]); export function supportsEffort(modelId: string): boolean { @@ -74,7 +70,7 @@ export function getEffortOptions(modelId: string): EffortOption[] | null { } // Model alias resolution — lets callers use human-friendly aliases like -// "opus" or "sonnet" instead of full model IDs like "claude-opus-4-6". +// "opus" or "sonnet" instead of full model IDs like "claude-opus-4-8". const MODEL_CONTEXT_HINT_PATTERN = /\[(\d+m)\]$/i; @@ -107,6 +103,31 @@ interface ModelOption { description?: string; } +// Captures a model family version such as `4-6` or `4.7` so we can keep +// `claude-opus-4-7` from being copied onto the SDK's `opus` alias when that +// alias currently resolves to a different family version (e.g. Opus 4.8). +const MODEL_FAMILY_VERSION_PATTERN = /\b(\d+)[-.](\d+)\b/; + +function extractModelFamilyVersion(s: string | undefined): string | null { + if (!s) return null; + const match = s.match(MODEL_FAMILY_VERSION_PATTERN); + return match ? `${match[1]}.${match[2]}` : null; +} + +function modelVersionsCompatible( + preference: string, + candidate: ModelOption, +): boolean { + const preferred = extractModelFamilyVersion(preference); + if (!preferred) return true; + const candidateVersion = + extractModelFamilyVersion(candidate.value) ?? + extractModelFamilyVersion(candidate.name) ?? + extractModelFamilyVersion(candidate.description); + if (!candidateVersion) return true; + return preferred === candidateVersion; +} + function scoreModelMatch( model: ModelOption, tokens: string[], @@ -142,6 +163,7 @@ export function resolveModelPreference( // Substring match const includesMatch = options.find((o) => { + if (!modelVersionsCompatible(trimmed, o)) return false; const value = o.value.toLowerCase(); const display = (o.name ?? "").toLowerCase(); return ( @@ -157,6 +179,7 @@ export function resolveModelPreference( let bestMatch: ModelOption | null = null; let bestScore = 0; for (const model of options) { + if (!modelVersionsCompatible(trimmed, model)) continue; const score = scoreModelMatch(model, tokens, contextHint); if (0 < score && (!bestMatch || bestScore < score)) { bestMatch = model; diff --git a/packages/agent/src/adapters/claude/session/options.test.ts b/packages/agent/src/adapters/claude/session/options.test.ts index ae0489bb81..e412e7e124 100644 --- a/packages/agent/src/adapters/claude/session/options.test.ts +++ b/packages/agent/src/adapters/claude/session/options.test.ts @@ -1,6 +1,6 @@ import * as os from "node:os"; import * as path from "node:path"; -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { Logger } from "../../../utils/logger"; import { SUBAGENT_REWRITES } from "../hooks"; import { buildSessionOptions } from "./options"; @@ -17,6 +17,7 @@ function makeParams() { sessionId: "test-session", isResume: false, settingsManager: new SettingsManager(cwd), + taskState: new Map(), }; } @@ -69,4 +70,67 @@ describe("buildSessionOptions", () => { expect(options.agents?.["ph-explore"]).toEqual(override); }); + + describe("ANTHROPIC_CUSTOM_HEADERS", () => { + const originalProjectId = process.env.POSTHOG_PROJECT_ID; + const originalCustomHeaders = process.env.ANTHROPIC_CUSTOM_HEADERS; + + beforeEach(() => { + delete process.env.POSTHOG_PROJECT_ID; + delete process.env.ANTHROPIC_CUSTOM_HEADERS; + }); + + afterEach(() => { + for (const [key, value] of [ + ["POSTHOG_PROJECT_ID", originalProjectId], + ["ANTHROPIC_CUSTOM_HEADERS", originalCustomHeaders], + ] as const) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + it.each([ + { + name: "omits the team_id header when POSTHOG_PROJECT_ID is unset", + projectId: undefined, + existingHeaders: undefined, + expected: "x-posthog-use-bedrock-fallback: true", + }, + { + name: "forwards POSTHOG_PROJECT_ID as the team_id attribution header", + projectId: "42", + existingHeaders: undefined, + expected: [ + "x-posthog-property-team_id: 42", + "x-posthog-use-bedrock-fallback: true", + ].join("\n"), + }, + { + name: "preserves pre-existing custom headers ahead of the team_id header", + projectId: "42", + existingHeaders: "x-posthog-property-task_id: task-abc", + expected: [ + "x-posthog-property-task_id: task-abc", + "x-posthog-property-team_id: 42", + "x-posthog-use-bedrock-fallback: true", + ].join("\n"), + }, + ])("$name", ({ projectId, existingHeaders, expected }) => { + if (projectId !== undefined) { + process.env.POSTHOG_PROJECT_ID = projectId; + } + if (existingHeaders !== undefined) { + process.env.ANTHROPIC_CUSTOM_HEADERS = existingHeaders; + } + + const headers = buildSessionOptions(makeParams()).env + ?.ANTHROPIC_CUSTOM_HEADERS; + + expect(headers).toBe(expected); + }); + }); }); diff --git a/packages/agent/src/adapters/claude/session/options.ts b/packages/agent/src/adapters/claude/session/options.ts index 4da0fd3af6..c87fb4f096 100644 --- a/packages/agent/src/adapters/claude/session/options.ts +++ b/packages/agent/src/adapters/claude/session/options.ts @@ -13,12 +13,14 @@ import type { import type { FileEnrichmentDeps } from "../../../enrichment/file-enricher"; import { IS_ROOT } from "../../../utils/common"; import type { Logger } from "../../../utils/logger"; +import type { TaskState } from "../conversion/task-state"; import { createPostToolUseHook, createPreToolUseHook, createReadEnrichmentHook, createSignedCommitGuardHook, createSubagentRewriteHook, + createTaskHook, type EnrichedReadCache, type OnModeChange, } from "../hooks"; @@ -58,6 +60,11 @@ export interface BuildOptionsParams { enrichedReadCache?: EnrichedReadCache; /** Cloud task session — enables the signed-commit guard. */ cloudMode?: boolean; + /** Per-session task state populated by createTaskHook from SDK Task* events. */ + taskState: TaskState; + /** Called after createTaskHook mutates taskState so callers can emit a plan + * sessionUpdate to the client. */ + onTaskStateChange?: () => Promise; } export function buildSystemPrompt( @@ -105,11 +112,35 @@ function buildMcpServers( } function buildEnvironment(): Record { - const bedrockFallbackHeader = "x-posthog-use-bedrock-fallback: true"; + // Custom HTTP headers reach the model only through the Claude CLI subprocess, + // which reads them from this env var (newline-delimited `name: value` lines) + // — the SDK has no direct header option. We finalize them here, the single + // chokepoint every session (desktop and cloud) funnels through. + const headerLines: string[] = []; const existingCustomHeaders = process.env.ANTHROPIC_CUSTOM_HEADERS; - const customHeaders = existingCustomHeaders - ? `${existingCustomHeaders}\n${bedrockFallbackHeader}` - : bedrockFallbackHeader; + if (existingCustomHeaders) { + headerLines.push(existingCustomHeaders); + } + // Attribute every captured $ai_generation event to the customer's team. The + // gateway authenticates with a shared key, so without this the spend lands on + // the key owner's team. The gateway lifts `x-posthog-property-*` headers onto + // the event; both entrypoints export POSTHOG_PROJECT_ID before this runs + // (apps/code auth-adapter.ts, server/agent-server.ts). Mirrors django's + // get_llm_client(team_id=...). + const projectId = process.env.POSTHOG_PROJECT_ID; + if (projectId) { + headerLines.push(`x-posthog-property-team_id: ${projectId}`); + } + // Route to AWS Bedrock as a fallback when Anthropic returns 5xx + headerLines.push("x-posthog-use-bedrock-fallback: true"); + const customHeaders = headerLines.join("\n"); + + // SDK 0.3.142 made MCP servers connect in the background by default. That + // default is what we want: a slow or unreachable user MCP server (PostHog + // MCP, custom stdio servers) would otherwise stall turn 1 by up to ~5s per + // server. We honor an explicit override from the caller's environment for + // sessions that genuinely need MCP tools available on turn 1. + const mcpNonblocking = process.env.MCP_CONNECTION_NONBLOCKING; return { ...process.env, @@ -119,7 +150,9 @@ function buildEnvironment(): Record { ENABLE_TOOL_SEARCH: "auto:0", // Enable idle state as end-of-turn signal (required for SDK 0.2.114+) CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1", - // Route to AWS Bedrock as a fallback when Anthropic returns 5xx + ...(mcpNonblocking !== undefined && { + MCP_CONNECTION_NONBLOCKING: mcpNonblocking, + }), ANTHROPIC_CUSTOM_HEADERS: customHeaders, }; } @@ -133,6 +166,8 @@ function buildHooks( enrichedReadCache: EnrichedReadCache | undefined, registeredAgents: ReadonlySet, cloudMode: boolean, + taskState: TaskState, + onTaskStateChange: (() => Promise) | undefined, ): Options["hooks"] { const postToolUseHooks = [createPostToolUseHook({ onModeChange })]; if (enrichmentDeps && enrichedReadCache) { @@ -149,6 +184,8 @@ function buildHooks( preToolUseHooks.push(createSignedCommitGuardHook(logger)); } + const taskHook = createTaskHook(taskState, onTaskStateChange); + return { ...userHooks, PostToolUse: [ @@ -156,11 +193,13 @@ function buildHooks( { hooks: postToolUseHooks }, ], PreToolUse: [...(userHooks?.PreToolUse || []), { hooks: preToolUseHooks }], + TaskCreated: [...(userHooks?.TaskCreated || []), { hooks: [taskHook] }], + TaskCompleted: [...(userHooks?.TaskCompleted || []), { hooks: [taskHook] }], }; } /** - * Read-only Haiku-powered exploration agent. Registered under the `ph-explore` + * Read-only exploration agent. Registered under the `ph-explore` * name rather than `Explore` to work around a Claude Agent SDK bug where * `options.agents` cannot shadow built-in agent definitions. The * `createSubagentRewriteHook` rewrites `subagent_type: "Explore"` to @@ -195,7 +234,10 @@ Rules: "WebFetch", "WebSearch", "NotebookRead", - "TodoWrite", + "TaskCreate", + "TaskUpdate", + "TaskGet", + "TaskList", ], }; @@ -357,6 +399,8 @@ export function buildSessionOptions(params: BuildOptionsParams): Options { params.enrichedReadCache, registeredAgentNames, params.cloudMode ?? false, + params.taskState, + params.onTaskStateChange, ), outputFormat: params.outputFormat, abortController: getAbortController( diff --git a/packages/agent/src/adapters/claude/session/settings.test.ts b/packages/agent/src/adapters/claude/session/settings.test.ts index 960c5d4f6e..5f6a91f425 100644 --- a/packages/agent/src/adapters/claude/session/settings.test.ts +++ b/packages/agent/src/adapters/claude/session/settings.test.ts @@ -4,7 +4,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { resolveMainRepoPath } from "./repo-path"; -import { SettingsManager } from "./settings"; +import { mergeAvailableModels, SettingsManager } from "./settings"; function runGit(cwd: string, args: string[]): void { execFileSync("git", args, { cwd, stdio: ["ignore", "ignore", "pipe"] }); @@ -207,3 +207,104 @@ describe("resolveMainRepoPath", () => { } }); }); + +describe("availableModels merge", () => { + let tmpRoot: string; + let cwd: string; + let configDir: string; + let originalConfigDir: string | undefined; + + beforeEach(async () => { + tmpRoot = await fs.promises.realpath( + await fs.promises.mkdtemp(path.join(os.tmpdir(), "available-models-")), + ); + cwd = path.join(tmpRoot, "repo"); + configDir = path.join(tmpRoot, "user"); + await fs.promises.mkdir(cwd, { recursive: true }); + await fs.promises.mkdir(configDir, { recursive: true }); + runGit(cwd, ["init", "-b", "main"]); + runGit(cwd, ["config", "user.email", "test@example.com"]); + runGit(cwd, ["config", "user.name", "test"]); + runGit(cwd, ["commit", "--allow-empty", "-m", "init"]); + + originalConfigDir = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = configDir; + }); + + afterEach(async () => { + if (originalConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = originalConfigDir; + } + await fs.promises.rm(tmpRoot, { recursive: true, force: true }); + }); + + async function writeUserSettings(settings: object): Promise { + await fs.promises.writeFile( + path.join(configDir, "settings.json"), + JSON.stringify(settings), + ); + } + + async function writeProjectSettings(settings: object): Promise { + const projectDir = path.join(cwd, ".claude"); + await fs.promises.mkdir(projectDir, { recursive: true }); + await fs.promises.writeFile( + path.join(projectDir, "settings.json"), + JSON.stringify(settings), + ); + } + + it("merges and dedupes availableModels across user and project layers", async () => { + await writeUserSettings({ availableModels: ["model-a", "model-b"] }); + await writeProjectSettings({ availableModels: ["model-b", "model-c"] }); + + const manager = new SettingsManager(cwd); + await manager.initialize(); + + expect(manager.getSettings().availableModels).toEqual([ + "model-a", + "model-b", + "model-c", + ]); + }); + + it("passes through a single layer unchanged", async () => { + await writeProjectSettings({ availableModels: ["only-one"] }); + + const manager = new SettingsManager(cwd); + await manager.initialize(); + + expect(manager.getSettings().availableModels).toEqual(["only-one"]); + }); + + it("leaves availableModels undefined when no layer defines it", async () => { + const manager = new SettingsManager(cwd); + await manager.initialize(); + + expect(manager.getSettings().availableModels).toBeUndefined(); + }); +}); + +describe("mergeAvailableModels", () => { + it("merges and dedupes non-enterprise layers", () => { + expect( + mergeAvailableModels( + ["model-a", "model-b"], + ["model-b", "model-c"], + "project", + ), + ).toEqual(["model-a", "model-b", "model-c"]); + }); + + it("lets enterprise settings replace lower-precedence allowlists", () => { + expect( + mergeAvailableModels( + ["model-a", "model-b"], + ["managed-a", "managed-a"], + "enterprise", + ), + ).toEqual(["managed-a"]); + }); +}); diff --git a/packages/agent/src/adapters/claude/session/settings.ts b/packages/agent/src/adapters/claude/session/settings.ts index 0a2b8e39b4..b4aa9ab6ed 100644 --- a/packages/agent/src/adapters/claude/session/settings.ts +++ b/packages/agent/src/adapters/claude/session/settings.ts @@ -196,9 +196,12 @@ export interface ClaudeCodeSettings { permissions?: PermissionSettings; env?: Record; model?: string; + availableModels?: string[]; posthogApprovedExecTools?: string[]; } +type SettingsLayer = "user" | "project" | "local" | "enterprise"; + export type PermissionDecision = "allow" | "deny" | "ask"; export interface PermissionCheckResult { @@ -220,6 +223,22 @@ export function getManagedSettingsPath(): string { } } +export function mergeAvailableModels( + existing: string[] | undefined, + incoming: string[] | undefined, + layer: SettingsLayer, +): string[] | undefined { + if (incoming === undefined) { + return existing; + } + + if (layer === "enterprise") { + return Array.from(new Set(incoming)); + } + + return Array.from(new Set([...(existing ?? []), ...incoming])); +} + export class SettingsManager { private cwd: string; private repoRoot: string; @@ -283,11 +302,14 @@ export class SettingsManager { } private mergeAllSettings(): void { - const allSettings = [ - this.userSettings, - this.projectSettings, - this.localSettings, - this.enterpriseSettings, + const allSettings: Array<{ + layer: SettingsLayer; + settings: ClaudeCodeSettings; + }> = [ + { layer: "user", settings: this.userSettings }, + { layer: "project", settings: this.projectSettings }, + { layer: "local", settings: this.localSettings }, + { layer: "enterprise", settings: this.enterpriseSettings }, ]; const permissions: PermissionSettings = { @@ -298,7 +320,7 @@ export class SettingsManager { const merged: ClaudeCodeSettings = { permissions }; const posthogApprovedExecTools = new Set(); - for (const settings of allSettings) { + for (const { layer, settings } of allSettings) { if (settings.permissions) { if (settings.permissions.allow) { permissions.allow?.push(...settings.permissions.allow); @@ -325,6 +347,11 @@ export class SettingsManager { if (settings.model) { merged.model = settings.model; } + merged.availableModels = mergeAvailableModels( + merged.availableModels, + settings.availableModels, + layer, + ); if (settings.posthogApprovedExecTools) { for (const tool of settings.posthogApprovedExecTools) { posthogApprovedExecTools.add(tool); diff --git a/packages/agent/src/adapters/claude/tools.ts b/packages/agent/src/adapters/claude/tools.ts index 2f847e99cb..9074737ae1 100644 --- a/packages/agent/src/adapters/claude/tools.ts +++ b/packages/agent/src/adapters/claude/tools.ts @@ -29,8 +29,10 @@ export const WEB_TOOLS: Set = new Set(["WebSearch", "WebFetch"]); export const AGENT_TOOLS: Set = new Set([ "Task", "Agent", - "TodoWrite", - "Skill", + "TaskCreate", + "TaskUpdate", + "TaskGet", + "TaskList", ]); const BASE_ALLOWED_TOOLS = [ diff --git a/packages/agent/src/adapters/claude/types.ts b/packages/agent/src/adapters/claude/types.ts index af07ea2225..c7aeca3dff 100644 --- a/packages/agent/src/adapters/claude/types.ts +++ b/packages/agent/src/adapters/claude/types.ts @@ -11,6 +11,7 @@ import type { import type { Pushable } from "../../utils/streams"; import type { BaseSession } from "../base-acp-agent"; import type { ContextBreakdownBaseline } from "./context-breakdown"; +import type { TaskState } from "./conversion/task-state"; import type { McpToolApprovals } from "./mcp/tool-metadata"; import type { SettingsManager } from "./session/settings"; import type { CodeExecutionMode } from "./tools"; @@ -68,6 +69,20 @@ export type Session = BaseSession & { emitRawSDKMessages: boolean | SDKMessageFilter[]; /** Refreshed at session init and on MCP/skill changes. */ contextBreakdownBaseline?: ContextBreakdownBaseline; + /** + * Slash command names (without leading slash) the SDK recognizes for this + * session — built-ins plus plugin/skill commands. Captured from the SDK's + * init response. Used to distinguish "command produced no output" from + * "command is genuinely unknown" when the session goes idle without an echo. + */ + knownSlashCommands?: Set; + /** + * Per-session task list accumulated from Task* tool calls. + * SDK >=0.3.142 replaced TodoWrite (snapshot) with TaskCreate/TaskUpdate + * (incremental, keyed by task id). Map iteration preserves insertion order + * which we use for plan entry ordering. + */ + taskState: TaskState; }; export type ToolUseCache = { diff --git a/packages/agent/src/adapters/codex/codex-agent.ts b/packages/agent/src/adapters/codex/codex-agent.ts index 4b43b3c0f9..11a878c2ce 100644 --- a/packages/agent/src/adapters/codex/codex-agent.ts +++ b/packages/agent/src/adapters/codex/codex-agent.ts @@ -57,7 +57,8 @@ import { type PermissionMode, } from "../../execution-mode"; import type { PostHogAPIConfig, ProcessSpawnedCallback } from "../../types"; -import { isCloudRun, resolveGithubToken } from "../../utils/common"; +import { isCloudRun } from "../../utils/common"; +import { resolveGithubToken } from "../../utils/github-token"; import { Logger } from "../../utils/logger"; import { nodeReadableToWebReadable, @@ -466,7 +467,7 @@ export class CodexAcpAgent extends BaseAcpAgent { // Carry taskRunId/taskId across load so prompt() still emits cloud // notifications (TURN_COMPLETE, USAGE_UPDATE) after a reload. newSession - // and unstable_resumeSession both do this; loadSession historically did + // and resumeSession both do this; loadSession historically did // not, which silently broke task-completion tracking on re-attach. resetSessionState(this.sessionState, params.sessionId, params.cwd, { taskRunId: meta?.taskRunId, @@ -489,7 +490,7 @@ export class CodexAcpAgent extends BaseAcpAgent { return response; } - async unstable_resumeSession( + async resumeSession( params: ResumeSessionRequest, ): Promise { const meta = params._meta as NewSessionMeta | undefined; diff --git a/packages/agent/src/adapters/codex/models.test.ts b/packages/agent/src/adapters/codex/models.test.ts new file mode 100644 index 0000000000..b31a039ac8 --- /dev/null +++ b/packages/agent/src/adapters/codex/models.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from "vitest"; +import { formatCodexModelName } from "./models"; + +describe("formatCodexModelName", () => { + it("uses raw lowercase model ids", () => { + expect(formatCodexModelName("GPT-5.5")).toBe("gpt-5.5"); + }); +}); diff --git a/packages/agent/src/adapters/codex/models.ts b/packages/agent/src/adapters/codex/models.ts index 635e631c7a..3264974fc0 100644 --- a/packages/agent/src/adapters/codex/models.ts +++ b/packages/agent/src/adapters/codex/models.ts @@ -21,21 +21,8 @@ export function getReasoningEffortOptions( return CODEX_REASONING_EFFORT_OPTIONS; } -const CODEX_ACRONYMS: Record = { - gpt: "GPT", -}; - export function formatCodexModelName(value: string): string { - const normalized = value.replace(/(\d)-(\d)/g, "$1.$2"); - return normalized - .split("-") - .map((part) => { - const lower = part.toLowerCase(); - if (CODEX_ACRONYMS[lower]) return CODEX_ACRONYMS[lower]; - if (/^[0-9.]+$/.test(part)) return part; - return part.charAt(0).toUpperCase() + part.slice(1).toLowerCase(); - }) - .join("-"); + return value.toLowerCase(); } export function normalizeCodexConfigOptions( diff --git a/packages/agent/src/adapters/local-tools/tools/signed-commit.test.ts b/packages/agent/src/adapters/local-tools/tools/signed-commit.test.ts new file mode 100644 index 0000000000..b749929f9d --- /dev/null +++ b/packages/agent/src/adapters/local-tools/tools/signed-commit.test.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const createSignedCommit = vi.fn(); + +vi.mock("@posthog/git/signed-commit", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + createSignedCommit: (...args: unknown[]) => createSignedCommit(...args), + }; +}); + +// Importing the tool after the mock so its transitive `createSignedCommit` +// reference resolves to the mock above. +const { signedCommitTool } = await import("./signed-commit"); + +describe("signed-commit tool handler", () => { + const savedSandbox = process.env.IS_SANDBOX; + + beforeEach(() => { + createSignedCommit.mockReset(); + createSignedCommit.mockResolvedValue({ + branch: "posthog-code/feature", + commits: [ + { sha: "deadbeef", url: "https://github.com/x/y/commit/deadbeef" }, + ], + }); + }); + + afterEach(() => { + if (savedSandbox === undefined) { + delete process.env.IS_SANDBOX; + } else { + process.env.IS_SANDBOX = savedSandbox; + } + }); + + it("defaults to the session cwd when args.cwd is absent", async () => { + await signedCommitTool.handler( + { cwd: "/tmp/workspace/repos/posthog/code", token: "ghs_x" }, + { message: "chore: bump" }, + ); + const [ctx] = createSignedCommit.mock.calls[0]; + expect(ctx.cwd).toBe("/tmp/workspace/repos/posthog/code"); + }); + + it("uses an absolute args.cwd verbatim so a sibling clone is reachable", async () => { + await signedCommitTool.handler( + { cwd: "/tmp/workspace/repos/posthog/code", token: "ghs_x" }, + { + message: "chore: bump", + cwd: "/tmp/workspace/repos/posthog/posthog", + }, + ); + const [ctx] = createSignedCommit.mock.calls[0]; + expect(ctx.cwd).toBe("/tmp/workspace/repos/posthog/posthog"); + }); + + it("resolves a relative args.cwd against the session cwd", async () => { + await signedCommitTool.handler( + { cwd: "/tmp/workspace/repos/posthog/code", token: "ghs_x" }, + { message: "chore: bump", cwd: "../posthog" }, + ); + const [ctx] = createSignedCommit.mock.calls[0]; + expect(ctx.cwd).toBe("/tmp/workspace/repos/posthog/posthog"); + }); + + it("does not forward cwd to createSignedCommit input", async () => { + await signedCommitTool.handler( + { cwd: "/tmp/workspace/repos/posthog/code", token: "ghs_x" }, + { message: "chore: bump", cwd: "/elsewhere" }, + ); + const [, input] = createSignedCommit.mock.calls[0]; + expect(input).not.toHaveProperty("cwd"); + expect(input).toEqual({ message: "chore: bump" }); + }); + + it("returns the no-token error without invoking createSignedCommit", async () => { + const savedGh = process.env.GH_TOKEN; + const savedGithub = process.env.GITHUB_TOKEN; + delete process.env.GH_TOKEN; + delete process.env.GITHUB_TOKEN; + try { + const result = await signedCommitTool.handler( + { cwd: "/tmp/workspace/repos/posthog/code" }, + { message: "chore: bump" }, + ); + expect(result.isError).toBe(true); + expect(createSignedCommit).not.toHaveBeenCalled(); + } finally { + if (savedGh !== undefined) process.env.GH_TOKEN = savedGh; + if (savedGithub !== undefined) process.env.GITHUB_TOKEN = savedGithub; + } + }); +}); diff --git a/packages/agent/src/adapters/local-tools/tools/signed-commit.ts b/packages/agent/src/adapters/local-tools/tools/signed-commit.ts index 24b78e5f03..4b38c5149b 100644 --- a/packages/agent/src/adapters/local-tools/tools/signed-commit.ts +++ b/packages/agent/src/adapters/local-tools/tools/signed-commit.ts @@ -1,4 +1,6 @@ -import { isCloudRun, resolveGithubToken } from "../../../utils/common"; +import * as path from "node:path"; +import { isCloudRun } from "../../../utils/common"; +import { resolveGithubToken } from "../../../utils/github-token"; import { runSignedCommitTool, SIGNED_COMMIT_TOOL_DESCRIPTION, @@ -21,7 +23,10 @@ export const signedCommitTool = defineLocalTool({ alwaysLoad: true, isEnabled: (_ctx, meta) => isCloudRun(meta), handler: (ctx, args) => { - const token = ctx.token ?? resolveGithubToken(); + // Prefer a freshly-resolved token (reads the live agentsh env file) over + // the one captured at session setup, so a mid-session credential refresh + // takes effect without rebuilding the session. + const token = resolveGithubToken() ?? ctx.token; if (!token) { return Promise.resolve({ content: [ @@ -33,9 +38,12 @@ export const signedCommitTool = defineLocalTool({ isError: true, }); } - return runSignedCommitTool( - { cwd: ctx.cwd, token, taskId: ctx.taskId }, - args, - ); + // Resolve an explicit `cwd` arg against the session cwd so the agent can + // commit from any clone reachable in the sandbox, not just the one the + // session was rooted at. Absolute paths fall through `path.resolve` + // unchanged; relative paths join the session cwd. + const { cwd: argCwd, ...input } = args; + const cwd = argCwd ? path.resolve(ctx.cwd, argCwd) : ctx.cwd; + return runSignedCommitTool({ cwd, token, taskId: ctx.taskId }, input); }, }); diff --git a/packages/agent/src/adapters/signed-commit-shared.ts b/packages/agent/src/adapters/signed-commit-shared.ts index d1014031a3..4f7c24fe43 100644 --- a/packages/agent/src/adapters/signed-commit-shared.ts +++ b/packages/agent/src/adapters/signed-commit-shared.ts @@ -41,6 +41,14 @@ export const signedCommitToolSchema = { .describe( "Files to stage before committing; defaults to already-staged files.", ), + cwd: z + .string() + .optional() + .describe( + "Path to the git checkout to commit from; defaults to the session's working directory. " + + "Pass this when committing to a clone outside the session cwd (e.g. a sibling repo cloned during the run). " + + "Relative paths resolve against the session cwd.", + ), }; export function formatSignedCommitResult(result: SignedCommitResult): string { diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index e4b3d9bfe7..4456e63be3 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -3,10 +3,10 @@ import { type InProcessAcpConnection, } from "./adapters/acp-connection"; import { - BLOCKED_MODELS, DEFAULT_CODEX_MODEL, DEFAULT_GATEWAY_MODEL, fetchModelsList, + isBlockedModelId, } from "./gateway-models"; import { PostHogAPIClient, type TaskRunUpdate } from "./posthog-api"; import { SessionLogWriter } from "./session-log-writer"; @@ -84,7 +84,7 @@ export class Agent { let allowedModelIds: Set | undefined; let sanitizedModel = - options.model && !BLOCKED_MODELS.has(options.model) + options.model && !isBlockedModelId(options.model) ? options.model : undefined; if (options.adapter === "codex" && gatewayConfig) { @@ -93,7 +93,7 @@ export class Agent { }); const codexModelIds = models .filter((model) => { - if (BLOCKED_MODELS.has(model.id)) return false; + if (isBlockedModelId(model.id)) return false; if (model.owned_by) { return model.owned_by === "openai"; } diff --git a/packages/agent/src/gateway-models.test.ts b/packages/agent/src/gateway-models.test.ts new file mode 100644 index 0000000000..42f4ac45c8 --- /dev/null +++ b/packages/agent/src/gateway-models.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { formatGatewayModelName, isBlockedModelId } from "./gateway-models"; + +describe("formatGatewayModelName", () => { + it("keeps Claude models in friendly title case", () => { + expect( + formatGatewayModelName({ + id: "claude-opus-4-8", + owned_by: "anthropic", + context_window: 200000, + supports_streaming: true, + supports_vision: true, + }), + ).toBe("Claude Opus 4.8"); + }); + + it("formats OpenAI models as raw lowercase model ids", () => { + expect( + formatGatewayModelName({ + id: "GPT-5.5", + owned_by: "openai", + context_window: 200000, + supports_streaming: true, + supports_vision: true, + }), + ).toBe("gpt-5.5"); + }); + + it("strips the openai/ prefix from OpenAI model ids", () => { + expect( + formatGatewayModelName({ + id: "openai/gpt-5.5", + owned_by: "openai", + context_window: 200000, + supports_streaming: true, + supports_vision: true, + }), + ).toBe("gpt-5.5"); + }); + + it("blocks deprecated Claude gateway models", () => { + expect(isBlockedModelId("claude-opus-4-5")).toBe(true); + expect(isBlockedModelId("claude-opus-4-6")).toBe(true); + expect(isBlockedModelId("claude-sonnet-4-5")).toBe(true); + expect(isBlockedModelId("claude-haiku-4-5")).toBe(true); + expect(isBlockedModelId("ANTHROPIC/CLAUDE-HAIKU-4-5")).toBe(true); + }); + + it("blocks deprecated Codex gateway models", () => { + expect(isBlockedModelId("gpt-5.2")).toBe(true); + expect(isBlockedModelId("gpt-5.3")).toBe(true); + expect(isBlockedModelId("gpt-5.3-codex")).toBe(true); + expect(isBlockedModelId("openai/gpt-5.2")).toBe(true); + expect(isBlockedModelId("OPENAI/GPT-5.3")).toBe(true); + expect(isBlockedModelId("OPENAI/GPT-5.3-CODEX")).toBe(true); + }); +}); diff --git a/packages/agent/src/gateway-models.ts b/packages/agent/src/gateway-models.ts index 01193777b8..b8aafc78f5 100644 --- a/packages/agent/src/gateway-models.ts +++ b/packages/agent/src/gateway-models.ts @@ -15,11 +15,32 @@ export interface FetchGatewayModelsOptions { gatewayUrl: string; } -export const DEFAULT_GATEWAY_MODEL = "claude-opus-4-7"; - -export const DEFAULT_CODEX_MODEL = "gpt-5.4"; - -export const BLOCKED_MODELS = new Set(["gpt-5-mini", "openai/gpt-5-mini"]); +export const DEFAULT_GATEWAY_MODEL = "claude-opus-4-8"; + +export const DEFAULT_CODEX_MODEL = "gpt-5.5"; + +const BLOCKED_MODELS = new Set([ + "gpt-5-mini", + "openai/gpt-5-mini", + "gpt-5.2", + "openai/gpt-5.2", + "gpt-5.3", + "openai/gpt-5.3", + "gpt-5.3-codex", + "openai/gpt-5.3-codex", + "claude-opus-4-5", + "anthropic/claude-opus-4-5", + "claude-opus-4-6", + "anthropic/claude-opus-4-6", + "claude-sonnet-4-5", + "anthropic/claude-sonnet-4-5", + "claude-haiku-4-5", + "anthropic/claude-haiku-4-5", +]); + +export function isBlockedModelId(modelId: string): boolean { + return BLOCKED_MODELS.has(modelId.toLowerCase()); +} type ModelsListResponse = | { @@ -62,7 +83,7 @@ export async function fetchGatewayModels( } const data = (await response.json()) as GatewayModelsResponse; - const models = (data.data ?? []).filter((m) => !BLOCKED_MODELS.has(m.id)); + const models = (data.data ?? []).filter((m) => !isBlockedModelId(m.id)); gatewayModelsCache = { models, expiry: Date.now() + CACHE_TTL, @@ -129,6 +150,7 @@ export async function fetchModelsList( for (const model of models) { const id = model?.id ? String(model.id) : ""; if (!id) continue; + if (isBlockedModelId(id)) continue; results.push({ id, owned_by: model?.owned_by }); } modelsListCache = { @@ -155,9 +177,22 @@ export function getProviderName(ownedBy: string): string { const PROVIDER_PREFIXES = ["anthropic/", "openai/", "google-vertex/"]; export function formatGatewayModelName(model: GatewayModel): string { + if (isOpenAIModel(model)) { + return stripProviderPrefix(model.id).toLowerCase(); + } + return formatModelId(model.id); } +function stripProviderPrefix(modelId: string): string { + for (const prefix of PROVIDER_PREFIXES) { + if (modelId.startsWith(prefix)) { + return modelId.slice(prefix.length); + } + } + return modelId; +} + export function formatModelId(modelId: string): string { let cleanId = modelId; for (const prefix of PROVIDER_PREFIXES) { diff --git a/packages/agent/src/server/agent-server.configure-environment.test.ts b/packages/agent/src/server/agent-server.configure-environment.test.ts index c855de495d..69871e5cb9 100644 --- a/packages/agent/src/server/agent-server.configure-environment.test.ts +++ b/packages/agent/src/server/agent-server.configure-environment.test.ts @@ -1,10 +1,11 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { Task } from "../types"; import { AgentServer } from "./agent-server"; interface TestableServer { configureEnvironment(args?: { isInternal?: boolean; - originProduct?: string | null; + originProduct?: Task["origin_product"] | null; signalReportId?: string | null; taskId?: string | null; taskRunId?: string | null; @@ -17,6 +18,7 @@ const ENV_KEYS_UNDER_TEST = [ "ANTHROPIC_BASE_URL", "OPENAI_BASE_URL", "ANTHROPIC_CUSTOM_HEADERS", + "POSTHOG_PROJECT_ID", ] as const; describe("AgentServer.configureEnvironment", () => { @@ -74,6 +76,15 @@ describe("AgentServer.configureEnvironment", () => { ); }); + // The Claude session builder reads POSTHOG_PROJECT_ID to emit the + // `x-posthog-property-team_id` attribution header (see + // adapters/claude/session/options.ts), so the cloud path must export it. + it("exports POSTHOG_PROJECT_ID for the team_id attribution header", () => { + buildServer("background").configureEnvironment({ isInternal: false }); + + expect(process.env.POSTHOG_PROJECT_ID).toBe("1"); + }); + it("tags as posthog_code when isInternal is omitted (getTask failure fallback)", () => { buildServer("background").configureEnvironment(); @@ -162,6 +173,43 @@ describe("AgentServer.configureEnvironment", () => { ); }); + it("tags as slack_app when the task was initiated from Slack", () => { + buildServer("interactive").configureEnvironment({ + originProduct: "slack", + }); + + expect(process.env.LLM_GATEWAY_URL).toBe( + "https://gateway.us.posthog.com/slack_app", + ); + expect(process.env.ANTHROPIC_BASE_URL).toBe( + "https://gateway.us.posthog.com/slack_app", + ); + expect(process.env.OPENAI_BASE_URL).toBe( + "https://gateway.us.posthog.com/slack_app/v1", + ); + }); + + it("prefers slack_app over background_agents when both signals are present", () => { + buildServer("interactive").configureEnvironment({ + isInternal: true, + originProduct: "slack", + }); + + expect(process.env.LLM_GATEWAY_URL).toBe( + "https://gateway.us.posthog.com/slack_app", + ); + }); + + it("falls back to posthog_code for non-slack origin products", () => { + buildServer("background").configureEnvironment({ + originProduct: "user_created", + }); + + expect(process.env.LLM_GATEWAY_URL).toBe( + "https://gateway.us.posthog.com/posthog_code", + ); + }); + it("respects the LLM_GATEWAY_URL override regardless of internal flag", () => { process.env.LLM_GATEWAY_URL = "http://ngrok.test/proxy"; diff --git a/packages/agent/src/server/agent-server.test.ts b/packages/agent/src/server/agent-server.test.ts index df5d57ba0d..388ca57797 100644 --- a/packages/agent/src/server/agent-server.test.ts +++ b/packages/agent/src/server/agent-server.test.ts @@ -16,6 +16,179 @@ import type { TaskRun } from "../types"; import { AgentServer, SSE_KEEPALIVE_INTERVAL_MS } from "./agent-server"; import { type JwtPayload, SANDBOX_CONNECTION_AUDIENCE } from "./jwt"; +const mockedClaudeSdk = vi.hoisted(() => { + const createSuccessResult = () => ({ + type: "result", + subtype: "success", + duration_ms: 100, + duration_api_ms: 50, + is_error: false, + num_turns: 1, + result: "Done", + stop_reason: null, + total_cost_usd: 0.01, + usage: { + input_tokens: 100, + output_tokens: 50, + output_tokens_details: { thinking_tokens: 0 }, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + cache_creation: { + ephemeral_1h_input_tokens: 0, + ephemeral_5m_input_tokens: 0, + }, + server_tool_use: { web_search_requests: 0, web_fetch_requests: 0 }, + service_tier: "standard", + inference_geo: "us", + iterations: [], + speed: "standard", + }, + modelUsage: {}, + permission_denials: [], + uuid: crypto.randomUUID() as `${string}-${string}-${string}-${string}-${string}`, + session_id: "test-session", + }); + + const query = vi.fn( + (params: { prompt?: { push?: (message: unknown) => void } }) => { + const queuedMessages: unknown[] = []; + let resolveNext: ((value: IteratorResult) => void) | null = + null; + let isDone = false; + + const flushQueue = () => { + if (!resolveNext) { + return; + } + + if (queuedMessages.length > 0) { + const resolve = resolveNext; + resolveNext = null; + resolve({ + value: queuedMessages.shift(), + done: false, + }); + return; + } + + if (isDone) { + const resolve = resolveNext; + resolveNext = null; + resolve({ value: undefined, done: true }); + } + }; + + const enqueue = (message: unknown) => { + if (isDone) { + return; + } + queuedMessages.push(message); + flushQueue(); + }; + + const prompt = params.prompt; + if (prompt && typeof prompt.push === "function") { + const originalPush = prompt.push.bind(prompt); + prompt.push = (message: unknown) => { + originalPush(message); + + if ( + message && + typeof message === "object" && + "uuid" in message && + typeof message.uuid === "string" + ) { + enqueue({ + type: "user", + uuid: message.uuid, + parent_tool_use_id: null, + message: { + content: [], + }, + }); + enqueue(createSuccessResult()); + } + }; + } + + return { + next: vi.fn(() => { + if (queuedMessages.length > 0) { + return Promise.resolve({ + value: queuedMessages.shift(), + done: false as const, + }); + } + + if (isDone) { + return Promise.resolve({ + value: undefined, + done: true as const, + }); + } + + return new Promise>((resolve) => { + resolveNext = resolve; + }); + }), + return: vi.fn(() => { + isDone = true; + flushQueue(); + return Promise.resolve({ value: undefined, done: true as const }); + }), + throw: vi.fn((error: Error) => { + isDone = true; + flushQueue(); + return Promise.reject(error); + }), + [Symbol.asyncIterator]() { + return this; + }, + interrupt: vi.fn(async () => { + isDone = true; + flushQueue(); + }), + setPermissionMode: vi.fn().mockResolvedValue(undefined), + setModel: vi.fn().mockResolvedValue(undefined), + setMaxThinkingTokens: vi.fn().mockResolvedValue(undefined), + supportedCommands: vi.fn().mockResolvedValue([]), + supportedModels: vi.fn().mockResolvedValue([]), + mcpServerStatus: vi.fn().mockResolvedValue([]), + accountInfo: vi.fn().mockResolvedValue({}), + rewindFiles: vi.fn().mockResolvedValue({ canRewind: false }), + setMcpServers: vi + .fn() + .mockResolvedValue({ added: [], removed: [], errors: {} }), + streamInput: vi.fn().mockResolvedValue(undefined), + close: vi.fn(), + initializationResult: vi.fn().mockResolvedValue({ + result: "success", + commands: [], + models: [], + }), + reconnectMcpServer: vi.fn().mockResolvedValue(undefined), + toggleMcpServer: vi.fn().mockResolvedValue(undefined), + supportedAgents: vi.fn().mockResolvedValue([]), + stopTask: vi.fn().mockResolvedValue(undefined), + applyFlagSettings: vi.fn().mockResolvedValue(undefined), + getContextUsage: vi.fn().mockResolvedValue({}), + reloadPlugins: vi.fn().mockResolvedValue(undefined), + seedReadState: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockResolvedValue(""), + backgroundTasks: vi.fn().mockResolvedValue([]), + [Symbol.asyncDispose]: vi.fn().mockResolvedValue(undefined), + }; + }, + ); + + return { query }; +}); + +vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => ({ + ...(await importOriginal()), + query: mockedClaudeSdk.query, +})); + interface TestableServer { getInitialPromptOverride(run: TaskRun): string | null; getClearedPendingUserState(run: TaskRun | null): string[] | null; diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index 332d863165..3c3bde54eb 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -43,6 +43,7 @@ import type { GitCheckpointEvent, HandoffLocalGitState, LogLevel, + Task, TaskRun, TaskRunArtifact, } from "../types"; @@ -586,6 +587,44 @@ export class AgentServer { this.logger.debug("Agent server stopped"); } + /** + * Mark the run failed after an unrecoverable crash (uncaught exception / + * unhandled rejection). Without this a hard death is silent: the run row + * stays non-terminal, the desktop client just sees the stream stop and shows + * a generic "Cloud stream disconnected", and the workflow only gives up after + * the multi-hour inactivity timeout. Best-effort and self-contained so it can + * run from a process-level handler with no session context. + */ + async reportFatalError(error: unknown): Promise { + const errorMessage = error instanceof Error ? error.message : String(error); + this.logger.error("Fatal agent-server error; marking run failed", error); + + try { + await this.posthogAPI.updateTaskRun( + this.config.taskId, + this.config.runId, + { + status: "failed", + error_message: `Agent server crashed: ${errorMessage}`, + }, + ); + } catch (updateError) { + this.logger.error( + "Failed to mark run failed after fatal error", + updateError, + ); + } + + try { + await this.eventStreamSender?.stop(); + } catch (stopError) { + this.logger.error( + "Failed to flush event stream after fatal error", + stopError, + ); + } + } + private authenticateRequest( getHeader: (name: string) => string | undefined, ): JwtPayload { @@ -749,6 +788,22 @@ export class AgentServer { const mcpServers = Array.isArray(params.mcpServers) ? params.mcpServers : []; + const refreshedCredentials = Array.isArray(params.refreshedCredentials) + ? (params.refreshedCredentials as string[]) + : []; + const authorship = + typeof params.authorship === "string" ? params.authorship : ""; + + if (refreshedCredentials.length > 0) { + const owner = authorship ? ` (${authorship})` : ""; + this.logger.debug( + `Refreshed sandbox credentials${owner}: ${refreshedCredentials.join(", ")}`, + ); + } + + if (mcpServers.length === 0) { + return { refreshed: true }; + } this.logger.debug("Refresh session requested", { serverCount: mcpServers.length, @@ -1860,7 +1915,7 @@ ${signedCommitInstructions} taskUserId, }: { isInternal?: boolean; - originProduct?: string | null; + originProduct?: Task["origin_product"] | null; signalReportId?: string | null; taskId?: string | null; taskRunId?: string | null; @@ -1876,7 +1931,9 @@ ${signedCommitInstructions} // Forward task metadata as `x-posthog-property-*` headers so the gateway // lifts them onto the $ai_generation event. Routes through the Anthropic // SDK's ANTHROPIC_CUSTOM_HEADERS env var; the OpenAI/codex path has no - // equivalent today. + // equivalent today. (The `team_id` attribution header is added downstream + // in the Claude session builder from POSTHOG_PROJECT_ID — see + // adapters/claude/session/options.ts.) const customHeaders = buildGatewayPropertyHeaders({ task_origin_product: originProduct, task_internal: isInternal, diff --git a/packages/agent/src/server/bin.ts b/packages/agent/src/server/bin.ts index 36bfe7a0e9..c70368ae9a 100644 --- a/packages/agent/src/server/bin.ts +++ b/packages/agent/src/server/bin.ts @@ -187,6 +187,27 @@ program process.exit(0); }); + // A hard crash would otherwise leave the run non-terminal and the user staring + // at a generic "Cloud stream disconnected". Mark the run failed before exiting + // so the desktop surfaces a real error instead of a silent stall. The deadline + // guarantees we exit even if reportFatalError's network calls hang at crash time + // (e.g. API unreachable during a restart), so we never block pod shutdown. + const FATAL_ERROR_REPORT_DEADLINE_MS = 5_000; + const handleFatalError = async (error: unknown) => { + try { + await Promise.race([ + server.reportFatalError(error), + new Promise((resolve) => + setTimeout(resolve, FATAL_ERROR_REPORT_DEADLINE_MS).unref(), + ), + ]); + } finally { + process.exit(1); + } + }; + process.on("uncaughtException", handleFatalError); + process.on("unhandledRejection", handleFatalError); + await server.start(); }); diff --git a/packages/agent/src/test/mocks/claude-sdk.ts b/packages/agent/src/test/mocks/claude-sdk.ts index 82786b54f4..e54cb05ccf 100644 --- a/packages/agent/src/test/mocks/claude-sdk.ts +++ b/packages/agent/src/test/mocks/claude-sdk.ts @@ -105,6 +105,8 @@ export function createMockQuery( getContextUsage: vi.fn().mockResolvedValue({}), reloadPlugins: vi.fn().mockResolvedValue(undefined), seedReadState: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockResolvedValue(""), + backgroundTasks: vi.fn().mockResolvedValue([]), [Symbol.asyncDispose]: vi.fn().mockResolvedValue(undefined), _abortController: abortController, _mockHelpers: { @@ -173,6 +175,7 @@ export function createSuccessResult( usage: { input_tokens: 100, output_tokens: 50, + output_tokens_details: { thinking_tokens: 0 }, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, cache_creation: { @@ -209,6 +212,7 @@ export function createErrorResult( usage: { input_tokens: 100, output_tokens: 50, + output_tokens_details: { thinking_tokens: 0 }, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, cache_creation: { @@ -240,7 +244,7 @@ export function createInitMessage(sessionId = "test-session"): SDKMessage { cwd: "/tmp", tools: [], mcp_servers: [], - model: "claude-sonnet-4-5-20250929", + model: "claude-sonnet-4-6", permissionMode: "default", slash_commands: [], output_style: "default", diff --git a/packages/agent/src/test/mocks/msw-handlers.ts b/packages/agent/src/test/mocks/msw-handlers.ts index efbb865f21..c73fe912cc 100644 --- a/packages/agent/src/test/mocks/msw-handlers.ts +++ b/packages/agent/src/test/mocks/msw-handlers.ts @@ -29,14 +29,14 @@ export function createPostHogHandlers(options: PostHogHandlersOptions = {}) { object: "list", data: [ { - id: "claude-opus-4-7", + id: "claude-opus-4-8", owned_by: "anthropic", context_window: 200000, supports_streaming: true, supports_vision: true, }, { - id: "gpt-5.4", + id: "gpt-5.5", owned_by: "openai", context_window: 200000, supports_streaming: true, diff --git a/packages/agent/src/test/native-binary.test.ts b/packages/agent/src/test/native-binary.test.ts new file mode 100644 index 0000000000..61c53413d7 --- /dev/null +++ b/packages/agent/src/test/native-binary.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { + CLAUDE_CLI_SUPPORT_DIRS, + CLAUDE_CLI_SUPPORT_FILES, + claudeExecutableCandidates, +} from "../../build/native-binary.mjs"; + +describe("claudeExecutableCandidates", () => { + it("includes the legacy cli.js fallback after native binary candidates", () => { + const candidates = claudeExecutableCandidates("/tmp/node_modules"); + expect(candidates.at(-1)).toBe( + "/tmp/node_modules/@anthropic-ai/claude-agent-sdk/cli.js", + ); + }); +}); + +describe("Claude CLI support assets", () => { + it("tracks the files needed by the legacy SDK layout", () => { + expect(CLAUDE_CLI_SUPPORT_FILES).toEqual([ + "package.json", + "manifest.json", + "manifest.zst.json", + "yoga.wasm", + ]); + expect(CLAUDE_CLI_SUPPORT_DIRS).toEqual(["vendor"]); + }); +}); diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 279f478046..0b707b70c9 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -38,7 +38,8 @@ export interface Task { | "user_created" | "support_queue" | "session_summaries" - | "signal_report"; + | "signal_report" + | "slack"; signal_report?: string | null; // Inbox report UUID when origin_product is "signal_report" github_integration?: number | null; repository: string; // Format: "organization/repository" (e.g., "posthog/posthog-js") diff --git a/packages/agent/src/utils/common.ts b/packages/agent/src/utils/common.ts index 30f5542318..0ca649e7ff 100644 --- a/packages/agent/src/utils/common.ts +++ b/packages/agent/src/utils/common.ts @@ -1,4 +1,3 @@ -import { readGithubTokenFromEnv } from "@posthog/git/signed-commit"; import type { Logger } from "./logger"; /** @@ -39,11 +38,6 @@ export function isCloudRun( return !!process.env.IS_SANDBOX; } -/** The GitHub token available to the sandbox, if any. */ -export function resolveGithubToken(): string | undefined { - return readGithubTokenFromEnv(); -} - export function unreachable(value: never, logger: Logger): void { let valueAsString: string; try { diff --git a/packages/agent/src/utils/gateway.ts b/packages/agent/src/utils/gateway.ts index ff27758b29..a3370bce0d 100644 --- a/packages/agent/src/utils/gateway.ts +++ b/packages/agent/src/utils/gateway.ts @@ -1,4 +1,8 @@ -export type GatewayProduct = "posthog_code" | "background_agents" | "signals"; +export type GatewayProduct = + | "posthog_code" + | "background_agents" + | "signals" + | "slack_app"; export function resolveGatewayProduct({ isInternal, @@ -7,6 +11,9 @@ export function resolveGatewayProduct({ isInternal?: boolean; originProduct?: string | null; } = {}): GatewayProduct { + if (originProduct === "slack") { + return "slack_app"; + } if (isInternal) { return originProduct === "signal_report" ? "signals" : "background_agents"; } diff --git a/packages/agent/src/utils/github-token.test.ts b/packages/agent/src/utils/github-token.test.ts new file mode 100644 index 0000000000..08ef9428b9 --- /dev/null +++ b/packages/agent/src/utils/github-token.test.ts @@ -0,0 +1,76 @@ +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + readGithubTokenFromSandboxEnvFile, + resolveGithubToken, +} from "./github-token"; + +function writeEnvFile(contents: string): string { + const dir = mkdtempSync(join(tmpdir(), "agent-env-")); + const path = join(dir, "agent-env"); + writeFileSync(path, contents); + return path; +} + +describe("github-token", () => { + describe("readGithubTokenFromSandboxEnvFile", () => { + it.each([ + { + name: "GH_TOKEN", + contents: "PATH=/usr/bin\0GH_TOKEN=ghs_fresh123\0HOME=/root\0", + expected: "ghs_fresh123", + }, + { + name: "GITHUB_TOKEN when GH_TOKEN is absent", + contents: "GITHUB_TOKEN=ghu_user456\0PATH=/usr/bin\0", + expected: "ghu_user456", + }, + ])( + "reads $name from the NUL-delimited env file", + ({ contents, expected }) => { + expect(readGithubTokenFromSandboxEnvFile(writeEnvFile(contents))).toBe( + expected, + ); + }, + ); + + it("reflects an updated file (live read, not cached)", () => { + const path = writeEnvFile("GH_TOKEN=ghs_old\0"); + expect(readGithubTokenFromSandboxEnvFile(path)).toBe("ghs_old"); + writeFileSync(path, "GH_TOKEN=ghs_new\0"); + expect(readGithubTokenFromSandboxEnvFile(path)).toBe("ghs_new"); + }); + + it("returns undefined when the file is missing", () => { + expect( + readGithubTokenFromSandboxEnvFile("/nonexistent/agent-env"), + ).toBeUndefined(); + }); + + it("ignores an empty token value", () => { + const path = writeEnvFile("GH_TOKEN=\0GITHUB_TOKEN=ghs_real\0"); + expect(readGithubTokenFromSandboxEnvFile(path)).toBe("ghs_real"); + }); + }); + + describe("resolveGithubToken", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("prefers the sandbox env file over the process env", () => { + vi.stubEnv("GH_TOKEN", "ghs_fromprocess"); + const path = writeEnvFile("GH_TOKEN=ghs_fromfile\0"); + expect(resolveGithubToken(path)).toBe("ghs_fromfile"); + }); + + it("falls back to the process env when the sandbox file is absent", () => { + vi.stubEnv("GH_TOKEN", "ghs_fromprocess"); + expect(resolveGithubToken("/nonexistent/agent-env")).toBe( + "ghs_fromprocess", + ); + }); + }); +}); diff --git a/packages/agent/src/utils/github-token.ts b/packages/agent/src/utils/github-token.ts new file mode 100644 index 0000000000..33a8f15a5e --- /dev/null +++ b/packages/agent/src/utils/github-token.ts @@ -0,0 +1,44 @@ +import { readFileSync } from "node:fs"; +import { readGithubTokenFromEnv } from "@posthog/git/signed-commit"; + +// helpers for resolving the in-sandbox GitHub token +// agentsh env file (NUL-delimited `key=value` pairs) that the PostHog backend +// rewrites in place when it refreshes the sandbox's GitHub credentials +// mid-session. The agent-server process env is frozen at launch, so reading +// this live file is how in-process tools pick up a refreshed token without a +// process restart. +export const SANDBOX_ENV_FILE = "/tmp/agent-env"; + +export function readGithubTokenFromSandboxEnvFile( + envFilePath: string = SANDBOX_ENV_FILE, +): string | undefined { + try { + const raw = readFileSync(envFilePath, "utf8"); + const env: Record = {}; + for (const entry of raw.split("\0")) { + const eq = entry.indexOf("="); + if (eq > 0) { + env[entry.slice(0, eq)] = entry.slice(eq + 1); + } + } + // Reuse the shared token-var allowlist + precedence instead of hardcoding. + return readGithubTokenFromEnv(env); + } catch { + // No env file (local/desktop or test) — fall back to the process env. + } + return undefined; +} + +/** The GitHub token available to the sandbox, if any. + * + * Prefers the live agentsh env file (refreshed in place mid-session) over the + * process env (frozen at launch) so long-running in-process tools — e.g. the + * signed-commit tool — pick up a refreshed token without a restart. + */ +export function resolveGithubToken( + envFilePath: string = SANDBOX_ENV_FILE, +): string | undefined { + return ( + readGithubTokenFromSandboxEnvFile(envFilePath) ?? readGithubTokenFromEnv() + ); +} diff --git a/packages/agent/tsup.config.ts b/packages/agent/tsup.config.ts index d17d91e4ce..e704a62e9b 100644 --- a/packages/agent/tsup.config.ts +++ b/packages/agent/tsup.config.ts @@ -1,4 +1,5 @@ import { + chmodSync, copyFileSync, cpSync, existsSync, @@ -6,8 +7,42 @@ import { writeFileSync, } from "node:fs"; import { builtinModules } from "node:module"; -import { resolve } from "node:path"; +import { dirname, resolve } from "node:path"; import { defineConfig } from "tsup"; +// Plain ESM helper, shared with apps/code/vite.main.config.mts. +import { + CLAUDE_CLI_SUPPORT_DIRS, + CLAUDE_CLI_SUPPORT_FILES, + claudeBinName, + claudeExecutableCandidates, + targetArch, + targetPlatform, +} from "./build/native-binary.mjs"; + +function nativeBinarySourcePath(): string | undefined { + const candidates = claudeExecutableCandidates( + resolve(import.meta.dirname, "../../node_modules"), + ); + return candidates.find((p: string) => existsSync(p)); +} + +function copyClaudeSupportAssets(sourcePath: string, destDir: string): void { + const sourceDir = dirname(sourcePath); + + for (const file of CLAUDE_CLI_SUPPORT_FILES) { + const source = resolve(sourceDir, file); + if (existsSync(source)) { + copyFileSync(source, resolve(destDir, file)); + } + } + + for (const dir of CLAUDE_CLI_SUPPORT_DIRS) { + const source = resolve(sourceDir, dir); + if (existsSync(source)) { + cpSync(source, resolve(destDir, dir), { recursive: true }); + } + } +} function copyAssets() { const distDir = resolve(import.meta.dirname, "dist"); @@ -22,32 +57,25 @@ function copyAssets() { cpSync(srcTemplatesDir, templatesDir, { recursive: true }); } - const claudeSdkPath = resolve( - import.meta.dirname, - "../../node_modules/@anthropic-ai/claude-agent-sdk", - ); - const cliJsPath = resolve(claudeSdkPath, "cli.js"); - if (existsSync(cliJsPath)) { - copyFileSync(cliJsPath, resolve(claudeCliDir, "cli.js")); + const binName = claudeBinName(); + const nativeBinary = nativeBinarySourcePath(); + if (nativeBinary) { + const dest = resolve(claudeCliDir, binName); + copyFileSync(nativeBinary, dest); + if (targetPlatform() !== "win32") { + chmodSync(dest, 0o755); + } + copyClaudeSupportAssets(nativeBinary, claudeCliDir); + } else { + console.warn( + `[agent/tsup] No Claude executable found for ${targetPlatform()}-${targetArch()}; install @anthropic-ai/claude-agent-sdk optional deps`, + ); } writeFileSync( resolve(claudeCliDir, "package.json"), JSON.stringify({ type: "module" }, null, 2), ); - - const yogaWasmPath = resolve( - import.meta.dirname, - "../../node_modules/yoga-wasm-web/dist/yoga.wasm", - ); - if (existsSync(yogaWasmPath)) { - copyFileSync(yogaWasmPath, resolve(claudeCliDir, "yoga.wasm")); - } - - const vendorDir = resolve(claudeSdkPath, "vendor"); - if (existsSync(vendorDir)) { - cpSync(vendorDir, resolve(claudeCliDir, "vendor"), { recursive: true }); - } } const sharedOptions = { diff --git a/packages/shared/src/inbox-prompts.ts b/packages/shared/src/inbox-prompts.ts new file mode 100644 index 0000000000..ad7ae7cf44 --- /dev/null +++ b/packages/shared/src/inbox-prompts.ts @@ -0,0 +1,20 @@ +interface BuildDiscussReportPromptOptions { + reportId: string; + reportLink: string; + question?: string; +} + +export function buildDiscussReportPrompt({ + reportId, + reportLink, + question, +}: BuildDiscussReportPromptOptions): string { + const trimmedQuestion = question?.trim(); + const intro = `Discuss PostHog inbox report ${reportId} ([inbox item](${reportLink})). Use the inbox MCP tools to fetch the report,`; + const guard = + " If you can't fetch the report, say so instead of guessing what it contains."; + const body = trimmedQuestion + ? `${intro} then answer this first: ${trimmedQuestion}` + : `${intro} then give me a brief readout and ask what I want to dig into.`; + return `${body}${guard}`; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 7dda6bd7f8..752019b25c 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -30,6 +30,7 @@ export { type ParsedImageDataUrl, parseImageDataUrl, } from "./image"; +export { buildDiscussReportPrompt } from "./inbox-prompts"; export { Saga, type SagaLogger, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee3f68cd71..5e7c6a229c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -196,6 +196,9 @@ importers: '@tanstack/react-query': specifier: ^5.90.2 version: 5.90.20(react@19.1.0) + '@tanstack/react-virtual': + specifier: ^3.13.26 + version: 3.13.26(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@tiptap/core': specifier: ^3.13.0 version: 3.19.0(@tiptap/pm@3.19.0) @@ -422,6 +425,9 @@ importers: '@posthog/rollup-plugin': specifier: ^1.4.0 version: 1.4.0(rollup@4.57.1) + '@reforged/maker-appimage': + specifier: ^5.2.0 + version: 5.2.0 '@storybook/addon-a11y': specifier: 10.2.0 version: 10.2.0(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) @@ -709,14 +715,14 @@ importers: packages/agent: dependencies: '@agentclientprotocol/sdk': - specifier: 0.19.0 - version: 0.19.0(zod@4.3.6) + specifier: 0.22.1 + version: 0.22.1(zod@4.3.6) '@anthropic-ai/claude-agent-sdk': - specifier: 0.2.112 - version: 0.2.112(zod@4.3.6) + specifier: 0.3.156 + version: 0.3.156(@anthropic-ai/sdk@0.100.1(zod@4.3.6))(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(zod@4.3.6) '@anthropic-ai/sdk': - specifier: 0.89.0 - version: 0.89.0(zod@4.3.6) + specifier: 0.100.1 + version: 0.100.1(zod@4.3.6) '@hono/node-server': specifier: ^1.19.9 version: 1.19.9(hono@4.11.7) @@ -910,6 +916,11 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 + '@agentclientprotocol/sdk@0.22.1': + resolution: {integrity: sha512-DfqXtl/8gO9NImq094MTaCXEU2vkhh6v7q/kT+9UjZxUqj8hYaya2OjLVIqn16MzNHcXEpShTR2RIauLSYeDQQ==} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -918,23 +929,60 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} - '@anthropic-ai/claude-agent-sdk@0.2.112': - resolution: {integrity: sha512-vMFoiDKlOive8p3tphpV1gQaaytOipwGJ+uw9mvvaLQUODSC2+fCdRDAY25i2Tsv+lOtxzXBKctmaDuWqZY7ig==} + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.156': + resolution: {integrity: sha512-IkjcS9dqAUlD4Nb62L9AZtmAXCa+FV4ul8lIlyXXUprh3nlecbKsWOXVd/GORrzAhMmynJaX4+iV1JiutFKXUA==} + cpu: [arm64] + os: [darwin] + + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.156': + resolution: {integrity: sha512-6PKi5fPmGRuzXu+Em/iwLmPG3mqg0hl92wcTU8fmChqyNtxhxsjCw7LTbdFqp/05o5NeZVVV4k3p7YUv5IFD6g==} + cpu: [x64] + os: [darwin] + + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.156': + resolution: {integrity: sha512-R7KEVjxkR4rYgIQoHGBzwPdUJYxRTO8I4vHjRbMLH1eW4FS7BJvVs7ogfKR/NnHFBvMVqtC+l6jHLQv8bobUiw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.156': + resolution: {integrity: sha512-H0Nfd41iw5isto9uQI1FlVSZ0eaDttr8rBpJMR25oK/mj3egMO5EmZ6aAxeeUYSLn2mSU50HA5VNxlGUE118TQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.156': + resolution: {integrity: sha512-/Q6WUizI6a+hqZZ6ElwRU0PEuFhOoN4v6CuU35HHbiZ/7uaocGht4A8ZIgK1Fw6wOGtZzGLbc00CA1OU1Zg8EA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.156': + resolution: {integrity: sha512-ymhrdlbWoYvTACUdaGdhrEv+ZMfwXLsf0BRLkr/IvY5aqybP7URzWmmZGOtDQpqkT/8xu/UCGqUYH3woJwUxfg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.156': + resolution: {integrity: sha512-5sAeNObQQrMy4NF9HwxewrMnU7mVxZDHh+/MfJVQSz0GSTvXQ6gOuRH8helMlfspoU6VOdekPxVLRooX/3foEw==} + cpu: [arm64] + os: [win32] + + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.156': + resolution: {integrity: sha512-/PofeTWoiKgnWNSNk0wG4SsRn22GGLmnLhg2R94WcNhCRFOyOTmiZcYH2DBlWZBIRVTZDsSfa/Pl1DyPvYCGKw==} + cpu: [x64] + os: [win32] + + '@anthropic-ai/claude-agent-sdk@0.3.156': + resolution: {integrity: sha512-6nM/Dj+VMds52UXJ2YaV4IKhYamlUqN0HtdDrFzYz5lvPMpDS935qD8YZDAUpy+ltdoD6PJMd1V/CKFY3/oWCQ==} engines: {node: '>=18.0.0'} peerDependencies: + '@anthropic-ai/sdk': '>=0.93.0' + '@modelcontextprotocol/sdk': ^1.29.0 zod: ^4.0.0 - '@anthropic-ai/sdk@0.81.0': - resolution: {integrity: sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw==} - hasBin: true - peerDependencies: - zod: ^3.25.0 || ^4.0.0 - peerDependenciesMeta: - zod: - optional: true - - '@anthropic-ai/sdk@0.89.0': - resolution: {integrity: sha512-nyGau0zex62EpU91hsHa0zod973YEoiMgzWZ9hC55WdiOLrE4AGpcg4wXI7lFqtvMLqMcLfewQU9sHgQB6psow==} + '@anthropic-ai/sdk@0.100.1': + resolution: {integrity: sha512-RANcEe7LpiLczkKGOwoXOTuFdPhuubS0i4xaAKOMpcqc55YO0mukgxppV7eygx3DXNjxWT6RYOLPyOy0aIAmwg==} hasBin: true peerDependencies: zod: ^3.25.0 || ^4.0.0 @@ -1000,6 +1048,12 @@ packages: peerDependencies: '@babel/core': ^7.0.0 + '@babel/helper-create-class-features-plugin@7.29.3': + resolution: {integrity: sha512-RpLYy2sb51oNLjuu1iD3bwBqCBWUzjO0ocp+iaCP/lJtb2CPLcnC2Fftw+4sAzaMELGeWTgExSKADbdo0GFVzA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + '@babel/helper-create-regexp-features-plugin@7.28.5': resolution: {integrity: sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==} engines: {node: '>=6.9.0'} @@ -1488,6 +1542,10 @@ packages: resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} engines: {node: '>=6.9.0'} + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} @@ -2613,105 +2671,6 @@ packages: '@ide/backoff@1.0.0': resolution: {integrity: sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g==} - '@img/sharp-darwin-arm64@0.34.5': - resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [darwin] - - '@img/sharp-darwin-x64@0.34.5': - resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-darwin-arm64@1.2.4': - resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} - cpu: [arm64] - os: [darwin] - - '@img/sharp-libvips-darwin-x64@1.2.4': - resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-linux-arm64@1.2.4': - resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-arm@1.2.4': - resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-x64@1.2.4': - resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@img/sharp-linux-arm64@0.34.5': - resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-arm@0.34.5': - resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-x64@0.34.5': - resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@img/sharp-linuxmusl-arm64@0.34.5': - resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@img/sharp-linuxmusl-x64@0.34.5': - resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@img/sharp-win32-arm64@0.34.5': - resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [win32] - - '@img/sharp-win32-x64@0.34.5': - resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [win32] - '@inquirer/ansi@1.0.2': resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} engines: {node: '>=18'} @@ -4615,6 +4574,13 @@ packages: '@react-navigation/routers@7.5.3': resolution: {integrity: sha512-1tJHg4KKRJuQ1/EvJxatrMef3NZXEPzwUIUZ3n1yJ2t7Q97siwRtbynRpQG9/69ebbtiZ8W3ScOZF/OmhvM4Rg==} + '@reforged/maker-appimage@5.2.0': + resolution: {integrity: sha512-5u7spsDMyMfwqAnTRsSipVgTIy+DW+wlfhceaRghCuTvyY8Sti8/tFhVKj4vb+dYTqPvS7m30Gl0InGv22J8RQ==} + engines: {node: '>=19.0.0 || ^18.11.0'} + + '@reforged/maker-types@2.1.0': + resolution: {integrity: sha512-gNMAFO6mxqGwuUov0CzXGTHUMfAawlM6v/uYrqVnKeMwmceaLBt3HtfPcuNapDSH4br6D4EZ14WuWbgefmDfOQ==} + '@remirror/core-constants@3.0.0': resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==} @@ -4821,6 +4787,12 @@ packages: '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + '@spacingbat3/lss@1.2.0': + resolution: {integrity: sha512-aywhxHNb6l7COooF3m439eT/6QN8E/RSl5IVboSKthMHcp0GlZYMSoS7546rqDLmFRxTD8f1tu/NIS9vtDwYAg==} + + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -5073,6 +5045,15 @@ packages: peerDependencies: react: ^18 || ^19 + '@tanstack/react-virtual@3.13.26': + resolution: {integrity: sha512-DosdgjOxCLahkn0o+ilmZYwEjo1glfMGuRT/j3PQ18yr5XqA8N/BCaL9IJ3B5TRl+nnzyK2IOFgAILwzN3a9xQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/virtual-core@3.16.0': + resolution: {integrity: sha512-Er2N7q3WOiH6y2JLxsxNX+u2/sLqSsL0bxFgDjuiPiA7vKhZRm+IzcS17vRee3GNXr64UsesA5CAp9yTiIYw9A==} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -5284,8 +5265,8 @@ packages: '@tokenizer/token@0.3.0': resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} - '@tootallnate/once@2.0.0': - resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} + '@tootallnate/once@2.0.1': + resolution: {integrity: sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==} engines: {node: '>= 10'} '@trpc/client@11.12.0': @@ -6100,6 +6081,9 @@ packages: brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@2.1.0: + resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} + brace-expansion@5.0.5: resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} engines: {node: 18 || 20 || >=22} @@ -7473,6 +7457,9 @@ packages: fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} @@ -8124,6 +8111,10 @@ packages: resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -9286,6 +9277,10 @@ packages: resolution: {integrity: sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==} engines: {node: '>= 8'} + minipass-flush@1.0.7: + resolution: {integrity: sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==} + engines: {node: '>= 8'} + minipass-pipeline@1.2.4: resolution: {integrity: sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==} engines: {node: '>=8'} @@ -10951,6 +10946,10 @@ packages: resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + socks@2.8.9: + resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + sonner@2.0.7: resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} peerDependencies: @@ -11023,6 +11022,9 @@ packages: resolution: {integrity: sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==} engines: {node: '>=6'} + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + statuses@1.5.0: resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} engines: {node: '>= 0.6'} @@ -12343,6 +12345,10 @@ snapshots: dependencies: zod: 4.3.6 + '@agentclientprotocol/sdk@0.22.1(zod@4.3.6)': + dependencies: + zod: 4.3.6 + '@alloc/quick-lru@5.2.0': {} '@ampproject/remapping@2.3.0': @@ -12350,34 +12356,49 @@ snapshots: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 - '@anthropic-ai/claude-agent-sdk@0.2.112(zod@4.3.6)': + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.156': + optional: true + + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.156': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.156': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.156': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.156': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.156': + optional: true + + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.156': + optional: true + + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.156': + optional: true + + '@anthropic-ai/claude-agent-sdk@0.3.156(@anthropic-ai/sdk@0.100.1(zod@4.3.6))(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(zod@4.3.6)': dependencies: - '@anthropic-ai/sdk': 0.81.0(zod@4.3.6) + '@anthropic-ai/sdk': 0.100.1(zod@4.3.6) '@modelcontextprotocol/sdk': 1.29.0(zod@4.3.6) zod: 4.3.6 optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.5 - '@img/sharp-darwin-x64': 0.34.5 - '@img/sharp-linux-arm': 0.34.5 - '@img/sharp-linux-arm64': 0.34.5 - '@img/sharp-linux-x64': 0.34.5 - '@img/sharp-linuxmusl-arm64': 0.34.5 - '@img/sharp-linuxmusl-x64': 0.34.5 - '@img/sharp-win32-arm64': 0.34.5 - '@img/sharp-win32-x64': 0.34.5 - transitivePeerDependencies: - - '@cfworker/json-schema' - - supports-color + '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.156 + '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.156 + '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.156 + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.156 + '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.156 + '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.156 + '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.156 + '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.156 - '@anthropic-ai/sdk@0.81.0(zod@4.3.6)': - dependencies: - json-schema-to-ts: 3.1.1 - optionalDependencies: - zod: 4.3.6 - - '@anthropic-ai/sdk@0.89.0(zod@4.3.6)': + '@anthropic-ai/sdk@0.100.1(zod@4.3.6)': dependencies: json-schema-to-ts: 3.1.1 + standardwebhooks: 1.0.0 optionalDependencies: zod: 4.3.6 @@ -12481,6 +12502,19 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-create-class-features-plugin@7.29.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/traverse': 7.29.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + '@babel/helper-create-regexp-features-plugin@7.28.5(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -12735,7 +12769,7 @@ snapshots: '@babel/plugin-transform-class-properties@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color @@ -13047,6 +13081,8 @@ snapshots: '@babel/runtime@7.28.6': {} + '@babel/runtime@7.29.2': {} + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 @@ -14474,68 +14510,6 @@ snapshots: '@ide/backoff@1.0.0': {} - '@img/sharp-darwin-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.4 - optional: true - - '@img/sharp-darwin-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.4 - optional: true - - '@img/sharp-libvips-darwin-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-darwin-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm@1.2.4': - optional: true - - '@img/sharp-libvips-linux-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - optional: true - - '@img/sharp-linux-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.4 - optional: true - - '@img/sharp-linux-arm@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.4 - optional: true - - '@img/sharp-linux-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - optional: true - - '@img/sharp-win32-arm64@0.34.5': - optional: true - - '@img/sharp-win32-x64@0.34.5': - optional: true - '@inquirer/ansi@1.0.2': {} '@inquirer/checkbox@3.0.1': @@ -16792,6 +16766,18 @@ snapshots: dependencies: nanoid: 3.3.11 + '@reforged/maker-appimage@5.2.0': + dependencies: + '@electron-forge/maker-base': 7.11.1 + '@reforged/maker-types': 2.1.0 + '@spacingbat3/lss': 1.2.0 + semver: 7.7.3 + transitivePeerDependencies: + - bluebird + - supports-color + + '@reforged/maker-types@2.1.0': {} + '@remirror/core-constants@3.0.0': {} '@rolldown/pluginutils@1.0.0-beta.27': {} @@ -16943,6 +16929,10 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@spacingbat3/lss@1.2.0': {} + + '@stablelib/base64@1.0.1': {} + '@standard-schema/spec@1.1.0': {} '@storybook/addon-a11y@10.2.0(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))': @@ -17198,10 +17188,18 @@ snapshots: '@tanstack/query-core': 5.90.20 react: 19.1.0 + '@tanstack/react-virtual@3.13.26(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@tanstack/virtual-core': 3.16.0 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + + '@tanstack/virtual-core@3.16.0': {} + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.0 - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 '@types/aria-query': 5.0.4 aria-query: 5.3.0 dom-accessibility-api: 0.5.16 @@ -17436,7 +17434,7 @@ snapshots: '@tokenizer/token@0.3.0': {} - '@tootallnate/once@2.0.0': {} + '@tootallnate/once@2.0.1': {} '@trpc/client@11.12.0(@trpc/server@11.12.0(typescript@5.9.3))(typescript@5.9.3)': dependencies: @@ -18433,6 +18431,10 @@ snapshots: dependencies: balanced-match: 1.0.2 + brace-expansion@2.1.0: + dependencies: + balanced-match: 1.0.2 + brace-expansion@5.0.5: dependencies: balanced-match: 4.0.4 @@ -18497,7 +18499,7 @@ snapshots: lru-cache: 7.18.3 minipass: 3.3.6 minipass-collect: 1.0.2 - minipass-flush: 1.0.5 + minipass-flush: 1.0.7 minipass-pipeline: 1.2.4 mkdirp: 1.0.4 p-map: 4.0.0 @@ -19890,6 +19892,8 @@ snapshots: fast-json-stable-stringify@2.1.0: {} + fast-sha256@1.3.0: {} + fast-uri@3.1.0: {} fastq@1.20.1: @@ -20110,7 +20114,7 @@ snapshots: fs-minipass@3.0.3: dependencies: - minipass: 7.1.2 + minipass: 7.1.3 fs-temp@1.2.1: dependencies: @@ -20257,7 +20261,7 @@ snapshots: foreground-child: 3.3.1 jackspeak: 3.4.3 minimatch: 9.0.5 - minipass: 7.1.2 + minipass: 7.1.3 package-json-from-dist: 1.0.1 path-scurry: 1.11.1 @@ -20504,7 +20508,7 @@ snapshots: http-proxy-agent@5.0.0: dependencies: - '@tootallnate/once': 2.0.0 + '@tootallnate/once': 2.0.1 agent-base: 6.0.2 debug: 4.4.3 transitivePeerDependencies: @@ -20632,6 +20636,8 @@ snapshots: ip-address@10.1.0: {} + ip-address@10.2.0: {} + ipaddr.js@1.9.1: {} is-alphabetical@2.0.1: {} @@ -21010,7 +21016,7 @@ snapshots: json-schema-to-ts@3.1.1: dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 ts-algebra: 2.0.0 json-schema-traverse@1.0.0: {} @@ -21422,7 +21428,7 @@ snapshots: minipass: 3.3.6 minipass-collect: 1.0.2 minipass-fetch: 2.1.2 - minipass-flush: 1.0.5 + minipass-flush: 1.0.7 minipass-pipeline: 1.2.4 negotiator: 0.6.4 promise-retry: 2.0.1 @@ -22098,7 +22104,7 @@ snapshots: minimatch@5.1.9: dependencies: - brace-expansion: 2.0.2 + brace-expansion: 2.1.0 minimatch@9.0.5: dependencies: @@ -22112,7 +22118,7 @@ snapshots: minipass-collect@2.0.1: dependencies: - minipass: 7.1.2 + minipass: 7.1.3 minipass-fetch@2.1.2: dependencies: @@ -22134,6 +22140,10 @@ snapshots: dependencies: minipass: 3.3.6 + minipass-flush@1.0.7: + dependencies: + minipass: 3.3.6 + minipass-pipeline@1.2.4: dependencies: minipass: 3.3.6 @@ -24125,7 +24135,7 @@ snapshots: dependencies: agent-base: 6.0.2 debug: 4.4.3 - socks: 2.8.7 + socks: 2.8.9 transitivePeerDependencies: - supports-color @@ -24142,6 +24152,11 @@ snapshots: ip-address: 10.1.0 smart-buffer: 4.2.0 + socks@2.8.9: + dependencies: + ip-address: 10.2.0 + smart-buffer: 4.2.0 + sonner@2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: react: 19.1.0 @@ -24203,6 +24218,11 @@ snapshots: dependencies: type-fest: 0.7.1 + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + statuses@1.5.0: {} statuses@2.0.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c4309763b4..a161e7ff82 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -11,9 +11,20 @@ minimumReleaseAge: 10080 minimumReleaseAgeExclude: - '@agentclientprotocol/sdk' - '@anthropic-ai/claude-agent-sdk' + - '@anthropic-ai/claude-agent-sdk-darwin-arm64' + - '@anthropic-ai/claude-agent-sdk-darwin-x64' + - '@anthropic-ai/claude-agent-sdk-linux-arm64' + - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl' + - '@anthropic-ai/claude-agent-sdk-linux-x64' + - '@anthropic-ai/claude-agent-sdk-linux-x64-musl' + - '@anthropic-ai/claude-agent-sdk-win32-arm64' + - '@anthropic-ai/claude-agent-sdk-win32-x64' + - '@anthropic-ai/sdk' - '@pierre/diffs' - '@posthog/quill' - '@posthog/quill-tokens' + - '@tanstack/react-virtual' + - '@tanstack/virtual-core' onlyBuiltDependencies: - '@parcel/watcher' From 7447a18c92043944cbced595a1a96fa6627dadf1 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Tue, 2 Jun 2026 14:53:10 +0100 Subject: [PATCH 5/8] chore(mobile): align inbox detail imports with main ahead of merge Generated-By: PostHog Code Task-Id: 3c668694-a211-41db-8646-068c350d22bb --- apps/mobile/src/app/inbox/[...id].tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/mobile/src/app/inbox/[...id].tsx b/apps/mobile/src/app/inbox/[...id].tsx index 2438b71813..20426723f5 100644 --- a/apps/mobile/src/app/inbox/[...id].tsx +++ b/apps/mobile/src/app/inbox/[...id].tsx @@ -26,10 +26,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { MarkdownText } from "@/features/chat/components/MarkdownText"; import { getReportRepository } from "@/features/inbox/api"; import { DiscussReportSheet } from "@/features/inbox/components/DiscussReportSheet"; -import { - type DismissReportResult, - DismissReportSheet, -} from "@/features/inbox/components/DismissReportSheet"; +import { DismissReportSheet } from "@/features/inbox/components/DismissReportSheet"; import { SignalCard } from "@/features/inbox/components/SignalCard"; import { SuggestedReviewers } from "@/features/inbox/components/SuggestedReviewers"; import { DISMISSAL_REASON_OPTIONS } from "@/features/inbox/constants"; From 856ff33185e9bbc5c954522d288cf10967bba318 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Tue, 2 Jun 2026 14:54:44 +0100 Subject: [PATCH 6/8] chore(mobile): reset inbox detail to main version ahead of merge Temporary scaffold: makes the catch-all route file byte-identical to main so the GitHub merge API (which lacks rename detection) can merge main in without an add/add conflict. The PR's inbox analytics are re-applied in the following commit. Generated-By: PostHog Code Task-Id: 3c668694-a211-41db-8646-068c350d22bb --- apps/mobile/src/app/inbox/[...id].tsx | 120 ++------------------------ 1 file changed, 8 insertions(+), 112 deletions(-) diff --git a/apps/mobile/src/app/inbox/[...id].tsx b/apps/mobile/src/app/inbox/[...id].tsx index 20426723f5..5da1282c78 100644 --- a/apps/mobile/src/app/inbox/[...id].tsx +++ b/apps/mobile/src/app/inbox/[...id].tsx @@ -14,14 +14,7 @@ import { } from "phosphor-react-native"; import { usePostHog } from "posthog-react-native"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { - ActivityIndicator, - type NativeScrollEvent, - type NativeSyntheticEvent, - Pressable, - ScrollView, - View, -} from "react-native"; +import { ActivityIndicator, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { MarkdownText } from "@/features/chat/components/MarkdownText"; import { getReportRepository } from "@/features/inbox/api"; @@ -29,14 +22,11 @@ import { DiscussReportSheet } from "@/features/inbox/components/DiscussReportShe import { DismissReportSheet } from "@/features/inbox/components/DismissReportSheet"; import { SignalCard } from "@/features/inbox/components/SignalCard"; import { SuggestedReviewers } from "@/features/inbox/components/SuggestedReviewers"; -import { DISMISSAL_REASON_OPTIONS } from "@/features/inbox/constants"; -import { useInboxEngagementTracker } from "@/features/inbox/hooks/useInboxEngagementTracker"; import { useInboxReport, useInboxReportArtefacts, useInboxReportSignals, } from "@/features/inbox/hooks/useInboxReports"; -import { useInboxStore } from "@/features/inbox/stores/inboxStore"; import type { ActionabilityJudgmentContent, SignalFindingContent, @@ -45,7 +35,6 @@ import type { SuggestedReviewer, } from "@/features/inbox/types"; import { inboxStatusLabel } from "@/features/inbox/utils"; -import { computeReportAgeHours, useAnalytics } from "@/lib/analytics"; import { useThemeColors } from "@/lib/theme"; const statusColorMap: Record = { @@ -139,59 +128,6 @@ export default function ReportDetailScreen() { const artefactsQuery = useInboxReportArtefacts(reportId ?? null); const signalsQuery = useInboxReportSignals(reportId ?? null); - // ── Engagement analytics ──────────────────────────────────────────────── - const analytics = useAnalytics(); - const lastVisibleReportIds = useInboxStore((s) => s.lastVisibleReportIds); - const previousOpenedReportId = useInboxStore((s) => s.previousOpenedReportId); - const setPreviousOpenedReportId = useInboxStore( - (s) => s.setPreviousOpenedReportId, - ); - const rank = useMemo(() => { - if (!reportId) return -1; - const idx = lastVisibleReportIds.indexOf(reportId); - return idx; - }, [reportId, lastVisibleReportIds]); - const listSize = lastVisibleReportIds.length; - const tracker = useInboxEngagementTracker({ - analytics, - report: report ?? null, - rank, - listSize, - openMethod: "click", - previousReportId: previousOpenedReportId, - }); - // Remember this report as the "previous" once it's been opened so the next - // OPENED event can chain to it. - useEffect(() => { - if (!reportId) return; - setPreviousOpenedReportId(reportId); - }, [reportId, setPreviousOpenedReportId]); - - const handleScroll = useCallback( - (_event: NativeSyntheticEvent) => { - tracker.signalScroll(); - }, - [tracker], - ); - - const handleToggleSignals = useCallback(() => { - // Fire analytics outside the state updater — Strict Mode double-invokes - // updaters in development, which would double-fire the event. - const next = !signalsExpanded; - if (next && report) { - tracker.signalAction({ - report_id: report.id, - report_title: report.title ?? null, - report_age_hours: computeReportAgeHours(report.created_at), - action_type: "expand_signal", - surface: "detail_pane", - is_bulk: false, - bulk_size: 1, - }); - } - setSignalsExpanded(next); - }, [report, tracker, signalsExpanded]); - useEffect(() => { if (!reportId) return; let cancelled = false; @@ -251,15 +187,6 @@ export default function ReportDetailScreen() { const handleStartTask = useCallback(() => { if (!report) return; Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); - tracker.signalAction({ - report_id: report.id, - report_title: report.title ?? null, - report_age_hours: computeReportAgeHours(report.created_at), - action_type: "create_pr", - surface: "detail_pane", - is_bulk: false, - bulk_size: 1, - }); const prompt = `Act on this signal report. Investigate the root cause, implement the fix, and open a PR if appropriate.\n\n${report.summary ?? ""}`; router.push({ pathname: "/task", @@ -269,41 +196,12 @@ export default function ReportDetailScreen() { signalReport: report.id, }, }); - }, [report, router, reportRepo, tracker]); - - const handleDismissed = useCallback( - (result: DismissReportResult) => { - setDismissOpen(false); - if (report) { - const reasonOption = DISMISSAL_REASON_OPTIONS.find( - (o) => o.value === result.reason, - ); - const isSnooze = - reasonOption !== undefined && - "snoozesInsteadOfDismiss" in reasonOption && - reasonOption.snoozesInsteadOfDismiss === true; - tracker.signalAction({ - report_id: report.id, - report_title: report.title ?? null, - report_age_hours: computeReportAgeHours(report.created_at), - action_type: isSnooze ? "snooze" : "dismiss", - surface: "detail_pane", - is_bulk: false, - bulk_size: 1, - ...(isSnooze - ? {} - : { - dismissal_reason: result.reason, - ...(result.note - ? { dismissal_note: result.note.slice(0, 1000) } - : {}), - }), - }); - } - if (router.canGoBack()) router.back(); - }, - [router, report, tracker], - ); + }, [report, router, reportRepo]); + + const handleDismissed = useCallback(() => { + setDismissOpen(false); + if (router.canGoBack()) router.back(); + }, [router]); const handleDiscussSubmit = useCallback( ({ prompt, question }: { prompt: string; question: string }) => { @@ -395,8 +293,6 @@ export default function ReportDetailScreen() { paddingTop: 16, paddingBottom: insets.bottom + 100, }} - onScroll={handleScroll} - scrollEventThrottle={250} > {/* Badges row */} @@ -474,7 +370,7 @@ export default function ReportDetailScreen() { {signals.length > 0 && ( setSignalsExpanded((v) => !v)} hitSlop={6} accessibilityRole="button" accessibilityState={{ expanded: signalsExpanded }} From 787092304e56afc554d5afae97d143a3201907f1 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Tue, 2 Jun 2026 14:59:32 +0100 Subject: [PATCH 7/8] chore: drop stray build-linux-docker.sh before merging main This script belongs to main and was accidentally baked into the branch by an earlier merge commit (at the wrong file mode). Removing it so merging main back in restores it cleanly at its correct executable mode and keeps it out of this PR's diff. Generated-By: PostHog Code Task-Id: 3c668694-a211-41db-8646-068c350d22bb --- apps/code/scripts/build-linux-docker.sh | 65 ------------------------- 1 file changed, 65 deletions(-) delete mode 100644 apps/code/scripts/build-linux-docker.sh diff --git a/apps/code/scripts/build-linux-docker.sh b/apps/code/scripts/build-linux-docker.sh deleted file mode 100644 index 7497d53c1f..0000000000 --- a/apps/code/scripts/build-linux-docker.sh +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ARCH="${ARCH:-x64}" -case "$ARCH" in - x64) DOCKER_PLATFORM="linux/amd64" ;; - arm64) DOCKER_PLATFORM="linux/arm64" ;; - *) echo "Unsupported ARCH=$ARCH (expected x64 or arm64)" >&2; exit 1 ;; -esac - -REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" -OUT_DIR="$REPO_ROOT/apps/code/out" -mkdir -p "$OUT_DIR" - -# Capture host commit so the in-container build reflects the real source revision, -# not the throwaway commit we synthesize below for postinstall scripts. -HOST_COMMIT="$(git -C "$REPO_ROOT" rev-parse --short HEAD 2>/dev/null || echo unknown)" - -# Stream the repo source (no node_modules / build artifacts) into the container -# so node_modules lives on the container's overlayfs, not a slow FUSE bind mount. -# Only the output dir is bind-mounted so artifacts come back to the host. -cd "$REPO_ROOT" -# COPYFILE_DISABLE stops bsdtar from embedding macOS extended attrs as ._ files. -COPYFILE_DISABLE=1 tar -cf - \ - --exclude='./.git' \ - --exclude='./.pnpm-store' \ - --exclude='node_modules' \ - --exclude='.turbo' \ - --exclude='.vite' \ - --exclude='dist' \ - --exclude='out' \ - --exclude='playwright-results' \ - --exclude='._*' \ - --exclude='.DS_Store' \ - . | exec docker run --rm -i \ - --platform "$DOCKER_PLATFORM" \ - --name build-linux \ - -e CI=true \ - -e NODE_OPTIONS="--max-old-space-size=8192" \ - -e NODE_ENV=production \ - -e ARCH="$ARCH" \ - -e BUILD_COMMIT="$HOST_COMMIT" \ - -v "$OUT_DIR":/out \ - node:22-bookworm bash -lc ' - set -euo pipefail - trap "rc=\$?; echo >&2; echo \"[build-linux-docker] FAILED (exit \$rc) at line \$LINENO: \$BASH_COMMAND\" >&2; exit \$rc" ERR - mkdir -p /work && cd /work && tar -xf - - corepack enable - apt-get update && apt-get install -y --no-install-recommends \ - libsecret-1-dev fuse libfuse2 ca-certificates git squashfs-tools zsync zip - # Tarball arrived owned by the host uid; tell git not to refuse on uid mismatch. - git config --global --add safe.directory /work - # Postinstall scripts call `git rev-parse` — give them a repo to find. - git init -q && git add -A && git -c user.email=x@x -c user.name=x commit -q -m init - pnpm install --frozen-lockfile - pnpm --filter @posthog/electron-trpc build - pnpm --filter @posthog/platform build - pnpm --filter @posthog/shared build - pnpm --filter @posthog/git build - pnpm --filter @posthog/enricher build - pnpm --filter @posthog/agent build - pnpm --filter code make --platform=linux --arch="$ARCH" - mkdir -p /out - cp -r apps/code/out/make /out/ - ' From 698f4e4af79a849dc76e3704fd48fee3e32cbc6f Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Tue, 2 Jun 2026 14:59:55 +0100 Subject: [PATCH 8/8] feat(mobile): re-apply inbox detail engagement analytics after merge Restores this PR's inbox-detail analytics (engagement tracker, signal-expand/start-task/dismiss tracking) on top of main's catch-all route + Discuss feature, after the temporary scaffold that reset the file to main's version to let the merge through cleanly. Generated-By: PostHog Code Task-Id: 3c668694-a211-41db-8646-068c350d22bb --- apps/mobile/src/app/inbox/[...id].tsx | 125 ++++++++++++++++++++++++-- 1 file changed, 116 insertions(+), 9 deletions(-) diff --git a/apps/mobile/src/app/inbox/[...id].tsx b/apps/mobile/src/app/inbox/[...id].tsx index 5da1282c78..2438b71813 100644 --- a/apps/mobile/src/app/inbox/[...id].tsx +++ b/apps/mobile/src/app/inbox/[...id].tsx @@ -14,19 +14,32 @@ import { } from "phosphor-react-native"; import { usePostHog } from "posthog-react-native"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { ActivityIndicator, Pressable, ScrollView, View } from "react-native"; +import { + ActivityIndicator, + type NativeScrollEvent, + type NativeSyntheticEvent, + Pressable, + ScrollView, + View, +} from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { MarkdownText } from "@/features/chat/components/MarkdownText"; import { getReportRepository } from "@/features/inbox/api"; import { DiscussReportSheet } from "@/features/inbox/components/DiscussReportSheet"; -import { DismissReportSheet } from "@/features/inbox/components/DismissReportSheet"; +import { + type DismissReportResult, + DismissReportSheet, +} from "@/features/inbox/components/DismissReportSheet"; import { SignalCard } from "@/features/inbox/components/SignalCard"; import { SuggestedReviewers } from "@/features/inbox/components/SuggestedReviewers"; +import { DISMISSAL_REASON_OPTIONS } from "@/features/inbox/constants"; +import { useInboxEngagementTracker } from "@/features/inbox/hooks/useInboxEngagementTracker"; import { useInboxReport, useInboxReportArtefacts, useInboxReportSignals, } from "@/features/inbox/hooks/useInboxReports"; +import { useInboxStore } from "@/features/inbox/stores/inboxStore"; import type { ActionabilityJudgmentContent, SignalFindingContent, @@ -35,6 +48,7 @@ import type { SuggestedReviewer, } from "@/features/inbox/types"; import { inboxStatusLabel } from "@/features/inbox/utils"; +import { computeReportAgeHours, useAnalytics } from "@/lib/analytics"; import { useThemeColors } from "@/lib/theme"; const statusColorMap: Record = { @@ -128,6 +142,59 @@ export default function ReportDetailScreen() { const artefactsQuery = useInboxReportArtefacts(reportId ?? null); const signalsQuery = useInboxReportSignals(reportId ?? null); + // ── Engagement analytics ──────────────────────────────────────────────── + const analytics = useAnalytics(); + const lastVisibleReportIds = useInboxStore((s) => s.lastVisibleReportIds); + const previousOpenedReportId = useInboxStore((s) => s.previousOpenedReportId); + const setPreviousOpenedReportId = useInboxStore( + (s) => s.setPreviousOpenedReportId, + ); + const rank = useMemo(() => { + if (!reportId) return -1; + const idx = lastVisibleReportIds.indexOf(reportId); + return idx; + }, [reportId, lastVisibleReportIds]); + const listSize = lastVisibleReportIds.length; + const tracker = useInboxEngagementTracker({ + analytics, + report: report ?? null, + rank, + listSize, + openMethod: "click", + previousReportId: previousOpenedReportId, + }); + // Remember this report as the "previous" once it's been opened so the next + // OPENED event can chain to it. + useEffect(() => { + if (!reportId) return; + setPreviousOpenedReportId(reportId); + }, [reportId, setPreviousOpenedReportId]); + + const handleScroll = useCallback( + (_event: NativeSyntheticEvent) => { + tracker.signalScroll(); + }, + [tracker], + ); + + const handleToggleSignals = useCallback(() => { + // Fire analytics outside the state updater — Strict Mode double-invokes + // updaters in development, which would double-fire the event. + const next = !signalsExpanded; + if (next && report) { + tracker.signalAction({ + report_id: report.id, + report_title: report.title ?? null, + report_age_hours: computeReportAgeHours(report.created_at), + action_type: "expand_signal", + surface: "detail_pane", + is_bulk: false, + bulk_size: 1, + }); + } + setSignalsExpanded(next); + }, [report, tracker, signalsExpanded]); + useEffect(() => { if (!reportId) return; let cancelled = false; @@ -187,6 +254,15 @@ export default function ReportDetailScreen() { const handleStartTask = useCallback(() => { if (!report) return; Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + tracker.signalAction({ + report_id: report.id, + report_title: report.title ?? null, + report_age_hours: computeReportAgeHours(report.created_at), + action_type: "create_pr", + surface: "detail_pane", + is_bulk: false, + bulk_size: 1, + }); const prompt = `Act on this signal report. Investigate the root cause, implement the fix, and open a PR if appropriate.\n\n${report.summary ?? ""}`; router.push({ pathname: "/task", @@ -196,12 +272,41 @@ export default function ReportDetailScreen() { signalReport: report.id, }, }); - }, [report, router, reportRepo]); - - const handleDismissed = useCallback(() => { - setDismissOpen(false); - if (router.canGoBack()) router.back(); - }, [router]); + }, [report, router, reportRepo, tracker]); + + const handleDismissed = useCallback( + (result: DismissReportResult) => { + setDismissOpen(false); + if (report) { + const reasonOption = DISMISSAL_REASON_OPTIONS.find( + (o) => o.value === result.reason, + ); + const isSnooze = + reasonOption !== undefined && + "snoozesInsteadOfDismiss" in reasonOption && + reasonOption.snoozesInsteadOfDismiss === true; + tracker.signalAction({ + report_id: report.id, + report_title: report.title ?? null, + report_age_hours: computeReportAgeHours(report.created_at), + action_type: isSnooze ? "snooze" : "dismiss", + surface: "detail_pane", + is_bulk: false, + bulk_size: 1, + ...(isSnooze + ? {} + : { + dismissal_reason: result.reason, + ...(result.note + ? { dismissal_note: result.note.slice(0, 1000) } + : {}), + }), + }); + } + if (router.canGoBack()) router.back(); + }, + [router, report, tracker], + ); const handleDiscussSubmit = useCallback( ({ prompt, question }: { prompt: string; question: string }) => { @@ -293,6 +398,8 @@ export default function ReportDetailScreen() { paddingTop: 16, paddingBottom: insets.bottom + 100, }} + onScroll={handleScroll} + scrollEventThrottle={250} > {/* Badges row */} @@ -370,7 +477,7 @@ export default function ReportDetailScreen() { {signals.length > 0 && ( setSignalsExpanded((v) => !v)} + onPress={handleToggleSignals} hitSlop={6} accessibilityRole="button" accessibilityState={{ expanded: signalsExpanded }}