From 484a6c7b88e6578dbe5dbdd74b6cc1753900a985 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 24 Jul 2026 19:17:22 +0300 Subject: [PATCH 01/43] refactor(shared): extract task contracts and model policy Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- .../agent/src/adapters/reasoning-effort.ts | 45 +- packages/agent/src/gateway-models.test.ts | 252 +----------- packages/agent/src/gateway-models.ts | 274 ++----------- packages/agent/src/utils/gateway.ts | 23 +- packages/api-client/src/posthog-client.ts | 2 +- packages/shared/src/cloud-task-models.test.ts | 200 +++++++++ packages/shared/src/cloud-task-models.ts | 385 ++++++++++++++++++ packages/shared/src/domain-types.test.ts | 19 +- packages/shared/src/domain-types.ts | 34 +- packages/shared/src/index.ts | 74 +++- packages/shared/src/reasoning-effort.test.ts | 20 + packages/shared/src/reasoning-effort.ts | 77 ++++ packages/shared/src/sessions.ts | 2 +- packages/shared/src/task-automation.test.ts | 66 +++ packages/shared/src/task-automation.ts | 58 +++ packages/shared/src/task.test.ts | 53 +++ packages/shared/src/task.ts | 103 +---- .../src/services/agent/agent.ts | 117 +----- 18 files changed, 1035 insertions(+), 769 deletions(-) create mode 100644 packages/shared/src/cloud-task-models.test.ts create mode 100644 packages/shared/src/cloud-task-models.ts create mode 100644 packages/shared/src/reasoning-effort.test.ts create mode 100644 packages/shared/src/reasoning-effort.ts create mode 100644 packages/shared/src/task-automation.test.ts create mode 100644 packages/shared/src/task-automation.ts create mode 100644 packages/shared/src/task.test.ts diff --git a/packages/agent/src/adapters/reasoning-effort.ts b/packages/agent/src/adapters/reasoning-effort.ts index 590bb3be86..2cc1da50f8 100644 --- a/packages/agent/src/adapters/reasoning-effort.ts +++ b/packages/agent/src/adapters/reasoning-effort.ts @@ -1,39 +1,6 @@ -import type { Adapter } from "@posthog/shared"; -import { getEffortOptions as getClaudeEffortOptions } from "./claude/session/models"; -import { getReasoningEffortOptions as getCodexReasoningEffortOptions } from "./codex-app-server/models"; - -export type SupportedReasoningEffort = - | "low" - | "medium" - | "high" - | "xhigh" - | "max"; - -export interface ReasoningEffortOption { - value: SupportedReasoningEffort; - name: string; -} - -export function getReasoningEffortOptions( - adapter: Adapter, - modelId: string, -): ReasoningEffortOption[] | null { - const options = - adapter === "codex" - ? getCodexReasoningEffortOptions(modelId) - : getClaudeEffortOptions(modelId); - - return options as ReasoningEffortOption[] | null; -} - -export function isSupportedReasoningEffort( - adapter: Adapter, - modelId: string, - value: string, -): value is SupportedReasoningEffort { - return ( - getReasoningEffortOptions(adapter, modelId)?.some( - (option) => option.value === value, - ) ?? false - ); -} +export { + getReasoningEffortOptions, + isSupportedReasoningEffort, + type ReasoningEffortOption, + type SupportedReasoningEffort, +} from "@posthog/shared"; diff --git a/packages/agent/src/gateway-models.test.ts b/packages/agent/src/gateway-models.test.ts index 5aa7438a8d..8c988ec1e9 100644 --- a/packages/agent/src/gateway-models.test.ts +++ b/packages/agent/src/gateway-models.test.ts @@ -1,163 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { - compareModelsForPicker, - fetchGatewayModels, - fetchModelsList, - formatGatewayModelName, - type GatewayModel, - getClaudeModelRecency, - isAnthropicModel, - isBlockedModelId, - isCloudflareModel, - pickAllowedModel, -} from "./gateway-models"; - -const model = (id: string, owned_by = ""): GatewayModel => ({ - id, - owned_by, - context_window: 128000, - supports_streaming: true, - supports_vision: false, - allowed: true, -}); - -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, - allowed: true, - }), - ).toBe("Claude Opus 4.8"); - }); - - it("uppercases the GPT acronym in OpenAI model ids", () => { - expect( - formatGatewayModelName({ - id: "GPT-5.5", - owned_by: "openai", - context_window: 200000, - supports_streaming: true, - supports_vision: true, - allowed: true, - }), - ).toBe("GPT-5.5"); - }); - - it("strips the openai/ prefix, uppercases GPT, and title-cases the suffix", () => { - expect( - formatGatewayModelName({ - id: "openai/gpt-5.6-sol", - owned_by: "openai", - context_window: 200000, - supports_streaming: true, - supports_vision: true, - allowed: true, - }), - ).toBe("GPT-5.6 Sol"); - }); - - it("formats Cloudflare models as the final path segment with GLM uppercased", () => { - expect( - formatGatewayModelName({ - id: "@cf/zai-org/glm-5.2", - owned_by: "cloudflare", - context_window: 128000, - supports_streaming: true, - supports_vision: false, - allowed: true, - }), - ).toBe("GLM-5.2"); - }); - - it("leaves non-acronym Cloudflare models lowercase", () => { - expect( - formatGatewayModelName({ - id: "@cf/meta/llama-3.1-8b-instruct", - owned_by: "cloudflare", - context_window: 128000, - supports_streaming: true, - supports_vision: false, - allowed: true, - }), - ).toBe("llama-3.1-8b-instruct"); - }); - - 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); - }); -}); - -describe("getClaudeModelRecency", () => { - it.each([ - ["claude-haiku-4-5", 4005], - ["claude-sonnet-4-6", 4006], - ["claude-opus-4-7", 4007], - ["claude-opus-4-8", 4008], - ["claude-opus-5", 5000], - ["claude-sonnet-5", 5000], - ["claude-fable-5", 5000], - ])("ranks %s by its embedded version (%i)", (modelId, rank) => { - expect(getClaudeModelRecency(modelId)).toBe(rank); - }); - - it("ignores a trailing date suffix when reading the version", () => { - expect(getClaudeModelRecency("claude-haiku-4-5-20251001")).toBe(4005); - }); - - it("ranks a model with no recognisable version as newest", () => { - expect(getClaudeModelRecency("claude-mystery")).toBe( - Number.MAX_SAFE_INTEGER, - ); - expect(getClaudeModelRecency("claude-mystery")).toBeGreaterThan( - getClaudeModelRecency("claude-fable-5"), - ); - }); -}); - -describe("compareModelsForPicker", () => { - it("groups models by family least capable first, newest version first", () => { - // The picker opens upward, so least-capable-first DOM order puts the most - // capable family (Fable) nearest the trigger — the visual top of the menu. - // Models as the gateway might return them — arbitrary order. - const gatewayOrder = [ - "claude-fable-5", - "claude-opus-4-7", - "claude-mystery", - "claude-sonnet-5", - "claude-haiku-4-5", - "claude-sonnet-4-6", - "claude-opus-4-8", - ]; - const displayed = [...gatewayOrder].sort(compareModelsForPicker); - expect(displayed).toEqual([ - "claude-haiku-4-5", - "claude-sonnet-5", - "claude-sonnet-4-6", - "claude-opus-4-8", - "claude-opus-4-7", - "claude-fable-5", - "claude-mystery", - ]); - }); -}); +import { fetchGatewayModels, fetchModelsList } from "./gateway-models"; describe("gateway model fetch timeout", () => { afterEach(() => { @@ -242,96 +84,4 @@ describe("gateway models cache", () => { expect(fetchMock).toHaveBeenCalledTimes(1); expect(cached[0]?.allowed).toBe(false); }); - - it("corrects stale GLM 5.2 context-window metadata", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response( - JSON.stringify({ - object: "list", - data: [ - { - id: "@cf/zai-org/glm-5.2", - owned_by: "cloudflare", - context_window: 128_000, - supports_streaming: true, - supports_vision: false, - }, - ], - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ), - ); - - const models = await fetchGatewayModels({ - gatewayUrl: "https://gateway.glm-context-test", - }); - - expect(models[0]?.context_window).toBe(1_000_000); - }); -}); - -describe("isCloudflareModel", () => { - it.each([ - { id: "@cf/zai-org/glm-5.2", owned_by: "cloudflare", expected: true }, - { id: "claude-opus-4-8", owned_by: "anthropic", expected: false }, - { id: "@cf/zai-org/glm-5.2", owned_by: "", expected: true }, - { id: "gpt-5.5", owned_by: "", expected: false }, - // A Cloudflare-served model can report an upstream owner; the `@cf/` prefix still wins. - { id: "@cf/openai/gpt-oss", owned_by: "openai", expected: true }, - ])( - "isCloudflareModel($id, owned_by=$owned_by) → $expected", - ({ id, owned_by, expected }) => { - expect(isCloudflareModel(model(id, owned_by))).toBe(expected); - }, - ); - - it("does not classify Cloudflare models as Anthropic", () => { - // The Claude adapter accepts both, but they must stay distinguishable. - const glm = model("@cf/zai-org/glm-5.2", "cloudflare"); - expect(isCloudflareModel(glm)).toBe(true); - expect(isAnthropicModel(glm)).toBe(false); - }); -}); - -describe("pickAllowedModel", () => { - const entry = (id: string, allowed: boolean) => ({ id, allowed }); - - it.each([ - [ - "keeps an allowed preferred model", - [entry("claude-opus-4-8", true)], - "claude-opus-4-8", - "claude-opus-4-8", - ], - [ - "keeps a preferred model absent from the list", - [entry("claude-opus-4-8", true)], - "claude-sonnet-5", - "claude-sonnet-5", - ], - [ - "moves a restricted preferred model to the newest allowed one", - [ - entry("claude-opus-4-8", false), - entry("claude-sonnet-4-6", true), - entry("@cf/zai-org/glm-5.2", true), - ], - "claude-opus-4-8", - "@cf/zai-org/glm-5.2", - ], - [ - "keeps the preferred model when everything is restricted", - [entry("claude-opus-4-8", false)], - "claude-opus-4-8", - "claude-opus-4-8", - ], - [ - "keeps the preferred model when the list is empty", - [], - "claude-opus-4-8", - "claude-opus-4-8", - ], - ] as const)("%s", (_name, models, preferred, expected) => { - expect(pickAllowedModel(models, preferred)).toBe(expected); - }); }); diff --git a/packages/agent/src/gateway-models.ts b/packages/agent/src/gateway-models.ts index 116d9bd7d1..35ae10df5a 100644 --- a/packages/agent/src/gateway-models.ts +++ b/packages/agent/src/gateway-models.ts @@ -1,20 +1,28 @@ -export interface GatewayModel { - id: string; - owned_by: string; - context_window: number; - supports_streaming: boolean; - supports_vision: boolean; - // Free-tier model gate: authenticated fetches mark models outside the - // caller's plan `allowed: false`. Anonymous fetches and older gateways - // don't mark, so absence means allowed. - allowed: boolean; - restriction_reason?: string | null; -} - -interface GatewayModelsResponse { - object: "list"; - data: Array & { allowed?: boolean }>; -} +import { + type GatewayModel, + normalizeGatewayModelsResponse, +} from "@posthog/shared"; + +export { + BLOCKED_GATEWAY_MODEL_IDS, + buildCloudTaskConfigOptions, + type CloudTaskConfigOption, + type CloudTaskConfigSelectOption, + compareModelsForPicker, + DEFAULT_CODEX_MODEL, + DEFAULT_GATEWAY_MODEL, + formatGatewayModelName, + formatModelId, + type GatewayModel, + getClaudeModelRecency, + getProviderName, + isAnthropicModel, + isBlockedModelId, + isCloudflareModel, + isCloudflareModelId, + isOpenAIModel, + pickAllowedModel, +} from "@posthog/shared"; export interface FetchGatewayModelsOptions { gatewayUrl: string; @@ -22,47 +30,6 @@ export interface FetchGatewayModelsOptions { authToken?: string; } -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()); -} - -interface ModelsListEntry { - id?: string; - owned_by?: string; - allowed?: boolean; - restriction_reason?: string | null; -} - -type ModelsListResponse = - | { - data?: ModelsListEntry[]; - models?: ModelsListEntry[]; - } - | ModelsListEntry[]; - const CACHE_TTL = 10 * 60 * 1000; // 10 minutes // Bound the gateway /v1/models request so a stalled connection cannot hold up @@ -71,10 +38,6 @@ const CACHE_TTL = 10 * 60 * 1000; // 10 minutes // the callers fall through to `return []`. const GATEWAY_FETCH_TIMEOUT_MS = 10_000; -const MODEL_CONTEXT_WINDOW_OVERRIDES: Readonly> = { - "@cf/zai-org/glm-5.2": 1_000_000, -}; - // Restriction marks are identity-scoped (free-tier marks are authed-only and // differ per org), so cache entries are keyed on the exact token — an org // switch in the same process must never be served the old org's marks. A @@ -125,17 +88,7 @@ export async function fetchGatewayModels( return []; } - const data = (await response.json()) as GatewayModelsResponse; - const models = (data.data ?? []) - .filter((m) => !isBlockedModelId(m.id)) - .map((m) => ({ - ...m, - context_window: Math.max( - m.context_window, - MODEL_CONTEXT_WINDOW_OVERRIDES[m.id] ?? 0, - ), - allowed: m.allowed !== false, - })); + const models = normalizeGatewayModelsResponse(await response.json()); gatewayModelsCache = { models, expiry: Date.now() + CACHE_TTL, @@ -148,36 +101,6 @@ export async function fetchGatewayModels( } } -export function isAnthropicModel(model: GatewayModel): boolean { - if (model.owned_by) { - return model.owned_by === "anthropic"; - } - return model.id.startsWith("claude-") || model.id.startsWith("anthropic/"); -} - -export function isOpenAIModel(model: GatewayModel): boolean { - if (model.owned_by) { - return model.owned_by === "openai"; - } - return model.id.startsWith("gpt-") || model.id.startsWith("openai/"); -} - -// Cloudflare Workers AI model ids carry the `@cf/` path prefix (e.g. `@cf/zai-org/glm-5.2`). Kept as -// a standalone id-only check so callers that only have a model id (not a full GatewayModel) — like the -// Claude adapter's desync guard — share one source of truth with `isCloudflareModel`. -export function isCloudflareModelId(modelId: string): boolean { - return modelId.startsWith("@cf/"); -} - -// Cloudflare Workers AI models (e.g. `@cf/zai-org/glm-5.2`). The gateway serves these over both its -// OpenAI and Anthropic-Messages surfaces (it translates the `@cf/` path), so the Claude adapter can -// drive them just like an Anthropic model. The `@cf/` path prefix is the structural, always-present -// signal, so honour it regardless of `owned_by` — a Cloudflare-served model can report an upstream -// owner (e.g. `@cf/openai/...` with `owned_by: "openai"`) and must still classify as Cloudflare. -export function isCloudflareModel(model: GatewayModel): boolean { - return isCloudflareModelId(model.id) || model.owned_by === "cloudflare"; -} - export interface ModelInfo { id: string; owned_by?: string; @@ -208,22 +131,14 @@ export async function fetchModelsList( if (!response.ok) { return []; } - const data = (await response.json()) as ModelsListResponse; - const models = Array.isArray(data) - ? data - : (data.data ?? data.models ?? []); - const results: ModelInfo[] = []; - 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, - allowed: model?.allowed !== false, - restriction_reason: model?.restriction_reason ?? null, - }); - } + const results = normalizeGatewayModelsResponse(await response.json()).map( + (model) => ({ + id: model.id, + owned_by: model.owned_by || undefined, + allowed: model.allowed, + restriction_reason: model.restriction_reason, + }), + ); modelsListCache = { models: results, expiry: Date.now() + CACHE_TTL, @@ -235,124 +150,3 @@ export async function fetchModelsList( return []; } } - -/** - * The model a session should start on: the preferred id when present and - * allowed, else the newest allowed model — a free-tier org must not default - * onto a model that 403s its first message. Falls back to the preferred id - * when the list is empty (fetch failed) or nothing is allowed (all locked — - * the picker gate communicates that state better than a silent swap). - */ -export function pickAllowedModel( - models: ReadonlyArray>, - preferred: string, -): string { - if (models.length === 0) return preferred; - const preferredEntry = models.find((m) => m.id === preferred); - if (!preferredEntry || preferredEntry.allowed) return preferred; - const allowed = models.filter((m) => m.allowed); - if (allowed.length === 0) return preferred; - return allowed.reduce((best, candidate) => - getClaudeModelRecency(candidate.id) >= getClaudeModelRecency(best.id) - ? candidate - : best, - ).id; -} - -const PROVIDER_NAMES: Record = { - anthropic: "Anthropic", - openai: "OpenAI", - "google-vertex": "Gemini", -}; - -export function getProviderName(ownedBy: string): string { - return PROVIDER_NAMES[ownedBy] ?? ownedBy; -} - -// Version embedded in the model id, e.g. "claude-opus-4-8" -> 4008. Ids with no -// recognisable version rank newest. A trailing date suffix is ignored. -export function getClaudeModelRecency(modelId: string): number { - const match = modelId.toLowerCase().match(/-(\d+)(?:[-.](\d+))?/); - if (!match) return Number.MAX_SAFE_INTEGER; - const major = Number(match[1]); - const minor = match[2] ? Number(match[2]) : 0; - return major * 1000 + minor; -} - -// Families ordered least-capable first. The picker opens upward (side="top") -// from the composer, so items later in this list render nearer the trigger and -// read as the top of the menu — this puts the most-capable family (Fable) on -// top. Unknown families sort after all known ones. -const MODEL_FAMILY_ORDER = ["haiku", "sonnet", "opus", "fable"]; - -function getModelFamilyRank(modelId: string): number { - const id = modelId.toLowerCase(); - const index = MODEL_FAMILY_ORDER.findIndex((family) => id.includes(family)); - return index === -1 ? MODEL_FAMILY_ORDER.length : index; -} - -// Group by family, then newest version first within each family. -export function compareModelsForPicker(a: string, b: string): number { - const familyDiff = getModelFamilyRank(a) - getModelFamilyRank(b); - if (familyDiff !== 0) return familyDiff; - return getClaudeModelRecency(b) - getClaudeModelRecency(a); -} - -const PROVIDER_PREFIXES = ["anthropic/", "openai/", "google-vertex/"]; - -const KNOWN_ACRONYMS = new Set(["gpt", "glm"]); - -// For a known acronym, uppercase it, keep the version attached, and title-case -// any suffix: "gpt-5.6-sol" -> "GPT-5.6 Sol", "glm-5.2" -> "GLM-5.2". Other ids -// stay lowercase to avoid mangling ordinary names (e.g. "llama-3.1-8b"). -function formatProviderModelName(modelId: string): string { - const [acronym, version, ...suffix] = modelId.split("-"); - if (!KNOWN_ACRONYMS.has(acronym.toLowerCase())) return modelId.toLowerCase(); - const head = version - ? `${acronym.toUpperCase()}-${version}` - : acronym.toUpperCase(); - const tail = suffix.map( - (word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(), - ); - return [head, ...tail].join(" "); -} - -export function formatGatewayModelName(model: GatewayModel): string { - if (isCloudflareModel(model)) { - return formatProviderModelName(model.id.split("/").pop() ?? model.id); - } - - if (isOpenAIModel(model)) { - return formatProviderModelName(stripProviderPrefix(model.id)); - } - - 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) { - if (cleanId.startsWith(prefix)) { - cleanId = cleanId.slice(prefix.length); - break; - } - } - - cleanId = cleanId.replace(/(\d)-(\d)/g, "$1.$2"); - - const words = cleanId.split(/[-_]/).map((word) => { - if (word.match(/^[0-9.]+$/)) return word; - return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); - }); - - return words.join(" "); -} diff --git a/packages/agent/src/utils/gateway.ts b/packages/agent/src/utils/gateway.ts index b258086070..5e97841742 100644 --- a/packages/agent/src/utils/gateway.ts +++ b/packages/agent/src/utils/gateway.ts @@ -1,3 +1,5 @@ +import { getCloudTaskGatewayUrl } from "@posthog/shared"; + export type GatewayProduct = | "posthog_code" | "background_agents" @@ -37,26 +39,7 @@ export { } from "@posthog/shared/posthog-property-headers"; function getGatewayBaseUrl(posthogHost: string): string { - const url = new URL(posthogHost); - const hostname = url.hostname; - - if (hostname === "localhost" || hostname === "127.0.0.1") { - return `${url.protocol}//localhost:3308`; - } - - if (hostname === "host.docker.internal") { - return `${url.protocol}//host.docker.internal:3308`; - } - - // The hosted dev environment runs its own LLM gateway with its own auth DB, - // so a dev-minted `pha_` token can't be routed to the US gateway — that's - // a different DB and returns 401 Authentication required. - if (hostname === "app.dev.posthog.dev") { - return "https://gateway.dev.posthog.dev"; - } - - const region = hostname.match(/^(us|eu)\.posthog\.com$/)?.[1] ?? "us"; - return `https://gateway.${region}.posthog.com`; + return getCloudTaskGatewayUrl(posthogHost).replace(/\/posthog_code$/, ""); } export function getLlmGatewayUrl( diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 00bbbc7700..906e77f290 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -1,5 +1,4 @@ import "./generated.augment"; -import { isSupportedReasoningEffort } from "@posthog/agent/adapters/reasoning-effort"; import type { Adapter, CloudMcpServerImport, @@ -15,6 +14,7 @@ import type { import { DISMISSAL_REASON_OPTIONS, type DismissalReasonOptionValue, + isSupportedReasoningEffort, resolveCloudInitialPermissionMode, } from "@posthog/shared"; import type { diff --git a/packages/shared/src/cloud-task-models.test.ts b/packages/shared/src/cloud-task-models.test.ts new file mode 100644 index 0000000000..31418a7775 --- /dev/null +++ b/packages/shared/src/cloud-task-models.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from "vitest"; +import { + buildCloudTaskConfigOptions, + compareModelsForPicker, + formatGatewayModelName, + type GatewayModel, + getClaudeModelRecency, + isAnthropicModel, + isBlockedModelId, + isCloudflareModel, + normalizeGatewayModelsResponse, + pickAllowedModel, +} from "./cloud-task-models"; + +const model = ( + id: string, + owned_by = "anthropic", + allowed = true, +): GatewayModel => ({ + id, + owned_by, + context_window: 128000, + supports_streaming: true, + supports_vision: false, + allowed, +}); + +describe("formatGatewayModelName", () => { + it.each([ + [model("claude-opus-4-8"), "Claude Opus 4.8"], + [model("GPT-5.5", "openai"), "GPT-5.5"], + [model("openai/gpt-5.6-sol", "openai"), "GPT-5.6 Sol"], + [model("@cf/zai-org/glm-5.2", "cloudflare"), "GLM-5.2"], + [ + model("@cf/meta/llama-3.1-8b-instruct", "cloudflare"), + "llama-3.1-8b-instruct", + ], + ])("formats $id", (gatewayModel, expected) => { + expect(formatGatewayModelName(gatewayModel)).toBe(expected); + }); +}); + +describe("normalizeGatewayModelsResponse", () => { + it("corrects stale GLM 5.2 context-window metadata", () => { + const models = normalizeGatewayModelsResponse([ + model("@cf/zai-org/glm-5.2", "cloudflare"), + ]); + + expect(models[0]?.context_window).toBe(1_000_000); + }); +}); + +describe("isBlockedModelId", () => { + it.each([ + "claude-opus-4-5", + "claude-opus-4-6", + "claude-sonnet-4-5", + "ANTHROPIC/CLAUDE-HAIKU-4-5", + "gpt-5.2", + "gpt-5.3", + "gpt-5.3-codex", + "OPENAI/GPT-5.3-CODEX", + ])("blocks %s", (modelId) => { + expect(isBlockedModelId(modelId)).toBe(true); + }); +}); + +describe("getClaudeModelRecency", () => { + it.each([ + ["claude-haiku-4-5", 4005], + ["claude-sonnet-4-6", 4006], + ["claude-opus-4-7", 4007], + ["claude-opus-4-8", 4008], + ["claude-sonnet-5", 5000], + ])("ranks %s", (modelId, expected) => { + expect(getClaudeModelRecency(modelId)).toBe(expected); + }); + + it("ignores trailing dates and ranks unknown versions newest", () => { + expect(getClaudeModelRecency("claude-haiku-4-5-20251001")).toBe(4005); + expect(getClaudeModelRecency("claude-mystery")).toBe( + Number.MAX_SAFE_INTEGER, + ); + }); +}); + +describe("compareModelsForPicker", () => { + it("groups by capability and sorts newest first", () => { + const displayed = [ + "claude-fable-5", + "claude-opus-4-7", + "claude-mystery", + "claude-sonnet-5", + "claude-haiku-4-5", + "claude-sonnet-4-6", + "claude-opus-4-8", + ].sort(compareModelsForPicker); + + expect(displayed).toEqual([ + "claude-fable-5", + "claude-opus-4-8", + "claude-opus-4-7", + "claude-sonnet-5", + "claude-sonnet-4-6", + "claude-haiku-4-5", + "claude-mystery", + ]); + }); +}); + +describe("model classification", () => { + it("keeps Cloudflare models distinct from Anthropic", () => { + const gatewayModel = model("@cf/openai/gpt-oss", "openai"); + expect(isCloudflareModel(gatewayModel)).toBe(true); + expect(isAnthropicModel(gatewayModel)).toBe(false); + }); +}); + +describe("pickAllowedModel", () => { + const entry = (id: string, allowed: boolean) => ({ id, allowed }); + + it.each([ + [[entry("claude-opus-4-8", true)], "claude-opus-4-8", "claude-opus-4-8"], + [[entry("claude-opus-4-8", true)], "claude-sonnet-5", "claude-sonnet-5"], + [ + [ + entry("claude-opus-4-8", false), + entry("claude-sonnet-4-6", true), + entry("@cf/zai-org/glm-5.2", true), + ], + "claude-opus-4-8", + "@cf/zai-org/glm-5.2", + ], + [[entry("claude-opus-4-8", false)], "claude-opus-4-8", "claude-opus-4-8"], + [[], "claude-opus-4-8", "claude-opus-4-8"], + ] as const)("selects an allowed default", (models, preferred, expected) => { + expect(pickAllowedModel(models, preferred)).toBe(expected); + }); +}); + +describe("buildCloudTaskConfigOptions", () => { + it("builds Claude options with restrictions and reasoning policy", () => { + const options = buildCloudTaskConfigOptions( + [ + model("gpt-5.5", "openai"), + model("claude-opus-4-7", "anthropic"), + model("claude-opus-4-8", "anthropic", false), + model("@cf/zai-org/glm-5.2", "cloudflare"), + ], + "claude", + ); + + expect(options).toMatchObject([ + { id: "mode", currentValue: "plan" }, + { + id: "model", + currentValue: "@cf/zai-org/glm-5.2", + options: [ + { value: "claude-opus-4-7" }, + { + value: "claude-opus-4-8", + _meta: { "posthog.code/restrictedModel": true }, + }, + { value: "@cf/zai-org/glm-5.2" }, + ], + }, + ]); + expect(options.map((option) => option.id)).toEqual(["mode", "model"]); + }); + + it("builds Codex options with the shared default and reasoning levels", () => { + const options = buildCloudTaskConfigOptions( + [ + model("claude-opus-4-8"), + model("gpt-5.6", "openai"), + model("gpt-5.5", "openai"), + ], + "codex", + ); + + expect(options).toMatchObject([ + { id: "mode", currentValue: "auto" }, + { + id: "model", + currentValue: "gpt-5.5", + options: [{ value: "gpt-5.6" }, { value: "gpt-5.5" }], + }, + { + id: "reasoning_effort", + currentValue: "high", + options: [ + { value: "low" }, + { value: "medium" }, + { value: "high" }, + { value: "xhigh" }, + ], + }, + ]); + }); +}); diff --git a/packages/shared/src/cloud-task-models.ts b/packages/shared/src/cloud-task-models.ts new file mode 100644 index 0000000000..a0fc5f4477 --- /dev/null +++ b/packages/shared/src/cloud-task-models.ts @@ -0,0 +1,385 @@ +import type { Adapter } from "./adapter"; +import { CODEX_MODE_PRESETS } from "./execution-modes"; +import { restrictedModelMeta } from "./models"; +import { getReasoningEffortOptions } from "./reasoning-effort"; + +export interface GatewayModel { + id: string; + owned_by: string; + context_window: number; + supports_streaming: boolean; + supports_vision: boolean; + allowed: boolean; + restriction_reason?: string | null; +} + +interface GatewayModelsResponse { + data?: unknown[]; + models?: unknown[]; +} + +export interface CloudTaskConfigSelectOption { + value: string; + name: string; + description?: string; + _meta?: Record; +} + +export interface CloudTaskConfigOption { + id: string; + name: string; + type: "select"; + currentValue: string; + options: CloudTaskConfigSelectOption[]; + category: "mode" | "model" | "thought_level"; + description: string; +} + +export interface CloudTaskModePreset { + id: string; + name: string; + description: string; +} + +export const DEFAULT_GATEWAY_MODEL = "claude-opus-4-8"; + +export const DEFAULT_CODEX_MODEL = "gpt-5.5"; + +export const BLOCKED_GATEWAY_MODEL_IDS = [ + "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", +] as const; + +const BLOCKED_GATEWAY_MODELS = new Set(BLOCKED_GATEWAY_MODEL_IDS); + +const CLAUDE_MODE_PRESETS: readonly CloudTaskModePreset[] = [ + { + id: "default", + name: "Default", + description: "Standard behavior, prompts for dangerous operations", + }, + { + id: "acceptEdits", + name: "Accept Edits", + description: "Auto-accept file edit operations", + }, + { + id: "plan", + name: "Plan Mode", + description: "Planning mode, no actual tool execution", + }, + { + id: "bypassPermissions", + name: "Bypass Permissions", + description: "Auto-accept all permission requests", + }, + { + id: "auto", + name: "Auto Mode", + description: "Auto-approve file edits and shell commands", + }, +]; + +const PROVIDER_NAMES: Record = { + anthropic: "Anthropic", + openai: "OpenAI", + "google-vertex": "Gemini", +}; + +const MODEL_FAMILY_ORDER = ["fable", "opus", "sonnet", "haiku"]; +const PROVIDER_PREFIXES = ["anthropic/", "openai/", "google-vertex/"]; +const KNOWN_ACRONYMS = new Set(["gpt", "glm"]); +const MODEL_CONTEXT_WINDOW_OVERRIDES: Readonly> = { + "@cf/zai-org/glm-5.2": 1_000_000, +}; + +export function getCloudTaskGatewayUrl(posthogHost: string): string { + const url = new URL(posthogHost); + let gatewayBaseUrl: string; + + if (url.hostname === "localhost" || url.hostname === "127.0.0.1") { + gatewayBaseUrl = `${url.protocol}//localhost:3308`; + } else if (url.hostname === "host.docker.internal") { + gatewayBaseUrl = `${url.protocol}//host.docker.internal:3308`; + } else if (url.hostname === "app.dev.posthog.dev") { + gatewayBaseUrl = "https://gateway.dev.posthog.dev"; + } else { + const region = url.hostname.match(/^(us|eu)\.posthog\.com$/)?.[1] ?? "us"; + gatewayBaseUrl = `https://gateway.${region}.posthog.com`; + } + + return `${gatewayBaseUrl}/posthog_code`; +} + +function isGatewayModel(value: unknown): value is Partial & { + id: string; +} { + return ( + typeof value === "object" && + value !== null && + typeof (value as { id?: unknown }).id === "string" + ); +} + +export function normalizeGatewayModelsResponse(value: unknown): GatewayModel[] { + const response = value as GatewayModelsResponse; + const entries = Array.isArray(value) + ? value + : Array.isArray(response?.data) + ? response.data + : Array.isArray(response?.models) + ? response.models + : []; + + return entries + .filter(isGatewayModel) + .filter((model) => !isBlockedModelId(model.id)) + .map((model) => ({ + id: model.id, + owned_by: model.owned_by ?? "", + context_window: Math.max( + model.context_window ?? 0, + MODEL_CONTEXT_WINDOW_OVERRIDES[model.id] ?? 0, + ), + supports_streaming: model.supports_streaming ?? false, + supports_vision: model.supports_vision ?? false, + allowed: model.allowed !== false, + restriction_reason: model.restriction_reason ?? null, + })); +} + +export function isBlockedModelId(modelId: string): boolean { + return BLOCKED_GATEWAY_MODELS.has(modelId.toLowerCase()); +} + +export function isAnthropicModel(model: GatewayModel): boolean { + if (model.owned_by) { + return model.owned_by === "anthropic"; + } + return model.id.startsWith("claude-") || model.id.startsWith("anthropic/"); +} + +export function isOpenAIModel(model: GatewayModel): boolean { + if (model.owned_by) { + return model.owned_by === "openai"; + } + return model.id.startsWith("gpt-") || model.id.startsWith("openai/"); +} + +export function isCloudflareModelId(modelId: string): boolean { + return modelId.startsWith("@cf/"); +} + +export function isGlmModelId(modelId: string): boolean { + return modelId.toLowerCase().includes("glm"); +} + +export function isCloudflareModel(model: GatewayModel): boolean { + return isCloudflareModelId(model.id) || model.owned_by === "cloudflare"; +} + +export function pickAllowedModel( + models: ReadonlyArray>, + preferred: string, +): string { + if (models.length === 0) return preferred; + const preferredEntry = models.find((model) => model.id === preferred); + if (!preferredEntry || preferredEntry.allowed) return preferred; + const allowed = models.filter((model) => model.allowed); + if (allowed.length === 0) return preferred; + return allowed.reduce((best, candidate) => + getClaudeModelRecency(candidate.id) >= getClaudeModelRecency(best.id) + ? candidate + : best, + ).id; +} + +export function getProviderName(ownedBy: string): string { + return PROVIDER_NAMES[ownedBy] ?? ownedBy; +} + +export function getClaudeModelRecency(modelId: string): number { + const match = modelId.toLowerCase().match(/-(\d+)(?:[-.](\d+))?/); + if (!match) return Number.MAX_SAFE_INTEGER; + const major = Number(match[1]); + const minor = match[2] ? Number(match[2]) : 0; + return major * 1000 + minor; +} + +function getModelFamilyRank(modelId: string): number { + const normalizedModelId = modelId.toLowerCase(); + const index = MODEL_FAMILY_ORDER.findIndex((family) => + normalizedModelId.includes(family), + ); + return index === -1 ? MODEL_FAMILY_ORDER.length : index; +} + +export function compareModelsForPicker(a: string, b: string): number { + const familyDiff = getModelFamilyRank(a) - getModelFamilyRank(b); + if (familyDiff !== 0) return familyDiff; + return getClaudeModelRecency(b) - getClaudeModelRecency(a); +} + +function stripProviderPrefix(modelId: string): string { + for (const prefix of PROVIDER_PREFIXES) { + if (modelId.startsWith(prefix)) { + return modelId.slice(prefix.length); + } + } + return modelId; +} + +function formatProviderModelName(modelId: string): string { + const [acronym, version, ...suffix] = modelId.split("-"); + if (!KNOWN_ACRONYMS.has(acronym.toLowerCase())) return modelId.toLowerCase(); + const head = version + ? `${acronym.toUpperCase()}-${version}` + : acronym.toUpperCase(); + const tail = suffix.map( + (word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(), + ); + return [head, ...tail].join(" "); +} + +export function formatGatewayModelName(model: GatewayModel): string { + if (isCloudflareModel(model)) { + return formatProviderModelName(model.id.split("/").pop() ?? model.id); + } + if (isOpenAIModel(model)) { + return formatProviderModelName(stripProviderPrefix(model.id)); + } + return formatModelId(model.id); +} + +export function formatModelId(modelId: string): string { + const cleanId = stripProviderPrefix(modelId).replace(/(\d)-(\d)/g, "$1.$2"); + return cleanId + .split(/[-_]/) + .map((word) => { + if (/^[0-9.]+$/.test(word)) return word; + return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); + }) + .join(" "); +} + +function getAdapterModels( + models: readonly GatewayModel[], + adapter: Adapter, +): GatewayModel[] { + return models.filter((model) => + adapter === "codex" + ? isOpenAIModel(model) + : isAnthropicModel(model) || isCloudflareModel(model), + ); +} + +function getModeOptions( + adapter: Adapter, + modePresets?: readonly CloudTaskModePreset[], +): CloudTaskConfigSelectOption[] { + const modes = + modePresets ?? + (adapter === "codex" ? CODEX_MODE_PRESETS : CLAUDE_MODE_PRESETS); + return modes.map((mode) => ({ + value: mode.id, + name: mode.name, + description: mode.description, + })); +} + +export function buildCloudTaskConfigOptions( + models: readonly GatewayModel[], + adapter: Adapter, + modePresets?: readonly CloudTaskModePreset[], +): CloudTaskConfigOption[] { + const adapterModels = getAdapterModels(models, adapter); + const modelOptions: CloudTaskConfigSelectOption[] = adapterModels.map( + (model) => ({ + value: model.id, + name: formatGatewayModelName(model), + description: `Context: ${model.context_window.toLocaleString()} tokens`, + ...(model.allowed ? {} : { _meta: restrictedModelMeta() }), + }), + ); + + if (adapter === "claude") { + modelOptions.sort( + (a, b) => getClaudeModelRecency(a.value) - getClaudeModelRecency(b.value), + ); + } + + const defaultModel = + adapter === "codex" + ? (modelOptions.find((option) => option.value === DEFAULT_CODEX_MODEL) + ?.value ?? + modelOptions[0]?.value ?? + "") + : DEFAULT_GATEWAY_MODEL; + const preferredModelId = modelOptions.some( + (option) => option.value === defaultModel, + ) + ? defaultModel + : (modelOptions[0]?.value ?? defaultModel); + const resolvedModelId = pickAllowedModel(adapterModels, preferredModelId); + + if (!modelOptions.some((option) => option.value === resolvedModelId)) { + modelOptions.unshift({ + value: resolvedModelId, + name: resolvedModelId, + description: "Custom model", + }); + } + + const configOptions: CloudTaskConfigOption[] = [ + { + id: "mode", + name: "Approval Preset", + type: "select", + currentValue: adapter === "codex" ? "auto" : "plan", + options: getModeOptions(adapter, modePresets), + category: "mode", + description: "Choose an approval and sandboxing preset for your session", + }, + { + id: "model", + name: "Model", + type: "select", + currentValue: resolvedModelId, + options: modelOptions, + category: "model", + description: "Choose which model the agent should use", + }, + ]; + + const reasoningOptions = getReasoningEffortOptions(adapter, resolvedModelId); + if (reasoningOptions) { + configOptions.push({ + id: adapter === "codex" ? "reasoning_effort" : "effort", + name: adapter === "codex" ? "Reasoning Level" : "Effort", + type: "select", + currentValue: "high", + options: reasoningOptions, + category: "thought_level", + description: + adapter === "codex" + ? "Controls how much reasoning effort the model uses" + : "Controls how much effort Claude puts into its response", + }); + } + + return configOptions; +} diff --git a/packages/shared/src/domain-types.test.ts b/packages/shared/src/domain-types.test.ts index f9b060cbed..1264ae0462 100644 --- a/packages/shared/src/domain-types.test.ts +++ b/packages/shared/src/domain-types.test.ts @@ -1,5 +1,22 @@ import { describe, expect, it } from "vitest"; -import { isContentlessTask } from "./domain-types"; +import { + isContentlessTask, + isTerminalStatus, + TERMINAL_STATUSES, +} from "./domain-types"; + +describe("task run statuses", () => { + it.each(TERMINAL_STATUSES)("identifies %s as terminal", (status) => { + expect(isTerminalStatus(status)).toBe(true); + }); + + it.each(["not_started", "queued", "in_progress", "unknown", null, undefined])( + "identifies %s as non-terminal", + (status) => { + expect(isTerminalStatus(status)).toBe(false); + }, + ); +}); describe("isContentlessTask", () => { it.each([ diff --git a/packages/shared/src/domain-types.ts b/packages/shared/src/domain-types.ts index 4aa90fd2c7..3600423dae 100644 --- a/packages/shared/src/domain-types.ts +++ b/packages/shared/src/domain-types.ts @@ -4,6 +4,7 @@ import type { AgentRuntime } from "./agent-runtime"; import type { DismissalReasonOptionValue } from "./dismissal-reasons"; import type { StoredLogEntry } from "./session-events"; import type { TaskRunArtifact } from "./task"; +import type { UploadableSkillSource } from "./skills"; // Execution mode schema and type - shared between main and renderer export const executionModeSchema = z.enum([ @@ -192,6 +193,37 @@ export type TaskRunStatus = | "failed" | "cancelled"; +export type TaskRunEnvironment = "local" | "cloud"; + +export type ArtifactType = + | "plan" + | "context" + | "reference" + | "output" + | "artifact" + | "user_attachment" + | "skill_bundle"; + +export interface TaskRunArtifactMetadata { + skill_name: string; + skill_source: UploadableSkillSource; + content_sha256: string; + bundle_format: "zip"; + schema_version: number; +} + +export interface TaskRunArtifact { + id?: string; + name: string; + type: ArtifactType; + source?: string; + size?: number; + content_type?: string; + metadata?: TaskRunArtifactMetadata; + storage_path?: string; + uploaded_at?: string; +} + export const TERMINAL_STATUSES = ["completed", "failed", "cancelled"] as const; export function isTerminalStatus( @@ -220,7 +252,7 @@ export interface TaskRun { model?: string | null; reasoning_effort?: "low" | "medium" | "high" | "xhigh" | "max" | null; stage?: string | null; // Current stage (e.g., 'research', 'plan', 'build') - environment?: "local" | "cloud"; + environment?: TaskRunEnvironment; status: TaskRunStatus; log_url: string; error_message: string | null; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ff03602e7e..5809830aba 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -71,6 +71,30 @@ export { promptBlocksToText, serializeCloudPrompt, } from "./cloud-prompt"; +export { + BLOCKED_GATEWAY_MODEL_IDS, + buildCloudTaskConfigOptions, + type CloudTaskConfigOption, + type CloudTaskConfigSelectOption, + type CloudTaskModePreset, + compareModelsForPicker, + DEFAULT_CODEX_MODEL, + DEFAULT_GATEWAY_MODEL, + formatGatewayModelName, + formatModelId, + type GatewayModel, + getClaudeModelRecency, + getCloudTaskGatewayUrl, + getProviderName, + isAnthropicModel, + isBlockedModelId, + isCloudflareModel, + isCloudflareModelId, + isGlmModelId, + isOpenAIModel, + normalizeGatewayModelsResponse, + pickAllowedModel, +} from "./cloud-task-models"; export { buildInboxDeeplink, buildScoutDeeplink, @@ -87,9 +111,28 @@ export { export { DISMISSAL_REASON_OPTIONS, type DismissalReasonOptionValue, + dismissalReasonLabel, isDismissalReasonSnooze, } from "./dismissal-reasons"; -export type { SignalReportPriority, Task } from "./domain-types"; +export { + type ArtifactType, + type CloudPermissionOption, + type CloudTaskErrorUpdate, + type CloudTaskLogsUpdate, + type CloudTaskPermissionRequestUpdate, + type CloudTaskSnapshotUpdate, + type CloudTaskStatusUpdate, + type CloudTaskUpdatePayload, + isTerminalStatus, + type SignalReportPriority, + type Task, + type TaskRun, + type TaskRunArtifact, + type TaskRunArtifactMetadata, + type TaskRunEnvironment, + type TaskRunStatus, + TERMINAL_STATUSES, +} from "./domain-types"; export * from "./enrichment"; export { classifyGatewayLimitError, @@ -213,6 +256,13 @@ export { isPrivateIpv4Octets, isPrivateIpv6Literal, } from "./private-network"; +export { + DEFAULT_REASONING_EFFORT, + getReasoningEffortOptions, + isSupportedReasoningEffort, + type ReasoningEffortOption, + type SupportedReasoningEffort, +} from "./reasoning-effort"; export { type CloudRegion, formatRegionBadge, @@ -276,15 +326,19 @@ export { serializeSkillMarkdown, stripFrontmatter, } from "./skills"; -export type { - ArtifactType, - PostHogAPIConfig, - TaskRun, - TaskRunArtifact, - TaskRunArtifactMetadata, - TaskRunEnvironment, - TaskRunStatus, -} from "./task"; +export type { PostHogAPIConfig } from "./task"; +export { + type CreateTaskAutomationOptions, + createTaskAutomationSchema, + type TaskAutomation, + type TaskAutomationList, + type TaskAutomationValidationErrorDetails, + taskAutomationListSchema, + taskAutomationSchema, + taskAutomationValidationErrorSchema, + type UpdateTaskAutomationOptions, + updateTaskAutomationSchema, +} from "./task-automation"; export type { TaskCreationInput, TaskCreationOutput, diff --git a/packages/shared/src/reasoning-effort.test.ts b/packages/shared/src/reasoning-effort.test.ts new file mode 100644 index 0000000000..5b1e301dd3 --- /dev/null +++ b/packages/shared/src/reasoning-effort.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { isSupportedReasoningEffort } from "./reasoning-effort"; + +describe("isSupportedReasoningEffort", () => { + it.each([ + ["codex", "gpt-5.5", "xhigh", true], + ["codex", "gpt-5.6-sol", "max", true], + ["codex", "gpt-5.4", "max", false], + ["claude", "claude-opus-4-8", "xhigh", true], + ["claude", "claude-sonnet-4-6", "xhigh", false], + ["claude", "claude-opus-4-8", "minimal", false], + ] as const)( + "validates %s %s effort %s", + (adapter, modelId, effort, expected) => { + expect(isSupportedReasoningEffort(adapter, modelId, effort)).toBe( + expected, + ); + }, + ); +}); diff --git a/packages/shared/src/reasoning-effort.ts b/packages/shared/src/reasoning-effort.ts new file mode 100644 index 0000000000..15f534a612 --- /dev/null +++ b/packages/shared/src/reasoning-effort.ts @@ -0,0 +1,77 @@ +import type { Adapter } from "./adapter"; + +export type SupportedReasoningEffort = + | "low" + | "medium" + | "high" + | "xhigh" + | "max"; + +export const DEFAULT_REASONING_EFFORT: SupportedReasoningEffort = "high"; + +export interface ReasoningEffortOption { + value: SupportedReasoningEffort; + name: string; +} + +const BASE_OPTIONS: ReasoningEffortOption[] = [ + { value: "low", name: "Low" }, + { value: "medium", name: "Medium" }, + { value: "high", name: "High" }, +]; + +const CLAUDE_MODELS_WITH_EFFORT = new Set([ + "claude-opus-4-7", + "claude-opus-4-8", + "claude-sonnet-4-6", + "claude-sonnet-5", + "claude-fable-5", +]); + +const CLAUDE_MODELS_WITH_XHIGH_EFFORT = new Set([ + "claude-opus-4-7", + "claude-opus-4-8", + "claude-sonnet-5", + "claude-fable-5", +]); + +export function getReasoningEffortOptions( + adapter: Adapter, + modelId: string, +): ReasoningEffortOption[] | null { + if (adapter === "claude" && !CLAUDE_MODELS_WITH_EFFORT.has(modelId)) { + return null; + } + + const options = [...BASE_OPTIONS]; + const normalizedModelId = modelId.toLowerCase(); + const supportsXhigh = + adapter === "claude" + ? CLAUDE_MODELS_WITH_XHIGH_EFFORT.has(modelId) + : normalizedModelId.includes("gpt-5.5") || + normalizedModelId.includes("gpt-5.6"); + + if (supportsXhigh) { + options.push({ value: "xhigh", name: "Extra High" }); + } + if ( + (adapter === "claude" && supportsXhigh) || + (adapter === "codex" && normalizedModelId.includes("gpt-5.6")) + ) { + options.push({ value: "max", name: "Max" }); + } + + return options; +} + +export function isSupportedReasoningEffort( + adapter: Adapter, + modelId: string, + value: string, +): value is SupportedReasoningEffort { + return ( + getReasoningEffortOptions(adapter, modelId)?.some( + (option) => option.value === value, + ) ?? false + ); +} diff --git a/packages/shared/src/sessions.ts b/packages/shared/src/sessions.ts index 76a49cd736..181ff36c20 100644 --- a/packages/shared/src/sessions.ts +++ b/packages/shared/src/sessions.ts @@ -8,9 +8,9 @@ import type { } from "@agentclientprotocol/sdk"; import type { Adapter } from "./adapter"; import type { SkillButtonId } from "./analytics-events"; +import type { TaskRunArtifact, TaskRunStatus } from "./domain-types"; import type { ExecutionMode } from "./exec-types"; import type { AcpMessage } from "./session-events"; -import type { TaskRunArtifact, TaskRunStatus } from "./task"; export type { Adapter }; diff --git a/packages/shared/src/task-automation.test.ts b/packages/shared/src/task-automation.test.ts new file mode 100644 index 0000000000..57e6fcaa97 --- /dev/null +++ b/packages/shared/src/task-automation.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, expectTypeOf, it } from "vitest"; +import { + type CreateTaskAutomationOptions, + createTaskAutomationSchema, + taskAutomationSchema, + taskAutomationValidationErrorSchema, + type UpdateTaskAutomationOptions, + updateTaskAutomationSchema, +} from "./task-automation"; + +describe("task automation contracts", () => { + it("normalizes optional automation response fields", () => { + expect( + taskAutomationSchema.parse({ + id: "automation-1", + name: "Daily PRs", + prompt: "Check PRs", + repository: "posthog/posthog", + cron_expression: "0 9 * * *", + last_run_at: null, + last_run_status: null, + last_task_id: null, + last_task_run_id: null, + last_error: null, + created_at: "2026-07-21T00:00:00Z", + updated_at: "2026-07-21T00:00:00Z", + }), + ).toMatchObject({ + github_integration: null, + timezone: null, + template_id: null, + enabled: true, + }); + }); + + it("keeps create fields required and update fields partial", () => { + const create = createTaskAutomationSchema.parse({ + name: "Daily PRs", + prompt: "Check PRs", + repository: "posthog/posthog", + cron_expression: "0 9 * * *", + timezone: "Europe/London", + }); + const update = updateTaskAutomationSchema.parse({ enabled: false }); + + expect(create.timezone).toBe("Europe/London"); + expect(update).toEqual({ enabled: false }); + expectTypeOf(create).toEqualTypeOf(); + expectTypeOf(update).toEqualTypeOf(); + }); + + it("preserves backend validation field attribution", () => { + expect( + taskAutomationValidationErrorSchema.parse({ + type: "validation_error", + detail: "Enter a valid cron expression.", + attr: "cron_expression", + }), + ).toEqual({ + type: "validation_error", + code: "invalid_input", + detail: "Enter a valid cron expression.", + attr: "cron_expression", + }); + }); +}); diff --git a/packages/shared/src/task-automation.ts b/packages/shared/src/task-automation.ts new file mode 100644 index 0000000000..0f3e4a4bde --- /dev/null +++ b/packages/shared/src/task-automation.ts @@ -0,0 +1,58 @@ +import { z } from "zod"; + +export const taskAutomationSchema = z.object({ + id: z.string(), + name: z.string(), + prompt: z.string(), + repository: z.string(), + github_integration: z.number().nullable().default(null), + cron_expression: z.string(), + timezone: z.string().nullable().default(null), + template_id: z.string().nullable().default(null), + enabled: z.boolean().default(true), + last_run_at: z.string().nullable(), + last_run_status: z.string().nullable(), + last_task_id: z.string().nullable(), + last_task_run_id: z.string().nullable(), + last_error: z.string().nullable(), + created_at: z.string(), + updated_at: z.string(), +}); +export type TaskAutomation = z.infer; + +export const taskAutomationListSchema = z.object({ + count: z.number(), + next: z.string().nullable().optional(), + previous: z.string().nullable().optional(), + results: z.array(taskAutomationSchema), +}); +export type TaskAutomationList = z.infer; + +export const createTaskAutomationSchema = z.object({ + name: z.string(), + prompt: z.string(), + repository: z.string(), + github_integration: z.number().nullable().optional(), + cron_expression: z.string(), + timezone: z.string(), + template_id: z.string().nullable().optional(), + enabled: z.boolean().optional(), +}); +export type CreateTaskAutomationOptions = z.infer< + typeof createTaskAutomationSchema +>; + +export const updateTaskAutomationSchema = createTaskAutomationSchema.partial(); +export type UpdateTaskAutomationOptions = z.infer< + typeof updateTaskAutomationSchema +>; + +export const taskAutomationValidationErrorSchema = z.object({ + type: z.string().optional(), + code: z.string().default("invalid_input"), + detail: z.string(), + attr: z.string().nullable().default(null), +}); +export type TaskAutomationValidationErrorDetails = z.infer< + typeof taskAutomationValidationErrorSchema +>; diff --git a/packages/shared/src/task.test.ts b/packages/shared/src/task.test.ts new file mode 100644 index 0000000000..8d666f2b11 --- /dev/null +++ b/packages/shared/src/task.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, expectTypeOf, it } from "vitest"; +import type { + Task, + TaskRun, + TaskRunArtifact, + TaskRunStatus, +} from "./domain-types"; +import { + type CloudPermissionOption, + type CloudTaskUpdatePayload, + isTerminalStatus, + type Task as RootTask, + type TaskRun as RootTaskRun, + type TaskRunArtifact as RootTaskRunArtifact, + type TaskRunStatus as RootTaskRunStatus, + TERMINAL_STATUSES, +} from "./index"; +import type { + Task as LegacyTask, + TaskRun as LegacyTaskRun, + TaskRunArtifact as LegacyTaskRunArtifact, + TaskRunStatus as LegacyTaskRunStatus, +} from "./task"; + +describe("cloud task contract exports", () => { + it("keeps legacy and root task exports canonical", () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + it("exports cloud permission and update payload contracts from the root", () => { + expectTypeOf().toMatchTypeOf<{ + kind: string; + optionId: string; + name: string; + }>(); + expectTypeOf().toEqualTypeOf< + "logs" | "status" | "snapshot" | "error" | "permission_request" + >(); + }); + + it("exports terminal status helpers from the root", () => { + expect(TERMINAL_STATUSES).toEqual(["completed", "failed", "cancelled"]); + expect(isTerminalStatus("completed")).toBe(true); + expect(isTerminalStatus("in_progress")).toBe(false); + }); +}); diff --git a/packages/shared/src/task.ts b/packages/shared/src/task.ts index b93c6e96d7..f6593d21bf 100644 --- a/packages/shared/src/task.ts +++ b/packages/shared/src/task.ts @@ -1,97 +1,12 @@ -// PostHog Task model (matches the desktop task API's OpenAPI schema) -import type { AgentRuntime } from "./agent-runtime"; -import type { UploadableSkillSource } from "./skills"; - -export interface Task { - id: string; - task_number?: number; - slug?: string; - title: string; - description: string; - origin_product: - | "error_tracking" - | "eval_clusters" - | "user_created" - | "support_queue" - | "session_summaries" - | "signal_report" - | "signals_scout" - | "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") - json_schema?: Record | null; // JSON schema for task output validation - internal?: boolean; - runtime?: AgentRuntime; - created_at: string; - updated_at: string; - created_by?: { - id: number; - uuid: string; - distinct_id: string; - first_name: string; - email: string; - }; - latest_run?: TaskRun; -} - -export type ArtifactType = - | "plan" - | "context" - | "reference" - | "output" - | "artifact" - | "user_attachment" - | "skill_bundle"; - -export interface TaskRunArtifactMetadata { - skill_name: string; - skill_source: UploadableSkillSource; - content_sha256: string; - bundle_format: "zip"; - schema_version: number; -} - -export interface TaskRunArtifact { - id?: string; - name: string; - type: ArtifactType; - source?: string; - size?: number; - content_type?: string; - metadata?: TaskRunArtifactMetadata; - storage_path?: string; - uploaded_at?: string; -} - -export type TaskRunStatus = - | "not_started" - | "queued" - | "in_progress" - | "completed" - | "failed" - | "cancelled"; - -export type TaskRunEnvironment = "local" | "cloud"; - -// TaskRun model - represents individual execution runs of tasks -export interface TaskRun { - id: string; - task: string; // Task ID - team: number; - branch: string | null; - stage: string | null; // Current stage (e.g., 'research', 'plan', 'build') - environment: TaskRunEnvironment; - status: TaskRunStatus; - log_url: string; - error_message: string | null; - output: Record | null; // Structured output (PR URL, commit SHA, etc.) - state: Record; // Intermediate run state (defaults to {}, never null) - artifacts?: TaskRunArtifact[]; - created_at: string; - updated_at: string; - completed_at: string | null; -} +export type { + ArtifactType, + Task, + TaskRun, + TaskRunArtifact, + TaskRunArtifactMetadata, + TaskRunEnvironment, + TaskRunStatus, +} from "./domain-types"; export interface PostHogAPIConfig { apiUrl: string; diff --git a/packages/workspace-server/src/services/agent/agent.ts b/packages/workspace-server/src/services/agent/agent.ts index 9654cb30b7..bd0821e254 100644 --- a/packages/workspace-server/src/services/agent/agent.ts +++ b/packages/workspace-server/src/services/agent/agent.ts @@ -20,24 +20,16 @@ import { } from "@posthog/agent"; import type { McpToolApprovals } from "@posthog/agent/adapters/claude/mcp/tool-metadata"; import { hydrateSessionJsonl } from "@posthog/agent/adapters/claude/session/jsonl-hydration"; -import { getReasoningEffortOptions } from "@posthog/agent/adapters/reasoning-effort"; import { Agent } from "@posthog/agent/agent"; import { getAvailableCodexModes, getAvailableModes, } from "@posthog/agent/execution-mode"; import { - DEFAULT_CODEX_MODEL, - DEFAULT_GATEWAY_MODEL, fetchGatewayModels, formatGatewayModelName, - type GatewayModel, getClaudeModelRecency, getProviderName, - isAnthropicModel, - isCloudflareModel, - isOpenAIModel, - pickAllowedModel, } from "@posthog/agent/gateway-models"; import { getLlmGatewayUrl } from "@posthog/agent/posthog-api"; import { @@ -72,10 +64,10 @@ import { import { type AcpMessage, type Adapter, + buildCloudTaskConfigOptions, type ExecutionMode, isAuthError, resolveCloudInitialPermissionMode, - restrictedModelMeta, serializeError, TypedEventEmitter, } from "@posthog/shared"; @@ -2395,111 +2387,14 @@ For git operations while detached: adapter: Adapter = "claude", ): Promise { const gatewayUrl = getLlmGatewayUrl(apiHost); - // Authenticated so the gateway can mark plan-restricted models; falls - // back to an anonymous fetch (everything allowed) without auth. const gatewayModels = await fetchGatewayModels({ gatewayUrl, authToken: (await this.agentAuthAdapter.gatewayAuthToken()) ?? undefined, }); - - // The Claude adapter can also drive Cloudflare `@cf/` models the gateway serves over its - // Anthropic-Messages surface, so the preview/default-model path must offer them too — otherwise an - // advertised `@cf/*` model is dropped here and the pre-session run falls back to Opus. - const modelFilter = - adapter === "codex" - ? isOpenAIModel - : (model: GatewayModel) => - isAnthropicModel(model) || isCloudflareModel(model); - - const adapterModels = gatewayModels.filter((model) => modelFilter(model)); - const modelOptions = adapterModels.map((model) => ({ - value: model.id, - name: formatGatewayModelName(model), - description: `Context: ${model.context_window.toLocaleString()} tokens`, - // Locked models stay listed so the picker can gate them instead of - // silently dropping them. - ...(model.allowed ? {} : { _meta: restrictedModelMeta() }), - })); - - // The gateway returns models in an arbitrary order. Sort Claude models - // oldest-to-newest so the picker is deterministic and the newest model - // lands at the end of the list, closest to the trigger. - if (adapter === "claude") { - modelOptions.sort( - (a, b) => - getClaudeModelRecency(a.value) - getClaudeModelRecency(b.value), - ); - } - - const defaultModel = - adapter === "codex" - ? (modelOptions.find((o) => o.value === DEFAULT_CODEX_MODEL)?.value ?? - modelOptions[0]?.value ?? - "") - : DEFAULT_GATEWAY_MODEL; - - const preferredModelId = modelOptions.some((o) => o.value === defaultModel) - ? defaultModel - : (modelOptions[0]?.value ?? defaultModel); - // Never preselect a model the org's plan can't use — it would 403 on the - // first message. - const resolvedModelId = pickAllowedModel(adapterModels, preferredModelId); - - if (!modelOptions.some((o) => o.value === resolvedModelId)) { - modelOptions.unshift({ - value: resolvedModelId, - name: resolvedModelId, - description: "Custom model", - }); - } - - const modes = - adapter === "codex" ? getAvailableCodexModes() : getAvailableModes(); - const modeOptions = modes.map((mode) => ({ - value: mode.id, - name: mode.name, - description: mode.description ?? undefined, - })); - const defaultMode = adapter === "codex" ? "auto" : "plan"; - - const configOptions: SessionConfigOption[] = [ - { - id: "mode", - name: "Approval Preset", - type: "select", - currentValue: defaultMode, - options: modeOptions, - category: "mode", - description: - "Choose an approval and sandboxing preset for your session", - }, - { - id: "model", - name: "Model", - type: "select", - currentValue: resolvedModelId, - options: modelOptions, - category: "model", - description: "Choose which model Claude should use", - }, - ]; - - const effortOpts = getReasoningEffortOptions(adapter, resolvedModelId); - if (effortOpts) { - configOptions.push({ - id: adapter === "codex" ? "reasoning_effort" : "effort", - name: adapter === "codex" ? "Reasoning Level" : "Effort", - type: "select", - currentValue: "high", - options: effortOpts, - category: "thought_level", - description: - adapter === "codex" - ? "Controls how much reasoning effort the model uses" - : "Controls how much effort Claude puts into its response", - }); - } - - return configOptions; + return buildCloudTaskConfigOptions( + gatewayModels, + adapter, + adapter === "codex" ? getAvailableCodexModes() : getAvailableModes(), + ) as SessionConfigOption[]; } } From ec5fb80c4e293a8a3f29a5bbfadb1559320cfb6c Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 24 Jul 2026 19:45:36 +0300 Subject: [PATCH 02/43] fix(models): preserve GLM effort policy Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- packages/shared/src/cloud-task-models.test.ts | 11 ++++- packages/shared/src/reasoning-effort.test.ts | 3 ++ packages/shared/src/reasoning-effort.ts | 48 ++++++++++--------- 3 files changed, 38 insertions(+), 24 deletions(-) diff --git a/packages/shared/src/cloud-task-models.test.ts b/packages/shared/src/cloud-task-models.test.ts index 31418a7775..afbad989f8 100644 --- a/packages/shared/src/cloud-task-models.test.ts +++ b/packages/shared/src/cloud-task-models.test.ts @@ -164,8 +164,17 @@ describe("buildCloudTaskConfigOptions", () => { { value: "@cf/zai-org/glm-5.2" }, ], }, + { + id: "effort", + currentValue: "high", + options: [{ value: "high" }, { value: "max" }], + }, + ]); + expect(options.map((option) => option.id)).toEqual([ + "mode", + "model", + "effort", ]); - expect(options.map((option) => option.id)).toEqual(["mode", "model"]); }); it("builds Codex options with the shared default and reasoning levels", () => { diff --git a/packages/shared/src/reasoning-effort.test.ts b/packages/shared/src/reasoning-effort.test.ts index 5b1e301dd3..2ac4a9bbff 100644 --- a/packages/shared/src/reasoning-effort.test.ts +++ b/packages/shared/src/reasoning-effort.test.ts @@ -8,6 +8,9 @@ describe("isSupportedReasoningEffort", () => { ["codex", "gpt-5.4", "max", false], ["claude", "claude-opus-4-8", "xhigh", true], ["claude", "claude-sonnet-4-6", "xhigh", false], + ["claude", "@cf/zai-org/glm-5.2", "high", true], + ["claude", "@cf/zai-org/glm-5.2", "max", true], + ["claude", "@cf/zai-org/glm-5.2", "medium", false], ["claude", "claude-opus-4-8", "minimal", false], ] as const)( "validates %s %s effort %s", diff --git a/packages/shared/src/reasoning-effort.ts b/packages/shared/src/reasoning-effort.ts index 15f534a612..2fe12b7232 100644 --- a/packages/shared/src/reasoning-effort.ts +++ b/packages/shared/src/reasoning-effort.ts @@ -20,44 +20,46 @@ const BASE_OPTIONS: ReasoningEffortOption[] = [ { value: "high", name: "High" }, ]; -const CLAUDE_MODELS_WITH_EFFORT = new Set([ - "claude-opus-4-7", - "claude-opus-4-8", - "claude-sonnet-4-6", - "claude-sonnet-5", - "claude-fable-5", -]); +const CLAUDE_MODEL_EFFORTS: Readonly< + Record +> = { + "claude-opus-4-7": ["low", "medium", "high", "xhigh", "max"], + "claude-opus-4-8": ["low", "medium", "high", "xhigh", "max"], + "claude-sonnet-4-6": ["low", "medium", "high"], + "claude-sonnet-5": ["low", "medium", "high", "xhigh", "max"], + "claude-fable-5": ["low", "medium", "high", "xhigh", "max"], + "@cf/zai-org/glm-5.2": ["high", "max"], +}; -const CLAUDE_MODELS_WITH_XHIGH_EFFORT = new Set([ - "claude-opus-4-7", - "claude-opus-4-8", - "claude-sonnet-5", - "claude-fable-5", -]); +const EFFORT_NAMES: Record = { + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra High", + max: "Max", +}; export function getReasoningEffortOptions( adapter: Adapter, modelId: string, ): ReasoningEffortOption[] | null { - if (adapter === "claude" && !CLAUDE_MODELS_WITH_EFFORT.has(modelId)) { - return null; + if (adapter === "claude") { + const efforts = CLAUDE_MODEL_EFFORTS[modelId]; + return ( + efforts?.map((value) => ({ value, name: EFFORT_NAMES[value] })) ?? null + ); } const options = [...BASE_OPTIONS]; const normalizedModelId = modelId.toLowerCase(); const supportsXhigh = - adapter === "claude" - ? CLAUDE_MODELS_WITH_XHIGH_EFFORT.has(modelId) - : normalizedModelId.includes("gpt-5.5") || - normalizedModelId.includes("gpt-5.6"); + normalizedModelId.includes("gpt-5.5") || + normalizedModelId.includes("gpt-5.6"); if (supportsXhigh) { options.push({ value: "xhigh", name: "Extra High" }); } - if ( - (adapter === "claude" && supportsXhigh) || - (adapter === "codex" && normalizedModelId.includes("gpt-5.6")) - ) { + if (adapter === "codex" && normalizedModelId.includes("gpt-5.6")) { options.push({ value: "max", name: "Max" }); } From fa2dda48da93a111d7ddca4081ce3763433da716 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Sat, 25 Jul 2026 01:55:53 +0300 Subject: [PATCH 03/43] test(ui): wait for async content Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- .../components/session-update/PlanApprovalView.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.test.tsx b/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.test.tsx index ef705ba77c..dd83ad5f6b 100644 --- a/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.test.tsx +++ b/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.test.tsx @@ -110,7 +110,7 @@ describe("PlanApprovalView", () => { ).toBeInTheDocument(); }); - it("uses updated content instead of stale raw input while streaming", () => { + it("uses updated content instead of stale raw input while streaming", async () => { renderView({ toolCall: makeToolCall({ status: "in_progress", @@ -124,7 +124,7 @@ describe("PlanApprovalView", () => { }), }); - expect(screen.getByText("Updated plan")).toBeInTheDocument(); + expect(await screen.findByText("Updated plan")).toBeInTheDocument(); expect(screen.queryByText("Initial plan")).not.toBeInTheDocument(); }); From 0960bc378d53c5e5e3cdb261eb619953bb69f18a Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Sat, 25 Jul 2026 02:06:34 +0300 Subject: [PATCH 04/43] test(ui): isolate plan approval presentation Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- .../components/session-update/PlanApprovalView.test.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.test.tsx b/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.test.tsx index dd83ad5f6b..3bd1add8e8 100644 --- a/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.test.tsx +++ b/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.test.tsx @@ -2,9 +2,13 @@ import type { ToolCall } from "@posthog/ui/features/sessions/types"; import { Theme } from "@radix-ui/themes"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { PlanApprovalView } from "./PlanApprovalView"; +vi.mock("../../../permissions/PlanContent", () => ({ + PlanContent: ({ plan }: { plan: string }) =>
{plan}
, +})); + const PLAN_MARKER = "Sentinel plan body for testing"; function makeToolCall(overrides: Partial = {}): ToolCall { @@ -124,7 +128,7 @@ describe("PlanApprovalView", () => { }), }); - expect(await screen.findByText("Updated plan")).toBeInTheDocument(); + expect(screen.getByText("Updated plan")).toBeInTheDocument(); expect(screen.queryByText("Initial plan")).not.toBeInTheDocument(); }); From 3f608f21ce8b2a1cf0a4277ed6fd77832371ee8f Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Tue, 28 Jul 2026 12:05:07 +0300 Subject: [PATCH 05/43] fix(shared): Preserve canonical task artifacts Keep the extracted domain declaration as the single source after rebasing onto current main. Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- packages/shared/src/domain-types.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/shared/src/domain-types.ts b/packages/shared/src/domain-types.ts index 3600423dae..8f1e8dc56d 100644 --- a/packages/shared/src/domain-types.ts +++ b/packages/shared/src/domain-types.ts @@ -3,7 +3,6 @@ import type { Adapter } from "./adapter"; import type { AgentRuntime } from "./agent-runtime"; import type { DismissalReasonOptionValue } from "./dismissal-reasons"; import type { StoredLogEntry } from "./session-events"; -import type { TaskRunArtifact } from "./task"; import type { UploadableSkillSource } from "./skills"; // Execution mode schema and type - shared between main and renderer From 9c4235c3f9f0b9ca26c0eb2aac4f547288b57029 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Tue, 28 Jul 2026 12:27:36 +0300 Subject: [PATCH 06/43] test(agent): match canonical model picker order Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- packages/agent/src/adapters/claude/session/model-config.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agent/src/adapters/claude/session/model-config.test.ts b/packages/agent/src/adapters/claude/session/model-config.test.ts index 22fd0bcf69..5561454baa 100644 --- a/packages/agent/src/adapters/claude/session/model-config.test.ts +++ b/packages/agent/src/adapters/claude/session/model-config.test.ts @@ -26,8 +26,8 @@ describe("applyAvailableModelsAllowlist", () => { "claude-opus-4-8", ]).options, ).toEqual([ - { value: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, { value: "claude-opus-4-8", name: "Claude Opus 4.8" }, + { value: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, ]); }); From fc5334ec8ffe716a7edc3cce19ddb810bc0350bb Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Tue, 28 Jul 2026 19:42:56 +0100 Subject: [PATCH 07/43] feat(canvas): delete a canvas with a confirm and an undo window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The canvas "…" menu had no way to delete; the canvases grid had one that deleted on click with no confirmation. Both now open an alert dialog, and confirming doesn't delete straight away: the canvas is marked pending and the host isn't told until the "Deleted artifact" toast's timer expires, so Undo simply cancels the timer rather than recreating anything. Pending canvases stay in their lists — the artifacts row swaps its template icon for a pulsing trash can and stops opening, the grid card dims behind the same icon — so undoing restores them in place. The commit runs outside React (module-level timer + hostClient) because deleting from inside a canvas navigates away immediately. Co-Authored-By: Claude Opus 5 (1M context) --- packages/shared/src/analytics-events.ts | 2 + .../components/WebsiteChannelArtifacts.tsx | 53 ++++++-- .../components/WebsiteDashboardsIndex.tsx | 108 ++++++++++----- .../canvas/components/WebsiteLayout.tsx | 68 +++++++++- .../canvas/deleteCanvasWithUndo.test.ts | 104 +++++++++++++++ .../features/canvas/deleteCanvasWithUndo.ts | 123 ++++++++++++++++++ .../features/canvas/hooks/useDashboards.ts | 6 + .../canvas/stores/pendingCanvasDeleteStore.ts | 29 +++++ 8 files changed, 452 insertions(+), 41 deletions(-) create mode 100644 packages/ui/src/features/canvas/deleteCanvasWithUndo.test.ts create mode 100644 packages/ui/src/features/canvas/deleteCanvasWithUndo.ts create mode 100644 packages/ui/src/features/canvas/stores/pendingCanvasDeleteStore.ts diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index 83f6edc458..3025d359b8 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -947,6 +947,8 @@ export type DashboardActionType = | "open" | "create" | "delete" + /** The delete was undone inside its undo window, so nothing was removed. */ + | "delete_undo" | "rename" | "save" | "fork" diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx index 4ea4783e1c..d8eca72aa9 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx @@ -1,4 +1,4 @@ -import { CaretRightIcon } from "@phosphor-icons/react"; +import { CaretRightIcon, TrashIcon } from "@phosphor-icons/react"; import type { ChannelTaskRecord } from "@posthog/core/canvas/channelTaskSchemas"; import type { DashboardSummary } from "@posthog/core/canvas/dashboardSchemas"; import { formatRelativeTimeShort } from "@posthog/shared"; @@ -9,6 +9,7 @@ import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTe import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { useChannelTasks } from "@posthog/ui/features/canvas/hooks/useChannelTasks"; import { useDashboards } from "@posthog/ui/features/canvas/hooks/useDashboards"; +import { useIsCanvasPendingDelete } from "@posthog/ui/features/canvas/stores/pendingCanvasDeleteStore"; import { usePrArtifact } from "@posthog/ui/features/git-interaction/usePrArtifact"; import { useTasks } from "@posthog/ui/features/tasks/useTasks"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; @@ -140,16 +141,13 @@ export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) {
{items.map((item) => item.kind === "canvas" ? ( - openCanvas(item.dashboardId)} + ts={item.ts} + onClick={openCanvas} /> ) : ( void; +}) { + const deleting = useIsCanvasPendingDelete(dashboardId); + + return ( + + ) : ( + iconForTemplate(templateId, { size: 15, className: "text-violet-9" }) + ) + } + title={title} + subtitle={ + deleting ? "Deleting…" : `Canvas · ${formatRelativeTimeShort(ts)}` + } + onClick={deleting ? undefined : () => onClick(dashboardId)} + /> + ); +} + // A PR artifact row. The PR's lifecycle state (open / draft / merged / closed) // comes from usePrArtifact, which also gates the URL — PR links come from run // output, so a row must not fetch from whatever host that names. diff --git a/packages/ui/src/features/canvas/components/WebsiteDashboardsIndex.tsx b/packages/ui/src/features/canvas/components/WebsiteDashboardsIndex.tsx index e4f359ead2..802081f82a 100644 --- a/packages/ui/src/features/canvas/components/WebsiteDashboardsIndex.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteDashboardsIndex.tsx @@ -1,6 +1,13 @@ import { DotsThreeIcon, LinkIcon, TrashIcon } from "@phosphor-icons/react"; import type { DashboardSummary } from "@posthog/core/canvas/dashboardSchemas"; import { + AlertDialog, + AlertDialogClose, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, Badge, Button, Card, @@ -15,6 +22,7 @@ import { import { formatRelativeTimeShort } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { NewCanvasMenu } from "@posthog/ui/features/canvas/components/NewCanvasMenu"; +import { deleteCanvasWithUndo } from "@posthog/ui/features/canvas/deleteCanvasWithUndo"; import { FreeformCanvas } from "@posthog/ui/features/canvas/freeform/FreeformCanvas"; import { handleFreeformDataRequest } from "@posthog/ui/features/canvas/freeform/freeformDataBridge"; import { useCanvasTemplates } from "@posthog/ui/features/canvas/hooks/useCanvasTemplates"; @@ -22,9 +30,9 @@ import { useDashboardMutations, useDashboards, } from "@posthog/ui/features/canvas/hooks/useDashboards"; +import { useIsCanvasPendingDelete } from "@posthog/ui/features/canvas/stores/pendingCanvasDeleteStore"; import { copyCanvasLink } from "@posthog/ui/features/canvas/utils/copyCanvasLink"; import { useInView } from "@posthog/ui/primitives/hooks/useInView"; -import { toast } from "@posthog/ui/primitives/toast"; import { track } from "@posthog/ui/shell/analytics"; import { ErrorBoundary } from "@posthog/ui/shell/ErrorBoundary"; import { Box, Flex, Grid } from "@radix-ui/themes"; @@ -106,10 +114,20 @@ const DashboardCard = memo(function DashboardCard({ summary: DashboardSummary; templateLabel: string; }) { + // While the canvas is inside its delete-undo window the card stays in the + // grid — dimmed, with a pulsing trash can over its preview — so undoing puts + // it back exactly where it was rather than re-inserting a row. + const deleting = useIsCanvasPendingDelete(summary.id); + // The React source rides along in the list response, so the grid renders // previews without a per-card fetch (no N+1 of get()). return ( - + - + + + {deleting && ( + + + + Deleting… + + + )} + @@ -223,31 +256,22 @@ function DashboardCardMenu({ channelId: string; }) { const [open, setOpen] = useState(false); - const { deleteDashboard, isDeleting } = useDashboardMutations(); + // "Delete…" opens a confirmation rather than deleting inline — the canvas and + // its version history go away for everyone in the channel. + const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); + const { invalidateDashboards } = useDashboardMutations(); - const onDelete = () => { - deleteDashboard(id) - .then(() => { - track(ANALYTICS_EVENTS.DASHBOARD_ACTION, { - action_type: "delete", - surface: "dashboards_grid", - channel_id: channelId, - dashboard_id: id, - success: true, - }); - }) - .catch((error) => { - track(ANALYTICS_EVENTS.DASHBOARD_ACTION, { - action_type: "delete", - surface: "dashboards_grid", - channel_id: channelId, - dashboard_id: id, - success: false, - }); - toast.error("Couldn't delete canvas", { - description: error instanceof Error ? error.message : String(error), - }); - }); + // The card disappears immediately, but the delete isn't sent until the undo + // toast's timer runs out — Undo simply cancels it. + const confirmDelete = () => { + setConfirmDeleteOpen(false); + deleteCanvasWithUndo({ + dashboardId: id, + channelId, + name, + surface: "dashboards_grid", + invalidate: invalidateDashboards, + }); }; return ( @@ -280,14 +304,38 @@ function DashboardCardMenu({ setConfirmDeleteOpen(true)} > - Delete + Delete… + {/* Destructive confirm for "Delete…" — the canvas goes for everyone. */} + + + + Delete canvas + + Permanently delete {name}? + This deletes its code and version history for everyone in the + channel and cannot be undone. + + + + + Cancel + + } + /> + + + + ); } diff --git a/packages/ui/src/features/canvas/components/WebsiteLayout.tsx b/packages/ui/src/features/canvas/components/WebsiteLayout.tsx index 70105c1e05..67de99105d 100644 --- a/packages/ui/src/features/canvas/components/WebsiteLayout.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteLayout.tsx @@ -5,9 +5,17 @@ import { LinkIcon, PencilSimpleIcon, PushPinIcon, + TrashIcon, XIcon, } from "@phosphor-icons/react"; import { + AlertDialog, + AlertDialogClose, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, Button, DropdownMenu, DropdownMenuContent, @@ -18,6 +26,7 @@ import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { ChannelBreadcrumb } from "@posthog/ui/features/canvas/components/ChannelBreadcrumb"; import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon"; import { NewCanvasMenu } from "@posthog/ui/features/canvas/components/NewCanvasMenu"; +import { deleteCanvasWithUndo } from "@posthog/ui/features/canvas/deleteCanvasWithUndo"; import { CanvasFrameHost } from "@posthog/ui/features/canvas/freeform/CanvasFrameHost"; import { useCanvasFrameStore } from "@posthog/ui/features/canvas/freeform/canvasFrameStore"; import { CANVAS_QUERY_KEY } from "@posthog/ui/features/canvas/freeform/freeformDataBridge"; @@ -50,7 +59,7 @@ import { useParams, useRouterState, } from "@tanstack/react-router"; -import type { ReactNode } from "react"; +import { type ReactNode, useState } from "react"; function threadIdFor(dashboardId: string): string { return `dashboard:${dashboardId}`; @@ -72,8 +81,30 @@ function FreeformEditControls({ const editing = useIsDashboardEditing(dashboardId); const setEditing = useDashboardEditStore((s) => s.setEditing); const { dashboard } = useDashboard(dashboardId); - const { forkFreeform, isCreating, setPinned } = useDashboardMutations(); + const { forkFreeform, isCreating, setPinned, invalidateDashboards } = + useDashboardMutations(); const isPinned = dashboard?.pinnedAt != null; + // "Delete…" opens a confirmation rather than deleting inline — the canvas and + // its version history go away for everyone in the channel. + const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); + + // Once confirmed the canvas vanishes from every list and we leave for the + // space's artifacts list, but the delete isn't sent until the undo toast's + // timer runs out — Undo simply cancels it. + const confirmDelete = () => { + setConfirmDeleteOpen(false); + deleteCanvasWithUndo({ + dashboardId, + channelId, + name: dashboard?.name ?? "Canvas", + surface: "canvas", + invalidate: invalidateDashboards, + }); + void navigate({ + to: "/website/$channelId/artifacts", + params: { channelId }, + }); + }; const onTogglePin = () => { void setPinned(dashboardId, !isPinned) @@ -250,8 +281,41 @@ function FreeformEditControls({ {isPinned ? "Unpin from channel" : "Pin to channel"} + setConfirmDeleteOpen(true)} + > + + Delete… + + {/* Destructive confirm for "Delete…" — the canvas goes for everyone. */} + + + + Delete canvas + + Permanently delete{" "} + {dashboard?.name ?? "Canvas"} + ? This deletes its code and version history for everyone in the + channel and cannot be undone. + + + + + Cancel + + } + /> + + + + + } + /> + {label} + + ); + })} + + ); +} diff --git a/packages/ui/src/features/canvas/components/FreeformPreview.tsx b/packages/ui/src/features/canvas/components/FreeformPreview.tsx new file mode 100644 index 0000000000..b753c620ab --- /dev/null +++ b/packages/ui/src/features/canvas/components/FreeformPreview.tsx @@ -0,0 +1,102 @@ +import { cn, Text } from "@posthog/quill"; +import { FreeformCanvas } from "@posthog/ui/features/canvas/freeform/FreeformCanvas"; +import { handleFreeformDataRequest } from "@posthog/ui/features/canvas/freeform/freeformDataBridge"; +import { useInView } from "@posthog/ui/primitives/hooks/useInView"; +import { ErrorBoundary } from "@posthog/ui/shell/ErrorBoundary"; +import { Box, Flex } from "@radix-ui/themes"; +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback } from "react"; + +// Render each canvas's live app at 1/SCALE of the card width, then shrink so it +// fits inside the preview frame as a thumbnail. +const PREVIEW_SCALE = 0.4; + +// Mount a preview only while it's near the viewport, and UNMOUNT it once it +// scrolls away (once: false). This caps how many full preview trees / sandbox +// iframes are live at any time, so a channel with many large canvases doesn't +// accumulate pages of off-screen DOM. The margin pre-mounts a little early so +// scrolling doesn't flash an empty frame. The fixed-height frame keeps the +// layout stable across mount/unmount (no scroll jump). +const PREVIEW_VIEWPORT = { once: false, rootMargin: "400px 0px" } as const; + +// A freeform (React-in-iframe) canvas preview: the app rendered at PREVIEW_SCALE +// in a clipped frame. Deferred until near the viewport, and runs with NO +// analytics so it fires no events. +export function FreeformPreview({ + code, + height = 176, + className, +}: { + code?: string; + /** Frame height in px. Taller frames simply reveal more of the app. */ + height?: number; + className?: string; +}) { + const [ref, inView] = useInView(PREVIEW_VIEWPORT); + + // Preview data handler: swallow captures so a thumbnail never emits analytics + // events, but let reads through (cached, shared with the full view) so the + // preview shows real-ish content. (posthog-js itself is never booted — no + // `analytics` prop — so there's no autocapture/pageview/replay either.) + const queryClient = useQueryClient(); + const onDataRequest = useCallback( + (method: string, payload: unknown) => + method === "capture" + ? Promise.resolve({ ok: true }) + : handleFreeformDataRequest(method, payload, queryClient), + [queryClient], + ); + + return ( + + {code ? ( + inView ? ( + + } + > + + + + ) : ( + + ) + ) : ( + + )} + + ); +} + +function PreviewPlaceholder({ label }: { label: string }) { + return ( + + + {label} + + + ); +} diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx index d8eca72aa9..b107531ad7 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx @@ -1,21 +1,36 @@ -import { CaretRightIcon, TrashIcon } from "@phosphor-icons/react"; +import { CaretRightIcon, FilesIcon, TrashIcon } from "@phosphor-icons/react"; import type { ChannelTaskRecord } from "@posthog/core/canvas/channelTaskSchemas"; import type { DashboardSummary } from "@posthog/core/canvas/dashboardSchemas"; +import { + Badge, + Card, + CardContent, + cn, + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, + Text, +} from "@posthog/quill"; import { formatRelativeTimeShort } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { useArchivedTaskIds } from "@posthog/ui/features/archive/useArchivedTaskIds"; +import { ArtifactsViewToggle } from "@posthog/ui/features/canvas/components/ArtifactsViewToggle"; import { ChannelHeader } from "@posthog/ui/features/canvas/components/ChannelHeader"; import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon"; +import { FreeformPreview } from "@posthog/ui/features/canvas/components/FreeformPreview"; import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { useChannelTasks } from "@posthog/ui/features/canvas/hooks/useChannelTasks"; import { useDashboards } from "@posthog/ui/features/canvas/hooks/useDashboards"; +import { useArtifactsViewStore } from "@posthog/ui/features/canvas/stores/artifactsViewStore"; import { useIsCanvasPendingDelete } from "@posthog/ui/features/canvas/stores/pendingCanvasDeleteStore"; +import { masonryPreviewHeight } from "@posthog/ui/features/canvas/utils/masonryPreviewHeight"; import { usePrArtifact } from "@posthog/ui/features/git-interaction/usePrArtifact"; import { useTasks } from "@posthog/ui/features/tasks/useTasks"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; import { track } from "@posthog/ui/shell/analytics"; import { openExternalUrl } from "@posthog/ui/shell/openExternal"; -import { Text } from "@radix-ui/themes"; import { useNavigate } from "@tanstack/react-router"; import { type ReactNode, useCallback, useEffect, useMemo } from "react"; @@ -30,6 +45,8 @@ type ArtifactItem = ts: number; templateId: string; dashboardId: string; + /** Live React source, along for the ride so cards preview without a get(). */ + code?: string; } | { kind: "pr"; @@ -41,10 +58,12 @@ type ArtifactItem = // A channel's artifacts: canvases and the pull requests produced by its tasks, // most recent first. Sibling of the History tab, but scoped to outputs rather -// than the full activity stream. +// than the full activity stream. The view toggle switches between a dense row +// list and card layouts that preview each canvas live. export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) { const spacesLayout = useChannelsLayout(); const navigate = useNavigate(); + const view = useArtifactsViewStore((s) => s.view); useEffect(() => { track(ANALYTICS_EVENTS.CHANNEL_ACTION, { @@ -72,6 +91,7 @@ export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) { ts: d.updatedAt, templateId: d.templateId, dashboardId: d.id, + code: d.code, }), ); @@ -126,39 +146,74 @@ export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) { return (
-
+ {/* The list reads best narrow; card layouts want the full width. */} +
+
+ + {items.length === 0 + ? "Artifacts" + : `${items.length} artifact${items.length === 1 ? "" : "s"}`} + + +
+ {items.length === 0 ? ( -
- - No artifacts yet - - - Canvases and pull requests from this{" "} - {spacesLayout ? "space's" : "channel's"} tasks show up here. - + + + + + + No artifacts yet + + Canvases and pull requests from this{" "} + {spacesLayout ? "space's" : "channel's"} tasks show up here. + + + + ) : view === "list" ? ( +
+ {items.map((item) => ( + + ))} +
+ ) : view === "grid" ? ( +
+ {items.map((item) => ( + + ))}
) : ( -
- {items.map((item) => - item.kind === "canvas" ? ( - - ) : ( - + {items.map((item) => ( +
+ - ), - )} +
+ ))}
)}
@@ -166,6 +221,64 @@ export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) { ); } +function ArtifactListItem({ + item, + onOpenCanvas, + onOpenPr, +}: { + item: ArtifactItem; + onOpenCanvas: (dashboardId: string) => void; + onOpenPr: (safeUrl: string) => void; +}) { + return item.kind === "canvas" ? ( + + ) : ( + + ); +} + +function ArtifactCard({ + item, + previewHeight, + onOpenCanvas, + onOpenPr, +}: { + item: ArtifactItem; + previewHeight: number; + onOpenCanvas: (dashboardId: string) => void; + onOpenPr: (safeUrl: string) => void; +}) { + return item.kind === "canvas" ? ( + + ) : ( + + ); +} + // A canvas artifact row. While the canvas is inside its delete-undo window the // row stays put — its template icon becomes a pulsing trash can and the row // stops opening — so undoing puts it back exactly where it was. @@ -283,3 +396,156 @@ function ArtifactRow({ ); } + +// The card form of a canvas artifact: a live preview of the canvas above its +// title. Same delete-undo behaviour as the row — the card stays in place, +// dimmed, until the undo window closes. +function CanvasArtifactCard({ + dashboardId, + templateId, + title, + ts, + code, + previewHeight, + onClick, +}: { + dashboardId: string; + templateId: string; + title: string; + ts: number; + code?: string; + previewHeight: number; + onClick: (dashboardId: string) => void; +}) { + const deleting = useIsCanvasPendingDelete(dashboardId); + + return ( + + + {deleting && ( +
+ + + Deleting… + +
+ )} + + } + icon={iconForTemplate(templateId, { + size: 14, + className: "text-violet-9", + })} + title={title} + badge="Canvas" + subtitle={ + deleting ? "Deleting…" : `Updated ${formatRelativeTimeShort(ts)}` + } + dimmed={deleting} + onClick={deleting ? undefined : () => onClick(dashboardId)} + /> + ); +} + +// The card form of a PR artifact. A PR has nothing to preview, so its media +// slot is a short tinted band carrying the lifecycle icon — which also keeps PR +// cards visibly shorter than canvas cards in the masonry layout. +function PrArtifactCard({ + title, + prUrl, + ts, + onClick, +}: { + title: string; + prUrl: string; + ts: number; + onClick: (safeUrl: string) => void; +}) { + const { + safeUrl, + title: prTitle, + stateLabel, + Icon, + iconColor, + accentColor, + } = usePrArtifact(prUrl); + + const subtitle = [prTitle, formatRelativeTimeShort(ts)] + .filter(Boolean) + .join(" · "); + + return ( + + +
+ } + icon={} + title={title} + badge={stateLabel || "Pull request"} + subtitle={subtitle} + onClick={safeUrl ? () => onClick(safeUrl) : undefined} + /> + ); +} + +function ArtifactCardShell({ + media, + icon, + title, + badge, + subtitle, + dimmed, + onClick, +}: { + media: ReactNode; + icon: ReactNode; + title: string; + badge: string; + subtitle: string; + dimmed?: boolean; + /** Absent for a card with nowhere safe to go — a non-github PR link. */ + onClick?: () => void; +}) { + return ( + + ); +} diff --git a/packages/ui/src/features/canvas/components/WebsiteDashboardsIndex.tsx b/packages/ui/src/features/canvas/components/WebsiteDashboardsIndex.tsx index 802081f82a..eb0fa12fa3 100644 --- a/packages/ui/src/features/canvas/components/WebsiteDashboardsIndex.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteDashboardsIndex.tsx @@ -21,10 +21,9 @@ import { } from "@posthog/quill"; import { formatRelativeTimeShort } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { FreeformPreview } from "@posthog/ui/features/canvas/components/FreeformPreview"; import { NewCanvasMenu } from "@posthog/ui/features/canvas/components/NewCanvasMenu"; import { deleteCanvasWithUndo } from "@posthog/ui/features/canvas/deleteCanvasWithUndo"; -import { FreeformCanvas } from "@posthog/ui/features/canvas/freeform/FreeformCanvas"; -import { handleFreeformDataRequest } from "@posthog/ui/features/canvas/freeform/freeformDataBridge"; import { useCanvasTemplates } from "@posthog/ui/features/canvas/hooks/useCanvasTemplates"; import { useDashboardMutations, @@ -32,25 +31,10 @@ import { } from "@posthog/ui/features/canvas/hooks/useDashboards"; import { useIsCanvasPendingDelete } from "@posthog/ui/features/canvas/stores/pendingCanvasDeleteStore"; import { copyCanvasLink } from "@posthog/ui/features/canvas/utils/copyCanvasLink"; -import { useInView } from "@posthog/ui/primitives/hooks/useInView"; import { track } from "@posthog/ui/shell/analytics"; -import { ErrorBoundary } from "@posthog/ui/shell/ErrorBoundary"; import { Box, Flex, Grid } from "@radix-ui/themes"; -import { useQueryClient } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; -import { memo, useCallback, useState } from "react"; - -// Render each canvas's live app at 1/SCALE of the card width, then shrink so it -// fits inside the fixed-height preview frame as a thumbnail. -const PREVIEW_SCALE = 0.4; - -// Mount a preview only while it's near the viewport, and UNMOUNT it once it -// scrolls away (once: false). This caps how many full preview trees / sandbox -// iframes are live at any time, so a channel with many large canvases doesn't -// accumulate pages of off-screen DOM. The margin pre-mounts a little early so -// scrolling doesn't flash an empty frame. The fixed-height frame keeps the -// layout stable across mount/unmount (no scroll jump). -const PREVIEW_VIEWPORT = { once: false, rootMargin: "400px 0px" } as const; +import { memo, useState } from "react"; // A channel's dashboards index: a grid of cards, each showing a scaled-down // live preview. Clicking a card opens the full dashboard. @@ -144,7 +128,10 @@ const DashboardCard = memo(function DashboardCard({ > - + {deleting && ( (PREVIEW_VIEWPORT); - - // Preview data handler: swallow captures so a thumbnail never emits analytics - // events, but let reads through (cached, shared with the full view) so the - // preview shows real-ish content. (posthog-js itself is never booted — no - // `analytics` prop — so there's no autocapture/pageview/replay either.) - const queryClient = useQueryClient(); - const onDataRequest = useCallback( - (method: string, payload: unknown) => - method === "capture" - ? Promise.resolve({ ok: true }) - : handleFreeformDataRequest(method, payload, queryClient), - [queryClient], - ); - - return ( - - {code ? ( - inView ? ( - - } - > - - - - ) : ( - - ) - ) : ( - - )} - - ); -} - function DashboardCardMenu({ id, name, @@ -339,17 +268,3 @@ function DashboardCardMenu({ ); } - -function PreviewPlaceholder({ label }: { label: string }) { - return ( - - - {label} - - - ); -} diff --git a/packages/ui/src/features/canvas/stores/artifactsViewStore.ts b/packages/ui/src/features/canvas/stores/artifactsViewStore.ts new file mode 100644 index 0000000000..939420cf82 --- /dev/null +++ b/packages/ui/src/features/canvas/stores/artifactsViewStore.ts @@ -0,0 +1,42 @@ +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { track } from "@posthog/ui/shell/analytics"; +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +// How a space's artifacts are laid out. "list" is the dense row list, "grid" a +// uniform card grid with live canvas previews, "masonry" the same cards in +// staggered columns so previews get varied vertical room. +export type ArtifactsViewMode = "list" | "grid" | "masonry"; + +export const ARTIFACTS_VIEW_MODES: ArtifactsViewMode[] = [ + "list", + "grid", + "masonry", +]; + +interface ArtifactsViewStore { + view: ArtifactsViewMode; + setView: (view: ArtifactsViewMode, channelId?: string) => void; +} + +// Per-device preference, not per-space: picking masonry once should hold as you +// move between spaces, the way a file browser's view setting does. +export const useArtifactsViewStore = create()( + persist( + (set) => ({ + view: "list", + setView: (view, channelId) => + set((state) => { + if (state.view === view) return state; + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "artifacts_view_change", + surface: "channel_artifacts", + channel_id: channelId, + view_mode: view, + }); + return { view }; + }), + }), + { name: "artifacts-view-storage" }, + ), +); diff --git a/packages/ui/src/features/canvas/utils/masonryPreviewHeight.test.ts b/packages/ui/src/features/canvas/utils/masonryPreviewHeight.test.ts new file mode 100644 index 0000000000..f7a4ed61f2 --- /dev/null +++ b/packages/ui/src/features/canvas/utils/masonryPreviewHeight.test.ts @@ -0,0 +1,21 @@ +import { masonryPreviewHeight } from "@posthog/ui/features/canvas/utils/masonryPreviewHeight"; +import { describe, expect, it } from "vitest"; + +describe("masonryPreviewHeight", () => { + it("is stable for a key", () => { + expect(masonryPreviewHeight("canvas:abc")).toBe( + masonryPreviewHeight("canvas:abc"), + ); + }); + + it("stays inside the bucket set", () => { + const keys = Array.from({ length: 50 }, (_, i) => `canvas:${i}`); + const heights = new Set(keys.map(masonryPreviewHeight)); + expect([...heights].every((h) => [168, 224, 288].includes(h))).toBe(true); + }); + + it("staggers across keys so masonry has something to stagger", () => { + const keys = Array.from({ length: 50 }, (_, i) => `canvas:${i}`); + expect(new Set(keys.map(masonryPreviewHeight)).size).toBeGreaterThan(1); + }); +}); diff --git a/packages/ui/src/features/canvas/utils/masonryPreviewHeight.ts b/packages/ui/src/features/canvas/utils/masonryPreviewHeight.ts new file mode 100644 index 0000000000..3a53214138 --- /dev/null +++ b/packages/ui/src/features/canvas/utils/masonryPreviewHeight.ts @@ -0,0 +1,13 @@ +// Masonry needs cards of differing height or it degrades into a ragged grid. +// Canvas previews have no intrinsic height to measure (the app is rendered into +// a clipped, scaled frame), so each card gets a stable height picked from its +// key — same canvas, same height across renders and reloads, no layout churn. +const MASONRY_HEIGHTS = [168, 224, 288] as const; + +export function masonryPreviewHeight(key: string): number { + let hash = 0; + for (let i = 0; i < key.length; i++) { + hash = (hash * 31 + key.charCodeAt(i)) >>> 0; + } + return MASONRY_HEIGHTS[hash % MASONRY_HEIGHTS.length]; +} From b69d17d464d63c87e1b054625fbd1fd4b16cc9f6 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 24 Jul 2026 19:18:07 +0300 Subject: [PATCH 09/43] refactor(api-client): extract cloud task transport Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- packages/api-client/package.json | 1 - packages/api-client/src/fetcher.test.ts | 39 ++ packages/api-client/src/fetcher.ts | 25 +- packages/api-client/src/index.ts | 6 +- .../src/posthog-client.automations.test.ts | 192 +++++++++ .../api-client/src/posthog-client.test.ts | 349 ++++++++++++++++- packages/api-client/src/posthog-client.ts | 368 +++++++++++++++--- .../api-client/src/task-normalization.test.ts | 115 ++++++ packages/api-client/src/task-normalization.ts | 203 ++++++++++ pnpm-lock.yaml | 12 +- 10 files changed, 1244 insertions(+), 66 deletions(-) create mode 100644 packages/api-client/src/posthog-client.automations.test.ts create mode 100644 packages/api-client/src/task-normalization.test.ts create mode 100644 packages/api-client/src/task-normalization.ts diff --git a/packages/api-client/package.json b/packages/api-client/package.json index bee0ebc547..3a592020a4 100644 --- a/packages/api-client/package.json +++ b/packages/api-client/package.json @@ -25,7 +25,6 @@ "src/**/*" ], "dependencies": { - "@posthog/agent": "workspace:*", "@posthog/shared": "workspace:*" } } diff --git a/packages/api-client/src/fetcher.test.ts b/packages/api-client/src/fetcher.test.ts index c205949418..6e1e17a351 100644 --- a/packages/api-client/src/fetcher.test.ts +++ b/packages/api-client/src/fetcher.test.ts @@ -53,6 +53,45 @@ describe("buildApiFetcher", () => { expect(mockFetch.mock.calls[0][1].headers.get("Authorization")).toBe( "Bearer my-token", ); + expect(mockFetch.mock.calls[0][1].headers.get("User-Agent")).toBe( + "posthog/desktop.hog.dev; version: test", + ); + }); + + it("uses an injected fetch implementation and custom user agent", async () => { + const injectedFetch = vi.fn().mockResolvedValueOnce(ok()); + const fetcher = buildApiFetcher({ + getAccessToken: vi.fn().mockResolvedValue("token"), + refreshAccessToken: vi.fn().mockResolvedValue("new-token"), + appVersion: "1.2.3", + fetch: injectedFetch, + userAgent: "posthog/mobile; version: 1.2.3", + }); + + await fetcher.fetch(mockInput); + + expect(injectedFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).not.toHaveBeenCalled(); + expect(injectedFetch.mock.calls[0][1].headers.get("User-Agent")).toBe( + "posthog/mobile; version: 1.2.3", + ); + }); + + it("omits the user agent when explicitly disabled", async () => { + const injectedFetch = vi.fn().mockResolvedValueOnce(ok()); + const fetcher = buildApiFetcher({ + getAccessToken: vi.fn().mockResolvedValue("token"), + refreshAccessToken: vi.fn().mockResolvedValue("new-token"), + appVersion: "1.2.3", + fetch: injectedFetch, + userAgent: null, + }); + + await fetcher.fetch(mockInput); + + expect(injectedFetch.mock.calls[0][1].headers.has("User-Agent")).toBe( + false, + ); }); it("retries once with a freshly fetched token on 401", async () => { diff --git a/packages/api-client/src/fetcher.ts b/packages/api-client/src/fetcher.ts index 6bf59aa9f8..bc061a3030 100644 --- a/packages/api-client/src/fetcher.ts +++ b/packages/api-client/src/fetcher.ts @@ -1,9 +1,16 @@ import type { createApiClient } from "./generated"; +export type FetchImplementation = ( + input: string | URL | Request, + init?: RequestInit, +) => Promise; + export type ApiFetcherConfig = { getAccessToken: () => Promise; refreshAccessToken: () => Promise; appVersion: string; + fetch?: FetchImplementation; + userAgent?: string | null; }; /** @@ -13,11 +20,13 @@ export type ApiFetcherConfig = { */ export class ApiRequestError extends Error { readonly status: number; + readonly body: unknown; - constructor(status: number, serializedBody: string) { + constructor(status: number, serializedBody: string, body?: unknown) { super(`Failed request: [${status}] ${serializedBody}`); this.name = "ApiRequestError"; this.status = status; + this.body = body; } } @@ -29,7 +38,11 @@ export function requestErrorStatus(error: unknown): number | undefined { export const buildApiFetcher: ( config: ApiFetcherConfig, ) => Parameters[0] = (config) => { - const userAgent = `posthog/desktop.hog.dev; version: ${config.appVersion}`; + const fetchImpl = config.fetch ?? globalThis.fetch; + const userAgent = + config.userAgent === undefined + ? `posthog/desktop.hog.dev; version: ${config.appVersion}` + : config.userAgent; const makeRequest = async ( input: Parameters[0]["fetch"]>[0], @@ -38,7 +51,9 @@ export const buildApiFetcher: ( const headers = new Headers(); headers.set("Authorization", `Bearer ${token}`); headers.set("Content-Type", "application/json"); - headers.set("User-Agent", userAgent); + if (userAgent) { + headers.set("User-Agent", userAgent); + } if (input.urlSearchParams) { input.url.search = input.urlSearchParams.toString(); @@ -59,7 +74,7 @@ export const buildApiFetcher: ( } try { - const response = await fetch(input.url, { + const response = await fetchImpl(input.url, { method: input.method.toUpperCase(), ...(body && { body }), headers, @@ -114,6 +129,7 @@ export const buildApiFetcher: ( throw new ApiRequestError( response.status, JSON.stringify(errorResponse), + errorResponse, ); } } @@ -128,6 +144,7 @@ export const buildApiFetcher: ( throw new ApiRequestError( response.status, JSON.stringify(errorResponse), + errorResponse, ); } diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index e6b7c3c639..a18d6ff4b0 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -1,6 +1,10 @@ import "./generated.augment"; -export { type ApiFetcherConfig, buildApiFetcher } from "./fetcher"; +export { + type ApiFetcherConfig, + buildApiFetcher, + type FetchImplementation, +} from "./fetcher"; export { createApiClient, type Schemas } from "./generated"; export { createLoop, diff --git a/packages/api-client/src/posthog-client.automations.test.ts b/packages/api-client/src/posthog-client.automations.test.ts new file mode 100644 index 0000000000..3d3386ef3a --- /dev/null +++ b/packages/api-client/src/posthog-client.automations.test.ts @@ -0,0 +1,192 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + PostHogAPIClient, + TaskAutomationValidationError, +} from "./posthog-client"; + +const automationPayload = { + id: "automation-1", + name: "Daily PRs", + prompt: "Check PRs", + repository: "posthog/posthog", + github_integration: 7, + cron_expression: "0 9 * * *", + timezone: "Europe/London", + template_id: "llm-skill:daily-prs", + enabled: true, + last_run_at: null, + last_run_status: null, + last_task_id: null, + last_task_run_id: null, + last_error: null, + created_at: "2026-07-21T00:00:00Z", + updated_at: "2026-07-21T00:00:00Z", +}; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("PostHogAPIClient task automations", () => { + const fetch = vi.fn(); + const client = new PostHogAPIClient( + "https://app.posthog.test", + async () => "access-token", + async () => "refreshed-token", + 42, + { appVersion: "test", fetch }, + ); + + beforeEach(() => { + fetch.mockReset(); + }); + + it("lists automations and normalizes optional response fields", async () => { + const minimalPayload = { + ...automationPayload, + github_integration: undefined, + timezone: undefined, + template_id: undefined, + enabled: undefined, + }; + fetch.mockResolvedValueOnce( + jsonResponse({ + count: 1, + next: null, + previous: null, + results: [minimalPayload], + }), + ); + + await expect(client.listTaskAutomations()).resolves.toEqual([ + expect.objectContaining({ + id: "automation-1", + github_integration: null, + timezone: null, + template_id: null, + enabled: true, + }), + ]); + expect(fetch).toHaveBeenCalledWith( + new URL( + "https://app.posthog.test/api/projects/42/task_automations/?limit=500", + ), + expect.objectContaining({ method: "GET" }), + ); + }); + + it("gets and creates automations through generated endpoints", async () => { + fetch + .mockResolvedValueOnce(jsonResponse(automationPayload)) + .mockResolvedValueOnce(jsonResponse(automationPayload, 201)); + + await expect(client.getTaskAutomation("automation-1")).resolves.toEqual( + automationPayload, + ); + await expect( + client.createTaskAutomation({ + name: "Daily PRs", + prompt: "Check PRs", + repository: "posthog/posthog", + github_integration: 7, + cron_expression: "0 9 * * *", + timezone: "Europe/London", + template_id: "llm-skill:daily-prs", + enabled: true, + }), + ).resolves.toEqual(automationPayload); + + expect(fetch).toHaveBeenNthCalledWith( + 2, + new URL("https://app.posthog.test/api/projects/42/task_automations/"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + name: "Daily PRs", + prompt: "Check PRs", + repository: "posthog/posthog", + github_integration: 7, + cron_expression: "0 9 * * *", + timezone: "Europe/London", + template_id: "llm-skill:daily-prs", + enabled: true, + }), + }), + ); + }); + + it("updates, deletes, and runs automations", async () => { + fetch + .mockResolvedValueOnce( + jsonResponse({ ...automationPayload, enabled: false }), + ) + .mockResolvedValueOnce(new Response(null, { status: 204 })) + .mockResolvedValueOnce(jsonResponse(automationPayload)); + + await expect( + client.updateTaskAutomation("automation-1", { enabled: false }), + ).resolves.toMatchObject({ enabled: false }); + await expect( + client.deleteTaskAutomation("automation-1"), + ).resolves.toBeUndefined(); + await expect(client.runTaskAutomation("automation-1")).resolves.toEqual( + automationPayload, + ); + + expect(fetch).toHaveBeenNthCalledWith( + 1, + new URL( + "https://app.posthog.test/api/projects/42/task_automations/automation-1/", + ), + expect.objectContaining({ + method: "PATCH", + body: JSON.stringify({ enabled: false }), + }), + ); + expect(fetch).toHaveBeenNthCalledWith( + 3, + new URL( + "https://app.posthog.test/api/projects/42/task_automations/automation-1/run/", + ), + expect.objectContaining({ method: "POST" }), + ); + expect(fetch.mock.calls[2]?.[1]?.body).toBeUndefined(); + }); + + it("preserves validation detail, code, and field attribution", async () => { + fetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + type: "validation_error", + code: "invalid_input", + detail: "Enter a valid cron expression.", + attr: "cron_expression", + }), + { + status: 400, + statusText: "Bad Request", + headers: { "Content-Type": "application/json" }, + }, + ), + ); + + const request = client.createTaskAutomation({ + name: "Daily PRs", + prompt: "Check PRs", + repository: "posthog/posthog", + cron_expression: "not a cron", + timezone: "Europe/London", + }); + + await expect(request).rejects.toBeInstanceOf(TaskAutomationValidationError); + await expect(request).rejects.toMatchObject({ + status: 400, + code: "invalid_input", + attr: "cron_expression", + message: "Enter a valid cron expression.", + }); + }); +}); diff --git a/packages/api-client/src/posthog-client.test.ts b/packages/api-client/src/posthog-client.test.ts index 42a36681cb..da93bc7960 100644 --- a/packages/api-client/src/posthog-client.test.ts +++ b/packages/api-client/src/posthog-client.test.ts @@ -1,8 +1,337 @@ import { describe, expect, it, vi } from "vitest"; import { ApiRequestError } from "./fetcher"; -import { PostHogAPIClient } from "./posthog-client"; +import { CloudCommandError, PostHogAPIClient } from "./posthog-client"; describe("PostHogAPIClient", () => { + it.each([ + "user_message", + "permission_response", + "set_config_option", + "cancel", + ] as const)("sends the %s cloud run command", async (method) => { + const fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ result: { accepted: true } }), { + status: 200, + }), + ); + const client = new PostHogAPIClient( + "https://app.posthog.test", + async () => "token", + async () => "token", + 42, + { fetch }, + ); + + await expect( + client.sendCloudRunCommand("task-1", "run-1", method, { + value: "payload", + }), + ).resolves.toEqual({ accepted: true }); + + expect(fetch).toHaveBeenCalledWith( + new URL( + "https://app.posthog.test/api/projects/42/tasks/task-1/runs/run-1/command/", + ), + expect.objectContaining({ + method: "POST", + body: expect.any(String), + }), + ); + const request = fetch.mock.calls[0][1] as RequestInit; + expect(JSON.parse(request.body as string)).toMatchObject({ + jsonrpc: "2.0", + method, + params: { value: "payload" }, + }); + }); + + it("throws structured cloud command errors for HTTP failures", async () => { + const fetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ error: "No active sandbox for this run" }), + { + status: 409, + statusText: "Conflict", + }, + ), + ); + const client = new PostHogAPIClient( + "https://app.posthog.test", + async () => "token", + async () => "token", + 42, + { fetch }, + ); + + const error = await client + .sendCloudRunCommand("task-1", "run-1", "user_message") + .catch((caught: unknown) => caught); + + expect(error).toMatchObject({ + name: "CloudCommandError", + method: "user_message", + status: 409, + backendError: "No active sandbox for this run", + }); + expect(error).toBeInstanceOf(CloudCommandError); + expect((error as CloudCommandError).isSandboxInactive()).toBe(true); + }); + + it("throws structured cloud command errors for JSON-RPC failures", async () => { + const fetch = vi + .fn() + .mockResolvedValue( + new Response( + JSON.stringify({ error: { message: "Permission request expired" } }), + { status: 200 }, + ), + ); + const client = new PostHogAPIClient( + "https://app.posthog.test", + async () => "token", + async () => "token", + 42, + { fetch }, + ); + + await expect( + client.sendCloudRunCommand("task-1", "run-1", "permission_response"), + ).rejects.toMatchObject({ + method: "permission_response", + status: 200, + backendError: "Permission request expired", + }); + }); + + it("preserves the legacy sendRunCommand result contract", async () => { + const fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: "Run is unavailable" }), { + status: 503, + }), + ); + const client = new PostHogAPIClient( + "https://app.posthog.test", + async () => "token", + async () => "token", + 42, + { fetch }, + ); + + await expect( + client.sendRunCommand("task-1", "run-1", "set_config_option"), + ).resolves.toEqual({ + success: false, + error: "Cloud command 'set_config_option' failed: 503 Run is unavailable", + }); + }); + + it("cancels a cloud task run with an optional reason", async () => { + const fetch = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ status: "cancelled" }), { status: 200 }), + ); + const client = new PostHogAPIClient( + "https://app.posthog.test", + async () => "token", + async () => "token", + 42, + { fetch }, + ); + + await expect( + client.cancelTaskRun("task-1", "run-1", "user requested"), + ).resolves.toEqual({ status: "cancelled" }); + + expect(fetch).toHaveBeenCalledWith( + new URL( + "https://app.posthog.test/api/projects/42/tasks/task-1/runs/run-1/cancel/", + ), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ reason: "user requested" }), + }), + ); + }); + + it("cancels a cloud task run with an empty body by default", async () => { + const fetch = vi + .fn() + .mockResolvedValue(new Response(null, { status: 204 })); + const client = new PostHogAPIClient( + "https://app.posthog.test", + async () => "token", + async () => "token", + 42, + { fetch }, + ); + + await expect(client.cancelTaskRun("task-1", "run-1")).resolves.toEqual({}); + + const request = fetch.mock.calls[0][1] as RequestInit; + expect(request.body).toBe(JSON.stringify({})); + }); + + it("builds cloud task config from the authenticated gateway catalog", async () => { + const fetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + data: [ + { + id: "claude-opus-4-8", + owned_by: "anthropic", + context_window: 200000, + supports_streaming: true, + supports_vision: true, + allowed: true, + }, + { + id: "claude-fable-5", + owned_by: "anthropic", + context_window: 200000, + supports_streaming: true, + supports_vision: true, + allowed: false, + }, + ], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + const client = new PostHogAPIClient( + "https://eu.posthog.com", + async () => "token", + async () => "token", + 123, + { fetch }, + ); + + const options = await client.getCloudTaskConfigOptions("claude"); + + expect(fetch).toHaveBeenCalledWith( + new URL("https://gateway.eu.posthog.com/posthog_code/v1/models"), + expect.objectContaining({ method: "GET" }), + ); + expect(options.find((option) => option.category === "model")).toMatchObject( + { + currentValue: "claude-opus-4-8", + options: [ + expect.objectContaining({ value: "claude-opus-4-8" }), + expect.objectContaining({ + value: "claude-fable-5", + _meta: expect.any(Object), + }), + ], + }, + ); + }); + + it("uses the configured fetch implementation for task log URLs", async () => { + const fetch = vi + .fn() + .mockResolvedValue( + new Response( + '{"type":"notification","timestamp":"2026-07-21T00:00:00Z"}\n', + { status: 200 }, + ), + ); + const client = new PostHogAPIClient( + "http://localhost:8000", + async () => "token", + async () => "token", + 123, + { fetch }, + ); + vi.spyOn(client, "getTask").mockResolvedValue({ + id: "task-1", + task_number: 1, + slug: "task-1", + title: "Task", + description: "Task", + created_at: "2026-07-21T00:00:00Z", + updated_at: "2026-07-21T00:00:00Z", + origin_product: "user_created", + latest_run: { + id: "run-1", + task: "task-1", + team: 123, + branch: null, + status: "in_progress", + log_url: "https://logs.posthog.test/run-1.jsonl", + error_message: null, + output: null, + state: {}, + created_at: "2026-07-21T00:00:00Z", + updated_at: "2026-07-21T00:00:00Z", + completed_at: null, + }, + }); + + await expect(client.getTaskLogs("task-1")).resolves.toHaveLength(1); + expect(fetch).toHaveBeenCalledWith("https://logs.posthog.test/run-1.jsonl"); + }); + + it.each([ + { + label: "desktop default", + options: undefined, + expectedConnectFrom: "posthog_code", + expectedUserAgent: "posthog/desktop.hog.dev; version: unknown", + }, + { + label: "mobile configuration", + options: { + appVersion: "1.2.3", + userAgent: "posthog/mobile; version: 1.2.3", + githubConnectFrom: "posthog_mobile", + }, + expectedConnectFrom: "posthog_mobile", + expectedUserAgent: "posthog/mobile; version: 1.2.3", + }, + ])( + "uses $label identity for GitHub connections", + async ({ options, expectedConnectFrom, expectedUserAgent }) => { + const fetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ install_url: "https://github.com/login/oauth" }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + ); + const client = new PostHogAPIClient( + "http://localhost:8000", + async () => "token", + async () => "token", + 123, + { ...options, fetch }, + ); + + await expect(client.startGithubUserIntegrationConnect()).resolves.toEqual( + { + install_url: "https://github.com/login/oauth", + }, + ); + + expect(fetch).toHaveBeenCalledWith( + new URL( + "http://localhost:8000/api/users/@me/integrations/github/start/", + ), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + team_id: 123, + connect_from: expectedConnectFrom, + }), + }), + ); + expect(fetch.mock.calls[0][1].headers.get("User-Agent")).toBe( + expectedUserAgent, + ); + }, + ); + it("sends supported reasoning effort for cloud Codex runs", async () => { const client = new PostHogAPIClient( "http://localhost:8000", @@ -257,7 +586,13 @@ describe("PostHogAPIClient", () => { reasoningLevel: "high", initialPermissionMode: "auto", }), - ).resolves.toEqual({ id: "run-123", environment: "cloud" }); + ).resolves.toMatchObject({ + id: "run-123", + task: "task-123", + team: 123, + environment: "cloud", + status: "not_started", + }); expect(fetch).toHaveBeenCalledWith( expect.objectContaining({ @@ -435,7 +770,15 @@ describe("PostHogAPIClient", () => { pendingUserMessage: "Read the attached file first", pendingUserArtifactIds: ["artifact-1"], }), - ).resolves.toEqual({ id: "task-123", latest_run: { id: "run-123" } }); + ).resolves.toMatchObject({ + id: "task-123", + latest_run: { + id: "run-123", + task: "task-123", + team: 123, + status: "not_started", + }, + }); expect(fetch).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 906e77f290..b2ef9fa8a8 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -4,18 +4,30 @@ import type { CloudMcpServerImport, CloudMcpServerRelayDesignation, CloudRunSource, + CreateTaskAutomationOptions, ExecutionMode, PrAuthorshipMode, SourceProduct, SourceType, StoredLogEntry, + TaskAutomation, TaskRunArtifactMetadata, + UpdateTaskAutomationOptions, } from "@posthog/shared"; import { + buildCloudTaskConfigOptions, + type CloudTaskConfigOption, + createTaskAutomationSchema, DISMISSAL_REASON_OPTIONS, type DismissalReasonOptionValue, + getCloudTaskGatewayUrl, isSupportedReasoningEffort, + normalizeGatewayModelsResponse, resolveCloudInitialPermissionMode, + taskAutomationListSchema, + taskAutomationSchema, + taskAutomationValidationErrorSchema, + updateTaskAutomationSchema, } from "@posthog/shared"; import type { AgentAnalyticsData, @@ -101,9 +113,18 @@ import { type HogQLGrid, shapeAgentAnalytics, } from "./agent-analytics"; -import { buildApiFetcher, requestErrorStatus } from "./fetcher"; +import { + ApiRequestError, + buildApiFetcher, + type FetchImplementation, + requestErrorStatus, +} from "./fetcher"; import { createApiClient, type Schemas } from "./generated"; import type { SpendAnalysisResponse } from "./spend-analysis"; +import { + normalizeTaskResponse, + normalizeTaskRunResponse, +} from "./task-normalization"; export interface ApiClientLogger { warn(...args: unknown[]): void; } @@ -122,6 +143,13 @@ export function setPosthogApiClientAppVersion(version: string): void { clientAppVersion = version; } +export interface PostHogAPIClientOptions { + fetch?: FetchImplementation; + appVersion?: string; + userAgent?: string | null; + githubConnectFrom?: string; +} + export function getPosthogApiClientAppVersion(): string { return clientAppVersion; } @@ -163,6 +191,36 @@ export class CloudUsageLimitError extends Error { } } +export class TaskAutomationValidationError extends Error { + readonly status = 400; + readonly code: string; + readonly attr: string | null; + + constructor(details: { + detail: string; + code: string; + attr: string | null; + }) { + super(details.detail); + this.name = "TaskAutomationValidationError"; + this.code = details.code; + this.attr = details.attr; + } +} + +function rethrowTaskAutomationError(error: unknown): never { + if (error instanceof ApiRequestError && error.status === 400) { + const validationError = taskAutomationValidationErrorSchema.safeParse( + error.body, + ); + if (validationError.success) { + throw new TaskAutomationValidationError(validationError.data); + } + } + + throw error; +} + export const MCP_CATEGORIES = [ { id: "all", label: "All" }, { id: "business", label: "Business Operations" }, @@ -581,7 +639,7 @@ export interface FinalizedTaskArtifactUpload { uploaded_at?: string; } -interface CloudRunOptions { +export interface CloudRunOptions { adapter?: Adapter; model?: string; reasoningLevel?: string; @@ -602,6 +660,56 @@ interface CloudRunOptions { relayedMcpServers?: CloudMcpServerRelayDesignation[]; } +export type CloudRunCommandMethod = + | "user_message" + | "permission_response" + | "set_config_option" + | "cancel" + | "close"; + +export class CloudCommandError extends Error { + readonly status: number; + readonly backendError: string | null; + readonly method: CloudRunCommandMethod; + + constructor( + method: CloudRunCommandMethod, + status: number, + backendError: string | null, + message: string, + ) { + super(message); + this.name = "CloudCommandError"; + this.method = method; + this.status = status; + this.backendError = backendError; + } + + isSandboxInactive(): boolean { + const backendError = this.backendError?.toLowerCase(); + return ( + this.status === 404 || + backendError?.includes("no active sandbox") === true || + backendError?.includes("returned 404") === true + ); + } +} + +function cloudCommandBackendError(payload: unknown): string | null { + if (typeof payload === "string") return payload || null; + if (!payload || typeof payload !== "object") return null; + + const error = "error" in payload ? payload.error : null; + if (typeof error === "string") return error || null; + if (error && typeof error === "object" && "message" in error) { + return typeof error.message === "string" ? error.message : null; + } + if ("message" in payload && typeof payload.message === "string") { + return payload.message; + } + return null; +} + interface CreateTaskRunOptions extends CloudRunOptions { environment?: "local" | "cloud"; mode?: "interactive" | "background"; @@ -1342,19 +1450,28 @@ function previewTokenHeader( export class PostHogAPIClient { private api: ReturnType; private _teamId: number | null = null; + private githubConnectFrom: string; + private readonly fetch: FetchImplementation; + private readonly apiHost: string; constructor( apiHost: string, getAccessToken: () => Promise, refreshAccessToken: () => Promise, teamId?: number, + options: PostHogAPIClientOptions = {}, ) { const baseUrl = apiHost.endsWith("/") ? apiHost.slice(0, -1) : apiHost; + this.apiHost = baseUrl; + this.githubConnectFrom = options.githubConnectFrom ?? "posthog_code"; + this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis); this.api = createApiClient( buildApiFetcher({ getAccessToken, refreshAccessToken, - appVersion: clientAppVersion, + appVersion: options.appVersion ?? clientAppVersion, + fetch: options.fetch, + userAgent: options.userAgent, }), baseUrl, ); @@ -1391,6 +1508,21 @@ export class PostHogAPIClient { return data; } + async getCloudTaskConfigOptions( + adapter: Adapter = "claude", + ): Promise { + const url = new URL(`${getCloudTaskGatewayUrl(this.apiHost)}/v1/models`); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: url.pathname, + }); + return buildCloudTaskConfigOptions( + normalizeGatewayModelsResponse(await response.json()), + adapter, + ); + } + // Desktop file system — the backend surface that backs canvas channels // (top-level folders) and dashboards. These routes aren't in the generated // OpenAPI client, so we use the raw fetcher. @@ -1755,7 +1887,10 @@ export class PostHogAPIClient { url, path: urlPath, overrides: { - body: JSON.stringify({ team_id: id, connect_from: "posthog_code" }), + body: JSON.stringify({ + team_id: id, + connect_from: this.githubConnectFrom, + }), }, }); if (!response.ok) { @@ -2257,7 +2392,7 @@ export class PostHogAPIClient { originProduct?: string; internal?: boolean; channel?: string; - }) { + }): Promise { const teamId = await this.getTeamId(); const params: Record = { limit: 500, @@ -2288,7 +2423,9 @@ export class PostHogAPIClient { query: params, }); - return data.results ?? []; + return (data.results ?? []).map((task) => + normalizeTaskResponse(task, { teamId }), + ); } async getTaskSummaries(ids: string[]) { @@ -2330,7 +2467,102 @@ export class PostHogAPIClient { const data = await this.api.get(`/api/projects/{project_id}/tasks/{id}/`, { path: { project_id: teamId.toString(), id: taskId }, }); - return data as unknown as Task; + return normalizeTaskResponse(data, { teamId }); + } + + async listTaskAutomations(options?: { + limit?: number; + offset?: number; + }): Promise { + const teamId = await this.getTeamId(); + const data = await this.api.get( + `/api/projects/{project_id}/task_automations/`, + { + path: { project_id: teamId.toString() }, + query: { + limit: options?.limit ?? 500, + ...(options?.offset === undefined ? {} : { offset: options.offset }), + }, + }, + ); + + return taskAutomationListSchema.parse(data).results; + } + + async getTaskAutomation(automationId: string): Promise { + const teamId = await this.getTeamId(); + const data = await this.api.get( + `/api/projects/{project_id}/task_automations/{id}/`, + { + path: { project_id: teamId.toString(), id: automationId }, + }, + ); + + return taskAutomationSchema.parse(data); + } + + async createTaskAutomation( + options: CreateTaskAutomationOptions, + ): Promise { + const teamId = await this.getTeamId(); + const body = createTaskAutomationSchema.parse(options); + + try { + const data = await this.api.post( + `/api/projects/{project_id}/task_automations/`, + { + path: { project_id: teamId.toString() }, + body: body as Schemas.TaskAutomation, + }, + ); + return taskAutomationSchema.parse(data); + } catch (error) { + rethrowTaskAutomationError(error); + } + } + + async updateTaskAutomation( + automationId: string, + updates: UpdateTaskAutomationOptions, + ): Promise { + const teamId = await this.getTeamId(); + const body = updateTaskAutomationSchema.parse(updates); + + try { + const data = await this.api.patch( + `/api/projects/{project_id}/task_automations/{id}/`, + { + path: { project_id: teamId.toString(), id: automationId }, + body, + }, + ); + return taskAutomationSchema.parse(data); + } catch (error) { + rethrowTaskAutomationError(error); + } + } + + async deleteTaskAutomation(automationId: string): Promise { + const teamId = await this.getTeamId(); + await this.api.delete(`/api/projects/{project_id}/task_automations/{id}/`, { + path: { project_id: teamId.toString(), id: automationId }, + }); + } + + async runTaskAutomation(automationId: string): Promise { + const teamId = await this.getTeamId(); + const path = `/api/projects/${teamId}/task_automations/${automationId}/run/`; + + try { + const response = await this.api.fetcher.fetch({ + method: "post", + path, + url: new URL(`${this.api.baseUrl}${path}`), + }); + return taskAutomationSchema.parse(await response.json()); + } catch (error) { + rethrowTaskAutomationError(error); + } } async createTask( @@ -2357,7 +2589,7 @@ export class PostHogAPIClient { pending_user_artifact_ids?: string[]; auto_publish?: boolean; }, - ) { + ): Promise { const teamId = await this.getTeamId(); const { origin_product: originProduct, ...taskOptions } = options; @@ -2369,10 +2601,13 @@ export class PostHogAPIClient { } as unknown as Schemas.Task, }); - return data; + return normalizeTaskResponse(data, { teamId }); } - async updateTask(taskId: string, updates: Partial) { + async updateTask( + taskId: string, + updates: Partial, + ): Promise { const teamId = await this.getTeamId(); const data = await this.api.patch( `/api/projects/{project_id}/tasks/{id}/`, @@ -2382,7 +2617,7 @@ export class PostHogAPIClient { }, ); - return data; + return normalizeTaskResponse(data, { teamId }); } async deleteTask(taskId: string) { @@ -2681,9 +2916,28 @@ export class PostHogAPIClient { async sendRunCommand( taskId: string, runId: string, - method: "user_message" | "cancel" | "close", + method: CloudRunCommandMethod, params?: Record, ): Promise<{ success: boolean; result?: unknown; error?: string }> { + try { + return { + success: true, + result: await this.sendCloudRunCommand(taskId, runId, method, params), + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } + } + + async sendCloudRunCommand( + taskId: string, + runId: string, + method: CloudRunCommandMethod, + params: Record = {}, + ): Promise { const teamId = await this.getTeamId(); const url = new URL( `${this.api.baseUrl}/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/command/`, @@ -2691,7 +2945,7 @@ export class PostHogAPIClient { const body = { jsonrpc: "2.0", method, - params: params ?? {}, + params, id: `posthog-code-${Date.now()}`, }; @@ -2705,39 +2959,54 @@ export class PostHogAPIClient { }, }); - if (!response.ok) { - const errorText = await response.text().catch(() => ""); - let errorMessage = `Command failed: ${response.statusText}`; - try { - const errorJson = JSON.parse(errorText); - errorMessage = - errorJson.error?.message ?? errorJson.error ?? errorMessage; - } catch { - if (errorText) errorMessage = errorText; - } - return { success: false, error: errorMessage }; - } - const data = (await response.json()) as { - error?: { message?: string }; + error?: unknown; result?: unknown; }; if (data.error) { - return { - success: false, - error: data.error.message ?? JSON.stringify(data.error), - }; + const backendError = cloudCommandBackendError(data); + throw new CloudCommandError( + method, + response.status, + backendError, + `Cloud command '${method}' error: ${backendError ?? JSON.stringify(data.error)}`, + ); } - return { success: true, result: data.result }; + return data.result; } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : "Unknown error", - }; + if (error instanceof CloudCommandError) throw error; + if (error instanceof ApiRequestError) { + const backendError = cloudCommandBackendError(error.body); + throw new CloudCommandError( + method, + error.status, + backendError, + `Cloud command '${method}' failed: ${error.status}${backendError ? ` ${backendError}` : ""}`, + ); + } + throw error; } } + async cancelTaskRun( + taskId: string, + runId: string, + reason?: string, + ): Promise<{ status?: string }> { + const teamId = await this.getTeamId(); + const path = `/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/cancel/`; + const response = await this.api.fetcher.fetch({ + method: "post", + url: new URL(`${this.api.baseUrl}${path}`), + path, + overrides: { + body: JSON.stringify(reason ? { reason } : {}), + }, + }); + return (await response.json().catch(() => ({}))) as { status?: string }; + } + async runTaskInCloud( taskId: string, branch?: string | null, @@ -2761,7 +3030,7 @@ export class PostHogAPIClient { }), ); - return data as unknown as Task; + return normalizeTaskResponse(data, { teamId }); } async warmTask(options: { @@ -3001,7 +3270,8 @@ export class PostHogAPIClient { throw new Error(`Failed to resume run in cloud: ${response.statusText}`); } - return (await response.json()) as TaskRun; + const data = (await response.json()) as Schemas.TaskRunDetail; + return normalizeTaskRunResponse(data, { teamId, taskId }); } async listTaskRuns(taskId: string): Promise { @@ -3019,8 +3289,11 @@ export class PostHogAPIClient { throw new Error(`Failed to fetch task runs: ${response.statusText}`); } - const data = (await response.json()) as { results?: TaskRun[] }; - return data.results ?? []; + const data = + (await response.json()) as Partial; + return (data.results ?? []).map((run) => + normalizeTaskRunResponse(run, { teamId, taskId }), + ); } async getTaskRun(taskId: string, runId: string): Promise { @@ -3038,7 +3311,8 @@ export class PostHogAPIClient { throw new Error(`Failed to fetch task run: ${response.statusText}`); } - return (await response.json()) as TaskRun; + const data = (await response.json()) as Schemas.TaskRunDetail; + return normalizeTaskRunResponse(data, { teamId, taskId }); } async createTaskRun( @@ -3070,7 +3344,8 @@ export class PostHogAPIClient { throw new Error(`Failed to create task run: ${response.statusText}`); } - return (await response.json()) as TaskRun; + const data = (await response.json()) as Schemas.TaskRunDetail; + return normalizeTaskRunResponse(data, { teamId, taskId }); } async startTaskRun( @@ -3100,7 +3375,8 @@ export class PostHogAPIClient { throw new Error(`Failed to start task run: ${response.statusText}`); } - return (await response.json()) as Task; + const data = (await response.json()) as Schemas.Task; + return normalizeTaskResponse(data, { teamId }); } async updateTaskRun( @@ -3125,7 +3401,7 @@ export class PostHogAPIClient { body: updates as Record, }, ); - return data as unknown as TaskRun; + return normalizeTaskRunResponse(data, { teamId, taskId }); } /** @@ -3215,14 +3491,14 @@ export class PostHogAPIClient { async getTaskLogs(taskId: string): Promise { try { - const task = (await this.getTask(taskId)) as unknown as Task; + const task = await this.getTask(taskId); const logUrl = task?.latest_run?.log_url; if (!logUrl) { return []; } - const response = await fetch(logUrl); + const response = await this.fetch(logUrl); if (!response.ok) { log.warn( diff --git a/packages/api-client/src/task-normalization.test.ts b/packages/api-client/src/task-normalization.test.ts new file mode 100644 index 0000000000..9c9739a51f --- /dev/null +++ b/packages/api-client/src/task-normalization.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; +import { + normalizeTaskResponse, + normalizeTaskRunResponse, +} from "./task-normalization"; + +describe("task response normalization", () => { + it("normalizes legacy started runs and nullable generated fields", () => { + expect( + normalizeTaskRunResponse( + { + id: "run-1", + task: "task-1", + status: "started", + branch: null, + stage: null, + runtime_adapter: null, + model: null, + reasoning_effort: null, + log_url: null, + error_message: null, + output: null, + state: null, + artifacts: [ + { + id: "artifact-1", + name: "result.txt", + type: "legacy_type", + storage_path: "tasks/result.txt", + uploaded_at: "2026-07-21T00:00:00Z", + }, + ], + created_at: "2026-07-21T00:00:00Z", + updated_at: "2026-07-21T00:01:00Z", + completed_at: null, + }, + { teamId: 123 }, + ), + ).toEqual({ + id: "run-1", + task: "task-1", + team: 123, + branch: null, + stage: null, + runtime_adapter: null, + model: null, + reasoning_effort: null, + status: "in_progress", + log_url: "", + error_message: null, + output: null, + state: {}, + artifacts: [ + { + id: "artifact-1", + name: "result.txt", + type: "artifact", + storage_path: "tasks/result.txt", + uploaded_at: "2026-07-21T00:00:00Z", + }, + ], + created_at: "2026-07-21T00:00:00Z", + updated_at: "2026-07-21T00:01:00Z", + completed_at: null, + }); + }); + + it("normalizes task responses and their generated latest-run records", () => { + expect( + normalizeTaskResponse( + { + id: "task-1", + task_number: null, + slug: "task-1", + repository: null, + github_integration: null, + github_user_integration: null, + json_schema: null, + signal_report: null, + channel: null, + latest_run: { + id: "run-1", + status: "started", + log_url: null, + }, + created_at: "2026-07-21T00:00:00Z", + updated_at: "2026-07-21T00:01:00Z", + }, + { teamId: 123 }, + ), + ).toMatchObject({ + id: "task-1", + task_number: null, + slug: "task-1", + title: "", + description: "", + origin_product: "", + repository: null, + github_integration: null, + github_user_integration: null, + json_schema: null, + signal_report: null, + channel: null, + latest_run: { + id: "run-1", + task: "task-1", + team: 123, + status: "in_progress", + log_url: "", + output: null, + state: {}, + }, + }); + }); +}); diff --git a/packages/api-client/src/task-normalization.ts b/packages/api-client/src/task-normalization.ts new file mode 100644 index 0000000000..9571a5b27e --- /dev/null +++ b/packages/api-client/src/task-normalization.ts @@ -0,0 +1,203 @@ +import type { + ArtifactType, + Task, + TaskRun, + TaskRunArtifact, + TaskRunArtifactMetadata, + TaskRunStatus, +} from "@posthog/shared/domain-types"; +import type { Schemas } from "./generated"; + +type TaskRunResponseDTO = Partial< + Omit +> & { + id: string; + artifacts?: Array< + Schemas.TaskRunArtifactResponse & { metadata?: unknown } + > | null; + status?: Schemas.StatusA35Enum | "started" | null; + team?: number | null; +}; + +type TaskResponseDTO = Partial< + Omit +> & { + id: string; + channel?: string | null; + created_by?: Schemas.UserBasic | null; + github_user_integration?: string | null; + json_schema?: unknown | null; + latest_run?: Record | null; + runtime?: unknown; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isTaskRunResponseDTO(value: unknown): value is TaskRunResponseDTO { + return isRecord(value) && typeof value.id === "string"; +} + +function normalizeTaskRunStatus(status: unknown): TaskRunStatus { + switch (status) { + case "started": + return "in_progress"; + case "not_started": + case "queued": + case "in_progress": + case "completed": + case "failed": + case "cancelled": + return status; + default: + return "not_started"; + } +} + +function normalizeArtifactType(type: string): ArtifactType { + switch (type) { + case "plan": + case "context": + case "reference": + case "output": + case "artifact": + case "user_attachment": + case "skill_bundle": + return type; + default: + return "artifact"; + } +} + +function normalizeArtifactMetadata( + value: unknown, +): TaskRunArtifactMetadata | undefined { + if ( + !isRecord(value) || + typeof value.skill_name !== "string" || + (value.skill_source !== "user" && + value.skill_source !== "repo" && + value.skill_source !== "marketplace" && + value.skill_source !== "codex") + ) { + return undefined; + } + if ( + typeof value.content_sha256 !== "string" || + value.bundle_format !== "zip" || + typeof value.schema_version !== "number" + ) { + return undefined; + } + + return { + skill_name: value.skill_name, + skill_source: value.skill_source, + content_sha256: value.content_sha256, + bundle_format: value.bundle_format, + schema_version: value.schema_version, + }; +} + +function normalizeTaskRunArtifact( + artifact: NonNullable[number], +): TaskRunArtifact { + const metadata = normalizeArtifactMetadata(artifact.metadata); + + return { + ...(artifact.id === undefined ? {} : { id: artifact.id }), + name: artifact.name, + type: normalizeArtifactType(artifact.type), + ...(artifact.source === undefined ? {} : { source: artifact.source }), + ...(artifact.size === undefined ? {} : { size: artifact.size }), + ...(artifact.content_type === undefined + ? {} + : { content_type: artifact.content_type }), + ...(metadata === undefined ? {} : { metadata }), + ...(artifact.storage_path === undefined + ? {} + : { storage_path: artifact.storage_path }), + ...(artifact.uploaded_at === undefined + ? {} + : { uploaded_at: artifact.uploaded_at }), + }; +} + +export function normalizeTaskRunResponse( + dto: TaskRunResponseDTO, + context: { teamId: number; taskId?: string }, +): TaskRun { + return { + id: dto.id, + task: dto.task ?? context.taskId ?? "", + team: dto.team ?? context.teamId, + branch: dto.branch ?? null, + ...(dto.runtime_adapter === undefined + ? {} + : { runtime_adapter: dto.runtime_adapter }), + ...(dto.model === undefined ? {} : { model: dto.model }), + ...(dto.reasoning_effort === undefined + ? {} + : { reasoning_effort: dto.reasoning_effort }), + ...(dto.stage === undefined ? {} : { stage: dto.stage }), + ...(dto.environment === undefined ? {} : { environment: dto.environment }), + status: normalizeTaskRunStatus(dto.status), + log_url: dto.log_url ?? "", + error_message: dto.error_message ?? null, + output: isRecord(dto.output) ? dto.output : null, + state: isRecord(dto.state) ? dto.state : {}, + ...(dto.artifacts == null + ? {} + : { artifacts: dto.artifacts.map(normalizeTaskRunArtifact) }), + created_at: dto.created_at ?? "", + updated_at: dto.updated_at ?? "", + completed_at: dto.completed_at ?? null, + }; +} + +export function normalizeTaskResponse( + dto: TaskResponseDTO, + context: { teamId: number }, +): Task { + const jsonSchema = isRecord(dto.json_schema) ? dto.json_schema : null; + const runtime = + dto.runtime === "acp" || dto.runtime === "pi" ? dto.runtime : undefined; + + const latestRun = isTaskRunResponseDTO(dto.latest_run) + ? normalizeTaskRunResponse(dto.latest_run, { + teamId: context.teamId, + taskId: dto.id, + }) + : undefined; + + return { + id: dto.id, + task_number: dto.task_number ?? null, + slug: dto.slug ?? "", + title: dto.title ?? "", + ...(dto.title_manually_set === undefined + ? {} + : { title_manually_set: dto.title_manually_set }), + description: dto.description ?? "", + created_at: dto.created_at ?? "", + updated_at: dto.updated_at ?? "", + ...(dto.created_by === undefined ? {} : { created_by: dto.created_by }), + origin_product: dto.origin_product ?? "", + ...(dto.repository === undefined ? {} : { repository: dto.repository }), + ...(dto.github_integration === undefined + ? {} + : { github_integration: dto.github_integration }), + ...(dto.github_user_integration === undefined + ? {} + : { github_user_integration: dto.github_user_integration }), + ...(dto.json_schema === undefined ? {} : { json_schema: jsonSchema }), + ...(dto.signal_report === undefined + ? {} + : { signal_report: dto.signal_report }), + ...(dto.internal === undefined ? {} : { internal: dto.internal }), + ...(runtime === undefined ? {} : { runtime }), + ...(dto.channel === undefined ? {} : { channel: dto.channel }), + ...(latestRun === undefined ? {} : { latest_run: latestRun }), + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 65ef2c8cbf..5cdfefdf93 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -869,9 +869,6 @@ importers: packages/api-client: dependencies: - '@posthog/agent': - specifier: workspace:* - version: link:../agent '@posthog/shared': specifier: workspace:* version: link:../shared @@ -19215,13 +19212,6 @@ snapshots: '@tybys/wasm-util': 0.10.2 optional: true - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': - dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@tybys/wasm-util': 0.10.3 - optional: true - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 @@ -22659,7 +22649,7 @@ snapshots: dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': From 01aec6d6b84e9751dec936413b67bc88700bb125 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 24 Jul 2026 19:18:39 +0300 Subject: [PATCH 10/43] refactor(core): extract cloud task policies Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- .../automations/automationSchedule.test.ts | 223 ++++++++++++++++++ .../src/automations/automationSchedule.ts | 216 +++++++++++++++++ .../src/automations/automationStatus.test.ts | 80 +++++++ .../core/src/automations/automationStatus.ts | 83 +++++++ packages/core/src/inbox/artefacts.test.ts | 159 ++++++++++++- packages/core/src/inbox/artefacts.ts | 86 +++++++ .../core/src/inbox/reportFiltering.test.ts | 10 + packages/core/src/inbox/reportFiltering.ts | 12 +- .../core/src/sessions/cloudSessionConfig.ts | 9 +- packages/core/src/sessions/executionModes.ts | 4 +- .../sessions/portableSessionEvents.test.ts | 71 ++++++ .../src/sessions/portableSessionEvents.ts | 98 ++++++++ .../core/src/sessions/sessionActivity.test.ts | 152 ++++++++++++ packages/core/src/sessions/sessionActivity.ts | 129 ++++++++++ packages/core/src/tasks/taskActivity.test.ts | 132 +++++++++++ packages/core/src/tasks/taskActivity.ts | 45 ++++ packages/core/src/tasks/taskArchive.test.ts | 44 ++++ packages/core/src/tasks/taskArchive.ts | 6 + .../src/tasks/taskStatusPresentation.test.ts | 69 ++++++ .../core/src/tasks/taskStatusPresentation.ts | 37 +++ 20 files changed, 1659 insertions(+), 6 deletions(-) create mode 100644 packages/core/src/automations/automationSchedule.test.ts create mode 100644 packages/core/src/automations/automationSchedule.ts create mode 100644 packages/core/src/automations/automationStatus.test.ts create mode 100644 packages/core/src/automations/automationStatus.ts create mode 100644 packages/core/src/sessions/portableSessionEvents.test.ts create mode 100644 packages/core/src/sessions/portableSessionEvents.ts create mode 100644 packages/core/src/sessions/sessionActivity.test.ts create mode 100644 packages/core/src/sessions/sessionActivity.ts create mode 100644 packages/core/src/tasks/taskActivity.test.ts create mode 100644 packages/core/src/tasks/taskActivity.ts create mode 100644 packages/core/src/tasks/taskArchive.test.ts create mode 100644 packages/core/src/tasks/taskArchive.ts create mode 100644 packages/core/src/tasks/taskStatusPresentation.test.ts create mode 100644 packages/core/src/tasks/taskStatusPresentation.ts diff --git a/packages/core/src/automations/automationSchedule.test.ts b/packages/core/src/automations/automationSchedule.test.ts new file mode 100644 index 0000000000..5deb2487a0 --- /dev/null +++ b/packages/core/src/automations/automationSchedule.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it } from "vitest"; +import { + type AutomationScheduleDraft, + buildCronExpression, + createDefaultScheduleDraft, + deriveAutomationName, + formatAutomationScheduleSummary, + formatScheduleSummary, + parseCronExpression, + sanitizeHour, + sanitizeMinute, + WEEKDAY_OPTIONS, +} from "./automationSchedule"; + +describe("automationSchedule", () => { + it("creates the default daily schedule draft", () => { + expect(createDefaultScheduleDraft()).toEqual({ + mode: "daily", + hour: "09", + minute: "00", + weekday: "1", + rawCron: "0 9 * * *", + }); + }); + + it("provides cron weekday values in display order", () => { + expect(WEEKDAY_OPTIONS).toEqual([ + { value: "1", label: "Mon" }, + { value: "2", label: "Tue" }, + { value: "3", label: "Wed" }, + { value: "4", label: "Thu" }, + { value: "5", label: "Fri" }, + { value: "6", label: "Sat" }, + { value: "0", label: "Sun" }, + ]); + }); + + it.each([ + ["", ""], + ["a", ""], + ["7", "07"], + ["09", "09"], + ["2x3", "23"], + ["24", "23"], + ["999", "23"], + ])("sanitizes hour input %j to %j", (input, expected) => { + expect(sanitizeHour(input)).toBe(expected); + }); + + it.each([ + ["", ""], + ["a", ""], + ["7", "07"], + ["09", "09"], + ["5x9", "59"], + ["60", "59"], + ["999", "59"], + ])("sanitizes minute input %j to %j", (input, expected) => { + expect(sanitizeMinute(input)).toBe(expected); + }); + + it.each<{ + name: string; + changes: Partial; + expected: string; + }>([ + { + name: "hourly", + changes: { mode: "hourly", minute: "15" }, + expected: "15 * * * *", + }, + { + name: "daily", + changes: { mode: "daily", hour: "09", minute: "15" }, + expected: "15 9 * * *", + }, + { + name: "weekdays", + changes: { mode: "weekdays", hour: "10", minute: "00" }, + expected: "0 10 * * 1-5", + }, + { + name: "weekly", + changes: { + mode: "weekly", + hour: "11", + minute: "30", + weekday: "4", + }, + expected: "30 11 * * 4", + }, + { + name: "weekly with a missing weekday", + changes: { mode: "weekly", weekday: "" }, + expected: "0 9 * * 1", + }, + { + name: "preset with missing time values", + changes: { mode: "daily", hour: "", minute: "" }, + expected: "0 9 * * *", + }, + { + name: "custom", + changes: { mode: "custom", rawCron: " */15 * * * * " }, + expected: "*/15 * * * *", + }, + ])("builds the $name cron expression", ({ changes, expected }) => { + expect( + buildCronExpression({ ...createDefaultScheduleDraft(), ...changes }), + ).toBe(expected); + }); + + it.each([ + [ + "15 * * * *", + { + mode: "hourly", + hour: "09", + minute: "15", + weekday: "*", + rawCron: "15 * * * *", + }, + ], + [ + "0 9 * * *", + { + mode: "daily", + hour: "09", + minute: "00", + weekday: "*", + rawCron: "0 9 * * *", + }, + ], + [ + "0 9 * * 1-5", + { + mode: "weekdays", + hour: "09", + minute: "00", + weekday: "1", + rawCron: "0 9 * * 1-5", + }, + ], + [ + "30 14 * * 2", + { + mode: "weekly", + hour: "14", + minute: "30", + weekday: "2", + rawCron: "30 14 * * 2", + }, + ], + ] as const)("parses %s into a schedule draft", (cron, expected) => { + expect(parseCronExpression(cron)).toEqual(expected); + }); + + it.each(["*/15 * * * *", "0 9 1 * *", "0 9 * 1 *", "0 9 * * 1,3"])( + "keeps unsupported cron expression %s in custom mode", + (cron) => { + expect(parseCronExpression(cron)).toMatchObject({ + mode: "custom", + rawCron: cron, + }); + }, + ); + + it("normalizes surrounding and repeated cron whitespace", () => { + expect(parseCronExpression(" 5 8 * * * ")).toEqual({ + mode: "daily", + hour: "08", + minute: "05", + weekday: "*", + rawCron: "5 8 * * *", + }); + }); + + it("uses default draft fields for a cron expression with the wrong arity", () => { + expect(parseCronExpression("0 9 * *")).toEqual({ + mode: "custom", + hour: "09", + minute: "00", + weekday: "1", + rawCron: "0 9 * *", + }); + }); + + it("derives a compact name from the first non-empty prompt line", () => { + expect( + deriveAutomationName( + "\n Review every open PostHog PR for stale comments \nIgnore this line", + ), + ).toBe("Review every open PostHog PR for stale comments"); + }); + + it("returns an empty name for a blank prompt", () => { + expect(deriveAutomationName(" \n\t\n ")).toBe(""); + }); + + it("limits derived names to 80 characters", () => { + expect(deriveAutomationName("a".repeat(100))).toBe("a".repeat(80)); + }); + + it.each([ + ["15 * * * *", "Europe/London", "Every hour at :15 · Europe/London"], + ["0 9 * * *", null, "Daily at 09:00"], + ["0 9 * * 1-5", "UTC", "Weekdays at 09:00 · UTC"], + ["30 14 * * 2", undefined, "Tue at 14:30"], + ["30 14 * * 7", "UTC", "Weekly at 14:30 · UTC"], + ["*/15 * * * *", "UTC", "Custom schedule · UTC"], + ])("formats %s with timezone %j", (cronExpression, timezone, expected) => { + expect(formatScheduleSummary(cronExpression, timezone)).toBe(expected); + }); + + it("formats a schedule from an automation-shaped input", () => { + expect( + formatAutomationScheduleSummary({ + cron_expression: "0 18 * * 0", + timezone: "America/New_York", + }), + ).toBe("Sun at 18:00 · America/New_York"); + }); +}); diff --git a/packages/core/src/automations/automationSchedule.ts b/packages/core/src/automations/automationSchedule.ts new file mode 100644 index 0000000000..527e8da497 --- /dev/null +++ b/packages/core/src/automations/automationSchedule.ts @@ -0,0 +1,216 @@ +export type AutomationScheduleMode = + | "hourly" + | "daily" + | "weekdays" + | "weekly" + | "custom"; + +export interface AutomationScheduleDraft { + mode: AutomationScheduleMode; + hour: string; + minute: string; + weekday: string; + rawCron: string; +} + +export interface AutomationScheduleSummaryInput { + cron_expression: string; + timezone?: string | null; +} + +export const WEEKDAY_OPTIONS = [ + { value: "1", label: "Mon" }, + { value: "2", label: "Tue" }, + { value: "3", label: "Wed" }, + { value: "4", label: "Thu" }, + { value: "5", label: "Fri" }, + { value: "6", label: "Sat" }, + { value: "0", label: "Sun" }, +] as const; + +export function createDefaultScheduleDraft(): AutomationScheduleDraft { + return { + mode: "daily", + hour: "09", + minute: "00", + weekday: "1", + rawCron: "0 9 * * *", + }; +} + +function padTimePart(value: string): string { + return value.padStart(2, "0"); +} + +export function sanitizeHour(value: string): string { + const digitsOnly = value.replace(/\D/g, "").slice(0, 2); + if (!digitsOnly) { + return ""; + } + + return String(Math.min(23, Number(digitsOnly))).padStart(2, "0"); +} + +export function sanitizeMinute(value: string): string { + const digitsOnly = value.replace(/\D/g, "").slice(0, 2); + if (!digitsOnly) { + return ""; + } + + return String(Math.min(59, Number(digitsOnly))).padStart(2, "0"); +} + +export function buildCronExpression(draft: AutomationScheduleDraft): string { + if (draft.mode === "custom") { + return draft.rawCron.trim(); + } + + const minute = draft.minute ? String(Number(draft.minute)) : "0"; + const hour = draft.hour ? String(Number(draft.hour)) : "9"; + + switch (draft.mode) { + case "hourly": + return `${minute} * * * *`; + case "weekdays": + return `${minute} ${hour} * * 1-5`; + case "weekly": + return `${minute} ${hour} * * ${draft.weekday || "1"}`; + default: + return `${minute} ${hour} * * *`; + } +} + +export function parseCronExpression( + cronExpression: string, +): AutomationScheduleDraft { + const normalized = cronExpression.trim(); + const parts = normalized.split(/\s+/); + + if (parts.length !== 5) { + return { + ...createDefaultScheduleDraft(), + mode: "custom", + rawCron: normalized, + }; + } + + const [minute, hour, dayOfMonth, month, dayOfWeek] = parts; + const isNumericMinute = /^\d{1,2}$/.test(minute); + const isNumericHour = /^\d{1,2}$/.test(hour); + const draftBase = { + hour: padTimePart(hour), + minute: padTimePart(minute), + weekday: dayOfWeek, + rawCron: normalized, + }; + + if ( + isNumericMinute && + hour === "*" && + dayOfMonth === "*" && + month === "*" && + dayOfWeek === "*" + ) { + return { + ...draftBase, + mode: "hourly", + hour: "09", + }; + } + + if ( + isNumericMinute && + isNumericHour && + dayOfMonth === "*" && + month === "*" && + dayOfWeek === "*" + ) { + return { + ...draftBase, + mode: "daily", + }; + } + + if ( + isNumericMinute && + isNumericHour && + dayOfMonth === "*" && + month === "*" && + dayOfWeek === "1-5" + ) { + return { + ...draftBase, + mode: "weekdays", + weekday: "1", + }; + } + + if ( + isNumericMinute && + isNumericHour && + dayOfMonth === "*" && + month === "*" && + /^\d$/.test(dayOfWeek) + ) { + return { + ...draftBase, + mode: "weekly", + }; + } + + return { + ...draftBase, + mode: "custom", + }; +} + +export function deriveAutomationName(prompt: string): string { + const normalized = prompt + .split("\n") + .map((line) => line.trim()) + .find(Boolean); + + if (!normalized) { + return ""; + } + + return normalized.replace(/\s+/g, " ").slice(0, 80); +} + +function formatTime(hour: string, minute: string): string { + return `${padTimePart(hour)}:${padTimePart(minute)}`; +} + +export function formatScheduleSummary( + cronExpression: string, + timezone: string | null | undefined, +): string { + const draft = parseCronExpression(cronExpression); + const suffix = timezone ? ` · ${timezone}` : ""; + + switch (draft.mode) { + case "hourly": + return `Every hour at :${padTimePart(draft.minute)}${suffix}`; + case "weekdays": + return `Weekdays at ${formatTime(draft.hour, draft.minute)}${suffix}`; + case "weekly": { + const label = + WEEKDAY_OPTIONS.find((option) => option.value === draft.weekday) + ?.label ?? "Weekly"; + return `${label} at ${formatTime(draft.hour, draft.minute)}${suffix}`; + } + case "custom": + return `Custom schedule${suffix}`; + default: + return `Daily at ${formatTime(draft.hour, draft.minute)}${suffix}`; + } +} + +export function formatAutomationScheduleSummary( + automation: AutomationScheduleSummaryInput, +): string { + return formatScheduleSummary( + automation.cron_expression, + automation.timezone ?? null, + ); +} diff --git a/packages/core/src/automations/automationStatus.test.ts b/packages/core/src/automations/automationStatus.test.ts new file mode 100644 index 0000000000..862fc3c0b5 --- /dev/null +++ b/packages/core/src/automations/automationStatus.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { + type AutomationStatusPresentation, + type AutomationTaskRunStatus, + getAutomationStatusPresentation, +} from "./automationStatus"; + +describe("automationStatus", () => { + it.each<{ + status: AutomationTaskRunStatus; + expected: AutomationStatusPresentation | null; + }>([ + { + status: "not_started", + expected: { label: "Queued", tone: "warning", iconKind: "queued" }, + }, + { + status: "queued", + expected: { label: "Queued", tone: "warning", iconKind: "queued" }, + }, + { status: "started", expected: null }, + { status: "in_progress", expected: null }, + { + status: "completed", + expected: { label: "Success", tone: "success", iconKind: "success" }, + }, + { + status: "failed", + expected: { label: "Failed", tone: "error", iconKind: "failed" }, + }, + { + status: "cancelled", + expected: { label: "Failed", tone: "error", iconKind: "failed" }, + }, + ])( + "maps task-run status $status to renderer-neutral presentation data", + ({ status, expected }) => { + expect( + getAutomationStatusPresentation({ + lastRunStatus: "success", + lastTaskRunStatus: status, + }), + ).toEqual(expected); + }, + ); + + it.each([ + ["running", null], + ["success", { label: "Success", tone: "success", iconKind: "success" }], + ["failed", { label: "Failed", tone: "error", iconKind: "failed" }], + [null, { label: "Never run", tone: "neutral", iconKind: "never-run" }], + ["unknown", { label: "Never run", tone: "neutral", iconKind: "never-run" }], + ] as const)( + "falls back from automation status %j to semantic presentation data", + (lastRunStatus, expected) => { + expect(getAutomationStatusPresentation({ lastRunStatus })).toEqual( + expected, + ); + }, + ); + + it("prioritizes linked task-run detail over the automation-level status", () => { + expect( + getAutomationStatusPresentation({ + lastRunStatus: "failed", + lastTaskRunStatus: "completed", + }), + ).toEqual({ + label: "Success", + tone: "success", + iconKind: "success", + }); + }); + + it("does not expose renderer-specific class names", () => { + expect( + getAutomationStatusPresentation({ lastRunStatus: "success" }), + ).not.toHaveProperty("className"); + }); +}); diff --git a/packages/core/src/automations/automationStatus.ts b/packages/core/src/automations/automationStatus.ts new file mode 100644 index 0000000000..4bcc13735e --- /dev/null +++ b/packages/core/src/automations/automationStatus.ts @@ -0,0 +1,83 @@ +export type AutomationTaskRunStatus = + | "not_started" + | "queued" + | "started" + | "in_progress" + | "completed" + | "failed" + | "cancelled"; + +export interface AutomationStatusInput { + lastRunStatus: string | null; + lastTaskRunStatus?: AutomationTaskRunStatus | null; +} + +export type AutomationStatusTone = "neutral" | "warning" | "success" | "error"; + +export type AutomationStatusIconKind = + | "queued" + | "success" + | "failed" + | "never-run"; + +export interface AutomationStatusPresentation { + label: string; + tone: AutomationStatusTone; + iconKind: AutomationStatusIconKind; +} + +export function getAutomationStatusPresentation({ + lastRunStatus, + lastTaskRunStatus, +}: AutomationStatusInput): AutomationStatusPresentation | null { + switch (lastTaskRunStatus) { + case "not_started": + case "queued": + return { + label: "Queued", + tone: "warning", + iconKind: "queued", + }; + case "started": + case "in_progress": + return null; + case "completed": + return { + label: "Success", + tone: "success", + iconKind: "success", + }; + case "failed": + case "cancelled": + return { + label: "Failed", + tone: "error", + iconKind: "failed", + }; + default: + break; + } + + switch (lastRunStatus) { + case "running": + return null; + case "success": + return { + label: "Success", + tone: "success", + iconKind: "success", + }; + case "failed": + return { + label: "Failed", + tone: "error", + iconKind: "failed", + }; + default: + return { + label: "Never run", + tone: "neutral", + iconKind: "never-run", + }; + } +} diff --git a/packages/core/src/inbox/artefacts.test.ts b/packages/core/src/inbox/artefacts.test.ts index a4e972613e..3d733ffbd3 100644 --- a/packages/core/src/inbox/artefacts.test.ts +++ b/packages/core/src/inbox/artefacts.test.ts @@ -1,11 +1,43 @@ -import type { SuggestedReviewer } from "@posthog/shared/types"; +import type { + AvailableSuggestedReviewer, + SuggestedReviewer, +} from "@posthog/shared/types"; import { describe, expect, it } from "vitest"; import { + buildReviewerOptions, extractSuggestedReviewers, + orderSuggestedReviewers, reviewerInitials, + reviewerMatchesAvailable, + reviewerOptionLabel, suggestedReviewerDisplayName, + toSuggestedReviewerWriteContent, } from "./artefacts"; +function makeReviewer( + partial: Partial = {}, +): SuggestedReviewer { + return { + github_login: "octocat", + github_name: "The Octocat", + relevant_commits: [], + user: null, + ...partial, + }; +} + +function makeAvailableReviewer( + partial: Partial = {}, +): AvailableSuggestedReviewer { + return { + uuid: "uuid-1", + name: "Ada Lovelace", + email: "ada@example.com", + github_login: "ada", + ...partial, + }; +} + describe("artefacts", () => { it("extracts suggested reviewers from artefacts", () => { const reviewers: SuggestedReviewer[] = [ @@ -46,4 +78,129 @@ describe("artefacts", () => { expect(reviewerInitials("Ben W.", null)).toBe("BW"); expect(reviewerInitials("", "ben@posthog.com")).toBe("BE"); }); + + it("moves the current user to the front", () => { + const reviewers = [ + makeReviewer({ + github_login: "a", + user: { + id: 1, + uuid: "uuid-a", + email: "a@posthog.com", + first_name: "a", + last_name: "", + }, + }), + makeReviewer({ + github_login: "me", + user: { + id: 2, + uuid: "uuid-me", + email: "me@posthog.com", + first_name: "me", + last_name: "", + }, + }), + ]; + + expect( + orderSuggestedReviewers(reviewers, "uuid-me").map( + (reviewer) => reviewer.github_login, + ), + ).toEqual(["me", "a"]); + }); + + it("deduplicates reviewer options and pins the current user first", () => { + const options = buildReviewerOptions( + [ + makeAvailableReviewer({ uuid: "b", name: "Bob" }), + makeAvailableReviewer({ uuid: "a", name: "Ada" }), + makeAvailableReviewer({ uuid: "a", name: "Ada duplicate" }), + ], + "b", + ); + + expect(options.map((option) => option.uuid)).toEqual(["b", "a"]); + }); + + it("labels the current reviewer", () => { + expect( + reviewerOptionLabel({ + uuid: "uuid-me", + name: "Ada", + email: "ada@example.com", + github_login: "ada", + isMe: true, + }), + ).toBe("Ada (Me)"); + }); + + it.each([ + { + name: "user uuid", + reviewer: makeReviewer({ + github_login: "", + user: { + id: 1, + uuid: "uuid-1", + email: "", + first_name: "", + last_name: "", + }, + }), + expected: true, + }, + { + name: "case-insensitive GitHub login", + reviewer: makeReviewer({ github_login: "ADA" }), + expected: true, + }, + { + name: "different reviewer", + reviewer: makeReviewer(), + expected: false, + }, + ])("matches an available reviewer by $name", ({ reviewer, expected }) => { + expect(reviewerMatchesAvailable(reviewer, makeAvailableReviewer())).toBe( + expected, + ); + }); + + it.each([ + { + name: "GitHub login", + reviewer: makeReviewer({ + github_login: "ada", + user: { + id: 1, + uuid: "uuid-1", + email: "", + first_name: "", + last_name: "", + }, + }), + expected: [{ github_login: "ada" }], + }, + { + name: "user uuid fallback", + reviewer: makeReviewer({ + github_login: "", + user: { + id: 1, + uuid: "uuid-1", + email: "", + first_name: "", + last_name: "", + }, + }), + expected: [{ user_uuid: "uuid-1" }], + }, + { + name: "unresolved reviewer", + reviewer: makeReviewer({ github_login: "" }), + expected: [], + }, + ])("builds write content from the $name", ({ reviewer, expected }) => { + expect(toSuggestedReviewerWriteContent([reviewer])).toEqual(expected); + }); }); diff --git a/packages/core/src/inbox/artefacts.ts b/packages/core/src/inbox/artefacts.ts index 1978f710bc..dc5b2eddb2 100644 --- a/packages/core/src/inbox/artefacts.ts +++ b/packages/core/src/inbox/artefacts.ts @@ -1,8 +1,18 @@ import type { + AvailableSuggestedReviewer, RepoSelectionArtefact, SuggestedReviewer, + SuggestedReviewerWriteEntry, } from "@posthog/shared/types"; +export interface ReviewerOption { + uuid: string; + name: string; + email: string; + github_login: string; + isMe: boolean; +} + function hasRepositoryContent( content: unknown, ): content is RepoSelectionArtefact["content"] { @@ -48,6 +58,82 @@ export function extractSuggestedReviewers( return artefact?.content ?? []; } +export function orderSuggestedReviewers( + reviewers: SuggestedReviewer[], + currentUserUuid: string | null | undefined, +): SuggestedReviewer[] { + if (!currentUserUuid) return reviewers; + const currentUserIndex = reviewers.findIndex( + (reviewer) => reviewer.user?.uuid === currentUserUuid, + ); + if (currentUserIndex <= 0) return reviewers; + return [ + reviewers[currentUserIndex], + ...reviewers.filter((_, index) => index !== currentUserIndex), + ]; +} + +export function buildReviewerOptions( + reviewers: AvailableSuggestedReviewer[], + currentUserUuid: string | undefined, +): ReviewerOption[] { + const seen = new Set(); + const options: ReviewerOption[] = []; + + for (const reviewer of reviewers) { + if (!reviewer.uuid || seen.has(reviewer.uuid)) continue; + seen.add(reviewer.uuid); + options.push({ + uuid: reviewer.uuid, + name: reviewer.name?.trim() || "", + email: reviewer.email?.trim() || "", + github_login: reviewer.github_login?.trim() || "", + isMe: reviewer.uuid === currentUserUuid, + }); + } + + options.sort((first, second) => { + if (first.isMe && !second.isMe) return -1; + if (!first.isMe && second.isMe) return 1; + return (first.name || first.email).localeCompare( + second.name || second.email, + ); + }); + + return options; +} + +export function reviewerOptionLabel(reviewer: ReviewerOption): string { + const base = reviewer.name || reviewer.email || "Unknown user"; + return reviewer.isMe ? `${base} (Me)` : base; +} + +export function reviewerMatchesAvailable( + reviewer: SuggestedReviewer, + available: AvailableSuggestedReviewer, +): boolean { + if (reviewer.user?.uuid && reviewer.user.uuid === available.uuid) { + return true; + } + return ( + !!reviewer.github_login && + !!available.github_login && + reviewer.github_login.toLowerCase() === available.github_login.toLowerCase() + ); +} + +export function toSuggestedReviewerWriteContent( + reviewers: SuggestedReviewer[], +): SuggestedReviewerWriteEntry[] { + return reviewers + .map((reviewer): SuggestedReviewerWriteEntry | null => { + if (reviewer.github_login) return { github_login: reviewer.github_login }; + if (reviewer.user?.uuid) return { user_uuid: reviewer.user.uuid }; + return null; + }) + .filter((entry): entry is SuggestedReviewerWriteEntry => entry !== null); +} + const AVATAR_PALETTE = [ "bg-(--orange-9) text-white", "bg-(--blue-9) text-white", diff --git a/packages/core/src/inbox/reportFiltering.test.ts b/packages/core/src/inbox/reportFiltering.test.ts index f2be318fac..3362ca5cde 100644 --- a/packages/core/src/inbox/reportFiltering.test.ts +++ b/packages/core/src/inbox/reportFiltering.test.ts @@ -6,8 +6,18 @@ import { buildSignalReportListOrdering, buildSuggestedReviewerFilterParam, filterReportsBySearch, + INBOX_PIPELINE_STATUS_FILTER, + INBOX_PIPELINE_STATUSES, } from "./reportFiltering"; +describe("inbox pipeline statuses", () => { + it("derives the API filter from the typed status list", () => { + expect(INBOX_PIPELINE_STATUS_FILTER).toBe( + INBOX_PIPELINE_STATUSES.join(","), + ); + }); +}); + function makeReport(overrides: Partial = {}): SignalReport { return { id: "1", diff --git a/packages/core/src/inbox/reportFiltering.ts b/packages/core/src/inbox/reportFiltering.ts index 06d36038b5..2a8271b2cb 100644 --- a/packages/core/src/inbox/reportFiltering.ts +++ b/packages/core/src/inbox/reportFiltering.ts @@ -5,12 +5,20 @@ import type { SignalReportStatus, } from "@posthog/shared/types"; +export const INBOX_PIPELINE_STATUSES = [ + "ready", + "pending_input", + "in_progress", + "failed", + "candidate", + "potential", +] as const satisfies readonly SignalReportStatus[]; + /** * Comma-separated statuses for the inbox query. We pull `failed` so the Runs * tab can surface failed runs in its Recently finished section. */ -export const INBOX_PIPELINE_STATUS_FILTER = - "potential,candidate,in_progress,ready,pending_input,failed"; +export const INBOX_PIPELINE_STATUS_FILTER = INBOX_PIPELINE_STATUSES.join(","); /** * Status filter for the Archive tab — the two terminal, not-in-inbox states: diff --git a/packages/core/src/sessions/cloudSessionConfig.ts b/packages/core/src/sessions/cloudSessionConfig.ts index ecb989b446..b6a9e4f8f4 100644 --- a/packages/core/src/sessions/cloudSessionConfig.ts +++ b/packages/core/src/sessions/cloudSessionConfig.ts @@ -1,6 +1,10 @@ import type { SessionConfigOption } from "@agentclientprotocol/sdk"; import type { Adapter, StoredLogEntry } from "@posthog/shared"; -import { getAvailableCodexModes, getAvailableModes } from "./executionModes"; +import { + DEFAULT_CLAUDE_EXECUTION_MODE, + getAvailableCodexModes, + getAvailableModes, +} from "./executionModes"; /** * Pure derivations of cloud session config options. No store or host access — @@ -54,7 +58,8 @@ export function buildCloudDefaultConfigOptions( ): SessionConfigOption[] { const modes = adapter === "codex" ? getAvailableCodexModes() : getAvailableModes(); - const fallbackMode = adapter === "codex" ? "auto" : "plan"; + const fallbackMode = + adapter === "codex" ? "auto" : DEFAULT_CLAUDE_EXECUTION_MODE; const currentMode = typeof initialMode === "string" && modes.some((mode) => mode.id === initialMode) diff --git a/packages/core/src/sessions/executionModes.ts b/packages/core/src/sessions/executionModes.ts index 4a32413c12..2ccbadf354 100644 --- a/packages/core/src/sessions/executionModes.ts +++ b/packages/core/src/sessions/executionModes.ts @@ -1,4 +1,4 @@ -import { CODEX_MODE_PRESETS } from "@posthog/shared"; +import { CODEX_MODE_PRESETS, type ExecutionMode } from "@posthog/shared"; export interface ModeInfo { id: string; @@ -6,6 +6,8 @@ export interface ModeInfo { description: string; } +export const DEFAULT_CLAUDE_EXECUTION_MODE: ExecutionMode = "plan"; + const availableModes: ModeInfo[] = [ { id: "default", diff --git a/packages/core/src/sessions/portableSessionEvents.test.ts b/packages/core/src/sessions/portableSessionEvents.test.ts new file mode 100644 index 0000000000..d777a0ec8b --- /dev/null +++ b/packages/core/src/sessions/portableSessionEvents.test.ts @@ -0,0 +1,71 @@ +import type { StoredLogEntry } from "@posthog/shared"; +import { describe, expect, it, vi } from "vitest"; +import { + convertStoredEntriesToPortableSessionEvents, + inferStoredLogEntryDirection, +} from "./portableSessionEvents"; + +describe("inferStoredLogEntryDirection", () => { + it.each([ + [ + "client requests", + { notification: { id: 1, method: "session/prompt" } }, + "client", + ], + ["agent responses", { notification: { id: 1, result: {} } }, "agent"], + [ + "agent notifications", + { notification: { method: "session/update" } }, + "agent", + ], + ["missing messages", {}, "agent"], + ] as const)("classifies %s", (_name, entry, expected) => { + expect(inferStoredLogEntryDirection(entry as StoredLogEntry)).toBe( + expected, + ); + }); +}); + +describe("convertStoredEntriesToPortableSessionEvents", () => { + it("projects session updates alongside their raw ACP message", () => { + const notification = { + update: { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "hello" }, + }, + }; + const events = convertStoredEntriesToPortableSessionEvents([ + { + type: "notification", + timestamp: "2026-07-21T12:00:00.000Z", + notification: { method: "session/update", params: notification }, + }, + ]); + + expect(events).toEqual([ + { + type: "acp_message", + direction: "agent", + ts: 1_784_635_200_000, + message: { method: "session/update", params: notification }, + }, + { + type: "session_update", + ts: 1_784_635_200_000, + notification, + }, + ]); + }); + + it("uses the current time when an entry has no timestamp", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-21T12:00:00.000Z")); + + const events = convertStoredEntriesToPortableSessionEvents([ + { type: "response", notification: { id: 1, result: {} } }, + ]); + + expect(events[0]?.ts).toBe(1_784_635_200_000); + vi.useRealTimers(); + }); +}); diff --git a/packages/core/src/sessions/portableSessionEvents.ts b/packages/core/src/sessions/portableSessionEvents.ts new file mode 100644 index 0000000000..5ee36c7642 --- /dev/null +++ b/packages/core/src/sessions/portableSessionEvents.ts @@ -0,0 +1,98 @@ +import type { JsonRpcMessage, StoredLogEntry } from "@posthog/shared"; + +export type PortableSessionToolCallStatus = + | "pending" + | "in_progress" + | "completed" + | "failed" + | null; + +export interface PortableSessionUpdate { + sessionUpdate?: string; + content?: { type: string; text: string }; + attachments?: Array<{ + kind: "image" | "document"; + uri: string; + fileName: string; + mimeType?: string; + }>; + title?: string; + toolCallId?: string; + status?: PortableSessionToolCallStatus; + rawInput?: Record; + rawOutput?: unknown; + entries?: Array<{ + content: string; + status: "pending" | "in_progress" | "completed" | "failed"; + priority: string; + }>; + _meta?: { + claudeCode?: { + toolName?: string; + parentToolCallId?: string; + }; + }; +} + +export interface PortableSessionNotification { + update?: PortableSessionUpdate; +} + +export interface PortableSessionAcpMessage { + type: "acp_message"; + direction: "client" | "agent"; + ts: number; + message: JsonRpcMessage; +} + +export interface PortableSessionUpdateEvent { + type: "session_update"; + ts: number; + notification: PortableSessionNotification; +} + +export type PortableSessionEvent = + | PortableSessionAcpMessage + | PortableSessionUpdateEvent; + +export function inferStoredLogEntryDirection( + entry: StoredLogEntry, +): "client" | "agent" { + const message = entry.notification; + if (!message) return "agent"; + if (message.id !== undefined && message.method !== undefined) return "client"; + return "agent"; +} + +export function convertStoredEntriesToPortableSessionEvents( + entries: readonly StoredLogEntry[], +): PortableSessionEvent[] { + const events: PortableSessionEvent[] = []; + + for (const entry of entries) { + const ts = entry.timestamp + ? new Date(entry.timestamp).getTime() + : Date.now(); + + events.push({ + type: "acp_message", + direction: inferStoredLogEntryDirection(entry), + ts, + message: (entry.notification ?? {}) as JsonRpcMessage, + }); + + if ( + entry.type === "notification" && + entry.notification?.method === "session/update" && + entry.notification.params + ) { + events.push({ + type: "session_update", + ts, + notification: entry.notification.params as PortableSessionNotification, + }); + } + } + + return events; +} diff --git a/packages/core/src/sessions/sessionActivity.test.ts b/packages/core/src/sessions/sessionActivity.test.ts new file mode 100644 index 0000000000..db795f2118 --- /dev/null +++ b/packages/core/src/sessions/sessionActivity.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "vitest"; +import type { + PortableSessionEvent, + PortableSessionToolCallStatus, +} from "./portableSessionEvents"; +import { + countUserMessages, + getSessionActivityPhase, + isSessionAwaitingUserInput, +} from "./sessionActivity"; + +function userMessage(ts = 1): PortableSessionEvent { + return { + type: "session_update", + ts, + notification: { + update: { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "Yes" }, + }, + }, + }; +} + +function questionToolCall( + status: PortableSessionToolCallStatus, + sessionUpdate = "tool_call", +): PortableSessionEvent { + return { + type: "session_update", + ts: 1, + notification: { + update: { + sessionUpdate, + toolCallId: "question-1", + status, + rawInput: { questions: [{ question: "Proceed?", options: [] }] }, + _meta: { claudeCode: { toolName: "AskUserQuestion" } }, + }, + }, + }; +} + +function acpNotification(method: string): PortableSessionEvent { + return { + type: "acp_message", + direction: "agent", + ts: 1, + message: { method }, + }; +} + +describe("isSessionAwaitingUserInput", () => { + it("tracks question tools until a metadata-free completion update", () => { + const completion: PortableSessionEvent = { + type: "session_update", + ts: 2, + notification: { + update: { + sessionUpdate: "tool_call_update", + toolCallId: "question-1", + status: "completed", + }, + }, + }; + + expect( + isSessionAwaitingUserInput([questionToolCall("pending"), completion]), + ).toBe(false); + }); + + it("clears questions when the user responds", () => { + expect( + isSessionAwaitingUserInput([questionToolCall("pending"), userMessage(2)]), + ).toBe(false); + }); + + it("honors explicit waiting and terminal backend markers", () => { + expect( + isSessionAwaitingUserInput([ + acpNotification("_posthog/awaiting_user_input"), + ]), + ).toBe(true); + expect( + isSessionAwaitingUserInput([ + acpNotification("_posthog/awaiting_user_input"), + acpNotification("_posthog/turn_complete"), + ]), + ).toBe(false); + }); +}); + +describe("countUserMessages", () => { + it("counts only projected user message updates", () => { + expect( + countUserMessages([ + userMessage(), + questionToolCall("pending"), + userMessage(2), + ]), + ).toBe(2); + }); +}); + +describe("getSessionActivityPhase", () => { + it.each([ + ["retrying", true, undefined, "connecting"], + [ + "awaiting agent output", + false, + { isPromptPending: true, awaitingAgentOutput: true }, + "connecting", + ], + [ + "working", + false, + { isPromptPending: true, awaitingAgentOutput: false }, + "working", + ], + [ + "not pending", + false, + { isPromptPending: false, awaitingAgentOutput: false }, + "idle", + ], + [ + "terminal", + false, + { + isPromptPending: true, + awaitingAgentOutput: false, + terminalStatus: "completed" as const, + }, + "idle", + ], + [ + "waiting for user", + false, + { + isPromptPending: true, + awaitingAgentOutput: false, + events: [questionToolCall("pending")], + }, + "idle", + ], + ] as const)( + "returns the expected phase while %s", + (_name, retrying, session, expected) => { + expect(getSessionActivityPhase({ retrying, session })).toBe(expected); + }, + ); +}); diff --git a/packages/core/src/sessions/sessionActivity.ts b/packages/core/src/sessions/sessionActivity.ts new file mode 100644 index 0000000000..6e2804ff47 --- /dev/null +++ b/packages/core/src/sessions/sessionActivity.ts @@ -0,0 +1,129 @@ +import { isNotification, POSTHOG_NOTIFICATIONS } from "./acpNotifications"; +import type { + PortableSessionEvent, + PortableSessionNotification, + PortableSessionToolCallStatus, +} from "./portableSessionEvents"; + +export type SessionActivityPhase = "idle" | "connecting" | "working"; + +export interface SessionActivityState { + isPromptPending?: boolean; + awaitingAgentOutput?: boolean; + terminalStatus?: "failed" | "completed"; + events?: readonly PortableSessionEvent[]; +} + +function isQuestionNotification( + notification: PortableSessionNotification, +): boolean { + const update = notification.update; + if (!update) return false; + + const rawToolName = update._meta?.claudeCode?.toolName; + if (typeof rawToolName === "string" && /question/i.test(rawToolName)) { + return true; + } + + const rawInput = update.rawInput; + if (!rawInput) return false; + if (Array.isArray(rawInput.questions)) return true; + + const nestedInput = rawInput.input; + return ( + typeof nestedInput === "object" && + nestedInput !== null && + Array.isArray((nestedInput as { questions?: unknown }).questions) + ); +} + +function isPendingQuestionStatus( + status: PortableSessionToolCallStatus | undefined, +): boolean { + return status === null || status === "pending" || status === "in_progress"; +} + +export function isSessionAwaitingUserInput( + events: readonly PortableSessionEvent[] = [], +): boolean { + let awaitingUserInput = false; + const questionStatuses = new Map< + string, + PortableSessionToolCallStatus | undefined + >(); + + for (const event of events) { + if (event.type === "session_update") { + const update = event.notification.update; + const sessionUpdate = update?.sessionUpdate; + + if (sessionUpdate === "user_message_chunk") { + awaitingUserInput = false; + questionStatuses.clear(); + continue; + } + + if ( + sessionUpdate === "tool_call" || + sessionUpdate === "tool_call_update" + ) { + const toolCallId = update?.toolCallId; + const isKnownQuestion = toolCallId + ? questionStatuses.has(toolCallId) + : false; + if (!isKnownQuestion && !isQuestionNotification(event.notification)) { + continue; + } + + questionStatuses.set( + toolCallId ?? `question-${event.ts}`, + update?.status, + ); + awaitingUserInput = [...questionStatuses.values()].some( + isPendingQuestionStatus, + ); + } + + continue; + } + + const method = "method" in event.message ? event.message.method : undefined; + if (method === "_posthog/awaiting_user_input") { + awaitingUserInput = true; + continue; + } + + if ( + isNotification(method, POSTHOG_NOTIFICATIONS.TURN_COMPLETE) || + isNotification(method, POSTHOG_NOTIFICATIONS.TASK_COMPLETE) || + isNotification(method, POSTHOG_NOTIFICATIONS.ERROR) + ) { + awaitingUserInput = false; + questionStatuses.clear(); + } + } + + return awaitingUserInput; +} + +export function countUserMessages( + events: readonly PortableSessionEvent[] = [], +): number { + return events.filter( + (event) => + event.type === "session_update" && + event.notification.update?.sessionUpdate === "user_message_chunk", + ).length; +} + +export function getSessionActivityPhase(args: { + retrying: boolean; + session?: SessionActivityState | null; +}): SessionActivityPhase { + const { retrying, session } = args; + + if (retrying) return "connecting"; + if (!session?.isPromptPending || session.terminalStatus) return "idle"; + if (isSessionAwaitingUserInput(session.events)) return "idle"; + return session.awaitingAgentOutput ? "connecting" : "working"; +} diff --git a/packages/core/src/tasks/taskActivity.test.ts b/packages/core/src/tasks/taskActivity.test.ts new file mode 100644 index 0000000000..35d1eadaff --- /dev/null +++ b/packages/core/src/tasks/taskActivity.test.ts @@ -0,0 +1,132 @@ +import type { Task } from "@posthog/shared/domain-types"; +import { describe, expect, it } from "vitest"; +import { filterAndSortTasks, taskActivityTimestamp } from "./taskActivity"; + +function makeTask(overrides: Partial = {}): Task { + return { + id: "task-1", + task_number: 1, + slug: "task-1", + title: "A real task", + description: "Do the thing", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-02T00:00:00Z", + origin_product: "tasks", + ...overrides, + }; +} + +describe("taskActivityTimestamp", () => { + it("uses creation time in created mode", () => { + const task = makeTask({ + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-03T00:00:00Z", + }); + + expect(taskActivityTimestamp(task, "created")).toBe( + new Date("2026-01-01T00:00:00Z").getTime(), + ); + }); + + it("uses the latest task or run update in updated mode", () => { + const task = makeTask({ + updated_at: "2026-01-02T00:00:00Z", + latest_run: { + id: "run-1", + task: "task-1", + team: 1, + branch: null, + status: "completed", + log_url: "", + error_message: null, + output: null, + state: {}, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-04T00:00:00Z", + completed_at: "2026-01-04T00:00:00Z", + }, + }); + + expect(taskActivityTimestamp(task, "updated")).toBe( + new Date("2026-01-04T00:00:00Z").getTime(), + ); + }); +}); + +describe("filterAndSortTasks", () => { + it.each([ + { title: "", description: "" }, + { title: " ", description: "\n\t" }, + ])("hides contentless placeholder tasks", ({ title, description }) => { + const placeholder = makeTask({ id: "placeholder", title, description }); + const realTask = makeTask({ id: "real" }); + + expect( + filterAndSortTasks([placeholder, realTask], "updated", false, "").map( + (task) => task.id, + ), + ).toEqual(["real"]); + }); + + it("selects internal or external tasks", () => { + const externalTask = makeTask({ id: "external", internal: false }); + const internalTask = makeTask({ id: "internal", internal: true }); + + expect( + filterAndSortTasks( + [externalTask, internalTask], + "updated", + false, + "", + ).map((task) => task.id), + ).toEqual(["external"]); + expect( + filterAndSortTasks([externalTask, internalTask], "updated", true, "").map( + (task) => task.id, + ), + ).toEqual(["internal"]); + }); + + it.each([ + ["title", { title: "Fix Login" }], + ["slug", { slug: "fix-login" }], + ["description", { description: "Fix Login" }], + ] as const)("matches a case-insensitive %s filter", (_field, overrides) => { + const matchingTask = makeTask({ id: "matching", ...overrides }); + const otherTask = makeTask({ id: "other", title: "Unrelated" }); + + expect( + filterAndSortTasks( + [otherTask, matchingTask], + "updated", + false, + "LOGIN", + ).map((task) => task.id), + ).toEqual(["matching"]); + }); + + it("sorts by the selected activity timestamp without mutating input", () => { + const olderCreated = makeTask({ + id: "older-created", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-04T00:00:00Z", + }); + const newerCreated = makeTask({ + id: "newer-created", + created_at: "2026-01-02T00:00:00Z", + updated_at: "2026-01-03T00:00:00Z", + }); + const tasks = [olderCreated, newerCreated]; + + expect( + filterAndSortTasks(tasks, "created", false, "").map((task) => task.id), + ).toEqual(["newer-created", "older-created"]); + expect( + filterAndSortTasks(tasks, "updated", false, "").map((task) => task.id), + ).toEqual(["older-created", "newer-created"]); + expect(tasks.map((task) => task.id)).toEqual([ + "older-created", + "newer-created", + ]); + }); +}); diff --git a/packages/core/src/tasks/taskActivity.ts b/packages/core/src/tasks/taskActivity.ts new file mode 100644 index 0000000000..6a52168e63 --- /dev/null +++ b/packages/core/src/tasks/taskActivity.ts @@ -0,0 +1,45 @@ +import { isContentlessTask, type Task } from "@posthog/shared/domain-types"; + +export type TaskActivitySortMode = "created" | "updated"; + +export function taskActivityTimestamp( + task: Pick, + sortMode: TaskActivitySortMode, +): number { + if (sortMode === "created") { + return new Date(task.created_at).getTime(); + } + + const runUpdatedAt = task.latest_run?.updated_at; + return Math.max( + runUpdatedAt ? new Date(runUpdatedAt).getTime() : 0, + new Date(task.updated_at ?? task.created_at).getTime(), + ); +} + +export function filterAndSortTasks( + tasks: readonly Task[], + sortMode: TaskActivitySortMode, + showInternal: boolean, + filter: string, +): Task[] { + const normalizedFilter = filter.toLowerCase(); + + return tasks + .filter((task) => !isContentlessTask(task)) + .filter((task) => + showInternal ? task.internal === true : task.internal !== true, + ) + .filter( + (task) => + !normalizedFilter || + task.title.toLowerCase().includes(normalizedFilter) || + task.slug.toLowerCase().includes(normalizedFilter) || + task.description?.toLowerCase().includes(normalizedFilter), + ) + .sort( + (firstTask, secondTask) => + taskActivityTimestamp(secondTask, sortMode) - + taskActivityTimestamp(firstTask, sortMode), + ); +} diff --git a/packages/core/src/tasks/taskArchive.test.ts b/packages/core/src/tasks/taskArchive.test.ts new file mode 100644 index 0000000000..ab22bbd459 --- /dev/null +++ b/packages/core/src/tasks/taskArchive.test.ts @@ -0,0 +1,44 @@ +import type { Task, TaskRunStatus } from "@posthog/shared/domain-types"; +import { describe, expect, it } from "vitest"; +import { isTaskRunning } from "./taskArchive"; + +function makeTask(status?: TaskRunStatus): Pick { + return { + latest_run: status + ? { + id: "run-1", + task: "task-1", + team: 1, + branch: null, + status, + log_url: "", + error_message: null, + output: null, + state: {}, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + completed_at: null, + } + : undefined, + }; +} + +describe("isTaskRunning", () => { + it("returns false when a task has no run", () => { + expect(isTaskRunning(makeTask())).toBe(false); + }); + + it.each(["not_started", "queued", "in_progress"] as const)( + "returns true for %s", + (status) => { + expect(isTaskRunning(makeTask(status))).toBe(true); + }, + ); + + it.each(["completed", "failed", "cancelled"] as const)( + "returns false for %s", + (status) => { + expect(isTaskRunning(makeTask(status))).toBe(false); + }, + ); +}); diff --git a/packages/core/src/tasks/taskArchive.ts b/packages/core/src/tasks/taskArchive.ts new file mode 100644 index 0000000000..db52bb6310 --- /dev/null +++ b/packages/core/src/tasks/taskArchive.ts @@ -0,0 +1,6 @@ +import { isTerminalStatus, type Task } from "@posthog/shared/domain-types"; + +export function isTaskRunning(task: Pick): boolean { + const status = task.latest_run?.status; + return status !== undefined && !isTerminalStatus(status); +} diff --git a/packages/core/src/tasks/taskStatusPresentation.test.ts b/packages/core/src/tasks/taskStatusPresentation.test.ts new file mode 100644 index 0000000000..a7895cf59e --- /dev/null +++ b/packages/core/src/tasks/taskStatusPresentation.test.ts @@ -0,0 +1,69 @@ +import type { Task, TaskRun } from "@posthog/shared/domain-types"; +import { describe, expect, it } from "vitest"; +import { getTaskStatusPresentationKind } from "./taskStatusPresentation"; + +function makeTask(latestRun?: Partial): Pick { + return { + latest_run: latestRun + ? { + id: "run-1", + task: "task-1", + team: 1, + branch: null, + status: "not_started", + log_url: "", + error_message: null, + output: null, + state: {}, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + completed_at: null, + ...latestRun, + } + : undefined, + }; +} + +describe("getTaskStatusPresentationKind", () => { + it("prioritizes a pull request over cloud presentation", () => { + expect( + getTaskStatusPresentationKind( + makeTask({ + environment: "cloud", + status: "in_progress", + output: { pr_url: "https://github.com/PostHog/code/pull/123" }, + }), + ), + ).toBe("pr"); + }); + + it.each([ + "not_started", + "queued", + "in_progress", + "completed", + "failed", + "cancelled", + ] as const)("uses chat presentation for cloud status %s", (status) => { + expect( + getTaskStatusPresentationKind(makeTask({ environment: "cloud", status })), + ).toBe("chat"); + }); + + it.each([ + ["completed", "completed"], + ["failed", "failed"], + ["in_progress", "running"], + ["queued", "started"], + ["not_started", "chat"], + ["cancelled", "chat"], + ] as const)("maps local status %s to %s", (status, expected) => { + expect( + getTaskStatusPresentationKind(makeTask({ environment: "local", status })), + ).toBe(expected); + }); + + it("falls back to chat when a task has no run", () => { + expect(getTaskStatusPresentationKind(makeTask())).toBe("chat"); + }); +}); diff --git a/packages/core/src/tasks/taskStatusPresentation.ts b/packages/core/src/tasks/taskStatusPresentation.ts new file mode 100644 index 0000000000..968a17bb76 --- /dev/null +++ b/packages/core/src/tasks/taskStatusPresentation.ts @@ -0,0 +1,37 @@ +import { readPrUrls } from "@posthog/shared"; +import type { Task } from "@posthog/shared/domain-types"; + +export type TaskStatusPresentationKind = + | "pr" + | "completed" + | "failed" + | "running" + | "started" + | "chat"; + +export function getTaskStatusPresentationKind( + task: Pick, +): TaskStatusPresentationKind { + const latestRun = task.latest_run; + + if (readPrUrls(latestRun?.output)[0]) { + return "pr"; + } + + if (latestRun?.environment === "cloud") { + return "chat"; + } + + switch (latestRun?.status) { + case "completed": + return "completed"; + case "failed": + return "failed"; + case "in_progress": + return "running"; + case "queued": + return "started"; + default: + return "chat"; + } +} From 53e538364092cc252cd383a9c8595a5c72268b8a Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 24 Jul 2026 19:19:34 +0300 Subject: [PATCH 11/43] refactor(core): rename cloud task service as engine Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- apps/web/src/web-container.ts | 2 +- .../core/src/cloud-task/{cloud-task.ts => cloud-task-engine.ts} | 0 packages/core/src/cloud-task/cloud-task.module.ts | 2 +- packages/core/src/cloud-task/cloud-task.test.ts | 2 +- packages/core/src/handoff/handoff.ts | 2 +- packages/host-router/src/routers/cloud-task.router.ts | 2 +- 6 files changed, 5 insertions(+), 5 deletions(-) rename packages/core/src/cloud-task/{cloud-task.ts => cloud-task-engine.ts} (100%) diff --git a/apps/web/src/web-container.ts b/apps/web/src/web-container.ts index 3075d139ce..cf17cd493e 100644 --- a/apps/web/src/web-container.ts +++ b/apps/web/src/web-container.ts @@ -27,8 +27,8 @@ import { } from "@posthog/core/auth/identifiers"; import { canvasCoreModule } from "@posthog/core/canvas/canvas.module"; import { taskThreadCoreModule } from "@posthog/core/canvas/taskThread.module"; -import type { CloudTaskService } from "@posthog/core/cloud-task/cloud-task"; import { cloudTaskModule } from "@posthog/core/cloud-task/cloud-task.module"; +import type { CloudTaskService } from "@posthog/core/cloud-task/cloud-task-engine"; import { CLOUD_TASK_AUTH, CLOUD_TASK_SERVICE, diff --git a/packages/core/src/cloud-task/cloud-task.ts b/packages/core/src/cloud-task/cloud-task-engine.ts similarity index 100% rename from packages/core/src/cloud-task/cloud-task.ts rename to packages/core/src/cloud-task/cloud-task-engine.ts diff --git a/packages/core/src/cloud-task/cloud-task.module.ts b/packages/core/src/cloud-task/cloud-task.module.ts index 02f0cc91db..464011d14f 100644 --- a/packages/core/src/cloud-task/cloud-task.module.ts +++ b/packages/core/src/cloud-task/cloud-task.module.ts @@ -1,5 +1,5 @@ import { ContainerModule } from "inversify"; -import { CloudTaskService } from "./cloud-task"; +import { CloudTaskService } from "./cloud-task-engine"; import { CLOUD_TASK_SERVICE } from "./identifiers"; export const cloudTaskModule = new ContainerModule(({ bind }) => { diff --git a/packages/core/src/cloud-task/cloud-task.test.ts b/packages/core/src/cloud-task/cloud-task.test.ts index a7fbf37c66..9002f948ff 100644 --- a/packages/core/src/cloud-task/cloud-task.test.ts +++ b/packages/core/src/cloud-task/cloud-task.test.ts @@ -22,7 +22,7 @@ const fetchRouter = vi.hoisted(() => }), ); -import { CloudTaskService } from "./cloud-task"; +import { CloudTaskService } from "./cloud-task-engine"; const mockAuthService = { authenticatedFetch: vi.fn(), diff --git a/packages/core/src/handoff/handoff.ts b/packages/core/src/handoff/handoff.ts index c85c2fcfa8..2b8d477099 100644 --- a/packages/core/src/handoff/handoff.ts +++ b/packages/core/src/handoff/handoff.ts @@ -5,7 +5,7 @@ import { TypedEventEmitter, } from "@posthog/shared"; import { inject, injectable } from "inversify"; -import type { CloudTaskService } from "../cloud-task/cloud-task"; +import type { CloudTaskService } from "../cloud-task/cloud-task-engine"; import { CLOUD_TASK_SERVICE } from "../cloud-task/identifiers"; import { HandoffSaga, type HandoffSagaDeps } from "./handoff-saga"; import { diff --git a/packages/host-router/src/routers/cloud-task.router.ts b/packages/host-router/src/routers/cloud-task.router.ts index 15d577ce59..4546995404 100644 --- a/packages/host-router/src/routers/cloud-task.router.ts +++ b/packages/host-router/src/routers/cloud-task.router.ts @@ -1,4 +1,4 @@ -import type { CloudTaskService } from "@posthog/core/cloud-task/cloud-task"; +import type { CloudTaskService } from "@posthog/core/cloud-task/cloud-task-engine"; import { CLOUD_TASK_SERVICE } from "@posthog/core/cloud-task/identifiers"; import { CloudTaskEvent, From 2245a2e61032c11dc77773d4fcd64afc9a121e2a Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 24 Jul 2026 19:20:17 +0300 Subject: [PATCH 12/43] refactor(core): extract portable cloud task engine Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- apps/web/src/web-container.ts | 2 +- .../core/src/cloud-task/cloud-task-engine.ts | 96 ++++++++++++------- .../src/cloud-task/cloud-task-service.test.ts | 27 ++++++ .../core/src/cloud-task/cloud-task.module.ts | 2 +- .../core/src/cloud-task/cloud-task.test.ts | 47 +++++---- packages/core/src/cloud-task/cloud-task.ts | 35 +++++++ packages/core/src/cloud-task/schemas.ts | 22 ++--- packages/core/src/handoff/handoff.ts | 2 +- packages/core/vitest.config.ts | 23 +++++ .../src/routers/cloud-task.router.ts | 2 +- 10 files changed, 183 insertions(+), 75 deletions(-) create mode 100644 packages/core/src/cloud-task/cloud-task-service.test.ts create mode 100644 packages/core/src/cloud-task/cloud-task.ts create mode 100644 packages/core/vitest.config.ts diff --git a/apps/web/src/web-container.ts b/apps/web/src/web-container.ts index cf17cd493e..3075d139ce 100644 --- a/apps/web/src/web-container.ts +++ b/apps/web/src/web-container.ts @@ -27,8 +27,8 @@ import { } from "@posthog/core/auth/identifiers"; import { canvasCoreModule } from "@posthog/core/canvas/canvas.module"; import { taskThreadCoreModule } from "@posthog/core/canvas/taskThread.module"; +import type { CloudTaskService } from "@posthog/core/cloud-task/cloud-task"; import { cloudTaskModule } from "@posthog/core/cloud-task/cloud-task.module"; -import type { CloudTaskService } from "@posthog/core/cloud-task/cloud-task-engine"; import { CLOUD_TASK_AUTH, CLOUD_TASK_SERVICE, diff --git a/packages/core/src/cloud-task/cloud-task-engine.ts b/packages/core/src/cloud-task/cloud-task-engine.ts index 22b5a1ddab..006b0ab6d2 100644 --- a/packages/core/src/cloud-task/cloud-task-engine.ts +++ b/packages/core/src/cloud-task/cloud-task-engine.ts @@ -1,37 +1,24 @@ +import type { RootLogger, ScopedLogger } from "@posthog/di/logger"; +import type { IAnalytics } from "@posthog/platform/analytics"; import { - ROOT_LOGGER, - type RootLogger, - type ScopedLogger, -} from "@posthog/di/logger"; -import { - ANALYTICS_SERVICE, - type IAnalytics, -} from "@posthog/platform/analytics"; -import type { StoredLogEntry } from "@posthog/shared"; -import { + type CloudTaskPermissionRequestUpdate, + isTerminalStatus, mcpToolKey, posthogToolMeta, + type StoredLogEntry, serializeError, + type TaskRunStatus, TypedEventEmitter, } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; -import { inject, injectable, optional, preDestroy } from "inversify"; -import type { CloudTaskPermissionRequestUpdate } from "./cloud-task-types"; -import { - CLOUD_TASK_AUTH, - type ICloudTaskAuth, - MCP_RELAY_EXECUTOR, - type McpRelayExecutor, -} from "./identifiers"; +import type { ICloudTaskAuth, McpRelayExecutor } from "./identifiers"; import { CloudTaskEvent, type CloudTaskEvents, - isTerminalStatus, type SendCommandInput, type SendCommandOutput, type StopInput, type StopOutput, - type TaskRunStatus, type WatchInput, } from "./schemas"; import { type SseEvent, SseEventParser } from "./sse-parser"; @@ -435,23 +422,45 @@ function sandboxAlivePayload(watcher: { lastSandboxAlive: boolean | null }): { : { sandboxAlive: watcher.lastSandboxAlive }; } -@injectable() -export class CloudTaskService extends TypedEventEmitter { +export interface CloudTaskEngineDependencies { + auth: ICloudTaskAuth; + analytics: IAnalytics; + logger: RootLogger; + mcpRelayExecutor?: McpRelayExecutor | null; + streamFetch?: CloudTaskFetch; +} + +export type CloudTaskFetch = ( + input: string | URL | Request, + init?: RequestInit, +) => Promise; + +export function createCloudTaskEngine( + dependencies: CloudTaskEngineDependencies, +): CloudTaskEngine { + return new CloudTaskEngine(dependencies); +} + +export class CloudTaskEngine extends TypedEventEmitter { private watchers = new Map(); private readonly log: ScopedLogger; - - constructor( - @inject(CLOUD_TASK_AUTH) - private readonly auth: ICloudTaskAuth, - @inject(ANALYTICS_SERVICE) - private readonly analytics: IAnalytics, - @inject(ROOT_LOGGER) - logger: RootLogger, - @inject(MCP_RELAY_EXECUTOR) - @optional() - private readonly mcpRelayExecutor: McpRelayExecutor | null = null, - ) { + private readonly auth: ICloudTaskAuth; + private readonly analytics: IAnalytics; + private readonly mcpRelayExecutor: McpRelayExecutor | null; + private readonly streamFetch: CloudTaskFetch; + + constructor({ + auth, + analytics, + logger, + mcpRelayExecutor = null, + streamFetch = globalThis.fetch.bind(globalThis), + }: CloudTaskEngineDependencies) { super(); + this.auth = auth; + this.analytics = analytics; + this.mcpRelayExecutor = mcpRelayExecutor; + this.streamFetch = streamFetch; this.log = logger.scope("cloud-task"); } @@ -770,6 +779,22 @@ export class CloudTaskService extends TypedEventEmitter { void this.bootstrapWatcher(key); } + reconnectIfDisconnected(taskId: string, runId: string): void { + const key = watcherKey(taskId, runId); + const watcher = this.watchers.get(key); + if ( + !watcher || + watcher.sseAbortController || + watcher.reconnectTimeoutId || + watcher.isBootstrapping || + isTerminalStatus(watcher.lastStatus) + ) { + return; + } + + void this.connectSse(key); + } + // Resets a watcher to its pre-bootstrap state so bootstrapWatcher can rebuild it from server truth. private resetWatcherForRebootstrap(watcher: WatcherState): void { watcher.reconnectAttempts = 0; @@ -959,7 +984,6 @@ export class CloudTaskService extends TypedEventEmitter { } } - @preDestroy() unwatchAll(): void { for (const key of [...this.watchers.keys()]) { this.stopWatcher(key); @@ -1306,7 +1330,7 @@ export class CloudTaskService extends TypedEventEmitter { try { // The proxy authenticates with the run-scoped Bearer token; the Django leg uses the session. const response = usingProxy - ? await fetch(url.toString(), { + ? await this.streamFetch(url.toString(), { method: "GET", headers, signal: controller.signal, diff --git a/packages/core/src/cloud-task/cloud-task-service.test.ts b/packages/core/src/cloud-task/cloud-task-service.test.ts new file mode 100644 index 0000000000..644f9dd7cb --- /dev/null +++ b/packages/core/src/cloud-task/cloud-task-service.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from "vitest"; +import { CloudTaskService } from "./cloud-task"; +import { CloudTaskEngine } from "./cloud-task-engine"; + +describe("CloudTaskService", () => { + it("preserves the injectable service API as a thin engine wrapper", () => { + const scopedLog = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + const service = new CloudTaskService( + { + authenticatedFetch: vi.fn(), + getCloudContext: vi.fn(), + }, + { track: vi.fn() } as never, + { ...scopedLog, scope: vi.fn(() => scopedLog) }, + ); + + expect(service).toBeInstanceOf(CloudTaskEngine); + expect(service.watch).toBeTypeOf("function"); + expect(service.retry).toBeTypeOf("function"); + expect(service.unwatchAll).toBeTypeOf("function"); + }); +}); diff --git a/packages/core/src/cloud-task/cloud-task.module.ts b/packages/core/src/cloud-task/cloud-task.module.ts index 464011d14f..02f0cc91db 100644 --- a/packages/core/src/cloud-task/cloud-task.module.ts +++ b/packages/core/src/cloud-task/cloud-task.module.ts @@ -1,5 +1,5 @@ import { ContainerModule } from "inversify"; -import { CloudTaskService } from "./cloud-task-engine"; +import { CloudTaskService } from "./cloud-task"; import { CLOUD_TASK_SERVICE } from "./identifiers"; export const cloudTaskModule = new ContainerModule(({ bind }) => { diff --git a/packages/core/src/cloud-task/cloud-task.test.ts b/packages/core/src/cloud-task/cloud-task.test.ts index 9002f948ff..4178bd3d78 100644 --- a/packages/core/src/cloud-task/cloud-task.test.ts +++ b/packages/core/src/cloud-task/cloud-task.test.ts @@ -5,14 +5,17 @@ const mockNetFetch = vi.hoisted(() => vi.fn()); const mockStreamFetch = vi.hoisted(() => vi.fn()); const mockStreamTokenFetch = vi.hoisted(() => vi.fn()); -// The service now uses global fetch for BOTH authenticated API calls (JSON) -// and SSE streaming. The two used to be distinct (net.fetch vs global fetch). // Route by URL: /stream_token/ → token mock (read-leg resolution), the stream leg // (Django /stream/ or proxy /v1/runs/:run/stream) → stream mock, everything else → API mock. // The token mock has a Django-path default so existing fixtures (which never set it) are untouched. const fetchRouter = vi.hoisted(() => - vi.fn((input: string | Request, init?: RequestInit) => { - const url = typeof input === "string" ? input : input.url; + vi.fn((input: string | URL | Request, init?: RequestInit) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; const impl = url.includes("/stream_token/") ? mockStreamTokenFetch : /\/stream(\/|\?|$)/.test(url) @@ -22,7 +25,10 @@ const fetchRouter = vi.hoisted(() => }), ); -import { CloudTaskService } from "./cloud-task-engine"; +import { + type CloudTaskEngine, + createCloudTaskEngine, +} from "./cloud-task-engine"; const mockAuthService = { authenticatedFetch: vi.fn(), @@ -86,8 +92,8 @@ async function waitFor( } } -describe("CloudTaskService", () => { - let service: CloudTaskService; +describe("CloudTaskEngine", () => { + let service: CloudTaskEngine; beforeEach(() => { const scopedLog = { @@ -98,11 +104,12 @@ describe("CloudTaskService", () => { }; const loggerMock = { ...scopedLog, scope: vi.fn(() => scopedLog) }; const analyticsMock = { track: vi.fn() }; - service = new CloudTaskService( - mockAuthService as never, - analyticsMock as never, - loggerMock, - ); + service = createCloudTaskEngine({ + auth: mockAuthService as never, + analytics: analyticsMock as never, + logger: loggerMock, + streamFetch: fetchRouter, + }); mockNetFetch.mockReset(); mockStreamFetch.mockReset(); mockStreamTokenFetch.mockReset(); @@ -3077,8 +3084,8 @@ describe("CloudTaskService", () => { }); }); -describe("CloudTaskService MCP relay", () => { - let relayService: CloudTaskService; +describe("CloudTaskEngine MCP relay", () => { + let relayService: CloudTaskEngine; let mcpRelayExecutor: { execute: ReturnType; closeRun: ReturnType; @@ -3099,12 +3106,12 @@ describe("CloudTaskService MCP relay", () => { })), closeRun: vi.fn(async () => {}), }; - relayService = new CloudTaskService( - mockAuthService as never, - analyticsMock as never, - loggerMock, - mcpRelayExecutor as never, - ); + relayService = createCloudTaskEngine({ + auth: mockAuthService as never, + analytics: analyticsMock as never, + logger: loggerMock, + mcpRelayExecutor: mcpRelayExecutor as never, + }); mockNetFetch.mockReset(); mockStreamFetch.mockReset(); diff --git a/packages/core/src/cloud-task/cloud-task.ts b/packages/core/src/cloud-task/cloud-task.ts new file mode 100644 index 0000000000..1e2003d85a --- /dev/null +++ b/packages/core/src/cloud-task/cloud-task.ts @@ -0,0 +1,35 @@ +import { ROOT_LOGGER, type RootLogger } from "@posthog/di/logger"; +import { + ANALYTICS_SERVICE, + type IAnalytics, +} from "@posthog/platform/analytics"; +import { inject, injectable, optional, preDestroy } from "inversify"; +import { CloudTaskEngine } from "./cloud-task-engine"; +import { + CLOUD_TASK_AUTH, + type ICloudTaskAuth, + MCP_RELAY_EXECUTOR, + type McpRelayExecutor, +} from "./identifiers"; + +@injectable() +export class CloudTaskService extends CloudTaskEngine { + constructor( + @inject(CLOUD_TASK_AUTH) + auth: ICloudTaskAuth, + @inject(ANALYTICS_SERVICE) + analytics: IAnalytics, + @inject(ROOT_LOGGER) + logger: RootLogger, + @inject(MCP_RELAY_EXECUTOR) + @optional() + mcpRelayExecutor: McpRelayExecutor | null = null, + ) { + super({ auth, analytics, logger, mcpRelayExecutor }); + } + + @preDestroy() + override unwatchAll(): void { + super.unwatchAll(); + } +} diff --git a/packages/core/src/cloud-task/schemas.ts b/packages/core/src/cloud-task/schemas.ts index d694e52141..b8c03eb202 100644 --- a/packages/core/src/cloud-task/schemas.ts +++ b/packages/core/src/cloud-task/schemas.ts @@ -1,20 +1,12 @@ -import type { TaskRunStatus } from "@posthog/shared"; +import type { CloudTaskUpdatePayload } from "@posthog/shared"; import { z } from "zod"; -import type { CloudTaskUpdatePayload } from "./cloud-task-types"; -export type { CloudTaskUpdatePayload, TaskRunStatus }; - -export const TERMINAL_STATUSES = ["completed", "failed", "cancelled"] as const; - -export function isTerminalStatus( - status: TaskRunStatus | string | null | undefined, -): boolean { - return ( - status !== null && - status !== undefined && - TERMINAL_STATUSES.includes(status as (typeof TERMINAL_STATUSES)[number]) - ); -} +export { + type CloudTaskUpdatePayload, + isTerminalStatus, + type TaskRunStatus, + TERMINAL_STATUSES, +} from "@posthog/shared"; // --- Events --- diff --git a/packages/core/src/handoff/handoff.ts b/packages/core/src/handoff/handoff.ts index 2b8d477099..c85c2fcfa8 100644 --- a/packages/core/src/handoff/handoff.ts +++ b/packages/core/src/handoff/handoff.ts @@ -5,7 +5,7 @@ import { TypedEventEmitter, } from "@posthog/shared"; import { inject, injectable } from "inversify"; -import type { CloudTaskService } from "../cloud-task/cloud-task-engine"; +import type { CloudTaskService } from "../cloud-task/cloud-task"; import { CLOUD_TASK_SERVICE } from "../cloud-task/identifiers"; import { HandoffSaga, type HandoffSagaDeps } from "./handoff-saga"; import { diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts new file mode 100644 index 0000000000..bed14b24e0 --- /dev/null +++ b/packages/core/vitest.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from "vitest/config"; +import { trunkTestOptions } from "../../vitest.config.base"; + +export default defineConfig({ + oxc: false, + esbuild: { + tsconfigRaw: { + compilerOptions: { + experimentalDecorators: true, + target: "ES2022", + useDefineForClassFields: false, + verbatimModuleSyntax: true, + }, + }, + }, + test: { + globals: true, + ...trunkTestOptions, + environment: "node", + include: ["src/**/*.test.ts", "src/**/*.test.tsx"], + exclude: ["**/node_modules/**", "**/dist/**"], + }, +}); diff --git a/packages/host-router/src/routers/cloud-task.router.ts b/packages/host-router/src/routers/cloud-task.router.ts index 4546995404..15d577ce59 100644 --- a/packages/host-router/src/routers/cloud-task.router.ts +++ b/packages/host-router/src/routers/cloud-task.router.ts @@ -1,4 +1,4 @@ -import type { CloudTaskService } from "@posthog/core/cloud-task/cloud-task-engine"; +import type { CloudTaskService } from "@posthog/core/cloud-task/cloud-task"; import { CLOUD_TASK_SERVICE } from "@posthog/core/cloud-task/identifiers"; import { CloudTaskEvent, From 36f9b398e6afb14a921e49148574f39f104a66c5 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 24 Jul 2026 19:21:03 +0300 Subject: [PATCH 13/43] refactor(core): extract repository integration semantics Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- .../src/integrations/repositories.test.ts | 66 ++++++++++ .../core/src/integrations/repositories.ts | 124 ++++++++++++++++++ 2 files changed, 190 insertions(+) diff --git a/packages/core/src/integrations/repositories.test.ts b/packages/core/src/integrations/repositories.test.ts index ce6af19540..80eec8ad36 100644 --- a/packages/core/src/integrations/repositories.test.ts +++ b/packages/core/src/integrations/repositories.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; import { + buildTeamRepositoryOptions, + buildUserRepositoryOptions, combineGithubRepositories, combineRepositoryPicker, combineUserGithubRepositories, @@ -7,8 +9,11 @@ import { isEmptyRepositoryMap, isRepoInIntegration, normalizeRepoKey, + normalizeRepositoryNames, type RepositoryCacheAction, type RepositoryQueryResult, + repositoryLoadWarning, + repositoryOptionsEqual, resolveEffectiveUserRepositoryMap, resolveUserRepositoryCacheAction, sameUserRepositoryMap, @@ -18,6 +23,67 @@ import { type UserRepositoryIntegrationRef, } from "./repositories"; +describe("repository options", () => { + it("normalizes repository names", () => { + expect(normalizeRepositoryNames(["PostHog/Code", ""])).toEqual([ + "posthog/code", + ]); + }); + + it("builds sorted team options with integration labels", () => { + expect( + buildTeamRepositoryOptions( + [ + { id: 2, display_name: "Work" }, + { id: 1, config: { account: { login: "personal" } } }, + ], + { 1: ["z/repo"], 2: ["a/repo"] }, + ), + ).toEqual([ + { integrationId: 2, integrationLabel: "Work", repository: "a/repo" }, + { + integrationId: 1, + integrationLabel: "personal", + repository: "z/repo", + }, + ]); + }); + + it("builds user options with the same shape", () => { + expect( + buildUserRepositoryOptions( + [{ id: "user-1", installation_id: "42", account: { name: "Me" } }], + { 42: ["posthog/code"] }, + ), + ).toEqual([ + { integrationId: 42, integrationLabel: "Me", repository: "posthog/code" }, + ]); + }); + + it.each([ + [0, 2, null], + [1, 2, "Some GitHub repositories could not be loaded. Pull to retry."], + [2, 2, "Could not load GitHub repositories. Pull to retry."], + ])( + "describes %i of %i failed repository loads", + (failed, total, expected) => { + expect(repositoryLoadWarning(failed, total)).toBe(expected); + }, + ); + + it("compares option lists by content", () => { + const options = [ + { integrationId: 1, integrationLabel: "Me", repository: "a/repo" }, + ]; + expect( + repositoryOptionsEqual( + options, + options.map((option) => ({ ...option })), + ), + ).toBe(true); + }); +}); + function result( data: T | undefined, flags: Partial, "data">> = {}, diff --git a/packages/core/src/integrations/repositories.ts b/packages/core/src/integrations/repositories.ts index 43a7fbf74e..ea9bb6d202 100644 --- a/packages/core/src/integrations/repositories.ts +++ b/packages/core/src/integrations/repositories.ts @@ -5,6 +5,130 @@ export interface RepositoryQueryResult { isRefetching: boolean; } +export interface RepositoryOption { + integrationId: number; + integrationLabel: string; + repository: string; +} + +export interface RepositorySelection { + integrationId: number | null; + repository: string | null; +} + +export interface TeamRepositoryIntegration { + id: number; + display_name?: string; + config?: { account?: { login?: string } }; +} + +export interface UserRepositoryIntegration { + id: string; + installation_id: string; + account?: { name?: string | null } | null; +} + +export function normalizeRepositoryNames( + repositories: ReadonlyArray, +): string[] { + return repositories + .map((repository) => repository.toLowerCase()) + .filter((repository) => repository.length > 0); +} + +export function repositoryLoadWarning( + failedCount: number, + totalCount: number, +): string | null { + if (failedCount === 0) return null; + return failedCount === totalCount + ? "Could not load GitHub repositories. Pull to retry." + : "Some GitHub repositories could not be loaded. Pull to retry."; +} + +export function buildTeamRepositoryOptions( + integrations: ReadonlyArray, + repositoriesByIntegration: Readonly>, +): RepositoryOption[] { + return integrations + .flatMap((integration) => + (repositoriesByIntegration[integration.id] ?? []).map((repository) => ({ + integrationId: integration.id, + integrationLabel: + integration.display_name ?? + integration.config?.account?.login ?? + `GitHub ${integration.id}`, + repository, + })), + ) + .sort((left, right) => left.repository.localeCompare(right.repository)); +} + +export function buildUserRepositoryOptions( + integrations: ReadonlyArray, + repositoriesByInstallation: Readonly>, +): RepositoryOption[] { + return integrations + .flatMap((integration) => + (repositoriesByInstallation[integration.installation_id] ?? []).map( + (repository) => ({ + integrationId: Number(integration.installation_id), + integrationLabel: + integration.account?.name ?? + `GitHub ${integration.installation_id}`, + repository, + }), + ), + ) + .sort((left, right) => left.repository.localeCompare(right.repository)); +} + +export function repositoryOptionsEqual( + left: ReadonlyArray, + right: ReadonlyArray, +): boolean { + return ( + left.length === right.length && + left.every((option, index) => { + const other = right[index]; + return ( + other?.integrationId === option.integrationId && + other.integrationLabel === option.integrationLabel && + other.repository === option.repository + ); + }) + ); +} + +export function findRepositoryOption( + options: ReadonlyArray, + selection: RepositorySelection, +): RepositoryOption | null { + if (!selection.integrationId || !selection.repository) return null; + return ( + options.find( + (option) => + option.integrationId === selection.integrationId && + option.repository === selection.repository, + ) ?? null + ); +} + +export function toRepositorySelection( + option: RepositoryOption | null, +): RepositorySelection { + return { + integrationId: option?.integrationId ?? null, + repository: option?.repository ?? null, + }; +} + +export function isRepositorySelectionComplete( + selection: RepositorySelection, +): boolean { + return !!selection.integrationId && !!selection.repository; +} + export interface TeamRepositoriesResult { integrationId: number; repos?: string[] | null; From 3003336e7f5c74b0181f0f79a4ec92524c372071 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 24 Jul 2026 19:21:33 +0300 Subject: [PATCH 14/43] refactor(core): extract pending prompt recovery Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- .../core/src/tasks/pendingPrompts.test.ts | 37 +++++++++++++++ packages/core/src/tasks/pendingPrompts.ts | 47 +++++++++++++++++++ .../task-detail/hooks/useTaskCreation.ts | 7 ++- .../ui/src/shell/pendingTaskPromptStore.ts | 45 +++++++----------- 4 files changed, 105 insertions(+), 31 deletions(-) create mode 100644 packages/core/src/tasks/pendingPrompts.test.ts create mode 100644 packages/core/src/tasks/pendingPrompts.ts diff --git a/packages/core/src/tasks/pendingPrompts.test.ts b/packages/core/src/tasks/pendingPrompts.test.ts new file mode 100644 index 0000000000..8a733ad113 --- /dev/null +++ b/packages/core/src/tasks/pendingPrompts.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { + buildPendingPromptKey, + capPendingPrompts, + listPendingPromptsNewestFirst, + selectNewestPendingPrompt, +} from "./pendingPrompts"; + +describe("pending prompts", () => { + it("keeps the newest prompts up to the limit", () => { + expect( + capPendingPrompts( + { + old: { createdAt: 1 }, + middle: { createdAt: 2 }, + newest: { createdAt: 3 }, + }, + 2, + ), + ).toEqual({ middle: { createdAt: 2 }, newest: { createdAt: 3 } }); + }); + + it("orders prompts newest first and selects the newest", () => { + const prompts = { old: { createdAt: 1 }, new: { createdAt: 2 } }; + expect( + listPendingPromptsNewestFirst(prompts).map(({ key }) => key), + ).toEqual(["new", "old"]); + expect(selectNewestPendingPrompt(prompts)?.key).toBe("new"); + }); + + it.each([ + ["uuid", 1, "abc", "uuid"], + [null, 123, "abc", "pending-123-abc"], + ])("builds a portable pending key", (uuid, timestamp, entropy, expected) => { + expect(buildPendingPromptKey(uuid, timestamp, entropy)).toBe(expected); + }); +}); diff --git a/packages/core/src/tasks/pendingPrompts.ts b/packages/core/src/tasks/pendingPrompts.ts new file mode 100644 index 0000000000..e4f77eafa2 --- /dev/null +++ b/packages/core/src/tasks/pendingPrompts.ts @@ -0,0 +1,47 @@ +export const MAX_RECOVERABLE_PROMPTS = 20; + +export interface TimestampedPendingPrompt { + createdAt: number; +} + +export interface RecoverablePendingPrompt< + TPrompt extends TimestampedPendingPrompt, +> { + key: string; + prompt: TPrompt; +} + +export function capPendingPrompts( + byKey: Record, + limit: number = MAX_RECOVERABLE_PROMPTS, +): Record { + const keys = Object.keys(byKey); + if (keys.length <= limit) return byKey; + + const kept = keys + .sort((left, right) => byKey[right].createdAt - byKey[left].createdAt) + .slice(0, limit); + return Object.fromEntries(kept.map((key) => [key, byKey[key]])); +} + +export function listPendingPromptsNewestFirst< + TPrompt extends TimestampedPendingPrompt, +>(byKey: Record): RecoverablePendingPrompt[] { + return Object.entries(byKey) + .map(([key, prompt]) => ({ key, prompt })) + .sort((left, right) => right.prompt.createdAt - left.prompt.createdAt); +} + +export function selectNewestPendingPrompt< + TPrompt extends TimestampedPendingPrompt, +>(byKey: Record): RecoverablePendingPrompt | null { + return listPendingPromptsNewestFirst(byKey)[0] ?? null; +} + +export function buildPendingPromptKey( + randomUuid: string | null, + timestamp: number, + entropy: string, +): string { + return randomUuid ?? `pending-${timestamp}-${entropy}`; +} diff --git a/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts b/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts index d13a1c445f..9b3abdaae0 100644 --- a/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts +++ b/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts @@ -31,7 +31,10 @@ import { useConnectivity } from "../../../hooks/useConnectivity"; import { toast } from "../../../primitives/toast"; import { track } from "../../../shell/analytics"; import { logger } from "../../../shell/logger"; -import { pendingTaskPromptStoreApi } from "../../../shell/pendingTaskPromptStore"; +import { + generatePendingTaskKey, + pendingTaskPromptStoreApi, +} from "../../../shell/pendingTaskPromptStore"; import { titleAttachmentStoreApi } from "../../../shell/titleAttachmentStore"; import { useAuthStateValue } from "../../auth/store"; import { assertCloudUsageAvailable } from "../../billing/preflightCloudUsage"; @@ -319,7 +322,7 @@ export function useTaskCreation({ const shouldShowPendingView = !onTaskCreated && !!plainPromptText; const pendingTaskKey = shouldShowPendingView - ? (globalThis.crypto?.randomUUID?.() ?? `pending-${Date.now()}`) + ? generatePendingTaskKey() : null; if (pendingTaskKey) { diff --git a/packages/ui/src/shell/pendingTaskPromptStore.ts b/packages/ui/src/shell/pendingTaskPromptStore.ts index b91fefd3f4..cd227ef24a 100644 --- a/packages/ui/src/shell/pendingTaskPromptStore.ts +++ b/packages/ui/src/shell/pendingTaskPromptStore.ts @@ -1,13 +1,8 @@ import type { UserMessageAttachment } from "@posthog/ui/features/sessions/userMessageTypes"; -import { logger } from "@posthog/ui/shell/logger"; import { electronStorage } from "@posthog/ui/shell/rendererStorage"; import { create } from "zustand"; import { persist } from "zustand/middleware"; -const log = logger.scope("pending-task-prompts"); - -const MAX_PENDING_PROMPTS = 20; - export interface PendingTaskPrompt { promptText: string; attachments: UserMessageAttachment[]; @@ -16,26 +11,6 @@ export interface PendingTaskPrompt { export type PendingTaskPromptInput = Omit; -function capToNewest( - byKey: Record, -): Record { - const keys = Object.keys(byKey); - if (keys.length <= MAX_PENDING_PROMPTS) { - return byKey; - } - const keptKeys = keys - .sort((a, b) => byKey[b].createdAt - byKey[a].createdAt) - .slice(0, MAX_PENDING_PROMPTS); - log.warn("Dropping oldest unrecovered prompts beyond cap", { - dropped: keys.length - keptKeys.length, - }); - const kept: Record = {}; - for (const key of keptKeys) { - kept[key] = byKey[key]; - } - return kept; -} - interface PendingTaskPromptStore { byKey: Record; _hasHydrated: boolean; @@ -54,7 +29,7 @@ export const usePendingTaskPromptStore = create()( setHasHydrated: (hydrated) => set({ _hasHydrated: hydrated }), set: (key, prompt) => set((state) => ({ - byKey: capToNewest({ + byKey: capPendingPrompts({ ...state.byKey, [key]: { ...prompt, createdAt: Date.now() }, }), @@ -110,9 +85,7 @@ export const pendingTaskPromptStoreApi = { usePendingTaskPromptStore.getState().move(fromKey, toKey), clear: (key: string) => usePendingTaskPromptStore.getState().clear(key), getAllNewestFirst: (): RecoverablePendingPrompt[] => - Object.entries(usePendingTaskPromptStore.getState().byKey) - .map(([key, prompt]) => ({ key, prompt })) - .sort((a, b) => b.prompt.createdAt - a.prompt.createdAt), + listPendingPromptsNewestFirst(usePendingTaskPromptStore.getState().byKey), whenHydrated: (): Promise => { if (usePendingTaskPromptStore.getState()._hasHydrated) { return Promise.resolve(); @@ -128,6 +101,14 @@ export const pendingTaskPromptStoreApi = { }, }; +export function generatePendingTaskKey(): string { + return buildPendingPromptKey( + globalThis.crypto?.randomUUID?.() ?? null, + Date.now(), + Math.random().toString(36).slice(2, 10), + ); +} + export function usePendingTaskPrompt( key: string | undefined, ): PendingTaskPrompt | undefined { @@ -135,3 +116,9 @@ export function usePendingTaskPrompt( key ? state.byKey[key] : undefined, ); } + +import { + buildPendingPromptKey, + capPendingPrompts, + listPendingPromptsNewestFirst, +} from "@posthog/core/tasks/pendingPrompts"; From b5204c8a1322c09f3b1f9a6432529a65479d787f Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 24 Jul 2026 19:22:05 +0300 Subject: [PATCH 15/43] refactor(core): extract plan approval presentation Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- .../sessions/planApprovalPresentation.test.ts | 29 +++++++++++++++++++ .../src/sessions/planApprovalPresentation.ts | 23 +++++++++++++++ .../session-update/PlanApprovalView.tsx | 19 ++++-------- 3 files changed, 57 insertions(+), 14 deletions(-) create mode 100644 packages/core/src/sessions/planApprovalPresentation.test.ts create mode 100644 packages/core/src/sessions/planApprovalPresentation.ts diff --git a/packages/core/src/sessions/planApprovalPresentation.test.ts b/packages/core/src/sessions/planApprovalPresentation.test.ts new file mode 100644 index 0000000000..27f9636189 --- /dev/null +++ b/packages/core/src/sessions/planApprovalPresentation.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { extractPlanText } from "./planApprovalPresentation"; + +describe("extractPlanText", () => { + it.each([ + [{ rawInput: { plan: "Raw plan" } }, "Raw plan"], + [{ content: [{ text: "Direct content" }] }, "Direct content"], + [ + { + content: [ + { type: "content", content: { type: "text", text: "Nested" } }, + ], + }, + "Nested", + ], + [{ rawInput: {}, content: [] }, null], + ])("extracts plan presentation from %o", (toolCall, expected) => { + expect(extractPlanText(toolCall)).toBe(expected); + }); + + it("prefers the canonical raw plan over rendered content", () => { + expect( + extractPlanText({ + rawInput: { plan: "Canonical" }, + content: [{ text: "Rendered" }], + }), + ).toBe("Canonical"); + }); +}); diff --git a/packages/core/src/sessions/planApprovalPresentation.ts b/packages/core/src/sessions/planApprovalPresentation.ts new file mode 100644 index 0000000000..2dd43553b6 --- /dev/null +++ b/packages/core/src/sessions/planApprovalPresentation.ts @@ -0,0 +1,23 @@ +function extractTextContent(item: unknown): string | null { + if (!item || typeof item !== "object") return null; + const record = item as Record; + if (typeof record.text === "string") return record.text; + + if (!record.content || typeof record.content !== "object") return null; + const content = record.content as Record; + return typeof content.text === "string" ? content.text : null; +} + +export function extractPlanText(toolCall: { + rawInput?: { plan?: unknown } | null; + content?: readonly unknown[] | null; +}): string | null { + const rawPlan = toolCall.rawInput?.plan; + if (typeof rawPlan === "string" && rawPlan.trim()) return rawPlan; + + for (const item of toolCall.content ?? []) { + const text = extractTextContent(item); + if (text?.trim()) return text; + } + return null; +} diff --git a/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.tsx b/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.tsx index b70c281953..bf609d89d2 100644 --- a/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.tsx +++ b/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.tsx @@ -1,4 +1,5 @@ import { CaretDown, CaretRight, CheckCircle } from "@phosphor-icons/react"; +import { extractPlanText } from "@posthog/core/sessions/planApprovalPresentation"; import { Box, Flex, Text } from "@radix-ui/themes"; import { useMemo, useState } from "react"; import { PlanContent } from "../../../permissions/PlanContent"; @@ -32,20 +33,10 @@ export function PlanApprovalView({ | undefined; const isHistoricalPlan = rawInput?.historical === true; - const planText = useMemo(() => { - if (content?.length) { - const textContent = content.find((c) => c.type === "content"); - if (textContent && "content" in textContent) { - const inner = textContent.content as - | { type?: string; text?: string } - | undefined; - if (inner?.type === "text" && inner.text) { - return inner.text; - } - } - } - return rawInput?.plan ?? null; - }, [content, rawInput?.plan]); + const planText = useMemo( + () => extractPlanText({ rawInput, content }), + [content, rawInput], + ); const wasNotApproved = isFailed || wasCancelled; const showResult = isHistoricalPlan || isComplete || wasNotApproved; From 7ccd2592d6cc8f4117fe8287442ee751aeeade10 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 24 Jul 2026 19:22:25 +0300 Subject: [PATCH 16/43] refactor(core): extract permission option presentation Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- .../src/sessions/permissionResponse.test.ts | 52 ++++++++++++++ .../core/src/sessions/permissionResponse.ts | 67 +++++++++++++++++++ .../permissions/PlanApprovalSelector.tsx | 42 +++--------- packages/ui/src/features/permissions/types.ts | 9 ++- 4 files changed, 131 insertions(+), 39 deletions(-) diff --git a/packages/core/src/sessions/permissionResponse.test.ts b/packages/core/src/sessions/permissionResponse.test.ts index 228a28142d..d467ca6154 100644 --- a/packages/core/src/sessions/permissionResponse.test.ts +++ b/packages/core/src/sessions/permissionResponse.test.ts @@ -2,10 +2,62 @@ import type { PermissionRequest } from "@posthog/shared"; import { describe, expect, it } from "vitest"; import { formatPermissionAnswerPrompt, + getPermissionOptionMeta, isOtherPermissionOption, + isPermissionApproval, + isPermissionRejection, + permissionOptionUsesCustomInput, planPermissionResponse, + resolveInitialPlanApprovalOption, + selectPlanPermissionOptions, } from "./permissionResponse"; +describe("permission option presentation", () => { + const approveOnce = { + optionId: "default", + name: "Approve", + kind: "allow_once" as const, + }; + const approveAuto = { + optionId: "auto", + name: "Approve automatically", + kind: "allow_always" as const, + }; + const reject = { + optionId: "reject_with_feedback", + name: "Reject", + kind: "reject_once" as const, + _meta: { customInput: true, description: "Explain why" }, + }; + + it("classifies approval, rejection, and custom-input options", () => { + expect(isPermissionApproval(approveOnce)).toBe(true); + expect(isPermissionRejection(reject)).toBe(true); + expect(permissionOptionUsesCustomInput(reject)).toBe(true); + expect(getPermissionOptionMeta(reject)).toEqual({ + customInput: true, + description: "Explain why", + }); + }); + + it("selects plan options and prefers a feedback rejection", () => { + expect(selectPlanPermissionOptions([approveOnce, reject])).toEqual({ + approvals: [approveOnce], + rejection: reject, + }); + }); + + it.each([ + ["default", "default"], + [null, "auto"], + ["missing", "auto"], + ])("resolves preferred approval %s", (preferred, expected) => { + expect( + resolveInitialPlanApprovalOption([approveOnce, approveAuto], preferred), + ).toBe(expected); + }); +}); + function makePermission( options: Array<{ optionId: string; diff --git a/packages/core/src/sessions/permissionResponse.ts b/packages/core/src/sessions/permissionResponse.ts index eef81a7f10..5cc4a596b5 100644 --- a/packages/core/src/sessions/permissionResponse.ts +++ b/packages/core/src/sessions/permissionResponse.ts @@ -1,5 +1,72 @@ import type { PermissionRequest } from "@posthog/shared"; +export type PermissionOption = PermissionRequest["options"][number]; + +export function getPermissionOptionMeta(option: PermissionOption): { + customInput: boolean; + description?: string; +} { + const meta = option._meta as + | { customInput?: boolean; description?: string } + | null + | undefined; + return { + customInput: meta?.customInput === true, + ...(meta?.description ? { description: meta.description } : {}), + }; +} + +export function isPermissionApproval(option: PermissionOption): boolean { + return option.kind === "allow_once" || option.kind === "allow_always"; +} + +export function isPermissionRejection(option: PermissionOption): boolean { + return ( + option.kind === "reject_once" || + option.kind === "reject_always" || + option.optionId.includes("reject") + ); +} + +export function permissionOptionUsesCustomInput( + option: PermissionOption, +): boolean { + return ( + isOtherPermissionOption(option.optionId) || + getPermissionOptionMeta(option).customInput + ); +} + +export function selectPlanPermissionOptions(options: PermissionOption[]): { + approvals: PermissionOption[]; + rejection: PermissionOption | null; +} { + const approvals = options.filter(isPermissionApproval); + const rejections = options.filter(isPermissionRejection); + return { + approvals, + rejection: + rejections.find(permissionOptionUsesCustomInput) ?? rejections[0] ?? null, + }; +} + +export function resolveInitialPlanApprovalOption( + approvals: PermissionOption[], + preferredOptionId?: string | null, +): string | undefined { + const has = (optionId: string): boolean => + approvals.some((option) => option.optionId === optionId); + return ( + (preferredOptionId && has(preferredOptionId) + ? preferredOptionId + : undefined) ?? + (has("auto") ? "auto" : undefined) ?? + approvals.find((option) => option.optionId === "default")?.optionId ?? + approvals.find((option) => option.kind === "allow_once")?.optionId ?? + approvals[0]?.optionId + ); +} + const OTHER_OPTION_ID = "_other"; const OTHER_OPTION_ID_ALT = "other"; diff --git a/packages/ui/src/features/permissions/PlanApprovalSelector.tsx b/packages/ui/src/features/permissions/PlanApprovalSelector.tsx index b22a77951f..145add84aa 100644 --- a/packages/ui/src/features/permissions/PlanApprovalSelector.tsx +++ b/packages/ui/src/features/permissions/PlanApprovalSelector.tsx @@ -1,7 +1,8 @@ -import type { - PermissionOption, - SessionConfigOption, -} from "@agentclientprotocol/sdk"; +import type { SessionConfigOption } from "@agentclientprotocol/sdk"; +import { + resolveInitialPlanApprovalOption, + selectPlanPermissionOptions, +} from "@posthog/core/sessions/permissionResponse"; import type { ExecutionMode } from "@posthog/shared"; import { ModeSelector } from "@posthog/ui/features/message-editor/components/ModeSelector"; import { MODE_LABELS } from "@posthog/ui/features/sessions/modeStyles"; @@ -17,21 +18,6 @@ import { type BasePermissionProps, toSelectorOptions } from "./types"; const TITLE = "Implementation Plan"; const QUESTION = "Approve this plan to proceed?"; -function isApprove(option: PermissionOption): boolean { - return option.kind === "allow_once" || option.kind === "allow_always"; -} - -function isReject(option: PermissionOption): boolean { - return option.kind === "reject_once" || option.kind === "reject_always"; -} - -function hasCustomInput(option: PermissionOption): boolean { - return ( - (option._meta as { customInput?: boolean } | null | undefined) - ?.customInput === true - ); -} - // Don't steal focus from an interactive element in a different grid cell // (multi-task view). Mirrors the guard in useActionSelectorState. function isInteractiveElementInDifferentCell( @@ -66,11 +52,8 @@ export function PlanApprovalSelector({ onSelect, onCancel, }: BasePermissionProps) { - const approveOptions = useMemo(() => options.filter(isApprove), [options]); - const rejectOption = useMemo( - () => - options.find((o) => isReject(o) && hasCustomInput(o)) ?? - options.find(isReject), + const { approvals: approveOptions, rejection: rejectOption } = useMemo( + () => selectPlanPermissionOptions(options), [options], ); @@ -87,16 +70,7 @@ export function PlanApprovalSelector({ // via `useMemo` (rather than seeding a `useState` once) means it stays // correct once the store finishes hydrating. const initialMode = useMemo(() => { - const has = (id: string) => approveOptions.some((o) => o.optionId === id); - return ( - (lastApprovalMode && has(lastApprovalMode) - ? lastApprovalMode - : undefined) ?? - (has("auto") ? "auto" : undefined) ?? - approveOptions.find((o) => o.optionId === "default")?.optionId ?? - approveOptions.find((o) => o.kind === "allow_once")?.optionId ?? - approveOptions[0]?.optionId - ); + return resolveInitialPlanApprovalOption(approveOptions, lastApprovalMode); }, [approveOptions, lastApprovalMode]); // Only the user's own pick lives in state; everything else derives from diff --git a/packages/ui/src/features/permissions/types.ts b/packages/ui/src/features/permissions/types.ts index 1505da5062..1794965cda 100644 --- a/packages/ui/src/features/permissions/types.ts +++ b/packages/ui/src/features/permissions/types.ts @@ -3,6 +3,7 @@ import type { RequestPermissionRequest, ToolCallContent, } from "@agentclientprotocol/sdk"; +import { getPermissionOptionMeta } from "@posthog/core/sessions/permissionResponse"; import type { CodeToolKind } from "@posthog/ui/features/sessions/types"; import type { SelectorOption } from "@posthog/ui/primitives/ActionSelector"; @@ -26,14 +27,12 @@ export function toSelectorOptions( options: PermissionOption[], ): SelectorOption[] { return options.map((opt) => { - const meta = opt._meta as - | { description?: string; customInput?: boolean } - | undefined; + const meta = getPermissionOptionMeta(opt); return { id: opt.optionId, label: opt.name, - description: meta?.description, - customInput: meta?.customInput, + description: meta.description, + customInput: meta.customInput, }; }); } From 046422e63a97b1c2125b878cc17b7aa734cb3edd Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 24 Jul 2026 19:22:47 +0300 Subject: [PATCH 17/43] refactor(core): extract composer controls Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- .../src/task-detail/composerControls.test.ts | 27 +++++ .../core/src/task-detail/composerControls.ts | 99 +++++++++++++++++++ .../components/UnifiedModelSelector.tsx | 4 +- 3 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/task-detail/composerControls.test.ts create mode 100644 packages/core/src/task-detail/composerControls.ts diff --git a/packages/core/src/task-detail/composerControls.test.ts b/packages/core/src/task-detail/composerControls.test.ts new file mode 100644 index 0000000000..0ba02d02cc --- /dev/null +++ b/packages/core/src/task-detail/composerControls.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { resolveComposerPrimaryAction } from "./composerControls"; + +describe("resolveComposerPrimaryAction", () => { + it.each([ + [{ hasContent: true }, "send"], + [{ canStop: true }, "stop"], + [{ canStop: true, hasContent: true }, "send"], + [{ canStop: true, hasContent: true, allowSendWhileRunning: false }, "stop"], + [{ isRecording: true }, "mic-stop"], + [{}, "mic"], + [{ disabled: true, hasContent: true }, "disabled"], + [{ isTranscribing: true }, "disabled"], + ])("derives %s", (overrides, expected) => { + expect( + resolveComposerPrimaryAction({ + hasContent: false, + disabled: false, + isRecording: false, + isTranscribing: false, + canStop: false, + allowSendWhileRunning: true, + ...overrides, + }), + ).toBe(expected); + }); +}); diff --git a/packages/core/src/task-detail/composerControls.ts b/packages/core/src/task-detail/composerControls.ts new file mode 100644 index 0000000000..e9694cccef --- /dev/null +++ b/packages/core/src/task-detail/composerControls.ts @@ -0,0 +1,99 @@ +import { + type Adapter, + type CloudTaskConfigOption, + DEFAULT_REASONING_EFFORT, + isRestrictedModelOption, + isSupportedReasoningEffort, + type SupportedReasoningEffort, +} from "@posthog/shared"; + +export interface ComposerModelOption { + value: string; + label: string; + description?: string; + disabled: boolean; +} + +export function getModelConfigOption( + configOptions: readonly CloudTaskConfigOption[], +): CloudTaskConfigOption { + const option = configOptions.find((item) => item.category === "model"); + if (!option) throw new Error("Cloud task model configuration is unavailable"); + return option; +} + +export function getComposerModelOptions( + modelOption: CloudTaskConfigOption, +): ComposerModelOption[] { + return modelOption.options.map((option) => ({ + value: option.value, + label: option.name, + description: option.description, + disabled: isRestrictedModelOption(option._meta), + })); +} + +export function getConfigOptionLabel( + options: ReadonlyArray<{ value: string; name: string }>, + value: string | undefined, +): string | undefined { + return options.find((option) => option.value === value)?.name ?? value; +} + +export function resolveAvailableModel( + modelOption: CloudTaskConfigOption, + value: string, +): string { + const selected = modelOption.options.find((option) => option.value === value); + return selected && !isRestrictedModelOption(selected._meta) + ? value + : modelOption.currentValue; +} + +export function resolveComposerModelChange({ + adapter, + modelOption, + requestedModel, + reasoning, +}: { + adapter: Adapter; + modelOption: CloudTaskConfigOption; + requestedModel: string; + reasoning: SupportedReasoningEffort; +}): { model: string; reasoning: SupportedReasoningEffort } { + const model = resolveAvailableModel(modelOption, requestedModel); + return { + model, + reasoning: isSupportedReasoningEffort(adapter, model, reasoning) + ? reasoning + : DEFAULT_REASONING_EFFORT, + }; +} + +export type ComposerPrimaryAction = + | "send" + | "stop" + | "mic" + | "mic-stop" + | "disabled"; + +export function resolveComposerPrimaryAction({ + hasContent, + disabled, + isRecording, + isTranscribing, + canStop, + allowSendWhileRunning, +}: { + hasContent: boolean; + disabled: boolean; + isRecording: boolean; + isTranscribing: boolean; + canStop: boolean; + allowSendWhileRunning: boolean; +}): ComposerPrimaryAction { + if (disabled || isTranscribing) return "disabled"; + if (canStop && (!allowSendWhileRunning || !hasContent)) return "stop"; + if (hasContent && !isRecording) return "send"; + return isRecording ? "mic-stop" : "mic"; +} diff --git a/packages/ui/src/features/sessions/components/UnifiedModelSelector.tsx b/packages/ui/src/features/sessions/components/UnifiedModelSelector.tsx index 06b3956f41..d34dbd9135 100644 --- a/packages/ui/src/features/sessions/components/UnifiedModelSelector.tsx +++ b/packages/ui/src/features/sessions/components/UnifiedModelSelector.tsx @@ -9,6 +9,7 @@ import { Robot, Spinner, } from "@phosphor-icons/react"; +import { getConfigOptionLabel } from "@posthog/core/task-detail/composerControls"; import { Button, DropdownMenu, @@ -78,8 +79,7 @@ export function UnifiedModelSelector({ }, [selectOption]); const currentValue = selectOption?.currentValue; - const currentLabel = - options.find((opt) => opt.value === currentValue)?.name ?? currentValue; + const currentLabel = getConfigOptionLabel(options, currentValue); const otherAdapter = getOtherAdapter(adapter); From 37f1c0ba9d4125f2ff41da71e43e60a2c8c71bf3 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 24 Jul 2026 19:23:30 +0300 Subject: [PATCH 18/43] refactor(core): extract composer model policy Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- .../task-detail/composerModelPolicy.test.ts | 42 +++++++++++++++++++ .../src/task-detail/composerModelPolicy.ts | 35 ++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 packages/core/src/task-detail/composerModelPolicy.test.ts create mode 100644 packages/core/src/task-detail/composerModelPolicy.ts diff --git a/packages/core/src/task-detail/composerModelPolicy.test.ts b/packages/core/src/task-detail/composerModelPolicy.test.ts new file mode 100644 index 0000000000..235c6199f6 --- /dev/null +++ b/packages/core/src/task-detail/composerModelPolicy.test.ts @@ -0,0 +1,42 @@ +import { + type Adapter, + type CloudTaskConfigOption, + DEFAULT_GATEWAY_MODEL, + restrictedModelMeta, + type SupportedReasoningEffort, +} from "@posthog/shared"; +import { expect, it } from "vitest"; +import { resolveCloudComposerModelChange } from "./composerModelPolicy"; + +const modelOption: CloudTaskConfigOption = { + id: "model", + name: "Model", + type: "select", + currentValue: DEFAULT_GATEWAY_MODEL, + options: [ + { value: DEFAULT_GATEWAY_MODEL, name: "Claude" }, + { value: "restricted", name: "Restricted", _meta: restrictedModelMeta() }, + { value: "gpt-5.3-codex", name: "Codex" }, + ], + category: "model", + description: "Choose a model", +}; + +it.each([ + ["claude", DEFAULT_GATEWAY_MODEL, "high", DEFAULT_GATEWAY_MODEL, "high"], + ["claude", "restricted", "high", DEFAULT_GATEWAY_MODEL, "high"], + ["claude", "missing", "high", DEFAULT_GATEWAY_MODEL, "high"], + ["codex", "gpt-5.3-codex", "xhigh", "gpt-5.3-codex", "high"], +] as const)( + "resolves %s model %s with %s reasoning", + (adapter, requestedModel, reasoning, expectedModel, expectedReasoning) => { + expect( + resolveCloudComposerModelChange({ + adapter: adapter as Adapter, + modelOption, + requestedModel, + reasoning: reasoning as SupportedReasoningEffort, + }), + ).toEqual({ model: expectedModel, reasoning: expectedReasoning }); + }, +); diff --git a/packages/core/src/task-detail/composerModelPolicy.ts b/packages/core/src/task-detail/composerModelPolicy.ts new file mode 100644 index 0000000000..d9783335f2 --- /dev/null +++ b/packages/core/src/task-detail/composerModelPolicy.ts @@ -0,0 +1,35 @@ +import { + type Adapter, + type CloudTaskConfigOption, + DEFAULT_REASONING_EFFORT, + isRestrictedModelOption, + isSupportedReasoningEffort, + type SupportedReasoningEffort, +} from "@posthog/shared"; + +export function resolveCloudComposerModelChange({ + adapter, + modelOption, + requestedModel, + reasoning, +}: { + adapter: Adapter; + modelOption: CloudTaskConfigOption; + requestedModel: string; + reasoning: SupportedReasoningEffort; +}): { model: string; reasoning: SupportedReasoningEffort } { + const selected = modelOption.options.find( + (option) => option.value === requestedModel, + ); + const model = + selected && !isRestrictedModelOption(selected._meta) + ? requestedModel + : modelOption.currentValue; + + return { + model, + reasoning: isSupportedReasoningEffort(adapter, model, reasoning) + ? reasoning + : DEFAULT_REASONING_EFFORT, + }; +} From 0a1c884c84bc23155b1d1e0420da7c00828bad89 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Sat, 25 Jul 2026 02:14:00 +0300 Subject: [PATCH 19/43] fix(core): prefer streamed plan content Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- packages/core/src/sessions/planApprovalPresentation.test.ts | 4 ++-- packages/core/src/sessions/planApprovalPresentation.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/core/src/sessions/planApprovalPresentation.test.ts b/packages/core/src/sessions/planApprovalPresentation.test.ts index 27f9636189..8519c8a41f 100644 --- a/packages/core/src/sessions/planApprovalPresentation.test.ts +++ b/packages/core/src/sessions/planApprovalPresentation.test.ts @@ -18,12 +18,12 @@ describe("extractPlanText", () => { expect(extractPlanText(toolCall)).toBe(expected); }); - it("prefers the canonical raw plan over rendered content", () => { + it("prefers streamed content over stale raw input", () => { expect( extractPlanText({ rawInput: { plan: "Canonical" }, content: [{ text: "Rendered" }], }), - ).toBe("Canonical"); + ).toBe("Rendered"); }); }); diff --git a/packages/core/src/sessions/planApprovalPresentation.ts b/packages/core/src/sessions/planApprovalPresentation.ts index 2dd43553b6..0817576da7 100644 --- a/packages/core/src/sessions/planApprovalPresentation.ts +++ b/packages/core/src/sessions/planApprovalPresentation.ts @@ -12,12 +12,12 @@ export function extractPlanText(toolCall: { rawInput?: { plan?: unknown } | null; content?: readonly unknown[] | null; }): string | null { - const rawPlan = toolCall.rawInput?.plan; - if (typeof rawPlan === "string" && rawPlan.trim()) return rawPlan; - for (const item of toolCall.content ?? []) { const text = extractTextContent(item); if (text?.trim()) return text; } + + const rawPlan = toolCall.rawInput?.plan; + if (typeof rawPlan === "string" && rawPlan.trim()) return rawPlan; return null; } From e27b337284941537e9cb5e6ff383aab58a7166b0 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 24 Jul 2026 19:24:43 +0300 Subject: [PATCH 20/43] refactor(core): extract session presentation semantics Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- .../core/src/sessions/posthogExecDisplay.ts | 85 +++++++++++ .../src/sessions/thinkingActivities.test.ts | 17 +++ .../core/src/sessions/thinkingActivities.ts | 103 +++++++++++++ .../mcp-apps/components/McpToolView.tsx | 2 +- .../features/permissions/McpPermission.tsx | 4 +- .../utils/posthog-exec-display.test.ts | 4 +- .../posthog-mcp/utils/posthog-exec-display.ts | 136 ------------------ .../components/GeneratingIndicator.tsx | 102 +------------ 8 files changed, 216 insertions(+), 237 deletions(-) create mode 100644 packages/core/src/sessions/posthogExecDisplay.ts create mode 100644 packages/core/src/sessions/thinkingActivities.test.ts create mode 100644 packages/core/src/sessions/thinkingActivities.ts delete mode 100644 packages/ui/src/features/posthog-mcp/utils/posthog-exec-display.ts diff --git a/packages/core/src/sessions/posthogExecDisplay.ts b/packages/core/src/sessions/posthogExecDisplay.ts new file mode 100644 index 0000000000..5b3029a535 --- /dev/null +++ b/packages/core/src/sessions/posthogExecDisplay.ts @@ -0,0 +1,85 @@ +import { parseMcpToolName } from "@posthog/shared"; + +const POSTHOG_SERVER_RE = /^(?:plugin_)?posthog(?:_[^_]+)*$/; +const POSTHOG_VERB_RE = + /^\s*(tools|search|info|schema|call)(?:\s+([\s\S]*))?\s*$/; +const POSTHOG_CALL_BODY_RE = /^(?:--json\s+)?([a-zA-Z0-9_-]+)\s*([\s\S]*)$/; +const POSTHOG_TOOL_NAME_RE = /^([a-zA-Z0-9_-]+)\s*([\s\S]*)$/; + +export interface PostHogExecDisplay { + label: string; + input?: string; +} + +export function isPostHogExecTool(toolName: string): boolean { + const mcp = parseMcpToolName(toolName); + return !!mcp && mcp.tool === "exec" && POSTHOG_SERVER_RE.test(mcp.server); +} + +export function getPostHogExecDisplay( + toolInput: unknown, +): PostHogExecDisplay | null { + if (!toolInput || typeof toolInput !== "object") return null; + const input = toolInput as { command?: unknown; input?: unknown }; + if (typeof input.command !== "string") return null; + const match = input.command.match(POSTHOG_VERB_RE); + if (!match) return null; + const verb = match[1] as "tools" | "search" | "info" | "schema" | "call"; + const rest = (match[2] ?? "").trim(); + const explicitInput = readExplicitInput(input.input); + + switch (verb) { + case "tools": + return { label: "List tools", input: undefined }; + case "search": + return { + label: "Search tools", + input: explicitInput ?? (rest || undefined), + }; + case "info": + return { label: rest ? `Read ${rest}` : "Read tool", input: undefined }; + case "schema": { + const schema = rest.match(POSTHOG_TOOL_NAME_RE); + if (!schema) return { label: "Inspect schema", input: undefined }; + const path = explicitInput ?? ((schema[2] ?? "").trim() || undefined); + return { + label: path + ? `Inspect ${schema[1]}.${path}` + : `Inspect ${schema[1]} fields`, + input: undefined, + }; + } + case "call": { + const call = rest.match(POSTHOG_CALL_BODY_RE); + if (!call) return null; + return { + label: call[1], + input: explicitInput ?? ((call[2] ?? "").trim() || undefined), + }; + } + } +} + +function readExplicitInput(value: unknown): string | undefined { + if (value === undefined || value === null) return undefined; + if (typeof value === "string") return value.trim() || undefined; + try { + return JSON.stringify(value); + } catch { + return undefined; + } +} + +export function formatPosthogExecBody( + input: string | undefined, +): string | undefined { + if (!input) return undefined; + try { + const parsed = JSON.parse(input); + if (parsed && typeof parsed === "object") + return JSON.stringify(parsed, null, 2); + } catch { + return input; + } + return input; +} diff --git a/packages/core/src/sessions/thinkingActivities.test.ts b/packages/core/src/sessions/thinkingActivities.test.ts new file mode 100644 index 0000000000..1b1ca94aa6 --- /dev/null +++ b/packages/core/src/sessions/thinkingActivities.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { + pickNextThinkingActivity, + pickThinkingActivity, + THINKING_ACTIVITIES, +} from "./thinkingActivities"; + +describe("thinking activities", () => { + it("selects from a bounded random value", () => { + expect(pickThinkingActivity(0)).toBe("Booping"); + expect(pickThinkingActivity(1)).toBe(THINKING_ACTIVITIES.at(-1)); + }); + + it("always advances when the random pick matches the current activity", () => { + expect(pickNextThinkingActivity("Booping", 0)).toBe("Crunching"); + }); +}); diff --git a/packages/core/src/sessions/thinkingActivities.ts b/packages/core/src/sessions/thinkingActivities.ts new file mode 100644 index 0000000000..0046775ffc --- /dev/null +++ b/packages/core/src/sessions/thinkingActivities.ts @@ -0,0 +1,103 @@ +export const THINKING_ACTIVITIES = [ + "Booping", + "Crunching", + "Digging", + "Fetching", + "Inferring", + "Indexing", + "Juggling", + "Noodling", + "Peeking", + "Percolating", + "Poking", + "Pondering", + "Scanning", + "Scrambling", + "Sifting", + "Sniffing", + "Spelunking", + "Tinkering", + "Unraveling", + "Decoding", + "Trekking", + "Sorting", + "Trimming", + "Mulling", + "Surfacing", + "Rummaging", + "Scouting", + "Scouring", + "Threading", + "Hunting", + "Swizzling", + "Grokking", + "Hedging", + "Scheming", + "Unfurling", + "Puzzling", + "Dissecting", + "Stacking", + "Snuffling", + "Hashing", + "Clustering", + "Teasing", + "Cranking", + "Merging", + "Snooping", + "Rewiring", + "Bundling", + "Linking", + "Mapping", + "Tickling", + "Flicking", + "Hopping", + "Rolling", + "Zipping", + "Twisting", + "Blooming", + "Sparking", + "Nesting", + "Looping", + "Wiring", + "Snipping", + "Zoning", + "Tracing", + "Warping", + "Twinkling", + "Flipping", + "Priming", + "Snagging", + "Scuttling", + "Framing", + "Sharpening", + "Flibbertigibbeting", + "Kerfuffling", + "Dithering", + "Discombobulating", + "Rambling", + "Befuddling", + "Waffling", + "Muckling", + "Hobnobbing", + "Galumphing", + "Puttering", + "Whiffling", + "Thinking", +] as const; + +export function pickThinkingActivity(randomValue: number): string { + const bounded = Math.max(0, Math.min(randomValue, 0.999999999999)); + return THINKING_ACTIVITIES[Math.floor(bounded * THINKING_ACTIVITIES.length)]; +} + +export function pickNextThinkingActivity( + current: string, + randomValue: number, +): string { + const picked = pickThinkingActivity(randomValue); + if (picked !== current || THINKING_ACTIVITIES.length <= 1) return picked; + const index = THINKING_ACTIVITIES.indexOf( + current as (typeof THINKING_ACTIVITIES)[number], + ); + return THINKING_ACTIVITIES[(index + 1) % THINKING_ACTIVITIES.length]; +} diff --git a/packages/ui/src/features/mcp-apps/components/McpToolView.tsx b/packages/ui/src/features/mcp-apps/components/McpToolView.tsx index 2a40dc99bc..6e99d80395 100644 --- a/packages/ui/src/features/mcp-apps/components/McpToolView.tsx +++ b/packages/ui/src/features/mcp-apps/components/McpToolView.tsx @@ -2,7 +2,7 @@ import { Plugs } from "@phosphor-icons/react"; import { getPostHogExecDisplay, isPostHogExecTool, -} from "../../posthog-mcp/utils/posthog-exec-display"; +} from "@posthog/core/sessions/posthogExecDisplay"; import { useChatThreadChrome } from "../../sessions/components/chat-thread/chatThreadChrome"; import { ToolRow } from "../../sessions/components/session-update/ToolRow"; import { diff --git a/packages/ui/src/features/permissions/McpPermission.tsx b/packages/ui/src/features/permissions/McpPermission.tsx index 6cf59f90cf..cd5ee5961d 100644 --- a/packages/ui/src/features/permissions/McpPermission.tsx +++ b/packages/ui/src/features/permissions/McpPermission.tsx @@ -1,9 +1,9 @@ -import { mcpToolKey, readMcpToolDescriptor } from "@posthog/shared"; import { formatPosthogExecBody, getPostHogExecDisplay, isPostHogExecTool, -} from "@posthog/ui/features/posthog-mcp/utils/posthog-exec-display"; +} from "@posthog/core/sessions/posthogExecDisplay"; +import { mcpToolKey, readMcpToolDescriptor } from "@posthog/shared"; import { formatInput } from "@posthog/ui/features/sessions/components/session-update/toolCallUtils"; import { ActionSelector } from "@posthog/ui/primitives/ActionSelector"; import { Box, Code } from "@radix-ui/themes"; diff --git a/packages/ui/src/features/posthog-mcp/utils/posthog-exec-display.test.ts b/packages/ui/src/features/posthog-mcp/utils/posthog-exec-display.test.ts index 08aa1b6f74..5a8603b301 100644 --- a/packages/ui/src/features/posthog-mcp/utils/posthog-exec-display.test.ts +++ b/packages/ui/src/features/posthog-mcp/utils/posthog-exec-display.test.ts @@ -1,9 +1,9 @@ -import { describe, expect, it } from "vitest"; import { formatPosthogExecBody, getPostHogExecDisplay, isPostHogExecTool, -} from "./posthog-exec-display"; +} from "@posthog/core/sessions/posthogExecDisplay"; +import { describe, expect, it } from "vitest"; describe("isPostHogExecTool", () => { it("matches the bare posthog exec tool", () => { diff --git a/packages/ui/src/features/posthog-mcp/utils/posthog-exec-display.ts b/packages/ui/src/features/posthog-mcp/utils/posthog-exec-display.ts deleted file mode 100644 index 50cda82a30..0000000000 --- a/packages/ui/src/features/posthog-mcp/utils/posthog-exec-display.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * The PostHog MCP exposes a single `exec` dispatcher that runs CLI-style - * subcommands. Generic MCP rendering would show this as - * `posthog - exec (MCP) {"command":"call execute-sql {…}"}` — pure plumbing - * with the dispatched action buried inside a JSON wrapper. - * - * These helpers pull the action out of the `command` string so the row can - * read `posthog - execute-sql {…}` (call), `posthog - Read execute-sql` - * (info), `posthog - Inspect query-trends.series` (schema), - * `posthog - Search tools query-` (search), or `posthog - List tools` - * (tools) instead. - * - * Supported verbs (per the `exec` tool description): - * tools — list every tool - * search — search by name/title/description - * info — show description + input schema - * schema [field_path] — drill into a specific field - * call [--json] — invoke a tool - */ - -import { parseMcpToolName } from "@posthog/shared"; - -// A PostHog MCP server name: optional `plugin_` prefix, `posthog`, then any -// number of `_` parts (e.g. `posthog`, `posthog_cloud`, -// `plugin_posthog_posthog`). The `exec` dispatcher lives on these servers. -const POSTHOG_SERVER_RE = /^(?:plugin_)?posthog(?:_[^_]+)*$/; - -const POSTHOG_VERB_RE = - /^\s*(tools|search|info|schema|call)(?:\s+([\s\S]*))?\s*$/; -const POSTHOG_CALL_BODY_RE = /^(?:--json\s+)?([a-zA-Z0-9_-]+)\s*([\s\S]*)$/; -const POSTHOG_TOOL_NAME_RE = /^([a-zA-Z0-9_-]+)\s*([\s\S]*)$/; - -export interface PostHogExecDisplay { - /** Replaces the tool name in the title — e.g. "execute-sql", "Read execute-sql". */ - label: string; - /** Args to show as the input preview, undefined when there is none to display. */ - input?: string; -} - -export function isPostHogExecTool(toolName: string): boolean { - const mcp = parseMcpToolName(toolName); - return !!mcp && mcp.tool === "exec" && POSTHOG_SERVER_RE.test(mcp.server); -} - -export function getPostHogExecDisplay( - toolInput: unknown, -): PostHogExecDisplay | null { - if (!toolInput || typeof toolInput !== "object") return null; - const obj = toolInput as { command?: unknown; input?: unknown }; - - if (typeof obj.command !== "string") return null; - const verbMatch = obj.command.match(POSTHOG_VERB_RE); - if (!verbMatch) return null; - - const verb = verbMatch[1] as "tools" | "search" | "info" | "schema" | "call"; - const rest = (verbMatch[2] ?? "").trim(); - const explicitInput = readExplicitInput(obj.input); - - switch (verb) { - case "tools": - // `tools` returns names only, not full schemas — "List", not "Read". - return { label: "List tools", input: undefined }; - - case "search": - return { - label: "Search tools", - input: explicitInput ?? (rest.length > 0 ? rest : undefined), - }; - - case "info": - // `info ` — fold the tool name into the label so the args slot stays clean. - return rest.length > 0 - ? { label: `Read ${rest}`, input: undefined } - : { label: "Read tool", input: undefined }; - - case "schema": { - // `schema [field_path]` is the drill-down verb. Fold the - // tool + path into a dotted locator so it reads as one path. - const m = rest.match(POSTHOG_TOOL_NAME_RE); - if (!m) return { label: "Inspect schema", input: undefined }; - const subTool = m[1]; - const fieldPath = (m[2] ?? "").trim(); - const path = - explicitInput ?? (fieldPath.length > 0 ? fieldPath : undefined); - return { - label: path - ? `Inspect ${subTool}.${path}` - : `Inspect ${subTool} fields`, - input: undefined, - }; - } - - case "call": { - // `call [--json] [json_input]` — collapse the verb, surface the - // sub-tool as the label and the JSON body as args. - const m = rest.match(POSTHOG_CALL_BODY_RE); - if (!m) return null; - const subTool = m[1]; - const args = (m[2] ?? "").trim(); - return { - label: subTool, - input: explicitInput ?? (args.length > 0 ? args : undefined), - }; - } - } -} - -function readExplicitInput(value: unknown): string | undefined { - if (value === undefined || value === null) return undefined; - if (typeof value === "string") return value.trim() || undefined; - try { - return JSON.stringify(value); - } catch { - return undefined; - } -} - -/** - * Pretty-prints the unwrapped exec args for display in the permission dialog - * body — JSON payloads (the `call` case) render multi-line; non-JSON args - * (e.g. a `search` regex) pass through unchanged. - */ -export function formatPosthogExecBody( - input: string | undefined, -): string | undefined { - if (!input) return undefined; - try { - const parsed = JSON.parse(input); - if (parsed && typeof parsed === "object") { - return JSON.stringify(parsed, null, 2); - } - } catch { - // not JSON — fall through and show raw - } - return input; -} diff --git a/packages/ui/src/features/sessions/components/GeneratingIndicator.tsx b/packages/ui/src/features/sessions/components/GeneratingIndicator.tsx index ba2a202e21..47343950d1 100644 --- a/packages/ui/src/features/sessions/components/GeneratingIndicator.tsx +++ b/packages/ui/src/features/sessions/components/GeneratingIndicator.tsx @@ -1,109 +1,19 @@ import { Brain, Circle } from "@phosphor-icons/react"; +import { + pickNextThinkingActivity, + pickThinkingActivity, +} from "@posthog/core/sessions/thinkingActivities"; import { Flex, Text } from "@radix-ui/themes"; import { useEffect, useRef, useState } from "react"; -const THINKING_MESSAGES = [ - "Booping", - "Crunching", - "Digging", - "Fetching", - "Inferring", - "Indexing", - "Juggling", - "Noodling", - "Peeking", - "Percolating", - "Poking", - "Pondering", - "Scanning", - "Scrambling", - "Sifting", - "Sniffing", - "Spelunking", - "Tinkering", - "Unraveling", - "Decoding", - "Trekking", - "Sorting", - "Trimming", - "Mulling", - "Surfacing", - "Rummaging", - "Scouting", - "Scouring", - "Threading", - "Hunting", - "Swizzling", - "Grokking", - "Hedging", - "Scheming", - "Unfurling", - "Puzzling", - "Dissecting", - "Stacking", - "Snuffling", - "Hashing", - "Clustering", - "Teasing", - "Cranking", - "Merging", - "Snooping", - "Rewiring", - "Bundling", - "Linking", - "Mapping", - "Tickling", - "Flicking", - "Hopping", - "Rolling", - "Zipping", - "Twisting", - "Blooming", - "Sparking", - "Nesting", - "Wiring", - "Snipping", - "Zoning", - "Tracing", - "Warping", - "Twinkling", - "Flipping", - "Priming", - "Snagging", - "Scuttling", - "Framing", - "Sharpening", - "Flibbertigibbeting", - "Kerfuffling", - "Dithering", - "Discombobulating", - "Rambling", - "Befuddling", - "Waffling", - "Muckling", - "Hobnobbing", - "Galumphing", - "Puttering", - "Whiffling", - "Thinking", -]; - function getRandomThinkingMessage(): string { - return THINKING_MESSAGES[ - Math.floor(Math.random() * THINKING_MESSAGES.length) - ]; + return pickThinkingActivity(Math.random()); } /** Pick a new word that differs from the current one, so consecutive changes * always read as a change. */ function getNextThinkingMessage(current: string): string { - if (THINKING_MESSAGES.length <= 1) return THINKING_MESSAGES[0]; - let next = current; - while (next === current) { - next = - THINKING_MESSAGES[Math.floor(Math.random() * THINKING_MESSAGES.length)]; - } - return next; + return pickNextThinkingActivity(current, Math.random()); } export function formatDuration(ms: number, fractionDigits = 2): string { From 04a7f525faf4ce436eb6d14905758c091f64511c Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 24 Jul 2026 19:25:57 +0300 Subject: [PATCH 21/43] refactor(api-client): expose shared automation contracts Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- .../api-client/src/posthog-client.test.ts | 27 ++++++ packages/api-client/src/posthog-client.ts | 93 +++++++++++++------ 2 files changed, 90 insertions(+), 30 deletions(-) diff --git a/packages/api-client/src/posthog-client.test.ts b/packages/api-client/src/posthog-client.test.ts index da93bc7960..cc25844ea1 100644 --- a/packages/api-client/src/posthog-client.test.ts +++ b/packages/api-client/src/posthog-client.test.ts @@ -475,6 +475,33 @@ describe("PostHogAPIClient", () => { ); }); + it.each([true, false])("forwards auto publish %s", async (autoPublish) => { + const client = new PostHogAPIClient( + "http://localhost:8000", + async () => "token", + async () => "token", + 123, + ); + const post = vi.fn().mockResolvedValue({ + id: "task-123", + title: "Task", + description: "Task", + created_at: "2026-04-14T00:00:00Z", + updated_at: "2026-04-14T00:00:00Z", + origin_product: "user_created", + }); + (client as unknown as { api: { post: typeof post } }).api = { post }; + + await client.runTaskInCloud("task-123", null, { autoPublish }); + + expect(post).toHaveBeenCalledWith( + "/api/projects/{project_id}/tasks/{id}/run/", + expect.objectContaining({ + body: expect.objectContaining({ auto_publish: autoPublish }), + }), + ); + }); + it("rejects unsupported reasoning effort for cloud Codex runs", async () => { const client = new PostHogAPIClient( "http://localhost:8000", diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index b2ef9fa8a8..75bf5183ea 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -4,30 +4,22 @@ import type { CloudMcpServerImport, CloudMcpServerRelayDesignation, CloudRunSource, - CreateTaskAutomationOptions, ExecutionMode, PrAuthorshipMode, SourceProduct, SourceType, StoredLogEntry, - TaskAutomation, TaskRunArtifactMetadata, - UpdateTaskAutomationOptions, } from "@posthog/shared"; import { buildCloudTaskConfigOptions, type CloudTaskConfigOption, - createTaskAutomationSchema, DISMISSAL_REASON_OPTIONS, type DismissalReasonOptionValue, getCloudTaskGatewayUrl, isSupportedReasoningEffort, normalizeGatewayModelsResponse, resolveCloudInitialPermissionMode, - taskAutomationListSchema, - taskAutomationSchema, - taskAutomationValidationErrorSchema, - updateTaskAutomationSchema, } from "@posthog/shared"; import type { AgentAnalyticsData, @@ -210,11 +202,22 @@ export class TaskAutomationValidationError extends Error { function rethrowTaskAutomationError(error: unknown): never { if (error instanceof ApiRequestError && error.status === 400) { - const validationError = taskAutomationValidationErrorSchema.safeParse( - error.body, - ); - if (validationError.success) { - throw new TaskAutomationValidationError(validationError.data); + const body = error.body; + if ( + typeof body === "object" && + body !== null && + "detail" in body && + typeof body.detail === "string" + ) { + throw new TaskAutomationValidationError({ + detail: body.detail, + code: + "code" in body && typeof body.code === "string" + ? body.code + : "invalid_input", + attr: + "attr" in body && typeof body.attr === "string" ? body.attr : null, + }); } } @@ -250,6 +253,41 @@ export type { export type Evaluation = Schemas.Evaluation; +export type TaskAutomation = Omit< + Schemas.TaskAutomation, + "github_integration" | "timezone" | "template_id" | "enabled" +> & { + github_integration: number | null; + timezone: string | null; + template_id: string | null; + enabled: boolean; +}; + +export type CreateTaskAutomationOptions = Pick< + Schemas.TaskAutomation, + "name" | "prompt" | "repository" | "cron_expression" +> & + Partial< + Pick< + Schemas.TaskAutomation, + "github_integration" | "template_id" | "enabled" + > + > & { timezone: string }; + +export type UpdateTaskAutomationOptions = Partial; + +function normalizeTaskAutomation( + automation: Schemas.TaskAutomation, +): TaskAutomation { + return { + ...automation, + github_integration: automation.github_integration ?? null, + timezone: automation.timezone ?? null, + template_id: automation.template_id ?? null, + enabled: automation.enabled ?? true, + }; +} + export interface UserGitHubIntegration { id: string; kind: "github"; @@ -787,7 +825,7 @@ function buildCloudRunRequestBody( if (options?.prAuthorshipMode) { body.pr_authorship_mode = options.prAuthorshipMode; } - if (options?.autoPublish) { + if (options?.autoPublish !== undefined) { body.auto_publish = options.autoPublish; } if (options?.rtkEnabled === false) { @@ -2486,7 +2524,7 @@ export class PostHogAPIClient { }, ); - return taskAutomationListSchema.parse(data).results; + return data.results.map(normalizeTaskAutomation); } async getTaskAutomation(automationId: string): Promise { @@ -2498,24 +2536,22 @@ export class PostHogAPIClient { }, ); - return taskAutomationSchema.parse(data); + return normalizeTaskAutomation(data); } async createTaskAutomation( options: CreateTaskAutomationOptions, ): Promise { const teamId = await this.getTeamId(); - const body = createTaskAutomationSchema.parse(options); - try { const data = await this.api.post( `/api/projects/{project_id}/task_automations/`, { path: { project_id: teamId.toString() }, - body: body as Schemas.TaskAutomation, + body: options as Schemas.TaskAutomation, }, ); - return taskAutomationSchema.parse(data); + return normalizeTaskAutomation(data); } catch (error) { rethrowTaskAutomationError(error); } @@ -2526,17 +2562,15 @@ export class PostHogAPIClient { updates: UpdateTaskAutomationOptions, ): Promise { const teamId = await this.getTeamId(); - const body = updateTaskAutomationSchema.parse(updates); - try { const data = await this.api.patch( `/api/projects/{project_id}/task_automations/{id}/`, { path: { project_id: teamId.toString(), id: automationId }, - body, + body: updates, }, ); - return taskAutomationSchema.parse(data); + return normalizeTaskAutomation(data); } catch (error) { rethrowTaskAutomationError(error); } @@ -2559,7 +2593,9 @@ export class PostHogAPIClient { path, url: new URL(`${this.api.baseUrl}${path}`), }); - return taskAutomationSchema.parse(await response.json()); + return normalizeTaskAutomation( + (await response.json()) as Schemas.TaskAutomation, + ); } catch (error) { rethrowTaskAutomationError(error); } @@ -2604,16 +2640,13 @@ export class PostHogAPIClient { return normalizeTaskResponse(data, { teamId }); } - async updateTask( - taskId: string, - updates: Partial, - ): Promise { + async updateTask(taskId: string, updates: Partial): Promise { const teamId = await this.getTeamId(); const data = await this.api.patch( `/api/projects/{project_id}/tasks/{id}/`, { path: { project_id: teamId.toString(), id: taskId }, - body: updates, + body: updates as unknown as Partial, }, ); From 0b25fcd86abfe0ce4a8b40f4c7fce246786d6292 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 24 Jul 2026 19:26:21 +0300 Subject: [PATCH 22/43] refactor(core): extract inbox presentation semantics Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- .../automationTemplatePresentation.ts | 37 +++++++++++++++++++ packages/core/src/inbox/engagement.ts | 27 ++++++++++---- packages/core/src/inbox/reportMembership.ts | 17 +++++++++ 3 files changed, 74 insertions(+), 7 deletions(-) create mode 100644 packages/core/src/automations/automationTemplatePresentation.ts diff --git a/packages/core/src/automations/automationTemplatePresentation.ts b/packages/core/src/automations/automationTemplatePresentation.ts new file mode 100644 index 0000000000..7d73244aa7 --- /dev/null +++ b/packages/core/src/automations/automationTemplatePresentation.ts @@ -0,0 +1,37 @@ +import type { TaskAutomation } from "@posthog/api-client/posthog-client"; + +export const SKILL_TEMPLATE_ID_PREFIX = "llm-skill:"; + +export function formatSkillTemplateId(skillName: string): string { + return `${SKILL_TEMPLATE_ID_PREFIX}${skillName.trim()}`; +} + +export function parseSkillTemplateId( + templateId: string | null | undefined, +): string | null { + if (!templateId?.startsWith(SKILL_TEMPLATE_ID_PREFIX)) return null; + const skillName = templateId.slice(SKILL_TEMPLATE_ID_PREFIX.length).trim(); + return skillName || null; +} + +export interface AutomationTemplatePresentation { + templateName: string | null; + repositoryLabel: string | null; + contextLabel: string | null; + secondaryLabel: string; +} + +export function getAutomationTemplatePresentation( + automation: Pick, +): AutomationTemplatePresentation { + const repositoryLabel = automation.repository.trim() || null; + const skillName = parseSkillTemplateId(automation.template_id); + const contextLabel = skillName ? "Skill store" : null; + return { + templateName: + skillName ?? (automation.template_id ? "Template automation" : null), + repositoryLabel, + contextLabel, + secondaryLabel: repositoryLabel ?? contextLabel ?? "No repository context", + }; +} diff --git a/packages/core/src/inbox/engagement.ts b/packages/core/src/inbox/engagement.ts index dc4428ddbb..012ca18dc4 100644 --- a/packages/core/src/inbox/engagement.ts +++ b/packages/core/src/inbox/engagement.ts @@ -174,13 +174,16 @@ export function buildBulkActionEvents( export interface InboxViewedFilterState { sourceProductFilter: string[]; priorityFilter: string[]; - searchQuery: string; + searchQuery?: string; + statusFilter?: readonly string[]; + defaultStatusFilter?: readonly string[]; + suggestedReviewerFilter?: string[]; /** * True when the reviewer scope is the default ("For you"). False when the * user has narrowed to a teammate or the whole project — treated as an * active filter for `has_active_filters`. */ - isDefaultScope: boolean; + isDefaultScope?: boolean; } export interface BuildInboxViewedInput { @@ -192,7 +195,7 @@ export interface BuildInboxViewedInput { /** Server-reported total of reports matching the active query — the headline inbox number. */ totalCount: number; /** Tab badge counts shown in the v2 header (the numbers the user actually sees). */ - tabCounts: { pulls: number; reports: number }; + tabCounts?: { pulls: number; reports: number }; filters: InboxViewedFilterState; } @@ -207,7 +210,8 @@ export interface BuildInboxViewedInput { export function buildInboxViewedProperties( input: BuildInboxViewedInput, ): InboxViewedProperties { - const { visibleReports, totalCount, tabCounts, filters } = input; + const { visibleReports, totalCount, filters } = input; + const tabCounts = input.tabCounts ?? { pulls: 0, reports: totalCount }; const priorityCounts = { P0: 0, P1: 0, P2: 0, P3: 0, P4: 0, unknown: 0 }; const actionabilityCounts = { @@ -237,11 +241,20 @@ export function buildInboxViewedProperties( } } + const statusFiltered = + filters.statusFilter !== undefined && + filters.defaultStatusFilter !== undefined && + (filters.statusFilter.length !== filters.defaultStatusFilter.length || + filters.statusFilter.some( + (status) => !filters.defaultStatusFilter?.includes(status), + )); const hasActiveFilters = filters.sourceProductFilter.length > 0 || filters.priorityFilter.length > 0 || - filters.searchQuery.trim().length > 0 || - !filters.isDefaultScope; + (filters.searchQuery?.trim().length ?? 0) > 0 || + statusFiltered || + (filters.suggestedReviewerFilter?.length ?? 0) > 0 || + filters.isDefaultScope === false; return { report_count: visibleReports.length, @@ -249,7 +262,7 @@ export function buildInboxViewedProperties( ready_count: readyCount, has_active_filters: hasActiveFilters, source_product_filter: filters.sourceProductFilter, - status_filter_count: 0, + status_filter_count: filters.statusFilter?.length ?? 0, is_empty: totalCount === 0, priority_p0_count: priorityCounts.P0, priority_p1_count: priorityCounts.P1, diff --git a/packages/core/src/inbox/reportMembership.ts b/packages/core/src/inbox/reportMembership.ts index edfca5f9e8..e31621fba3 100644 --- a/packages/core/src/inbox/reportMembership.ts +++ b/packages/core/src/inbox/reportMembership.ts @@ -35,6 +35,23 @@ export function isDismissedReport(report: SignalReport): boolean { return report.status === "suppressed" || report.status === "resolved"; } +export function isRestorableReport( + report: Pick, +): boolean { + return report.status === "suppressed"; +} + +export function getImmediatelyActionableReports( + reports: SignalReport[], +): SignalReport[] { + return reports.filter( + (report) => + report.status === "ready" && + report.actionability === "immediately_actionable" && + !report.already_addressed, + ); +} + export type InboxScope = "for-you" | "entire-project" | `teammate:${string}`; export const INBOX_SCOPE_FOR_YOU: InboxScope = "for-you"; From afa5cb66f1dc49d187bb03c698076d03d639ed48 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 24 Jul 2026 19:26:59 +0300 Subject: [PATCH 23/43] refactor(core): extract inbox activity presentation Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- packages/core/src/inbox/activityLog.ts | 74 +++++++++++++++++++ .../components/detail/ArtefactLogList.tsx | 11 +-- .../components/detail/ArtefactTaskRun.tsx | 25 ++----- 3 files changed, 81 insertions(+), 29 deletions(-) create mode 100644 packages/core/src/inbox/activityLog.ts diff --git a/packages/core/src/inbox/activityLog.ts b/packages/core/src/inbox/activityLog.ts new file mode 100644 index 0000000000..11fb75cab2 --- /dev/null +++ b/packages/core/src/inbox/activityLog.ts @@ -0,0 +1,74 @@ +import type { AnySignalReportArtefact } from "@posthog/shared/domain-types"; + +export type ActivityArtefact = Extract< + AnySignalReportArtefact, + { type: "commit" | "task_run" } +>; + +export function selectActivityArtefacts( + artefacts: AnySignalReportArtefact[], +): ActivityArtefact[] { + return artefacts + .filter( + (artefact): artefact is ActivityArtefact => + artefact.type === "commit" || artefact.type === "task_run", + ) + .sort((left, right) => left.created_at.localeCompare(right.created_at)); +} + +export function shortSha(sha: string): string { + return sha.slice(0, 12); +} + +const SIGNALS_TYPE_LABELS: Record = { + research: "Research", + implementation: "Implementation", + repo_selection: "Repo selection", +}; + +export function humanizeIdentifier(value: string): string { + const spaced = value.replace(/[_-]+/g, " ").trim(); + return spaced.charAt(0).toUpperCase() + spaced.slice(1); +} + +export function taskRunLabel(content: { + product: string; + type: string; +}): string { + return content.product === "signals" + ? (SIGNALS_TYPE_LABELS[content.type] ?? humanizeIdentifier(content.type)) + : humanizeIdentifier(content.type); +} + +export function attributionLabel(artefact: { + created_by?: { first_name?: string; email: string } | null; + task_id?: string | null; +}): string | null { + if (artefact.created_by) { + return artefact.created_by.first_name?.trim() || artefact.created_by.email; + } + return artefact.task_id ? "agent" : null; +} + +export type DiffLineKind = "add" | "del" | "hunk" | "context"; + +export interface DiffLine { + text: string; + kind: DiffLineKind; +} + +export function parseDiffLines(diff: string): DiffLine[] { + return diff + .replace(/\n$/, "") + .split("\n") + .map((text) => { + if (text.startsWith("+") && !text.startsWith("+++")) { + return { text, kind: "add" as const }; + } + if (text.startsWith("-") && !text.startsWith("---")) { + return { text, kind: "del" as const }; + } + if (text.startsWith("@@")) return { text, kind: "hunk" as const }; + return { text, kind: "context" as const }; + }); +} diff --git a/packages/ui/src/features/inbox/components/detail/ArtefactLogList.tsx b/packages/ui/src/features/inbox/components/detail/ArtefactLogList.tsx index 9170e124b4..58fed94c23 100644 --- a/packages/ui/src/features/inbox/components/detail/ArtefactLogList.tsx +++ b/packages/ui/src/features/inbox/components/detail/ArtefactLogList.tsx @@ -3,6 +3,7 @@ import { CaretDownIcon, CaretRightIcon, } from "@phosphor-icons/react"; +import { attributionLabel } from "@posthog/core/inbox/activityLog"; import type { ActionabilityJudgmentContent, AnySignalReportArtefact, @@ -70,16 +71,6 @@ function languageFromPath(filePath: string): string { * Who produced the artefact: a user's name, "agent" for task-attributed writes, * or null for system (pipeline) writes and pre-attribution rows. */ -function attributionLabel(artefact: AnySignalReportArtefact): string | null { - if (artefact.created_by) { - return artefact.created_by.first_name?.trim() || artefact.created_by.email; - } - if (artefact.task_id) { - return "agent"; - } - return null; -} - // The generic `SignalReportArtefact` fallback carries `type: string`, so it stays // in every narrowed branch and breaks discriminated-union narrowing — the runtime // `type` dispatch is authoritative (content is set alongside type in the diff --git a/packages/ui/src/features/inbox/components/detail/ArtefactTaskRun.tsx b/packages/ui/src/features/inbox/components/detail/ArtefactTaskRun.tsx index ed07a0d98a..d3fe02b8ed 100644 --- a/packages/ui/src/features/inbox/components/detail/ArtefactTaskRun.tsx +++ b/packages/ui/src/features/inbox/components/detail/ArtefactTaskRun.tsx @@ -1,4 +1,8 @@ import { CaretDownIcon, CaretRightIcon } from "@phosphor-icons/react"; +import { + humanizeIdentifier, + taskRunLabel, +} from "@posthog/core/inbox/activityLog"; import type { Task, TaskRunArtefactContent } from "@posthog/shared/types"; import { TaskLogsPanel } from "@posthog/ui/features/task-detail/components/TaskLogsPanel"; import { taskKeys } from "@posthog/ui/features/tasks/taskKeys"; @@ -6,21 +10,6 @@ import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; import { Badge, Box, Text } from "@radix-ui/themes"; import { useState } from "react"; -const SIGNALS_PRODUCT = "signals"; - -// Friendlier labels for the built-in signals-pipeline task types; custom-agent types fall back -// to a humanized form of their identifier. -const SIGNALS_TYPE_LABELS: Record = { - research: "Research", - implementation: "Implementation", - repo_selection: "Repo selection", -}; - -function humanizeIdentifier(value: string): string { - const spaced = value.replace(/[_-]+/g, " ").trim(); - return spaced.charAt(0).toUpperCase() + spaced.slice(1); -} - /** * Renders a `task_run` artefact: loads the referenced task and lets the user * expand it to read the full conversation log (read-only) via `TaskLogsPanel`. @@ -39,10 +28,8 @@ export function ArtefactTaskRun({ ); const task = taskQuery.data; - const isSignals = content.product === SIGNALS_PRODUCT; - const label = isSignals - ? (SIGNALS_TYPE_LABELS[content.type] ?? humanizeIdentifier(content.type)) - : humanizeIdentifier(content.type); + const isSignals = content.product === "signals"; + const label = taskRunLabel(content); const status = task?.latest_run?.status; return ( From a7e4e28fda33b6af9fb35043d512a9b9ed399b47 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 24 Jul 2026 19:27:37 +0300 Subject: [PATCH 24/43] refactor(core): extract portability contracts Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- packages/api-client/src/types.ts | 30 +++++++++++++++++++ packages/core/src/mcp-servers/presentation.ts | 13 ++++++++ 2 files changed, 43 insertions(+) create mode 100644 packages/core/src/mcp-servers/presentation.ts diff --git a/packages/api-client/src/types.ts b/packages/api-client/src/types.ts index a17f035ad1..0c6146081c 100644 --- a/packages/api-client/src/types.ts +++ b/packages/api-client/src/types.ts @@ -8,3 +8,33 @@ export type McpAuthType = Schemas.MCPAuthTypeEnum; export type McpRecommendedServer = Schemas.MCPServerTemplate; export type McpServerInstallation = Schemas.MCPServerInstallation; export type McpInstallationTool = Schemas.MCPServerInstallationTool; +export type McpOAuthRedirectResponse = Schemas.OAuthRedirectResponse; +export type McpInstallSource = "posthog" | "posthog-code" | "posthog-mobile"; +export type McpInstallResponse = + | McpServerInstallation + | McpOAuthRedirectResponse; + +export interface InstallCustomMcpServerOptions { + name: string; + url: string; + auth_type: McpAuthType; + api_key?: string; + description?: string; + client_id?: string; + client_secret?: string; + install_source?: McpInstallSource; + posthog_code_callback_url?: string; +} + +export interface InstallMcpTemplateOptions { + template_id: string; + api_key?: string; + install_source?: McpInstallSource; + posthog_code_callback_url?: string; +} + +export interface UpdateMcpServerInstallationOptions { + display_name?: string; + description?: string; + is_enabled?: boolean; +} diff --git a/packages/core/src/mcp-servers/presentation.ts b/packages/core/src/mcp-servers/presentation.ts new file mode 100644 index 0000000000..66b3bfd895 --- /dev/null +++ b/packages/core/src/mcp-servers/presentation.ts @@ -0,0 +1,13 @@ +export function isMcpOAuthRedirect( + response: object, +): response is { redirect_url: string } { + return ( + "redirect_url" in response && typeof response.redirect_url === "string" + ); +} + +export function isStdioMcpServer(server: { + transport_type?: string | null; +}): boolean { + return server.transport_type === "stdio"; +} From 1f3ad6a77ca2a4a921c45ed5bbae43d4460dbeac Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 24 Jul 2026 19:28:04 +0300 Subject: [PATCH 25/43] refactor(ui): reuse inbox identifier formatting Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- packages/ui/src/features/inbox/hooks/useReportTasks.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/ui/src/features/inbox/hooks/useReportTasks.ts b/packages/ui/src/features/inbox/hooks/useReportTasks.ts index 3f2093f148..76ba270a13 100644 --- a/packages/ui/src/features/inbox/hooks/useReportTasks.ts +++ b/packages/ui/src/features/inbox/hooks/useReportTasks.ts @@ -1,3 +1,4 @@ +import { humanizeIdentifier } from "@posthog/core/inbox/activityLog"; import type { SignalReportStatus, Task, @@ -19,11 +20,6 @@ export interface ReportTaskData { startedAt: string; } -function humanizeIdentifier(value: string): string { - const words = value.replace(/[_-]+/g, " ").trim(); - return words.charAt(0).toUpperCase() + words.slice(1); -} - function derivePurpose(taskRun: { product: string; type: string; From 0c3e6fb4d458974e060c02c5fe9640a4901a4bc9 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 24 Jul 2026 19:29:57 +0300 Subject: [PATCH 26/43] refactor(mobile): adopt shared task runtime Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- apps/mobile/package.json | 2 + apps/mobile/src/app/automation/[id].tsx | 2 +- apps/mobile/src/app/automation/create.tsx | 2 +- apps/mobile/src/app/task/[id].tsx | 25 +- apps/mobile/src/app/task/index.tsx | 5 +- .../features/chat/components/ToolMessage.tsx | 7 +- apps/mobile/src/features/inbox/api.ts | 3 +- .../inbox/components/EditReviewersSheet.tsx | 5 +- .../inbox/components/ReviewerFilterSheet.tsx | 2 +- .../inbox/components/ReviewerOptionRow.tsx | 5 +- .../inbox/components/SuggestedReviewers.tsx | 10 +- .../features/inbox/components/TinderView.tsx | 5 +- apps/mobile/src/features/inbox/constants.ts | 14 - .../features/inbox/hooks/useInboxReports.ts | 22 +- .../features/inbox/stores/inboxFilterStore.ts | 8 +- apps/mobile/src/features/inbox/utils.test.ts | 242 +---- apps/mobile/src/features/inbox/utils.ts | 160 --- .../components/SwipeableArchivedDrawerRow.tsx | 2 +- .../features/tasks/api.automations.test.ts | 280 ----- apps/mobile/src/features/tasks/api.test.ts | 78 +- apps/mobile/src/features/tasks/api.ts | 718 +------------ .../src/features/tasks/api.warm.test.ts | 187 ---- .../tasks/components/AutomationDetail.tsx | 5 +- .../tasks/components/AutomationForm.tsx | 20 +- .../tasks/components/AutomationItem.tsx | 5 +- .../tasks/components/AutomationList.tsx | 2 +- .../components/AutomationStatusBadge.tsx | 2 +- .../CreateAutomationScreen.test.tsx | 2 +- .../components/CustomImageBadge.test.tsx | 10 +- .../tasks/components/CustomImageBadge.tsx | 2 +- .../components/GitHubConnectionPrompt.tsx | 5 +- .../tasks/components/ScheduleEditor.tsx | 4 +- .../tasks/components/SwipeableTaskItem.tsx | 8 +- .../tasks/components/TaskItem.test.tsx | 2 +- .../features/tasks/components/TaskItem.tsx | 2 +- .../features/tasks/components/TaskList.tsx | 5 +- .../tasks/components/TaskStatusIcon.test.ts | 12 +- .../tasks/components/TaskStatusIcon.tsx | 2 +- .../tasks/components/taskStatusIconKind.ts | 5 +- .../features/tasks/composer/options.test.ts | 27 + .../src/features/tasks/composer/options.ts | 79 +- .../tasks/hooks/useAutomations.test.ts | 24 +- .../features/tasks/hooks/useAutomations.ts | 31 +- .../tasks/hooks/useCustomImageName.ts | 6 +- .../tasks/hooks/useIntegrations.test.ts | 8 +- .../features/tasks/hooks/useIntegrations.ts | 31 +- .../src/features/tasks/hooks/useTasks.test.ts | 15 +- .../src/features/tasks/hooks/useTasks.ts | 33 +- .../tasks/hooks/useUserIntegrations.ts | 13 +- .../features/tasks/hooks/useWarmTask.test.tsx | 4 +- .../src/features/tasks/hooks/useWarmTask.ts | 34 +- apps/mobile/src/features/tasks/index.ts | 3 - .../tasks/lib/cloudTaskStream.test.ts | 54 + .../src/features/tasks/lib/cloudTaskStream.ts | 962 ++---------------- .../src/features/tasks/lib/sseParser.ts | 89 -- .../tasks/stores/taskSessionStore.test.ts | 22 +- .../features/tasks/stores/taskSessionStore.ts | 26 +- .../features/tasks/stores/taskStore.test.ts | 54 - .../src/features/tasks/stores/taskStore.ts | 48 +- apps/mobile/src/features/tasks/types.ts | 206 +--- .../features/tasks/utils/archiveGuard.test.ts | 54 +- .../src/features/tasks/utils/archiveGuard.ts | 6 - .../tasks/utils/automationSchedule.test.ts | 101 -- .../tasks/utils/automationSchedule.ts | 213 ---- .../tasks/utils/automationStatus.test.ts | 16 +- .../features/tasks/utils/automationStatus.ts | 3 +- .../utils/automationTemplatePresentation.ts | 2 +- .../features/tasks/utils/parseSessionLogs.ts | 93 +- .../tasks/utils/sessionActivity.test.ts | 161 --- .../features/tasks/utils/sessionActivity.ts | 133 --- apps/mobile/src/lib/analytics.ts | 10 +- apps/mobile/src/lib/api.ts | 15 +- apps/mobile/src/lib/posthogApiClient.test.ts | 190 ++++ apps/mobile/src/lib/posthogApiClient.ts | 84 ++ pnpm-lock.yaml | 6 + 75 files changed, 854 insertions(+), 3879 deletions(-) delete mode 100644 apps/mobile/src/features/tasks/api.automations.test.ts delete mode 100644 apps/mobile/src/features/tasks/api.warm.test.ts create mode 100644 apps/mobile/src/features/tasks/composer/options.test.ts create mode 100644 apps/mobile/src/features/tasks/lib/cloudTaskStream.test.ts delete mode 100644 apps/mobile/src/features/tasks/lib/sseParser.ts delete mode 100644 apps/mobile/src/features/tasks/stores/taskStore.test.ts delete mode 100644 apps/mobile/src/features/tasks/utils/automationSchedule.test.ts delete mode 100644 apps/mobile/src/features/tasks/utils/automationSchedule.ts delete mode 100644 apps/mobile/src/features/tasks/utils/sessionActivity.test.ts delete mode 100644 apps/mobile/src/features/tasks/utils/sessionActivity.ts create mode 100644 apps/mobile/src/lib/posthogApiClient.test.ts create mode 100644 apps/mobile/src/lib/posthogApiClient.ts diff --git a/apps/mobile/package.json b/apps/mobile/package.json index f1057c0146..09ebacf3d0 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -27,6 +27,8 @@ "@expo/ui": "0.2.0-beta.9", "@modelcontextprotocol/ext-apps": "^1.2.2", "@modelcontextprotocol/sdk": "^1.29.0", + "@posthog/api-client": "workspace:*", + "@posthog/core": "workspace:*", "@posthog/shared": "workspace:*", "@react-native-async-storage/async-storage": "^2.2.0", "@react-native-community/netinfo": "^12.0.1", diff --git a/apps/mobile/src/app/automation/[id].tsx b/apps/mobile/src/app/automation/[id].tsx index 5983ecdf28..55f9bd5c6d 100644 --- a/apps/mobile/src/app/automation/[id].tsx +++ b/apps/mobile/src/app/automation/[id].tsx @@ -1,4 +1,5 @@ import { Text } from "@components/text"; +import { TaskAutomationValidationError } from "@posthog/api-client/posthog-client"; import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import { useState } from "react"; import { @@ -10,7 +11,6 @@ import { ScrollView, View, } from "react-native"; -import { TaskAutomationValidationError } from "@/features/tasks/api"; import { AutomationDetail } from "@/features/tasks/components/AutomationDetail"; import { AutomationForm } from "@/features/tasks/components/AutomationForm"; import { diff --git a/apps/mobile/src/app/automation/create.tsx b/apps/mobile/src/app/automation/create.tsx index 2233cd688a..1f4d662bff 100644 --- a/apps/mobile/src/app/automation/create.tsx +++ b/apps/mobile/src/app/automation/create.tsx @@ -1,3 +1,4 @@ +import { TaskAutomationValidationError } from "@posthog/api-client/posthog-client"; import { getCalendars } from "expo-localization"; import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import { useMemo, useRef, useState } from "react"; @@ -10,7 +11,6 @@ import { View, } from "react-native"; import { Text } from "@/components/text"; -import { TaskAutomationValidationError } from "@/features/tasks/api"; import { AutomationForm } from "@/features/tasks/components/AutomationForm"; import { useCreateTaskAutomation } from "@/features/tasks/hooks/useAutomations"; import { useSkillStoreSkill } from "@/features/tasks/skills/hooks"; diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index 889c7811ea..fe73a847f3 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -1,4 +1,10 @@ import { Text } from "@components/text"; +import { + countUserMessages, + getSessionActivityPhase, +} from "@posthog/core/sessions/sessionActivity"; +import { isTaskRunning } from "@posthog/core/tasks/taskArchive"; +import type { Task } from "@posthog/shared"; import { useQueryClient } from "@tanstack/react-query"; import * as Haptics from "expo-haptics"; import { useLocalSearchParams, useRouter } from "expo-router"; @@ -14,7 +20,7 @@ import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller import Animated, { useAnimatedStyle } from "react-native-reanimated"; import { FloatingBackButton } from "@/components/FloatingBackButton"; import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; -import { getTask, runTaskInCloud } from "@/features/tasks/api"; +import { runTaskInCloud } from "@/features/tasks/api"; import { CustomImageBadge } from "@/features/tasks/components/CustomImageBadge"; import { FloatingTaskHeader } from "@/features/tasks/components/FloatingTaskHeader"; import { PrDiffStatsBadge } from "@/features/tasks/components/PrDiffStatsBadge"; @@ -51,15 +57,7 @@ import { } from "@/features/tasks/stores/pendingTaskPromptStore"; import { useTaskSessionStore } from "@/features/tasks/stores/taskSessionStore"; import { useTaskStore } from "@/features/tasks/stores/taskStore"; -import type { Task } from "@/features/tasks/types"; -import { - confirmStopRun, - isTaskRunning, -} from "@/features/tasks/utils/archiveGuard"; -import { - countUserMessages, - getSessionActivityPhase, -} from "@/features/tasks/utils/sessionActivity"; +import { confirmStopRun } from "@/features/tasks/utils/archiveGuard"; import { useScreenInsets } from "@/hooks/useScreenInsets"; import { ANALYTICS_EVENTS, @@ -67,6 +65,7 @@ import { useAnalytics, } from "@/lib/analytics"; import { logger } from "@/lib/logger"; +import { getPostHogApiClient } from "@/lib/posthogApiClient"; import { useThemeColors } from "@/lib/theme"; const log = logger.scope("task-detail"); @@ -221,7 +220,8 @@ export default function TaskDetailScreen() { setLoading(true); setError(null); - getTask(taskId) + getPostHogApiClient() + .getTask(taskId) .then((fetchedTask) => { if (cancelled) return; setTask(fetchedTask); @@ -252,7 +252,8 @@ export default function TaskDetailScreen() { if (retrying) return; let cancelled = false; - getTask(taskId) + getPostHogApiClient() + .getTask(taskId) .then((freshTask) => { if (cancelled) return; setTask(freshTask); diff --git a/apps/mobile/src/app/task/index.tsx b/apps/mobile/src/app/task/index.tsx index 75fe35e96a..012b206dcc 100644 --- a/apps/mobile/src/app/task/index.tsx +++ b/apps/mobile/src/app/task/index.tsx @@ -30,7 +30,7 @@ import { import Animated, { runOnJS, useAnimatedStyle } from "react-native-reanimated"; import { useVoiceRecording } from "@/features/chat"; import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; -import { createTask, runTaskInCloud } from "@/features/tasks/api"; +import { runTaskInCloud } from "@/features/tasks/api"; import { GitHubConnectionPrompt } from "@/features/tasks/components/GitHubConnectionPrompt"; import { GitHubLoadNotice } from "@/features/tasks/components/GitHubLoadNotice"; import { AttachmentSheet } from "@/features/tasks/composer/attachments/AttachmentSheet"; @@ -80,6 +80,7 @@ import { } from "@/features/tasks/utils/repositorySelection"; import { useScreenInsets } from "@/hooks/useScreenInsets"; import { logger } from "@/lib/logger"; +import { getPostHogApiClient } from "@/lib/posthogApiClient"; import { toRgba, useThemeColors } from "@/lib/theme"; const log = logger.scope("task-create"); @@ -308,7 +309,7 @@ export default function NewTaskScreen() { ? `Attached: ${attachments[0].fileName}` : `Attached ${attachments.length} files`); - const task = await createTask({ + const task = await getPostHogApiClient().createTask({ description: descriptionText, title: descriptionText.slice(0, 100), repository: selection.repository ?? undefined, diff --git a/apps/mobile/src/features/chat/components/ToolMessage.tsx b/apps/mobile/src/features/chat/components/ToolMessage.tsx index 479849a51b..3859504777 100644 --- a/apps/mobile/src/features/chat/components/ToolMessage.tsx +++ b/apps/mobile/src/features/chat/components/ToolMessage.tsx @@ -659,9 +659,12 @@ function CreateTaskPreview({ try { // Dynamic import to avoid circular dependency - const { createTask, runTaskInCloud } = await import("../../tasks/api"); + const [{ runTaskInCloud }, { getPostHogApiClient }] = await Promise.all([ + import("../../tasks/api"), + import("@/lib/posthogApiClient"), + ]); - const task = await createTask({ + const task = await getPostHogApiClient().createTask({ title: args.title, description: args.description, repository: args.repository, diff --git a/apps/mobile/src/features/inbox/api.ts b/apps/mobile/src/features/inbox/api.ts index ce7bbeec0e..9b37b82725 100644 --- a/apps/mobile/src/features/inbox/api.ts +++ b/apps/mobile/src/features/inbox/api.ts @@ -1,5 +1,4 @@ -import { HttpError } from "@/features/tasks/api"; -import { authedFetch, getBaseUrl, getProjectId } from "@/lib/api"; +import { authedFetch, getBaseUrl, getProjectId, HttpError } from "@/lib/api"; import { logger } from "@/lib/logger"; import type { DismissalReasonOptionValue } from "./constants"; diff --git a/apps/mobile/src/features/inbox/components/EditReviewersSheet.tsx b/apps/mobile/src/features/inbox/components/EditReviewersSheet.tsx index 2aec45975d..2e30695576 100644 --- a/apps/mobile/src/features/inbox/components/EditReviewersSheet.tsx +++ b/apps/mobile/src/features/inbox/components/EditReviewersSheet.tsx @@ -1,4 +1,8 @@ import { Text } from "@components/text"; +import { + buildReviewerOptions, + reviewerMatchesAvailable, +} from "@posthog/core/inbox/artefacts"; import { MagnifyingGlass } from "phosphor-react-native"; import { useMemo, useState } from "react"; import { @@ -14,7 +18,6 @@ import { useScreenInsets } from "@/hooks/useScreenInsets"; import { useThemeColors } from "@/lib/theme"; import { useAvailableSuggestedReviewers } from "../hooks/useInboxReports"; import type { AvailableSuggestedReviewer, SuggestedReviewer } from "../types"; -import { buildReviewerOptions, reviewerMatchesAvailable } from "../utils"; import { ReviewerOptionRow } from "./ReviewerOptionRow"; interface EditReviewersSheetProps { diff --git a/apps/mobile/src/features/inbox/components/ReviewerFilterSheet.tsx b/apps/mobile/src/features/inbox/components/ReviewerFilterSheet.tsx index d07faaffa1..44ac730daf 100644 --- a/apps/mobile/src/features/inbox/components/ReviewerFilterSheet.tsx +++ b/apps/mobile/src/features/inbox/components/ReviewerFilterSheet.tsx @@ -1,4 +1,5 @@ import { Text } from "@components/text"; +import { buildReviewerOptions } from "@posthog/core/inbox/artefacts"; import { useMemo } from "react"; import { ActivityIndicator, @@ -12,7 +13,6 @@ import { useScreenInsets } from "@/hooks/useScreenInsets"; import { useThemeColors } from "@/lib/theme"; import { useAvailableSuggestedReviewers } from "../hooks/useInboxReports"; import { useInboxFilterStore } from "../stores/inboxFilterStore"; -import { buildReviewerOptions } from "../utils"; import { ReviewerOptionRow } from "./ReviewerOptionRow"; interface ReviewerFilterSheetProps { diff --git a/apps/mobile/src/features/inbox/components/ReviewerOptionRow.tsx b/apps/mobile/src/features/inbox/components/ReviewerOptionRow.tsx index a6f9c5c265..0c21699177 100644 --- a/apps/mobile/src/features/inbox/components/ReviewerOptionRow.tsx +++ b/apps/mobile/src/features/inbox/components/ReviewerOptionRow.tsx @@ -1,8 +1,11 @@ import { Text } from "@components/text"; +import { + type ReviewerOption, + reviewerOptionLabel, +} from "@posthog/core/inbox/artefacts"; import { Check } from "phosphor-react-native"; import { Image, Pressable, View } from "react-native"; import { useThemeColors } from "@/lib/theme"; -import { type ReviewerOption, reviewerOptionLabel } from "../utils"; interface ReviewerOptionRowProps { reviewer: ReviewerOption; diff --git a/apps/mobile/src/features/inbox/components/SuggestedReviewers.tsx b/apps/mobile/src/features/inbox/components/SuggestedReviewers.tsx index 0536f1b364..d0eab2e25f 100644 --- a/apps/mobile/src/features/inbox/components/SuggestedReviewers.tsx +++ b/apps/mobile/src/features/inbox/components/SuggestedReviewers.tsx @@ -1,4 +1,9 @@ import { Text } from "@components/text"; +import { + orderSuggestedReviewers, + reviewerMatchesAvailable, + toSuggestedReviewerWriteContent, +} from "@posthog/core/inbox/artefacts"; import { Eye, Plus, X } from "phosphor-react-native"; import { useMemo, useState } from "react"; import { @@ -20,11 +25,6 @@ import type { SuggestedReviewer, SuggestedReviewersArtefact, } from "../types"; -import { - orderSuggestedReviewers, - reviewerMatchesAvailable, - toSuggestedReviewerWriteContent, -} from "../utils"; import { EditReviewersSheet } from "./EditReviewersSheet"; export type ReviewerActionExtra = Pick< diff --git a/apps/mobile/src/features/inbox/components/TinderView.tsx b/apps/mobile/src/features/inbox/components/TinderView.tsx index cc034d6ceb..a8c99da1e4 100644 --- a/apps/mobile/src/features/inbox/components/TinderView.tsx +++ b/apps/mobile/src/features/inbox/components/TinderView.tsx @@ -17,7 +17,7 @@ import { } from "react-native-safe-area-context"; import { MarkdownText } from "@/features/chat/components/MarkdownText"; import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; -import { createTask, runTaskInCloud } from "@/features/tasks/api"; +import { runTaskInCloud } from "@/features/tasks/api"; import { DEFAULT_MODEL } from "@/features/tasks/composer/options"; import type { CreateTaskOptions, @@ -29,6 +29,7 @@ import { useAnalytics, } from "@/lib/analytics"; import { logger } from "@/lib/logger"; +import { getPostHogApiClient } from "@/lib/posthogApiClient"; import { useThemeColors } from "@/lib/theme"; import { getReportRepository } from "../api"; import { useDismissedReportsStore } from "../stores/dismissedReportsStore"; @@ -239,7 +240,7 @@ export function TinderView({ // 3. Create the task const prompt = `Act on this signal report. Investigate the root cause, implement the fix, and open a PR if appropriate.\n\n${report.summary ?? ""}`; - const task = await createTask({ + const task = await getPostHogApiClient().createTask({ description: prompt, title: prompt.slice(0, 255), repository: match?.repository ?? repo ?? undefined, diff --git a/apps/mobile/src/features/inbox/constants.ts b/apps/mobile/src/features/inbox/constants.ts index 201d15982f..f15aca7c5b 100644 --- a/apps/mobile/src/features/inbox/constants.ts +++ b/apps/mobile/src/features/inbox/constants.ts @@ -1,17 +1,3 @@ -/** Comma-separated statuses for the inbox pipeline (excludes terminal/deleted). */ -export const INBOX_PIPELINE_STATUS_FILTER = - "potential,candidate,in_progress,ready,pending_input"; - -/** - * Status filter for the Archive view — the two terminal, not-in-inbox states: - * `suppressed` (user archived it; restorable) and `resolved` (its - * implementation PR merged; terminal, reference only). - */ -export const INBOX_DISMISSED_STATUS_FILTER = "suppressed,resolved"; - -/** Polling interval for inbox queries (ms). */ -export const INBOX_REFETCH_INTERVAL_MS = 5_000; - /** * Reasons offered when the user dismisses a signal report. * Mirrors apps/code/src/shared/dismissalReasons.ts. diff --git a/apps/mobile/src/features/inbox/hooks/useInboxReports.ts b/apps/mobile/src/features/inbox/hooks/useInboxReports.ts index 25f6e0eb40..ac37c738d1 100644 --- a/apps/mobile/src/features/inbox/hooks/useInboxReports.ts +++ b/apps/mobile/src/features/inbox/hooks/useInboxReports.ts @@ -1,3 +1,12 @@ +import { + buildArchiveListOrdering, + buildPriorityFilterParam, + buildSignalReportListOrdering, + buildStatusFilterParam, + buildSuggestedReviewerFilterParam, + INBOX_DISMISSED_STATUS_FILTER, + INBOX_REFETCH_INTERVAL_MS, +} from "@posthog/core/inbox/reportFiltering"; import { useInfiniteQuery, useMutation, @@ -19,10 +28,6 @@ import { restoreSignalReport, updateSignalReportArtefact, } from "../api"; -import { - INBOX_DISMISSED_STATUS_FILTER, - INBOX_REFETCH_INTERVAL_MS, -} from "../constants"; import { useInboxFilterStore } from "../stores/inboxFilterStore"; import type { AvailableSuggestedReviewersResponse, @@ -36,14 +41,7 @@ import type { SuggestedReviewer, SuggestedReviewerWriteEntry, } from "../types"; -import { - buildArchiveListOrdering, - buildPriorityFilterParam, - buildSignalReportListOrdering, - buildStatusFilterParam, - buildSuggestedReviewerFilterParam, - isRestorableReport, -} from "../utils"; +import { isRestorableReport } from "../utils"; export const inboxKeys = { all: ["inbox", "signal-reports"] as const, diff --git a/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts b/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts index 97c16ccc3f..a0536417da 100644 --- a/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts +++ b/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts @@ -1,3 +1,4 @@ +import { INBOX_PIPELINE_STATUSES } from "@posthog/core/inbox/reportFiltering"; import type { SourceProduct } from "@posthog/shared"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; @@ -18,12 +19,7 @@ type SortDirection = "asc" | "desc"; export type { SourceProduct }; export const DEFAULT_STATUS_FILTER: SignalReportStatus[] = [ - "ready", - "pending_input", - "in_progress", - "failed", - "candidate", - "potential", + ...INBOX_PIPELINE_STATUSES, ]; interface InboxFilterState { diff --git a/apps/mobile/src/features/inbox/utils.test.ts b/apps/mobile/src/features/inbox/utils.test.ts index 8c1d70a32e..ad56b19305 100644 --- a/apps/mobile/src/features/inbox/utils.test.ts +++ b/apps/mobile/src/features/inbox/utils.test.ts @@ -1,25 +1,11 @@ import { describe, expect, it } from "vitest"; -import type { - AvailableSuggestedReviewer, - Signal, - SignalReport, - SignalReportOrderingField, - SignalReportStatus, - SuggestedReviewer, -} from "./types"; +import type { Signal, SignalReport, SignalReportStatus } from "./types"; import { - buildArchiveListOrdering, buildInboxViewedProperties, - buildPriorityFilterParam, - buildReviewerOptions, - buildSignalReportListOrdering, dismissalReasonLabel, formatSignalReportSummaryMarkdown, isRestorableReport, - orderSuggestedReviewers, - reviewerMatchesAvailable, sourceLine, - toSuggestedReviewerWriteContent, } from "./utils"; function signal(source_product: string, source_type: string): Signal { @@ -35,84 +21,6 @@ function signal(source_product: string, source_type: string): Signal { }; } -function reviewer(login: string, uuid?: string): SuggestedReviewer { - return { - github_login: login, - github_name: login, - relevant_commits: [], - user: uuid - ? { - id: 1, - uuid, - email: `${login}@posthog.com`, - first_name: login, - last_name: "", - } - : null, - }; -} - -describe("orderSuggestedReviewers", () => { - it("moves the current user to the front", () => { - const reviewers = [ - reviewer("a", "uuid-a"), - reviewer("me", "uuid-me"), - reviewer("c", "uuid-c"), - ]; - const ordered = orderSuggestedReviewers(reviewers, "uuid-me"); - expect(ordered.map((r) => r.github_login)).toEqual(["me", "a", "c"]); - }); - - it.each([ - { - label: "already first", - reviewers: [reviewer("me", "uuid-me"), reviewer("a", "uuid-a")], - meUuid: "uuid-me" as string | null | undefined, - }, - { - label: "absent", - reviewers: [reviewer("a", "uuid-a"), reviewer("b", "uuid-b")], - meUuid: "uuid-me" as string | null | undefined, - }, - { - label: "null meUuid", - reviewers: [reviewer("a", "uuid-a"), reviewer("me", "uuid-me")], - meUuid: null as string | null | undefined, - }, - { - label: "undefined meUuid", - reviewers: [reviewer("a", "uuid-a"), reviewer("me", "uuid-me")], - meUuid: undefined as string | null | undefined, - }, - ])("is a no-op when $label", ({ reviewers, meUuid }) => { - expect(orderSuggestedReviewers(reviewers, meUuid)).toBe(reviewers); - }); -}); - -function makeReviewer( - partial: Partial = {}, -): SuggestedReviewer { - return { - github_login: "octocat", - github_name: "The Octocat", - relevant_commits: [], - user: null, - ...partial, - }; -} - -function makeAvailable( - partial: Partial = {}, -): AvailableSuggestedReviewer { - return { - uuid: "uuid-1", - name: "Ada Lovelace", - email: "ada@example.com", - github_login: "ada", - ...partial, - }; -} - const DEFAULT_STATUS_FILTER: SignalReportStatus[] = [ "ready", "pending_input", @@ -299,139 +207,6 @@ describe("buildInboxViewedProperties", () => { }); }); -describe("toSuggestedReviewerWriteContent", () => { - it.each([ - { - name: "prefers github_login so the server preserves commits/name", - reviewer: makeReviewer({ - github_login: "ada", - user: { id: 1, uuid: "u1", email: "", first_name: "", last_name: "" }, - }), - expected: [{ github_login: "ada" }], - }, - { - name: "falls back to user_uuid when there is no github_login", - reviewer: makeReviewer({ - github_login: "", - user: { id: 1, uuid: "u1", email: "", first_name: "", last_name: "" }, - }), - expected: [{ user_uuid: "u1" }], - }, - { - name: "drops entries with neither a login nor a resolved user", - reviewer: makeReviewer({ github_login: "", user: null }), - expected: [], - }, - ])("$name", ({ reviewer, expected }) => { - expect(toSuggestedReviewerWriteContent([reviewer])).toEqual(expected); - }); -}); - -describe("reviewerMatchesAvailable", () => { - it.each([ - { - name: "matches on user uuid", - reviewer: makeReviewer({ - github_login: "", - user: { - id: 1, - uuid: "uuid-1", - email: "", - first_name: "", - last_name: "", - }, - }), - expected: true, - }, - { - name: "matches on case-insensitive github login", - reviewer: makeReviewer({ github_login: "ADA", user: null }), - expected: true, - }, - { - name: "does not match different people", - reviewer: makeReviewer({ github_login: "octocat", user: null }), - expected: false, - }, - ])("$name", ({ reviewer, expected }) => { - expect(reviewerMatchesAvailable(reviewer, makeAvailable())).toBe(expected); - }); -}); - -describe("buildSignalReportListOrdering", () => { - it.each([ - { - field: "priority" as SignalReportOrderingField, - direction: "desc" as const, - expected: "status,-is_suggested_reviewer,-priority,-created_at", - }, - { - field: "priority" as SignalReportOrderingField, - direction: "asc" as const, - expected: "status,-is_suggested_reviewer,priority,-created_at", - }, - { - field: "signal_count" as SignalReportOrderingField, - direction: "desc" as const, - expected: "status,-is_suggested_reviewer,-signal_count", - }, - { - field: "total_weight" as SignalReportOrderingField, - direction: "asc" as const, - expected: "status,-is_suggested_reviewer,total_weight", - }, - { - field: "created_at" as SignalReportOrderingField, - direction: "desc" as const, - expected: "status,-is_suggested_reviewer,-created_at", - }, - { - field: "updated_at" as SignalReportOrderingField, - direction: "asc" as const, - expected: "status,-is_suggested_reviewer,updated_at", - }, - ])( - "orders $field $direction as $expected", - ({ field, direction, expected }) => { - expect(buildSignalReportListOrdering(field, direction)).toBe(expected); - }, - ); -}); - -describe("buildPriorityFilterParam", () => { - it.each([ - { - name: "returns undefined for an empty selection", - input: [], - expected: undefined, - }, - { - name: "joins selected priorities with commas", - input: ["P0", "P2"] as const, - expected: "P0,P2", - }, - { - name: "dedupes repeated priorities", - input: ["P1", "P1", "P3"] as const, - expected: "P1,P3", - }, - ])("$name", ({ input, expected }) => { - expect(buildPriorityFilterParam([...input])).toBe(expected); - }); -}); - -describe("buildArchiveListOrdering", () => { - it.each([ - { direction: "desc" as const, expected: "-updated_at" }, - { direction: "asc" as const, expected: "updated_at" }, - ])( - "sorts by field without a status prefix ($direction)", - ({ direction, expected }) => { - expect(buildArchiveListOrdering("updated_at", direction)).toBe(expected); - }, - ); -}); - describe("isRestorableReport", () => { it.each([ { status: "suppressed" as SignalReportStatus, expected: true }, @@ -470,18 +245,3 @@ describe("sourceLine", () => { expect(sourceLine(signal(product, type))).toBe(expected); }); }); - -describe("buildReviewerOptions", () => { - it("dedupes by uuid and pins the current user first", () => { - const options = buildReviewerOptions( - [ - makeAvailable({ uuid: "b", name: "Bob" }), - makeAvailable({ uuid: "a", name: "Ada" }), - makeAvailable({ uuid: "a", name: "Ada (dupe)" }), - ], - "b", - ); - expect(options.map((o) => o.uuid)).toEqual(["b", "a"]); - expect(options[0].isMe).toBe(true); - }); -}); diff --git a/apps/mobile/src/features/inbox/utils.ts b/apps/mobile/src/features/inbox/utils.ts index 52ffb73972..b0040ddcef 100644 --- a/apps/mobile/src/features/inbox/utils.ts +++ b/apps/mobile/src/features/inbox/utils.ts @@ -6,14 +6,10 @@ import { differenceInHours, format, formatDistanceToNow } from "date-fns"; import type { InboxViewedProperties } from "@/lib/analytics"; import { DISMISSAL_REASON_OPTIONS } from "./constants"; import type { - AvailableSuggestedReviewer, Signal, SignalReport, - SignalReportOrderingField, SignalReportPriority, SignalReportStatus, - SuggestedReviewer, - SuggestedReviewerWriteEntry, } from "./types"; const ERROR_TRACKING_TYPE_LABELS: Record = { @@ -129,77 +125,6 @@ export function inboxStatusLabel(status: SignalReportStatus): string { } } -/** - * Build comma-separated `ordering` param for the API: - * 1. Status rank (ready first) - * 2. Suggested reviewer (current user first) - * 3. User-selected field - * - * Priority is a coarse 5-bucket rank, so ties are broken by newest first. - */ -export function buildSignalReportListOrdering( - field: SignalReportOrderingField, - direction: "asc" | "desc", -): string { - const fieldKey = direction === "desc" ? `-${field}` : field; - const tiebreak = field === "priority" ? ",-created_at" : ""; - return `status,-is_suggested_reviewer,${fieldKey}${tiebreak}`; -} - -/** - * Ordering for the Archive view, which lists two terminal statuses - * (`suppressed` + `resolved`). Unlike the pipeline ordering, it must not prefix - * with `status`: that would group one terminal state ahead of the other before - * the time sort, burying recent items behind older ones from the sibling status. - */ -export function buildArchiveListOrdering( - field: SignalReportOrderingField, - direction: "asc" | "desc", -): string { - return direction === "desc" ? `-${field}` : field; -} - -/** - * Build a comma-separated status filter string for the API. - */ -export function buildStatusFilterParam(statuses: SignalReportStatus[]): string { - return statuses.join(","); -} - -/** - * Build a comma-separated suggested reviewer filter for the API. - */ -export function buildSuggestedReviewerFilterParam( - reviewerIds: string[], -): string | undefined { - const normalized = reviewerIds.map((id) => id.trim()).filter(Boolean); - if (normalized.length === 0) return undefined; - return Array.from(new Set(normalized)).join(","); -} - -export function buildPriorityFilterParam( - priorities: SignalReportPriority[], -): string | undefined { - if (priorities.length === 0) return undefined; - return Array.from(new Set(priorities)).join(","); -} - -export function filterReportsBySearch( - reports: SignalReport[], - query: string, -): SignalReport[] { - const trimmed = query.trim(); - if (!trimmed) return reports; - - const lower = trimmed.toLowerCase(); - return reports.filter( - (report) => - report.title?.toLowerCase().includes(lower) || - report.summary?.toLowerCase().includes(lower) || - report.id.toLowerCase().includes(lower), - ); -} - /** * Returns only reports that are actionable for the tinder-like card deck: * ready, immediately actionable, not already addressed. @@ -213,91 +138,6 @@ export function getActionableReports(reports: SignalReport[]): SignalReport[] { ); } -export function orderSuggestedReviewers( - reviewers: SuggestedReviewer[], - meUuid: string | null | undefined, -): SuggestedReviewer[] { - if (!meUuid) return reviewers; - const meIndex = reviewers.findIndex((r) => r.user?.uuid === meUuid); - if (meIndex <= 0) return reviewers; - return [reviewers[meIndex], ...reviewers.filter((_, i) => i !== meIndex)]; -} - -export interface ReviewerOption { - uuid: string; - name: string; - email: string; - github_login: string; - isMe: boolean; -} - -/** Deduplicate the available-reviewers list by uuid and sort "Me" first, then by name. */ -export function buildReviewerOptions( - reviewers: AvailableSuggestedReviewer[], - currentUserUuid: string | undefined, -): ReviewerOption[] { - const seen = new Set(); - const options: ReviewerOption[] = []; - - for (const r of reviewers) { - if (!r.uuid || seen.has(r.uuid)) continue; - seen.add(r.uuid); - options.push({ - uuid: r.uuid, - name: r.name?.trim() || "", - email: r.email?.trim() || "", - github_login: r.github_login?.trim() || "", - isMe: r.uuid === currentUserUuid, - }); - } - - options.sort((a, b) => { - if (a.isMe && !b.isMe) return -1; - if (!a.isMe && b.isMe) return 1; - return (a.name || a.email).localeCompare(b.name || b.email); - }); - - return options; -} - -export function reviewerOptionLabel(r: ReviewerOption): string { - const base = r.name || r.email || "Unknown user"; - return r.isMe ? `${base} (Me)` : base; -} - -/** A reviewer in the artefact matches an org member by user uuid or (case-insensitive) login. */ -export function reviewerMatchesAvailable( - reviewer: SuggestedReviewer, - available: AvailableSuggestedReviewer, -): boolean { - if (reviewer.user?.uuid && reviewer.user.uuid === available.uuid) { - return true; - } - return ( - !!reviewer.github_login && - !!available.github_login && - reviewer.github_login.toLowerCase() === available.github_login.toLowerCase() - ); -} - -/** - * Build the full-replacement write payload from a read-shape list. Kept reviewers - * are sent by `github_login` so the server preserves their commits/name; an entry - * with only a resolved user falls back to `user_uuid`. Entries with neither are - * dropped. - */ -export function toSuggestedReviewerWriteContent( - reviewers: SuggestedReviewer[], -): SuggestedReviewerWriteEntry[] { - return reviewers - .map((reviewer): SuggestedReviewerWriteEntry | null => { - if (reviewer.github_login) return { github_login: reviewer.github_login }; - if (reviewer.user?.uuid) return { user_uuid: reviewer.user.uuid }; - return null; - }) - .filter((entry): entry is SuggestedReviewerWriteEntry => entry !== null); -} - interface InboxViewedFilterState { sourceProductFilter: string[]; statusFilter: SignalReportStatus[]; diff --git a/apps/mobile/src/features/navigation/components/SwipeableArchivedDrawerRow.tsx b/apps/mobile/src/features/navigation/components/SwipeableArchivedDrawerRow.tsx index 4bb2583879..6e942a2688 100644 --- a/apps/mobile/src/features/navigation/components/SwipeableArchivedDrawerRow.tsx +++ b/apps/mobile/src/features/navigation/components/SwipeableArchivedDrawerRow.tsx @@ -1,4 +1,5 @@ import { Text } from "@components/text"; +import type { Task } from "@posthog/shared"; import * as Haptics from "expo-haptics"; import { ArrowCounterClockwise } from "phosphor-react-native"; import { useEffect, useRef } from "react"; @@ -11,7 +12,6 @@ import { View, } from "react-native"; import { TaskStatusIcon } from "@/features/tasks/components/TaskStatusIcon"; -import type { Task } from "@/features/tasks/types"; import { useThemeColors } from "@/lib/theme"; const SWIPE_THRESHOLD = 60; diff --git a/apps/mobile/src/features/tasks/api.automations.test.ts b/apps/mobile/src/features/tasks/api.automations.test.ts deleted file mode 100644 index c4390a5d14..0000000000 --- a/apps/mobile/src/features/tasks/api.automations.test.ts +++ /dev/null @@ -1,280 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const { mockFetch } = vi.hoisted(() => ({ - mockFetch: vi.fn(), -})); - -vi.mock("expo/fetch", () => ({ - fetch: mockFetch, -})); - -vi.mock("@/lib/api", () => ({ - getBaseUrl: () => "https://app.posthog.test", - getProjectId: () => 42, - authedFetch: (url: string, init?: RequestInit) => - mockFetch(url, { - ...init, - headers: { - Authorization: "Bearer token", - "Content-Type": "application/json", - ...((init?.headers as Record | undefined) ?? {}), - }, - }), -})); - -import { - createTaskAutomation, - deleteTaskAutomation, - getTaskAutomation, - getTaskAutomations, - runTaskAutomation, - TaskAutomationValidationError, - updateTaskAutomation, -} from "./api"; - -const automationPayload = { - id: "automation-1", - name: "Daily PRs", - prompt: "Check PRs", - repository: "posthog/posthog", - github_integration: 7, - cron_expression: "0 9 * * *", - timezone: "Europe/London", - template_id: "llm-skill:shared-daily-brief", - enabled: true, - last_run_at: null, - last_run_status: null, - last_task_id: "task-1", - last_task_run_id: null, - last_error: null, - created_at: "2026-05-13T00:00:00Z", - updated_at: "2026-05-13T00:00:00Z", -}; - -describe("task automation api", () => { - beforeEach(() => { - mockFetch.mockReset(); - }); - - it("lists task automations from the existing backend endpoint", async () => { - mockFetch.mockResolvedValueOnce( - new Response( - JSON.stringify({ - results: [automationPayload], - }), - { status: 200 }, - ), - ); - - const automations = await getTaskAutomations(); - - expect(automations).toHaveLength(1); - expect(automations[0]?.id).toBe("automation-1"); - expect(mockFetch).toHaveBeenCalledWith( - "https://app.posthog.test/api/projects/42/task_automations/?limit=500", - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: "Bearer token", - }), - }), - ); - }); - - it("serializes automation creation payloads with the existing backend contract", async () => { - mockFetch.mockResolvedValueOnce( - new Response(JSON.stringify(automationPayload), { status: 200 }), - ); - - await createTaskAutomation({ - name: "Daily PRs", - prompt: "Check PRs", - repository: "posthog/posthog", - github_integration: 7, - cron_expression: "0 9 * * *", - timezone: "Europe/London", - enabled: true, - template_id: "llm-skill:shared-daily-brief", - }); - - expect(mockFetch).toHaveBeenCalledWith( - "https://app.posthog.test/api/projects/42/task_automations/", - expect.objectContaining({ - method: "POST", - body: JSON.stringify({ - name: "Daily PRs", - prompt: "Check PRs", - repository: "posthog/posthog", - github_integration: 7, - cron_expression: "0 9 * * *", - timezone: "Europe/London", - enabled: true, - template_id: "llm-skill:shared-daily-brief", - }), - }), - ); - }); - - it("serializes skill-backed automation payloads with a prefixed template id", async () => { - mockFetch.mockResolvedValueOnce( - new Response( - JSON.stringify({ - ...automationPayload, - id: "automation-2", - name: "Shared daily brief", - template_id: "llm-skill:shared-daily-brief", - }), - { status: 200 }, - ), - ); - - await createTaskAutomation({ - name: "Shared daily brief", - prompt: "Summarize feature usage for my product areas.", - repository: "posthog/posthog", - github_integration: 7, - cron_expression: "0 8 * * 1-5", - timezone: "America/New_York", - enabled: true, - template_id: "llm-skill:shared-daily-brief", - }); - - expect(mockFetch).toHaveBeenCalledWith( - "https://app.posthog.test/api/projects/42/task_automations/", - expect.objectContaining({ - method: "POST", - body: JSON.stringify({ - name: "Shared daily brief", - prompt: "Summarize feature usage for my product areas.", - repository: "posthog/posthog", - github_integration: 7, - cron_expression: "0 8 * * 1-5", - timezone: "America/New_York", - enabled: true, - template_id: "llm-skill:shared-daily-brief", - }), - }), - ); - }); - - it("retains backend field attribution for validation errors", async () => { - mockFetch.mockImplementation(() => - Promise.resolve( - new Response( - JSON.stringify({ - type: "validation_error", - code: "invalid_input", - detail: - "Only standard 5-field cron expressions are supported (minute hour day month weekday). Example: '0 9 * * 1-5'.", - attr: "cron_expression", - }), - { status: 400, statusText: "Bad Request" }, - ), - ), - ); - - await expect( - createTaskAutomation({ - name: "Daily PRs", - prompt: "Check PRs", - repository: "posthog/posthog", - cron_expression: "not a cron", - timezone: "Europe/London", - }), - ).rejects.toBeInstanceOf(TaskAutomationValidationError); - - await expect( - createTaskAutomation({ - name: "Daily PRs", - prompt: "Check PRs", - repository: "posthog/posthog", - cron_expression: "not a cron", - timezone: "Europe/London", - }), - ).rejects.toMatchObject({ - attr: "cron_expression", - code: "invalid_input", - }); - }); - - it("surfaces skill-backed validation failures without losing backend attr info", async () => { - mockFetch.mockResolvedValueOnce( - new Response( - JSON.stringify({ - type: "validation_error", - code: "invalid_input", - detail: "Repository is still required for this template.", - attr: "repository", - }), - { status: 400, statusText: "Bad Request" }, - ), - ); - - await expect( - createTaskAutomation({ - name: "Shared daily brief", - prompt: "Summarize feature usage for my product areas.", - repository: "", - github_integration: null, - cron_expression: "0 8 * * 1-5", - timezone: "America/New_York", - enabled: true, - template_id: "llm-skill:shared-daily-brief", - }), - ).rejects.toMatchObject({ - attr: "repository", - code: "invalid_input", - message: "Repository is still required for this template.", - }); - }); - - it("supports retrieve, update, delete, and run-now automation flows", async () => { - mockFetch - .mockResolvedValueOnce( - new Response(JSON.stringify(automationPayload), { status: 200 }), - ) - .mockResolvedValueOnce( - new Response(JSON.stringify(automationPayload), { status: 200 }), - ) - .mockResolvedValueOnce(new Response(null, { status: 204 })) - .mockResolvedValueOnce( - new Response(JSON.stringify(automationPayload), { status: 200 }), - ); - - const retrieved = await getTaskAutomation("automation-1"); - const updated = await updateTaskAutomation("automation-1", { - enabled: false, - cron_expression: "30 14 * * *", - }); - await deleteTaskAutomation("automation-1"); - const ran = await runTaskAutomation("automation-1"); - - expect(retrieved.id).toBe("automation-1"); - expect(updated.id).toBe("automation-1"); - expect(ran.id).toBe("automation-1"); - expect(mockFetch).toHaveBeenNthCalledWith( - 2, - "https://app.posthog.test/api/projects/42/task_automations/automation-1/", - expect.objectContaining({ - method: "PATCH", - body: JSON.stringify({ - enabled: false, - cron_expression: "30 14 * * *", - }), - }), - ); - expect(mockFetch).toHaveBeenNthCalledWith( - 3, - "https://app.posthog.test/api/projects/42/task_automations/automation-1/", - expect.objectContaining({ - method: "DELETE", - }), - ); - expect(mockFetch).toHaveBeenNthCalledWith( - 4, - "https://app.posthog.test/api/projects/42/task_automations/automation-1/run/", - expect.objectContaining({ - method: "POST", - }), - ); - }); -}); diff --git a/apps/mobile/src/features/tasks/api.test.ts b/apps/mobile/src/features/tasks/api.test.ts index ab54e96253..bcb3f5c1f0 100644 --- a/apps/mobile/src/features/tasks/api.test.ts +++ b/apps/mobile/src/features/tasks/api.test.ts @@ -1,7 +1,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const { mockFetch } = vi.hoisted(() => ({ +const { mockFetch, mockRunTaskInCloud } = vi.hoisted(() => ({ mockFetch: vi.fn(), + mockRunTaskInCloud: vi.fn(), })); vi.mock("expo/fetch", () => ({ @@ -9,6 +10,16 @@ vi.mock("expo/fetch", () => ({ })); vi.mock("@/lib/api", () => ({ + HttpError: class HttpError extends Error { + constructor( + readonly status: number, + readonly statusText: string, + message: string, + ) { + super(message); + this.name = "HttpError"; + } + }, getBaseUrl: () => "https://app.posthog.test", getProjectId: () => 42, getAccessToken: () => "token", @@ -24,6 +35,10 @@ vi.mock("@/lib/api", () => ({ }), })); +vi.mock("@/lib/posthogApiClient", () => ({ + getPostHogApiClient: () => ({ runTaskInCloud: mockRunTaskInCloud }), +})); + import { cancelRun, HttpError, @@ -38,10 +53,8 @@ function bodyOf(call: unknown): Record { describe("runTaskInCloud", () => { beforeEach(() => { - mockFetch.mockReset(); - mockFetch.mockResolvedValue( - new Response(JSON.stringify({ id: "task-1" }), { status: 200 }), - ); + mockRunTaskInCloud.mockReset(); + mockRunTaskInCloud.mockResolvedValue({ id: "task-1" }); }); it.each([true, false])( @@ -49,23 +62,28 @@ describe("runTaskInCloud", () => { async (flag) => { await runTaskInCloud("task-1", { autoPublish: flag }); - expect(bodyOf(mockFetch.mock.calls[0])).toMatchObject({ - auto_publish: flag, - }); + expect(mockRunTaskInCloud).toHaveBeenCalledWith( + "task-1", + undefined, + expect.objectContaining({ autoPublish: flag }), + ); }, ); it("omits auto_publish when not provided", async () => { await runTaskInCloud("task-1", { model: "claude-opus-4-8" }); - expect(bodyOf(mockFetch.mock.calls[0])).not.toHaveProperty("auto_publish"); + expect(mockRunTaskInCloud).toHaveBeenCalledWith( + "task-1", + undefined, + expect.objectContaining({ autoPublish: undefined }), + ); }); it("sends no body for the plain initial run", async () => { await runTaskInCloud("task-1"); - const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; - expect(init.body).toBeUndefined(); + expect(mockRunTaskInCloud).toHaveBeenCalledWith("task-1"); }); it("forwards the selected sandbox environment and custom image", async () => { @@ -74,10 +92,14 @@ describe("runTaskInCloud", () => { customImageId: "image-123", }); - expect(bodyOf(mockFetch.mock.calls[0])).toMatchObject({ - sandbox_environment_id: "environment-123", - custom_image_id: "image-123", - }); + expect(mockRunTaskInCloud).toHaveBeenCalledWith( + "task-1", + undefined, + expect.objectContaining({ + sandboxEnvironmentId: "environment-123", + customImageId: "image-123", + }), + ); }); it("omits the sandbox environment and custom image when unset", async () => { @@ -87,24 +109,34 @@ describe("runTaskInCloud", () => { customImageId: null, }); - const body = bodyOf(mockFetch.mock.calls[0]); - expect(body).not.toHaveProperty("sandbox_environment_id"); - expect(body).not.toHaveProperty("custom_image_id"); + expect(mockRunTaskInCloud).toHaveBeenCalledWith( + "task-1", + undefined, + expect.objectContaining({ + sandboxEnvironmentId: undefined, + customImageId: undefined, + }), + ); }); it("sends rtk_enabled=false when the run opts out", async () => { await runTaskInCloud("task-1", { rtkEnabled: false }); - expect(bodyOf(mockFetch.mock.calls[0])).toMatchObject({ - rtk_enabled: false, - }); + expect(mockRunTaskInCloud).toHaveBeenCalledWith( + "task-1", + undefined, + expect.objectContaining({ rtkEnabled: false }), + ); }); it("omits rtk_enabled when the run keeps compression on", async () => { await runTaskInCloud("task-1", { rtkEnabled: true }); - const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; - expect(init.body).toBeUndefined(); + expect(mockRunTaskInCloud).toHaveBeenCalledWith( + "task-1", + undefined, + expect.objectContaining({ rtkEnabled: true }), + ); }); }); diff --git a/apps/mobile/src/features/tasks/api.ts b/apps/mobile/src/features/tasks/api.ts index f7d03d1056..f50a155246 100644 --- a/apps/mobile/src/features/tasks/api.ts +++ b/apps/mobile/src/features/tasks/api.ts @@ -1,8 +1,10 @@ -import type { Adapter } from "@posthog/shared"; import type { - SandboxCustomImage, - SandboxEnvironment, -} from "@posthog/shared/domain-types"; + Adapter, + ExecutionMode, + StoredLogEntry, + Task, + TaskRun, +} from "@posthog/shared"; import { fetch } from "expo/fetch"; import { authedFetch, @@ -10,75 +12,11 @@ import { getAccessToken, getBaseUrl, getProjectId, + HttpError, } from "@/lib/api"; -import { logger } from "@/lib/logger"; -import type { - CreateTaskAutomationOptions, - CreateTaskOptions, - Integration, - StoredLogEntry, - Task, - TaskAutomation, - TaskRun, - UpdateTaskAutomationOptions, - UserGithubIntegration, -} from "./types"; - -const log = logger.scope("tasks-api"); - -export class HttpError extends Error { - readonly status: number; - - constructor(status: number, statusText: string, prefix: string) { - super(`${prefix}: ${status} ${statusText}`); - this.name = "HttpError"; - this.status = status; - } -} - -export class TaskAutomationValidationError extends Error { - readonly code: string; - readonly attr: string | null; - - constructor(message: string, code: string, attr: string | null) { - super(message); - this.name = "TaskAutomationValidationError"; - this.code = code; - this.attr = attr; - } -} - -async function parseJsonResponse(response: Response): Promise { - return (await response.json()) as T; -} +import { getPostHogApiClient } from "@/lib/posthogApiClient"; -async function parseTaskAutomationError(response: Response): Promise { - let payload: { - code?: string; - detail?: string; - attr?: string; - } | null = null; - - try { - payload = await response.json(); - } catch { - payload = null; - } - - if (response.status === 400 && payload?.detail) { - throw new TaskAutomationValidationError( - payload.detail, - payload.code ?? "invalid_input", - payload.attr ?? null, - ); - } - - throw new HttpError( - response.status, - response.statusText, - "Task automation request failed", - ); -} +export { HttpError } from "@/lib/api"; async function withRetry( fn: () => Promise, @@ -125,309 +63,6 @@ function isRetryableError(error: unknown): boolean { return false; } -export async function getTasks(filters?: { - repository?: string; - createdBy?: number; - originProduct?: string; -}): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const params = new URLSearchParams({ limit: "500" }); - if (filters?.repository) { - params.set("repository", filters.repository); - } - if (filters?.createdBy) { - params.set("created_by", String(filters.createdBy)); - } - if (filters?.originProduct) { - params.set("origin_product", filters.originProduct); - } - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/tasks/?${params}`, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to fetch tasks", - ); - } - - const data = await parseJsonResponse<{ results?: Task[] }>(response); - return data.results ?? []; -} - -export async function getTask(taskId: string): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/tasks/${taskId}/`, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to fetch task", - ); - } - - return await parseJsonResponse(response); -} - -export async function getTaskAutomations(): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/task_automations/?limit=500`, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to fetch task automations", - ); - } - - const data = await parseJsonResponse<{ results?: TaskAutomation[] }>( - response, - ); - return data.results ?? []; -} - -export async function getTaskAutomation( - automationId: string, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/task_automations/${automationId}/`, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to fetch task automation", - ); - } - - return await parseJsonResponse(response); -} - -export async function createTaskAutomation( - options: CreateTaskAutomationOptions, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/task_automations/`, - { - method: "POST", - body: JSON.stringify(options), - }, - ); - - if (!response.ok) { - await parseTaskAutomationError(response); - } - - return await parseJsonResponse(response); -} - -export async function updateTaskAutomation( - automationId: string, - updates: UpdateTaskAutomationOptions, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/task_automations/${automationId}/`, - { - method: "PATCH", - body: JSON.stringify(updates), - }, - ); - - if (!response.ok) { - await parseTaskAutomationError(response); - } - - return await parseJsonResponse(response); -} - -export async function deleteTaskAutomation( - automationId: string, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/task_automations/${automationId}/`, - { method: "DELETE" }, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to delete task automation", - ); - } -} - -export async function runTaskAutomation( - automationId: string, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/task_automations/${automationId}/run/`, - { method: "POST" }, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to run task automation", - ); - } - - return await parseJsonResponse(response); -} - -export async function warmTask(options: { - repository: string; - github_integration: number; - branch?: string | null; - runtime_adapter?: string | null; - model?: string | null; - reasoning_effort?: string | null; - sandbox_environment_id?: string | null; - custom_image_id?: string | null; -}): Promise<{ task_id: string; run_id: string } | null> { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/tasks/warm/`, - { - method: "POST", - body: JSON.stringify({ - repository: options.repository, - github_integration: options.github_integration, - branch: options.branch ?? null, - runtime_adapter: options.runtime_adapter ?? null, - model: options.model ?? null, - reasoning_effort: options.reasoning_effort ?? null, - ...(options.sandbox_environment_id - ? { sandbox_environment_id: options.sandbox_environment_id } - : {}), - ...(options.custom_image_id - ? { custom_image_id: options.custom_image_id } - : {}), - }), - }, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to warm task", - ); - } - - const text = await response.text(); - if (!text) { - return null; - } - return JSON.parse(text) as { task_id: string; run_id: string }; -} - -export async function createTask(options: CreateTaskOptions): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - 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(); - log.error("Create task error", errorText); - throw new HttpError( - response.status, - `${response.statusText} - ${errorText}`, - "Failed to create task", - ); - } - - return await parseJsonResponse(response); -} - -export async function updateTask( - taskId: string, - updates: Partial, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/tasks/${taskId}/`, - { - method: "PATCH", - body: JSON.stringify(updates), - }, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to update task", - ); - } - - return await parseJsonResponse(response); -} - -export async function deleteTask(taskId: string): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/tasks/${taskId}/`, - { method: "DELETE" }, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to delete task", - ); - } -} - export interface RunTaskInCloudOptions { branch?: string | null; resumeFromRunId?: string; @@ -439,10 +74,6 @@ export interface RunTaskInCloudOptions { model?: string; /** Reasoning effort: "low" | "medium" | "high" (model-dependent). */ reasoningEffort?: string; - /** Sandbox environment / custom base image to run on. Sent so a reused warm - * sandbox matches the selection instead of a mismatched default. */ - sandboxEnvironmentId?: string | null; - customImageId?: string | null; /** Permission mode: "default" | "acceptEdits" | "plan" | "auto". */ initialPermissionMode?: string; /** Source that triggered this run. */ @@ -454,92 +85,34 @@ export interface RunTaskInCloudOptions { autoPublish?: boolean; /** Only false is sent: opts the run out of rtk command-output compression. */ rtkEnabled?: boolean; + sandboxEnvironmentId?: string | null; + customImageId?: string | null; } export async function runTaskInCloud( taskId: string, options?: RunTaskInCloudOptions, ): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - // Only serialize a body when we have options to send. Sending an empty - // or minimal body on the initial run historically changed backend - // behavior, so we preserve the "no body" path for the common case. - const hasOptions = - !!options && - (options.branch !== undefined || - options.resumeFromRunId !== undefined || - options.pendingUserMessage !== undefined || - options.mode !== undefined || - options.runtimeAdapter !== undefined || - options.model !== undefined || - options.reasoningEffort !== undefined || - options.sandboxEnvironmentId !== undefined || - options.customImageId !== undefined || - options.initialPermissionMode !== undefined || - options.runSource !== undefined || - options.signalReportId !== undefined || - options.autoPublish !== undefined || - options.rtkEnabled === false); - - let body: string | undefined; - if (hasOptions) { - const payload: Record = { - mode: options?.mode ?? "interactive", - }; - if (options?.branch) payload.branch = options.branch; - if (options?.resumeFromRunId) { - payload.resume_from_run_id = options.resumeFromRunId; - } - if (options?.pendingUserMessage) { - payload.pending_user_message = options.pendingUserMessage; - } - if (options?.runtimeAdapter) { - payload.runtime_adapter = options.runtimeAdapter; - if (options?.model) payload.model = options.model; - if (options?.reasoningEffort) { - payload.reasoning_effort = options.reasoningEffort; - } - } - if (options?.sandboxEnvironmentId) { - payload.sandbox_environment_id = options.sandboxEnvironmentId; - } - if (options?.customImageId) { - payload.custom_image_id = options.customImageId; - } - if (options?.initialPermissionMode) { - payload.initial_permission_mode = options.initialPermissionMode; - } - if (options?.runSource) payload.run_source = options.runSource; - if (options?.signalReportId) - payload.signal_report_id = options.signalReportId; - if (options?.autoPublish !== undefined) { - payload.auto_publish = options.autoPublish; - } - if (options?.rtkEnabled === false) { - payload.rtk_enabled = false; - } - body = JSON.stringify(payload); - } - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/tasks/${taskId}/run/`, - { - method: "POST", - body, - }, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to run task", - ); - } - - return await response.json(); + if (!options) { + return getPostHogApiClient().runTaskInCloud(taskId); + } + + return getPostHogApiClient().runTaskInCloud(taskId, options.branch, { + adapter: options.runtimeAdapter, + model: options.model, + reasoningLevel: options.reasoningEffort, + initialPermissionMode: options.initialPermissionMode as + | ExecutionMode + | undefined, + runSource: options.runSource, + signalReportId: options.signalReportId, + autoPublish: options.autoPublish, + rtkEnabled: options.rtkEnabled, + sandboxEnvironmentId: options.sandboxEnvironmentId ?? undefined, + customImageId: options.customImageId ?? undefined, + resumeFromRunId: options.resumeFromRunId, + pendingUserMessage: options.pendingUserMessage, + }); } export async function getTaskRun( @@ -834,232 +407,3 @@ export async function streamCloudTask( signal: options.signal, }); } - -export async function getSandboxCustomImages(): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/sandbox_custom_images/`, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to fetch sandbox custom images", - ); - } - - const data = await parseJsonResponse<{ results?: SandboxCustomImage[] }>( - response, - ); - return data.results ?? []; -} - -export async function getSandboxEnvironments(): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/sandbox_environments/`, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to fetch sandbox environments", - ); - } - - const data = await parseJsonResponse<{ results?: SandboxEnvironment[] }>( - response, - ); - return data.results ?? []; -} - -export async function getIntegrations(): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/environments/${projectId}/integrations/`, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to fetch integrations", - ); - } - - const data = await parseJsonResponse< - | { - results?: Integration[]; - } - | Integration[] - >(response); - return Array.isArray(data) ? data : (data.results ?? []); -} - -const GITHUB_REPOS_PAGE_SIZE = 500; - -export async function getGithubRepositories( - integrationId: number, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const allRepos: string[] = []; - let offset = 0; - - while (true) { - const params = new URLSearchParams({ - limit: String(GITHUB_REPOS_PAGE_SIZE), - offset: String(offset), - }); - const response = await authedFetch( - `${baseUrl}/api/environments/${projectId}/integrations/${integrationId}/github_repos/?${params}`, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to fetch repositories", - ); - } - - const data = await response.json(); - const repos: Array = - data.repositories ?? data.results ?? data ?? []; - - const normalized = repos - .map((repo) => { - if (typeof repo === "string") return repo.toLowerCase(); - return (repo.full_name ?? repo.name ?? "").toLowerCase(); - }) - .filter((name) => name.length > 0); - - allRepos.push(...normalized); - - if (!data.has_more || repos.length === 0) { - return allRepos; - } - - offset += repos.length; - } -} - -export interface GithubUserConnectResult { - install_url: string; - connect_flow?: "oauth_authorize" | "oauth_discover" | "app_install"; -} - -/** - * Starts the user-scoped GitHub connection flow (mirrors desktop). The backend - * picks the lightweight OAuth flow when the team already has the GitHub App - * installed, otherwise a discover/install flow, and returns the URL to open. - * - * `connect_from: "posthog_mobile"` tells the backend to redirect the OAuth - * callback to `posthog://github/callback` so the in-app browser auto-closes. - */ -export async function startGithubUserIntegrationConnect(): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/users/@me/integrations/github/start/`, - { - method: "POST", - body: JSON.stringify({ - team_id: projectId, - connect_from: "posthog_mobile", - }), - }, - ); - - if (!response.ok) { - const payload = (await response.json().catch(() => ({}))) as { - detail?: unknown; - }; - const detail = - typeof payload.detail === "string" - ? payload.detail - : "Failed to start GitHub connection"; - throw new HttpError(response.status, response.statusText, detail); - } - - return parseJsonResponse(response); -} - -export async function getUserGithubIntegrations(): Promise< - UserGithubIntegration[] -> { - const baseUrl = getBaseUrl(); - - const response = await authedFetch( - `${baseUrl}/api/users/@me/integrations/?kind=github`, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to fetch personal GitHub integrations", - ); - } - - const data = await parseJsonResponse<{ results?: UserGithubIntegration[] }>( - response, - ); - return data.results ?? []; -} - -export async function getUserGithubRepositories( - installationId: string, -): Promise { - const baseUrl = getBaseUrl(); - - const allRepos: string[] = []; - let offset = 0; - - while (true) { - const params = new URLSearchParams({ - limit: String(GITHUB_REPOS_PAGE_SIZE), - offset: String(offset), - }); - const response = await authedFetch( - `${baseUrl}/api/users/@me/integrations/github/${installationId}/repos/?${params}`, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to fetch repositories", - ); - } - - const data = await response.json(); - const repos: Array = - data.repositories ?? data.results ?? data ?? []; - - const normalized = repos - .map((repo) => { - if (typeof repo === "string") return repo.toLowerCase(); - return (repo.full_name ?? repo.name ?? "").toLowerCase(); - }) - .filter((name) => name.length > 0); - - allRepos.push(...normalized); - - if (!data.has_more || repos.length === 0) { - return allRepos; - } - - offset += repos.length; - } -} diff --git a/apps/mobile/src/features/tasks/api.warm.test.ts b/apps/mobile/src/features/tasks/api.warm.test.ts deleted file mode 100644 index 06c9d1aba0..0000000000 --- a/apps/mobile/src/features/tasks/api.warm.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const { mockFetch } = vi.hoisted(() => ({ - mockFetch: vi.fn(), -})); - -vi.mock("expo/fetch", () => ({ - fetch: mockFetch, -})); - -vi.mock("@/lib/api", () => ({ - getBaseUrl: () => "https://app.posthog.test", - getProjectId: () => 42, - authedFetch: (url: string, init?: RequestInit) => - mockFetch(url, { - ...init, - headers: { - Authorization: "Bearer token", - "Content-Type": "application/json", - ...((init?.headers as Record | undefined) ?? {}), - }, - }), -})); - -import { HttpError, warmTask } from "./api"; - -describe("warmTask", () => { - beforeEach(() => { - mockFetch.mockReset(); - }); - - it("posts the warm request with the backend contract", async () => { - mockFetch.mockResolvedValueOnce( - new Response(JSON.stringify({ task_id: "task-1", run_id: "run-1" }), { - status: 200, - }), - ); - - const result = await warmTask({ - repository: "posthog/posthog", - github_integration: 7, - branch: "main", - }); - - expect(result).toEqual({ task_id: "task-1", run_id: "run-1" }); - expect(mockFetch).toHaveBeenCalledWith( - "https://app.posthog.test/api/projects/42/tasks/warm/", - expect.objectContaining({ - method: "POST", - body: JSON.stringify({ - repository: "posthog/posthog", - github_integration: 7, - branch: "main", - runtime_adapter: null, - model: null, - reasoning_effort: null, - }), - }), - ); - }); - - it("forwards the selected runtime, model, and reasoning effort", async () => { - mockFetch.mockResolvedValueOnce( - new Response(JSON.stringify({ task_id: "task-1", run_id: "run-1" }), { - status: 200, - }), - ); - - await warmTask({ - repository: "posthog/posthog", - github_integration: 7, - branch: "main", - runtime_adapter: "claude", - model: "claude-opus-4-8", - reasoning_effort: "high", - }); - - expect(mockFetch).toHaveBeenCalledWith( - "https://app.posthog.test/api/projects/42/tasks/warm/", - expect.objectContaining({ - body: JSON.stringify({ - repository: "posthog/posthog", - github_integration: 7, - branch: "main", - runtime_adapter: "claude", - model: "claude-opus-4-8", - reasoning_effort: "high", - }), - }), - ); - }); - - it("forwards the selected sandbox environment and custom image", async () => { - mockFetch.mockResolvedValueOnce( - new Response(JSON.stringify({ task_id: "task-1", run_id: "run-1" }), { - status: 200, - }), - ); - - await warmTask({ - repository: "posthog/posthog", - github_integration: 7, - branch: "main", - sandbox_environment_id: "environment-123", - custom_image_id: "image-123", - }); - - expect(mockFetch).toHaveBeenCalledWith( - "https://app.posthog.test/api/projects/42/tasks/warm/", - expect.objectContaining({ - body: JSON.stringify({ - repository: "posthog/posthog", - github_integration: 7, - branch: "main", - runtime_adapter: null, - model: null, - reasoning_effort: null, - sandbox_environment_id: "environment-123", - custom_image_id: "image-123", - }), - }), - ); - }); - - it("omits the sandbox environment and custom image when unset", async () => { - mockFetch.mockResolvedValueOnce( - new Response(JSON.stringify({ task_id: "task-1", run_id: "run-1" }), { - status: 200, - }), - ); - - await warmTask({ - repository: "posthog/posthog", - github_integration: 7, - sandbox_environment_id: null, - custom_image_id: null, - }); - - const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; - const body = JSON.parse(init.body as string); - expect(body).not.toHaveProperty("sandbox_environment_id"); - expect(body).not.toHaveProperty("custom_image_id"); - }); - - it("serializes a missing branch as null", async () => { - mockFetch.mockResolvedValueOnce( - new Response(JSON.stringify({ task_id: "task-1", run_id: "run-1" }), { - status: 200, - }), - ); - - await warmTask({ repository: "posthog/posthog", github_integration: 7 }); - - expect(mockFetch).toHaveBeenCalledWith( - "https://app.posthog.test/api/projects/42/tasks/warm/", - expect.objectContaining({ - body: JSON.stringify({ - repository: "posthog/posthog", - github_integration: 7, - branch: null, - runtime_adapter: null, - model: null, - reasoning_effort: null, - }), - }), - ); - }); - - it("returns null when the response body is empty", async () => { - mockFetch.mockResolvedValueOnce(new Response("", { status: 200 })); - - const result = await warmTask({ - repository: "posthog/posthog", - github_integration: 7, - }); - - expect(result).toBeNull(); - }); - - it("throws an HttpError on a failed response", async () => { - mockFetch.mockResolvedValueOnce(new Response("nope", { status: 500 })); - - await expect( - warmTask({ repository: "posthog/posthog", github_integration: 7 }), - ).rejects.toBeInstanceOf(HttpError); - }); -}); diff --git a/apps/mobile/src/features/tasks/components/AutomationDetail.tsx b/apps/mobile/src/features/tasks/components/AutomationDetail.tsx index 6838b40761..b560293b6b 100644 --- a/apps/mobile/src/features/tasks/components/AutomationDetail.tsx +++ b/apps/mobile/src/features/tasks/components/AutomationDetail.tsx @@ -1,7 +1,8 @@ import { Text } from "@components/text"; +import type { TaskAutomation } from "@posthog/api-client/posthog-client"; +import { formatAutomationScheduleSummary } from "@posthog/core/automations/automationSchedule"; +import type { TaskRun } from "@posthog/shared"; import { ActivityIndicator, Pressable, View } from "react-native"; -import type { TaskAutomation, TaskRun } from "../types"; -import { formatAutomationScheduleSummary } from "../utils/automationSchedule"; import { getAutomationTemplatePresentation } from "../utils/automationTemplatePresentation"; import { AutomationStatusBadge } from "./AutomationStatusBadge"; diff --git a/apps/mobile/src/features/tasks/components/AutomationForm.tsx b/apps/mobile/src/features/tasks/components/AutomationForm.tsx index d6f076ef82..56387f474d 100644 --- a/apps/mobile/src/features/tasks/components/AutomationForm.tsx +++ b/apps/mobile/src/features/tasks/components/AutomationForm.tsx @@ -1,4 +1,12 @@ import { Text } from "@components/text"; +import type { CreateTaskAutomationOptions } from "@posthog/api-client/posthog-client"; +import { + type AutomationScheduleDraft, + buildCronExpression, + createDefaultScheduleDraft, + deriveAutomationName, + parseCronExpression, +} from "@posthog/core/automations/automationSchedule"; import { CaretDown, GithubLogo } from "phosphor-react-native"; import { type MutableRefObject, useEffect, useMemo, useState } from "react"; import { @@ -12,17 +20,7 @@ import { MarkdownText } from "@/features/chat/components/MarkdownText"; import { useThemeColors } from "@/lib/theme"; import { RepositoryPickerInline } from "../composer/RepositoryPickerInline"; import { useIntegrations } from "../hooks/useIntegrations"; -import type { - CreateTaskAutomationOptions, - RepositorySelection, -} from "../types"; -import { - type AutomationScheduleDraft, - buildCronExpression, - createDefaultScheduleDraft, - deriveAutomationName, - parseCronExpression, -} from "../utils/automationSchedule"; +import type { RepositorySelection } from "../types"; import { findRepositoryOption, isRepositorySelectionComplete, diff --git a/apps/mobile/src/features/tasks/components/AutomationItem.tsx b/apps/mobile/src/features/tasks/components/AutomationItem.tsx index 5ce8a7fe80..89d4f4d5de 100644 --- a/apps/mobile/src/features/tasks/components/AutomationItem.tsx +++ b/apps/mobile/src/features/tasks/components/AutomationItem.tsx @@ -1,9 +1,10 @@ import { Text } from "@components/text"; +import type { TaskAutomation } from "@posthog/api-client/posthog-client"; +import { formatAutomationScheduleSummary } from "@posthog/core/automations/automationSchedule"; +import type { TaskRun } from "@posthog/shared"; import { format, formatDistanceToNow } from "date-fns"; import { memo } from "react"; import { Pressable, View } from "react-native"; -import type { TaskAutomation, TaskRun } from "../types"; -import { formatAutomationScheduleSummary } from "../utils/automationSchedule"; import { getAutomationTemplatePresentation } from "../utils/automationTemplatePresentation"; import { AutomationStatusBadge } from "./AutomationStatusBadge"; diff --git a/apps/mobile/src/features/tasks/components/AutomationList.tsx b/apps/mobile/src/features/tasks/components/AutomationList.tsx index 31a5821962..1027a5aa46 100644 --- a/apps/mobile/src/features/tasks/components/AutomationList.tsx +++ b/apps/mobile/src/features/tasks/components/AutomationList.tsx @@ -1,4 +1,5 @@ import { Text } from "@components/text"; +import type { TaskAutomation } from "@posthog/api-client/posthog-client"; import { Plus } from "phosphor-react-native"; import { ActivityIndicator, @@ -10,7 +11,6 @@ import { import { useThemeColors } from "@/lib/theme"; import { useAutomations } from "../hooks/useAutomations"; import { useTasks } from "../hooks/useTasks"; -import type { TaskAutomation } from "../types"; import { AutomationItem } from "./AutomationItem"; interface AutomationListProps { diff --git a/apps/mobile/src/features/tasks/components/AutomationStatusBadge.tsx b/apps/mobile/src/features/tasks/components/AutomationStatusBadge.tsx index 970de00d64..600b36302e 100644 --- a/apps/mobile/src/features/tasks/components/AutomationStatusBadge.tsx +++ b/apps/mobile/src/features/tasks/components/AutomationStatusBadge.tsx @@ -1,6 +1,6 @@ import { Text } from "@components/text"; +import type { TaskRun } from "@posthog/shared"; import { View } from "react-native"; -import type { TaskRun } from "../types"; import { getAutomationStatusPresentation } from "../utils/automationStatus"; interface AutomationStatusBadgeProps { diff --git a/apps/mobile/src/features/tasks/components/CreateAutomationScreen.test.tsx b/apps/mobile/src/features/tasks/components/CreateAutomationScreen.test.tsx index 29934cada1..87fb997014 100644 --- a/apps/mobile/src/features/tasks/components/CreateAutomationScreen.test.tsx +++ b/apps/mobile/src/features/tasks/components/CreateAutomationScreen.test.tsx @@ -60,7 +60,7 @@ vi.mock("@/features/tasks/components/AutomationForm", () => ({ createElement("AutomationForm", props), })); -vi.mock("@/features/tasks/api", () => ({ +vi.mock("@posthog/api-client/posthog-client", () => ({ TaskAutomationValidationError: class TaskAutomationValidationError extends Error { code: string; attr: string | null; diff --git a/apps/mobile/src/features/tasks/components/CustomImageBadge.test.tsx b/apps/mobile/src/features/tasks/components/CustomImageBadge.test.tsx index 09b0531b6c..cc6d38b3bd 100644 --- a/apps/mobile/src/features/tasks/components/CustomImageBadge.test.tsx +++ b/apps/mobile/src/features/tasks/components/CustomImageBadge.test.tsx @@ -1,8 +1,8 @@ +import type { Task, TaskRun } from "@posthog/shared"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createElement } from "react"; import { act, create } from "react-test-renderer"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { Task, TaskRun } from "../types"; const { mockUseAuthStore, mockGetImages, mockGetEnvironments } = vi.hoisted( () => ({ @@ -14,9 +14,11 @@ const { mockUseAuthStore, mockGetImages, mockGetEnvironments } = vi.hoisted( vi.mock("@/features/auth", () => ({ useAuthStore: mockUseAuthStore })); -vi.mock("../api", () => ({ - getSandboxCustomImages: mockGetImages, - getSandboxEnvironments: mockGetEnvironments, +vi.mock("@/lib/posthogApiClient", () => ({ + getPostHogApiClient: () => ({ + listSandboxCustomImages: mockGetImages, + listSandboxEnvironments: mockGetEnvironments, + }), })); vi.mock("phosphor-react-native", () => ({ diff --git a/apps/mobile/src/features/tasks/components/CustomImageBadge.tsx b/apps/mobile/src/features/tasks/components/CustomImageBadge.tsx index 2e382c228b..13bf4e2d4a 100644 --- a/apps/mobile/src/features/tasks/components/CustomImageBadge.tsx +++ b/apps/mobile/src/features/tasks/components/CustomImageBadge.tsx @@ -1,9 +1,9 @@ import { Text } from "@components/text"; +import type { Task } from "@posthog/shared"; import { Cube } from "phosphor-react-native"; import { View } from "react-native"; import { toRgba } from "@/lib/theme"; import { useCustomImageName } from "../hooks/useCustomImageName"; -import type { Task } from "../types"; // Theme tokens have no violet; a fixed Radix violet-9 mirrors the desktop // custom-image badge and reads well in both light and dark. diff --git a/apps/mobile/src/features/tasks/components/GitHubConnectionPrompt.tsx b/apps/mobile/src/features/tasks/components/GitHubConnectionPrompt.tsx index 296c8ead4e..0da19fca21 100644 --- a/apps/mobile/src/features/tasks/components/GitHubConnectionPrompt.tsx +++ b/apps/mobile/src/features/tasks/components/GitHubConnectionPrompt.tsx @@ -3,8 +3,8 @@ import * as WebBrowser from "expo-web-browser"; import { Pressable, View } from "react-native"; import { useAuthStore } from "@/features/auth"; import { logger } from "@/lib/logger"; +import { getPostHogApiClient } from "@/lib/posthogApiClient"; import { useThemeColors } from "@/lib/theme"; -import { startGithubUserIntegrationConnect } from "../api"; const log = logger.scope("github-connection-prompt"); @@ -44,7 +44,8 @@ export function GitHubConnectionPrompt({ // and, because we pass `connect_from: "posthog_mobile"`, redirects the // callback to `posthog://github/callback` so this in-app browser closes. try { - const { install_url } = await startGithubUserIntegrationConnect(); + const { install_url } = + await getPostHogApiClient().startGithubUserIntegrationConnect(); authorizeUrl = install_url; } catch (error) { log.error("Failed to start GitHub connection", { error }); diff --git a/apps/mobile/src/features/tasks/components/ScheduleEditor.tsx b/apps/mobile/src/features/tasks/components/ScheduleEditor.tsx index 102e607e87..726973a639 100644 --- a/apps/mobile/src/features/tasks/components/ScheduleEditor.tsx +++ b/apps/mobile/src/features/tasks/components/ScheduleEditor.tsx @@ -1,5 +1,4 @@ import { Text } from "@components/text"; -import { Pressable, TextInput, View } from "react-native"; import { type AutomationScheduleDraft, type AutomationScheduleMode, @@ -7,7 +6,8 @@ import { sanitizeHour, sanitizeMinute, WEEKDAY_OPTIONS, -} from "../utils/automationSchedule"; +} from "@posthog/core/automations/automationSchedule"; +import { Pressable, TextInput, View } from "react-native"; interface ScheduleEditorProps { value: AutomationScheduleDraft; diff --git a/apps/mobile/src/features/tasks/components/SwipeableTaskItem.tsx b/apps/mobile/src/features/tasks/components/SwipeableTaskItem.tsx index a65b4c2576..64bd68eb9a 100644 --- a/apps/mobile/src/features/tasks/components/SwipeableTaskItem.tsx +++ b/apps/mobile/src/features/tasks/components/SwipeableTaskItem.tsx @@ -1,3 +1,5 @@ +import { isTaskRunning } from "@posthog/core/tasks/taskArchive"; +import type { Task } from "@posthog/shared"; import * as Haptics from "expo-haptics"; import { Archive, ArrowCounterClockwise } from "phosphor-react-native"; import { useEffect, useRef } from "react"; @@ -10,11 +12,7 @@ import { View, } from "react-native"; import { useThemeColors } from "@/lib/theme"; -import type { Task } from "../types"; -import { - confirmArchiveRunningTask, - isTaskRunning, -} from "../utils/archiveGuard"; +import { confirmArchiveRunningTask } from "../utils/archiveGuard"; import { TaskItem } from "./TaskItem"; const SWIPE_THRESHOLD = 60; diff --git a/apps/mobile/src/features/tasks/components/TaskItem.test.tsx b/apps/mobile/src/features/tasks/components/TaskItem.test.tsx index d62cfd7665..38cac02b57 100644 --- a/apps/mobile/src/features/tasks/components/TaskItem.test.tsx +++ b/apps/mobile/src/features/tasks/components/TaskItem.test.tsx @@ -1,7 +1,7 @@ +import type { Task } from "@posthog/shared"; import { createElement } from "react"; import { act, create } from "react-test-renderer"; import { describe, expect, it, vi } from "vitest"; -import type { Task } from "../types"; import { TaskItem } from "./TaskItem"; vi.mock("phosphor-react-native", () => ({ diff --git a/apps/mobile/src/features/tasks/components/TaskItem.tsx b/apps/mobile/src/features/tasks/components/TaskItem.tsx index e99bdfb768..c235722b37 100644 --- a/apps/mobile/src/features/tasks/components/TaskItem.tsx +++ b/apps/mobile/src/features/tasks/components/TaskItem.tsx @@ -1,11 +1,11 @@ import { Text } from "@components/text"; +import type { Task } from "@posthog/shared"; import { differenceInHours, format, formatDistanceToNow } from "date-fns"; import { Check, GitPullRequest } from "phosphor-react-native"; import { memo } from "react"; import { Linking, Pressable, View } from "react-native"; import { parseGithubIssueUrl } from "@/lib/githubIssueUrl"; import { useThemeColors } from "@/lib/theme"; -import type { Task } from "../types"; import { TaskStatusIcon } from "./TaskStatusIcon"; function PrBadge({ prUrl, number }: { prUrl: string; number: number }) { diff --git a/apps/mobile/src/features/tasks/components/TaskList.tsx b/apps/mobile/src/features/tasks/components/TaskList.tsx index 403d9c6836..e2a5a7e11a 100644 --- a/apps/mobile/src/features/tasks/components/TaskList.tsx +++ b/apps/mobile/src/features/tasks/components/TaskList.tsx @@ -1,4 +1,6 @@ import { Text } from "@components/text"; +import { taskActivityTimestamp } from "@posthog/core/tasks/taskActivity"; +import type { Task } from "@posthog/shared"; import * as Haptics from "expo-haptics"; import { Archive, GitBranch, Plus, Sparkle, X } from "phosphor-react-native"; import { useCallback, useMemo, useState } from "react"; @@ -13,8 +15,7 @@ import { useThemeColors } from "@/lib/theme"; import { useTasks } from "../hooks/useTasks"; import { useUserIntegrations } from "../hooks/useUserIntegrations"; import { useArchivedTasksStore } from "../stores/archivedTasksStore"; -import { taskActivityTimestamp, useTaskStore } from "../stores/taskStore"; -import type { Task } from "../types"; +import { useTaskStore } from "../stores/taskStore"; import { GitHubConnectionPrompt } from "./GitHubConnectionPrompt"; import { GitHubLoadNotice } from "./GitHubLoadNotice"; import { SwipeableTaskItem } from "./SwipeableTaskItem"; diff --git a/apps/mobile/src/features/tasks/components/TaskStatusIcon.test.ts b/apps/mobile/src/features/tasks/components/TaskStatusIcon.test.ts index 080e0f1ac9..92e99ef8af 100644 --- a/apps/mobile/src/features/tasks/components/TaskStatusIcon.test.ts +++ b/apps/mobile/src/features/tasks/components/TaskStatusIcon.test.ts @@ -1,5 +1,5 @@ +import type { Task } from "@posthog/shared"; import { describe, expect, it } from "vitest"; -import type { Task } from "../types"; import { getTaskStatusIconKind } from "./taskStatusIconKind"; function makeTask(latestRun?: Partial>): Task { @@ -51,25 +51,16 @@ describe("getTaskStatusIconKind", () => { makeTask({ environment: "cloud", status: "queued" }), ), ).toBe("chat"); - expect( getTaskStatusIconKind( makeTask({ environment: "cloud", status: "in_progress" }), ), ).toBe("chat"); - - expect( - getTaskStatusIconKind( - makeTask({ environment: "cloud", status: "started" }), - ), - ).toBe("chat"); - expect( getTaskStatusIconKind( makeTask({ environment: "cloud", status: "completed" }), ), ).toBe("chat"); - expect( getTaskStatusIconKind( makeTask({ environment: "cloud", status: "cancelled" }), @@ -83,7 +74,6 @@ describe("getTaskStatusIconKind", () => { makeTask({ environment: "local", status: "in_progress" }), ), ).toBe("running"); - expect( getTaskStatusIconKind( makeTask({ environment: "local", status: "failed" }), diff --git a/apps/mobile/src/features/tasks/components/TaskStatusIcon.tsx b/apps/mobile/src/features/tasks/components/TaskStatusIcon.tsx index 06c992f047..8736b203b0 100644 --- a/apps/mobile/src/features/tasks/components/TaskStatusIcon.tsx +++ b/apps/mobile/src/features/tasks/components/TaskStatusIcon.tsx @@ -1,3 +1,4 @@ +import type { Task } from "@posthog/shared"; import { ChatCircle, CheckCircle, @@ -9,7 +10,6 @@ import { import { memo, useEffect, useRef } from "react"; import { Animated, Easing } from "react-native"; import { useThemeColors } from "@/lib/theme"; -import type { Task } from "../types"; import { getTaskStatusIconKind } from "./taskStatusIconKind"; interface TaskStatusIconProps { diff --git a/apps/mobile/src/features/tasks/components/taskStatusIconKind.ts b/apps/mobile/src/features/tasks/components/taskStatusIconKind.ts index fa7fbcd357..0fb172132b 100644 --- a/apps/mobile/src/features/tasks/components/taskStatusIconKind.ts +++ b/apps/mobile/src/features/tasks/components/taskStatusIconKind.ts @@ -1,4 +1,4 @@ -import type { Task } from "../types"; +import type { Task } from "@posthog/shared"; export type TaskStatusIconKind = | "pr" @@ -13,7 +13,6 @@ export function getTaskStatusIconKind(task: Task): TaskStatusIconKind { const status = task.latest_run?.status; const environment = task.latest_run?.environment; - // Match desktop semantics, but let PR win when a cloud task also has one. if (prUrl) { return "pr"; } @@ -34,7 +33,7 @@ export function getTaskStatusIconKind(task: Task): TaskStatusIconKind { return "running"; } - if (status === "queued" || status === "started") { + if (status === "queued") { return "started"; } diff --git a/apps/mobile/src/features/tasks/composer/options.test.ts b/apps/mobile/src/features/tasks/composer/options.test.ts new file mode 100644 index 0000000000..75328ebf04 --- /dev/null +++ b/apps/mobile/src/features/tasks/composer/options.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_MODEL, + DEFAULT_REASONING, + modelSupportsReasoning, + REASONING_LEVELS, +} from "./options"; + +describe("task composer options", () => { + it("uses an eligible non-premium default model", () => { + expect(DEFAULT_MODEL).toBe("claude-opus-4-8"); + expect(DEFAULT_MODEL).not.toContain("fable"); + }); + + it("derives reasoning defaults and options from shared policy", () => { + expect(DEFAULT_REASONING).toBe("high"); + expect(REASONING_LEVELS.map((option) => option.value)).toEqual([ + "low", + "medium", + "high", + "xhigh", + "max", + ]); + expect(modelSupportsReasoning("claude-opus-4-8")).toBe(true); + expect(modelSupportsReasoning("claude-haiku-4-5")).toBe(false); + }); +}); diff --git a/apps/mobile/src/features/tasks/composer/options.ts b/apps/mobile/src/features/tasks/composer/options.ts index be0b48cdd3..572fff4a35 100644 --- a/apps/mobile/src/features/tasks/composer/options.ts +++ b/apps/mobile/src/features/tasks/composer/options.ts @@ -1,32 +1,39 @@ -export type ExecutionMode = "default" | "acceptEdits" | "plan" | "auto"; -export type ReasoningEffort = "low" | "medium" | "high" | "xhigh" | "max"; +import { + DEFAULT_CLAUDE_EXECUTION_MODE, + getAvailableModes, +} from "@posthog/core/sessions/executionModes"; +import { + DEFAULT_GATEWAY_MODEL, + DEFAULT_REASONING_EFFORT, + defaultEligibleModel, + getReasoningEffortOptions, + type ExecutionMode as SharedExecutionMode, + type SupportedReasoningEffort, +} from "@posthog/shared"; + +export type ExecutionMode = Extract< + SharedExecutionMode, + "default" | "acceptEdits" | "plan" | "auto" +>; +export type ReasoningEffort = SupportedReasoningEffort; export const EXECUTION_MODES: { value: ExecutionMode; label: string; description: string; -}[] = [ - { - value: "plan", - label: "Plan Mode", - description: "Plan first, no tool execution", - }, - { - value: "default", - label: "Default", - description: "Standard behaviour, prompts for dangerous operations", - }, - { - value: "acceptEdits", - label: "Accept Edits", - description: "Auto-accept file edit operations", - }, - { - value: "auto", - label: "Auto", - description: "Model decides which prompts to approve or deny", - }, -]; +}[] = getAvailableModes() + .filter( + (mode): mode is typeof mode & { id: ExecutionMode } => + mode.id === "default" || + mode.id === "acceptEdits" || + mode.id === "plan" || + mode.id === "auto", + ) + .map((mode) => ({ + value: mode.id, + label: mode.name, + description: mode.description, + })); export interface ModelOption { value: string; @@ -68,20 +75,20 @@ export const MODELS: ModelOption[] = [ }, ]; +export const DEFAULT_EXECUTION_MODE: ExecutionMode = + DEFAULT_CLAUDE_EXECUTION_MODE; +export const DEFAULT_MODEL = + defaultEligibleModel(DEFAULT_GATEWAY_MODEL) ?? + MODELS.find((model) => defaultEligibleModel(model.value))?.value ?? + DEFAULT_GATEWAY_MODEL; +export const DEFAULT_REASONING: ReasoningEffort = DEFAULT_REASONING_EFFORT; + export const REASONING_LEVELS: { value: ReasoningEffort; label: string; -}[] = [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High" }, - { value: "xhigh", label: "Extra High" }, - { value: "max", label: "Max" }, -]; - -export const DEFAULT_EXECUTION_MODE: ExecutionMode = "plan"; -export const DEFAULT_MODEL = "claude-opus-4-8"; -export const DEFAULT_REASONING: ReasoningEffort = "high"; +}[] = (getReasoningEffortOptions("claude", DEFAULT_MODEL) ?? []).map( + (option) => ({ value: option.value, label: option.name }), +); export function modelLabel(value: string): string { return MODELS.find((m) => m.value === value)?.label ?? value; @@ -96,5 +103,5 @@ export function reasoningLabel(value: ReasoningEffort): string { } export function modelSupportsReasoning(value: string): boolean { - return MODELS.find((m) => m.value === value)?.supportsReasoning ?? false; + return getReasoningEffortOptions("claude", value) !== null; } diff --git a/apps/mobile/src/features/tasks/hooks/useAutomations.test.ts b/apps/mobile/src/features/tasks/hooks/useAutomations.test.ts index 93c4cf1dc0..772d9a4f69 100644 --- a/apps/mobile/src/features/tasks/hooks/useAutomations.test.ts +++ b/apps/mobile/src/features/tasks/hooks/useAutomations.test.ts @@ -8,26 +8,36 @@ const { mockGetTaskAutomations, mockCreateTaskAutomation, mockUpdateTaskAutomation, + mockApiClient, } = vi.hoisted(() => ({ mockUseAuthStore: vi.fn(), mockGetTaskAutomations: vi.fn(), mockCreateTaskAutomation: vi.fn(), mockUpdateTaskAutomation: vi.fn(), + mockApiClient: { + listTaskAutomations: vi.fn(), + getTaskAutomation: vi.fn(), + createTaskAutomation: vi.fn(), + updateTaskAutomation: vi.fn(), + deleteTaskAutomation: vi.fn(), + runTaskAutomation: vi.fn(), + }, })); vi.mock("@/features/auth", () => ({ useAuthStore: mockUseAuthStore, })); -vi.mock("../api", () => ({ - getTaskAutomations: mockGetTaskAutomations, - getTaskAutomation: vi.fn(), - createTaskAutomation: mockCreateTaskAutomation, - updateTaskAutomation: mockUpdateTaskAutomation, - deleteTaskAutomation: vi.fn(), - runTaskAutomation: vi.fn(), +vi.mock("../api", () => ({ runTaskInCloud: vi.fn() })); + +vi.mock("@/lib/posthogApiClient", () => ({ + getPostHogApiClient: () => mockApiClient, })); +mockApiClient.listTaskAutomations = mockGetTaskAutomations; +mockApiClient.createTaskAutomation = mockCreateTaskAutomation; +mockApiClient.updateTaskAutomation = mockUpdateTaskAutomation; + import { automationKeys, getAutomationPollingInterval, diff --git a/apps/mobile/src/features/tasks/hooks/useAutomations.ts b/apps/mobile/src/features/tasks/hooks/useAutomations.ts index e22d3e6d16..e6db7c11bd 100644 --- a/apps/mobile/src/features/tasks/hooks/useAutomations.ts +++ b/apps/mobile/src/features/tasks/hooks/useAutomations.ts @@ -1,19 +1,12 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useAuthStore } from "@/features/auth"; -import { logger } from "@/lib/logger"; -import { - createTaskAutomation, - deleteTaskAutomation, - getTaskAutomation, - getTaskAutomations, - runTaskAutomation, - updateTaskAutomation, -} from "../api"; import type { CreateTaskAutomationOptions, TaskAutomation, UpdateTaskAutomationOptions, -} from "../types"; +} from "@posthog/api-client/posthog-client"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useAuthStore } from "@/features/auth"; +import { logger } from "@/lib/logger"; +import { getPostHogApiClient } from "@/lib/posthogApiClient"; import { taskKeys } from "./useTasks"; const log = logger.scope("automations-mutations"); @@ -59,7 +52,7 @@ export function useAutomations() { const query = useQuery({ queryKey: automationKeys.list(), - queryFn: getTaskAutomations, + queryFn: () => getPostHogApiClient().listTaskAutomations(), enabled: !!projectId && !!oauthAccessToken, refetchInterval: (query) => getAutomationPollingInterval( @@ -80,7 +73,7 @@ export function useAutomation(automationId: string) { return useQuery({ queryKey: automationKeys.detail(automationId), - queryFn: () => getTaskAutomation(automationId), + queryFn: () => getPostHogApiClient().getTaskAutomation(automationId), enabled: !!projectId && !!oauthAccessToken && !!automationId, refetchInterval: (query) => getAutomationPollingInterval( @@ -94,7 +87,7 @@ export function useCreateTaskAutomation() { return useMutation({ mutationFn: (options: CreateTaskAutomationOptions) => - createTaskAutomation(options), + getPostHogApiClient().createTaskAutomation(options), onSuccess: (automation) => { queryClient.setQueryData( automationKeys.detail(automation.id), @@ -118,7 +111,7 @@ export function useUpdateTaskAutomation() { }: { automationId: string; updates: UpdateTaskAutomationOptions; - }) => updateTaskAutomation(automationId, updates), + }) => getPostHogApiClient().updateTaskAutomation(automationId, updates), onSuccess: (automation, { automationId }) => { queryClient.setQueryData( automationKeys.detail(automationId), @@ -136,7 +129,8 @@ export function useDeleteTaskAutomation() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (automationId: string) => deleteTaskAutomation(automationId), + mutationFn: (automationId: string) => + getPostHogApiClient().deleteTaskAutomation(automationId), onSuccess: (_, automationId) => { queryClient.removeQueries({ queryKey: automationKeys.detail(automationId), @@ -153,7 +147,8 @@ export function useRunTaskAutomation() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (automationId: string) => runTaskAutomation(automationId), + mutationFn: (automationId: string) => + getPostHogApiClient().runTaskAutomation(automationId), onSuccess: (automation, automationId) => { queryClient.setQueryData( automationKeys.detail(automationId), diff --git a/apps/mobile/src/features/tasks/hooks/useCustomImageName.ts b/apps/mobile/src/features/tasks/hooks/useCustomImageName.ts index a634ecd5e6..a8bf916ce8 100644 --- a/apps/mobile/src/features/tasks/hooks/useCustomImageName.ts +++ b/apps/mobile/src/features/tasks/hooks/useCustomImageName.ts @@ -1,6 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { useAuthStore } from "@/features/auth"; -import { getSandboxCustomImages, getSandboxEnvironments } from "../api"; +import { getPostHogApiClient } from "@/lib/posthogApiClient"; export const sandboxKeys = { customImages: () => ["sandbox-custom-images"] as const, @@ -26,7 +26,7 @@ export function useCustomImageName({ const imagesQuery = useQuery({ queryKey: sandboxKeys.customImages(), - queryFn: getSandboxCustomImages, + queryFn: () => getPostHogApiClient().listSandboxCustomImages(), enabled: canQuery && hasImageRef, staleTime: 60_000, retry: 0, @@ -34,7 +34,7 @@ export function useCustomImageName({ const environmentsQuery = useQuery({ queryKey: sandboxKeys.environments(), - queryFn: getSandboxEnvironments, + queryFn: () => getPostHogApiClient().listSandboxEnvironments(), enabled: canQuery && !!sandboxEnvironmentId, staleTime: 60_000, retry: 0, diff --git a/apps/mobile/src/features/tasks/hooks/useIntegrations.test.ts b/apps/mobile/src/features/tasks/hooks/useIntegrations.test.ts index 070fb37c47..45040a2df6 100644 --- a/apps/mobile/src/features/tasks/hooks/useIntegrations.test.ts +++ b/apps/mobile/src/features/tasks/hooks/useIntegrations.test.ts @@ -14,9 +14,11 @@ vi.mock("@/features/auth", () => ({ useAuthStore: mockUseAuthStore, })); -vi.mock("../api", () => ({ - getGithubRepositories: mockGetGithubRepositories, - getIntegrations: mockGetIntegrations, +vi.mock("@/lib/posthogApiClient", () => ({ + getPostHogApiClient: () => ({ + getGithubRepositories: mockGetGithubRepositories, + getIntegrations: mockGetIntegrations, + }), })); import { useRepositoryCacheStore } from "../stores/repositoryCacheStore"; diff --git a/apps/mobile/src/features/tasks/hooks/useIntegrations.ts b/apps/mobile/src/features/tasks/hooks/useIntegrations.ts index 49c1638780..bf2aab3c77 100644 --- a/apps/mobile/src/features/tasks/hooks/useIntegrations.ts +++ b/apps/mobile/src/features/tasks/hooks/useIntegrations.ts @@ -1,9 +1,9 @@ import { useQuery } from "@tanstack/react-query"; import { useEffect, useMemo } from "react"; import { useAuthStore } from "@/features/auth"; -import { getGithubRepositories, getIntegrations } from "../api"; +import { getPostHogApiClient } from "@/lib/posthogApiClient"; import { useRepositoryCacheStore } from "../stores/repositoryCacheStore"; -import type { RepositoryOption } from "../types"; +import type { Integration, RepositoryOption } from "../types"; import { buildRepositoryOptions } from "../utils/repositorySelection"; /** Cheap content-equality check for repository option lists. Lets the cache @@ -59,8 +59,27 @@ export function useIntegrations(options: UseIntegrationsOptions = {}) { const integrationsQuery = useQuery({ queryKey: integrationKeys.github(), queryFn: async () => { - const data = await getIntegrations(); - return data.filter((i) => i.kind === "github"); + const data = await getPostHogApiClient().getIntegrations(); + return data.flatMap((integration): Integration[] => { + if ( + integration.kind !== "github" || + typeof integration.id !== "number" + ) { + return []; + } + + return [ + { + id: integration.id, + kind: integration.kind, + display_name: + typeof integration.display_name === "string" + ? integration.display_name + : undefined, + config: integration.config as Integration["config"], + }, + ]; + }); }, enabled: enabled && !!projectId && !!oauthAccessToken, }); @@ -78,7 +97,9 @@ export function useIntegrations(options: UseIntegrationsOptions = {}) { const results = await Promise.allSettled( githubIntegrations.map(async (integration) => ({ integrationId: integration.id, - repositories: await getGithubRepositories(integration.id), + repositories: await getPostHogApiClient().getGithubRepositories( + integration.id, + ), })), ); diff --git a/apps/mobile/src/features/tasks/hooks/useTasks.test.ts b/apps/mobile/src/features/tasks/hooks/useTasks.test.ts index 6cd5c33ada..579ec4bce2 100644 --- a/apps/mobile/src/features/tasks/hooks/useTasks.test.ts +++ b/apps/mobile/src/features/tasks/hooks/useTasks.test.ts @@ -28,12 +28,17 @@ vi.mock("@/lib/logger", () => { }); vi.mock("../api", () => ({ - createTask: vi.fn(), - deleteTask: vi.fn(), - getTask: vi.fn(), - getTasks: vi.fn(), runTaskInCloud: vi.fn(), - updateTask: vi.fn(), +})); + +vi.mock("@/lib/posthogApiClient", () => ({ + getPostHogApiClient: () => ({ + createTask: vi.fn(), + deleteTask: vi.fn(), + getTask: vi.fn(), + getTasks: vi.fn(), + updateTask: vi.fn(), + }), })); vi.mock("../stores/taskStore", () => ({ diff --git a/apps/mobile/src/features/tasks/hooks/useTasks.ts b/apps/mobile/src/features/tasks/hooks/useTasks.ts index 1af7d5aa84..95b5d4725f 100644 --- a/apps/mobile/src/features/tasks/hooks/useTasks.ts +++ b/apps/mobile/src/features/tasks/hooks/useTasks.ts @@ -1,16 +1,12 @@ +import { filterAndSortTasks } from "@posthog/core/tasks/taskActivity"; +import type { Task } from "@posthog/shared"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useAuthStore, useUserQuery } from "@/features/auth"; import { logger } from "@/lib/logger"; -import { - createTask, - deleteTask, - getTask, - getTasks, - runTaskInCloud, - updateTask, -} from "../api"; -import { filterAndSortTasks, useTaskStore } from "../stores/taskStore"; -import type { CreateTaskOptions, Task } from "../types"; +import { getPostHogApiClient } from "@/lib/posthogApiClient"; +import { runTaskInCloud } from "../api"; +import { useTaskStore } from "../stores/taskStore"; +import type { CreateTaskOptions } from "../types"; const log = logger.scope("tasks-mutations"); const ACTIVE_TASK_POLLING_INTERVAL_MS = 5_000; @@ -69,7 +65,7 @@ export function useTasks(filters?: { const query = useQuery({ queryKey: taskKeys.list(queryFilters), - queryFn: () => getTasks(queryFilters), + queryFn: () => getPostHogApiClient().getTasks(queryFilters), enabled: !!projectId && !!oauthAccessToken && !!currentUser?.id, refetchInterval: (query) => getTaskPollingInterval(query.state.data as Task[] | undefined), @@ -102,7 +98,7 @@ export function useTask(taskId: string) { return useQuery({ queryKey: taskKeys.detail(taskId), - queryFn: () => getTask(taskId), + queryFn: () => getPostHogApiClient().getTask(taskId), enabled: !!projectId && !!oauthAccessToken && !!taskId, refetchInterval: (query) => getTaskPollingInterval(query.state.data as Task | undefined), @@ -117,7 +113,8 @@ export function useCreateTask() { }; const mutation = useMutation({ - mutationFn: (options: CreateTaskOptions) => createTask(options), + mutationFn: (options: CreateTaskOptions) => + getPostHogApiClient().createTask(options), onSuccess: () => { invalidateTasks(); }, @@ -139,7 +136,13 @@ export function useUpdateTask() { }: { taskId: string; updates: Partial; - }) => updateTask(taskId, updates), + }) => + getPostHogApiClient().updateTask( + taskId, + updates as Parameters< + ReturnType["updateTask"] + >[1], + ), onSuccess: (updatedTask, { taskId }) => { // Update the detail cache immediately queryClient.setQueryData(taskKeys.detail(taskId), updatedTask); @@ -155,7 +158,7 @@ export function useDeleteTask() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (taskId: string) => deleteTask(taskId), + mutationFn: (taskId: string) => getPostHogApiClient().deleteTask(taskId), onSuccess: (_, taskId) => { // Remove from detail cache queryClient.removeQueries({ queryKey: taskKeys.detail(taskId) }); diff --git a/apps/mobile/src/features/tasks/hooks/useUserIntegrations.ts b/apps/mobile/src/features/tasks/hooks/useUserIntegrations.ts index 68ae4f9808..1ba6655cf2 100644 --- a/apps/mobile/src/features/tasks/hooks/useUserIntegrations.ts +++ b/apps/mobile/src/features/tasks/hooks/useUserIntegrations.ts @@ -1,8 +1,8 @@ import { useQuery } from "@tanstack/react-query"; import { useCallback, useMemo } from "react"; import { useAuthStore } from "@/features/auth"; -import { getUserGithubIntegrations, getUserGithubRepositories } from "../api"; -import type { RepositoryOption, UserGithubIntegration } from "../types"; +import { getPostHogApiClient } from "@/lib/posthogApiClient"; +import type { RepositoryOption } from "../types"; /** * User-scoped sibling of {@link useIntegrations}. Reads the authenticated @@ -29,7 +29,10 @@ interface UseUserIntegrationsOptions { enabled?: boolean; } -function integrationLabel(integration: UserGithubIntegration): string { +function integrationLabel(integration: { + installation_id: string; + account?: { name?: string | null } | null; +}): string { return integration.account?.name ?? `GitHub ${integration.installation_id}`; } @@ -39,7 +42,7 @@ export function useUserIntegrations(options: UseUserIntegrationsOptions = {}) { const integrationsQuery = useQuery({ queryKey: userIntegrationKeys.github(), - queryFn: getUserGithubIntegrations, + queryFn: () => getPostHogApiClient().getGithubUserIntegrations(), enabled: enabled && !!oauthAccessToken, }); @@ -54,7 +57,7 @@ export function useUserIntegrations(options: UseUserIntegrationsOptions = {}) { const results = await Promise.allSettled( integrations.map(async (integration) => ({ installationId: integration.installation_id, - repositories: await getUserGithubRepositories( + repositories: await getPostHogApiClient().getGithubUserRepositories( integration.installation_id, ), })), diff --git a/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx b/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx index 5b757d40fc..175a77c276 100644 --- a/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx +++ b/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx @@ -8,8 +8,8 @@ const flagState = vi.hoisted(() => ({ enabled: true as boolean })); vi.mock("posthog-react-native", () => ({ useFeatureFlag: () => flagState.enabled, })); -vi.mock("@/features/tasks/api", () => ({ - warmTask: mockWarmTask, +vi.mock("@/lib/posthogApiClient", () => ({ + getPostHogApiClient: () => ({ warmTask: mockWarmTask }), })); vi.mock("@/lib/logger", () => { const mockLogger = { diff --git a/apps/mobile/src/features/tasks/hooks/useWarmTask.ts b/apps/mobile/src/features/tasks/hooks/useWarmTask.ts index 935619f75f..d3034f8638 100644 --- a/apps/mobile/src/features/tasks/hooks/useWarmTask.ts +++ b/apps/mobile/src/features/tasks/hooks/useWarmTask.ts @@ -1,8 +1,8 @@ import { TASKS_PREWARM_SANDBOX_FLAG } from "@posthog/shared"; import { useFeatureFlag } from "posthog-react-native"; import { useEffect, useRef } from "react"; -import { warmTask } from "@/features/tasks/api"; import { logger } from "@/lib/logger"; +import { getPostHogApiClient } from "@/lib/posthogApiClient"; const log = logger.scope("warm-task"); @@ -79,21 +79,23 @@ export function useWarmTask({ debounceRef.current = setTimeout(() => { debounceRef.current = null; lastWarmedKeyRef.current = key; - void warmTask({ - repository: repo, - github_integration: githubIntegration, - branch: warmBranch, - runtime_adapter: warmRuntimeAdapter, - model: warmModel, - reasoning_effort: warmReasoningEffort, - ...(warmSandboxEnvironmentId - ? { sandbox_environment_id: warmSandboxEnvironmentId } - : {}), - ...(warmCustomImageId ? { custom_image_id: warmCustomImageId } : {}), - }).catch((error) => { - lastWarmedKeyRef.current = null; - log.warn("Failed to warm task", error); - }); + void getPostHogApiClient() + .warmTask({ + repository: repo, + github_integration: githubIntegration, + branch: warmBranch, + runtime_adapter: warmRuntimeAdapter, + model: warmModel, + reasoning_effort: warmReasoningEffort, + ...(warmSandboxEnvironmentId + ? { sandbox_environment_id: warmSandboxEnvironmentId } + : {}), + ...(warmCustomImageId ? { custom_image_id: warmCustomImageId } : {}), + }) + .catch((error) => { + lastWarmedKeyRef.current = null; + log.warn("Failed to warm task", error); + }); }, WARM_DEBOUNCE_MS); return clearDebounce; diff --git a/apps/mobile/src/features/tasks/index.ts b/apps/mobile/src/features/tasks/index.ts index 07c05346e7..7da4db747e 100644 --- a/apps/mobile/src/features/tasks/index.ts +++ b/apps/mobile/src/features/tasks/index.ts @@ -1,7 +1,5 @@ // Tasks feature -// API -export * from "./api"; // Components export { TaskItem } from "./components/TaskItem"; export { TaskList } from "./components/TaskList"; @@ -29,7 +27,6 @@ export * from "./types"; // Utils export { - convertRawEntriesToEvents, convertStoredEntriesToEvents, parseSessionLogs, } from "./utils/parseSessionLogs"; diff --git a/apps/mobile/src/features/tasks/lib/cloudTaskStream.test.ts b/apps/mobile/src/features/tasks/lib/cloudTaskStream.test.ts new file mode 100644 index 0000000000..a3fc53a610 --- /dev/null +++ b/apps/mobile/src/features/tasks/lib/cloudTaskStream.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + engine: { + off: vi.fn(), + on: vi.fn(), + reconnectIfDisconnected: vi.fn(), + unwatch: vi.fn(), + watch: vi.fn(), + }, +})); + +vi.mock("@posthog/core/cloud-task/cloud-task-engine", () => ({ + createCloudTaskEngine: () => mocks.engine, +})); + +vi.mock("@posthog/core/cloud-task/schemas", () => ({ + CloudTaskEvent: { Update: "cloud-task-update" }, +})); + +vi.mock("expo/fetch", () => ({ fetch: vi.fn() })); + +vi.mock("@/lib/api", () => ({ + authedFetch: vi.fn(), + getBaseUrl: () => "https://app.posthog.test", + getProjectId: () => 42, +})); + +vi.mock("@/lib/logger", () => ({ + logger: { scope: vi.fn() }, +})); + +import { watchCloudTask } from "./cloudTaskStream"; + +describe("watchCloudTask", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("asks the shared engine to reconnect only when disconnected", () => { + const handle = watchCloudTask({ + taskId: "task-1", + runId: "run-1", + onUpdate: vi.fn(), + }); + + handle.reconnectIfDisconnected(); + + expect(mocks.engine.reconnectIfDisconnected).toHaveBeenCalledWith( + "task-1", + "run-1", + ); + }); +}); diff --git a/apps/mobile/src/features/tasks/lib/cloudTaskStream.ts b/apps/mobile/src/features/tasks/lib/cloudTaskStream.ts index 2541eed7e7..ac761b6bc9 100644 --- a/apps/mobile/src/features/tasks/lib/cloudTaskStream.ts +++ b/apps/mobile/src/features/tasks/lib/cloudTaskStream.ts @@ -1,120 +1,18 @@ -import { fetch } from "expo/fetch"; -import { createTimeoutSignal } from "@/lib/api"; -import { logger } from "@/lib/logger"; import { - fetchSessionLogs, - getTaskRun, - HttpError, - streamCloudTask, -} from "../api"; + type CloudTaskEngine, + type CloudTaskFetch, + createCloudTaskEngine, +} from "@posthog/core/cloud-task/cloud-task-engine"; +import { CloudTaskEvent } from "@posthog/core/cloud-task/schemas"; +import type { CloudTaskUpdatePayload } from "@posthog/shared"; +import { fetch } from "expo/fetch"; import { - type CloudTaskUpdatePayload, - isKeepaliveEvent, - isPermissionRequestEvent, - isSseErrorEvent, - isTaskRunStateEvent, - isTerminalStatus, - type StoredLogEntry, - type TaskRun, - type TaskRunStateEvent, - type TaskRunStatus, -} from "../types"; -import { parseSessionLogs } from "../utils/parseSessionLogs"; -import { type SseEvent, SseEventParser } from "./sseParser"; - -const log = logger.scope("cloud-task-stream"); - -const MAX_SSE_RECONNECT_ATTEMPTS = 5; -const SSE_RECONNECT_BASE_DELAY_MS = 2_000; -const SSE_RECONNECT_MAX_DELAY_MS = 30_000; -const EVENT_BATCH_FLUSH_MS = 16; -const EVENT_BATCH_MAX_SIZE = 50; -const SESSION_LOG_PAGE_LIMIT = 5_000; - -interface CloudTaskConnectionError { - title: string; - message: string; - retryable: boolean; - autoRetry?: boolean; -} - -class CloudTaskStreamError extends Error { - constructor( - message: string, - public readonly details: CloudTaskConnectionError, - public readonly status?: number, - ) { - super(message); - this.name = "CloudTaskStreamError"; - } -} - -function createStreamStatusError(status: number): CloudTaskStreamError { - switch (status) { - case 401: - return new CloudTaskStreamError( - "Cloud authentication expired", - { - title: "Cloud authentication expired", - message: "Please reauthenticate and retry the cloud run stream.", - retryable: true, - autoRetry: false, - }, - status, - ); - case 403: - return new CloudTaskStreamError( - "Cloud access denied", - { - title: "Cloud access denied", - message: - "You no longer have access to this cloud run. Reauthenticate and retry.", - retryable: true, - autoRetry: false, - }, - status, - ); - case 404: - return new CloudTaskStreamError( - "Cloud run not found", - { - title: "Cloud run not found", - message: - "This cloud run could not be found. It may have been deleted or moved.", - retryable: false, - autoRetry: false, - }, - status, - ); - case 406: - return new CloudTaskStreamError( - "Cloud stream unavailable", - { - title: "Cloud stream unavailable", - message: - "The backend rejected the live stream request. Restart the backend and retry.", - retryable: true, - autoRetry: false, - }, - status, - ); - default: - return new CloudTaskStreamError( - `Stream request failed with status ${status}`, - { - title: "Cloud stream failed", - message: `The cloud stream request failed with status ${status}. Retry to reconnect.`, - retryable: true, - autoRetry: true, - }, - status, - ); - } -} - -function shouldFailWatcherForFetchStatus(status: number): boolean { - return status === 401 || status === 403 || status === 404; -} + authedFetch, + type FetchInit, + getBaseUrl, + getProjectId, +} from "@/lib/api"; +import { logger } from "@/lib/logger"; export interface WatchCloudTaskOptions { taskId: string; @@ -127,786 +25,78 @@ export interface WatchCloudTaskHandle { reconnectIfDisconnected: () => void; } -interface WatcherState { - taskId: string; - runId: string; - onUpdate: (update: CloudTaskUpdatePayload) => void; - stopped: boolean; - sseAbortController: AbortController | null; - reconnectTimeoutId: ReturnType | null; - batchFlushTimeoutId: ReturnType | null; - pendingLogEntries: StoredLogEntry[]; - totalEntryCount: number; - reconnectAttempts: number; - lastEventId: string | null; - lastStatus: TaskRunStatus | null; - lastStage: string | null; - lastOutput: Record | null; - lastErrorMessage: string | null; - lastBranch: string | null; - lastStatusUpdatedAt: string | null; - isBootstrapping: boolean; - hasEmittedSnapshot: boolean; - bufferedLogBatches: StoredLogEntry[][]; - failed: boolean; - needsPostBootstrapReconnect: boolean; - needsStopAfterBootstrap: boolean; -} - -export function watchCloudTask( - options: WatchCloudTaskOptions, -): WatchCloudTaskHandle { - const watcher: WatcherState = { - taskId: options.taskId, - runId: options.runId, - onUpdate: options.onUpdate, - stopped: false, - sseAbortController: null, - reconnectTimeoutId: null, - batchFlushTimeoutId: null, - pendingLogEntries: [], - totalEntryCount: 0, - reconnectAttempts: 0, - lastEventId: null, - lastStatus: null, - lastStage: null, - lastOutput: null, - lastErrorMessage: null, - lastBranch: null, - lastStatusUpdatedAt: null, - isBootstrapping: false, - hasEmittedSnapshot: false, - bufferedLogBatches: [], - failed: false, - needsPostBootstrapReconnect: false, - needsStopAfterBootstrap: false, - }; - - void bootstrapWatcher(watcher); - - return { - stop: () => stopWatcher(watcher), - reconnectIfDisconnected: () => { - if ( - watcher.stopped || - watcher.failed || - isTerminalStatus(watcher.lastStatus) - ) { - return; - } - if (watcher.sseAbortController || watcher.reconnectTimeoutId) { - return; - } - log.debug("Force reconnect after suspension", { runId: watcher.runId }); - watcher.reconnectAttempts = 0; - void connectSse(watcher, { - startLatest: !watcher.lastEventId, - }); +const mobileCloudTaskAnalytics = { + initialize: () => {}, + track: () => {}, + identify: () => {}, + setCurrentUserId: () => {}, + getCurrentUserId: () => null, + getOrCreateSessionId: () => "mobile-cloud-task", + resetUser: () => {}, + captureException: () => {}, + flush: async () => {}, + shutdown: async () => {}, +}; + +let cloudTaskEngine: CloudTaskEngine | null = null; + +function getCloudTaskEngine(): CloudTaskEngine { + if (cloudTaskEngine) { + return cloudTaskEngine; + } + + cloudTaskEngine = createCloudTaskEngine({ + auth: { + authenticatedFetch: (url, init) => + authedFetch(url, init as FetchInit | undefined), + getCloudContext: async () => ({ + apiHost: getBaseUrl(), + teamId: getProjectId(), + }), }, - }; -} - -function stopWatcher(watcher: WatcherState): void { - if (watcher.stopped) return; - watcher.stopped = true; - - watcher.sseAbortController?.abort(); - watcher.sseAbortController = null; - - if (watcher.reconnectTimeoutId) { - clearTimeout(watcher.reconnectTimeoutId); - watcher.reconnectTimeoutId = null; - } - - if (watcher.batchFlushTimeoutId) { - clearTimeout(watcher.batchFlushTimeoutId); - watcher.batchFlushTimeoutId = null; - } - - // Drop any unflushed batches; the consumer is gone. - watcher.pendingLogEntries = []; - watcher.bufferedLogBatches = []; -} - -async function bootstrapWatcher(watcher: WatcherState): Promise { - if (watcher.stopped) return; - - watcher.failed = false; - watcher.needsPostBootstrapReconnect = false; - watcher.needsStopAfterBootstrap = false; - - const run = await fetchTaskRunState(watcher); - if (watcher.stopped || watcher.failed) return; - - if (!run) { - failWatcher(watcher, { - title: "Failed to load cloud run", - message: "Could not fetch the cloud run state. Retry to reconnect.", - retryable: true, - }); - return; - } - - applyTaskRunState(watcher, run); - - if (isTerminalStatus(run.status)) { - const historicalEntries = await fetchHistoricalEntries(watcher, run); - if (watcher.stopped || watcher.failed) return; - if (!historicalEntries) { - failWatcher(watcher, { - title: "Failed to load task history", - message: - "Could not load the persisted cloud task logs. Retry to reconnect.", - retryable: true, - }); - return; - } - - watcher.totalEntryCount = historicalEntries.length; - watcher.hasEmittedSnapshot = true; - emitSnapshot(watcher, historicalEntries); - stopWatcher(watcher); - return; - } - - watcher.isBootstrapping = true; - watcher.bufferedLogBatches = []; - void connectSse(watcher, { startLatest: true }); - - const historicalEntries = await fetchHistoricalEntries(watcher, run); - if (watcher.stopped || watcher.failed) return; - if (!historicalEntries) { - failWatcher(watcher, { - title: "Failed to load cloud run history", - message: - "Could not load the existing cloud run logs. Retry to reconnect.", - retryable: true, - }); - return; - } - - // Flush any pending live entries into the bootstrap buffer before snapshot. - flushLogBatch(watcher); - - watcher.totalEntryCount = historicalEntries.length; - watcher.hasEmittedSnapshot = true; - emitSnapshot(watcher, historicalEntries); - - watcher.isBootstrapping = false; - drainBufferedLogBatches(watcher, historicalEntries); - - if (watcher.failed) return; - - if (watcher.needsStopAfterBootstrap || isTerminalStatus(watcher.lastStatus)) { - watcher.needsStopAfterBootstrap = false; - stopWatcher(watcher); - return; - } - - if (watcher.needsPostBootstrapReconnect) { - watcher.needsPostBootstrapReconnect = false; - scheduleReconnect(watcher, undefined, { countAttempt: false }); - } - - void verifyPostBootstrapStatus(watcher); -} - -async function verifyPostBootstrapStatus(watcher: WatcherState): Promise { - if (watcher.stopped) return; - if (isTerminalStatus(watcher.lastStatus)) return; - - const run = await fetchTaskRunState(watcher); - if (watcher.stopped || !run) return; - - if (!applyTaskRunState(watcher, run)) return; - if (isTerminalStatus(watcher.lastStatus)) return; - - emitStatus(watcher); -} - -async function connectSse( - watcher: WatcherState, - options?: { startLatest?: boolean }, -): Promise { - if (watcher.stopped) return; - - const controller = new AbortController(); - watcher.sseAbortController = controller; - - const parser = new SseEventParser(); - const decoder = new TextDecoder(); - - try { - const response = await streamCloudTask(watcher.taskId, watcher.runId, { - lastEventId: watcher.lastEventId, - startLatest: options?.startLatest, - signal: controller.signal, - }); - - if (!response.ok) { - throw createStreamStatusError(response.status); - } - - if (!response.body) { - throw new Error("Stream response did not include a body"); - } - - const reader = response.body.getReader(); - - while (true) { - const { done, value } = await reader.read(); - if (done) { - break; - } - - if (!value) { - continue; - } - - const chunk = decoder.decode(value, { stream: true }); - const events = parser.parse(chunk); - for (const event of events) { - handleSseEvent(watcher, event); - if (watcher.failed) return; - } - } - - const trailingEvents = parser.parse(decoder.decode()); - for (const event of trailingEvents) { - handleSseEvent(watcher, event); - if (watcher.failed) return; - } - - flushLogBatch(watcher); - - if (controller.signal.aborted) { - return; - } - - await handleStreamCompletion(watcher, { reconnectIfNonTerminal: true }); - } catch (error) { - flushLogBatch(watcher); - - if (controller.signal.aborted) { - return; - } - - if ( - error instanceof CloudTaskStreamError && - error.details.autoRetry === false - ) { - failWatcher(watcher, error.details); - return; - } - - const errorMessage = - error instanceof Error ? error.message : "Unknown stream error"; - log.warn("Cloud task stream error", { - runId: watcher.runId, - error: errorMessage, - }); - await handleStreamCompletion(watcher, { - reconnectIfNonTerminal: true, - reconnectError: error, - countReconnectAttempt: true, - }); - } finally { - if (watcher.sseAbortController === controller) { - watcher.sseAbortController = null; - } - } -} - -function handleSseEvent(watcher: WatcherState, event: SseEvent): void { - if (watcher.failed || watcher.stopped) return; - - if (event.id) { - watcher.lastEventId = event.id; - } - - if (event.event === "error") { - const message = isSseErrorEvent(event.data) - ? event.data.error - : "Unknown stream error"; - throw new Error(message); - } - - if (event.event === "keepalive" || isKeepaliveEvent(event.data)) { - return; - } - - watcher.reconnectAttempts = 0; - - if (isTaskRunStateEvent(event.data)) { - if (applyTaskRunState(watcher, event.data)) { - if (!watcher.isBootstrapping && !isTerminalStatus(watcher.lastStatus)) { - emitStatus(watcher); - } - } - return; - } - - if (isPermissionRequestEvent(event.data)) { - watcher.onUpdate({ - taskId: watcher.taskId, - runId: watcher.runId, - kind: "permission_request", - requestId: event.data.requestId, - toolCall: event.data.toolCall, - options: event.data.options, - }); - return; - } - - // StoredLogEntry always has a string `type`. Anything else is a server - // event the mobile client doesn't understand yet — drop it instead of - // forwarding a malformed entry to convertStoredEntriesToEvents. - if ( - typeof event.data !== "object" || - event.data === null || - typeof (event.data as { type?: unknown }).type !== "string" - ) { - log.warn("Skipping unrecognized SSE event", { - runId: watcher.runId, - eventName: event.event, - }); - return; - } - - watcher.pendingLogEntries.push(event.data as StoredLogEntry); - if (watcher.pendingLogEntries.length >= EVENT_BATCH_MAX_SIZE) { - flushLogBatch(watcher); - return; - } - - if (!watcher.batchFlushTimeoutId) { - watcher.batchFlushTimeoutId = setTimeout(() => { - watcher.batchFlushTimeoutId = null; - flushLogBatch(watcher); - }, EVENT_BATCH_FLUSH_MS); - } -} - -function flushLogBatch(watcher: WatcherState): void { - if (watcher.pendingLogEntries.length === 0) return; - - if (watcher.batchFlushTimeoutId) { - clearTimeout(watcher.batchFlushTimeoutId); - watcher.batchFlushTimeoutId = null; - } - - const entries = watcher.pendingLogEntries; - watcher.pendingLogEntries = []; - - if (watcher.isBootstrapping) { - watcher.bufferedLogBatches.push(entries); - return; - } - - watcher.totalEntryCount += entries.length; - watcher.onUpdate({ - taskId: watcher.taskId, - runId: watcher.runId, - kind: "logs", - newEntries: entries, - totalEntryCount: watcher.totalEntryCount, + analytics: mobileCloudTaskAnalytics, + logger, + streamFetch: fetch as CloudTaskFetch, }); -} - -function drainBufferedLogBatches( - watcher: WatcherState, - historicalEntries: StoredLogEntry[], -): void { - if (watcher.bufferedLogBatches.length === 0) return; - - // Content-based dedup because SSE IDs (Redis stream IDs) don't exist in - // the S3-backed historical entries — the JSON payload is the only shared key. - const historicalCounts = new Map(); - for (const entry of historicalEntries) { - const serialized = JSON.stringify(entry); - historicalCounts.set( - serialized, - (historicalCounts.get(serialized) ?? 0) + 1, - ); - } - - for (const entries of watcher.bufferedLogBatches) { - const dedupedEntries = entries.filter((entry) => { - const serialized = JSON.stringify(entry); - const remaining = historicalCounts.get(serialized) ?? 0; - if (remaining <= 0) return true; - historicalCounts.set(serialized, remaining - 1); - return false; - }); - if (dedupedEntries.length === 0) continue; - - watcher.totalEntryCount += dedupedEntries.length; - watcher.onUpdate({ - taskId: watcher.taskId, - runId: watcher.runId, - kind: "logs", - newEntries: dedupedEntries, - totalEntryCount: watcher.totalEntryCount, - }); - } - - watcher.bufferedLogBatches = []; -} - -function emitSnapshot(watcher: WatcherState, entries: StoredLogEntry[]): void { - watcher.onUpdate({ - taskId: watcher.taskId, - runId: watcher.runId, - kind: "snapshot", - newEntries: entries, - totalEntryCount: watcher.totalEntryCount, - status: watcher.lastStatus ?? undefined, - stage: watcher.lastStage, - output: watcher.lastOutput, - errorMessage: watcher.lastErrorMessage, - branch: watcher.lastBranch, - }); -} - -function emitStatus(watcher: WatcherState): void { - watcher.onUpdate({ - taskId: watcher.taskId, - runId: watcher.runId, - kind: "status", - status: watcher.lastStatus ?? undefined, - stage: watcher.lastStage, - output: watcher.lastOutput, - errorMessage: watcher.lastErrorMessage, - branch: watcher.lastBranch, - }); -} - -function failWatcher( - watcher: WatcherState, - error: CloudTaskConnectionError, -): void { - if (watcher.stopped) return; - - watcher.failed = true; - watcher.isBootstrapping = false; - watcher.pendingLogEntries = []; - watcher.bufferedLogBatches = []; - - if (watcher.reconnectTimeoutId) { - clearTimeout(watcher.reconnectTimeoutId); - watcher.reconnectTimeoutId = null; - } - - if (watcher.batchFlushTimeoutId) { - clearTimeout(watcher.batchFlushTimeoutId); - watcher.batchFlushTimeoutId = null; - } - - watcher.sseAbortController?.abort(); - watcher.sseAbortController = null; - - watcher.onUpdate({ - taskId: watcher.taskId, - runId: watcher.runId, - kind: "error", - errorTitle: error.title, - errorMessage: error.message, - retryable: error.retryable, - }); + return cloudTaskEngine; } -function scheduleReconnect( - watcher: WatcherState, - error?: unknown, - options: { countAttempt?: boolean } = {}, -): void { - if ( - watcher.stopped || - watcher.failed || - isTerminalStatus(watcher.lastStatus) - ) { - return; - } - - if (watcher.reconnectTimeoutId) { - clearTimeout(watcher.reconnectTimeoutId); - } - - const countAttempt = options.countAttempt ?? true; - if (countAttempt) { - watcher.reconnectAttempts += 1; - } else { - watcher.reconnectAttempts = 0; - } - - if (watcher.reconnectAttempts > MAX_SSE_RECONNECT_ATTEMPTS) { - const details = - error instanceof CloudTaskStreamError - ? error.details - : { - title: "Cloud stream disconnected", - message: - "Lost connection to the cloud run stream. Retry to reconnect.", - retryable: true, - }; - failWatcher(watcher, details); - return; - } - - const delay = Math.min( - SSE_RECONNECT_BASE_DELAY_MS * - 2 ** Math.max(watcher.reconnectAttempts - 1, 0), - SSE_RECONNECT_MAX_DELAY_MS, - ); - - watcher.reconnectTimeoutId = setTimeout(() => { - if (watcher.stopped) return; - watcher.reconnectTimeoutId = null; - void connectSse(watcher, { - startLatest: watcher.isBootstrapping || watcher.hasEmittedSnapshot, - }); - }, delay); -} - -async function handleStreamCompletion( - watcher: WatcherState, - options: { - reconnectIfNonTerminal: boolean; - reconnectError?: unknown; - countReconnectAttempt?: boolean; - }, -): Promise { - if (watcher.stopped) return; - - const { reconnectIfNonTerminal } = options; - const run = await fetchTaskRunState(watcher); - if (watcher.stopped || watcher.failed) return; - - if (watcher.isBootstrapping) { - if (!run) { - watcher.needsPostBootstrapReconnect = true; - return; - } - - applyTaskRunState(watcher, run); - if (isTerminalStatus(watcher.lastStatus) || !reconnectIfNonTerminal) { - watcher.needsStopAfterBootstrap = true; - } else { - watcher.needsPostBootstrapReconnect = true; - } - return; - } - - if (!run) { - scheduleReconnect( - watcher, - new CloudTaskStreamError("Failed to fetch terminal cloud run state", { - title: "Cloud run state unavailable", - message: - "Could not fetch the latest cloud run state after the stream ended. Retry to reconnect.", - retryable: true, - }), - ); - return; - } - - const stateChanged = applyTaskRunState(watcher, run); - - if (!isTerminalStatus(watcher.lastStatus) && reconnectIfNonTerminal) { - if (stateChanged) { - emitStatus(watcher); - } - log.warn("Cloud task stream ended before terminal status", { - runId: watcher.runId, - status: watcher.lastStatus, - }); - scheduleReconnect(watcher, options.reconnectError, { - countAttempt: options.countReconnectAttempt ?? false, - }); - return; - } - - emitStatus(watcher); - stopWatcher(watcher); -} - -function applyTaskRunState( - watcher: WatcherState, - run: - | Pick< - TaskRun, - | "status" - | "stage" - | "output" - | "error_message" - | "branch" - | "updated_at" - > - | TaskRunStateEvent, -): boolean { - const updatedAt = run.updated_at ?? null; - if ( - updatedAt && - watcher.lastStatusUpdatedAt && - Date.parse(updatedAt) <= Date.parse(watcher.lastStatusUpdatedAt) - ) { - return false; - } - - const nextStatus = run.status ?? watcher.lastStatus; - const nextStage = run.stage ?? null; - const nextOutput = run.output ?? null; - const nextErrorMessage = run.error_message ?? null; - const nextBranch = run.branch ?? null; - - const changed = - nextStatus !== watcher.lastStatus || - nextStage !== watcher.lastStage || - JSON.stringify(nextOutput) !== JSON.stringify(watcher.lastOutput) || - nextErrorMessage !== watcher.lastErrorMessage || - nextBranch !== watcher.lastBranch; - - watcher.lastStatus = nextStatus ?? null; - watcher.lastStage = nextStage; - watcher.lastOutput = nextOutput; - watcher.lastErrorMessage = nextErrorMessage; - watcher.lastBranch = nextBranch; - if (updatedAt) { - watcher.lastStatusUpdatedAt = updatedAt; - } - - return changed; -} - -async function fetchTaskRunState( - watcher: WatcherState, -): Promise { - try { - return await getTaskRun(watcher.taskId, watcher.runId); - } catch (error) { - if (error instanceof HttpError) { - log.warn("Cloud task status fetch failed", { - runId: watcher.runId, - status: error.status, - }); - if (shouldFailWatcherForFetchStatus(error.status)) { - failWatcher(watcher, createStreamStatusError(error.status).details); - } - return null; - } - log.warn("Cloud task status fetch error", { - runId: watcher.runId, - error, - }); - return null; - } -} - -/** - * Loads the historical log entries for the run, mirroring the desktop's - * dual-source strategy: - * 1. Try the paginated `session_logs/` API — the live source while a run - * is active. For older / archived runs this can come back empty even - * though the canonical log exists on S3. - * 2. Fall back to the run's presigned `log_url` (S3 NDJSON), which is the - * canonical archive for completed runs. - * - * Returns `null` only when both sources fail outright (so the bootstrap can - * surface a retryable error). An empty paginated result is treated as "no - * data yet" and falls through to S3 — if S3 also has nothing we return the - * empty array so the snapshot can still flip the session to `"connected"`. - */ -async function fetchHistoricalEntries( - watcher: WatcherState, - run: TaskRun, -): Promise { - const paginated = await fetchAllSessionLogs(watcher); - if (watcher.stopped || watcher.failed) return null; - if (paginated && paginated.length > 0) return paginated; - - if (run.log_url) { - const s3Entries = await fetchS3LogEntries(watcher, run.log_url); - if (watcher.stopped || watcher.failed) return null; - if (s3Entries && s3Entries.length > 0) return s3Entries; - } - - // Both sources returned no rows. Prefer the paginated result (which is - // `[]` rather than `null`) so the caller can still emit an empty snapshot - // and the session flips to `"connected"` instead of hanging on loading. - return paginated ?? null; -} - -async function fetchS3LogEntries( - watcher: WatcherState, - logUrl: string, -): Promise { - try { - const response = await fetch(logUrl, { - signal: createTimeoutSignal(15_000), - }); - if (response.status === 404) { - // No archived log yet for this run — not an error, just no data. - return []; - } - if (!response.ok) { - log.warn("S3 session log fetch returned non-OK", { - runId: watcher.runId, - status: response.status, - }); - return null; +export function watchCloudTask({ + taskId, + runId, + onUpdate, +}: WatchCloudTaskOptions): WatchCloudTaskHandle { + const engine = getCloudTaskEngine(); + const listener = (update: CloudTaskUpdatePayload): void => { + if (update.taskId === taskId && update.runId === runId) { + onUpdate(update); } - const content = await response.text(); - if (!content.trim()) return []; - return parseSessionLogs(content).rawEntries; - } catch (error) { - log.warn("S3 session log fetch failed", { - runId: watcher.runId, - error, - }); - return null; - } -} + }; -async function fetchAllSessionLogs( - watcher: WatcherState, -): Promise { - const entries: StoredLogEntry[] = []; - let offset = 0; + engine.on(CloudTaskEvent.Update, listener); + engine.watch({ + taskId, + runId, + apiHost: getBaseUrl(), + teamId: getProjectId(), + }); - while (true) { - if (watcher.stopped || watcher.failed) return null; - try { - const page = await fetchSessionLogs(watcher.taskId, watcher.runId, { - limit: SESSION_LOG_PAGE_LIMIT, - offset, - }); + let stopped = false; - for (const entry of page.entries) { - entries.push(entry); - } - if (!page.hasMore || page.entries.length === 0) { - return entries; + return { + stop: () => { + if (stopped) { + return; } - offset += page.entries.length; - } catch (error) { - if (error instanceof HttpError) { - log.warn("Cloud task session logs fetch failed", { - runId: watcher.runId, - status: error.status, - offset, - }); - if (shouldFailWatcherForFetchStatus(error.status)) { - failWatcher(watcher, createStreamStatusError(error.status).details); - } - return null; + stopped = true; + engine.off(CloudTaskEvent.Update, listener); + engine.unwatch(taskId, runId); + }, + reconnectIfDisconnected: () => { + if (!stopped) { + engine.reconnectIfDisconnected(taskId, runId); } - log.warn("Cloud task session logs fetch error", { - runId: watcher.runId, - offset, - error, - }); - return null; - } - } + }, + }; } diff --git a/apps/mobile/src/features/tasks/lib/sseParser.ts b/apps/mobile/src/features/tasks/lib/sseParser.ts deleted file mode 100644 index 4c626fd657..0000000000 --- a/apps/mobile/src/features/tasks/lib/sseParser.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { logger } from "@/lib/logger"; - -const log = logger.scope("sse-parser"); - -export interface SseEvent { - event?: string; - id?: string; - data: unknown; -} - -export class SseEventParser { - private buffer = ""; - private currentEventName: string | null = null; - private currentEventId: string | null = null; - private currentData: string[] = []; - - parse(chunk: string): SseEvent[] { - this.buffer += chunk; - const lines = this.buffer.split("\n"); - this.buffer = lines.pop() || ""; - - const events: SseEvent[] = []; - - for (const rawLine of lines) { - const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; - - if (line === "") { - const event = this.flushEvent(); - if (event) { - events.push(event); - } - continue; - } - - if (line.startsWith(":")) { - continue; - } - - if (line.startsWith("event:")) { - this.currentEventName = line.slice(6).trim() || null; - continue; - } - - if (line.startsWith("id:")) { - this.currentEventId = line.slice(3).trim() || null; - continue; - } - - if (line.startsWith("data:")) { - this.currentData.push(line.slice(5).trimStart()); - } - } - - return events; - } - - reset(): void { - this.buffer = ""; - this.currentEventName = null; - this.currentEventId = null; - this.currentData = []; - } - - private flushEvent(): SseEvent | null { - if (this.currentData.length === 0) { - this.currentEventName = null; - this.currentEventId = null; - return null; - } - - const rawData = this.currentData.join("\n"); - this.currentData = []; - - try { - const data = JSON.parse(rawData); - return { - event: this.currentEventName ?? undefined, - id: this.currentEventId ?? undefined, - data, - }; - } catch { - log.warn("SSE event JSON parse failure", { rawData }); - return null; - } finally { - this.currentEventName = null; - this.currentEventId = null; - } - } -} diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts index b0b49e1ef1..864ca6271e 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts @@ -1,5 +1,13 @@ +import type { + CloudTaskUpdatePayload, + StoredLogEntry, + Task, + TaskRun, +} from "@posthog/shared"; import { beforeEach, describe, expect, it, vi } from "vitest"; +const { mockGetTask } = vi.hoisted(() => ({ mockGetTask: vi.fn() })); + vi.mock("expo-haptics", () => ({ impactAsync: vi.fn(), notificationAsync: vi.fn(), @@ -18,19 +26,16 @@ vi.mock("@/features/notifications/lib/notifications", () => ({ })); vi.mock("../api", () => ({ CloudCommandError: class CloudCommandError extends Error {}, - getTask: vi.fn(), runTaskInCloud: vi.fn(), sendCloudCommand: vi.fn(), })); +vi.mock("@/lib/posthogApiClient", () => ({ + getPostHogApiClient: () => ({ getTask: mockGetTask }), +})); + import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; -import { getTask, runTaskInCloud } from "../api"; -import type { - CloudTaskUpdatePayload, - StoredLogEntry, - Task, - TaskRun, -} from "../types"; +import { runTaskInCloud } from "../api"; import { useMessageQueueStore } from "./messageQueueStore"; import { mapTerminalStatus, @@ -234,7 +239,6 @@ describe("flushQueuedMessagesIfIdle", () => { }); describe("_resumeCloudRun", () => { - const mockGetTask = vi.mocked(getTask); const mockRunTaskInCloud = vi.mocked(runTaskInCloud); function previousTask(latestRun: Partial): Task { diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts index e71f284ab5..a47638c679 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts @@ -1,13 +1,19 @@ +import { + type CloudTaskUpdatePayload, + isTerminalStatus, + type StoredLogEntry, + type Task, +} from "@posthog/shared"; import * as Haptics from "expo-haptics"; import { AppState } from "react-native"; import { create } from "zustand"; import { presentLocalNotification } from "@/features/notifications/lib/notifications"; import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; import { logger } from "@/lib/logger"; +import { getPostHogApiClient } from "@/lib/posthogApiClient"; import { CloudCommandError, cancelRun, - getTask, runTaskInCloud, sendCloudCommand, } from "../api"; @@ -18,16 +24,12 @@ import { type WatchCloudTaskHandle, watchCloudTask, } from "../lib/cloudTaskStream"; -import { - type CloudPendingPermissionRequest, - type CloudTaskUpdatePayload, - isTerminalStatus, - type SessionEvent, - type SessionNotification, - type SessionNotificationAttachment, - type StoredLogEntry, - type Task, - type TerminalStatus, +import type { + CloudPendingPermissionRequest, + SessionEvent, + SessionNotification, + SessionNotificationAttachment, + TerminalStatus, } from "../types"; import { convertStoredEntriesToEvents } from "../utils/parseSessionLogs"; import { playbackRateForTaskDuration } from "../utils/playbackRate"; @@ -1178,7 +1180,7 @@ export const useTaskSessionStore = create((set, get) => ({ previousRunId: string, prompt: string, ) => { - const freshTask = await getTask(taskId); + const freshTask = await getPostHogApiClient().getTask(taskId); const previousRun = freshTask.latest_run; const previousBranch = previousRun?.branch ?? null; diff --git a/apps/mobile/src/features/tasks/stores/taskStore.test.ts b/apps/mobile/src/features/tasks/stores/taskStore.test.ts deleted file mode 100644 index 19f990c105..0000000000 --- a/apps/mobile/src/features/tasks/stores/taskStore.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { Task } from "../types"; -import { filterAndSortTasks } from "./taskStore"; - -function makeTask(overrides: Partial): Task { - return { - id: "task-1", - task_number: 1, - slug: "task-1", - title: "A real task", - description: "Do the thing", - created_at: "2026-01-01T00:00:00Z", - updated_at: "2026-01-01T00:00:00Z", - origin_product: "tasks", - ...overrides, - }; -} - -describe("filterAndSortTasks", () => { - it.each([ - { name: "empty title and description", title: "", description: "" }, - { name: "whitespace-only fields", title: " ", description: "\n\t" }, - ])( - "hides warm-sandbox placeholder tasks ($name)", - ({ title, description }) => { - const placeholder = makeTask({ id: "warm", title, description }); - const real = makeTask({ id: "real" }); - - const result = filterAndSortTasks( - [placeholder, real], - "updated", - false, - "", - ); - - expect(result.map((t) => t.id)).toEqual(["real"]); - }, - ); - - it.each([ - { - name: "description only (title not landed yet)", - title: "", - description: "Fix login", - }, - { name: "title only", title: "Fix login", description: "" }, - ])("keeps a real task with $name", ({ title, description }) => { - const task = makeTask({ id: "real", title, description }); - - const result = filterAndSortTasks([task], "updated", false, ""); - - expect(result.map((t) => t.id)).toEqual(["real"]); - }); -}); diff --git a/apps/mobile/src/features/tasks/stores/taskStore.ts b/apps/mobile/src/features/tasks/stores/taskStore.ts index 6c8daa14f2..456eae37a1 100644 --- a/apps/mobile/src/features/tasks/stores/taskStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskStore.ts @@ -1,9 +1,8 @@ -import { isContentlessTask } from "@posthog/shared/domain-types"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import type { ExecutionMode, ReasoningEffort } from "../composer/options"; -import type { RepositorySelection, Task } from "../types"; +import type { RepositorySelection } from "../types"; export type OrganizeMode = "by-project" | "chronological"; export type SortMode = "created" | "updated"; @@ -106,48 +105,3 @@ export const useTaskStore = create()( }, ), ); - -export function taskActivityTimestamp(task: Task, sortMode: SortMode): number { - if (sortMode === "created") { - return new Date(task.created_at).getTime(); - } - // "updated" — take the most recent of task.updated_at and latest_run.updated_at. - const runUpdated = task.latest_run?.updated_at; - const taskUpdated = task.updated_at ?? task.created_at; - return Math.max( - runUpdated ? new Date(runUpdated).getTime() : 0, - new Date(taskUpdated).getTime(), - ); -} - -export function filterAndSortTasks( - tasks: Task[], - sortMode: SortMode, - showInternal: boolean, - filter: string, -): Task[] { - let filtered = tasks; - - // Warm-sandbox prewarming creates empty placeholder tasks; never surface them. - filtered = filtered.filter((task) => !isContentlessTask(task)); - - // Visibility filter — mirrors desktop radio: External hides internal, Internal shows only internal. - filtered = filtered.filter((task) => - showInternal ? task.internal === true : task.internal !== true, - ); - - if (filter) { - const lowerFilter = filter.toLowerCase(); - filtered = filtered.filter( - (task) => - task.title.toLowerCase().includes(lowerFilter) || - task.slug.toLowerCase().includes(lowerFilter) || - task.description?.toLowerCase().includes(lowerFilter), - ); - } - - return [...filtered].sort( - (a, b) => - taskActivityTimestamp(b, sortMode) - taskActivityTimestamp(a, sortMode), - ); -} diff --git a/apps/mobile/src/features/tasks/types.ts b/apps/mobile/src/features/tasks/types.ts index e777246331..29a2754d7f 100644 --- a/apps/mobile/src/features/tasks/types.ts +++ b/apps/mobile/src/features/tasks/types.ts @@ -1,115 +1,19 @@ -export interface Task { - id: string; - task_number: number | null; - slug: string; - title: string; - description: string; - 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; - latest_run?: TaskRun; -} - -export interface TaskAutomation { - id: string; - name: string; - prompt: string; - repository: string; - github_integration?: number | null; - cron_expression: string; - timezone?: string | null; - template_id?: string | null; - enabled: boolean; - last_run_at: string | null; - last_run_status: string | null; - last_task_id: string | null; - last_task_run_id: string | null; - last_error: string | null; - created_at: string; - updated_at: string; -} - -export type TaskRunStatus = - | "not_started" - | "queued" - | "started" - | "in_progress" - | "completed" - | "failed" - | "cancelled"; - -export const TERMINAL_STATUSES = ["completed", "failed", "cancelled"] as const; - -// UI-facing terminal outcome for a run. `cancelled` maps to `stopped` (the user -// deliberately halted it) so the UI can distinguish it from a real failure. -export type TerminalStatus = "completed" | "failed" | "stopped"; - -export function isTerminalStatus( - status: TaskRunStatus | string | null | undefined, -): boolean { - return ( - status !== null && - status !== undefined && - TERMINAL_STATUSES.includes(status as (typeof TERMINAL_STATUSES)[number]) - ); -} - -export interface TaskRunArtifact { - id?: string; - storage_path?: string; -} - -export interface TaskRun { - id: string; - task: string; - team: number; - branch: string | null; - stage?: string | null; - environment?: "local" | "cloud"; - status: TaskRunStatus; - log_url: string; - error_message: string | null; - reasoning_effort?: string | null; - output: Record | null; - state: Record; - artifacts?: TaskRunArtifact[]; - created_at: string; - updated_at: string; - completed_at: string | null; -} - -export interface StoredLogEntry { - type: string; - timestamp?: string; - notification?: { - id?: number; - method?: string; - params?: unknown; - result?: unknown; - error?: unknown; - }; +import type { + CloudPermissionOption, + CloudTaskPermissionRequestUpdate, + StoredLogEntry as SharedStoredLogEntry, + TaskRunStatus, +} from "@posthog/shared"; + +export interface MobileStoredLogEntry extends SharedStoredLogEntry { direction?: "client" | "agent"; } -export interface CloudArtifactRef { - runId: string; - artifactId: string; -} - export interface SessionNotificationAttachment { kind: "image" | "document"; uri: string; fileName: string; mimeType?: string; - // Set when the attachment was resolved from a cloud `session/prompt` entry. - // Its bytes live in S3 as a run artifact; the preview is fetched by presigning - // rather than read off the local device. - cloudArtifact?: CloudArtifactRef; } export interface SessionNotification { @@ -157,22 +61,6 @@ export interface SessionUpdateEvent { export type SessionEvent = AcpMessage | SessionUpdateEvent; -export interface CloudPermissionOption { - kind: string; - optionId: string; - name: string; - _meta?: Record; -} - -export interface CloudPermissionToolCall { - toolCallId: string; - title: string; - kind: string; - content?: unknown[]; - rawInput?: Record; - _meta?: Record; -} - export interface CloudPermissionResponseSelection { optionId: string; displayText: string; @@ -182,63 +70,11 @@ export interface CloudPermissionResponseSelection { export interface CloudPendingPermissionRequest { requestId: string; - toolCall: CloudPermissionToolCall; + toolCall: CloudTaskPermissionRequestUpdate["toolCall"]; options: CloudPermissionOption[]; response?: CloudPermissionResponseSelection; } -interface CloudTaskUpdateBase { - taskId: string; - runId: string; -} - -export interface CloudTaskLogsUpdate extends CloudTaskUpdateBase { - kind: "logs"; - newEntries: StoredLogEntry[]; - totalEntryCount: number; -} - -export interface CloudTaskStatusUpdate extends CloudTaskUpdateBase { - kind: "status"; - status?: TaskRunStatus; - stage?: string | null; - output?: Record | null; - errorMessage?: string | null; - branch?: string | null; -} - -export interface CloudTaskSnapshotUpdate extends CloudTaskUpdateBase { - kind: "snapshot"; - newEntries: StoredLogEntry[]; - totalEntryCount: number; - status?: TaskRunStatus; - stage?: string | null; - output?: Record | null; - errorMessage?: string | null; - branch?: string | null; -} - -export interface CloudTaskErrorUpdate extends CloudTaskUpdateBase { - kind: "error"; - errorTitle: string; - errorMessage: string; - retryable: boolean; -} - -export interface CloudTaskPermissionRequestUpdate extends CloudTaskUpdateBase { - kind: "permission_request"; - requestId: string; - toolCall: CloudPermissionToolCall; - options: CloudPermissionOption[]; -} - -export type CloudTaskUpdatePayload = - | CloudTaskLogsUpdate - | CloudTaskStatusUpdate - | CloudTaskSnapshotUpdate - | CloudTaskErrorUpdate - | CloudTaskPermissionRequestUpdate; - export interface TaskRunStateEvent { type: "task_run_state"; status?: TaskRunStatus; @@ -253,7 +89,7 @@ export interface TaskRunStateEvent { export interface PermissionRequestEventData { type: "permission_request"; requestId: string; - toolCall: CloudPermissionToolCall; + toolCall: CloudTaskPermissionRequestUpdate["toolCall"]; options: CloudPermissionOption[]; } @@ -344,25 +180,3 @@ export interface CreateTaskOptions { * cloud runs. Preferred over `github_integration` for interactive tasks. */ github_user_integration?: string; } - -export interface CreateTaskAutomationOptions { - name: string; - prompt: string; - repository: string; - github_integration?: number | null; - cron_expression: string; - timezone: string; - enabled?: boolean; - template_id?: string | null; -} - -export interface UpdateTaskAutomationOptions { - name?: string; - prompt?: string; - repository?: string; - github_integration?: number | null; - cron_expression?: string; - timezone?: string; - enabled?: boolean; - template_id?: string | null; -} diff --git a/apps/mobile/src/features/tasks/utils/archiveGuard.test.ts b/apps/mobile/src/features/tasks/utils/archiveGuard.test.ts index 6ad491c61f..730e0d98d6 100644 --- a/apps/mobile/src/features/tasks/utils/archiveGuard.test.ts +++ b/apps/mobile/src/features/tasks/utils/archiveGuard.test.ts @@ -1,58 +1,6 @@ import { Alert } from "react-native"; import { describe, expect, it, vi } from "vitest"; -import type { Task, TaskRunStatus } from "../types"; -import { confirmArchiveRunningTask, isTaskRunning } from "./archiveGuard"; - -function makeTask(status?: TaskRunStatus): Task { - return { - id: "task-1", - task_number: 1, - slug: "task-1", - title: "Test task", - description: "", - created_at: "2026-01-01T00:00:00Z", - updated_at: "2026-01-01T00:00:00Z", - origin_product: "code", - latest_run: status - ? { - id: "run-1", - task: "task-1", - team: 1, - branch: null, - stage: null, - environment: "cloud", - status, - log_url: "", - error_message: null, - output: null, - state: {}, - created_at: "2026-01-01T00:00:00Z", - updated_at: "2026-01-01T00:00:00Z", - completed_at: null, - } - : undefined, - }; -} - -describe("isTaskRunning", () => { - it("treats a task with no run as not running", () => { - expect(isTaskRunning(makeTask())).toBe(false); - }); - - it.each(["not_started", "queued", "started", "in_progress"] as const)( - "treats %s as running", - (status) => { - expect(isTaskRunning(makeTask(status))).toBe(true); - }, - ); - - it.each(["completed", "failed", "cancelled"] as const)( - "treats %s as not running", - (status) => { - expect(isTaskRunning(makeTask(status))).toBe(false); - }, - ); -}); +import { confirmArchiveRunningTask } from "./archiveGuard"; describe("confirmArchiveRunningTask", () => { it("archives only when the user confirms", () => { diff --git a/apps/mobile/src/features/tasks/utils/archiveGuard.ts b/apps/mobile/src/features/tasks/utils/archiveGuard.ts index 377b13dd47..d5e8cd9687 100644 --- a/apps/mobile/src/features/tasks/utils/archiveGuard.ts +++ b/apps/mobile/src/features/tasks/utils/archiveGuard.ts @@ -1,10 +1,4 @@ import { Alert } from "react-native"; -import { isTerminalStatus, type Task } from "../types"; - -export function isTaskRunning(task: Task): boolean { - const status = task.latest_run?.status; - return status !== undefined && !isTerminalStatus(status); -} export function confirmArchiveRunningTask( taskTitle: string, diff --git a/apps/mobile/src/features/tasks/utils/automationSchedule.test.ts b/apps/mobile/src/features/tasks/utils/automationSchedule.test.ts deleted file mode 100644 index af54fb0162..0000000000 --- a/apps/mobile/src/features/tasks/utils/automationSchedule.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - buildCronExpression, - createDefaultScheduleDraft, - deriveAutomationName, - formatScheduleSummary, - parseCronExpression, -} from "./automationSchedule"; - -describe("automationSchedule", () => { - it("builds cron expressions for common schedule presets", () => { - expect( - buildCronExpression({ - ...createDefaultScheduleDraft(), - mode: "hourly", - minute: "15", - }), - ).toBe("15 * * * *"); - - expect( - buildCronExpression({ - ...createDefaultScheduleDraft(), - mode: "daily", - hour: "09", - minute: "15", - }), - ).toBe("15 9 * * *"); - - expect( - buildCronExpression({ - ...createDefaultScheduleDraft(), - mode: "weekdays", - hour: "10", - minute: "00", - }), - ).toBe("0 10 * * 1-5"); - - expect( - buildCronExpression({ - ...createDefaultScheduleDraft(), - mode: "weekly", - hour: "11", - minute: "30", - weekday: "4", - }), - ).toBe("30 11 * * 4"); - }); - - it("parses common cron expressions back into schedule drafts", () => { - expect(parseCronExpression("15 * * * *")).toMatchObject({ - mode: "hourly", - minute: "15", - }); - - expect(parseCronExpression("0 9 * * *")).toMatchObject({ - mode: "daily", - hour: "09", - minute: "00", - }); - - expect(parseCronExpression("0 9 * * 1-5")).toMatchObject({ - mode: "weekdays", - hour: "09", - minute: "00", - }); - - expect(parseCronExpression("30 14 * * 2")).toMatchObject({ - mode: "weekly", - weekday: "2", - hour: "14", - minute: "30", - }); - }); - - it("keeps custom cron expressions in custom mode", () => { - expect(parseCronExpression("*/15 * * * *")).toMatchObject({ - mode: "custom", - rawCron: "*/15 * * * *", - }); - }); - - it("derives a readable automation name from the prompt", () => { - expect( - deriveAutomationName( - "\n Review every open PostHog PR for stale comments \n", - ), - ).toBe("Review every open PostHog PR for stale comments"); - }); - - it("formats schedule summaries with timezone context", () => { - expect(formatScheduleSummary("15 * * * *", "Europe/London")).toBe( - "Every hour at :15 · Europe/London", - ); - expect(formatScheduleSummary("0 9 * * 1-5", "Europe/London")).toBe( - "Weekdays at 09:00 · Europe/London", - ); - expect(formatScheduleSummary("*/15 * * * *", "UTC")).toBe( - "Custom schedule · UTC", - ); - }); -}); diff --git a/apps/mobile/src/features/tasks/utils/automationSchedule.ts b/apps/mobile/src/features/tasks/utils/automationSchedule.ts deleted file mode 100644 index 06cec5faf3..0000000000 --- a/apps/mobile/src/features/tasks/utils/automationSchedule.ts +++ /dev/null @@ -1,213 +0,0 @@ -import type { TaskAutomation } from "../types"; - -export type AutomationScheduleMode = - | "hourly" - | "daily" - | "weekdays" - | "weekly" - | "custom"; - -export interface AutomationScheduleDraft { - mode: AutomationScheduleMode; - hour: string; - minute: string; - weekday: string; - rawCron: string; -} - -export const WEEKDAY_OPTIONS = [ - { value: "1", label: "Mon" }, - { value: "2", label: "Tue" }, - { value: "3", label: "Wed" }, - { value: "4", label: "Thu" }, - { value: "5", label: "Fri" }, - { value: "6", label: "Sat" }, - { value: "0", label: "Sun" }, -] as const; - -export function createDefaultScheduleDraft(): AutomationScheduleDraft { - return { - mode: "daily", - hour: "09", - minute: "00", - weekday: "1", - rawCron: "0 9 * * *", - }; -} - -function padTimePart(value: string): string { - return value.padStart(2, "0"); -} - -export function sanitizeHour(value: string): string { - const digitsOnly = value.replace(/\D/g, "").slice(0, 2); - if (!digitsOnly) { - return ""; - } - - return String(Math.min(23, Number(digitsOnly))).padStart(2, "0"); -} - -export function sanitizeMinute(value: string): string { - const digitsOnly = value.replace(/\D/g, "").slice(0, 2); - if (!digitsOnly) { - return ""; - } - - return String(Math.min(59, Number(digitsOnly))).padStart(2, "0"); -} - -export function buildCronExpression(draft: AutomationScheduleDraft): string { - if (draft.mode === "custom") { - return draft.rawCron.trim(); - } - - const minute = draft.minute ? String(Number(draft.minute)) : "0"; - const hour = draft.hour ? String(Number(draft.hour)) : "9"; - - switch (draft.mode) { - case "hourly": - return `${minute} * * * *`; - case "weekdays": - return `${minute} ${hour} * * 1-5`; - case "weekly": - return `${minute} ${hour} * * ${draft.weekday || "1"}`; - default: - return `${minute} ${hour} * * *`; - } -} - -export function parseCronExpression( - cronExpression: string, -): AutomationScheduleDraft { - const normalized = cronExpression.trim(); - const parts = normalized.split(/\s+/); - - if (parts.length !== 5) { - return { - ...createDefaultScheduleDraft(), - mode: "custom", - rawCron: normalized, - }; - } - - const [minute, hour, dayOfMonth, month, dayOfWeek] = parts; - const isNumericMinute = /^\d{1,2}$/.test(minute); - const isNumericHour = /^\d{1,2}$/.test(hour); - const draftBase = { - hour: padTimePart(hour), - minute: padTimePart(minute), - weekday: dayOfWeek, - rawCron: normalized, - }; - - if ( - isNumericMinute && - hour === "*" && - dayOfMonth === "*" && - month === "*" && - dayOfWeek === "*" - ) { - return { - ...draftBase, - mode: "hourly", - hour: "09", - }; - } - - if ( - isNumericMinute && - isNumericHour && - dayOfMonth === "*" && - month === "*" && - dayOfWeek === "*" - ) { - return { - ...draftBase, - mode: "daily", - }; - } - - if ( - isNumericMinute && - isNumericHour && - dayOfMonth === "*" && - month === "*" && - dayOfWeek === "1-5" - ) { - return { - ...draftBase, - mode: "weekdays", - weekday: "1", - }; - } - - if ( - isNumericMinute && - isNumericHour && - dayOfMonth === "*" && - month === "*" && - /^\d$/.test(dayOfWeek) - ) { - return { - ...draftBase, - mode: "weekly", - }; - } - - return { - ...draftBase, - mode: "custom", - }; -} - -export function deriveAutomationName(prompt: string): string { - const normalized = prompt - .split("\n") - .map((line) => line.trim()) - .find(Boolean); - - if (!normalized) { - return ""; - } - - return normalized.replace(/\s+/g, " ").slice(0, 80); -} - -function formatTime(hour: string, minute: string): string { - return `${padTimePart(hour)}:${padTimePart(minute)}`; -} - -export function formatScheduleSummary( - cronExpression: string, - timezone: string | null | undefined, -): string { - const draft = parseCronExpression(cronExpression); - const suffix = timezone ? ` · ${timezone}` : ""; - - switch (draft.mode) { - case "hourly": - return `Every hour at :${padTimePart(draft.minute)}${suffix}`; - case "weekdays": - return `Weekdays at ${formatTime(draft.hour, draft.minute)}${suffix}`; - case "weekly": { - const label = - WEEKDAY_OPTIONS.find((option) => option.value === draft.weekday) - ?.label ?? "Weekly"; - return `${label} at ${formatTime(draft.hour, draft.minute)}${suffix}`; - } - case "custom": - return `Custom schedule${suffix}`; - default: - return `Daily at ${formatTime(draft.hour, draft.minute)}${suffix}`; - } -} - -export function formatAutomationScheduleSummary( - automation: Pick, -): string { - return formatScheduleSummary( - automation.cron_expression, - automation.timezone ?? null, - ); -} diff --git a/apps/mobile/src/features/tasks/utils/automationStatus.test.ts b/apps/mobile/src/features/tasks/utils/automationStatus.test.ts index 3a3305451b..7a24d5d050 100644 --- a/apps/mobile/src/features/tasks/utils/automationStatus.test.ts +++ b/apps/mobile/src/features/tasks/utils/automationStatus.test.ts @@ -8,9 +8,7 @@ describe("automationStatus", () => { lastRunStatus: "running", lastTaskRunStatus: "queued", }), - ).toMatchObject({ - label: "Queued", - }); + ).toMatchObject({ label: "Queued" }); }); it("hides the running badge while the linked task run is actively in progress", () => { @@ -24,19 +22,13 @@ describe("automationStatus", () => { it("hides the running badge when only the automation-level status is available", () => { expect( - getAutomationStatusPresentation({ - lastRunStatus: "running", - }), + getAutomationStatusPresentation({ lastRunStatus: "running" }), ).toBeNull(); }); it("falls back to automation status when task-run detail is unavailable", () => { expect( - getAutomationStatusPresentation({ - lastRunStatus: "success", - }), - ).toMatchObject({ - label: "Success", - }); + getAutomationStatusPresentation({ lastRunStatus: "success" }), + ).toMatchObject({ label: "Success" }); }); }); diff --git a/apps/mobile/src/features/tasks/utils/automationStatus.ts b/apps/mobile/src/features/tasks/utils/automationStatus.ts index e5dd7c8fe3..5e8b7f553f 100644 --- a/apps/mobile/src/features/tasks/utils/automationStatus.ts +++ b/apps/mobile/src/features/tasks/utils/automationStatus.ts @@ -1,4 +1,4 @@ -import type { TaskRun } from "../types"; +import type { TaskRun } from "@posthog/shared"; export interface AutomationStatusInput { lastRunStatus: string | null; @@ -21,7 +21,6 @@ export function getAutomationStatusPresentation({ label: "Queued", className: "bg-status-warning/20 text-status-warning", }; - case "started": case "in_progress": return null; case "completed": diff --git a/apps/mobile/src/features/tasks/utils/automationTemplatePresentation.ts b/apps/mobile/src/features/tasks/utils/automationTemplatePresentation.ts index d899a29b9b..2f42d89acc 100644 --- a/apps/mobile/src/features/tasks/utils/automationTemplatePresentation.ts +++ b/apps/mobile/src/features/tasks/utils/automationTemplatePresentation.ts @@ -1,5 +1,5 @@ +import type { TaskAutomation } from "@posthog/api-client/posthog-client"; import { parseSkillTemplateId } from "../skills/skillTemplateIds"; -import type { TaskAutomation } from "../types"; export interface AutomationTemplatePresentation { templateName: string | null; diff --git a/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts b/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts index 9cce3995d2..a6d512d59d 100644 --- a/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts +++ b/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts @@ -1,12 +1,9 @@ -import type { - SessionEvent, - SessionNotification, - StoredLogEntry, -} from "../types"; +import { convertStoredEntriesToPortableSessionEvents } from "@posthog/core/sessions/portableSessionEvents"; +import type { MobileStoredLogEntry, SessionNotification } from "../types"; export interface ParsedSessionLogs { notifications: SessionNotification[]; - rawEntries: StoredLogEntry[]; + rawEntries: MobileStoredLogEntry[]; } export function parseSessionLogs(content: string): ParsedSessionLogs { @@ -15,11 +12,11 @@ export function parseSessionLogs(content: string): ParsedSessionLogs { } const notifications: SessionNotification[] = []; - const rawEntries: StoredLogEntry[] = []; + const rawEntries: MobileStoredLogEntry[] = []; for (const line of content.trim().split("\n")) { try { - const stored = JSON.parse(line) as StoredLogEntry; + const stored = JSON.parse(line) as MobileStoredLogEntry; const msg = stored.notification; if (msg) { @@ -53,81 +50,5 @@ export function parseSessionLogs(content: string): ParsedSessionLogs { return { notifications, rawEntries }; } -export function convertRawEntriesToEvents( - rawEntries: StoredLogEntry[], - notifications: SessionNotification[], -): SessionEvent[] { - const events: SessionEvent[] = []; - let notificationIdx = 0; - - for (const entry of rawEntries) { - const ts = entry.timestamp - ? new Date(entry.timestamp).getTime() - : Date.now(); - - events.push({ - type: "acp_message", - direction: entry.direction ?? "agent", - ts, - message: entry.notification, - }); - - if ( - entry.type === "notification" && - entry.notification?.method === "session/update" && - notificationIdx < notifications.length - ) { - events.push({ - type: "session_update", - ts, - notification: notifications[notificationIdx], - }); - notificationIdx++; - } - } - - return events; -} - -function inferDirection(entry: StoredLogEntry): "client" | "agent" { - if (entry.direction) return entry.direction; - const msg = entry.notification; - if (!msg) return "agent"; - const hasId = msg.id !== undefined; - const hasMethod = msg.method !== undefined; - const hasResult = msg.result !== undefined || msg.error !== undefined; - if (hasId && hasMethod) return "client"; - if (hasId && hasResult) return "agent"; - return "agent"; -} - -export function convertStoredEntriesToEvents( - entries: StoredLogEntry[], -): SessionEvent[] { - const events: SessionEvent[] = []; - for (const entry of entries) { - const ts = entry.timestamp - ? new Date(entry.timestamp).getTime() - : Date.now(); - - events.push({ - type: "acp_message", - direction: inferDirection(entry), - ts, - message: entry.notification, - }); - - if ( - entry.type === "notification" && - entry.notification?.method === "session/update" && - entry.notification?.params - ) { - events.push({ - type: "session_update", - ts, - notification: entry.notification.params as SessionNotification, - }); - } - } - return events; -} +export const convertStoredEntriesToEvents = + convertStoredEntriesToPortableSessionEvents; diff --git a/apps/mobile/src/features/tasks/utils/sessionActivity.test.ts b/apps/mobile/src/features/tasks/utils/sessionActivity.test.ts deleted file mode 100644 index aef0f08b44..0000000000 --- a/apps/mobile/src/features/tasks/utils/sessionActivity.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { SessionEvent } from "../types"; -import { - countUserMessages, - getSessionActivityPhase, - isSessionAwaitingUserInput, -} from "./sessionActivity"; - -function buildUserMessage(text: string): SessionEvent { - return { - type: "session_update", - ts: 1, - notification: { - update: { - sessionUpdate: "user_message_chunk", - content: { type: "text", text }, - }, - }, - } satisfies SessionEvent; -} - -describe("countUserMessages", () => { - it("counts only user_message_chunk events", () => { - expect( - countUserMessages([ - buildUserMessage("hello"), - buildQuestionToolCall("pending"), - buildUserMessage("again"), - ]), - ).toBe(2); - }); - - it("returns 0 for no events", () => { - expect(countUserMessages()).toBe(0); - }); -}); - -function buildQuestionToolCall( - status: "pending" | "in_progress" | "completed", -) { - return { - type: "session_update", - ts: 1, - notification: { - update: { - sessionUpdate: "tool_call", - toolCallId: "question-1", - status, - rawInput: { - questions: [{ question: "Proceed?", options: [] }], - }, - _meta: { - claudeCode: { - toolName: "AskUserQuestion", - }, - }, - }, - }, - } satisfies SessionEvent; -} - -describe("getSessionActivityPhase", () => { - it("treats retrying as connecting", () => { - expect( - getSessionActivityPhase({ - retrying: true, - session: { isPromptPending: true, awaitingAgentOutput: false }, - }), - ).toBe("connecting"); - }); - - it("stays connecting until the agent emits visible output", () => { - expect( - getSessionActivityPhase({ - retrying: false, - session: { isPromptPending: true, awaitingAgentOutput: true }, - }), - ).toBe("connecting"); - }); - - it("shows working only after the agent is actively in a turn", () => { - expect( - getSessionActivityPhase({ - retrying: false, - session: { isPromptPending: true, awaitingAgentOutput: false }, - }), - ).toBe("working"); - }); - - it("returns idle once the agent is no longer working", () => { - expect( - getSessionActivityPhase({ - retrying: false, - session: { isPromptPending: false, awaitingAgentOutput: false }, - }), - ).toBe("idle"); - - expect( - getSessionActivityPhase({ - retrying: false, - session: { - isPromptPending: true, - awaitingAgentOutput: false, - terminalStatus: "completed", - }, - }), - ).toBe("idle"); - }); - - it("returns idle while the agent is paused on a question tool", () => { - expect( - getSessionActivityPhase({ - retrying: false, - session: { - isPromptPending: true, - awaitingAgentOutput: false, - events: [buildQuestionToolCall("pending")], - }, - }), - ).toBe("idle"); - }); -}); - -describe("isSessionAwaitingUserInput", () => { - it("detects unresolved question tools", () => { - expect(isSessionAwaitingUserInput([buildQuestionToolCall("pending")])).toBe( - true, - ); - }); - - it("clears the waiting state once the user responds", () => { - const events: SessionEvent[] = [ - buildQuestionToolCall("pending"), - { - type: "session_update", - ts: 2, - notification: { - update: { - sessionUpdate: "user_message_chunk", - content: { type: "text", text: "Yes" }, - }, - }, - }, - ]; - - expect(isSessionAwaitingUserInput(events)).toBe(false); - }); - - it("honors explicit awaiting-user-input backend markers", () => { - const events: SessionEvent[] = [ - { - type: "acp_message", - direction: "agent", - ts: 1, - message: { method: "_posthog/awaiting_user_input" }, - }, - ]; - - expect(isSessionAwaitingUserInput(events)).toBe(true); - }); -}); diff --git a/apps/mobile/src/features/tasks/utils/sessionActivity.ts b/apps/mobile/src/features/tasks/utils/sessionActivity.ts deleted file mode 100644 index 982d4db8b6..0000000000 --- a/apps/mobile/src/features/tasks/utils/sessionActivity.ts +++ /dev/null @@ -1,133 +0,0 @@ -import type { - SessionEvent, - SessionNotification, - TerminalStatus, -} from "../types"; - -export type SessionActivityPhase = "idle" | "connecting" | "working"; - -interface SessionActivityState { - isPromptPending?: boolean; - awaitingAgentOutput?: boolean; - terminalStatus?: TerminalStatus; - events?: SessionEvent[]; -} - -function isQuestionNotification(notification: SessionNotification): boolean { - const update = notification.update; - if (!update) return false; - - const rawToolName = update._meta?.claudeCode?.toolName; - if (typeof rawToolName === "string" && /question/i.test(rawToolName)) { - return true; - } - - const rawInput = update.rawInput; - if (!rawInput) return false; - - if (Array.isArray(rawInput.questions)) { - return true; - } - - const nestedInput = rawInput.input; - return ( - typeof nestedInput === "object" && - nestedInput !== null && - Array.isArray((nestedInput as { questions?: unknown }).questions) - ); -} - -function isPendingQuestionStatus( - status?: "pending" | "in_progress" | "completed" | "failed" | null, -): boolean { - return status === null || status === "pending" || status === "in_progress"; -} - -export function isSessionAwaitingUserInput( - events: SessionEvent[] = [], -): boolean { - let awaitingUserInput = false; - const questionStatuses = new Map< - string, - "pending" | "in_progress" | "completed" | "failed" | null | undefined - >(); - - for (const event of events) { - if (event.type === "session_update") { - const update = event.notification.update; - const sessionUpdate = update?.sessionUpdate; - - if (sessionUpdate === "user_message_chunk") { - awaitingUserInput = false; - questionStatuses.clear(); - continue; - } - - if ( - (sessionUpdate === "tool_call" || - sessionUpdate === "tool_call_update") && - isQuestionNotification(event.notification) - ) { - questionStatuses.set( - update?.toolCallId ?? `question-${event.ts}`, - update?.status, - ); - awaitingUserInput = [...questionStatuses.values()].some((status) => - isPendingQuestionStatus(status), - ); - } - - continue; - } - - const method = - event.message && typeof event.message === "object" - ? (event.message as { method?: string }).method - : undefined; - - if (method === "_posthog/awaiting_user_input") { - awaitingUserInput = true; - continue; - } - - if ( - method === "_posthog/turn_complete" || - method === "_posthog/task_complete" || - method === "_posthog/error" - ) { - awaitingUserInput = false; - questionStatuses.clear(); - } - } - - return awaitingUserInput; -} - -export function countUserMessages(events: SessionEvent[] = []): number { - return events.filter( - (e) => - e.type === "session_update" && - e.notification.update?.sessionUpdate === "user_message_chunk", - ).length; -} - -export function getSessionActivityPhase(args: { - retrying: boolean; - session?: SessionActivityState | null; -}): SessionActivityPhase { - const { retrying, session } = args; - - if (retrying) { - return "connecting"; - } - - if (!session?.isPromptPending || session.terminalStatus) { - return "idle"; - } - - if (isSessionAwaitingUserInput(session.events)) { - return "idle"; - } - - return session.awaitingAgentOutput ? "connecting" : "working"; -} diff --git a/apps/mobile/src/lib/analytics.ts b/apps/mobile/src/lib/analytics.ts index 0bee789344..e7665ba661 100644 --- a/apps/mobile/src/lib/analytics.ts +++ b/apps/mobile/src/lib/analytics.ts @@ -1,5 +1,4 @@ -import type { PostHogEventProperties } from "@posthog/core"; -import { usePostHog } from "posthog-react-native"; +import { type PostHog, usePostHog } from "posthog-react-native"; import { useEffect, useMemo } from "react"; /** @@ -199,6 +198,8 @@ export interface Analytics { ): void; } +type PostHogCaptureProperties = Parameters[1]; + // Client discriminator stamped on inbox events so the shared PostHog project // can be sliced by surface (desktop sends "code", the web frontend sends // "cloud"). Mirrors packages/ui/src/shell/posthogAnalyticsImpl.ts. @@ -221,12 +222,9 @@ export function useAnalytics(): Analytics { const enriched = INBOX_ANALYTICS_EVENT_NAMES.has(eventName) ? { inbox_client: INBOX_CLIENT, ...properties } : 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, - enriched as unknown as PostHogEventProperties, + enriched as unknown as PostHogCaptureProperties, ); }, }), diff --git a/apps/mobile/src/lib/api.ts b/apps/mobile/src/lib/api.ts index 58603788d3..ccc2a97a09 100644 --- a/apps/mobile/src/lib/api.ts +++ b/apps/mobile/src/lib/api.ts @@ -3,9 +3,20 @@ import Constants from "expo-constants"; import { useAuthStore } from "@/features/auth"; import { logger } from "@/lib/logger"; +export class HttpError extends Error { + constructor( + readonly status: number, + readonly statusText: string, + message: string, + ) { + super(message); + this.name = "HttpError"; + } +} + // 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]>; +export type FetchInit = NonNullable[1]>; const log = logger.scope("api"); @@ -66,7 +77,7 @@ export function createTimeoutSignal(ms: number): AbortSignal { // pending refresh across all callers and reset it once it settles. let pendingRefresh: Promise | null = null; -async function refreshAccessTokenOnce(): Promise { +export async function refreshAccessTokenOnce(): Promise { if (pendingRefresh) return pendingRefresh; const promise = useAuthStore .getState() diff --git a/apps/mobile/src/lib/posthogApiClient.test.ts b/apps/mobile/src/lib/posthogApiClient.test.ts new file mode 100644 index 0000000000..2aae2a0ed2 --- /dev/null +++ b/apps/mobile/src/lib/posthogApiClient.test.ts @@ -0,0 +1,190 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + authState: { + cloudRegion: "us" as string | null, + getCloudUrlFromRegion: vi.fn(() => "https://us.posthog.com"), + oauthAccessToken: "access-token" as string | null, + projectId: 123 as number | null, + refreshAccessToken: vi.fn(async () => {}), + }, + expoApplication: { + nativeApplicationVersion: "1.2.3" as string | null, + }, + expoConstants: { + expoConfig: { version: "9.9.9" } as { version?: string } | null, + }, + expoFetch: vi.fn(), + instances: [] as Array<{ + apiHost: string; + getAccessToken: () => Promise; + refreshAccessToken: () => Promise; + teamId: number | undefined; + options: Record; + setTeamId: ReturnType; + }>, +})); + +vi.mock("@posthog/api-client/posthog-client", () => ({ + PostHogAPIClient: class { + setTeamId = vi.fn(); + + constructor( + apiHost: string, + getAccessToken: () => Promise, + refreshAccessToken: () => Promise, + teamId: number | undefined, + options: Record, + ) { + mocks.instances.push({ + apiHost, + getAccessToken, + refreshAccessToken, + teamId, + options, + setTeamId: this.setTeamId, + }); + } + }, +})); + +vi.mock("expo-application", () => ({ + get nativeApplicationVersion() { + return mocks.expoApplication.nativeApplicationVersion; + }, +})); + +vi.mock("expo-constants", () => ({ + default: { + get expoConfig() { + return mocks.expoConstants.expoConfig; + }, + }, +})); + +vi.mock("expo/fetch", () => ({ fetch: mocks.expoFetch })); + +vi.mock("@/features/auth", () => ({ + useAuthStore: { + getState: () => mocks.authState, + }, +})); + +beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + mocks.instances.length = 0; + mocks.authState.cloudRegion = "us"; + mocks.authState.oauthAccessToken = "access-token"; + mocks.authState.projectId = 123; + mocks.authState.getCloudUrlFromRegion.mockReturnValue( + "https://us.posthog.com", + ); + mocks.authState.refreshAccessToken.mockImplementation(async () => {}); + mocks.expoApplication.nativeApplicationVersion = "1.2.3"; + mocks.expoConstants.expoConfig = { version: "9.9.9" }; +}); + +describe("createPostHogApiClient", () => { + it("configures the shared client for the mobile host", async () => { + const { createPostHogApiClient } = await import("./posthogApiClient"); + + createPostHogApiClient(); + + expect(mocks.instances).toHaveLength(1); + expect(mocks.instances[0]).toMatchObject({ + apiHost: "https://us.posthog.com", + teamId: 123, + options: { + appVersion: "1.2.3", + fetch: mocks.expoFetch, + githubConnectFrom: "posthog_mobile", + userAgent: "posthog/mobile.hog.dev; version: 1.2.3", + }, + }); + }); + + it("falls back to the Expo config version", async () => { + mocks.expoApplication.nativeApplicationVersion = null; + mocks.expoConstants.expoConfig = { version: "4.5.6" }; + const { createPostHogApiClient } = await import("./posthogApiClient"); + + createPostHogApiClient(); + + expect(mocks.instances[0]?.options).toMatchObject({ + appVersion: "4.5.6", + userAgent: "posthog/mobile.hog.dev; version: 4.5.6", + }); + }); + + it("returns the refreshed token from the current auth store state", async () => { + mocks.authState.refreshAccessToken.mockImplementation(async () => { + mocks.authState.oauthAccessToken = "refreshed-token"; + }); + const { createPostHogApiClient } = await import("./posthogApiClient"); + createPostHogApiClient(); + + await expect(mocks.instances[0]?.refreshAccessToken()).resolves.toBe( + "refreshed-token", + ); + expect(mocks.authState.refreshAccessToken).toHaveBeenCalledOnce(); + }); + + it("shares one refresh across concurrent client retries", async () => { + let resolveRefresh: (() => void) | undefined; + mocks.authState.refreshAccessToken.mockImplementation( + () => + new Promise((resolve) => { + resolveRefresh = () => { + mocks.authState.oauthAccessToken = "refreshed-token"; + resolve(); + }; + }), + ); + const { createPostHogApiClient } = await import("./posthogApiClient"); + createPostHogApiClient(); + + const refreshes = [ + mocks.instances[0]?.refreshAccessToken(), + mocks.instances[0]?.refreshAccessToken(), + ]; + expect(mocks.authState.refreshAccessToken).toHaveBeenCalledOnce(); + resolveRefresh?.(); + + await expect(Promise.all(refreshes)).resolves.toEqual([ + "refreshed-token", + "refreshed-token", + ]); + }); +}); + +describe("getPostHogApiClient", () => { + it("reuses the regional client and updates its project", async () => { + const { getPostHogApiClient } = await import("./posthogApiClient"); + + const first = getPostHogApiClient(); + mocks.authState.projectId = 456; + const second = getPostHogApiClient(); + + expect(second).toBe(first); + expect(mocks.instances).toHaveLength(1); + expect(mocks.instances[0]?.setTeamId).toHaveBeenCalledWith(456); + }); + + it("creates a new client when the cloud region changes", async () => { + const { getPostHogApiClient } = await import("./posthogApiClient"); + + const first = getPostHogApiClient(); + mocks.authState.cloudRegion = "eu"; + mocks.authState.getCloudUrlFromRegion.mockReturnValue( + "https://eu.posthog.com", + ); + const second = getPostHogApiClient(); + + expect(second).not.toBe(first); + expect(mocks.instances.map(({ apiHost }) => apiHost)).toEqual([ + "https://us.posthog.com", + "https://eu.posthog.com", + ]); + }); +}); diff --git a/apps/mobile/src/lib/posthogApiClient.ts b/apps/mobile/src/lib/posthogApiClient.ts new file mode 100644 index 0000000000..c094452f44 --- /dev/null +++ b/apps/mobile/src/lib/posthogApiClient.ts @@ -0,0 +1,84 @@ +import type { FetchImplementation } from "@posthog/api-client/fetcher"; +import { PostHogAPIClient } from "@posthog/api-client/posthog-client"; +import { fetch } from "expo/fetch"; +import * as Application from "expo-application"; +import Constants from "expo-constants"; +import { useAuthStore } from "@/features/auth"; +import { refreshAccessTokenOnce } from "@/lib/api"; + +const MOBILE_GITHUB_CONNECT_FROM = "posthog_mobile"; + +let posthogApiClient: PostHogAPIClient | null = null; +let posthogApiHost: string | null = null; + +function getAppVersion(): string { + return ( + Application.nativeApplicationVersion ?? + Constants.expoConfig?.version ?? + "unknown" + ); +} + +function getAuthenticatedContext(): { + apiHost: string; + projectId: number; +} { + const { cloudRegion, getCloudUrlFromRegion, projectId } = + useAuthStore.getState(); + + if (!cloudRegion) { + throw new Error("No cloud region set"); + } + if (!projectId) { + throw new Error("No project ID set"); + } + + return { + apiHost: getCloudUrlFromRegion(cloudRegion), + projectId, + }; +} + +async function getAccessToken(): Promise { + const { oauthAccessToken } = useAuthStore.getState(); + if (!oauthAccessToken) { + throw new Error("Not authenticated"); + } + return oauthAccessToken; +} + +async function refreshAccessToken(): Promise { + await refreshAccessTokenOnce(); + return getAccessToken(); +} + +export function createPostHogApiClient(): PostHogAPIClient { + const { apiHost, projectId } = getAuthenticatedContext(); + const appVersion = getAppVersion(); + + return new PostHogAPIClient( + apiHost, + getAccessToken, + refreshAccessToken, + projectId, + { + appVersion, + fetch: fetch as FetchImplementation, + githubConnectFrom: MOBILE_GITHUB_CONNECT_FROM, + userAgent: `posthog/mobile.hog.dev; version: ${appVersion}`, + }, + ); +} + +export function getPostHogApiClient(): PostHogAPIClient { + const { apiHost, projectId } = getAuthenticatedContext(); + + if (!posthogApiClient || posthogApiHost !== apiHost) { + posthogApiClient = createPostHogApiClient(); + posthogApiHost = apiHost; + } else { + posthogApiClient.setTeamId(projectId); + } + + return posthogApiClient; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5cdfefdf93..4f35ccf04a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -479,6 +479,12 @@ importers: '@modelcontextprotocol/sdk': specifier: ^1.29.0 version: 1.29.0(zod@4.4.3) + '@posthog/api-client': + specifier: workspace:* + version: link:../../packages/api-client + '@posthog/core': + specifier: workspace:* + version: link:../../packages/core '@posthog/shared': specifier: workspace:* version: link:../../packages/shared From 5299c52f6cf4090546cba9f0f6d6cf8881013843 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Sat, 25 Jul 2026 01:29:34 +0300 Subject: [PATCH 27/43] fix(mobile): preserve permission mode on resume Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts | 1 + apps/mobile/src/features/tasks/stores/taskSessionStore.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts index 864ca6271e..c38504a1c3 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts @@ -274,6 +274,7 @@ describe("_resumeCloudRun", () => { expect(mockRunTaskInCloud).toHaveBeenCalledWith("t1", { branch: "feature", + runtimeAdapter: "claude", resumeFromRunId: "prev-run", pendingUserMessage: "hi", reasoningEffort: "low", diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts index a47638c679..462589a58e 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts @@ -1197,6 +1197,7 @@ export const useTaskSessionStore = create((set, get) => ({ const updatedTask = await runTaskInCloud(taskId, { branch: previousBranch, + runtimeAdapter: "claude", resumeFromRunId: previousRunId, pendingUserMessage: prompt, reasoningEffort, From 2b5d2a50f9d7262a2021552a20202453cf7d6ead Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 24 Jul 2026 19:30:06 +0300 Subject: [PATCH 28/43] refactor(mobile): finish shared task adoption Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- apps/mobile/src/app/(tabs)/inbox.tsx | 10 +- apps/mobile/src/app/inbox/[...id].tsx | 24 +- apps/mobile/src/app/task/[id].tsx | 68 +++--- apps/mobile/src/app/task/index.tsx | 125 +++++++---- .../src/features/inbox/activityLog.test.ts | 14 +- apps/mobile/src/features/inbox/activityLog.ts | 6 +- apps/mobile/src/features/inbox/api.ts | 21 +- .../inbox/components/ArchivedReportList.tsx | 11 +- .../inbox/components/ArtefactCommit.tsx | 2 +- .../inbox/components/ArtefactTaskRun.tsx | 2 +- .../inbox/components/DismissReportSheet.tsx | 8 +- .../inbox/components/EditReviewersSheet.tsx | 5 +- .../features/inbox/components/FilterSheet.tsx | 29 +-- .../inbox/components/ReportActivity.tsx | 4 +- .../features/inbox/components/ReportList.tsx | 2 +- .../inbox/components/ReportListRow.tsx | 2 +- .../features/inbox/components/SignalCard.tsx | 5 +- .../inbox/components/SuggestedReviewers.tsx | 10 +- .../inbox/components/SwipeableReportCard.tsx | 12 +- .../features/inbox/components/TinderView.tsx | 33 +-- apps/mobile/src/features/inbox/constants.ts | 22 -- .../hooks/useInboxEngagementTracker.test.ts | 2 +- .../inbox/hooks/useInboxEngagementTracker.ts | 2 +- .../inbox/hooks/useInboxReports.test.ts | 5 +- .../features/inbox/hooks/useInboxReports.ts | 45 ++-- .../inbox/stores/inboxFilterStore.test.ts | 3 +- .../features/inbox/stores/inboxFilterStore.ts | 18 +- .../src/features/inbox/stores/inboxStore.ts | 2 +- apps/mobile/src/features/inbox/types.ts | 209 ------------------ apps/mobile/src/features/inbox/utils.test.ts | 126 ++++++++--- apps/mobile/src/features/inbox/utils.ts | 72 +----- .../tasks/composer/TaskChatComposer.tsx | 105 ++++++--- .../tasks/composer/attachments/cloudPrompt.ts | 16 -- .../features/tasks/composer/options.test.ts | 79 +++++-- .../src/features/tasks/composer/options.ts | 133 ++++------- .../tasks/hooks/useAutomations.test.ts | 24 +- .../hooks/useCloudTaskConfigOptions.test.ts | 121 ++++++++++ .../tasks/hooks/useCloudTaskConfigOptions.ts | 52 +++++ .../tasks/hooks/useIntegrations.test.ts | 6 +- .../features/tasks/hooks/useIntegrations.ts | 31 +-- .../src/features/tasks/hooks/useTasks.test.ts | 9 +- .../src/features/tasks/hooks/useTasks.ts | 16 +- .../tasks/hooks/useUserIntegrations.ts | 31 ++- .../features/tasks/hooks/useWarmTask.test.tsx | 4 +- apps/mobile/src/features/tasks/index.ts | 6 - .../features/tasks/stores/taskSessionStore.ts | 7 +- .../src/features/tasks/stores/taskStore.ts | 7 +- apps/mobile/src/features/tasks/types.ts | 103 --------- .../features/tasks/utils/parseSessionLogs.ts | 54 ----- 49 files changed, 774 insertions(+), 929 deletions(-) delete mode 100644 apps/mobile/src/features/inbox/constants.ts delete mode 100644 apps/mobile/src/features/inbox/types.ts delete mode 100644 apps/mobile/src/features/tasks/composer/attachments/cloudPrompt.ts create mode 100644 apps/mobile/src/features/tasks/hooks/useCloudTaskConfigOptions.test.ts create mode 100644 apps/mobile/src/features/tasks/hooks/useCloudTaskConfigOptions.ts delete mode 100644 apps/mobile/src/features/tasks/utils/parseSessionLogs.ts diff --git a/apps/mobile/src/app/(tabs)/inbox.tsx b/apps/mobile/src/app/(tabs)/inbox.tsx index 4013102faa..1e64ab8df4 100644 --- a/apps/mobile/src/app/(tabs)/inbox.tsx +++ b/apps/mobile/src/app/(tabs)/inbox.tsx @@ -1,3 +1,5 @@ +import { INBOX_PIPELINE_STATUSES } from "@posthog/core/inbox/reportFiltering"; +import type { SignalReport } from "@posthog/shared/domain-types"; import { useFocusEffect, useRouter } from "expo-router"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { View } from "react-native"; @@ -20,12 +22,8 @@ import { decidedIds, useDismissedReportsStore, } from "@/features/inbox/stores/dismissedReportsStore"; -import { - DEFAULT_STATUS_FILTER, - useInboxFilterStore, -} from "@/features/inbox/stores/inboxFilterStore"; +import { useInboxFilterStore } from "@/features/inbox/stores/inboxFilterStore"; import { useInboxStore } from "@/features/inbox/stores/inboxStore"; -import type { SignalReport } from "@/features/inbox/types"; import { buildInboxViewedProperties } from "@/features/inbox/utils"; import { useIntegrations } from "@/features/tasks/hooks/useIntegrations"; import { ANALYTICS_EVENTS, useAnalytics } from "@/lib/analytics"; @@ -74,7 +72,7 @@ export default function InboxScreen() { statusFilter, suggestedReviewerFilter, priorityFilter, - defaultStatusFilter: DEFAULT_STATUS_FILTER, + defaultStatusFilter: INBOX_PIPELINE_STATUSES, }), ); }, [ diff --git a/apps/mobile/src/app/inbox/[...id].tsx b/apps/mobile/src/app/inbox/[...id].tsx index 48fa3daab2..8ad989bfa9 100644 --- a/apps/mobile/src/app/inbox/[...id].tsx +++ b/apps/mobile/src/app/inbox/[...id].tsx @@ -1,4 +1,16 @@ import { Text } from "@components/text"; +import { + formatSignalReportSummaryMarkdown, + inboxStatusLabel, +} from "@posthog/core/inbox/reportPresentation"; +import { DISMISSAL_REASON_OPTIONS } from "@posthog/shared"; +import type { + ActionabilityJudgmentContent, + SignalFindingContent, + SignalReportPriority, + SignalReportStatus, + SuggestedReviewersArtefact, +} from "@posthog/shared/domain-types"; import { differenceInHours, format, formatDistanceToNow } from "date-fns"; import * as Haptics from "expo-haptics"; import { useLocalSearchParams, useRouter } from "expo-router"; @@ -39,7 +51,6 @@ import { type ReviewerActionExtra, SuggestedReviewers, } from "@/features/inbox/components/SuggestedReviewers"; -import { DISMISSAL_REASON_OPTIONS } from "@/features/inbox/constants"; import { useInboxEngagementTracker } from "@/features/inbox/hooks/useInboxEngagementTracker"; import { useInboxReport, @@ -47,17 +58,6 @@ import { useInboxReportSignals, } from "@/features/inbox/hooks/useInboxReports"; import { useInboxStore } from "@/features/inbox/stores/inboxStore"; -import type { - ActionabilityJudgmentContent, - SignalFindingContent, - SignalReportPriority, - SignalReportStatus, - SuggestedReviewersArtefact, -} from "@/features/inbox/types"; -import { - formatSignalReportSummaryMarkdown, - inboxStatusLabel, -} from "@/features/inbox/utils"; import { PrStatusBadge } from "@/features/tasks/components/PrStatusBadge"; import { computeReportAgeHours, diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index fe73a847f3..31dc3d6e5d 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -1,10 +1,19 @@ import { Text } from "@components/text"; +import { DEFAULT_CLAUDE_EXECUTION_MODE } from "@posthog/core/sessions/executionModes"; import { countUserMessages, getSessionActivityPhase, } from "@posthog/core/sessions/sessionActivity"; import { isTaskRunning } from "@posthog/core/tasks/taskArchive"; -import type { Task } from "@posthog/shared"; +import { + DEFAULT_GATEWAY_MODEL, + DEFAULT_REASONING_EFFORT, + type ExecutionMode, + getReasoningEffortOptions, + type SupportedReasoningEffort, + serializeCloudPrompt, + type Task, +} from "@posthog/shared"; import { useQueryClient } from "@tanstack/react-query"; import * as Haptics from "expo-haptics"; import { useLocalSearchParams, useRouter } from "expo-router"; @@ -20,7 +29,6 @@ import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller import Animated, { useAnimatedStyle } from "react-native-reanimated"; import { FloatingBackButton } from "@/components/FloatingBackButton"; import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; -import { runTaskInCloud } from "@/features/tasks/api"; import { CustomImageBadge } from "@/features/tasks/components/CustomImageBadge"; import { FloatingTaskHeader } from "@/features/tasks/components/FloatingTaskHeader"; import { PrDiffStatsBadge } from "@/features/tasks/components/PrDiffStatsBadge"; @@ -28,16 +36,7 @@ import { PrStatusBadge } from "@/features/tasks/components/PrStatusBadge"; import { StopRunButton } from "@/features/tasks/components/StopRunButton"; import { TaskSessionView } from "@/features/tasks/components/TaskSessionView"; import { buildCloudPromptBlocks } from "@/features/tasks/composer/attachments/buildCloudPrompt"; -import { serializeCloudPrompt } from "@/features/tasks/composer/attachments/cloudPrompt"; import type { PendingAttachment } from "@/features/tasks/composer/attachments/types"; -import { - DEFAULT_EXECUTION_MODE, - DEFAULT_MODEL, - DEFAULT_REASONING, - type ExecutionMode, - modelSupportsReasoning, - type ReasoningEffort, -} from "@/features/tasks/composer/options"; import { QueuedMessagesDock } from "@/features/tasks/composer/QueuedMessagesDock"; import { TaskChatComposer } from "@/features/tasks/composer/TaskChatComposer"; import { @@ -169,10 +168,10 @@ export default function TaskDetailScreen() { string | undefined >(); const composerMode: ExecutionMode = - composerConfig?.mode ?? DEFAULT_EXECUTION_MODE; - const composerModel = composerConfig?.model ?? DEFAULT_MODEL; - const composerReasoning: ReasoningEffort = - composerConfig?.reasoning ?? DEFAULT_REASONING; + composerConfig?.mode ?? DEFAULT_CLAUDE_EXECUTION_MODE; + const composerModel = composerConfig?.model ?? DEFAULT_GATEWAY_MODEL; + const composerReasoning: SupportedReasoningEffort = + composerConfig?.reasoning ?? DEFAULT_REASONING_EFFORT; const messagingMode = useMessagingMode(taskId); const queuedCount = useQueuedCount(taskId); @@ -315,16 +314,21 @@ export default function TaskDetailScreen() { ) : text; - const supportsReasoning = modelSupportsReasoning(composerModel); - const updatedTask = await runTaskInCloud(taskId, { - resumeFromRunId: task.latest_run?.id, - pendingUserMessage, - runtimeAdapter: "claude", - model: composerModel, - reasoningEffort: supportsReasoning ? composerReasoning : undefined, - initialPermissionMode: composerMode, - rtkEnabled: usePreferencesStore.getState().rtkEnabledCloud, - }); + const supportsReasoning = + getReasoningEffortOptions("claude", composerModel) !== null; + const updatedTask = await getPostHogApiClient().runTaskInCloud( + taskId, + undefined, + { + resumeFromRunId: task.latest_run?.id, + pendingUserMessage, + adapter: "claude", + model: composerModel, + reasoningLevel: supportsReasoning ? composerReasoning : undefined, + initialPermissionMode: composerMode, + rtkEnabled: usePreferencesStore.getState().rtkEnabledCloud, + }, + ); setTask(updatedTask); await connectToTask(updatedTask); updateTaskInCache(updatedTask); @@ -513,7 +517,7 @@ export default function TaskDetailScreen() { ); const handleReasoningChange = useCallback( - (value: ReasoningEffort) => { + (value: SupportedReasoningEffort) => { if (!taskId) return; setComposerConfig(taskId, { reasoning: value }); setConfigOption(taskId, "effort", value).catch(() => {}); @@ -566,10 +570,14 @@ export default function TaskDetailScreen() { setRetrying(true); disconnectFromTask(taskId); - const updatedTask = await runTaskInCloud(taskId, { - resumeFromRunId: task.latest_run?.id, - rtkEnabled: usePreferencesStore.getState().rtkEnabledCloud, - }); + const updatedTask = await getPostHogApiClient().runTaskInCloud( + taskId, + undefined, + { + resumeFromRunId: task.latest_run?.id, + rtkEnabled: usePreferencesStore.getState().rtkEnabledCloud, + }, + ); setTask(updatedTask); await connectToTask(updatedTask); updateTaskInCache(updatedTask); diff --git a/apps/mobile/src/app/task/index.tsx b/apps/mobile/src/app/task/index.tsx index 012b206dcc..9df757f9de 100644 --- a/apps/mobile/src/app/task/index.tsx +++ b/apps/mobile/src/app/task/index.tsx @@ -1,4 +1,17 @@ import { Text } from "@components/text"; +import { + DEFAULT_CLAUDE_EXECUTION_MODE, + getAvailableModes, +} from "@posthog/core/sessions/executionModes"; +import { + DEFAULT_GATEWAY_MODEL, + DEFAULT_REASONING_EFFORT, + type ExecutionMode, + getReasoningEffortOptions, + isSupportedReasoningEffort, + type SupportedReasoningEffort, + serializeCloudPrompt, +} from "@posthog/shared"; import { LinearGradient } from "expo-linear-gradient"; import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import { @@ -15,7 +28,7 @@ import { Sparkle, StopIcon, } from "phosphor-react-native"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { ActivityIndicator, Pressable, @@ -30,13 +43,11 @@ import { import Animated, { runOnJS, useAnimatedStyle } from "react-native-reanimated"; import { useVoiceRecording } from "@/features/chat"; import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; -import { runTaskInCloud } from "@/features/tasks/api"; import { GitHubConnectionPrompt } from "@/features/tasks/components/GitHubConnectionPrompt"; import { GitHubLoadNotice } from "@/features/tasks/components/GitHubLoadNotice"; import { AttachmentSheet } from "@/features/tasks/composer/attachments/AttachmentSheet"; import { AttachmentsBar } from "@/features/tasks/composer/attachments/AttachmentsBar"; import { buildCloudPromptBlocks } from "@/features/tasks/composer/attachments/buildCloudPrompt"; -import { serializeCloudPrompt } from "@/features/tasks/composer/attachments/cloudPrompt"; import { captureFromCamera, pickDocument, @@ -45,22 +56,15 @@ import { import type { PendingAttachment } from "@/features/tasks/composer/attachments/types"; import { DotBackground } from "@/features/tasks/composer/DotBackground"; import { - DEFAULT_EXECUTION_MODE, - DEFAULT_MODEL, - DEFAULT_REASONING, - EXECUTION_MODES, - type ExecutionMode, - MODELS, - modeLabel, - modelLabel, - modelSupportsReasoning, - REASONING_LEVELS, - type ReasoningEffort, - reasoningLabel, + getMobileModelOptions, + getModelConfigOption, + getModelLabel, + resolveAvailableModel, } from "@/features/tasks/composer/options"; import { Pill } from "@/features/tasks/composer/Pill"; import { RepositoryPickerInline } from "@/features/tasks/composer/RepositoryPickerInline"; import { SelectSheet } from "@/features/tasks/composer/SelectSheet"; +import { useCloudTaskConfigOptions } from "@/features/tasks/hooks/useCloudTaskConfigOptions"; import { useUserIntegrations } from "@/features/tasks/hooks/useUserIntegrations"; import { useWarmTask } from "@/features/tasks/hooks/useWarmTask"; import { pendingPromptRecoveryStoreApi } from "@/features/tasks/stores/pendingPromptRecoveryStore"; @@ -84,6 +88,7 @@ import { getPostHogApiClient } from "@/lib/posthogApiClient"; import { toRgba, useThemeColors } from "@/lib/theme"; const log = logger.scope("task-create"); +const EXECUTION_MODES = getAvailableModes(); const SUGGESTIONS = [ "Create or update my CLAUDE.md file", @@ -99,6 +104,11 @@ function modeIcon(mode: ExecutionMode, color: string, size = 14) { return ; case "acceptEdits": return ; + case "bypassPermissions": + case "full-access": + return ; + case "read-only": + return ; case "auto": return ; } @@ -119,6 +129,9 @@ export default function NewTaskScreen() { const { insets, bottom } = useScreenInsets(); const keyboard = useReanimatedKeyboardAnimation(); const restingBottom = bottom("compact"); + const { configOptions, hasLiveConfig } = useCloudTaskConfigOptions("claude"); + const modelConfigOption = getModelConfigOption(configOptions); + const mobileModelOptions = getMobileModelOptions(modelConfigOption); const { error, hasGithubIntegration, @@ -182,22 +195,32 @@ export default function NewTaskScreen() { const prefs = usePreferencesStore.getState(); if (prefs.defaultInitialTaskMode === "last_used") { const last = prefs.lastNewTaskMode; - const isValidMode = EXECUTION_MODES.some((m) => m.value === last); + const isValidMode = EXECUTION_MODES.some((mode) => mode.id === last); if (isValidMode) return last as ExecutionMode; } - return DEFAULT_EXECUTION_MODE; + return DEFAULT_CLAUDE_EXECUTION_MODE; }); - const [model, setModel] = useState(DEFAULT_MODEL); - const [reasoning, setReasoning] = useState(() => { + const [model, setModel] = useState(DEFAULT_GATEWAY_MODEL); + const [reasoning, setReasoning] = useState(() => { const prefs = usePreferencesStore.getState(); - const isValidReasoning = (v: string): v is ReasoningEffort => - REASONING_LEVELS.some((r) => r.value === v); const desired = prefs.defaultReasoningEffort === "last_used" ? prefs.lastUsedReasoningEffort : prefs.defaultReasoningEffort; - return isValidReasoning(desired) ? desired : DEFAULT_REASONING; + return isSupportedReasoningEffort("claude", DEFAULT_GATEWAY_MODEL, desired) + ? desired + : DEFAULT_REASONING_EFFORT; }); + + useEffect(() => { + if (!hasLiveConfig) return; + const availableModel = resolveAvailableModel(modelConfigOption, model); + if (availableModel === model) return; + setModel(availableModel); + if (!isSupportedReasoningEffort("claude", availableModel, reasoning)) { + setReasoning(DEFAULT_REASONING_EFFORT); + } + }, [hasLiveConfig, model, modelConfigOption, reasoning]); const [creating, setCreating] = useState(false); const [repoSheetOpen, setRepoSheetOpen] = useState(false); const [modeSheetOpen, setModeSheetOpen] = useState(false); @@ -309,7 +332,8 @@ export default function NewTaskScreen() { ? `Attached: ${attachments[0].fileName}` : `Attached ${attachments.length} files`); - const task = await getPostHogApiClient().createTask({ + const client = getPostHogApiClient(); + const task = await client.createTask({ description: descriptionText, title: descriptionText.slice(0, 100), repository: selection.repository ?? undefined, @@ -334,7 +358,7 @@ export default function NewTaskScreen() { // Seed the per-task composer config with the mode/model/reasoning the // user picked here, so the task detail screen reflects them and every // subsequent run (resume-after-terminal) reuses the selected mode rather - // than falling back to DEFAULT_EXECUTION_MODE ("plan"). + // than falling back to the default plan mode. setComposerConfig(task.id, { mode, model, reasoning }); const pendingUserMessage = @@ -344,13 +368,14 @@ export default function NewTaskScreen() { ) : trimmedPrompt; - const supportsReasoning = modelSupportsReasoning(model); + const supportsReasoning = + getReasoningEffortOptions("claude", model) !== null; - await runTaskInCloud(task.id, { + await client.runTaskInCloud(task.id, undefined, { pendingUserMessage, - runtimeAdapter: "claude", + adapter: "claude", model, - reasoningEffort: supportsReasoning ? reasoning : undefined, + reasoningLevel: supportsReasoning ? reasoning : undefined, initialPermissionMode: mode, autoPublish: usePreferencesStore.getState().autoPublishCloudRuns, rtkEnabled: usePreferencesStore.getState().rtkEnabledCloud, @@ -386,8 +411,12 @@ export default function NewTaskScreen() { const hasContent = !!prompt.trim() || attachments.length > 0; const canSubmit = - hasContent && isRepositorySelectionComplete(selection) && !creating; - const showReasoningPill = modelSupportsReasoning(model); + hasLiveConfig && + hasContent && + isRepositorySelectionComplete(selection) && + !creating; + const reasoningOptions = getReasoningEffortOptions("claude", model) ?? []; + const showReasoningPill = reasoningOptions.length > 0; // Best-effort prewarm; failures are swallowed. `selection.integrationId` is // the GitHub installation id, not a PostHog integration id — the backend @@ -395,7 +424,7 @@ export default function NewTaskScreen() { useWarmTask({ repository: selection.repository, githubIntegrationId: selection.integrationId, - composerIsEmpty: !hasContent, + composerIsEmpty: !hasContent || !hasLiveConfig, runtimeAdapter: "claude", model, reasoningEffort: showReasoningPill ? reasoning : null, @@ -610,14 +639,17 @@ export default function NewTaskScreen() { ? themeColors.accent[11] : themeColors.gray[11], )} - label={modeLabel(mode)} + label={ + EXECUTION_MODES.find((option) => option.id === mode) + ?.name ?? mode + } accent={mode === "plan"} onPress={() => setModeSheetOpen(true)} /> } - label={modelLabel(model)} + label={getModelLabel(modelConfigOption, model)} onPress={() => setModelSheetOpen(true)} /> @@ -626,7 +658,11 @@ export default function NewTaskScreen() { icon={ } - label={reasoningLabel(reasoning)} + label={ + reasoningOptions.find( + (option) => option.value === reasoning, + )?.name ?? reasoning + } onPress={() => setReasoningSheetOpen(true)} /> ) : null} @@ -715,12 +751,12 @@ export default function NewTaskScreen() { }} onClose={() => setModeSheetOpen(false)} options={EXECUTION_MODES.map((executionMode) => ({ - value: executionMode.value, - label: executionMode.label, + value: executionMode.id, + label: executionMode.name, description: executionMode.description, icon: modeIcon( - executionMode.value, - executionMode.value === "plan" + executionMode.id as ExecutionMode, + executionMode.id === "plan" ? themeColors.accent[11] : themeColors.gray[11], 16, @@ -734,15 +770,16 @@ export default function NewTaskScreen() { value={model} onChange={(value) => { setModel(value); - if (!modelSupportsReasoning(value)) { - setReasoning(DEFAULT_REASONING); + if (!isSupportedReasoningEffort("claude", value, reasoning)) { + setReasoning(DEFAULT_REASONING_EFFORT); } }} onClose={() => setModelSheetOpen(false)} - options={MODELS.map((modelOption) => ({ + options={mobileModelOptions.map((modelOption) => ({ value: modelOption.value, label: modelOption.label, description: modelOption.description, + disabled: modelOption.disabled, icon: , }))} /> @@ -752,14 +789,14 @@ export default function NewTaskScreen() { title="Reasoning" value={reasoning} onChange={(value) => { - const next = value as ReasoningEffort; + const next = value as SupportedReasoningEffort; setReasoning(next); usePreferencesStore.getState().setLastUsedReasoningEffort(next); }} onClose={() => setReasoningSheetOpen(false)} - options={REASONING_LEVELS.map((reasoningLevel) => ({ + options={reasoningOptions.map((reasoningLevel) => ({ value: reasoningLevel.value, - label: reasoningLevel.label, + label: reasoningLevel.name, icon: , }))} /> diff --git a/apps/mobile/src/features/inbox/activityLog.test.ts b/apps/mobile/src/features/inbox/activityLog.test.ts index 8b791296be..8ba0a681fe 100644 --- a/apps/mobile/src/features/inbox/activityLog.test.ts +++ b/apps/mobile/src/features/inbox/activityLog.test.ts @@ -1,3 +1,4 @@ +import type { AnySignalReportArtefact } from "@posthog/shared/domain-types"; import { describe, expect, it } from "vitest"; import { attributionLabel, @@ -6,9 +7,8 @@ import { shortSha, taskRunLabel, } from "./activityLog"; -import type { ReportArtefact } from "./types"; -function commit(id: string, createdAt: string): ReportArtefact { +function commit(id: string, createdAt: string): AnySignalReportArtefact { return { id, type: "commit", @@ -22,7 +22,7 @@ function commit(id: string, createdAt: string): ReportArtefact { }; } -function taskRun(id: string, createdAt: string): ReportArtefact { +function taskRun(id: string, createdAt: string): AnySignalReportArtefact { return { id, type: "task_run", @@ -33,13 +33,13 @@ function taskRun(id: string, createdAt: string): ReportArtefact { describe("selectActivityArtefacts", () => { it("keeps only commit and task_run, sorted oldest-first", () => { - const artefacts: ReportArtefact[] = [ + const artefacts: AnySignalReportArtefact[] = [ taskRun("b", "2026-01-02T00:00:00Z"), { id: "x", type: "note", created_at: "2026-01-03T00:00:00Z", - content: {}, + content: { note: "" }, }, commit("a", "2026-01-01T00:00:00Z"), ]; @@ -51,12 +51,12 @@ describe("selectActivityArtefacts", () => { }); it("returns an empty list when there is no activity", () => { - const artefacts: ReportArtefact[] = [ + const artefacts: AnySignalReportArtefact[] = [ { id: "x", type: "note", created_at: "2026-01-01T00:00:00Z", - content: {}, + content: { note: "" }, }, ]; expect(selectActivityArtefacts(artefacts)).toEqual([]); diff --git a/apps/mobile/src/features/inbox/activityLog.ts b/apps/mobile/src/features/inbox/activityLog.ts index 053b84f04b..cb11aefa22 100644 --- a/apps/mobile/src/features/inbox/activityLog.ts +++ b/apps/mobile/src/features/inbox/activityLog.ts @@ -1,12 +1,12 @@ -import type { ReportArtefact } from "./types"; +import type { AnySignalReportArtefact } from "@posthog/shared/domain-types"; export type ActivityArtefact = Extract< - ReportArtefact, + AnySignalReportArtefact, { type: "commit" | "task_run" } >; export function selectActivityArtefacts( - artefacts: ReportArtefact[], + artefacts: AnySignalReportArtefact[], ): ActivityArtefact[] { return artefacts .filter( diff --git a/apps/mobile/src/features/inbox/api.ts b/apps/mobile/src/features/inbox/api.ts index 9b37b82725..ed6461ce9f 100644 --- a/apps/mobile/src/features/inbox/api.ts +++ b/apps/mobile/src/features/inbox/api.ts @@ -1,14 +1,9 @@ -import { authedFetch, getBaseUrl, getProjectId, HttpError } from "@/lib/api"; -import { logger } from "@/lib/logger"; -import type { DismissalReasonOptionValue } from "./constants"; - -const log = logger.scope("inbox-api"); - +import type { DismissalReasonOptionValue } from "@posthog/shared"; import type { + AnySignalReportArtefact, AvailableSuggestedReviewer, AvailableSuggestedReviewersResponse, CommitDiffResponse, - ReportArtefact, SignalProcessingStateResponse, SignalReport, SignalReportArtefactsResponse, @@ -16,7 +11,11 @@ import type { SignalReportsQueryParams, SignalReportsResponse, SuggestedReviewerWriteEntry, -} from "./types"; +} from "@posthog/shared/domain-types"; +import { authedFetch, getBaseUrl, getProjectId, HttpError } from "@/lib/api"; +import { logger } from "@/lib/logger"; + +const log = logger.scope("inbox-api"); export async function getSignalReports( params?: SignalReportsQueryParams, @@ -172,7 +171,7 @@ export async function getSignalReportArtefacts( } const data = await response.json(); - const results: ReportArtefact[] = data.results ?? []; + const results: AnySignalReportArtefact[] = data.results ?? []; return { results, count: data.count ?? results.length }; } @@ -245,11 +244,11 @@ export async function getSignalReportSignals( reportId, status: response.status, }); - return { signals: [] }; + return { report: null, signals: [] }; } const data = await response.json(); - return { signals: data.signals ?? [] }; + return { report: data.report ?? null, signals: data.signals ?? [] }; } /** Resolve the repository associated with a signal report via its repo_selection artefact. */ diff --git a/apps/mobile/src/features/inbox/components/ArchivedReportList.tsx b/apps/mobile/src/features/inbox/components/ArchivedReportList.tsx index abe60b8a46..a0aed9bc98 100644 --- a/apps/mobile/src/features/inbox/components/ArchivedReportList.tsx +++ b/apps/mobile/src/features/inbox/components/ArchivedReportList.tsx @@ -1,4 +1,7 @@ import { Text } from "@components/text"; +import { inboxStatusLabel } from "@posthog/core/inbox/reportPresentation"; +import { dismissalReasonLabel } from "@posthog/shared"; +import type { SignalReport } from "@posthog/shared/domain-types"; import * as Haptics from "expo-haptics"; import { ArrowCounterClockwise, Tray } from "phosphor-react-native"; import { memo, useCallback, useEffect, useRef, useState } from "react"; @@ -11,13 +14,7 @@ import { } from "react-native"; import { useThemeColors } from "@/lib/theme"; import { useArchivedReports, useRestoreReport } from "../hooks/useInboxReports"; -import type { SignalReport } from "../types"; -import { - dismissalReasonLabel, - formatReportTimestamp, - inboxStatusLabel, - isRestorableReport, -} from "../utils"; +import { formatReportTimestamp, isRestorableReport } from "../utils"; interface ArchivedReportListProps { onReportPress?: (report: SignalReport) => void; diff --git a/apps/mobile/src/features/inbox/components/ArtefactCommit.tsx b/apps/mobile/src/features/inbox/components/ArtefactCommit.tsx index fb176374a6..19f4cc2510 100644 --- a/apps/mobile/src/features/inbox/components/ArtefactCommit.tsx +++ b/apps/mobile/src/features/inbox/components/ArtefactCommit.tsx @@ -1,11 +1,11 @@ import { Text } from "@components/text"; +import type { CommitContent } from "@posthog/shared/domain-types"; import { CaretDown, CaretRight } from "phosphor-react-native"; import { useState } from "react"; import { ActivityIndicator, Pressable, View } from "react-native"; import { useThemeColors } from "@/lib/theme"; import { shortSha } from "../activityLog"; import { useCommitDiff } from "../hooks/useInboxReports"; -import type { CommitContent } from "../types"; import { DiffBlock } from "./DiffBlock"; export function ArtefactCommit({ diff --git a/apps/mobile/src/features/inbox/components/ArtefactTaskRun.tsx b/apps/mobile/src/features/inbox/components/ArtefactTaskRun.tsx index bea2436c62..2c2b004a58 100644 --- a/apps/mobile/src/features/inbox/components/ArtefactTaskRun.tsx +++ b/apps/mobile/src/features/inbox/components/ArtefactTaskRun.tsx @@ -1,11 +1,11 @@ import { Text } from "@components/text"; +import type { TaskRunArtefactContent } from "@posthog/shared/domain-types"; import { useRouter } from "expo-router"; import { CaretRight } from "phosphor-react-native"; import { Pressable, View } from "react-native"; import { useTask } from "@/features/tasks"; import { useThemeColors } from "@/lib/theme"; import { taskRunLabel } from "../activityLog"; -import type { TaskRunArtefactContent } from "../types"; export function ArtefactTaskRun({ content, diff --git a/apps/mobile/src/features/inbox/components/DismissReportSheet.tsx b/apps/mobile/src/features/inbox/components/DismissReportSheet.tsx index 65fc913399..56253e4a2c 100644 --- a/apps/mobile/src/features/inbox/components/DismissReportSheet.tsx +++ b/apps/mobile/src/features/inbox/components/DismissReportSheet.tsx @@ -1,4 +1,8 @@ import { Text } from "@components/text"; +import { + DISMISSAL_REASON_OPTIONS, + type DismissalReasonOptionValue, +} from "@posthog/shared"; import * as Haptics from "expo-haptics"; import { Check } from "phosphor-react-native"; import { useEffect, useState } from "react"; @@ -14,10 +18,6 @@ import { } from "react-native"; import { useScreenInsets } from "@/hooks/useScreenInsets"; import { useThemeColors } from "@/lib/theme"; -import { - DISMISSAL_REASON_OPTIONS, - type DismissalReasonOptionValue, -} from "../constants"; import { useDismissReport } from "../hooks/useInboxReports"; export interface DismissReportResult { diff --git a/apps/mobile/src/features/inbox/components/EditReviewersSheet.tsx b/apps/mobile/src/features/inbox/components/EditReviewersSheet.tsx index 2e30695576..ad1d1e013c 100644 --- a/apps/mobile/src/features/inbox/components/EditReviewersSheet.tsx +++ b/apps/mobile/src/features/inbox/components/EditReviewersSheet.tsx @@ -3,6 +3,10 @@ import { buildReviewerOptions, reviewerMatchesAvailable, } from "@posthog/core/inbox/artefacts"; +import type { + AvailableSuggestedReviewer, + SuggestedReviewer, +} from "@posthog/shared/domain-types"; import { MagnifyingGlass } from "phosphor-react-native"; import { useMemo, useState } from "react"; import { @@ -17,7 +21,6 @@ import { useDebouncedValue } from "@/hooks/useDebouncedValue"; import { useScreenInsets } from "@/hooks/useScreenInsets"; import { useThemeColors } from "@/lib/theme"; import { useAvailableSuggestedReviewers } from "../hooks/useInboxReports"; -import type { AvailableSuggestedReviewer, SuggestedReviewer } from "../types"; import { ReviewerOptionRow } from "./ReviewerOptionRow"; interface EditReviewersSheetProps { diff --git a/apps/mobile/src/features/inbox/components/FilterSheet.tsx b/apps/mobile/src/features/inbox/components/FilterSheet.tsx index 11ed58e5af..47a22b36d8 100644 --- a/apps/mobile/src/features/inbox/components/FilterSheet.tsx +++ b/apps/mobile/src/features/inbox/components/FilterSheet.tsx @@ -1,15 +1,13 @@ import { Text } from "@components/text"; -import { EXTERNAL_INBOX_SOURCES } from "@posthog/shared"; +import { INBOX_PIPELINE_STATUSES } from "@posthog/core/inbox/reportFiltering"; +import { inboxStatusLabel } from "@posthog/core/inbox/reportPresentation"; +import { EXTERNAL_INBOX_SOURCES, type SourceProduct } from "@posthog/shared"; +import type { SignalReportPriority } from "@posthog/shared/domain-types"; import { Check } from "phosphor-react-native"; import { Modal, Pressable, ScrollView, View } from "react-native"; import { useScreenInsets } from "@/hooks/useScreenInsets"; import { useThemeColors } from "@/lib/theme"; -import { - type SourceProduct, - useInboxFilterStore, -} from "../stores/inboxFilterStore"; -import type { SignalReportPriority, SignalReportStatus } from "../types"; -import { inboxStatusLabel } from "../utils"; +import { useInboxFilterStore } from "../stores/inboxFilterStore"; interface FilterSheetProps { visible: boolean; @@ -29,15 +27,6 @@ const SORT_OPTIONS: SortOption[] = [ { label: "Oldest first", field: "created_at", direction: "asc" }, ]; -const FILTERABLE_STATUSES: SignalReportStatus[] = [ - "ready", - "pending_input", - "in_progress", - "failed", - "candidate", - "potential", -]; - function useStatusDotColors(): Record { const themeColors = useThemeColors(); return { @@ -74,9 +63,11 @@ export const SOURCE_PRODUCT_OPTIONS: { value: SourceProduct; label: string }[] = { value: "session_replay", label: "Session replay" }, { value: "error_tracking", label: "Error tracking" }, { value: "llm_analytics", label: "AI observability" }, + { value: "github", label: "GitHub" }, + { value: "linear", label: "Linear" }, + { value: "zendesk", label: "Zendesk" }, { value: "conversations", label: "Conversations" }, { value: "signals_scout", label: "Scout" }, - { value: "health_checks", label: "Health checks" }, ...EXTERNAL_INBOX_SOURCES.map((source) => ({ value: source.product, label: source.label, @@ -141,7 +132,7 @@ export function FilterSheet({ visible, onClose }: FilterSheetProps) { const hasActiveFilters = sourceProductFilter.length > 0 || priorityFilter.length > 0 || - statusFilter.length < FILTERABLE_STATUSES.length; + statusFilter.length < INBOX_PIPELINE_STATUSES.length; return ( - {FILTERABLE_STATUSES.map((status) => ( + {INBOX_PIPELINE_STATUSES.map((status) => ( s.currentIndex); @@ -240,7 +245,8 @@ export function TinderView({ // 3. Create the task const prompt = `Act on this signal report. Investigate the root cause, implement the fix, and open a PR if appropriate.\n\n${report.summary ?? ""}`; - const task = await getPostHogApiClient().createTask({ + const client = getPostHogApiClient(); + const task = await client.createTask({ description: prompt, title: prompt.slice(0, 255), repository: match?.repository ?? repo ?? undefined, @@ -251,10 +257,10 @@ export function TinderView({ } as CreateTaskOptions); // 4. Run it - await runTaskInCloud(task.id, { + await client.runTaskInCloud(task.id, undefined, { pendingUserMessage: prompt, - runtimeAdapter: "claude", - model: DEFAULT_MODEL, + adapter: "claude", + model, initialPermissionMode: "plan", runSource: "signal_report", signalReportId: report.id, @@ -276,6 +282,7 @@ export function TinderView({ }, [ repositoryOptions, + model, showToastPending, showToastDone, acceptReport, @@ -489,7 +496,7 @@ export function TinderView({ setExpandedReport(null); }} className="h-16 w-16 items-center justify-center rounded-full border-2 border-status-success bg-status-success/10 active:bg-status-success/20" - disabled={creating} + disabled={creating || !hasLiveConfig} hitSlop={8} > {creating ? ( diff --git a/apps/mobile/src/features/inbox/constants.ts b/apps/mobile/src/features/inbox/constants.ts deleted file mode 100644 index f15aca7c5b..0000000000 --- a/apps/mobile/src/features/inbox/constants.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Reasons offered when the user dismisses a signal report. - * Mirrors apps/code/src/shared/dismissalReasons.ts. - */ -export const DISMISSAL_REASON_OPTIONS = [ - { - value: "already_fixed", - label: "Already fixed", - snoozesInsteadOfDismiss: true, - }, - { value: "report_unclear", label: "Report is unclear to me" }, - { value: "analysis_wrong", label: "Agent's analysis is wrong" }, - { value: "wontfix_intentional", label: "Won't fix — intentional behavior" }, - { - value: "wontfix_irrelevant", - label: "Won't fix — issue is real but insignificant", - }, - { value: "other", label: "Something else…" }, -] as const; - -export type DismissalReasonOptionValue = - (typeof DISMISSAL_REASON_OPTIONS)[number]["value"]; diff --git a/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.test.ts b/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.test.ts index 2e970def6b..16cd3b1f64 100644 --- a/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.test.ts +++ b/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.test.ts @@ -9,8 +9,8 @@ vi.mock("posthog-react-native", () => ({ usePostHog: () => null, })); +import type { SignalReport } from "@posthog/shared/domain-types"; import { ANALYTICS_EVENTS, type Analytics } from "@/lib/analytics"; -import type { SignalReport } from "../types"; import { type InboxEngagementTracker, type UseInboxEngagementTrackerOptions, diff --git a/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.ts b/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.ts index 67ac03e03a..323f03a938 100644 --- a/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.ts +++ b/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.ts @@ -1,3 +1,4 @@ +import type { SignalReport } from "@posthog/shared/domain-types"; import { useCallback, useEffect, useRef } from "react"; import { ANALYTICS_EVENTS, @@ -7,7 +8,6 @@ import { type InboxReportCloseMethod, type InboxReportOpenMethod, } from "@/lib/analytics"; -import type { SignalReport } from "../types"; interface OpenInfo { reportId: string; diff --git a/apps/mobile/src/features/inbox/hooks/useInboxReports.test.ts b/apps/mobile/src/features/inbox/hooks/useInboxReports.test.ts index 407730a054..ad4abf8190 100644 --- a/apps/mobile/src/features/inbox/hooks/useInboxReports.test.ts +++ b/apps/mobile/src/features/inbox/hooks/useInboxReports.test.ts @@ -1,3 +1,7 @@ +import type { + SignalReport, + SignalReportsResponse, +} from "@posthog/shared/domain-types"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createElement } from "react"; import { act, create } from "react-test-renderer"; @@ -16,7 +20,6 @@ vi.mock("../api", () => ({ getAvailableSuggestedReviewers(query), })); -import type { SignalReport, SignalReportsResponse } from "../types"; import { getReportsNextPageParam, useAvailableSuggestedReviewers, diff --git a/apps/mobile/src/features/inbox/hooks/useInboxReports.ts b/apps/mobile/src/features/inbox/hooks/useInboxReports.ts index ac37c738d1..a2d91f0520 100644 --- a/apps/mobile/src/features/inbox/hooks/useInboxReports.ts +++ b/apps/mobile/src/features/inbox/hooks/useInboxReports.ts @@ -7,6 +7,19 @@ import { INBOX_DISMISSED_STATUS_FILTER, INBOX_REFETCH_INTERVAL_MS, } from "@posthog/core/inbox/reportFiltering"; +import type { + AvailableSuggestedReviewersResponse, + CommitDiffResponse, + SignalProcessingStateResponse, + SignalReport, + SignalReportArtefactsResponse, + SignalReportSignalsResponse, + SignalReportsQueryParams, + SignalReportsResponse, + SuggestedReviewer, + SuggestedReviewersArtefact, + SuggestedReviewerWriteEntry, +} from "@posthog/shared/domain-types"; import { useInfiniteQuery, useMutation, @@ -29,18 +42,6 @@ import { updateSignalReportArtefact, } from "../api"; import { useInboxFilterStore } from "../stores/inboxFilterStore"; -import type { - AvailableSuggestedReviewersResponse, - CommitDiffResponse, - SignalProcessingStateResponse, - SignalReport, - SignalReportArtefactsResponse, - SignalReportSignalsResponse, - SignalReportsQueryParams, - SignalReportsResponse, - SuggestedReviewer, - SuggestedReviewerWriteEntry, -} from "../types"; import { isRestorableReport } from "../utils"; export const inboxKeys = { @@ -264,12 +265,20 @@ export function useUpdateSuggestedReviewers(reportId: string) { if (previous) { queryClient.setQueryData(queryKey, { ...previous, - results: previous.results.map((artefact) => - artefact.id === artefactId && - artefact.type === "suggested_reviewers" - ? { ...artefact, content: optimisticReviewers } - : artefact, - ), + results: previous.results.map((artefact) => { + if ( + artefact.id === artefactId && + artefact.type === "suggested_reviewers" + ) { + const updatedArtefact: SuggestedReviewersArtefact = { + ...artefact, + type: "suggested_reviewers", + content: optimisticReviewers, + }; + return updatedArtefact; + } + return artefact; + }), }); } return { previous }; diff --git a/apps/mobile/src/features/inbox/stores/inboxFilterStore.test.ts b/apps/mobile/src/features/inbox/stores/inboxFilterStore.test.ts index 935407c640..3bfc12271b 100644 --- a/apps/mobile/src/features/inbox/stores/inboxFilterStore.test.ts +++ b/apps/mobile/src/features/inbox/stores/inboxFilterStore.test.ts @@ -1,3 +1,4 @@ +import type { SourceProduct } from "@posthog/shared"; import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("@react-native-async-storage/async-storage", () => ({ @@ -8,7 +9,7 @@ vi.mock("@react-native-async-storage/async-storage", () => ({ }, })); -import { type SourceProduct, useInboxFilterStore } from "./inboxFilterStore"; +import { useInboxFilterStore } from "./inboxFilterStore"; describe("inboxFilterStore", () => { beforeEach(() => { diff --git a/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts b/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts index a0536417da..1f779d7dd7 100644 --- a/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts +++ b/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts @@ -1,13 +1,13 @@ import { INBOX_PIPELINE_STATUSES } from "@posthog/core/inbox/reportFiltering"; import type { SourceProduct } from "@posthog/shared"; -import AsyncStorage from "@react-native-async-storage/async-storage"; -import { create } from "zustand"; -import { createJSONStorage, persist } from "zustand/middleware"; import type { SignalReportOrderingField, SignalReportPriority, SignalReportStatus, -} from "../types"; +} from "@posthog/shared/domain-types"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; type SortField = Extract< SignalReportOrderingField, @@ -16,12 +16,6 @@ type SortField = Extract< type SortDirection = "asc" | "desc"; -export type { SourceProduct }; - -export const DEFAULT_STATUS_FILTER: SignalReportStatus[] = [ - ...INBOX_PIPELINE_STATUSES, -]; - interface InboxFilterState { sortField: SortField; sortDirection: SortDirection; @@ -51,7 +45,7 @@ export const useInboxFilterStore = create()( (set) => ({ sortField: "priority", sortDirection: "asc", - statusFilter: DEFAULT_STATUS_FILTER, + statusFilter: [...INBOX_PIPELINE_STATUSES], sourceProductFilter: [], suggestedReviewerFilter: [], priorityFilter: [], @@ -100,7 +94,7 @@ export const useInboxFilterStore = create()( set({ priorityFilter: Array.from(new Set(priorities)) }), resetFilters: () => set({ - statusFilter: DEFAULT_STATUS_FILTER, + statusFilter: [...INBOX_PIPELINE_STATUSES], sourceProductFilter: [], suggestedReviewerFilter: [], priorityFilter: [], diff --git a/apps/mobile/src/features/inbox/stores/inboxStore.ts b/apps/mobile/src/features/inbox/stores/inboxStore.ts index 7bdb2ec206..32120c07a2 100644 --- a/apps/mobile/src/features/inbox/stores/inboxStore.ts +++ b/apps/mobile/src/features/inbox/stores/inboxStore.ts @@ -1,5 +1,5 @@ +import type { SignalReportOrderingField } from "@posthog/shared/domain-types"; import { create } from "zustand"; -import type { SignalReportOrderingField } from "../types"; type OrderDirection = "asc" | "desc"; diff --git a/apps/mobile/src/features/inbox/types.ts b/apps/mobile/src/features/inbox/types.ts deleted file mode 100644 index 248ab5f86b..0000000000 --- a/apps/mobile/src/features/inbox/types.ts +++ /dev/null @@ -1,209 +0,0 @@ -import type { DismissalReasonOptionValue } from "./constants"; - -export type SignalReportStatus = - | "potential" - | "candidate" - | "in_progress" - | "ready" - | "failed" - | "pending_input" - | "resolved" - | "suppressed" - | "deleted"; - -export type SignalReportPriority = "P0" | "P1" | "P2" | "P3" | "P4"; - -export type SignalReportActionability = - | "immediately_actionable" - | "requires_human_input" - | "not_actionable"; - -export interface SignalReport { - id: string; - title: string | null; - summary: string | null; - status: SignalReportStatus; - total_weight: number; - signal_count: number; - signals_at_run?: number; - created_at: string; - updated_at: string; - artefact_count: number; - priority?: SignalReportPriority | null; - actionability?: SignalReportActionability | null; - already_addressed?: boolean | null; - dismissal_reason?: DismissalReasonOptionValue | null; - dismissal_note?: string | null; - is_suggested_reviewer?: boolean; - source_products?: string[]; - implementation_pr_url?: string | null; -} - -export interface SignalReportsResponse { - results: SignalReport[]; - count: number; -} - -export type SignalReportOrderingField = - | "priority" - | "signal_count" - | "total_weight" - | "created_at" - | "updated_at"; - -export interface SignalReportsQueryParams { - limit?: number; - offset?: number; - status?: string; - ordering?: string; - source_product?: string; - suggested_reviewers?: string; - priority?: string; -} - -export interface SignalProcessingStateResponse { - paused_until: string | null; -} - -export interface AvailableSuggestedReviewer { - uuid: string; - name: string; - email: string; - github_login: string; -} - -export interface AvailableSuggestedReviewersResponse { - results: AvailableSuggestedReviewer[]; - count: number; -} - -export interface Signal { - signal_id: string; - content: string; - source_product: string; - source_type: string; - source_id: string; - weight: number; - timestamp: string; - extra: Record; -} - -export interface SignalFindingContent { - signal_id: string; - relevant_code_paths: string[]; - relevant_commit_hashes: Record; - data_queried: string; - verified: boolean; -} - -export interface PriorityJudgmentContent { - explanation: string; - priority: SignalReportPriority; -} - -export interface ActionabilityJudgmentContent { - explanation: string; - actionability: SignalReportActionability; - already_addressed: boolean; -} - -export interface SuggestedReviewerCommit { - sha: string; - url: string; - reason: string; -} - -export interface SuggestedReviewerUser { - id: number; - uuid: string; - email: string; - first_name: string; - last_name: string; -} - -export interface SuggestedReviewer { - github_login: string; - github_name: string | null; - relevant_commits: SuggestedReviewerCommit[]; - user: SuggestedReviewerUser | null; -} - -export interface SuggestedReviewersArtefact { - id: string; - type: "suggested_reviewers"; - created_at: string; - content: SuggestedReviewer[]; -} - -/** - * Write shape for replacing the suggested_reviewers artefact. The server - * canonicalizes to a lowercase `github_login`, with `user_uuid` winning when - * both are supplied. - */ -export interface SuggestedReviewerWriteEntry { - github_login?: string; - user_uuid?: string; - github_name?: string; -} - -export interface ArtefactUser { - uuid?: string; - email: string; - first_name?: string; - last_name?: string; -} - -export interface CommitContent { - repository: string; - branch: string; - commit_sha: string; - message: string; - note?: string | null; -} - -export interface TaskRunArtefactContent { - task_id: string; - product: string; - type: string; -} - -export interface CommitDiffResponse { - diff: string; - truncated: boolean; -} - -/** - * Fields shared by every artefact row. `created_by` / `task_id` carry - * attribution: at most one is set — `created_by` for user writes, `task_id` - * for agent writes, neither for system writes. - */ -interface BaseArtefact { - id: string; - created_at: string; - created_by?: ArtefactUser | null; - task_id?: string | null; -} - -export type ReportArtefact = - | (BaseArtefact & { - type: "priority_judgment"; - content: PriorityJudgmentContent; - }) - | (BaseArtefact & { - type: "actionability_judgment"; - content: ActionabilityJudgmentContent; - }) - | (BaseArtefact & { type: "signal_finding"; content: SignalFindingContent }) - | (BaseArtefact & { type: "commit"; content: CommitContent }) - | (BaseArtefact & { type: "task_run"; content: TaskRunArtefactContent }) - | (BaseArtefact & SuggestedReviewersArtefact) - | (BaseArtefact & { type: string; content: unknown }); - -export interface SignalReportArtefactsResponse { - results: ReportArtefact[]; - count: number; -} - -export interface SignalReportSignalsResponse { - signals: Signal[]; -} diff --git a/apps/mobile/src/features/inbox/utils.test.ts b/apps/mobile/src/features/inbox/utils.test.ts index ad56b19305..ce8b2b43af 100644 --- a/apps/mobile/src/features/inbox/utils.test.ts +++ b/apps/mobile/src/features/inbox/utils.test.ts @@ -1,9 +1,20 @@ +import { + buildArchiveListOrdering, + buildPriorityFilterParam, + buildSignalReportListOrdering, + INBOX_PIPELINE_STATUSES, +} from "@posthog/core/inbox/reportFiltering"; +import { formatSignalReportSummaryMarkdown } from "@posthog/core/inbox/reportPresentation"; +import { dismissalReasonLabel } from "@posthog/shared"; +import type { + Signal, + SignalReport, + SignalReportOrderingField, + SignalReportStatus, +} from "@posthog/shared/domain-types"; import { describe, expect, it } from "vitest"; -import type { Signal, SignalReport, SignalReportStatus } from "./types"; import { buildInboxViewedProperties, - dismissalReasonLabel, - formatSignalReportSummaryMarkdown, isRestorableReport, sourceLine, } from "./utils"; @@ -21,15 +32,6 @@ function signal(source_product: string, source_type: string): Signal { }; } -const DEFAULT_STATUS_FILTER: SignalReportStatus[] = [ - "ready", - "pending_input", - "in_progress", - "failed", - "candidate", - "potential", -]; - function makeReport( partial: Partial & Pick, ): SignalReport { @@ -88,10 +90,10 @@ describe("buildInboxViewedProperties", () => { it("emits zero counts for an empty list", () => { const props = buildInboxViewedProperties([], 0, { sourceProductFilter: [], - statusFilter: DEFAULT_STATUS_FILTER, + statusFilter: INBOX_PIPELINE_STATUSES, suggestedReviewerFilter: [], priorityFilter: [], - defaultStatusFilter: DEFAULT_STATUS_FILTER, + defaultStatusFilter: INBOX_PIPELINE_STATUSES, }); expect(props).toMatchObject({ report_count: 0, @@ -137,10 +139,10 @@ describe("buildInboxViewedProperties", () => { const props = buildInboxViewedProperties(reports, 4, { sourceProductFilter: [], - statusFilter: DEFAULT_STATUS_FILTER, + statusFilter: INBOX_PIPELINE_STATUSES, suggestedReviewerFilter: [], priorityFilter: [], - defaultStatusFilter: DEFAULT_STATUS_FILTER, + defaultStatusFilter: INBOX_PIPELINE_STATUSES, }); expect(props.report_count).toBe(4); @@ -161,36 +163,36 @@ describe("buildInboxViewedProperties", () => { statusFilter: ["ready"], suggestedReviewerFilter: [], priorityFilter: [], - defaultStatusFilter: DEFAULT_STATUS_FILTER, + defaultStatusFilter: INBOX_PIPELINE_STATUSES, }); expect(narrowed.has_active_filters).toBe(true); expect(narrowed.status_filter_count).toBe(1); const sourced = buildInboxViewedProperties([], 0, { sourceProductFilter: ["error_tracking"], - statusFilter: DEFAULT_STATUS_FILTER, + statusFilter: INBOX_PIPELINE_STATUSES, suggestedReviewerFilter: [], priorityFilter: [], - defaultStatusFilter: DEFAULT_STATUS_FILTER, + defaultStatusFilter: INBOX_PIPELINE_STATUSES, }); expect(sourced.has_active_filters).toBe(true); expect(sourced.source_product_filter).toEqual(["error_tracking"]); const reviewer = buildInboxViewedProperties([], 0, { sourceProductFilter: [], - statusFilter: DEFAULT_STATUS_FILTER, + statusFilter: INBOX_PIPELINE_STATUSES, suggestedReviewerFilter: ["uuid-1"], priorityFilter: [], - defaultStatusFilter: DEFAULT_STATUS_FILTER, + defaultStatusFilter: INBOX_PIPELINE_STATUSES, }); expect(reviewer.has_active_filters).toBe(true); const prioritized = buildInboxViewedProperties([], 0, { sourceProductFilter: [], - statusFilter: DEFAULT_STATUS_FILTER, + statusFilter: INBOX_PIPELINE_STATUSES, suggestedReviewerFilter: [], priorityFilter: ["P0"], - defaultStatusFilter: DEFAULT_STATUS_FILTER, + defaultStatusFilter: INBOX_PIPELINE_STATUSES, }); expect(prioritized.has_active_filters).toBe(true); }); @@ -198,15 +200,89 @@ describe("buildInboxViewedProperties", () => { it("treats a reordered default status set as not filtered", () => { const props = buildInboxViewedProperties([], 0, { sourceProductFilter: [], - statusFilter: [...DEFAULT_STATUS_FILTER].reverse(), + statusFilter: [...INBOX_PIPELINE_STATUSES].reverse(), suggestedReviewerFilter: [], priorityFilter: [], - defaultStatusFilter: DEFAULT_STATUS_FILTER, + defaultStatusFilter: INBOX_PIPELINE_STATUSES, }); expect(props.has_active_filters).toBe(false); }); }); +describe("buildSignalReportListOrdering", () => { + it.each([ + { + field: "priority" as SignalReportOrderingField, + direction: "desc" as const, + expected: "status,-priority,-created_at", + }, + { + field: "priority" as SignalReportOrderingField, + direction: "asc" as const, + expected: "status,priority,-created_at", + }, + { + field: "signal_count" as SignalReportOrderingField, + direction: "desc" as const, + expected: "status,-signal_count,priority", + }, + { + field: "total_weight" as SignalReportOrderingField, + direction: "asc" as const, + expected: "status,total_weight,priority", + }, + { + field: "created_at" as SignalReportOrderingField, + direction: "desc" as const, + expected: "status,-created_at,priority", + }, + { + field: "updated_at" as SignalReportOrderingField, + direction: "asc" as const, + expected: "status,updated_at,priority", + }, + ])( + "orders $field $direction as $expected", + ({ field, direction, expected }) => { + expect(buildSignalReportListOrdering(field, direction)).toBe(expected); + }, + ); +}); + +describe("buildPriorityFilterParam", () => { + it.each([ + { + name: "returns undefined for an empty selection", + input: [], + expected: undefined, + }, + { + name: "joins selected priorities with commas", + input: ["P0", "P2"] as const, + expected: "P0,P2", + }, + { + name: "dedupes repeated priorities", + input: ["P1", "P1", "P3"] as const, + expected: "P1,P3", + }, + ])("$name", ({ input, expected }) => { + expect(buildPriorityFilterParam([...input])).toBe(expected); + }); +}); + +describe("buildArchiveListOrdering", () => { + it.each([ + { direction: "desc" as const, expected: "-updated_at" }, + { direction: "asc" as const, expected: "updated_at" }, + ])( + "sorts by field without a status prefix ($direction)", + ({ direction, expected }) => { + expect(buildArchiveListOrdering("updated_at", direction)).toBe(expected); + }, + ); +}); + describe("isRestorableReport", () => { it.each([ { status: "suppressed" as SignalReportStatus, expected: true }, diff --git a/apps/mobile/src/features/inbox/utils.ts b/apps/mobile/src/features/inbox/utils.ts index b0040ddcef..13a8e4c80b 100644 --- a/apps/mobile/src/features/inbox/utils.ts +++ b/apps/mobile/src/features/inbox/utils.ts @@ -2,15 +2,14 @@ import { EXTERNAL_INBOX_SOURCE_BY_PRODUCT, type SourceProduct, } from "@posthog/shared"; -import { differenceInHours, format, formatDistanceToNow } from "date-fns"; -import type { InboxViewedProperties } from "@/lib/analytics"; -import { DISMISSAL_REASON_OPTIONS } from "./constants"; import type { Signal, SignalReport, SignalReportPriority, SignalReportStatus, -} from "./types"; +} from "@posthog/shared/domain-types"; +import { differenceInHours, format, formatDistanceToNow } from "date-fns"; +import type { InboxViewedProperties } from "@/lib/analytics"; const ERROR_TRACKING_TYPE_LABELS: Record = { issue_created: "New issue", @@ -46,34 +45,7 @@ export function sourceLine(signal: Signal): string { const warehouseSource = EXTERNAL_INBOX_SOURCE_BY_PRODUCT[source_product as SourceProduct]; const product = warehouseSource?.label ?? source_product.replace(/_/g, " "); - const type = source_type.replace(/_/g, " "); - return `${product} · ${type}`; -} - -const SIGNAL_SUMMARY_SECTION_HEADERS = [ - "What's happening", - "Root cause", - "How to resolve", -] as const; - -/** - * Inserts blank lines around signal report summary section headers so each - * label and its body render on their own line (agent output often packs them - * together, e.g. `**What's happening:** text **Root cause:** ...`). - */ -export function formatSignalReportSummaryMarkdown(content: string): string { - let result = content; - - for (const header of SIGNAL_SUMMARY_SECTION_HEADERS) { - const boldHeader = `\\*\\*${header}:\\*\\*`; - result = result.replace( - new RegExp(`([^\\n])\\s*(${boldHeader})`, "gi"), - "$1\n\n$2", - ); - result = result.replace(new RegExp(`(${boldHeader})\\s+`, "gi"), "$1\n\n"); - } - - return result; + return `${product} · ${source_type.replace(/_/g, " ")}`; } /** Relative time for the last day, absolute "MMM d" beyond it. */ @@ -93,38 +65,6 @@ export function isRestorableReport( return report.status === "suppressed"; } -/** Human label for a persisted dismissal reason, falling back to the raw code. */ -export function dismissalReasonLabel(value: string): string { - return ( - DISMISSAL_REASON_OPTIONS.find((o) => o.value === value)?.label ?? value - ); -} - -export function inboxStatusLabel(status: SignalReportStatus): string { - switch (status) { - case "ready": - return "Ready"; - case "resolved": - return "Resolved"; - case "pending_input": - return "Needs input"; - case "in_progress": - return "Researching"; - case "candidate": - return "Queued"; - case "potential": - return "Gathering"; - case "failed": - return "Failed"; - case "suppressed": - return "Suppressed"; - case "deleted": - return "Deleted"; - default: - return status; - } -} - /** * Returns only reports that are actionable for the tinder-like card deck: * ready, immediately actionable, not already addressed. @@ -140,11 +80,11 @@ export function getActionableReports(reports: SignalReport[]): SignalReport[] { interface InboxViewedFilterState { sourceProductFilter: string[]; - statusFilter: SignalReportStatus[]; + statusFilter: readonly SignalReportStatus[]; suggestedReviewerFilter: string[]; priorityFilter: SignalReportPriority[]; /** Default status filter as defined in the filter store, used to detect whether the user has narrowed it. */ - defaultStatusFilter: SignalReportStatus[]; + defaultStatusFilter: readonly SignalReportStatus[]; } /** diff --git a/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx b/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx index 4297268370..b61e6f732e 100644 --- a/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx +++ b/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx @@ -1,4 +1,16 @@ import { Text } from "@components/text"; +import { + DEFAULT_CLAUDE_EXECUTION_MODE, + getAvailableModes, +} from "@posthog/core/sessions/executionModes"; +import { + DEFAULT_GATEWAY_MODEL, + DEFAULT_REASONING_EFFORT, + type ExecutionMode, + getReasoningEffortOptions, + isSupportedReasoningEffort, + type SupportedReasoningEffort, +} from "@posthog/shared"; import * as Haptics from "expo-haptics"; import { ArrowUp, @@ -32,6 +44,7 @@ import { View, } from "react-native"; import { useVoiceRecording } from "@/features/chat"; +import { useCloudTaskConfigOptions } from "@/features/tasks/hooks/useCloudTaskConfigOptions"; import { logger } from "@/lib/logger"; import { useThemeColors } from "@/lib/theme"; import type { MessagingMode } from "../stores/messagingModeStore"; @@ -44,18 +57,10 @@ import { } from "./attachments/pickers"; import type { PendingAttachment } from "./attachments/types"; import { - DEFAULT_EXECUTION_MODE, - DEFAULT_MODEL, - DEFAULT_REASONING, - EXECUTION_MODES, - type ExecutionMode, - MODELS, - modeLabel, - modelLabel, - modelSupportsReasoning, - REASONING_LEVELS, - type ReasoningEffort, - reasoningLabel, + getMobileModelOptions, + getModelConfigOption, + getModelLabel, + resolveAvailableModel, } from "./options"; import { Pill } from "./Pill"; import { SelectSheet } from "./SelectSheet"; @@ -66,6 +71,7 @@ import { } from "./submitComposerMessage"; const log = logger.scope("task-chat-composer"); +const EXECUTION_MODES = getAvailableModes(); interface TaskChatComposerProps { onSend: ( @@ -80,10 +86,10 @@ interface TaskChatComposerProps { /** Current pill values (persisted per-task by the caller). */ mode: ExecutionMode; model: string; - reasoning: ReasoningEffort; + reasoning: SupportedReasoningEffort; onModeChange: (mode: ExecutionMode) => void; onModelChange: (model: string) => void; - onReasoningChange: (reasoning: ReasoningEffort) => void; + onReasoningChange: (reasoning: SupportedReasoningEffort) => void; /** Steer vs Queue behaviour for messages sent while a turn is running. */ messagingMode: MessagingMode; queuedCount: number; @@ -103,6 +109,11 @@ function modeIcon(mode: ExecutionMode, color: string, size = 14): ReactNode { return ; case "acceptEdits": return ; + case "bypassPermissions": + case "full-access": + return ; + case "read-only": + return ; case "auto": return ; } @@ -183,6 +194,9 @@ export function TaskChatComposer({ onCancelEdit, }: TaskChatComposerProps) { const themeColors = useThemeColors(); + const { configOptions, hasLiveConfig } = useCloudTaskConfigOptions("claude"); + const modelConfigOption = getModelConfigOption(configOptions); + const mobileModelOptions = getMobileModelOptions(modelConfigOption); const [message, setMessage] = useState(() => initialMessage ?? ""); const [attachments, setAttachments] = useState([]); const [attachmentSheetOpen, setAttachmentSheetOpen] = useState(false); @@ -206,6 +220,23 @@ export function TaskChatComposer({ setAttachments(restoredDraft.attachments); }, [restoredDraft]); + useEffect(() => { + if (!hasLiveConfig) return; + const availableModel = resolveAvailableModel(modelConfigOption, model); + if (availableModel === model) return; + onModelChange(availableModel); + if (!isSupportedReasoningEffort("claude", availableModel, reasoning)) { + onReasoningChange(DEFAULT_REASONING_EFFORT); + } + }, [ + hasLiveConfig, + model, + modelConfigOption, + onModelChange, + onReasoningChange, + reasoning, + ]); + const appendTranscript = useCallback((transcript: string) => { setMessage((prev) => (prev ? `${prev} ${transcript}` : transcript)); }, []); @@ -220,7 +251,8 @@ export function TaskChatComposer({ const [modelSheetOpen, setModelSheetOpen] = useState(false); const [reasoningSheetOpen, setReasoningSheetOpen] = useState(false); - const showReasoningPill = modelSupportsReasoning(model); + const reasoningOptions = getReasoningEffortOptions("claude", model) ?? []; + const showReasoningPill = reasoningOptions.length > 0; const hasContent = !isComposerEmpty({ text: message, attachments }); const canSend = hasContent && !disabled && !isRecording; @@ -399,21 +431,28 @@ export function TaskChatComposer({ ? themeColors.accent[11] : themeColors.gray[11], )} - label={modeLabel(mode)} + label={ + EXECUTION_MODES.find((option) => option.id === mode) + ?.name ?? mode + } accent={mode === "plan"} onPress={() => setModeSheetOpen(true)} /> } - label={modelLabel(model)} + label={getModelLabel(modelConfigOption, model)} onPress={() => setModelSheetOpen(true)} /> {showReasoningPill ? ( } - label={reasoningLabel(reasoning)} + label={ + reasoningOptions.find( + (option) => option.value === reasoning, + )?.name ?? reasoning + } onPress={() => setReasoningSheetOpen(true)} /> ) : null} @@ -462,12 +501,12 @@ export function TaskChatComposer({ onChange={(v) => onModeChange(v as ExecutionMode)} onClose={() => setModeSheetOpen(false)} options={EXECUTION_MODES.map((m) => ({ - value: m.value, - label: m.label, + value: m.id, + label: m.name, description: m.description, icon: modeIcon( - m.value, - m.value === "plan" ? themeColors.accent[11] : themeColors.gray[11], + m.id as ExecutionMode, + m.id === "plan" ? themeColors.accent[11] : themeColors.gray[11], 16, ), }))} @@ -479,18 +518,16 @@ export function TaskChatComposer({ value={model} onChange={(v) => { onModelChange(v); - // If the new model doesn't support reasoning, drop the level so the - // payload stays consistent. Default reasoning re-applies when - // switching back to a reasoning-capable model. - if (!modelSupportsReasoning(v)) { - onReasoningChange(DEFAULT_REASONING); + if (!isSupportedReasoningEffort("claude", v, reasoning)) { + onReasoningChange(DEFAULT_REASONING_EFFORT); } }} onClose={() => setModelSheetOpen(false)} - options={MODELS.map((m) => ({ + options={mobileModelOptions.map((m) => ({ value: m.value, label: m.label, description: m.description, + disabled: m.disabled, icon: , }))} /> @@ -499,11 +536,11 @@ export function TaskChatComposer({ open={reasoningSheetOpen} title="Reasoning" value={reasoning} - onChange={(v) => onReasoningChange(v as ReasoningEffort)} + onChange={(v) => onReasoningChange(v as SupportedReasoningEffort)} onClose={() => setReasoningSheetOpen(false)} - options={REASONING_LEVELS.map((r) => ({ + options={reasoningOptions.map((r) => ({ value: r.value, - label: r.label, + label: r.name, icon: , }))} /> @@ -520,7 +557,7 @@ export function TaskChatComposer({ } export const TASK_CHAT_DEFAULTS = { - mode: DEFAULT_EXECUTION_MODE, - model: DEFAULT_MODEL, - reasoning: DEFAULT_REASONING, + mode: DEFAULT_CLAUDE_EXECUTION_MODE, + model: DEFAULT_GATEWAY_MODEL, + reasoning: DEFAULT_REASONING_EFFORT, } as const; diff --git a/apps/mobile/src/features/tasks/composer/attachments/cloudPrompt.ts b/apps/mobile/src/features/tasks/composer/attachments/cloudPrompt.ts deleted file mode 100644 index 35f885cdc7..0000000000 --- a/apps/mobile/src/features/tasks/composer/attachments/cloudPrompt.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { CloudPromptBlock } from "./types"; - -/** - * Wire format prefix shared with `packages/shared/src/cloud-prompt.ts`. The - * backend's `deserializeCloudPrompt` looks for this prefix and decodes the - * trailing JSON as `{ blocks: ContentBlock[] }`. Plain-text prompts without - * attachments are sent as strings (no prefix) so chat echoes stay readable. - */ -export const CLOUD_PROMPT_PREFIX = "__twig_cloud_prompt_v1__:"; - -export function serializeCloudPrompt(blocks: CloudPromptBlock[]): string { - if (blocks.length === 1 && blocks[0].type === "text") { - return blocks[0].text.trim(); - } - return `${CLOUD_PROMPT_PREFIX}${JSON.stringify({ blocks })}`; -} diff --git a/apps/mobile/src/features/tasks/composer/options.test.ts b/apps/mobile/src/features/tasks/composer/options.test.ts index 75328ebf04..c155d51849 100644 --- a/apps/mobile/src/features/tasks/composer/options.test.ts +++ b/apps/mobile/src/features/tasks/composer/options.test.ts @@ -1,27 +1,68 @@ +import { + type CloudTaskConfigOption, + DEFAULT_GATEWAY_MODEL, + restrictedModelMeta, +} from "@posthog/shared"; import { describe, expect, it } from "vitest"; import { - DEFAULT_MODEL, - DEFAULT_REASONING, - modelSupportsReasoning, - REASONING_LEVELS, + getMobileModelOptions, + getModelConfigOption, + getModelLabel, + resolveAvailableModel, } from "./options"; -describe("task composer options", () => { - it("uses an eligible non-premium default model", () => { - expect(DEFAULT_MODEL).toBe("claude-opus-4-8"); - expect(DEFAULT_MODEL).not.toContain("fable"); - }); +const modelOption: CloudTaskConfigOption = { + id: "model", + name: "Model", + type: "select", + currentValue: DEFAULT_GATEWAY_MODEL, + options: [ + { + value: DEFAULT_GATEWAY_MODEL, + name: "Claude Opus 4.8", + description: "Default", + }, + { + value: "claude-fable-5", + name: "Claude Fable 5", + _meta: restrictedModelMeta(), + }, + ], + category: "model", + description: "Choose a model", +}; - it("derives reasoning defaults and options from shared policy", () => { - expect(DEFAULT_REASONING).toBe("high"); - expect(REASONING_LEVELS.map((option) => option.value)).toEqual([ - "low", - "medium", - "high", - "xhigh", - "max", +describe("mobile cloud task model options", () => { + it("adapts live model options and disables restricted entries", () => { + expect(getMobileModelOptions(modelOption)).toEqual([ + { + value: DEFAULT_GATEWAY_MODEL, + label: "Claude Opus 4.8", + description: "Default", + disabled: false, + }, + { + value: "claude-fable-5", + label: "Claude Fable 5", + description: undefined, + disabled: true, + }, ]); - expect(modelSupportsReasoning("claude-opus-4-8")).toBe(true); - expect(modelSupportsReasoning("claude-haiku-4-5")).toBe(false); + }); + + it("falls back from restricted or missing selections", () => { + expect(resolveAvailableModel(modelOption, "claude-fable-5")).toBe( + DEFAULT_GATEWAY_MODEL, + ); + expect(resolveAvailableModel(modelOption, "missing-model")).toBe( + DEFAULT_GATEWAY_MODEL, + ); + }); + + it("reads the live model label and config option", () => { + expect(getModelConfigOption([modelOption])).toBe(modelOption); + expect(getModelLabel(modelOption, DEFAULT_GATEWAY_MODEL)).toBe( + "Claude Opus 4.8", + ); }); }); diff --git a/apps/mobile/src/features/tasks/composer/options.ts b/apps/mobile/src/features/tasks/composer/options.ts index 572fff4a35..1db34b57a1 100644 --- a/apps/mobile/src/features/tasks/composer/options.ts +++ b/apps/mobile/src/features/tasks/composer/options.ts @@ -1,107 +1,56 @@ import { - DEFAULT_CLAUDE_EXECUTION_MODE, - getAvailableModes, -} from "@posthog/core/sessions/executionModes"; -import { - DEFAULT_GATEWAY_MODEL, - DEFAULT_REASONING_EFFORT, - defaultEligibleModel, - getReasoningEffortOptions, - type ExecutionMode as SharedExecutionMode, - type SupportedReasoningEffort, + type CloudTaskConfigOption, + isRestrictedModelOption, } from "@posthog/shared"; -export type ExecutionMode = Extract< - SharedExecutionMode, - "default" | "acceptEdits" | "plan" | "auto" ->; -export type ReasoningEffort = SupportedReasoningEffort; - -export const EXECUTION_MODES: { - value: ExecutionMode; - label: string; - description: string; -}[] = getAvailableModes() - .filter( - (mode): mode is typeof mode & { id: ExecutionMode } => - mode.id === "default" || - mode.id === "acceptEdits" || - mode.id === "plan" || - mode.id === "auto", - ) - .map((mode) => ({ - value: mode.id, - label: mode.name, - description: mode.description, - })); - -export interface ModelOption { +export interface MobileModelOption { value: string; label: string; description?: string; - supportsReasoning: boolean; + disabled: boolean; } -export const MODELS: ModelOption[] = [ - { - value: "claude-fable-5", - label: "Claude Fable 5", - description: "Newest, most capable", - supportsReasoning: true, - }, - { - value: "claude-opus-5", - label: "Claude Opus 5", - description: "Most capable, slower", - supportsReasoning: true, - }, - { - value: "claude-opus-4-8", - label: "Claude Opus 4.8", - description: "Previous Opus generation", - supportsReasoning: true, - }, - { - value: "claude-sonnet-5", - label: "Claude Sonnet 5", - description: "Balanced, fast", - supportsReasoning: true, - }, - { - value: "claude-sonnet-4-6", - label: "Claude Sonnet 4.6", - description: "Balanced", - supportsReasoning: true, - }, -]; - -export const DEFAULT_EXECUTION_MODE: ExecutionMode = - DEFAULT_CLAUDE_EXECUTION_MODE; -export const DEFAULT_MODEL = - defaultEligibleModel(DEFAULT_GATEWAY_MODEL) ?? - MODELS.find((model) => defaultEligibleModel(model.value))?.value ?? - DEFAULT_GATEWAY_MODEL; -export const DEFAULT_REASONING: ReasoningEffort = DEFAULT_REASONING_EFFORT; - -export const REASONING_LEVELS: { - value: ReasoningEffort; - label: string; -}[] = (getReasoningEffortOptions("claude", DEFAULT_MODEL) ?? []).map( - (option) => ({ value: option.value, label: option.name }), -); - -export function modelLabel(value: string): string { - return MODELS.find((m) => m.value === value)?.label ?? value; +export function getModelConfigOption( + configOptions: readonly CloudTaskConfigOption[], +): CloudTaskConfigOption { + const modelOption = configOptions.find( + (option) => option.category === "model", + ); + if (!modelOption) { + throw new Error("Cloud task model configuration is unavailable"); + } + return modelOption; } -export function modeLabel(value: ExecutionMode): string { - return EXECUTION_MODES.find((m) => m.value === value)?.label ?? value; +export function getMobileModelOptions( + modelOption: CloudTaskConfigOption, +): MobileModelOption[] { + return modelOption.options.map((option) => ({ + value: option.value, + label: option.name, + description: option.description, + disabled: isRestrictedModelOption(option._meta), + })); } -export function reasoningLabel(value: ReasoningEffort): string { - return REASONING_LEVELS.find((r) => r.value === value)?.label ?? value; +export function getModelLabel( + modelOption: CloudTaskConfigOption, + value: string, +): string { + return ( + modelOption.options.find((option) => option.value === value)?.name ?? value + ); } -export function modelSupportsReasoning(value: string): boolean { - return getReasoningEffortOptions("claude", value) !== null; +export function resolveAvailableModel( + modelOption: CloudTaskConfigOption, + value: string, +): string { + const selectedOption = modelOption.options.find( + (option) => option.value === value, + ); + if (selectedOption && !isRestrictedModelOption(selectedOption._meta)) { + return value; + } + return modelOption.currentValue; } diff --git a/apps/mobile/src/features/tasks/hooks/useAutomations.test.ts b/apps/mobile/src/features/tasks/hooks/useAutomations.test.ts index 772d9a4f69..fb47817a8c 100644 --- a/apps/mobile/src/features/tasks/hooks/useAutomations.test.ts +++ b/apps/mobile/src/features/tasks/hooks/useAutomations.test.ts @@ -8,36 +8,28 @@ const { mockGetTaskAutomations, mockCreateTaskAutomation, mockUpdateTaskAutomation, - mockApiClient, } = vi.hoisted(() => ({ mockUseAuthStore: vi.fn(), mockGetTaskAutomations: vi.fn(), mockCreateTaskAutomation: vi.fn(), mockUpdateTaskAutomation: vi.fn(), - mockApiClient: { - listTaskAutomations: vi.fn(), - getTaskAutomation: vi.fn(), - createTaskAutomation: vi.fn(), - updateTaskAutomation: vi.fn(), - deleteTaskAutomation: vi.fn(), - runTaskAutomation: vi.fn(), - }, })); vi.mock("@/features/auth", () => ({ useAuthStore: mockUseAuthStore, })); -vi.mock("../api", () => ({ runTaskInCloud: vi.fn() })); - vi.mock("@/lib/posthogApiClient", () => ({ - getPostHogApiClient: () => mockApiClient, + getPostHogApiClient: vi.fn(() => ({ + listTaskAutomations: mockGetTaskAutomations, + getTaskAutomation: vi.fn(), + createTaskAutomation: mockCreateTaskAutomation, + updateTaskAutomation: mockUpdateTaskAutomation, + deleteTaskAutomation: vi.fn(), + runTaskAutomation: vi.fn(), + })), })); -mockApiClient.listTaskAutomations = mockGetTaskAutomations; -mockApiClient.createTaskAutomation = mockCreateTaskAutomation; -mockApiClient.updateTaskAutomation = mockUpdateTaskAutomation; - import { automationKeys, getAutomationPollingInterval, diff --git a/apps/mobile/src/features/tasks/hooks/useCloudTaskConfigOptions.test.ts b/apps/mobile/src/features/tasks/hooks/useCloudTaskConfigOptions.test.ts new file mode 100644 index 0000000000..4b516f1445 --- /dev/null +++ b/apps/mobile/src/features/tasks/hooks/useCloudTaskConfigOptions.test.ts @@ -0,0 +1,121 @@ +import { + type CloudTaskConfigOption, + DEFAULT_GATEWAY_MODEL, +} from "@posthog/shared"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createElement, type PropsWithChildren } from "react"; +import { act, create } from "react-test-renderer"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockGetCloudTaskConfigOptions, mockUseAuthStore } = vi.hoisted(() => ({ + mockGetCloudTaskConfigOptions: vi.fn(), + mockUseAuthStore: vi.fn(), +})); + +vi.mock("posthog-react-native", () => ({ + useFeatureFlag: () => false, +})); + +vi.mock("@/features/auth", () => ({ + useAuthStore: mockUseAuthStore, +})); + +vi.mock("@/lib/posthogApiClient", () => ({ + getPostHogApiClient: () => ({ + getCloudTaskConfigOptions: mockGetCloudTaskConfigOptions, + }), +})); + +import { getModelConfigOption } from "../composer/options"; +import { useCloudTaskConfigOptions } from "./useCloudTaskConfigOptions"; + +function createWrapper(queryClient: QueryClient) { + return function Wrapper({ children }: PropsWithChildren) { + return createElement( + QueryClientProvider, + { client: queryClient }, + children, + ); + }; +} + +async function renderHook() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + let currentResult: ReturnType; + + function HookProbe() { + currentResult = useCloudTaskConfigOptions("claude"); + return null; + } + + const Wrapper = createWrapper(queryClient); + await act(async () => { + create(createElement(Wrapper, null, createElement(HookProbe))); + await Promise.resolve(); + }); + + return { + get current() { + return currentResult; + }, + }; +} + +async function waitForAssertion(assertion: () => void): Promise { + const timeoutAt = Date.now() + 2_000; + while (Date.now() < timeoutAt) { + try { + assertion(); + return; + } catch (error) { + await new Promise((resolve) => setTimeout(resolve, 10)); + if (Date.now() >= timeoutAt) throw error; + } + } +} + +describe("useCloudTaskConfigOptions", () => { + beforeEach(() => { + mockGetCloudTaskConfigOptions.mockReset(); + mockUseAuthStore.mockImplementation((selector) => + selector({ oauthAccessToken: "token" }), + ); + }); + + it("uses the authenticated live Claude catalog", async () => { + const liveOptions: CloudTaskConfigOption[] = [ + { + id: "model", + name: "Model", + type: "select", + currentValue: "claude-sonnet-5", + options: [{ value: "claude-sonnet-5", name: "Claude Sonnet 5" }], + category: "model", + description: "Choose a model", + }, + ]; + mockGetCloudTaskConfigOptions.mockResolvedValue(liveOptions); + + const result = await renderHook(); + await waitForAssertion(() => { + expect(result.current.configOptions).toEqual(liveOptions); + expect(result.current.hasLiveConfig).toBe(true); + }); + expect(mockGetCloudTaskConfigOptions).toHaveBeenCalledWith("claude"); + }); + + it("keeps the shared fallback when unauthenticated", async () => { + mockUseAuthStore.mockImplementation((selector) => + selector({ oauthAccessToken: null }), + ); + + const result = await renderHook(); + + expect( + getModelConfigOption(result.current.configOptions).currentValue, + ).toBe(DEFAULT_GATEWAY_MODEL); + expect(mockGetCloudTaskConfigOptions).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/features/tasks/hooks/useCloudTaskConfigOptions.ts b/apps/mobile/src/features/tasks/hooks/useCloudTaskConfigOptions.ts new file mode 100644 index 0000000000..aaedf3375d --- /dev/null +++ b/apps/mobile/src/features/tasks/hooks/useCloudTaskConfigOptions.ts @@ -0,0 +1,52 @@ +import { + type Adapter, + buildCloudTaskConfigOptions, + type CloudTaskConfigOption, + GLM_MODEL_FLAG, + isGlmModelId, +} from "@posthog/shared"; +import { useQuery } from "@tanstack/react-query"; +import { useFeatureFlag } from "posthog-react-native"; +import { useAuthStore } from "@/features/auth"; +import { getPostHogApiClient } from "@/lib/posthogApiClient"; + +export const cloudTaskConfigOptionKeys = { + all: ["cloud-task-config-options"] as const, + adapter: (adapter: Adapter) => + [...cloudTaskConfigOptionKeys.all, adapter] as const, +}; + +const fallbackOptionsByAdapter: Record = { + claude: buildCloudTaskConfigOptions([], "claude"), + codex: buildCloudTaskConfigOptions([], "codex"), +}; + +export function useCloudTaskConfigOptions(adapter: Adapter = "claude") { + const oauthAccessToken = useAuthStore((state) => state.oauthAccessToken); + const glmEnabled = useFeatureFlag(GLM_MODEL_FLAG); + const query = useQuery({ + queryKey: cloudTaskConfigOptionKeys.adapter(adapter), + queryFn: () => getPostHogApiClient().getCloudTaskConfigOptions(adapter), + enabled: !!oauthAccessToken, + staleTime: 5 * 60 * 1000, + }); + const configOptions = query.data ?? fallbackOptionsByAdapter[adapter]; + const visibleConfigOptions = glmEnabled + ? configOptions + : configOptions.map((option) => + option.category === "model" + ? { + ...option, + options: option.options.filter( + (model) => !isGlmModelId(model.value), + ), + } + : option, + ); + + return { + ...query, + configOptions: visibleConfigOptions, + hasLiveConfig: query.data !== undefined, + }; +} diff --git a/apps/mobile/src/features/tasks/hooks/useIntegrations.test.ts b/apps/mobile/src/features/tasks/hooks/useIntegrations.test.ts index 45040a2df6..68394d2aba 100644 --- a/apps/mobile/src/features/tasks/hooks/useIntegrations.test.ts +++ b/apps/mobile/src/features/tasks/hooks/useIntegrations.test.ts @@ -15,10 +15,10 @@ vi.mock("@/features/auth", () => ({ })); vi.mock("@/lib/posthogApiClient", () => ({ - getPostHogApiClient: () => ({ + getPostHogApiClient: vi.fn(() => ({ getGithubRepositories: mockGetGithubRepositories, getIntegrations: mockGetIntegrations, - }), + })), })); import { useRepositoryCacheStore } from "../stores/repositoryCacheStore"; @@ -123,7 +123,7 @@ describe("useIntegrations", () => { }, ]); mockGetGithubRepositories - .mockResolvedValueOnce(["annika/mobile-app"]) + .mockResolvedValueOnce(["Annika/Mobile-App", ""]) .mockRejectedValueOnce(new Error("GitHub repos failed")); const queryClient = new QueryClient({ diff --git a/apps/mobile/src/features/tasks/hooks/useIntegrations.ts b/apps/mobile/src/features/tasks/hooks/useIntegrations.ts index bf2aab3c77..f0ca06ade5 100644 --- a/apps/mobile/src/features/tasks/hooks/useIntegrations.ts +++ b/apps/mobile/src/features/tasks/hooks/useIntegrations.ts @@ -3,7 +3,7 @@ import { useEffect, useMemo } from "react"; import { useAuthStore } from "@/features/auth"; import { getPostHogApiClient } from "@/lib/posthogApiClient"; import { useRepositoryCacheStore } from "../stores/repositoryCacheStore"; -import type { Integration, RepositoryOption } from "../types"; +import type { RepositoryOption } from "../types"; import { buildRepositoryOptions } from "../utils/repositorySelection"; /** Cheap content-equality check for repository option lists. Lets the cache @@ -60,26 +60,7 @@ export function useIntegrations(options: UseIntegrationsOptions = {}) { queryKey: integrationKeys.github(), queryFn: async () => { const data = await getPostHogApiClient().getIntegrations(); - return data.flatMap((integration): Integration[] => { - if ( - integration.kind !== "github" || - typeof integration.id !== "number" - ) { - return []; - } - - return [ - { - id: integration.id, - kind: integration.kind, - display_name: - typeof integration.display_name === "string" - ? integration.display_name - : undefined, - config: integration.config as Integration["config"], - }, - ]; - }); + return data.filter((i) => i.kind === "github"); }, enabled: enabled && !!projectId && !!oauthAccessToken, }); @@ -97,9 +78,11 @@ export function useIntegrations(options: UseIntegrationsOptions = {}) { const results = await Promise.allSettled( githubIntegrations.map(async (integration) => ({ integrationId: integration.id, - repositories: await getPostHogApiClient().getGithubRepositories( - integration.id, - ), + repositories: ( + await getPostHogApiClient().getGithubRepositories(integration.id) + ) + .map((repository) => repository.toLowerCase()) + .filter((repository) => repository.length > 0), })), ); diff --git a/apps/mobile/src/features/tasks/hooks/useTasks.test.ts b/apps/mobile/src/features/tasks/hooks/useTasks.test.ts index 579ec4bce2..1395c283e8 100644 --- a/apps/mobile/src/features/tasks/hooks/useTasks.test.ts +++ b/apps/mobile/src/features/tasks/hooks/useTasks.test.ts @@ -27,18 +27,15 @@ vi.mock("@/lib/logger", () => { }; }); -vi.mock("../api", () => ({ - runTaskInCloud: vi.fn(), -})); - vi.mock("@/lib/posthogApiClient", () => ({ - getPostHogApiClient: () => ({ + getPostHogApiClient: vi.fn(() => ({ createTask: vi.fn(), deleteTask: vi.fn(), getTask: vi.fn(), getTasks: vi.fn(), + runTaskInCloud: vi.fn(), updateTask: vi.fn(), - }), + })), })); vi.mock("../stores/taskStore", () => ({ diff --git a/apps/mobile/src/features/tasks/hooks/useTasks.ts b/apps/mobile/src/features/tasks/hooks/useTasks.ts index 95b5d4725f..862459708e 100644 --- a/apps/mobile/src/features/tasks/hooks/useTasks.ts +++ b/apps/mobile/src/features/tasks/hooks/useTasks.ts @@ -4,7 +4,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useAuthStore, useUserQuery } from "@/features/auth"; import { logger } from "@/lib/logger"; import { getPostHogApiClient } from "@/lib/posthogApiClient"; -import { runTaskInCloud } from "../api"; import { useTaskStore } from "../stores/taskStore"; import type { CreateTaskOptions } from "../types"; @@ -136,13 +135,13 @@ export function useUpdateTask() { }: { taskId: string; updates: Partial; - }) => - getPostHogApiClient().updateTask( + }) => { + const client = getPostHogApiClient(); + return client.updateTask( taskId, - updates as Parameters< - ReturnType["updateTask"] - >[1], - ), + updates as Parameters[1], + ); + }, onSuccess: (updatedTask, { taskId }) => { // Update the detail cache immediately queryClient.setQueryData(taskKeys.detail(taskId), updatedTask); @@ -174,7 +173,8 @@ export function useRunTask() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (taskId: string) => runTaskInCloud(taskId), + mutationFn: (taskId: string) => + getPostHogApiClient().runTaskInCloud(taskId), onSuccess: (updatedTask, taskId) => { queryClient.setQueryData(taskKeys.detail(taskId), updatedTask); queryClient.invalidateQueries({ queryKey: taskKeys.lists() }); diff --git a/apps/mobile/src/features/tasks/hooks/useUserIntegrations.ts b/apps/mobile/src/features/tasks/hooks/useUserIntegrations.ts index 1ba6655cf2..0724faad9c 100644 --- a/apps/mobile/src/features/tasks/hooks/useUserIntegrations.ts +++ b/apps/mobile/src/features/tasks/hooks/useUserIntegrations.ts @@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { useCallback, useMemo } from "react"; import { useAuthStore } from "@/features/auth"; import { getPostHogApiClient } from "@/lib/posthogApiClient"; -import type { RepositoryOption } from "../types"; +import type { RepositoryOption, UserGithubIntegration } from "../types"; /** * User-scoped sibling of {@link useIntegrations}. Reads the authenticated @@ -29,10 +29,7 @@ interface UseUserIntegrationsOptions { enabled?: boolean; } -function integrationLabel(integration: { - installation_id: string; - account?: { name?: string | null } | null; -}): string { +function integrationLabel(integration: UserGithubIntegration): string { return integration.account?.name ?? `GitHub ${integration.installation_id}`; } @@ -42,7 +39,19 @@ export function useUserIntegrations(options: UseUserIntegrationsOptions = {}) { const integrationsQuery = useQuery({ queryKey: userIntegrationKeys.github(), - queryFn: () => getPostHogApiClient().getGithubUserIntegrations(), + queryFn: async () => { + const integrations = + await getPostHogApiClient().getGithubUserIntegrations(); + return integrations.map(({ account, ...integration }) => ({ + ...integration, + account: account + ? { + name: account.name ?? undefined, + type: account.type ?? undefined, + } + : undefined, + })); + }, enabled: enabled && !!oauthAccessToken, }); @@ -57,9 +66,13 @@ export function useUserIntegrations(options: UseUserIntegrationsOptions = {}) { const results = await Promise.allSettled( integrations.map(async (integration) => ({ installationId: integration.installation_id, - repositories: await getPostHogApiClient().getGithubUserRepositories( - integration.installation_id, - ), + repositories: ( + await getPostHogApiClient().getGithubUserRepositories( + integration.installation_id, + ) + ) + .map((repository) => repository.toLowerCase()) + .filter((repository) => repository.length > 0), })), ); diff --git a/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx b/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx index 175a77c276..4985ffeaee 100644 --- a/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx +++ b/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx @@ -9,7 +9,9 @@ vi.mock("posthog-react-native", () => ({ useFeatureFlag: () => flagState.enabled, })); vi.mock("@/lib/posthogApiClient", () => ({ - getPostHogApiClient: () => ({ warmTask: mockWarmTask }), + getPostHogApiClient: vi.fn(() => ({ + warmTask: mockWarmTask, + })), })); vi.mock("@/lib/logger", () => { const mockLogger = { diff --git a/apps/mobile/src/features/tasks/index.ts b/apps/mobile/src/features/tasks/index.ts index 7da4db747e..2bcbcc35a7 100644 --- a/apps/mobile/src/features/tasks/index.ts +++ b/apps/mobile/src/features/tasks/index.ts @@ -24,9 +24,3 @@ export { useTaskStore } from "./stores/taskStore"; // Types export * from "./types"; - -// Utils -export { - convertStoredEntriesToEvents, - parseSessionLogs, -} from "./utils/parseSessionLogs"; diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts index 462589a58e..0c57cc6d1a 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts @@ -1,7 +1,9 @@ +import { convertStoredEntriesToPortableSessionEvents } from "@posthog/core/sessions/portableSessionEvents"; import { type CloudTaskUpdatePayload, isTerminalStatus, type StoredLogEntry, + serializeCloudPrompt, type Task, } from "@posthog/shared"; import * as Haptics from "expo-haptics"; @@ -18,7 +20,6 @@ import { sendCloudCommand, } from "../api"; import { buildCloudPromptBlocks } from "../composer/attachments/buildCloudPrompt"; -import { serializeCloudPrompt } from "../composer/attachments/cloudPrompt"; import type { PendingAttachment } from "../composer/attachments/types"; import { type WatchCloudTaskHandle, @@ -31,7 +32,6 @@ import type { SessionNotificationAttachment, TerminalStatus, } from "../types"; -import { convertStoredEntriesToEvents } from "../utils/parseSessionLogs"; import { playbackRateForTaskDuration } from "../utils/playbackRate"; import { reinjectPromptAttachments } from "../utils/promptAttachments"; import { playCompletionSound } from "../utils/sounds"; @@ -991,7 +991,8 @@ export const useTaskSessionStore = create((set, get) => ({ ? update.newEntries : dedupAgainstLocalEchoes(update.newEntries, echoSet); - const events = convertStoredEntriesToEvents(dedupedEntries); + const events = + convertStoredEntriesToPortableSessionEvents(dedupedEntries); // Snapshots are S3-backed and replay user turns as text-only chunks; // reattach the images from the `session/prompt` entries in the same log. if (isSnapshot) { diff --git a/apps/mobile/src/features/tasks/stores/taskStore.ts b/apps/mobile/src/features/tasks/stores/taskStore.ts index 456eae37a1..39276a7eeb 100644 --- a/apps/mobile/src/features/tasks/stores/taskStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskStore.ts @@ -1,11 +1,12 @@ +import type { TaskActivitySortMode } from "@posthog/core/tasks/taskActivity"; +import type { ExecutionMode, SupportedReasoningEffort } from "@posthog/shared"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; -import type { ExecutionMode, ReasoningEffort } from "../composer/options"; import type { RepositorySelection } from "../types"; export type OrganizeMode = "by-project" | "chronological"; -export type SortMode = "created" | "updated"; +export type SortMode = TaskActivitySortMode; const EMPTY_REPOSITORY_SELECTION: RepositorySelection = { integrationId: null, @@ -17,7 +18,7 @@ const EMPTY_REPOSITORY_SELECTION: RepositorySelection = { export interface TaskComposerConfig { mode?: ExecutionMode; model?: string; - reasoning?: ReasoningEffort; + reasoning?: SupportedReasoningEffort; } interface TaskUIState { diff --git a/apps/mobile/src/features/tasks/types.ts b/apps/mobile/src/features/tasks/types.ts index 29a2754d7f..ad45e83576 100644 --- a/apps/mobile/src/features/tasks/types.ts +++ b/apps/mobile/src/features/tasks/types.ts @@ -1,14 +1,8 @@ import type { CloudPermissionOption, CloudTaskPermissionRequestUpdate, - StoredLogEntry as SharedStoredLogEntry, - TaskRunStatus, } from "@posthog/shared"; -export interface MobileStoredLogEntry extends SharedStoredLogEntry { - direction?: "client" | "agent"; -} - export interface SessionNotificationAttachment { kind: "image" | "document"; uri: string; @@ -16,51 +10,12 @@ export interface SessionNotificationAttachment { mimeType?: string; } -export interface SessionNotification { - update?: { - sessionUpdate?: string; - content?: { type: string; text: string }; - // Sidecar carrying user-uploaded attachments on user_message_chunk events. - // The wire format embeds the bytes themselves in a separate serialized - // cloud-prompt payload sent to the agent; this field exists only so the - // local feed can render the attachments alongside the echoed text. - attachments?: SessionNotificationAttachment[]; - title?: string; - toolCallId?: string; - status?: "pending" | "in_progress" | "completed" | "failed" | null; - rawInput?: Record; - rawOutput?: unknown; - entries?: PlanEntry[]; - _meta?: { - claudeCode?: { - toolName?: string; - parentToolCallId?: string; - }; - }; - }; -} - export interface PlanEntry { content: string; status: "pending" | "in_progress" | "completed" | "failed"; priority: string; } -export interface AcpMessage { - type: "acp_message"; - direction: "client" | "agent"; - ts: number; - message: unknown; -} - -export interface SessionUpdateEvent { - type: "session_update"; - ts: number; - notification: SessionNotification; -} - -export type SessionEvent = AcpMessage | SessionUpdateEvent; - export interface CloudPermissionResponseSelection { optionId: string; displayText: string; @@ -75,64 +30,6 @@ export interface CloudPendingPermissionRequest { response?: CloudPermissionResponseSelection; } -export interface TaskRunStateEvent { - type: "task_run_state"; - status?: TaskRunStatus; - stage?: string | null; - output?: Record | null; - error_message?: string | null; - branch?: string | null; - updated_at?: string | null; - completed_at?: string | null; -} - -export interface PermissionRequestEventData { - type: "permission_request"; - requestId: string; - toolCall: CloudTaskPermissionRequestUpdate["toolCall"]; - options: CloudPermissionOption[]; -} - -export interface SseErrorEventData { - error: string; -} - -export function isTaskRunStateEvent(data: unknown): data is TaskRunStateEvent { - return ( - typeof data === "object" && - data !== null && - (data as { type?: string }).type === "task_run_state" - ); -} - -export function isPermissionRequestEvent( - data: unknown, -): data is PermissionRequestEventData { - return ( - typeof data === "object" && - data !== null && - (data as { type?: string }).type === "permission_request" && - typeof (data as { requestId?: string }).requestId === "string" - ); -} - -export function isKeepaliveEvent(data: unknown): boolean { - return ( - typeof data === "object" && - data !== null && - (data as { type?: string }).type === "keepalive" - ); -} - -export function isSseErrorEvent(data: unknown): data is SseErrorEventData { - return ( - typeof data === "object" && - data !== null && - "error" in data && - typeof (data as SseErrorEventData).error === "string" - ); -} - export interface Integration { id: number; kind: string; diff --git a/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts b/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts deleted file mode 100644 index a6d512d59d..0000000000 --- a/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { convertStoredEntriesToPortableSessionEvents } from "@posthog/core/sessions/portableSessionEvents"; -import type { MobileStoredLogEntry, SessionNotification } from "../types"; - -export interface ParsedSessionLogs { - notifications: SessionNotification[]; - rawEntries: MobileStoredLogEntry[]; -} - -export function parseSessionLogs(content: string): ParsedSessionLogs { - if (!content?.trim()) { - return { notifications: [], rawEntries: [] }; - } - - const notifications: SessionNotification[] = []; - const rawEntries: MobileStoredLogEntry[] = []; - - for (const line of content.trim().split("\n")) { - try { - const stored = JSON.parse(line) as MobileStoredLogEntry; - - const msg = stored.notification; - if (msg) { - const hasId = msg.id !== undefined; - const hasMethod = msg.method !== undefined; - const hasResult = msg.result !== undefined || msg.error !== undefined; - - if (hasId && hasMethod) { - stored.direction = "client"; - } else if (hasId && hasResult) { - stored.direction = "agent"; - } else if (hasMethod && !hasId) { - stored.direction = "agent"; - } - } - - rawEntries.push(stored); - - if ( - stored.type === "notification" && - stored.notification?.method === "session/update" && - stored.notification?.params - ) { - notifications.push(stored.notification.params as SessionNotification); - } - } catch { - // Skip malformed lines - } - } - - return { notifications, rawEntries }; -} - -export const convertStoredEntriesToEvents = - convertStoredEntriesToPortableSessionEvents; From d98d556ac1bc960888692f9a4401a2f7a04598c9 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 24 Jul 2026 19:30:19 +0300 Subject: [PATCH 29/43] refactor(mobile): adopt repository and inbox transport Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- apps/mobile/src/features/inbox/api.ts | 341 +----------------- .../inbox/hooks/useInboxReports.test.ts | 8 +- .../features/inbox/hooks/useInboxReports.ts | 54 +-- .../tasks/composer/RepositoryPickerInline.tsx | 2 +- .../features/tasks/hooks/useIntegrations.ts | 58 ++- .../tasks/hooks/useUserIntegrations.ts | 73 ++-- .../tasks/utils/repositorySelection.test.ts | 99 +++-- .../tasks/utils/repositorySelection.ts | 62 +++- 8 files changed, 219 insertions(+), 478 deletions(-) diff --git a/apps/mobile/src/features/inbox/api.ts b/apps/mobile/src/features/inbox/api.ts index ed6461ce9f..1afbcb3c8b 100644 --- a/apps/mobile/src/features/inbox/api.ts +++ b/apps/mobile/src/features/inbox/api.ts @@ -1,342 +1,11 @@ -import type { DismissalReasonOptionValue } from "@posthog/shared"; -import type { - AnySignalReportArtefact, - AvailableSuggestedReviewer, - AvailableSuggestedReviewersResponse, - CommitDiffResponse, - SignalProcessingStateResponse, - SignalReport, - SignalReportArtefactsResponse, - SignalReportSignalsResponse, - SignalReportsQueryParams, - SignalReportsResponse, - SuggestedReviewerWriteEntry, -} from "@posthog/shared/domain-types"; -import { authedFetch, getBaseUrl, getProjectId, HttpError } from "@/lib/api"; -import { logger } from "@/lib/logger"; - -const log = logger.scope("inbox-api"); - -export async function getSignalReports( - params?: SignalReportsQueryParams, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const url = new URL(`${baseUrl}/api/projects/${projectId}/signals/reports/`); - - if (params?.limit != null) { - url.searchParams.set("limit", String(params.limit)); - } - if (params?.offset != null) { - url.searchParams.set("offset", String(params.offset)); - } - if (params?.status) { - url.searchParams.set("status", params.status); - } - if (params?.ordering) { - url.searchParams.set("ordering", params.ordering); - } - if (params?.source_product) { - url.searchParams.set("source_product", params.source_product); - } - if (params?.suggested_reviewers) { - url.searchParams.set("suggested_reviewers", params.suggested_reviewers); - } - if (params?.priority) { - url.searchParams.set("priority", params.priority); - } - - const response = await authedFetch(url.toString()); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to fetch signal reports", - ); - } - - const data = await response.json(); - return { - results: data.results ?? [], - count: data.count ?? data.results?.length ?? 0, - }; -} - -export async function getSignalReport( - reportId: string, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/signals/reports/${reportId}/`, - ); - - if (response.status === 404 || response.status === 403) { - return null; - } - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to fetch signal report", - ); - } - - return await response.json(); -} - -export async function getSignalProcessingState(): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/signals/processing_state/`, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to fetch signal processing state", - ); - } - - return await response.json(); -} - -export async function getAvailableSuggestedReviewers( - query?: string, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const url = new URL( - `${baseUrl}/api/projects/${projectId}/signals/reports/available_reviewers/`, - ); - - if (query?.trim()) { - url.searchParams.set("query", query.trim()); - } - - const response = await authedFetch(url.toString()); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to fetch available suggested reviewers", - ); - } - - // API returns a dict keyed by UUID: { "uuid": { name, email, github_login } } - const data = await response.json(); - const results = Object.entries(data) - .map(([uuid, value]) => { - if (typeof value !== "object" || value === null) return null; - const v = value as Record; - return { - uuid, - name: typeof v.name === "string" ? v.name : "", - email: typeof v.email === "string" ? v.email : "", - github_login: typeof v.github_login === "string" ? v.github_login : "", - }; - }) - .filter((r): r is AvailableSuggestedReviewer => r !== null); - - return { results, count: results.length }; -} - -export async function getSignalReportArtefacts( - reportId: string, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/signals/reports/${reportId}/artefacts/`, - ); - - if (!response.ok) { - const body = await response.text().catch(() => ""); - log.warn("Failed to fetch report artefacts", { - reportId, - status: response.status, - body: body.slice(0, 500), - }); - return { results: [], count: 0 }; - } - - const data = await response.json(); - const results: AnySignalReportArtefact[] = data.results ?? []; - return { results, count: data.count ?? results.length }; -} - -/** Fetch a commit artefact's diff against its parent (lazily, on demand). */ -export async function getCommitDiff( - reportId: string, - artefactId: string, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/signals/reports/${reportId}/artefacts/${artefactId}/diff/`, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Couldn’t load the diff", - ); - } - - const data = await response.json(); - return { - diff: typeof data.diff === "string" ? data.diff : "", - truncated: data.truncated === true, - }; -} - -/** Replace the content of a report artefact (full PUT, not a partial update). */ -export async function updateSignalReportArtefact( - reportId: string, - artefactId: string, - content: SuggestedReviewerWriteEntry[], -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/signals/reports/${reportId}/artefacts/${artefactId}/`, - { - method: "PUT", - body: JSON.stringify({ content }), - }, - ); - - if (!response.ok) { - const errorText = await response.text().catch(() => ""); - throw new HttpError( - response.status, - response.statusText, - errorText || "Failed to update suggested reviewers", - ); - } -} - -export async function getSignalReportSignals( - reportId: string, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/signals/reports/${reportId}/signals/`, - ); - - if (!response.ok) { - log.warn("Failed to fetch report signals", { - reportId, - status: response.status, - }); - return { report: null, signals: [] }; - } - - const data = await response.json(); - return { report: data.report ?? null, signals: data.signals ?? [] }; -} +import { extractRepoSelectionRepository } from "@posthog/core/inbox/artefacts"; +import { getPostHogApiClient } from "@/lib/posthogApiClient"; /** Resolve the repository associated with a signal report via its repo_selection artefact. */ export async function getReportRepository( reportId: string, ): Promise { - const { results } = await getSignalReportArtefacts(reportId); - const repoArtefact = results.find((a) => a.type === "repo_selection"); - if (!repoArtefact) return null; - - let parsed: unknown = repoArtefact.content; - if (typeof parsed === "string") { - try { - parsed = JSON.parse(parsed); - } catch { - return (parsed as string).toLowerCase(); - } - } - - if (typeof parsed === "object" && parsed !== null) { - const repo = - (parsed as Record).repository ?? - (parsed as Record).repo; - if (typeof repo === "string") return repo.toLowerCase(); - } - - return null; -} - -export interface DismissSignalReportInput { - reason: DismissalReasonOptionValue; - note?: string; -} - -export async function dismissSignalReport( - reportId: string, - input: DismissSignalReportInput, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/signals/reports/${reportId}/state/`, - { - method: "POST", - body: JSON.stringify({ - state: "suppressed", - dismissal_reason: input.reason, - ...(input.note?.trim() ? { dismissal_note: input.note.trim() } : {}), - }), - }, - ); - - if (!response.ok) { - const errorText = await response.text().catch(() => ""); - throw new HttpError( - response.status, - response.statusText, - errorText || "Failed to dismiss signal report", - ); - } - - return await response.json(); -} - -/** Re-queue a dismissed report into the inbox via the `potential` transition. */ -export async function restoreSignalReport( - reportId: string, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/signals/reports/${reportId}/state/`, - { - method: "POST", - body: JSON.stringify({ state: "potential" }), - }, - ); - - if (!response.ok) { - const errorText = await response.text().catch(() => ""); - throw new HttpError( - response.status, - response.statusText, - errorText || "Failed to restore signal report", - ); - } - - return await response.json(); + const { results } = + await getPostHogApiClient().getSignalReportArtefacts(reportId); + return extractRepoSelectionRepository(results); } diff --git a/apps/mobile/src/features/inbox/hooks/useInboxReports.test.ts b/apps/mobile/src/features/inbox/hooks/useInboxReports.test.ts index ad4abf8190..542886a407 100644 --- a/apps/mobile/src/features/inbox/hooks/useInboxReports.test.ts +++ b/apps/mobile/src/features/inbox/hooks/useInboxReports.test.ts @@ -15,9 +15,11 @@ const getAvailableSuggestedReviewers = vi.fn(async (_query?: string) => ({ results: [], count: 0, })); -vi.mock("../api", () => ({ - getAvailableSuggestedReviewers: (query?: string) => - getAvailableSuggestedReviewers(query), +vi.mock("@/lib/posthogApiClient", () => ({ + getPostHogApiClient: () => ({ + getAvailableSuggestedReviewers: (query?: string) => + getAvailableSuggestedReviewers(query), + }), })); import { diff --git a/apps/mobile/src/features/inbox/hooks/useInboxReports.ts b/apps/mobile/src/features/inbox/hooks/useInboxReports.ts index a2d91f0520..e262300bf9 100644 --- a/apps/mobile/src/features/inbox/hooks/useInboxReports.ts +++ b/apps/mobile/src/features/inbox/hooks/useInboxReports.ts @@ -7,6 +7,7 @@ import { INBOX_DISMISSED_STATUS_FILTER, INBOX_REFETCH_INTERVAL_MS, } from "@posthog/core/inbox/reportFiltering"; +import type { DismissalReasonOptionValue } from "@posthog/shared"; import type { AvailableSuggestedReviewersResponse, CommitDiffResponse, @@ -28,19 +29,7 @@ import { } from "@tanstack/react-query"; import { useMemo } from "react"; import { useAuthStore } from "@/features/auth"; -import { - type DismissSignalReportInput, - dismissSignalReport, - getAvailableSuggestedReviewers, - getCommitDiff, - getSignalProcessingState, - getSignalReport, - getSignalReportArtefacts, - getSignalReportSignals, - getSignalReports, - restoreSignalReport, - updateSignalReportArtefact, -} from "../api"; +import { getPostHogApiClient } from "@/lib/posthogApiClient"; import { useInboxFilterStore } from "../stores/inboxFilterStore"; import { isRestorableReport } from "../utils"; @@ -98,7 +87,7 @@ export function useInboxReports(options?: { enabled?: boolean }) { const query = useInfiniteQuery({ queryKey: inboxKeys.list(params), queryFn: ({ pageParam }) => - getSignalReports({ + getPostHogApiClient().getSignalReports({ ...params, limit: REPORTS_PAGE_SIZE, offset: pageParam, @@ -137,7 +126,7 @@ export function useArchivedReports(options?: { enabled?: boolean }) { const query = useQuery({ queryKey: inboxKeys.archived(params), - queryFn: () => getSignalReports(params), + queryFn: () => getPostHogApiClient().getSignalReports(params), enabled: !!projectId && !!oauthAccessToken && (options?.enabled ?? true), }); @@ -158,7 +147,7 @@ export function useInboxReport(reportId: string | null) { queryKey: inboxKeys.detail(reportId ?? ""), queryFn: () => { if (!reportId) throw new Error("reportId is required"); - return getSignalReport(reportId); + return getPostHogApiClient().getSignalReport(reportId); }, enabled: !!projectId && !!oauthAccessToken && !!reportId, }); @@ -169,7 +158,7 @@ export function useSignalProcessingState(options?: { enabled?: boolean }) { return useQuery({ queryKey: inboxKeys.processingState, - queryFn: () => getSignalProcessingState(), + queryFn: () => getPostHogApiClient().getSignalProcessingState(), enabled: !!projectId && !!oauthAccessToken && (options?.enabled ?? true), refetchInterval: INBOX_REFETCH_INTERVAL_MS, }); @@ -184,7 +173,8 @@ export function useAvailableSuggestedReviewers(options?: { return useQuery({ queryKey: [...inboxKeys.all, "available-reviewers", query] as const, - queryFn: () => getAvailableSuggestedReviewers(query || undefined), + queryFn: () => + getPostHogApiClient().getAvailableSuggestedReviewers(query || undefined), enabled: !!projectId && !!oauthAccessToken && (options?.enabled ?? true), staleTime: 5 * 60 * 1000, // Only poll the unfiltered list; search terms are transient and each one @@ -200,7 +190,7 @@ export function useInboxReportArtefacts(reportId: string | null) { queryKey: inboxKeys.artefacts(reportId ?? ""), queryFn: () => { if (!reportId) throw new Error("reportId is required"); - return getSignalReportArtefacts(reportId); + return getPostHogApiClient().getSignalReportArtefacts(reportId); }, enabled: !!projectId && !!oauthAccessToken && !!reportId, // The log is a live work record — agents append artefacts while a report @@ -219,7 +209,7 @@ export function useCommitDiff( return useQuery({ queryKey: inboxKeys.commitDiff(reportId, artefactId), - queryFn: () => getCommitDiff(reportId, artefactId), + queryFn: () => getPostHogApiClient().getCommitDiff(reportId, artefactId), // A commit's diff is immutable, so only fetch once expanded and never retry. enabled: enabled && !!projectId && !!oauthAccessToken, staleTime: 5 * 60_000, @@ -234,7 +224,7 @@ export function useInboxReportSignals(reportId: string | null) { queryKey: inboxKeys.signals(reportId ?? ""), queryFn: () => { if (!reportId) throw new Error("reportId is required"); - return getSignalReportSignals(reportId); + return getPostHogApiClient().getSignalReportSignals(reportId); }, enabled: !!projectId && !!oauthAccessToken && !!reportId, }); @@ -257,7 +247,9 @@ export function useUpdateSuggestedReviewers(reportId: string) { { previous: SignalReportArtefactsResponse | undefined } >({ mutationFn: ({ artefactId, content }) => - updateSignalReportArtefact(reportId, artefactId, content), + getPostHogApiClient() + .updateSignalReportArtefact(reportId, artefactId, content) + .then(() => undefined), onMutate: async ({ artefactId, optimisticReviewers }) => { await queryClient.cancelQueries({ queryKey }); const previous = @@ -297,8 +289,17 @@ export function useUpdateSuggestedReviewers(reportId: string) { export function useDismissReport(reportId: string) { const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (input) => dismissSignalReport(reportId, input), + return useMutation< + SignalReport, + Error, + { reason: DismissalReasonOptionValue; note?: string } + >({ + mutationFn: (input) => + getPostHogApiClient().updateSignalReportState(reportId, { + state: "suppressed", + dismissal_reason: input.reason, + ...(input.note?.trim() ? { dismissal_note: input.note.trim() } : {}), + }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: inboxKeys.detail(reportId) }); queryClient.invalidateQueries({ queryKey: inboxKeys.all }); @@ -314,11 +315,12 @@ export function useRestoreReport() { // report. return useMutation({ mutationFn: async (reportId) => { - const current = await getSignalReport(reportId); + const client = getPostHogApiClient(); + const current = await client.getSignalReport(reportId); if (current && !isRestorableReport(current)) { return false; } - await restoreSignalReport(reportId); + await client.updateSignalReportState(reportId, { state: "potential" }); return true; }, onSuccess: () => { diff --git a/apps/mobile/src/features/tasks/composer/RepositoryPickerInline.tsx b/apps/mobile/src/features/tasks/composer/RepositoryPickerInline.tsx index e953df43b9..6e39aead06 100644 --- a/apps/mobile/src/features/tasks/composer/RepositoryPickerInline.tsx +++ b/apps/mobile/src/features/tasks/composer/RepositoryPickerInline.tsx @@ -15,8 +15,8 @@ import Animated, { useSharedValue, withTiming, } from "react-native-reanimated"; -import type { RepositoryOption } from "@/features/tasks/types"; import { useThemeColors } from "@/lib/theme"; +import type { RepositoryOption } from "../types"; // Tuning for the nested (ScrollView) path's progressive mount. The first // chunk needs to cover the rows the user can actually see (~5 with the diff --git a/apps/mobile/src/features/tasks/hooks/useIntegrations.ts b/apps/mobile/src/features/tasks/hooks/useIntegrations.ts index f0ca06ade5..25f909f6f9 100644 --- a/apps/mobile/src/features/tasks/hooks/useIntegrations.ts +++ b/apps/mobile/src/features/tasks/hooks/useIntegrations.ts @@ -1,34 +1,14 @@ +import { combineGithubRepositories } from "@posthog/core/integrations/repositories"; import { useQuery } from "@tanstack/react-query"; import { useEffect, useMemo } from "react"; import { useAuthStore } from "@/features/auth"; import { getPostHogApiClient } from "@/lib/posthogApiClient"; import { useRepositoryCacheStore } from "../stores/repositoryCacheStore"; -import type { RepositoryOption } from "../types"; -import { buildRepositoryOptions } from "../utils/repositorySelection"; - -/** Cheap content-equality check for repository option lists. Lets the cache - * write effect skip no-op updates, which is what kept retriggering renders - * before — `buildRepositoryOptions` always returns a fresh array, so the - * effect's dep array churned every render. */ -function repositoryOptionsEqual( - a: RepositoryOption[], - b: RepositoryOption[], -): boolean { - if (a === b) return true; - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) { - const left = a[i]; - const right = b[i]; - if ( - left.integrationId !== right.integrationId || - left.repository !== right.repository || - left.integrationLabel !== right.integrationLabel - ) { - return false; - } - } - return true; -} +import { + buildRepositoryOptions, + repositoryLoadWarning, + repositoryOptionsEqual, +} from "../utils/repositorySelection"; export const integrationKeys = { all: ["integrations"] as const, @@ -82,7 +62,7 @@ export function useIntegrations(options: UseIntegrationsOptions = {}) { await getPostHogApiClient().getGithubRepositories(integration.id) ) .map((repository) => repository.toLowerCase()) - .filter((repository) => repository.length > 0), + .filter(Boolean), })), ); @@ -100,12 +80,10 @@ export function useIntegrations(options: UseIntegrationsOptions = {}) { return { repositoriesByIntegration, - partialError: - failedCount === 0 - ? null - : failedCount === githubIntegrations.length - ? "Could not load GitHub repositories. Pull to retry." - : "Some GitHub repositories could not be loaded. Pull to retry.", + partialError: repositoryLoadWarning( + failedCount, + githubIntegrations.length, + ), }; }, enabled: enabled && githubIntegrations.length > 0, @@ -113,7 +91,19 @@ export function useIntegrations(options: UseIntegrationsOptions = {}) { const repositoriesByIntegration = repositoriesQuery.data?.repositoriesByIntegration ?? {}; - const repositories = Object.values(repositoriesByIntegration).flat().sort(); + const repositories = Object.keys( + combineGithubRepositories( + githubIntegrations.map((integration) => ({ + data: { + integrationId: integration.id, + repos: repositoriesByIntegration[integration.id] ?? [], + }, + isPending: repositoriesQuery.isPending, + isError: false, + isRefetching: repositoriesQuery.isRefetching, + })), + ).repositoryMap, + ).sort(); // Memoize the derived options list keyed on the underlying query data so // its reference is stable across renders when the data hasn't actually diff --git a/apps/mobile/src/features/tasks/hooks/useUserIntegrations.ts b/apps/mobile/src/features/tasks/hooks/useUserIntegrations.ts index 0724faad9c..c951950409 100644 --- a/apps/mobile/src/features/tasks/hooks/useUserIntegrations.ts +++ b/apps/mobile/src/features/tasks/hooks/useUserIntegrations.ts @@ -1,8 +1,13 @@ +import { combineUserGithubRepositories } from "@posthog/core/integrations/repositories"; import { useQuery } from "@tanstack/react-query"; import { useCallback, useMemo } from "react"; import { useAuthStore } from "@/features/auth"; import { getPostHogApiClient } from "@/lib/posthogApiClient"; -import type { RepositoryOption, UserGithubIntegration } from "../types"; +import type { RepositoryOption } from "../types"; +import { + buildUserRepositoryOptions, + repositoryLoadWarning, +} from "../utils/repositorySelection"; /** * User-scoped sibling of {@link useIntegrations}. Reads the authenticated @@ -29,10 +34,6 @@ interface UseUserIntegrationsOptions { enabled?: boolean; } -function integrationLabel(integration: UserGithubIntegration): string { - return integration.account?.name ?? `GitHub ${integration.installation_id}`; -} - export function useUserIntegrations(options: UseUserIntegrationsOptions = {}) { const { enabled = true } = options; const { oauthAccessToken } = useAuthStore(); @@ -62,7 +63,6 @@ export function useUserIntegrations(options: UseUserIntegrationsOptions = {}) { integrations.map((i) => i.installation_id), ), queryFn: async () => { - const byInstallation: Record = {}; const results = await Promise.allSettled( integrations.map(async (integration) => ({ installationId: integration.installation_id, @@ -72,47 +72,48 @@ export function useUserIntegrations(options: UseUserIntegrationsOptions = {}) { ) ) .map((repository) => repository.toLowerCase()) - .filter((repository) => repository.length > 0), + .filter(Boolean), })), ); - let failedCount = 0; - for (const result of results) { - if (result.status === "fulfilled") { - byInstallation[result.value.installationId] = - result.value.repositories; - } else { - failedCount += 1; - } - } + const combined = combineUserGithubRepositories( + results.map((result) => ({ + data: + result.status === "fulfilled" + ? { + userIntegrationId: + integrations.find( + (integration) => + integration.installation_id === + result.value.installationId, + )?.id ?? "", + installationId: result.value.installationId, + repos: result.value.repositories, + } + : undefined, + isPending: false, + isError: result.status === "rejected", + isRefetching: false, + })), + integrations.map((integration) => integration.installation_id), + ); return { - byInstallation, - partialError: - failedCount === 0 - ? null - : failedCount === integrations.length - ? "Could not load GitHub repositories. Pull to retry." - : "Some GitHub repositories could not be loaded. Pull to retry.", + byInstallation: combined.reposByInstallationId, + partialError: repositoryLoadWarning( + combined.failedInstallationIds.length, + integrations.length, + ), }; }, enabled: enabled && integrations.length > 0, }); const repositoryOptions = useMemo(() => { - const byInstallation = repositoriesQuery.data?.byInstallation ?? {}; - return integrations - .flatMap((integration) => { - const repositories = byInstallation[integration.installation_id] ?? []; - return repositories.map((repository) => ({ - // GitHub installation ids fit in a JS number; use it as the numeric - // key the picker/RepositoryOption already expect. - integrationId: Number(integration.installation_id), - integrationLabel: integrationLabel(integration), - repository, - })); - }) - .sort((left, right) => left.repository.localeCompare(right.repository)); + return buildUserRepositoryOptions( + integrations, + repositoriesQuery.data?.byInstallation ?? {}, + ); }, [integrations, repositoriesQuery.data]); /** Resolve the `UserIntegration` UUID for a selected installation id, to send diff --git a/apps/mobile/src/features/tasks/utils/repositorySelection.test.ts b/apps/mobile/src/features/tasks/utils/repositorySelection.test.ts index 820ef73caf..b7177617e3 100644 --- a/apps/mobile/src/features/tasks/utils/repositorySelection.test.ts +++ b/apps/mobile/src/features/tasks/utils/repositorySelection.test.ts @@ -1,36 +1,31 @@ import { describe, expect, it } from "vitest"; import { buildRepositoryOptions, + buildUserRepositoryOptions, findRepositoryOption, isRepositorySelectionComplete, + repositoryLoadWarning, + repositoryOptionsEqual, toRepositorySelection, } from "./repositorySelection"; describe("repositorySelection", () => { const integrations = [ - { - id: 7, - kind: "github", - display_name: "Personal GitHub", - }, + { id: 7, kind: "github", display_name: "Personal GitHub" }, { id: 11, kind: "github", - config: { - account: { - login: "posthog", - }, - }, + config: { account: { login: "posthog" } }, }, ]; it("preserves integration identity for each repository option", () => { - const options = buildRepositoryOptions(integrations, { - 7: ["annika/mobile-app"], - 11: ["posthog/posthog", "posthog/code"], - }); - - expect(options).toEqual([ + expect( + buildRepositoryOptions(integrations, { + 7: ["annika/mobile-app"], + 11: ["posthog/posthog", "posthog/code"], + }), + ).toEqual([ { integrationId: 7, integrationLabel: "Personal GitHub", @@ -49,41 +44,81 @@ describe("repositorySelection", () => { ]); }); - it("finds the exact repository option when multiple integrations expose the same repository", () => { + it("finds an exact repository option", () => { const options = buildRepositoryOptions(integrations, { 7: ["posthog/posthog"], 11: ["posthog/posthog"], }); - const selected = findRepositoryOption(options, { + expect( + findRepositoryOption(options, { + integrationId: 11, + repository: "posthog/posthog", + }), + ).toEqual({ integrationId: 11, + integrationLabel: "posthog", repository: "posthog/posthog", }); + }); + + it.each([ + [{ installation_id: "42", account: { name: "PostHog" } }, "PostHog"], + [{ installation_id: "43" }, "GitHub 43"], + ])( + "builds user integration options with the expected label", + (integration, integrationLabel) => { + expect( + buildUserRepositoryOptions([integration], { + [integration.installation_id]: ["posthog/code"], + }), + ).toEqual([ + { + integrationId: Number(integration.installation_id), + integrationLabel, + repository: "posthog/code", + }, + ]); + }, + ); - expect(selected).toEqual({ + it("treats a changed label as a different repository option", () => { + const option = { integrationId: 11, integrationLabel: "posthog", - repository: "posthog/posthog", - }); + repository: "posthog/code", + }; + + expect( + repositoryOptionsEqual( + [option], + [{ ...option, integrationLabel: "PostHog GitHub" }], + ), + ).toBe(false); }); - it("converts an option into a reusable repository selection payload", () => { - const options = buildRepositoryOptions(integrations, { - 11: ["posthog/code"], - }); + it.each([ + [0, 2, null], + [1, 2, "Some GitHub repositories could not be loaded. Pull to retry."], + [2, 2, "Could not load GitHub repositories. Pull to retry."], + ])( + "maps repository failures to the expected warning", + (failedCount, totalCount, expected) => { + expect(repositoryLoadWarning(failedCount, totalCount)).toBe(expected); + }, + ); - const selection = toRepositorySelection(options[0] ?? null); + it("converts an option into a repository selection", () => { + const selection = toRepositorySelection({ + integrationId: 11, + integrationLabel: "posthog", + repository: "posthog/code", + }); expect(selection).toEqual({ integrationId: 11, repository: "posthog/code", }); expect(isRepositorySelectionComplete(selection)).toBe(true); - expect( - isRepositorySelectionComplete({ - integrationId: null, - repository: "posthog/code", - }), - ).toBe(false); }); }); diff --git a/apps/mobile/src/features/tasks/utils/repositorySelection.ts b/apps/mobile/src/features/tasks/utils/repositorySelection.ts index 9e7b2cc8a8..bff8441a6a 100644 --- a/apps/mobile/src/features/tasks/utils/repositorySelection.ts +++ b/apps/mobile/src/features/tasks/utils/repositorySelection.ts @@ -2,6 +2,7 @@ import type { Integration, RepositoryOption, RepositorySelection, + UserGithubIntegration, } from "../types"; function getIntegrationLabel(integration: Integration): string { @@ -17,26 +18,67 @@ export function buildRepositoryOptions( repositoriesByIntegration: Record, ): RepositoryOption[] { return integrations - .flatMap((integration) => { - const repositories = repositoriesByIntegration[integration.id] ?? []; - - return repositories.map((repository) => ({ + .flatMap((integration) => + (repositoriesByIntegration[integration.id] ?? []).map((repository) => ({ integrationId: integration.id, integrationLabel: getIntegrationLabel(integration), repository, - })); - }) + })), + ) + .sort((left, right) => left.repository.localeCompare(right.repository)); +} + +export function buildUserRepositoryOptions( + integrations: UserGithubIntegration[], + repositoriesByInstallation: Record, +): RepositoryOption[] { + return integrations + .flatMap((integration) => + (repositoriesByInstallation[integration.installation_id] ?? []).map( + (repository) => ({ + integrationId: Number(integration.installation_id), + integrationLabel: + integration.account?.name ?? + `GitHub ${integration.installation_id}`, + repository, + }), + ), + ) .sort((left, right) => left.repository.localeCompare(right.repository)); } +export function repositoryOptionsEqual( + left: RepositoryOption[], + right: RepositoryOption[], +): boolean { + return ( + left.length === right.length && + left.every((option, index) => { + const other = right[index]; + return ( + other?.integrationId === option.integrationId && + other.integrationLabel === option.integrationLabel && + other.repository === option.repository + ); + }) + ); +} + +export function repositoryLoadWarning( + failedCount: number, + totalCount: number, +): string | null { + if (failedCount === 0) return null; + return failedCount === totalCount + ? "Could not load GitHub repositories. Pull to retry." + : "Some GitHub repositories could not be loaded. Pull to retry."; +} + export function findRepositoryOption( options: RepositoryOption[], selection: RepositorySelection, ): RepositoryOption | null { - if (!selection.integrationId || !selection.repository) { - return null; - } - + if (!selection.integrationId || !selection.repository) return null; return ( options.find( (option) => From 228cd02ae843779bc8081682b669bd6b3ef94c1f Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Mon, 27 Jul 2026 07:44:14 +0100 Subject: [PATCH 30/43] feat(chat-thread): copy a turn from its footer, a message from its menu Every message row carried a hover copy button plus 36px of reserved right padding for it. Drop both. In its place: - Each completed turn's hover footer gains a "Copy turn" button beside its timestamp, copying that turn as plain text (prompt + agent prose; tools and thoughts left out). The windowed body carries the same text on the row that already carries the turn timestamp. - User messages get the same button in their own footer. - Right-clicking any user or agent message offers "Copy message". Both buttons are quill Button + Tooltip, muted-foreground in every state, and confirm with an anchored quill toast ("Copied!") fired off the copied flag -- so a rejected clipboard write never claims success. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BYPVPkN4Lm3gygchrpcoqr --- .../components/chat-thread/ChatThread.tsx | 325 ++++++++++-------- .../chat-thread/threadVirtualization.test.ts | 28 +- .../chat-thread/threadVirtualization.ts | 8 + .../chat-thread/turnCopyText.test.ts | 78 +++++ .../components/chat-thread/turnCopyText.ts | 31 ++ 5 files changed, 326 insertions(+), 144 deletions(-) create mode 100644 packages/ui/src/features/sessions/components/chat-thread/turnCopyText.test.ts create mode 100644 packages/ui/src/features/sessions/components/chat-thread/turnCopyText.ts diff --git a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx index 6a80be72ab..61175cb576 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx +++ b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx @@ -8,6 +8,7 @@ import { import { WorkerPoolContextProvider } from "@pierre/diffs/react"; import { useService } from "@posthog/di/react"; import { + Button, ChatBubble, ChatBubbleContent, ChatMarker, @@ -22,7 +23,14 @@ import { ChatMessageScrollerItem, ChatMessageScrollerProvider, ChatMessageScrollerViewport, + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, cn, + Tooltip, + TooltipContent, + TooltipTrigger, useChatMessageScroller, useChatMessageScrollerScrollable, useChatMessageScrollerVisibility, @@ -62,6 +70,7 @@ import { type ThreadScrollResume, type TurnRow, } from "@posthog/ui/features/sessions/components/chat-thread/threadVirtualization"; +import { buildTurnCopyText } from "@posthog/ui/features/sessions/components/chat-thread/turnCopyText"; import { usePromptRecallSource } from "@posthog/ui/features/sessions/components/chat-thread/usePromptRecallSource"; import { VirtualThreadScrollBody } from "@posthog/ui/features/sessions/components/chat-thread/VirtualThreadScrollBody"; import { GitActionMessage } from "@posthog/ui/features/sessions/components/GitActionMessage"; @@ -103,9 +112,9 @@ import { DIFF_WORKER_FACTORY, type DiffWorkerFactory, } from "@posthog/ui/shell/diffWorkerHost"; -import { IconButton, Tooltip } from "@radix-ui/themes"; import { memo, + type ReactElement, type ReactNode, type RefObject, useCallback, @@ -238,22 +247,59 @@ function formatTimestamp(ts: number): string { } /** - * Hover-revealed timestamp rendered right-aligned under agent-side content (the end-aligned user - * bubble keeps its own right-aligned footer). Sits inside a `group` container so it fades in only - * while that container is hovered. Shown once per completed agent turn (under the turn card) - * rather than on every message — per-row it was too noisy. + * Hover-revealed footer under a completed agent turn: the turn's timestamp plus a button copying + * the whole turn. Rendered right-aligned under agent-side content — the end-aligned user bubble + * keeps its own footer — inside a `group` container, so it fades in only while that turn is + * hovered. Once per turn rather than per row, which was too noisy. */ -function RowTimestamp({ timestamp }: { timestamp?: number }) { +function TurnFooter({ + timestamp, + copyText, +}: { + timestamp?: number; + copyText?: string; +}) { if (timestamp == null) return null; return ( {formatTimestamp(timestamp)} + {copyText && } ); } +/** + * Shared copy affordance for the message and turn footers. Stays muted whether idle or just-copied — + * the icon swap is the confirmation, so the row never lights up in a colour the thread doesn't use + * elsewhere. + */ +function CopyButton({ value, label }: { value: string; label: string }) { + const { copied, copy } = useCopy(); + const [hovered, setHovered] = useState(false); + return ( + // Held open for the life of the `copied` window so the confirmation lands even when the click + // moves the pointer off the button; hover drives it the rest of the time. + + copy(value)} + className="text-muted-foreground hover:text-foreground" + > + {copied ? : } + + } + /> + {copied ? "Copied!" : label} + + ); +} + /** * End-aligned user bubble. The text is clamped to five lines (`max-height: 5lh` + `overflow-hidden`, * which — unlike `-webkit-line-clamp` — reliably clamps markdown's block `

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

+ + + {showHeaderChips && ( + + {showChannelContextTag && channelContext && ( + } + label={`${ + channelContext.mention.name + ? `#${channelContext.mention.name} ` + : "" + }CONTEXT.md`} + onClick={ + taskId + ? () => + openChannelContextInSplit(taskId, { + channelName: channelContext.mention.name, + body: channelContext.mention.body, + }) + : undefined + } + /> )} - > - {containsFileMentions ? ( - parseFileMentions(displayContent) - ) : ( - + {showCanvasInstructionsTag && canvasInstructions && ( + } + label="Canvas instructions" + onClick={ + taskId + ? () => + openCanvasInstructionsInSplit(taskId, { + body: canvasInstructions.body, + }) + : undefined + } + /> )} -
- {attachments.length > 0 && !containsFileMentions && ( -
- -
+ + )} + setIsExpanded((v) => !v)} - className="mt-1 flex items-center gap-0.5 text-muted-foreground text-sm hover:text-foreground" + > + +
- Show {isExpanded ? "less" : "more"} - - - )} - - - {timestamp != null && ( - - {formatTimestamp(timestamp)} - - )} - - - + {containsFileMentions ? ( + parseFileMentions(displayContent) + ) : ( + + )} +
+ {attachments.length > 0 && !containsFileMentions && ( +
+ +
+ )} + {isOverflowing && ( + + )} +
+
+ {timestamp != null && ( + + {formatTimestamp(timestamp)} + + + )} + + + ); } /** - * Copy icon that floats into a message's right rail on hover. The hover-group qualifier differs by - * message type (`group` for user bubbles, `group/msg` for agent prose), so callers pass their own - * `revealClassName` (the `group-hover*:opacity-100` utility). + * Right-click a message to copy it. Replaces the per-message copy button that used to float in the + * message's right rail — the turn footer covers the common case, so a single message's copy lives + * here instead of costing every row a hover affordance. */ -function MessageCopyButton({ +function MessageContextMenu({ value, - revealClassName, + children, }: { value: string; - revealClassName: string; + children: ReactElement; }) { - const { copied, copy } = useCopy(); + const { copy } = useCopy(); return ( - - copy(value)} - className={cn( - "absolute top-1 right-1 cursor-pointer opacity-0 transition-opacity", - revealClassName, - )} - aria-label="Copy message" - > - {copied ? : } - - + + + + copy(value)}> + + Copy message + + + ); } @@ -488,25 +527,21 @@ const AgentProse = memo(function AgentProse({ const smoothed = useSmoothedText(text); return ( - - - - - {isStreaming ? ( - - ) : ( - - )} - - - - {isStreaming ? null : ( - - )} - + + + + + + {isStreaming ? ( + + ) : ( + + )} + + + + + ); }); @@ -591,7 +626,10 @@ const ThreadRow = memo(function ThreadRow({
))}
- + ); } @@ -927,7 +965,10 @@ const FlatRowView = memo( keyboardFocused={keyboardFocused} /> {row.turnTimestamp != null && ( - + )} ); diff --git a/packages/ui/src/features/sessions/components/chat-thread/threadVirtualization.test.ts b/packages/ui/src/features/sessions/components/chat-thread/threadVirtualization.test.ts index 15db845688..3b44615c95 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/threadVirtualization.test.ts +++ b/packages/ui/src/features/sessions/components/chat-thread/threadVirtualization.test.ts @@ -21,14 +21,15 @@ function sessionUpdate( { turnComplete = false, timestamp, - }: { turnComplete?: boolean; timestamp?: number } = {}, + text, + }: { turnComplete?: boolean; timestamp?: number; text?: string } = {}, ): SessionUpdateItem { return { type: "session_update", id, update: { sessionUpdate: "agent_message_chunk", - content: { type: "text", text: `text ${id}` }, + content: { type: "text", text: text ?? `text ${id}` }, } as SessionUpdateItem["update"], turnContext: { toolCalls: new Map(), @@ -91,6 +92,29 @@ describe("flattenTurnRows", () => { expect(flat.map((r) => r.turnTimestamp)).toEqual([undefined, 1234]); }); + it("carries the turn's copy text on the same row as its timestamp", () => { + const done = agentTurn("d", [ + sessionUpdate("d1", { text: "first" }), + sessionUpdate("d2", { + turnComplete: true, + timestamp: 1234, + text: "last", + }), + ]); + const flat = flattenTurnRows([done]); + expect(flat.map((r) => r.turnCopyText)).toEqual([ + undefined, + "first\n\nlast", + ]); + }); + + it("leaves copy text off a turn that is still streaming", () => { + const streaming = agentTurn("s", [ + sessionUpdate("s1", { text: "partial" }), + ]); + expect(flattenTurnRows([streaming])[0].turnCopyText).toBeUndefined(); + }); + it("reads a trailing tool group's timestamp from its last tool", () => { const turn = agentTurn("t", [ sessionUpdate("t1"), diff --git a/packages/ui/src/features/sessions/components/chat-thread/threadVirtualization.ts b/packages/ui/src/features/sessions/components/chat-thread/threadVirtualization.ts index 92248198cd..5aed57f403 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/threadVirtualization.ts +++ b/packages/ui/src/features/sessions/components/chat-thread/threadVirtualization.ts @@ -1,5 +1,6 @@ import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems"; import type { ToolGroupItem } from "@posthog/ui/features/sessions/components/chat-thread/ToolGroup"; +import { buildTurnCopyText } from "@posthog/ui/features/sessions/components/chat-thread/turnCopyText"; /** A row is either a parsed conversation item or a synthesized group of tool calls. */ export type ThreadItem = ConversationItem | ToolGroupItem; @@ -74,6 +75,8 @@ export interface FlatThreadRow { isTrailingInTurn: boolean; /** Set on the last row of a completed turn; renders the turn's hover timestamp under it. */ turnTimestamp?: number; + /** Set alongside {@link turnTimestamp}: the whole turn as plain text, for its copy button. */ + turnCopyText?: string; } /** @@ -98,6 +101,10 @@ export function flattenTurnRows(rows: TurnRow[]): FlatThreadRow[] { for (const row of rows) { if (row.type === "agent_turn") { const timestamp = completedTurnTimestamp(row); + const copyText = + timestamp == null + ? undefined + : (buildTurnCopyText(row.items) ?? undefined); for (let i = 0; i < row.items.length; i++) { const item = row.items[i]; const isTrailing = i === row.items.length - 1; @@ -107,6 +114,7 @@ export function flattenTurnRows(rows: TurnRow[]): FlatThreadRow[] { inTurn: true, isTrailingInTurn: isTrailing, turnTimestamp: isTrailing ? timestamp : undefined, + turnCopyText: isTrailing ? copyText : undefined, }); } continue; diff --git a/packages/ui/src/features/sessions/components/chat-thread/turnCopyText.test.ts b/packages/ui/src/features/sessions/components/chat-thread/turnCopyText.test.ts new file mode 100644 index 0000000000..01ead18162 --- /dev/null +++ b/packages/ui/src/features/sessions/components/chat-thread/turnCopyText.test.ts @@ -0,0 +1,78 @@ +import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems"; +import type { ToolGroupItem } from "@posthog/ui/features/sessions/components/chat-thread/ToolGroup"; +import { describe, expect, it } from "vitest"; +import { buildTurnCopyText } from "./turnCopyText"; + +function userMessage(id: string, content: string): ConversationItem { + return { type: "user_message", id, content, timestamp: 0 }; +} + +function agentText(id: string, text: string): ConversationItem { + return { + type: "session_update", + id, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text }, + }, + turnContext: { + toolCalls: new Map(), + childItems: new Map(), + turnCancelled: false, + turnComplete: true, + }, + } as ConversationItem; +} + +function toolCall(id: string): ConversationItem { + return { + type: "session_update", + id, + update: { + sessionUpdate: "tool_call", + toolCallId: id, + title: "Read file", + status: "completed", + }, + turnContext: { + toolCalls: new Map(), + childItems: new Map(), + turnCancelled: false, + turnComplete: true, + }, + } as ConversationItem; +} + +function toolGroup(id: string): ToolGroupItem { + return { type: "tool_group", id, tools: [] } as unknown as ToolGroupItem; +} + +describe("buildTurnCopyText", () => { + it("joins the rows' prose in order", () => { + const text = buildTurnCopyText([ + agentText("a1", "first paragraph"), + agentText("a2", "second paragraph"), + ]); + + expect(text).toBe("first paragraph\n\nsecond paragraph"); + }); + + it("skips tool calls, tool groups and other non-prose rows", () => { + const text = buildTurnCopyText([ + userMessage("u1", "do the thing"), + toolCall("t1"), + toolGroup("g1"), + agentText("a1", "done"), + ]); + + expect(text).toBe("do the thing\n\ndone"); + }); + + it.each([ + ["no items", [] as ConversationItem[]], + ["tools only", [toolCall("t1"), toolGroup("g1")]], + ["blank prose", [userMessage("u1", " "), agentText("a1", "\n")]], + ])("returns null when there is nothing to copy: %s", (_label, items) => { + expect(buildTurnCopyText(items)).toBeNull(); + }); +}); diff --git a/packages/ui/src/features/sessions/components/chat-thread/turnCopyText.ts b/packages/ui/src/features/sessions/components/chat-thread/turnCopyText.ts new file mode 100644 index 0000000000..31c11715bf --- /dev/null +++ b/packages/ui/src/features/sessions/components/chat-thread/turnCopyText.ts @@ -0,0 +1,31 @@ +import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems"; +import type { ToolGroupItem } from "@posthog/ui/features/sessions/components/chat-thread/ToolGroup"; + +/** + * Plain-text transcript of a turn's rows: user prompts and agent prose, in order. + * + * Tool calls, thoughts and status rows are left out — this is for pasting an answer somewhere else, + * not for reproducing the run. Returns null when the rows carry no prose. + */ +export function buildTurnCopyText( + items: Array, +): string | null { + const parts: string[] = []; + + for (const item of items) { + if (item.type === "user_message") { + const content = item.content.trim(); + if (content) parts.push(content); + continue; + } + if (item.type !== "session_update") continue; + const update = item.update; + if (update.sessionUpdate !== "agent_message_chunk") continue; + if (update.content.type !== "text") continue; + const text = update.content.text.trim(); + if (text) parts.push(text); + } + + if (parts.length === 0) return null; + return parts.join("\n\n"); +} From cd2f82445e808f04ac0e021007c42ae8d4922b1c Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Mon, 27 Jul 2026 13:47:00 +0100 Subject: [PATCH 31/43] small nits --- .../src/features/sessions/components/chat-thread/ChatThread.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx index 61175cb576..5a85a63033 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx +++ b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx @@ -637,7 +637,7 @@ const ThreadRow = memo(function ThreadRow({ Date: Mon, 27 Jul 2026 13:50:04 +0100 Subject: [PATCH 32/43] fix(chat-thread): keep the raw-logs toggle on message right-click The per-message context menu sits inside SessionView's own menu and wins the event, so right-clicking a message was the one spot in the session where "Show raw logs" went missing. Carry the toggle (reading the same sessionViewStore state) alongside "Copy message". Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BYPVPkN4Lm3gygchrpcoqr --- .../components/chat-thread/ChatThread.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx index 5a85a63033..4912b3c17b 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx +++ b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx @@ -26,6 +26,7 @@ import { ContextMenu, ContextMenuContent, ContextMenuItem, + ContextMenuSeparator, ContextMenuTrigger, cn, Tooltip, @@ -99,6 +100,10 @@ import { useOptimisticItemsForTask, useSessionIsCloud, } from "@posthog/ui/features/sessions/sessionStore"; +import { + useSessionViewActions, + useShowRawLogs, +} from "@posthog/ui/features/sessions/sessionViewStore"; import { useThreadScrollRequest } from "@posthog/ui/features/sessions/threadNavigationStore"; import type { UserMessageAttachment } from "@posthog/ui/features/sessions/userMessageTypes"; import { @@ -486,6 +491,10 @@ function UserBubble({ * Right-click a message to copy it. Replaces the per-message copy button that used to float in the * message's right rail — the turn footer covers the common case, so a single message's copy lives * here instead of costing every row a hover affordance. + * + * This menu sits inside `SessionView`'s own context menu and wins the event over it, so it also + * carries that menu's raw-logs toggle; without it, right-clicking a message would be the one spot + * in the session where the toggle went missing. */ function MessageContextMenu({ value, @@ -495,6 +504,8 @@ function MessageContextMenu({ children: ReactElement; }) { const { copy } = useCopy(); + const showRawLogs = useShowRawLogs(); + const { setShowRawLogs } = useSessionViewActions(); return ( @@ -503,6 +514,11 @@ function MessageContextMenu({ Copy message + + setShowRawLogs(!showRawLogs)}> + + {showRawLogs ? "Back to conversation" : "Show raw logs"} + ); From d36a03eac271e6d02d5ae783e76afbb4f8837148 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Wed, 29 Jul 2026 11:22:47 +0100 Subject: [PATCH 33/43] feat(ui): shared page header, space breadcrumbs, artifact views Introduce a compound PageHeader primitive (header shell, heading, title, chip, description, actions, sub-nav, filters) and adopt it on Inbox, Activity, Loops, the space Artifacts / Context / Loops pages. Each adoption keeps its previous header on the flag-off branch: space pages switch with the spaces layout (useChannelsLayout), Inbox and the global Loops list with project-bluebird. Space wayfinding now reads "{space} / {page}" everywhere, with labels and icons resolved from one CHANNEL_PAGES table that the sidebar rows share, so the two can't drift. ChannelBreadcrumb renders every segment as a button (uniform padding/height), marks non-navigable segments aria-disabled without the disabled dimming, gained an optional middle segment ("{space} / Loops / {loop}"), and opens the rename editor on a single click at matching type scale. Artifacts gains list / grid / masonry views with live canvas previews, persisted per device; the freeform preview moves out of the dashboards grid so both surfaces share it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BYPVPkN4Lm3gygchrpcoqr --- .../canvas/components/ActivityView.tsx | 174 +++++++---- .../components/ChannelBreadcrumb.test.tsx | 91 +++++- .../canvas/components/ChannelBreadcrumb.tsx | 242 +++++++++++----- .../canvas/components/ChannelHeader.tsx | 59 +++- .../canvas/components/ChannelSidebar.tsx | 30 +- .../components/WebsiteChannelArtifacts.tsx | 172 ++++++----- .../components/WebsiteChannelHistory.tsx | 5 +- .../canvas/components/WebsiteChannelHome.tsx | 5 +- .../canvas/components/WebsiteChannelLoops.tsx | 120 +++++--- .../canvas/components/WebsiteContext.tsx | 32 +- .../canvas/components/WebsiteLayout.tsx | 7 +- .../canvas/components/channelPages.test.ts | 24 ++ .../canvas/components/channelPages.tsx | 53 ++++ .../components/CommandCenterView.tsx | 26 +- .../inbox/components/InboxPageHeader.tsx | 51 +++- .../features/inbox/components/InboxTabBar.tsx | 96 +++--- .../loops/components/LoopDetailView.tsx | 30 +- .../features/loops/components/LoopForm.tsx | 23 +- .../loops/components/LoopSpaceBreadcrumb.tsx | 51 ++++ .../components/LoopsListView.stories.tsx | 5 + .../loops/components/LoopsListView.test.tsx | 44 +++ .../loops/components/LoopsListView.tsx | 274 +++++++++++++----- .../task-detail/HeaderTitleEditor.tsx | 13 +- .../ui/src/primitives/PageHeader.stories.tsx | 108 +++++++ packages/ui/src/primitives/PageHeader.tsx | 189 ++++++++++++ packages/ui/src/shell/ContentHeader.tsx | 18 +- 26 files changed, 1501 insertions(+), 441 deletions(-) create mode 100644 packages/ui/src/features/canvas/components/channelPages.test.ts create mode 100644 packages/ui/src/features/canvas/components/channelPages.tsx create mode 100644 packages/ui/src/features/loops/components/LoopSpaceBreadcrumb.tsx create mode 100644 packages/ui/src/primitives/PageHeader.stories.tsx create mode 100644 packages/ui/src/primitives/PageHeader.tsx diff --git a/packages/ui/src/features/canvas/components/ActivityView.tsx b/packages/ui/src/features/canvas/components/ActivityView.tsx index 136489b667..5ca1c131ad 100644 --- a/packages/ui/src/features/canvas/components/ActivityView.tsx +++ b/packages/ui/src/features/canvas/components/ActivityView.tsx @@ -31,6 +31,15 @@ import { useMarkTaskActivityRead } from "@posthog/ui/features/canvas/hooks/useMa import { useTaskActivity } from "@posthog/ui/features/canvas/hooks/useTaskActivity"; import { copyChannelLink } from "@posthog/ui/features/canvas/utils/copyChannelLink"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; +import { + PageHeader, + PageHeaderActions, + PageHeaderChip, + PageHeaderDescription, + PageHeaderHeading, + PageHeaderTitle, + PageHeaderTitleRow, +} from "@posthog/ui/primitives/PageHeader"; import { navigateToChannelTask, navigateToTaskDetail, @@ -287,77 +296,116 @@ export function ActivityView() { }); }, []); - return ( -
-
-
-
- - Activity - - - Tasks you're involved in across{" "} - {spacesLayout ? "spaces" : "channels"}. - -
- {unreadCount > 0 && ( + const markAllReadButton = + unreadCount > 0 ? ( + + ) : null; + + const feed = ( + <> + {isLoading && items.length === 0 ? ( +
+ +
+ ) : items.length === 0 ? ( + + + + + + No activity yet + + Tasks you create, get tagged in, or reply to across{" "} + {spacesLayout ? "spaces" : "channels"} land here. + + + + ) : ( +
+ {items.map((item) => ( + + ))} + {hasNextPage && ( )}
-
- {isLoading && items.length === 0 ? ( -
- -
- ) : items.length === 0 ? ( - - - - - - No activity yet - - Tasks you create, get tagged in, or reply to across{" "} - {spacesLayout ? "spaces" : "channels"} land here. - - - - ) : ( -
- {items.map((item) => ( - - ))} - {hasNextPage && ( - - )} + )} + + ); + + // The shared page header ships with the spaces layout; without it the page + // keeps the in-container title it has always had. Delete the legacy branch + // when the layout flag graduates. + if (!spacesLayout) { + return ( +
+
+
+
+ + Activity + + + Tasks you're involved in across{" "} + {spacesLayout ? "spaces" : "channels"}. +
- )} + {markAllReadButton} +
+
{feed}
+ ); + } + + return ( +
+ + + + Activity + {unreadCount > 0 && ( + }> + {unreadCount} unread + + )} + {markAllReadButton && ( + {markAllReadButton} + )} + + + Tasks you're involved in across{" "} + {spacesLayout ? "spaces" : "channels"}. + + + +
+
{feed}
+
); } diff --git a/packages/ui/src/features/canvas/components/ChannelBreadcrumb.test.tsx b/packages/ui/src/features/canvas/components/ChannelBreadcrumb.test.tsx index f7124726ee..0353b144bf 100644 --- a/packages/ui/src/features/canvas/components/ChannelBreadcrumb.test.tsx +++ b/packages/ui/src/features/canvas/components/ChannelBreadcrumb.test.tsx @@ -1,9 +1,21 @@ import { Theme } from "@radix-ui/themes"; import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Hoisted so the module factory below can read them, and each test can steer +// the current route / assert navigations. +const router = vi.hoisted(() => ({ + pathname: "/website/team/artifacts", + navigate: vi.fn(), +})); vi.mock("@tanstack/react-router", () => ({ - useNavigate: () => vi.fn(), + useNavigate: () => router.navigate, + useRouterState: ({ + select, + }: { + select: (state: { location: { pathname: string } }) => T; + }) => select({ location: { pathname: router.pathname } }), })); vi.mock("@posthog/ui/features/canvas/hooks/useChannelsLayout", () => ({ useChannelsLayout: () => true, @@ -12,6 +24,11 @@ vi.mock("@posthog/ui/features/canvas/hooks/useChannelsLayout", () => ({ import { ChannelBreadcrumb } from "./ChannelBreadcrumb"; describe("ChannelBreadcrumb", () => { + beforeEach(() => { + router.pathname = "/website/team/artifacts"; + router.navigate.mockClear(); + }); + it("closes title editing when the editable leaf changes", () => { const onRename = vi.fn(); const { rerender } = render( @@ -25,7 +42,14 @@ describe("ChannelBreadcrumb", () => { , ); - fireEvent.doubleClick(screen.getByText("Task A")); + // A renamable leaf stays a live control, so it isn't marked disabled. + expect(screen.getByRole("button", { name: "Task A" })).not.toHaveAttribute( + "aria-disabled", + ); + + // One click opens the editor — the leaf never navigates, so a click has + // nothing else to mean. + fireEvent.click(screen.getByRole("button", { name: "Task A" })); expect(screen.getByRole("textbox")).toHaveValue("Task A"); rerender( @@ -43,4 +67,65 @@ describe("ChannelBreadcrumb", () => { expect(screen.getByText("Task B")).toBeInTheDocument(); expect(onRename).not.toHaveBeenCalled(); }); + + it("navigates home from the root segment on a sub-page", () => { + render( + + + , + ); + + const root = screen.getByRole("button", { name: /Team/ }); + expect(root).not.toHaveAttribute("aria-disabled", "true"); + fireEvent.click(root); + expect(router.navigate).toHaveBeenCalledWith({ + to: "/website/$channelId", + params: { channelId: "team" }, + }); + }); + + it("links the middle segment to its section", () => { + const onMiddleClick = vi.fn(); + render( + + + , + ); + + // Every segment is a Button so they share padding and height; the leaf is + // the current page, so it's the disabled one. + expect(screen.getAllByRole("button")).toHaveLength(3); + fireEvent.click(screen.getByRole("button", { name: "Loops" })); + expect(onMiddleClick).toHaveBeenCalledTimes(1); + expect( + screen.getByRole("button", { name: "CI failure summary" }), + ).toHaveAttribute("aria-disabled", "true"); + }); + + it("disables the root segment on the space's own index", () => { + router.pathname = "/website/team"; + render( + + + , + ); + + const root = screen.getByRole("button", { name: /Team/ }); + expect(root).toHaveAttribute("aria-disabled", "true"); + fireEvent.click(root); + expect(router.navigate).not.toHaveBeenCalled(); + }); }); diff --git a/packages/ui/src/features/canvas/components/ChannelBreadcrumb.tsx b/packages/ui/src/features/canvas/components/ChannelBreadcrumb.tsx index e47614aa05..670f071518 100644 --- a/packages/ui/src/features/canvas/components/ChannelBreadcrumb.tsx +++ b/packages/ui/src/features/canvas/components/ChannelBreadcrumb.tsx @@ -1,5 +1,6 @@ import { Button, + cn, Tooltip, TooltipContent, TooltipTrigger, @@ -8,7 +9,7 @@ import { channelGlyph } from "@posthog/ui/features/canvas/components/channelGlyp import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { HeaderTitleEditor } from "@posthog/ui/features/task-detail/HeaderTitleEditor"; import { Flex, Text } from "@radix-ui/themes"; -import { useNavigate } from "@tanstack/react-router"; +import { useNavigate, useRouterState } from "@tanstack/react-router"; import { type ReactNode, useState } from "react"; interface ChannelBreadcrumbProps { @@ -19,14 +20,23 @@ interface ChannelBreadcrumbProps { * sidebar channel row and the channel-view header. */ channelId?: string; + /** + * An optional segment between the space and the leaf — the section a scene + * belongs to, e.g. "{space} / Loops / {loop}". `onClick` links it; without + * one it reads as a plain step. + */ + middle?: { icon?: ReactNode; label: string; onClick?: () => void }; /** Optional leading icon for the leaf segment (e.g. a canvas's tier icon). */ leafIcon?: ReactNode; - /** The trailing (current page) segment label. */ - leafLabel: string; + /** + * The trailing (current page) segment label. Omitted at a space's root, which + * renders the channel segment alone — same size and styling either way. + */ + leafLabel?: string; editScopeKey?: string; /** - * When provided, the leaf becomes inline-editable: double-click to rename, - * Enter or blur to submit, Escape to cancel. Receives the trimmed new value. + * When provided, the leaf becomes inline-editable: click to rename, Enter or + * blur to submit, Escape to cancel. Receives the trimmed new value. */ onRename?: (next: string) => void; /** Right-aligned slot pushed to the far end of the bar (e.g. an opener). */ @@ -35,11 +45,13 @@ interface ChannelBreadcrumbProps { // "# channel / leaf" header breadcrumb shared across channel scenes (CONTEXT.md, // new + existing tasks, canvases). The leaf can carry a tier icon and, when -// onRename is given, edits inline using the same editor as task titles. When -// channelId is given, the "# channel" segment links back to the channel home. +// onRename is given, edits inline on a single click using the same editor as +// task titles. When channelId is given, the "# channel" segment links back to +// the channel home. export function ChannelBreadcrumb({ channelName, channelId, + middle, leafIcon, leafLabel, editScopeKey, @@ -47,84 +59,164 @@ export function ChannelBreadcrumb({ trailing, }: ChannelBreadcrumbProps) { const spacesLayout = useChannelsLayout(); - const currentEditScope = editScopeKey ?? leafLabel; + // Only a leaf is renamable, so the scope key falls back to its label. + const currentEditScope = editScopeKey ?? leafLabel ?? ""; const [editingScope, setEditingScope] = useState(null); const editing = editingScope === currentEditScope; const navigate = useNavigate(); - - const channelSegment = ( - <> - {channelGlyph(channelName, { - size: 12, - space: spacesLayout, - className: "mt-px shrink-0 text-muted-foreground/80", - })} - - {channelName} - - - ); + const pathname = useRouterState({ select: (s) => s.location.pathname }); + const atChannelHome = channelId + ? pathname === `/website/${channelId}` + : false; return ( - - {channelId ? ( - - ) : ( -
{channelSegment}
- )} - / -
- {leafIcon && ( - {leafIcon} - )} - {editing && onRename ? ( - { - setEditingScope(null); - onRename(next); - }} - onCancel={() => setEditingScope(null)} + {/* flex-1 so the inline editor can stretch across the row; the trailing + slot still sits at the far end. */} + + + void navigate({ + to: "/website/$channelId", + params: { channelId }, + }) + : undefined + } + /> + {middle && ( + <> + + - ) : ( - - setEditingScope(currentEditScope) - : undefined - } + + )} + {leafLabel !== undefined && ( + <> + + {editing && onRename ? ( + // Matches the segment it replaces — same height, padding and type + // scale as a `size="sm"` button — so opening the editor doesn't + // jump the row. It takes the rest of the row, since a long name is + // exactly what you're most likely to be editing. + { + setEditingScope(null); + onRename(next); + }} + onCancel={() => setEditingScope(null)} + className="h-6 px-2 font-normal text-[13px]" + /> + ) : onRename ? ( + // Only a renamable leaf gets a tooltip: it carries a user-authored + // name that can be long enough to truncate. Fixed section labels + // never overflow, so a tooltip there is just noise. + + }> + {/* A renamable leaf is a live control — a click opens the + editor — so it reads as one: full-strength text, pointer + cursor, hover fill. */} + setEditingScope(currentEditScope)} /> - } - > - {leafLabel} - - {leafLabel} - - )} -
+ + {leafLabel} + + ) : ( + + )} + + )}
{trailing}
); } + +/** + * One segment of the breadcrumb. Always a Button, so every segment carries the + * same padding, height and icon gap whether or not it goes anywhere — the leaf + * used to be bare text, which left it visually adrift from its siblings. + * + * Without `onClick` the segment is genuinely inert: `aria-disabled` (so quill + * drops the hover fill and assistive tech reads it as unavailable) plus + * `pointer-events-none`, and out of the tab order. The disabled dimming is + * overridden — a breadcrumb has to stay readable. + */ +function BreadcrumbSegment({ + icon, + label, + strong, + muted, + onClick, + ...rest +}: { + icon?: ReactNode; + label: string; + /** The root segment carries the space name, which reads heavier. */ + strong?: boolean; + /** The leaf is the current page, so it sits back from the linked segments. */ + muted?: boolean; + /** Navigates, or (on a renamable leaf) opens the inline editor. */ + onClick?: () => void; +}) { + const interactive = Boolean(onClick); + + return ( + + ); +} + +function BreadcrumbSeparator() { + return ( + / + ); +} diff --git a/packages/ui/src/features/canvas/components/ChannelHeader.tsx b/packages/ui/src/features/canvas/components/ChannelHeader.tsx index 289cb5b713..40f65fb29e 100644 --- a/packages/ui/src/features/canvas/components/ChannelHeader.tsx +++ b/packages/ui/src/features/canvas/components/ChannelHeader.tsx @@ -1,25 +1,64 @@ import { Button, cn } from "@posthog/quill"; +import { ChannelBreadcrumb } from "@posthog/ui/features/canvas/components/ChannelBreadcrumb"; import { ChannelTabs } from "@posthog/ui/features/canvas/components/ChannelTabs"; import { channelGlyph } from "@posthog/ui/features/canvas/components/channelGlyph"; +import { + type ChannelPageKey, + channelPageIcon, + channelPageLabel, +} from "@posthog/ui/features/canvas/components/channelPages"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { useMarkChannelSeen } from "@posthog/ui/features/canvas/hooks/useMarkChannelSeen"; import { Text } from "@radix-ui/themes"; import { useNavigate, useRouterState } from "@tanstack/react-router"; -// The shared channel header. The new layout drops the section tab strip — the -// channel sidebar carries those entries — while flag off keeps it. Starring -// lives on the sidebar back row and the channel list, not here. -export function ChannelHeader({ channelId }: { channelId: string }) { - const navigate = useNavigate(); +// The shared channel header. Every space scene renders the same breadcrumb — +// the root segment is identical whether or not there's a leaf, so the space +// name doesn't change size between the space home and its sub-pages. The new +// layout drops the section tab strip (the channel sidebar carries those +// entries); flag off keeps it. Starring lives on the sidebar back row and the +// channel list, not here. +export function ChannelHeader({ + channelId, + page, +}: { + channelId: string; + /** + * Which space page this is — supplies the leaf's label and icon. Every space + * page names itself, the feed included ("{space} / Feed"); omitting it leaves + * the root segment alone, for scenes that carry no page of their own. + */ + page?: ChannelPageKey; +}) { const channelsLayout = useChannelsLayout(); const { channels } = useChannels(); const channelName = channels.find((c) => c.id === channelId)?.name; - const pathname = useRouterState({ select: (s) => s.location.pathname }); - const isHome = pathname === `/website/${channelId}`; // Every channel surface renders this header, so mark the channel read here. useMarkChannelSeen(channelName); + // Channels-layout off keeps the header it has always had: the channel pill + // plus the section tab strip, no breadcrumb. Delete this branch when the + // layout flag graduates. + if (!channelsLayout) return ; + + return ( + + ); +} + +function LegacyChannelHeader({ channelId }: { channelId: string }) { + const navigate = useNavigate(); + const { channels } = useChannels(); + const channelName = channels.find((c) => c.id === channelId)?.name; + const pathname = useRouterState({ select: (s) => s.location.pathname }); + const isHome = pathname === `/website/${channelId}`; + return (
- {!channelsLayout && } +
); } diff --git a/packages/ui/src/features/canvas/components/ChannelSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelSidebar.tsx index ac5bd8ebd8..156ac81e31 100644 --- a/packages/ui/src/features/canvas/components/ChannelSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ChannelSidebar.tsx @@ -1,10 +1,8 @@ import { - BookOpenTextIcon, ChatsCircleIcon, FunnelSimple as FunnelSimpleIcon, MagnifyingGlass, PackageIcon, - RepeatIcon, } from "@phosphor-icons/react"; import type { CreatedByFilter } from "@posthog/core/canvas/channelItems"; import { filterChannelItems } from "@posthog/core/canvas/channelItems"; @@ -32,6 +30,11 @@ import type { TaskRunStatus } from "@posthog/shared/domain-types"; import { ChannelBackRow } from "@posthog/ui/features/canvas/components/ChannelBackRow"; import { ChannelItemRow } from "@posthog/ui/features/canvas/components/ChannelItemRow"; import { ChannelsFab } from "@posthog/ui/features/canvas/components/ChannelsFab"; +import { + type ChannelPageKey, + channelPageIcon, + channelPageLabel, +} from "@posthog/ui/features/canvas/components/channelPages"; import { useChannelItems } from "@posthog/ui/features/canvas/hooks/useChannelItems"; import { useCommandCenterStore } from "@posthog/ui/features/command-center/commandCenterStore"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; @@ -42,7 +45,7 @@ import { useTasks } from "@posthog/ui/features/tasks/useTasks"; import { navigateToCommandCenter } from "@posthog/ui/router/navigationBridge"; import { logger } from "@posthog/ui/shell/logger"; import { useNavigate, useRouterState } from "@tanstack/react-router"; -import { type ReactNode, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; const CREATED_BY_OPTIONS: readonly { value: CreatedByFilter; label: string }[] = [ @@ -296,16 +299,17 @@ export function ChannelSidebar({ channelId }: { channelId: string }) { /> ); + // Label and icon come from the shared space-page table, so a sidebar row and + // the header breadcrumb for the same page can never disagree. const sectionRow = ( - label: string, - icon: ReactNode, + page: ChannelPageKey, to: string, onClick: () => void, ) => ( @@ -317,15 +321,13 @@ export function ChannelSidebar({ channelId }: { channelId: string }) {
{sectionRow( - "Feed", - , + "home", base, () => void navigate({ to: "/website/$channelId", params: { channelId } }), )} {sectionRow( - "Context", - , + "context", `${base}/context`, () => void navigate({ @@ -335,8 +337,7 @@ export function ChannelSidebar({ channelId }: { channelId: string }) { )} {loopsEnabled && sectionRow( - "Loops", - , + "loops", `${base}/loops`, () => void navigate({ @@ -345,8 +346,7 @@ export function ChannelSidebar({ channelId }: { channelId: string }) { }), )} {sectionRow( - "Artifacts", - , + "artifacts", `${base}/artifacts`, () => void navigate({ diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx index b107531ad7..61e693a69e 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx @@ -29,6 +29,15 @@ import { masonryPreviewHeight } from "@posthog/ui/features/canvas/utils/masonryP import { usePrArtifact } from "@posthog/ui/features/git-interaction/usePrArtifact"; import { useTasks } from "@posthog/ui/features/tasks/useTasks"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; +import { + PageHeader, + PageHeaderActions, + PageHeaderChip, + PageHeaderDescription, + PageHeaderHeading, + PageHeaderTitle, + PageHeaderTitleRow, +} from "@posthog/ui/primitives/PageHeader"; import { track } from "@posthog/ui/shell/analytics"; import { openExternalUrl } from "@posthog/ui/shell/openExternal"; import { useNavigate } from "@tanstack/react-router"; @@ -74,7 +83,10 @@ export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) { }, [channelId]); useSetHeaderContent( - useMemo(() => , [channelId]), + useMemo( + () => , + [channelId], + ), ); const { dashboards } = useDashboards(channelId); @@ -145,77 +157,105 @@ export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) { ); return ( -
- {/* The list reads best narrow; card layouts want the full width. */} -
-
- - {items.length === 0 - ? "Artifacts" - : `${items.length} artifact${items.length === 1 ? "" : "s"}`} - - -
+
+ {/* Full-bleed header over a container-width body — the Inbox shape. Ships + behind the spaces layout like every other space page header; off, + the view switcher rides above the list instead. */} + {spacesLayout ? ( + + + + Artifacts + {items.length > 0 && ( + }> + {items.length} item{items.length === 1 ? "" : "s"} + + )} + + + + + + Canvases and pull requests from this{" "} + {spacesLayout ? "space's" : "channel's"} tasks. + + + + ) : null} - {items.length === 0 ? ( - - - - - - No artifacts yet - - Canvases and pull requests from this{" "} - {spacesLayout ? "space's" : "channel's"} tasks show up here. - - - - ) : view === "list" ? ( -
- {items.map((item) => ( - - ))} -
- ) : view === "grid" ? ( -
- {items.map((item) => ( - - ))} -
- ) : ( - // CSS columns rather than a JS masonry: cards are self-contained and - // never reflow into each other, so break-inside-avoid is enough. The - // trade-off is column-major order — newest runs down column one, not - // across the row — which is fine for a browse-y wall of previews. -
- {items.map((item) => ( -
+
+ {/* The list reads best narrow; card layouts want the full width. */} +
+ {!spacesLayout && ( +
+ + {items.length === 0 + ? "Artifacts" + : `${items.length} artifact${items.length === 1 ? "" : "s"}`} + + +
+ )} + {items.length === 0 ? ( + + + + + + No artifacts yet + + Canvases and pull requests from this{" "} + {spacesLayout ? "space's" : "channel's"} tasks show up here. + + + + ) : view === "list" ? ( +
+ {items.map((item) => ( + + ))} +
+ ) : view === "grid" ? ( +
+ {items.map((item) => ( -
- ))} -
- )} + ))} +
+ ) : ( + // CSS columns rather than a JS masonry: cards are self-contained and + // never reflow into each other, so break-inside-avoid is enough. The + // trade-off is column-major order — newest runs down column one, not + // across the row — which is fine for a browse-y wall of previews. +
+ {items.map((item) => ( +
+ +
+ ))} +
+ )} +
); diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelHistory.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHistory.tsx index 4780ce51b0..2dfe4a6106 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelHistory.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelHistory.tsx @@ -42,7 +42,10 @@ export function WebsiteChannelHistory({ channelId }: { channelId: string }) { }, [channelId]); useSetHeaderContent( - useMemo(() => , [channelId]), + useMemo( + () => , + [channelId], + ), ); const { dashboards } = useDashboards(channelId); diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx index bee6dd19a9..ba9c092d2c 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx @@ -96,7 +96,10 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { }, [backendChannel, feedMessages]); useSetHeaderContent( - useMemo(() => , [channelId]), + useMemo( + () => , + [channelId], + ), ); const composerRef = useRef(null); diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelLoops.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelLoops.tsx index 11a1e9f08f..ae3e58a8f9 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelLoops.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelLoops.tsx @@ -1,7 +1,17 @@ import { CloudIcon, PlusIcon } from "@phosphor-icons/react"; import { ChannelHeader } from "@posthog/ui/features/canvas/components/ChannelHeader"; +import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; import { Button } from "@posthog/ui/primitives/Button"; +import { + PageHeader, + PageHeaderActions, + PageHeaderChip, + PageHeaderDescription, + PageHeaderHeading, + PageHeaderTitle, + PageHeaderTitleRow, +} from "@posthog/ui/primitives/PageHeader"; import { navigateToNewLoop } from "@posthog/ui/router/navigationBridge"; import { Flex, Heading, Text } from "@radix-ui/themes"; import { useMemo } from "react"; @@ -44,6 +54,7 @@ function contextQuickStarts(name: string): { label: string; prompt: string }[] { * this context. `channelId` is the desktop folder id, matching `context_target.folder_id`. */ export function WebsiteChannelLoops({ channelId }: { channelId: string }) { const { data: loops, isLoading, isError } = useLoops(); + const spacesLayout = useChannelsLayout(); const limits = useLoopLimits(); const limitReason = limits?.atLimit === true @@ -54,7 +65,10 @@ export function WebsiteChannelLoops({ channelId }: { channelId: string }) { const contextName = channel?.name ?? channelId; useSetHeaderContent( - useMemo(() => , [channelId]), + useMemo( + () => , + [channelId], + ), ); const attachedLoops = useMemo( @@ -82,55 +96,81 @@ export function WebsiteChannelLoops({ channelId }: { channelId: string }) { navigateToNewLoop(); }; + const createButton = ( + + ); + return ( + {/* The shared page header ships with the spaces layout; without it the + in-container title block below is used. Delete that branch when the + layout flag graduates. */} + {spacesLayout && ( + + + + Automate #{contextName} + }> + Runs entirely in the cloud + + {createButton} + + + Build a loop that posts its runs to this context's feed, or keeps + its context.md or a canvas up to date. + + + + )}
-
- - - - Automate #{contextName} - - - - - Runs entirely in the cloud - + {!spacesLayout && ( +
+ + + + Automate #{contextName} + + + + + Runs entirely in the cloud + + + + Build a loop that posts its runs to this context's feed, or + keeps its context.md or a canvas up to date. + - - Build a loop that posts its runs to this context's feed, or - keeps its context.md or a canvas up to date. - - - -
+ {createButton} +
+ )} {isLoading ? ( diff --git a/packages/ui/src/features/canvas/components/WebsiteContext.tsx b/packages/ui/src/features/canvas/components/WebsiteContext.tsx index 3c815483b2..4b8f783b37 100644 --- a/packages/ui/src/features/canvas/components/WebsiteContext.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteContext.tsx @@ -13,6 +13,7 @@ import { import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { ChannelHeader } from "@posthog/ui/features/canvas/components/ChannelHeader"; import { CreateChannelModal } from "@posthog/ui/features/canvas/components/CreateChannelModal"; +import { channelPageIcon } from "@posthog/ui/features/canvas/components/channelPages"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { @@ -22,6 +23,14 @@ import { } from "@posthog/ui/features/canvas/hooks/useFolderInstructions"; import { MarkdownRenderer } from "@posthog/ui/features/editor/components/MarkdownRenderer"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; +import { + PageHeader, + PageHeaderChip, + PageHeaderDescription, + PageHeaderHeading, + PageHeaderTitle, + PageHeaderTitleRow, +} from "@posthog/ui/primitives/PageHeader"; import { track } from "@posthog/ui/shell/analytics"; import { Box, @@ -90,7 +99,7 @@ export function WebsiteContext({ channelId }: WebsiteContextProps) { }, [latest?.content, hasDraft]); const headerContent = useMemo( - () => , + () => , [channelId], ); useSetHeaderContent(headerContent); @@ -163,6 +172,27 @@ export function WebsiteContext({ channelId }: WebsiteContextProps) { return ( + {/* The shared page header ships with the spaces layout; without it the + page opens straight onto its mode toolbar as it always has. */} + {spacesLayout && ( + + + + Context + {latest?.version != null && ( + + v{latest.version} + + )} + + + Background every agent working in this{" "} + {spacesLayout ? "space" : "channel"} reads before it starts — what + lives here, who cares about it, and how to work on it. + + + + )} } /> )} diff --git a/packages/ui/src/features/canvas/components/channelPages.test.ts b/packages/ui/src/features/canvas/components/channelPages.test.ts new file mode 100644 index 0000000000..fd420349b3 --- /dev/null +++ b/packages/ui/src/features/canvas/components/channelPages.test.ts @@ -0,0 +1,24 @@ +import { CHANNEL_SECTIONS } from "@posthog/ui/features/canvas/channelSections"; +import { + CHANNEL_PAGES, + type ChannelPageKey, +} from "@posthog/ui/features/canvas/components/channelPages"; +import { describe, expect, it } from "vitest"; + +describe("CHANNEL_PAGES", () => { + // The two tables are keyed the same on purpose: channelSections carries the + // route segment (plain data, read by non-UI code), CHANNEL_PAGES carries the + // label and icon. A section with no page entry would render an unlabelled, + // icon-less breadcrumb leaf, so fail here instead. + it("has an entry for every routable channel section", () => { + for (const section of CHANNEL_SECTIONS) { + expect(CHANNEL_PAGES[section.key as ChannelPageKey]).toBeDefined(); + } + }); + + it("gives every page a label", () => { + for (const page of Object.values(CHANNEL_PAGES)) { + expect(page.label.trim()).not.toBe(""); + } + }); +}); diff --git a/packages/ui/src/features/canvas/components/channelPages.tsx b/packages/ui/src/features/canvas/components/channelPages.tsx new file mode 100644 index 0000000000..74ad043e22 --- /dev/null +++ b/packages/ui/src/features/canvas/components/channelPages.tsx @@ -0,0 +1,53 @@ +import { + BookOpenTextIcon, + ChatsCircleIcon, + ClockCounterClockwiseIcon, + type Icon, + PackageIcon, + RepeatIcon, + ShapesIcon, +} from "@phosphor-icons/react"; +import type { ReactNode } from "react"; + +/** + * The pages inside a space, and how each one is named and drawn. One table so + * the sidebar rows, the header breadcrumb leaf, and anything else that points + * at a space page can't drift apart — add a page here, not at the call sites. + * + * `home` is the space's feed. It's both the root route and a named page, so it + * reads as "{space} / Feed" like every sibling rather than a bare space name. + * + * Route segments and browser-tab names live in `channelSections.ts`, which is + * plain data (no React) because non-UI code reads it. + */ +export type ChannelPageKey = + | "home" + | "context" + | "loops" + | "artifacts" + | "canvases" + | "history"; + +export const CHANNEL_PAGES: Record< + ChannelPageKey, + { label: string; Icon: Icon } +> = { + home: { label: "Feed", Icon: ChatsCircleIcon }, + context: { label: "Context", Icon: BookOpenTextIcon }, + loops: { label: "Loops", Icon: RepeatIcon }, + artifacts: { label: "Artifacts", Icon: PackageIcon }, + canvases: { label: "Canvases", Icon: ShapesIcon }, + history: { label: "Recents", Icon: ClockCounterClockwiseIcon }, +}; + +export function channelPageLabel(key: ChannelPageKey): string { + return CHANNEL_PAGES[key].label; +} + +export function channelPageIcon( + key: ChannelPageKey, + opts?: { size?: number; className?: string }, +): ReactNode { + const { Icon: PageIcon } = CHANNEL_PAGES[key]; + return ; +} diff --git a/packages/ui/src/features/command-center/components/CommandCenterView.tsx b/packages/ui/src/features/command-center/components/CommandCenterView.tsx index 744cfd397e..5dfdef0b6f 100644 --- a/packages/ui/src/features/command-center/components/CommandCenterView.tsx +++ b/packages/ui/src/features/command-center/components/CommandCenterView.tsx @@ -1,6 +1,5 @@ -import { Lightning } from "@phosphor-icons/react"; -import { Box, Flex, Text } from "@radix-ui/themes"; -import { useEffect, useMemo } from "react"; +import { Box, Flex } from "@radix-ui/themes"; +import { useEffect } from "react"; import { useSetHeaderContent } from "../../../hooks/useSetHeaderContent"; import { useTaskViewed } from "../../sidebar/useTaskViewed"; import { useCommandCenterStore } from "../commandCenterStore"; @@ -28,22 +27,11 @@ export function CommandCenterView() { } }, [visibleTaskIdsKey, markAsViewed]); - const headerContent = useMemo( - () => ( - - - - Command Center - - - ), - [], - ); - - useSetHeaderContent(headerContent); + // Root-level page: no breadcrumb row. Its own toolbar names the view, and + // there's no parent space to walk back to, so the bar was an empty frame. + // (Pushing null also collapses the row inside the Channels space, where + // WebsiteLayout renders whatever the active view puts in the header store.) + useSetHeaderContent(null); return ( diff --git a/packages/ui/src/features/inbox/components/InboxPageHeader.tsx b/packages/ui/src/features/inbox/components/InboxPageHeader.tsx index 5e07d9fefd..f089a0eaad 100644 --- a/packages/ui/src/features/inbox/components/InboxPageHeader.tsx +++ b/packages/ui/src/features/inbox/components/InboxPageHeader.tsx @@ -1,12 +1,61 @@ import type { InboxTabCounts } from "@posthog/core/inbox/reportMembership"; -import { InboxTabBar } from "@posthog/ui/features/inbox/components/InboxTabBar"; +import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; +import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; +import { InboxScopeSelect } from "@posthog/ui/features/inbox/components/InboxScopeSelect"; +import { + activeTabFromPath, + InboxTabBar, + InboxTabs, + inboxScopeApplies, +} from "@posthog/ui/features/inbox/components/InboxTabBar"; +import { + PageHeader, + PageHeaderDescription, + PageHeaderFilters, + PageHeaderHeading, + PageHeaderNav, + PageHeaderTitle, + PageHeaderTitleRow, +} from "@posthog/ui/primitives/PageHeader"; import { Flex, Text } from "@radix-ui/themes"; +import { useRouterState } from "@tanstack/react-router"; interface InboxPageHeaderProps { counts: InboxTabCounts; } export function InboxPageHeader({ counts }: InboxPageHeaderProps) { + // The shared page header ships behind bluebird; everyone else keeps the + // header this page has always had. Delete the legacy branch when the flag + // graduates. + const bluebird = useFeatureFlag(PROJECT_BLUEBIRD_FLAG, import.meta.env.DEV); + const pathname = useRouterState({ select: (s) => s.location.pathname }); + + if (!bluebird) return ; + + return ( + + + + Inbox + + + Work done by your agents – pull requests, reports, and live runs. + + + + + {inboxScopeApplies(activeTabFromPath(pathname)) && ( + + + + )} + + + ); +} + +function LegacyInboxPageHeader({ counts }: InboxPageHeaderProps) { return ( s.location.pathname }); const activeKey = activeTabFromPath(pathname); return ( - { - const key = value as InboxTabKey; - navigate({ to: INBOX_TAB_LIST_ROUTE[key] }); - }} + + {inboxScopeApplies(activeKey) && } + + ); +} + +/** Whether the reviewer-scope control means anything on this tab. */ +export function inboxScopeApplies(tab: InboxTabKey): boolean { + return tab !== "runs" && tab !== "dismissed"; +} + +/** Just the tab strip — the header slots its own filters beside it. */ +export function InboxTabs({ counts }: InboxTabBarProps) { + const navigate = useNavigate(); + const pathname = useRouterState({ select: (s) => s.location.pathname }); + const activeKey = activeTabFromPath(pathname); + + return ( + { + const key = value as InboxTabKey; + navigate({ to: INBOX_TAB_LIST_ROUTE[key] }); + }} + > + - - {INBOX_TAB_KEYS.map((key) => { - const isActive = key === activeKey; - return ( - - - {INBOX_TAB_LABEL[key]} + {INBOX_TAB_KEYS.map((key) => { + const isActive = key === activeKey; + return ( + + + {INBOX_TAB_LABEL[key]} + + {/* Runs and the open-ended Archive don't get a running total — it adds no signal. */} + {key !== "runs" && key !== "dismissed" && counts[key] > 0 && ( + + {counts[key]} - {/* Runs and the open-ended Archive don't get a running total — it adds no signal. */} - {key !== "runs" && key !== "dismissed" && counts[key] > 0 && ( - - {counts[key]} - - )} - - ); - })} - - - {activeKey !== "runs" && activeKey !== "dismissed" && ( - - )} - + )} + + ); + })} + + ); } diff --git a/packages/ui/src/features/loops/components/LoopDetailView.tsx b/packages/ui/src/features/loops/components/LoopDetailView.tsx index 48abf2c7cc..88644373cc 100644 --- a/packages/ui/src/features/loops/components/LoopDetailView.tsx +++ b/packages/ui/src/features/loops/components/LoopDetailView.tsx @@ -1,4 +1,4 @@ -import { ArrowLeftIcon, RepeatIcon } from "@phosphor-icons/react"; +import { ArrowLeftIcon } from "@phosphor-icons/react"; import type { LoopSchemas } from "@posthog/api-client/loops"; import { isUploadableSkillSource } from "@posthog/core/message-editor/skillTags"; import { useHostTRPC } from "@posthog/host-router/react"; @@ -19,6 +19,7 @@ import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar"; import { assertCloudUsageAvailable } from "@posthog/ui/features/billing/preflightCloudUsage"; import { useUsageLimitStore } from "@posthog/ui/features/billing/usageLimitStore"; +import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { useOrgMembers } from "@posthog/ui/features/canvas/hooks/useOrgMembers"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; @@ -37,7 +38,7 @@ import { useHostCapabilities } from "@posthog/ui/shell/useHostCapabilities"; import { Flex, Text } from "@radix-ui/themes"; import { useQuery } from "@tanstack/react-query"; import { useLocation } from "@tanstack/react-router"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useLoop } from "../hooks/useLoop"; import { useDeleteLoop, @@ -63,6 +64,7 @@ import { formatLoopModel } from "../loopModels"; import { loopSkillBundles, primaryLoopSkillBundle } from "../loopSkill"; import { LoopLoadError } from "./LoopFallbacks"; import { LoopRunRow } from "./LoopRunRow"; +import { LoopSpaceBreadcrumb } from "./LoopSpaceBreadcrumb"; export function LoopDetailView({ loopId }: { loopId: string }) { const hasLoopListOrigin = useLocation({ @@ -89,16 +91,22 @@ export function LoopDetailView({ loopId }: { loopId: string }) { ); }, [isLoading, runsQuery.isLoading, runsQuery.isError, loop, runs.length]); + // A loop attached to a space gets a breadcrumb back to it; a project-level + // loop has nowhere to walk back to, so it drops the row entirely. + const spacesLayout = useChannelsLayout(); + const contextTarget = loop?.context_target ?? null; useSetHeaderContent( - - - - {loop?.name ?? "Loop"} - - , + useMemo( + () => + spacesLayout && contextTarget ? ( + + ) : null, + [spacesLayout, contextTarget, loop?.name], + ), ); const handleToggleEnabled = (enabled: boolean) => { diff --git a/packages/ui/src/features/loops/components/LoopForm.tsx b/packages/ui/src/features/loops/components/LoopForm.tsx index e68cd60a99..cbfe235760 100644 --- a/packages/ui/src/features/loops/components/LoopForm.tsx +++ b/packages/ui/src/features/loops/components/LoopForm.tsx @@ -6,6 +6,7 @@ import { } from "@phosphor-icons/react"; import { type LoopSchemas, LoopsApiError } from "@posthog/api-client/loops"; import { ANALYTICS_EVENTS, PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; +import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { SettingsOptionSelect } from "@posthog/ui/features/settings/SettingsOptionSelect"; import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; @@ -18,7 +19,7 @@ import { } from "@posthog/ui/router/navigationBridge"; import { track } from "@posthog/ui/shell/analytics"; import { Box, Flex, Text, TextField } from "@radix-ui/themes"; -import { type ReactNode, useEffect, useState } from "react"; +import { type ReactNode, useEffect, useMemo, useState } from "react"; import { useAuthStateValue } from "../../auth/store"; import { useCreateLoop, @@ -52,6 +53,7 @@ import { LoopModelFields } from "./LoopModelFields"; import { LoopNotificationsFields } from "./LoopNotificationsFields"; import { LoopRepositoryPicker } from "./LoopRepositoryPicker"; import { LoopInstructionsFields } from "./LoopSkillFields"; +import { LoopSpaceBreadcrumb } from "./LoopSpaceBreadcrumb"; import { LoopTriggerEditor } from "./LoopTriggerEditor"; const VISIBILITY_OPTIONS: { @@ -133,10 +135,23 @@ export function LoopForm({ loop }: LoopFormProps) { ]; const isLastStep = step === STEPS.length - 1; + // Building a loop for a space keeps a way back to it; a project-level loop + // has no parent to breadcrumb to, so the row collapses. + const spacesLayout = useChannelsLayout(); + const contextTarget = values.contextTarget; + const headerLeaf = isEdit ? loop.name : "New loop"; useSetHeaderContent( - - {isEdit ? `Edit ${loop.name}` : "New loop"} - , + useMemo( + () => + spacesLayout && contextTarget ? ( + + ) : null, + [spacesLayout, contextTarget, headerLeaf], + ), ); const triggerEndpointPath = diff --git a/packages/ui/src/features/loops/components/LoopSpaceBreadcrumb.tsx b/packages/ui/src/features/loops/components/LoopSpaceBreadcrumb.tsx new file mode 100644 index 0000000000..c2528fc677 --- /dev/null +++ b/packages/ui/src/features/loops/components/LoopSpaceBreadcrumb.tsx @@ -0,0 +1,51 @@ +import { ChannelBreadcrumb } from "@posthog/ui/features/canvas/components/ChannelBreadcrumb"; +import { + channelPageIcon, + channelPageLabel, +} from "@posthog/ui/features/canvas/components/channelPages"; +import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { useNavigate } from "@tanstack/react-router"; + +/** + * Header breadcrumb for a loop that belongs to a space: + * "{space} / Loops / {loop}", with the space and Loops segments both linking + * back. Loops live outside the space routes (/code/loops/…), so without this a + * space-attached loop is a dead end. + * + * Render this only when the spaces layout is on and the loop has a context + * target — callers pass `null` to `useSetHeaderContent` otherwise, which + * leaves a project-level loop with no breadcrumb row at all. + */ +export function LoopSpaceBreadcrumb({ + folderId, + spaceName, + leafLabel, +}: { + /** Desktop folder id of the attached space (`context_target.folder_id`). */ + folderId: string; + /** Name stamped on the loop, used until the live space list resolves. */ + spaceName: string; + leafLabel: string; +}) { + const navigate = useNavigate(); + // The loop's stored name can go stale after a rename, so prefer the live one. + const { channels } = useChannels(); + const liveName = channels.find((c) => c.id === folderId)?.name; + + return ( + + void navigate({ + to: "/website/$channelId/loops", + params: { channelId: folderId }, + }), + }} + leafLabel={leafLabel} + /> + ); +} diff --git a/packages/ui/src/features/loops/components/LoopsListView.stories.tsx b/packages/ui/src/features/loops/components/LoopsListView.stories.tsx index 3c868e4670..34a57b47cd 100644 --- a/packages/ui/src/features/loops/components/LoopsListView.stories.tsx +++ b/packages/ui/src/features/loops/components/LoopsListView.stories.tsx @@ -155,6 +155,11 @@ type Story = StoryObj; export const Comprehensive: Story = {}; +/** Bluebird: title, cloud chip and CTA move into the shared full-bleed header. */ +export const SharedPageHeader: Story = { + args: { sharedPageHeader: true }, +}; + export const LongMixedList: Story = { args: { loops: Array.from({ length: 18 }, (_, index) => { diff --git a/packages/ui/src/features/loops/components/LoopsListView.test.tsx b/packages/ui/src/features/loops/components/LoopsListView.test.tsx index 708f4fb4be..6e98ce82aa 100644 --- a/packages/ui/src/features/loops/components/LoopsListView.test.tsx +++ b/packages/ui/src/features/loops/components/LoopsListView.test.tsx @@ -108,4 +108,48 @@ describe("LoopsListViewPresentation", () => { expect(screen.queryByText("personal loop")).not.toBeInTheDocument(), ); }); + + // With the shared page header the triggers sit in the header and the panels + // stay in the scrolling body — one Tabs root spanning both, so switching has + // to keep working across that split. + it("switches tabs when the trigger strip lives in the page header", async () => { + render( + + + , + ); + + const teamTab = screen.getByRole("tab", { name: "Team loops (1)" }); + await userEvent.click(teamTab); + + expect(teamTab).toHaveAttribute("aria-selected", "true"); + expect( + within(controlledPanel(teamTab)).getByText("team loop"), + ).toBeVisible(); + }); + + it("hides the header trigger strip while loops are loading", () => { + render( + + + , + ); + + expect(screen.queryByRole("tab")).not.toBeInTheDocument(); + }); }); diff --git a/packages/ui/src/features/loops/components/LoopsListView.tsx b/packages/ui/src/features/loops/components/LoopsListView.tsx index c8df964946..697776ac2c 100644 --- a/packages/ui/src/features/loops/components/LoopsListView.tsx +++ b/packages/ui/src/features/loops/components/LoopsListView.tsx @@ -1,19 +1,26 @@ -import { - ChatCircleDotsIcon, - CloudIcon, - PlusIcon, - RepeatIcon, -} from "@phosphor-icons/react"; +import { ChatCircleDotsIcon, CloudIcon, PlusIcon } from "@phosphor-icons/react"; import type { LoopSchemas } from "@posthog/api-client/loops"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@posthog/quill"; +import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import type { UserBasic } from "@posthog/shared/domain-types"; import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; import { useOrgMembers } from "@posthog/ui/features/canvas/hooks/useOrgMembers"; +import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { StopCloudRunDialog } from "@posthog/ui/features/sessions/components/StopCloudRunDialog"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; import { Button } from "@posthog/ui/primitives/Button"; +import { + PageHeader, + PageHeaderActions, + PageHeaderChip, + PageHeaderDescription, + PageHeaderHeading, + PageHeaderNav, + PageHeaderTitle, + PageHeaderTitleRow, +} from "@posthog/ui/primitives/PageHeader"; import { toast } from "@posthog/ui/primitives/toast"; import { navigateToNewLoop, @@ -21,7 +28,7 @@ import { } from "@posthog/ui/router/navigationBridge"; import { track } from "@posthog/ui/shell/analytics"; import { Flex, Heading, Text } from "@radix-ui/themes"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useLoopBuilderSessions } from "../hooks/useLoopBuilderSessions"; import { useLoopLimits, useLoops } from "../hooks/useLoops"; import { @@ -69,6 +76,10 @@ function startLoopFromTemplate(template: LoopTemplate): void { export function LoopsListView() { const { data: loops, isLoading, isError, error } = useLoops(); + // The shared page header ships behind bluebird. Read here, not in the + // presentation — that renders bare in tests and Storybook, with no container + // to resolve the flags service from. + const bluebird = useFeatureFlag(PROJECT_BLUEBIRD_FLAG, import.meta.env.DEV); const authenticatedClient = useOptionalAuthenticatedClient(); const { data: currentUser, @@ -86,21 +97,9 @@ export function LoopsListView() { listError = currentUserQueryError; } - const headerContent = useMemo( - () => ( - - - - Loops - - - ), - [], - ); - useSetHeaderContent(headerContent); + // The page names itself (in-page header / title block), so it pushes no + // breadcrumb row — only a space-attached loop scene has a parent to show. + useSetHeaderContent(null); const { sessions: builderSessions, isSettled: builderSessionsSettled } = useLoopBuilderSessions(); @@ -148,6 +147,7 @@ export function LoopsListView() { return ( + const createButton = ( + + ); + + // Only the loaded, non-empty list has tabs to show — the skeleton, the error + // notice and the empty state all render without them. + const hasTabs = !isLoading && !error && loops.length > 0; + + const body = ( + <>
-
- - - Loops - - - - Runs entirely in the cloud - + {!sharedPageHeader && ( +
+ + + Loops + + + + Runs entirely in the cloud + + + + Put your work on autopilot. Loops run on a schedule, on an + API call, or when something happens on GitHub. You can + finally close the laptop! + - - Put your work on autopilot. Loops run on a schedule, on an API - call, or when something happens on GitHub. You can finally - close the laptop! - - - -
+ {createButton} +
+ )} {isLoading ? ( @@ -268,14 +281,26 @@ export function LoopsListViewPresentation({ } /> ) : loops.length > 0 ? ( - + sharedPageHeader ? ( + // Triggers live in the page header; only the panels sit here. + + ) : ( + + ) ) : ( )} @@ -302,7 +327,50 @@ export function LoopsListViewPresentation({
-
+ + ); + + if (!sharedPageHeader) { + return ( + + {body} + + ); + } + + // One Tabs root spanning header and body: the trigger strip sits in the + // header's sub-nav, its panels stay down in the scrolling body. + return ( + + + + + Loops + }> + Runs entirely in the cloud + + {createButton} + + + Put your work on autopilot. Loops run on a schedule, on an API call, + or when something happens on GitHub. You can finally close the + laptop! + + + {hasTabs && ( + + + + )} + + {body} + ); } @@ -323,18 +391,64 @@ function LoopListTabs({ }) { return ( - - - - My loops ({personalLoops.length}) - - - - - Team loops ({teamLoops.length}) - - - + + + + ); +} + +/** The trigger strip. Rendered inside the page header when one is present. */ +function LoopTabsList({ + personalCount, + teamCount, +}: { + personalCount: number; + teamCount: number; +}) { + return ( + + + + My loops ({personalCount}) + + + + + Team loops ({teamCount}) + + + + ); +} + +/** The panels. Always in the scrolling body, wherever the triggers live. */ +function LoopTabPanels({ + personalLoops, + teamLoops, + members, + membersLoading, + membersError, + membersComplete, +}: { + personalLoops: LoopSchemas.Loop[]; + teamLoops: LoopSchemas.Loop[]; + members: UserBasic[]; + membersLoading: boolean; + membersError: boolean; + membersComplete: boolean; +}) { + return ( + <> {personalLoops.length > 0 ? ( )} - + ); } diff --git a/packages/ui/src/features/task-detail/HeaderTitleEditor.tsx b/packages/ui/src/features/task-detail/HeaderTitleEditor.tsx index 51d425fd8c..c5f15aba09 100644 --- a/packages/ui/src/features/task-detail/HeaderTitleEditor.tsx +++ b/packages/ui/src/features/task-detail/HeaderTitleEditor.tsx @@ -1,15 +1,23 @@ +import { cn } from "@posthog/quill"; import { useEffect, useRef, useState } from "react"; interface HeaderTitleEditorProps { initialTitle: string; onSubmit: (newTitle: string) => void; onCancel: () => void; + /** + * Extends the base styling — callers match the input to whatever it replaces + * (e.g. a breadcrumb segment's type scale and height) so opening the editor + * doesn't resize the row. + */ + className?: string; } export function HeaderTitleEditor({ initialTitle, onSubmit, onCancel, + className, }: HeaderTitleEditorProps) { const [editValue, setEditValue] = useState(initialTitle); const inputRef = useRef(null); @@ -53,7 +61,10 @@ export function HeaderTitleEditor({ onChange={(e) => setEditValue(e.target.value)} onKeyDown={handleKeyDown} onBlur={handleSubmit} - className="no-drag h-5 min-w-0 flex-1 rounded-sm border border-accent-8 bg-gray-2 px-1 font-medium text-[12px] text-gray-12 outline-none" + className={cn( + "no-drag h-5 min-w-0 flex-1 rounded-sm border border-accent-8 bg-gray-2 px-1 font-medium text-[12px] text-gray-12 outline-none", + className, + )} /> ); } diff --git a/packages/ui/src/primitives/PageHeader.stories.tsx b/packages/ui/src/primitives/PageHeader.stories.tsx new file mode 100644 index 0000000000..328d466b3e --- /dev/null +++ b/packages/ui/src/primitives/PageHeader.stories.tsx @@ -0,0 +1,108 @@ +import { CloudIcon, FilesIcon } from "@phosphor-icons/react"; +import { + Button, + ButtonGroup, + Tabs, + TabsList, + TabsTrigger, +} from "@posthog/quill"; +import { + PageHeader, + PageHeaderActions, + PageHeaderChip, + PageHeaderDescription, + PageHeaderFilters, + PageHeaderHeading, + PageHeaderNav, + PageHeaderTitle, + PageHeaderTitleRow, +} from "@posthog/ui/primitives/PageHeader"; +import type { Meta, StoryObj } from "@storybook/react-vite"; + +const meta = { + title: "Primitives/PageHeader", + component: PageHeader, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** The Inbox shape: title, description, tab strip with a filter on the right. */ +export const WithTabsAndFilters: Story = { + args: { + children: ( + <> + + + Inbox + + + Work done by your agents – pull requests, reports, and live runs. + + + + + + + Pull requests + + + Reports + + + + + + + + + ), + }, +}; + +/** The Artifacts shape: a count chip beside the title, view switcher on the right. */ +export const WithChipAndActions: Story = { + args: { + children: ( + + + Artifacts + }> + 12 items + + + + + + + + + + Canvases and pull requests from this space's tasks. + + + ), + }, +}; + +/** Title only — the minimum a page has to spend. */ +export const TitleOnly: Story = { + args: { + children: ( + + + Loops + }> + Runs entirely in the cloud + + + + ), + }, +}; diff --git a/packages/ui/src/primitives/PageHeader.tsx b/packages/ui/src/primitives/PageHeader.tsx new file mode 100644 index 0000000000..2580511ff1 --- /dev/null +++ b/packages/ui/src/primitives/PageHeader.tsx @@ -0,0 +1,189 @@ +import { cn } from "@posthog/quill"; +import type { ReactNode } from "react"; + +/** + * The shared page header section. Full-bleed (the page body below it keeps its + * own container), bordered off from the content, and composed from parts so + * each surface takes only what it needs: + * + * + * + * + * Inbox + * Runs in the cloud + * + * + * + * + * + * + * + * + * + * + * Layout base is the Inbox header (full width, title + description + tab bar); + * the chip comes from Loops. + */ +export function PageHeader({ + className, + children, +}: { + className?: string; + children: ReactNode; +}) { + return ( +
+ {children} +
+ ); +} + +/** Title row + description, tight against each other. */ +export function PageHeaderHeading({ + className, + children, +}: { + className?: string; + children: ReactNode; +}) { + return ( +
+ {children} +
+ ); +} + +/** The title line: title, any chips, and (pushed right) actions. */ +export function PageHeaderTitleRow({ + className, + children, +}: { + className?: string; + children: ReactNode; +}) { + return ( +
+ {children} +
+ ); +} + +export function PageHeaderTitle({ + className, + children, +}: { + className?: string; + children: ReactNode; +}) { + return ( +

+ {children} +

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

+ {children} +

+ ); +} + +/** Trailing controls on the title line (create buttons, view switchers). */ +export function PageHeaderActions({ + className, + children, +}: { + className?: string; + children: ReactNode; +}) { + return ( +
+ {children} +
+ ); +} + +/** + * The sub-nav row: a tab strip, with filters pushed to the right. Cancels the + * header's bottom padding so an underlined tab strip sits on the header border + * the way the Inbox tabs do; the tabs' own padding keeps the breathing room. + */ +export function PageHeaderNav({ + className, + children, +}: { + className?: string; + children: ReactNode; +}) { + return ( +
+ {children} +
+ ); +} + +/** Filters/controls sitting to the right of the sub-nav. */ +export function PageHeaderFilters({ + className, + children, +}: { + className?: string; + children: ReactNode; +}) { + return ( +
+ {children} +
+ ); +} diff --git a/packages/ui/src/shell/ContentHeader.tsx b/packages/ui/src/shell/ContentHeader.tsx index 74f34c089d..741a785734 100644 --- a/packages/ui/src/shell/ContentHeader.tsx +++ b/packages/ui/src/shell/ContentHeader.tsx @@ -10,10 +10,17 @@ import { Flex } from "@radix-ui/themes"; // review-panel toggle, cloud/local handoff, skill buttons and task actions that // used to live in the Code header bar. // -// This breadcrumb row is now scoped to the task-detail view only: every other -// page drops it (the title bar search carries wayfinding instead). The /website -// (Channels) space keeps its own header (WebsiteLayout), so it's unaffected — -// this is mounted only outside it. +// This breadcrumb row is scoped to views that have somewhere to walk back to: +// task detail, and the loop scenes (list / detail / form), which live outside +// the space routes but can belong to a space. Every other page drops it (the +// title bar search carries wayfinding instead). The /website (Channels) space +// keeps its own header (WebsiteLayout), so it's unaffected — this is mounted +// only outside it. +// +// A loop with no space pushes null, so the row collapses for it too: what a +// view puts in the header store decides, this only says who may. +const BREADCRUMB_VIEWS = new Set(["task-detail", "loops"]); + export function ContentHeader() { const content = useHeaderStore((state) => state.content); const view = useAppView(); @@ -25,8 +32,7 @@ export function ContentHeader() { : undefined; const showTaskSection = view.type === "task-detail" && Boolean(activeTask); - // Only the task-detail view keeps the breadcrumb row. - if (view.type !== "task-detail") return null; + if (!BREADCRUMB_VIEWS.has(view.type)) return null; if (!content && !showTaskSection) return null; From 6f47a347c2ab0da2927d85cf50cd7c60941a0e26 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Wed, 29 Jul 2026 11:48:27 +0100 Subject: [PATCH 34/43] fix(canvas): no white flash when a preview loads in dark mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sandbox document painted with a hard light fallback (background: var(--background, #fff)) during the window before its stylesheets loaded and before the host's init/set-theme message toggled `.dark` — so every canvas preview scrolling into view flashed white over a dark app. It now stays transparent until the tokens land, and the host sets color-scheme on the iframe so the UA's base canvas is dark too. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BYPVPkN4Lm3gygchrpcoqr --- .../src/features/canvas/freeform/FreeformCanvas.tsx | 5 +++++ .../features/canvas/freeform/sandboxRuntime.test.ts | 10 ++++++++++ .../src/features/canvas/freeform/sandboxRuntime.ts | 13 +++++++++++-- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/features/canvas/freeform/FreeformCanvas.tsx b/packages/ui/src/features/canvas/freeform/FreeformCanvas.tsx index f62258877c..afbce9eb32 100644 --- a/packages/ui/src/features/canvas/freeform/FreeformCanvas.tsx +++ b/packages/ui/src/features/canvas/freeform/FreeformCanvas.tsx @@ -270,6 +270,11 @@ export function FreeformCanvas({ }} // bg tracks the host theme so there's no white flash in dark mode before // the iframe paints; the canvas body uses the same --background token. + // color-scheme matters as much as the background: without it the embedded + // document's base canvas is painted white by the UA — which is what a + // preview scrolling into view flashed before its stylesheets and the + // first `init` (carrying the theme) arrived. + style={{ colorScheme: theme }} className="h-full w-full border-0 bg-background" /> ); diff --git a/packages/ui/src/features/canvas/freeform/sandboxRuntime.test.ts b/packages/ui/src/features/canvas/freeform/sandboxRuntime.test.ts index 617363d19e..6786ad4097 100644 --- a/packages/ui/src/features/canvas/freeform/sandboxRuntime.test.ts +++ b/packages/ui/src/features/canvas/freeform/sandboxRuntime.test.ts @@ -65,6 +65,16 @@ describe("buildSandboxDocument", () => { expect(html).toContain('"open-external"'); expect(html).toContain("event.defaultPrevented"); }); + + // The document paints before its stylesheets load and before the host's + // theme message arrives. A light fallback there flashed white over a dark + // app every time a canvas preview scrolled into view. + it("paints nothing of its own before the host theme lands", () => { + const html = buildSandboxDocument("edit"); + expect(html).toContain("background: var(--background, transparent)"); + expect(html).not.toContain("var(--background, #fff)"); + expect(html).toContain("html.dark { color-scheme: dark; }"); + }); }); describe("resolveExternalAnchorUrl", () => { diff --git a/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts b/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts index 641a8c0c43..8f4f73d27f 100644 --- a/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts +++ b/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts @@ -453,9 +453,18 @@ ${FREEFORM_QUILL_CSS_URLS.map( /* Fill the iframe viewport exactly so overflow scrolls on the iframe's own root scroller — the iframe is pinned to its parent's height and never grows it. */ html, body { margin: 0; padding: 0; height: 100%; } + /* No light default: leaving \`color-scheme\` alone lets the base canvas inherit + the embedder's scheme (the host sets it on the iframe), so the first paint + is already dark in a dark app. Once the host's theme message toggles + \`.dark\`, this pins it so form controls and scrollbars match too. */ + html.dark { color-scheme: dark; } /* Track the theme via Quill's tokens (set on :root / .dark) so the page chrome - flips with the host theme; fall back to light if the tokens haven't loaded. */ - body { font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; color: var(--foreground, #111); background: var(--background, #fff); } + flips with the host theme. Until those tokens land — the stylesheets are + still loading, and \`.dark\` is only applied once the host's init/set-theme + message arrives — stay transparent and inherit, so the host iframe's own + themed background shows through. A hard light fallback here flashed white + over a dark app every time a preview scrolled into view. */ + body { font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; color: var(--foreground, inherit); background: var(--background, transparent); } #root { min-height: 100vh; } From b9c1d6c3e3ae55ca56f03acb79faa50469c559e7 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Wed, 29 Jul 2026 12:10:10 +0100 Subject: [PATCH 35/43] fix(canvas): canvas menu fits its labels, and pins to a "space" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The options menu kept quill's default width, which clipped "Unpin from channel" mid-word; it now sizes to its longest item like the channel-list menus do. The label itself follows the layout — space under the new one, channel under the old. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BYPVPkN4Lm3gygchrpcoqr --- .../features/canvas/components/WebsiteLayout.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/features/canvas/components/WebsiteLayout.tsx b/packages/ui/src/features/canvas/components/WebsiteLayout.tsx index 8ee861e4de..9eebdd3169 100644 --- a/packages/ui/src/features/canvas/components/WebsiteLayout.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteLayout.tsx @@ -82,6 +82,9 @@ function FreeformEditControls({ dashboardId: string; }) { const navigate = useNavigate(); + // Pinning is scoped to whatever holds the canvas; the new layout calls that a + // space, the old one a channel. + const containerNoun = useChannelsLayout() ? "space" : "channel"; const editing = useIsDashboardEditing(dashboardId); const setEditing = useDashboardEditStore((s) => s.setEditing); const { dashboard } = useDashboard(dashboardId); @@ -268,7 +271,14 @@ function FreeformEditControls({ } /> - + {/* Sized to its longest item — the default width clipped "Unpin from + space". Same treatment as the channel-list menus. */} + Refresh @@ -283,7 +293,9 @@ function FreeformEditControls({ - {isPinned ? "Unpin from channel" : "Pin to channel"} + {isPinned + ? `Unpin from ${containerNoun}` + : `Pin to ${containerNoun}`} Date: Wed, 29 Jul 2026 11:47:58 +0100 Subject: [PATCH 36/43] fix(canvas): delete confirm copy matches the undo window The dialog said the delete "cannot be undone" while the action routes through deleteCanvasWithUndo, which holds it for 8s behind an Undo toast. Say what actually happens instead, and call the container a space under the new layout, like the menu above it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BYPVPkN4Lm3gygchrpcoqr --- .../canvas/components/WebsiteDashboardsIndex.tsx | 10 ++++++---- .../src/features/canvas/components/WebsiteLayout.tsx | 9 +++++---- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/features/canvas/components/WebsiteDashboardsIndex.tsx b/packages/ui/src/features/canvas/components/WebsiteDashboardsIndex.tsx index eb0fa12fa3..e88cf4efc9 100644 --- a/packages/ui/src/features/canvas/components/WebsiteDashboardsIndex.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteDashboardsIndex.tsx @@ -25,6 +25,7 @@ import { FreeformPreview } from "@posthog/ui/features/canvas/components/Freeform import { NewCanvasMenu } from "@posthog/ui/features/canvas/components/NewCanvasMenu"; import { deleteCanvasWithUndo } from "@posthog/ui/features/canvas/deleteCanvasWithUndo"; import { useCanvasTemplates } from "@posthog/ui/features/canvas/hooks/useCanvasTemplates"; +import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { useDashboardMutations, useDashboards, @@ -185,8 +186,9 @@ function DashboardCardMenu({ channelId: string; }) { const [open, setOpen] = useState(false); + const containerNoun = useChannelsLayout() ? "space" : "channel"; // "Delete…" opens a confirmation rather than deleting inline — the canvas and - // its version history go away for everyone in the channel. + // its version history go away for everyone in the space. const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); const { invalidateDashboards } = useDashboardMutations(); @@ -246,9 +248,9 @@ function DashboardCardMenu({ Delete canvas - Permanently delete {name}? - This deletes its code and version history for everyone in the - channel and cannot be undone. + Delete {name}? Its code and + version history go for everyone in the {containerNoun}. You get a + few seconds to undo, then it's permanent. diff --git a/packages/ui/src/features/canvas/components/WebsiteLayout.tsx b/packages/ui/src/features/canvas/components/WebsiteLayout.tsx index 9eebdd3169..032b1988b5 100644 --- a/packages/ui/src/features/canvas/components/WebsiteLayout.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteLayout.tsx @@ -92,7 +92,7 @@ function FreeformEditControls({ useDashboardMutations(); const isPinned = dashboard?.pinnedAt != null; // "Delete…" opens a confirmation rather than deleting inline — the canvas and - // its version history go away for everyone in the channel. + // its version history go away for everyone in the space. const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); // Once confirmed the canvas vanishes from every list and we leave for the @@ -312,10 +312,11 @@ function FreeformEditControls({ Delete canvas - Permanently delete{" "} + Delete{" "} {dashboard?.name ?? "Canvas"} - ? This deletes its code and version history for everyone in the - channel and cannot be undone. + ? Its code and version history go for everyone in the{" "} + {containerNoun}. You get a few seconds to undo, then it's + permanent. From e39432787e45762dfb6bdd4d3431f672a8d87971 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Wed, 29 Jul 2026 12:17:01 +0100 Subject: [PATCH 37/43] refactor(ui): review cleanups on the page-header work - one useBluebirdFlag hook so the dev default isn't repeated per call site - type view_mode on the channel-action event instead of a bare string - drop the two redundant comments on the analytics event union - memoize the Activity feed and its mark-all-read button Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BYPVPkN4Lm3gygchrpcoqr --- packages/shared/src/analytics-events.ts | 4 +- .../canvas/components/ActivityView.tsx | 139 ++++++++++-------- .../canvas/hooks/useChannelsLayout.ts | 8 +- .../features/feature-flags/useBluebirdFlag.ts | 15 ++ .../inbox/components/InboxPageHeader.tsx | 5 +- .../features/loops/components/LoopForm.tsx | 9 +- .../loops/components/LoopsListView.tsx | 5 +- 7 files changed, 105 insertions(+), 80 deletions(-) create mode 100644 packages/ui/src/features/feature-flags/useBluebirdFlag.ts diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index e53435c68f..2da7a31ed6 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -919,7 +919,6 @@ export type ChannelActionType = | "open_mention" | "canvas_mode_toggle" | "activity_tab_change" - /** Switched the artifacts list between list / grid / masonry. */ | "artifacts_view_change"; export interface ChannelActionProperties { @@ -941,8 +940,7 @@ export interface ChannelActionProperties { armed?: boolean; /** For activity_tab_change: the tab landed on. */ tab?: string; - /** For artifacts_view_change: the layout landed on ("list"|"grid"|"masonry"). */ - view_mode?: string; + view_mode?: "list" | "grid" | "masonry"; /** Whether the underlying mutation resolved successfully. */ success?: boolean; } diff --git a/packages/ui/src/features/canvas/components/ActivityView.tsx b/packages/ui/src/features/canvas/components/ActivityView.tsx index 5ca1c131ad..046c534ce8 100644 --- a/packages/ui/src/features/canvas/components/ActivityView.tsx +++ b/packages/ui/src/features/canvas/components/ActivityView.tsx @@ -287,8 +287,11 @@ export function ActivityView() { () => createChannelIdByName(folderChannels), [folderChannels], ); - const folderChannelIdFor = (channelName: string | null): string | null => - channelIdForName(folderIdByName, channelName); + const folderChannelIdFor = useCallback( + (channelName: string | null): string | null => + channelIdForName(folderIdByName, channelName), + [folderIdByName], + ); useEffect(() => { track(ANALYTICS_EVENTS.CHANNEL_ACTION, { action_type: "view_activity", @@ -296,65 +299,81 @@ export function ActivityView() { }); }, []); - const markAllReadButton = - unreadCount > 0 ? ( - - ) : null; + const markAllReadButton = useMemo( + () => + unreadCount > 0 ? ( + + ) : null, + [unreadCount, unreadItems.length, isMarkingRead, markAllRead], + ); - const feed = ( - <> - {isLoading && items.length === 0 ? ( -
- -
- ) : items.length === 0 ? ( - - - - - - No activity yet - - Tasks you create, get tagged in, or reply to across{" "} - {spacesLayout ? "spaces" : "channels"} land here. - - - - ) : ( -
- {items.map((item) => ( - - ))} - {hasNextPage && ( - - )} -
- )} - + const feed = useMemo( + () => ( + <> + {isLoading && items.length === 0 ? ( +
+ +
+ ) : items.length === 0 ? ( + + + + + + No activity yet + + Tasks you create, get tagged in, or reply to across{" "} + {spacesLayout ? "spaces" : "channels"} land here. + + + + ) : ( +
+ {items.map((item) => ( + + ))} + {hasNextPage && ( + + )} +
+ )} + + ), + [ + isLoading, + items, + spacesLayout, + folderChannelIdFor, + markRead, + currentUser, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + ], ); // The shared page header ships with the spaces layout; without it the page diff --git a/packages/ui/src/features/canvas/hooks/useChannelsLayout.ts b/packages/ui/src/features/canvas/hooks/useChannelsLayout.ts index 23a0e28ffa..487f968aa4 100644 --- a/packages/ui/src/features/canvas/hooks/useChannelsLayout.ts +++ b/packages/ui/src/features/canvas/hooks/useChannelsLayout.ts @@ -1,4 +1,5 @@ -import { CHANNELS_LAYOUT_FLAG, PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; +import { CHANNELS_LAYOUT_FLAG } from "@posthog/shared"; +import { useBluebirdFlag } from "@posthog/ui/features/feature-flags/useBluebirdFlag"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; /** @@ -6,10 +7,7 @@ import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFla * No dev default, so dev matches prod; bluebird keeps its own backend guard. */ export function useChannelsLayout(): boolean { - const bluebirdEnabled = useFeatureFlag( - PROJECT_BLUEBIRD_FLAG, - import.meta.env.DEV, - ); + const bluebirdEnabled = useBluebirdFlag(); const layoutEnabled = useFeatureFlag( CHANNELS_LAYOUT_FLAG, import.meta.env.DEV, diff --git a/packages/ui/src/features/feature-flags/useBluebirdFlag.ts b/packages/ui/src/features/feature-flags/useBluebirdFlag.ts new file mode 100644 index 0000000000..627b119f48 --- /dev/null +++ b/packages/ui/src/features/feature-flags/useBluebirdFlag.ts @@ -0,0 +1,15 @@ +import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; +import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; + +/** + * The project-bluebird gate. Read this rather than the raw flag: the dev + * default lives here once, so a surface can't ship with the flag on in dev and + * off for a colleague (or the reverse) because a call site forgot it. + * + * Space-scoped surfaces want {@link useChannelsLayout} instead — that's this + * flag *and* the channels layout, which is what actually puts a page inside a + * space. + */ +export function useBluebirdFlag(): boolean { + return useFeatureFlag(PROJECT_BLUEBIRD_FLAG, import.meta.env.DEV); +} diff --git a/packages/ui/src/features/inbox/components/InboxPageHeader.tsx b/packages/ui/src/features/inbox/components/InboxPageHeader.tsx index f089a0eaad..63beacda05 100644 --- a/packages/ui/src/features/inbox/components/InboxPageHeader.tsx +++ b/packages/ui/src/features/inbox/components/InboxPageHeader.tsx @@ -1,6 +1,5 @@ import type { InboxTabCounts } from "@posthog/core/inbox/reportMembership"; -import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; -import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; +import { useBluebirdFlag } from "@posthog/ui/features/feature-flags/useBluebirdFlag"; import { InboxScopeSelect } from "@posthog/ui/features/inbox/components/InboxScopeSelect"; import { activeTabFromPath, @@ -28,7 +27,7 @@ export function InboxPageHeader({ counts }: InboxPageHeaderProps) { // The shared page header ships behind bluebird; everyone else keeps the // header this page has always had. Delete the legacy branch when the flag // graduates. - const bluebird = useFeatureFlag(PROJECT_BLUEBIRD_FLAG, import.meta.env.DEV); + const bluebird = useBluebirdFlag(); const pathname = useRouterState({ select: (s) => s.location.pathname }); if (!bluebird) return ; diff --git a/packages/ui/src/features/loops/components/LoopForm.tsx b/packages/ui/src/features/loops/components/LoopForm.tsx index cbfe235760..01e6ef80fe 100644 --- a/packages/ui/src/features/loops/components/LoopForm.tsx +++ b/packages/ui/src/features/loops/components/LoopForm.tsx @@ -5,9 +5,9 @@ import { Check, } from "@phosphor-icons/react"; import { type LoopSchemas, LoopsApiError } from "@posthog/api-client/loops"; -import { ANALYTICS_EVENTS, PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; +import { ANALYTICS_EVENTS } from "@posthog/shared"; import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; -import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; +import { useBluebirdFlag } from "@posthog/ui/features/feature-flags/useBluebirdFlag"; import { SettingsOptionSelect } from "@posthog/ui/features/settings/SettingsOptionSelect"; import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; @@ -104,10 +104,7 @@ export function LoopForm({ loop }: LoopFormProps) { // Contexts are a channels surface; hide the attachment UI when channels are // off, unless this loop is already attached so the link stays visible and // detachable. - const bluebirdEnabled = useFeatureFlag( - PROJECT_BLUEBIRD_FLAG, - import.meta.env.DEV, - ); + const bluebirdEnabled = useBluebirdFlag(); const channelsEnabled = useSidebarStore((s) => s.channelsEnabled) && bluebirdEnabled; const showContextField = channelsEnabled || !!values.contextTarget; diff --git a/packages/ui/src/features/loops/components/LoopsListView.tsx b/packages/ui/src/features/loops/components/LoopsListView.tsx index 697776ac2c..2ed371b75f 100644 --- a/packages/ui/src/features/loops/components/LoopsListView.tsx +++ b/packages/ui/src/features/loops/components/LoopsListView.tsx @@ -1,13 +1,12 @@ import { ChatCircleDotsIcon, CloudIcon, PlusIcon } from "@phosphor-icons/react"; import type { LoopSchemas } from "@posthog/api-client/loops"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@posthog/quill"; -import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import type { UserBasic } from "@posthog/shared/domain-types"; import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; import { useOrgMembers } from "@posthog/ui/features/canvas/hooks/useOrgMembers"; -import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; +import { useBluebirdFlag } from "@posthog/ui/features/feature-flags/useBluebirdFlag"; import { StopCloudRunDialog } from "@posthog/ui/features/sessions/components/StopCloudRunDialog"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; import { Button } from "@posthog/ui/primitives/Button"; @@ -79,7 +78,7 @@ export function LoopsListView() { // The shared page header ships behind bluebird. Read here, not in the // presentation — that renders bare in tests and Storybook, with no container // to resolve the flags service from. - const bluebird = useFeatureFlag(PROJECT_BLUEBIRD_FLAG, import.meta.env.DEV); + const bluebird = useBluebirdFlag(); const authenticatedClient = useOptionalAuthenticatedClient(); const { data: currentUser, From 25a9611026b54109b47d3ad5331c3500dcf2e4e7 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Wed, 29 Jul 2026 12:25:35 +0100 Subject: [PATCH 38/43] fix(ui): review follow-ups on headers, artifact cards and nav - loop scenes with no space keep an identifying header instead of an empty row (greptile P1) - artifacts body left-aligns under its header rather than centring - grid cards stretch to a common height, so a PR tile no longer ends short beside a canvas thumbnail - canvas previews get a skeleton while deferred and an icon + "Nothing built yet" when empty, instead of a bare line of text - the Activity nav entry is a quill icon button (28px) like its neighbours, not a hand-rolled 32px one Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BYPVPkN4Lm3gygchrpcoqr --- .../features/canvas/components/ChannelNav.tsx | 16 ++++-- .../canvas/components/FreeformPreview.tsx | 49 +++++++++++++--- .../components/WebsiteChannelArtifacts.tsx | 57 +++++++++++++++---- .../loops/components/LoopDetailView.tsx | 15 +++-- .../features/loops/components/LoopForm.tsx | 9 ++- .../loops/components/LoopHeaderTitle.tsx | 23 ++++++++ 6 files changed, 138 insertions(+), 31 deletions(-) create mode 100644 packages/ui/src/features/loops/components/LoopHeaderTitle.tsx diff --git a/packages/ui/src/features/canvas/components/ChannelNav.tsx b/packages/ui/src/features/canvas/components/ChannelNav.tsx index 23f0a6fe81..9c324c7676 100644 --- a/packages/ui/src/features/canvas/components/ChannelNav.tsx +++ b/packages/ui/src/features/canvas/components/ChannelNav.tsx @@ -98,6 +98,10 @@ interface NavButtonProps extends ComponentPropsWithRef<"button"> { badge?: ReactNode; } +// Same quill Button as NavIcon above — this variant only exists because the +// Activity entry is a Popover trigger, so it needs to forward the trigger's +// props and ref. Hand-rolling the button here left it a size larger than its +// neighbours. function NavButton({ icon, label, @@ -109,23 +113,23 @@ function NavButton({ ...buttonProps }: NavButtonProps) { return ( - + ); } diff --git a/packages/ui/src/features/canvas/components/FreeformPreview.tsx b/packages/ui/src/features/canvas/components/FreeformPreview.tsx index b753c620ab..37b1f62bfc 100644 --- a/packages/ui/src/features/canvas/components/FreeformPreview.tsx +++ b/packages/ui/src/features/canvas/components/FreeformPreview.tsx @@ -1,11 +1,12 @@ -import { cn, Text } from "@posthog/quill"; +import { ShapesIcon, WarningIcon } from "@phosphor-icons/react"; +import { cn, Skeleton, Text } from "@posthog/quill"; import { FreeformCanvas } from "@posthog/ui/features/canvas/freeform/FreeformCanvas"; import { handleFreeformDataRequest } from "@posthog/ui/features/canvas/freeform/freeformDataBridge"; import { useInView } from "@posthog/ui/primitives/hooks/useInView"; import { ErrorBoundary } from "@posthog/ui/shell/ErrorBoundary"; import { Box, Flex } from "@radix-ui/themes"; import { useQueryClient } from "@tanstack/react-query"; -import { useCallback } from "react"; +import { type ReactNode, useCallback } from "react"; // Render each canvas's live app at 1/SCALE of the card width, then shrink so it // fits inside the preview frame as a thumbnail. @@ -68,7 +69,12 @@ export function FreeformPreview({ } + fallback={ + } + label="Preview unavailable" + /> + } > ) : ( - + // Deferred, not broken: a shimmer reads as "coming", where a line of + // text reads as the final state. + ) ) : ( - + } + label="Nothing built yet" + /> )} ); } -function PreviewPlaceholder({ label }: { label: string }) { +function PreviewPlaceholder({ + icon, + label, +}: { + icon?: ReactNode; + label: string; +}) { return ( + {icon} {label} ); } + +/** Stand-in for a preview that hasn't mounted yet — the shape of a small app: + * a title bar, a chart block, a couple of rows. */ +function PreviewSkeleton() { + return ( +
+ + +
+ + +
+
+ ); +} diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx index 61e693a69e..05beaab443 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx @@ -43,6 +43,10 @@ import { openExternalUrl } from "@posthog/ui/shell/openExternal"; import { useNavigate } from "@tanstack/react-router"; import { type ReactNode, useCallback, useEffect, useMemo } from "react"; +// Uniform media height for the grid: cards line up row to row, and a PR tile +// (which has nothing to preview) fills the same band as a canvas thumbnail. +const GRID_PREVIEW_HEIGHT = 176; + // Artifacts are the durable outputs of a channel's work. Canvases for now; PRs // are surfaced from each filed task's latest run output. More kinds (reports, // files, …) slot into this union later. @@ -184,11 +188,13 @@ export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) { ) : null}
- {/* The list reads best narrow; card layouts want the full width. */} + {/* Left-aligned, like the page header above it — a centred column + drifts away from the title as the window widens. The list keeps a + readable measure; the card layouts spread. */}
{!spacesLayout && ( @@ -226,12 +232,16 @@ export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) { ))}
) : view === "grid" ? ( -
+ // items-stretch + a full-height card: a PR tile (no preview to + // show) matches the canvas cards in its row instead of ending + // short. +
{items.map((item) => ( @@ -291,11 +301,14 @@ function ArtifactListItem({ function ArtifactCard({ item, previewHeight, + fillHeight, onOpenCanvas, onOpenPr, }: { item: ArtifactItem; previewHeight: number; + /** Grid only: stretch to the tallest card in the row. */ + fillHeight?: boolean; onOpenCanvas: (dashboardId: string) => void; onOpenPr: (safeUrl: string) => void; }) { @@ -307,6 +320,7 @@ function ArtifactCard({ ts={item.ts} code={item.code} previewHeight={previewHeight} + fillHeight={fillHeight} onClick={onOpenCanvas} /> ) : ( @@ -314,6 +328,8 @@ function ArtifactCard({ title={item.title} prUrl={item.prUrl} ts={item.ts} + mediaHeight={fillHeight ? previewHeight : undefined} + fillHeight={fillHeight} onClick={onOpenPr} /> ); @@ -447,6 +463,7 @@ function CanvasArtifactCard({ ts, code, previewHeight, + fillHeight, onClick, }: { dashboardId: string; @@ -455,6 +472,7 @@ function CanvasArtifactCard({ ts: number; code?: string; previewHeight: number; + fillHeight?: boolean; onClick: (dashboardId: string) => void; }) { const deleting = useIsCanvasPendingDelete(dashboardId); @@ -488,6 +506,7 @@ function CanvasArtifactCard({ deleting ? "Deleting…" : `Updated ${formatRelativeTimeShort(ts)}` } dimmed={deleting} + fillHeight={fillHeight} onClick={deleting ? undefined : () => onClick(dashboardId)} /> ); @@ -500,11 +519,16 @@ function PrArtifactCard({ title, prUrl, ts, + mediaHeight, + fillHeight, onClick, }: { title: string; prUrl: string; ts: number; + /** Grid only: match the canvas thumbnails' band instead of a short strip. */ + mediaHeight?: number; + fillHeight?: boolean; onClick: (safeUrl: string) => void; }) { const { @@ -524,8 +548,11 @@ function PrArtifactCard({
@@ -534,6 +561,7 @@ function PrArtifactCard({ title={title} badge={stateLabel || "Pull request"} subtitle={subtitle} + fillHeight={fillHeight} onClick={safeUrl ? () => onClick(safeUrl) : undefined} /> ); @@ -546,6 +574,7 @@ function ArtifactCardShell({ badge, subtitle, dimmed, + fillHeight, onClick, }: { media: ReactNode; @@ -554,6 +583,8 @@ function ArtifactCardShell({ badge: string; subtitle: string; dimmed?: boolean; + /** Grid only: fill the row so neighbouring cards end at the same line. */ + fillHeight?: boolean; /** Absent for a card with nowhere safe to go — a non-github PR link. */ onClick?: () => void; }) { @@ -566,12 +597,18 @@ function ArtifactCardShell({ // A card with nowhere to go (or mid-delete) takes no pointer events, so // the hover treatment below can key off plain group-hover. "group w-full text-left disabled:pointer-events-none", + fillHeight && "h-full", dimmed && "pointer-events-none opacity-60", )} > - -
{media}
- + +
{media}
+
{icon} diff --git a/packages/ui/src/features/loops/components/LoopDetailView.tsx b/packages/ui/src/features/loops/components/LoopDetailView.tsx index 88644373cc..b274013e43 100644 --- a/packages/ui/src/features/loops/components/LoopDetailView.tsx +++ b/packages/ui/src/features/loops/components/LoopDetailView.tsx @@ -63,6 +63,7 @@ import { import { formatLoopModel } from "../loopModels"; import { loopSkillBundles, primaryLoopSkillBundle } from "../loopSkill"; import { LoopLoadError } from "./LoopFallbacks"; +import { LoopHeaderTitle } from "./LoopHeaderTitle"; import { LoopRunRow } from "./LoopRunRow"; import { LoopSpaceBreadcrumb } from "./LoopSpaceBreadcrumb"; @@ -91,10 +92,12 @@ export function LoopDetailView({ loopId }: { loopId: string }) { ); }, [isLoading, runsQuery.isLoading, runsQuery.isError, loop, runs.length]); - // A loop attached to a space gets a breadcrumb back to it; a project-level - // loop has nowhere to walk back to, so it drops the row entirely. + // A loop attached to a space gets a breadcrumb back to it; one that belongs + // to the project (or any loop while the spaces layout is off) still names + // itself, it just has no parent to offer. const spacesLayout = useChannelsLayout(); const contextTarget = loop?.context_target ?? null; + const loopName = loop?.name ?? "Loop"; useSetHeaderContent( useMemo( () => @@ -102,10 +105,12 @@ export function LoopDetailView({ loopId }: { loopId: string }) { - ) : null, - [spacesLayout, contextTarget, loop?.name], + ) : ( + + ), + [spacesLayout, contextTarget, loopName], ), ); diff --git a/packages/ui/src/features/loops/components/LoopForm.tsx b/packages/ui/src/features/loops/components/LoopForm.tsx index 01e6ef80fe..c41cf27ca5 100644 --- a/packages/ui/src/features/loops/components/LoopForm.tsx +++ b/packages/ui/src/features/loops/components/LoopForm.tsx @@ -49,6 +49,7 @@ import { buildSkillInstructions, loopSkillBundles } from "../loopSkill"; import { LoopBehaviorFields } from "./LoopBehaviorFields"; import { LoopContextFields } from "./LoopContextFields"; import { Field } from "./LoopFormPrimitives"; +import { LoopHeaderTitle } from "./LoopHeaderTitle"; import { LoopModelFields } from "./LoopModelFields"; import { LoopNotificationsFields } from "./LoopNotificationsFields"; import { LoopRepositoryPicker } from "./LoopRepositoryPicker"; @@ -132,8 +133,8 @@ export function LoopForm({ loop }: LoopFormProps) { ]; const isLastStep = step === STEPS.length - 1; - // Building a loop for a space keeps a way back to it; a project-level loop - // has no parent to breadcrumb to, so the row collapses. + // Building a loop for a space keeps a way back to it; without one the header + // still names the scene, it just has no parent to offer. const spacesLayout = useChannelsLayout(); const contextTarget = values.contextTarget; const headerLeaf = isEdit ? loop.name : "New loop"; @@ -146,7 +147,9 @@ export function LoopForm({ loop }: LoopFormProps) { spaceName={contextTarget.name} leafLabel={headerLeaf} /> - ) : null, + ) : ( + + ), [spacesLayout, contextTarget, headerLeaf], ), ); diff --git a/packages/ui/src/features/loops/components/LoopHeaderTitle.tsx b/packages/ui/src/features/loops/components/LoopHeaderTitle.tsx new file mode 100644 index 0000000000..d3aa8faaab --- /dev/null +++ b/packages/ui/src/features/loops/components/LoopHeaderTitle.tsx @@ -0,0 +1,23 @@ +import { RepeatIcon } from "@phosphor-icons/react"; +import { Flex, Text } from "@radix-ui/themes"; + +/** + * Header lockup for a loop with no space to walk back to — a project-level + * loop, or any loop while the spaces layout is off. Names the scene without + * pretending there's a parent to click. + * + * A space-attached loop uses {@link LoopSpaceBreadcrumb} instead. + */ +export function LoopHeaderTitle({ label }: { label: string }) { + return ( + + + + {label} + + + ); +} From 7546946001016add2f027a2300b1881e024e4dbb Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Wed, 29 Jul 2026 12:34:57 +0100 Subject: [PATCH 39/43] refactor(canvas): artifacts view switcher is a quill ToggleGroup Replaces the ButtonGroup of outline buttons and its hand-rolled data-[active] styling: ToggleGroup owns the pressed state, so the selected view styles itself. Tooltips share one provider with no open delay, since the icons are the only labelling. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BYPVPkN4Lm3gygchrpcoqr --- .../canvas/components/ArtifactsViewToggle.tsx | 49 +++++++++++-------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ArtifactsViewToggle.tsx b/packages/ui/src/features/canvas/components/ArtifactsViewToggle.tsx index 973a380477..ce21ec6f35 100644 --- a/packages/ui/src/features/canvas/components/ArtifactsViewToggle.tsx +++ b/packages/ui/src/features/canvas/components/ArtifactsViewToggle.tsx @@ -1,12 +1,14 @@ import { LayoutIcon, ListIcon, SquaresFourIcon } from "@phosphor-icons/react"; import { - Button, - ButtonGroup, + ToggleGroup, + ToggleGroupItem, Tooltip, TooltipContent, + TooltipProvider, TooltipTrigger, } from "@posthog/quill"; import { + ARTIFACTS_VIEW_MODES, type ArtifactsViewMode, useArtifactsViewStore, } from "@posthog/ui/features/canvas/stores/artifactsViewStore"; @@ -22,38 +24,45 @@ const OPTIONS: { { mode: "masonry", label: "Masonry", Icon: LayoutIcon }, ]; -// Segmented control over the artifacts layout: a ButtonGroup of outline buttons -// joined into one control, the active one tinted (the same data-[active] idiom -// the sidebar's Channels/List switch uses) since outline has no selected state. +function isViewMode(value: string | undefined): value is ArtifactsViewMode { + return ARTIFACTS_VIEW_MODES.some((mode) => mode === value); +} + +// Layout switcher for the artifacts list. A quill ToggleGroup carries the +// pressed state itself, so there's no hand-rolled active styling here. export function ArtifactsViewToggle({ channelId }: { channelId?: string }) { const view = useArtifactsViewStore((s) => s.view); const setView = useArtifactsViewStore((s) => s.setView); return ( - - {OPTIONS.map(({ mode, label, Icon }) => { - const active = view === mode; - return ( + + { + // Pressing the active item would otherwise clear the group — a view + // is always on, so ignore the empty result. + const mode = next[0]; + if (isViewMode(mode)) setView(mode, channelId); + }} + > + {OPTIONS.map(({ mode, label, Icon }) => ( setView(mode, channelId)} > - + } /> {label} - ); - })} - + ))} + + ); } From 4563f4a332a20fb6a38ddf606c67316f8471c507 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 11:38:35 +0000 Subject: [PATCH 40/43] fix(chat-thread): lead turn copies with the prompt, defer context-menu copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review fixes: groupIntoTurns emits the user message as a standalone row, so the turn footer's copy carried the agent's prose without the prompt it answers. Each AgentTurn now records the user-initiated row that opened it, and both bodies pass it ahead of the turn's items to buildTurnCopyText. The context-menu "Copy message" wrote to the clipboard synchronously from the closing menu, which rejects in Electron while focus is being restored — silently leaving the clipboard's previous contents. It now goes through the deferred copyFromContextMenu helper (same as SessionView's outer menu) and surfaces both outcomes as toasts. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Q2H7r8hPC6pFCDrnYkMSRD --- .../components/chat-thread/ChatThread.tsx | 29 +++++++++++++++---- .../chat-thread/threadVirtualization.test.ts | 25 ++++++++++++++-- .../chat-thread/threadVirtualization.ts | 15 ++++++++-- 3 files changed, 60 insertions(+), 9 deletions(-) diff --git a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx index 4912b3c17b..fbe795c71e 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx +++ b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx @@ -74,6 +74,7 @@ import { import { buildTurnCopyText } from "@posthog/ui/features/sessions/components/chat-thread/turnCopyText"; import { usePromptRecallSource } from "@posthog/ui/features/sessions/components/chat-thread/usePromptRecallSource"; import { VirtualThreadScrollBody } from "@posthog/ui/features/sessions/components/chat-thread/VirtualThreadScrollBody"; +import { copyFromContextMenu } from "@posthog/ui/features/sessions/components/copyContextTarget"; import { GitActionMessage } from "@posthog/ui/features/sessions/components/GitActionMessage"; import { GitActionResult } from "@posthog/ui/features/sessions/components/GitActionResult"; import { isUserInitiatedConversationItem } from "@posthog/ui/features/sessions/components/isUserInitiatedConversationItem"; @@ -112,6 +113,7 @@ import { } from "@posthog/ui/features/sessions/useSessionTaskId"; import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; import { SkillButtonActionMessage } from "@posthog/ui/features/skill-buttons/components/SkillButtonActionMessage"; +import { toast } from "@posthog/ui/primitives/toast"; import { useCopy } from "@posthog/ui/primitives/useCopy"; import { DIFF_WORKER_FACTORY, @@ -213,14 +215,16 @@ function groupToolRuns(items: ConversationItem[]): ThreadItem[] { * Collapse each contiguous run of non-user rows into one {@link AgentTurn}, broken only by a * user-initiated row (which stays standalone so it remains the scroll anchor for the sticky header * and auto-follow). The turn block renders as a single muted card, tightening the spacing between - * the agent's successive replies and tool calls. + * the agent's successive replies and tool calls. Each turn records the user-initiated row that + * opened it, so "Copy turn" can lead with the prompt the turn answered. */ function groupIntoTurns(rows: ThreadItem[]): TurnRow[] { const out: TurnRow[] = []; let buffer: ThreadItem[] = []; + let prompt: ThreadItem | undefined; const flush = () => { if (buffer.length > 0) { - out.push({ type: "agent_turn", id: buffer[0].id, items: buffer }); + out.push({ type: "agent_turn", id: buffer[0].id, items: buffer, prompt }); buffer = []; } }; @@ -232,6 +236,7 @@ function groupIntoTurns(rows: ThreadItem[]): TurnRow[] { if (isUserInitiatedConversationItem(row)) { flush(); out.push(row); + prompt = row; } else { buffer.push(row); } @@ -495,6 +500,10 @@ function UserBubble({ * This menu sits inside `SessionView`'s own context menu and wins the event over it, so it also * carries that menu's raw-logs toggle; without it, right-clicking a message would be the one spot * in the session where the toggle went missing. + * + * The write goes through {@link copyFromContextMenu}: a synchronous write from a closing menu + * rejects while focus is still being restored, and both outcomes surface as toasts — a silent + * failure would leave the clipboard's previous contents where the user believes the message is. */ function MessageContextMenu({ value, @@ -503,14 +512,20 @@ function MessageContextMenu({ value: string; children: ReactElement; }) { - const { copy } = useCopy(); const showRawLogs = useShowRawLogs(); const { setShowRawLogs } = useSessionViewActions(); return ( - copy(value)}> + + copyFromContextMenu(value, { + onSuccess: () => toast.success("Copied"), + onError: () => toast.error("Couldn't copy"), + }) + } + > Copy message @@ -644,7 +659,11 @@ const ThreadRow = memo(function ThreadRow({
); diff --git a/packages/ui/src/features/sessions/components/chat-thread/threadVirtualization.test.ts b/packages/ui/src/features/sessions/components/chat-thread/threadVirtualization.test.ts index 3b44615c95..73178cf470 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/threadVirtualization.test.ts +++ b/packages/ui/src/features/sessions/components/chat-thread/threadVirtualization.test.ts @@ -45,8 +45,12 @@ function toolGroup(id: string, tools: SessionUpdateItem[]): ToolGroupItem { return { type: "tool_group", id, tools }; } -function agentTurn(id: string, items: TurnRow[]): AgentTurn { - return { type: "agent_turn", id, items: items as AgentTurn["items"] }; +function agentTurn( + id: string, + items: TurnRow[], + prompt?: ConversationItem, +): AgentTurn { + return { type: "agent_turn", id, items: items as AgentTurn["items"], prompt }; } describe("flattenTurnRows", () => { @@ -108,6 +112,23 @@ describe("flattenTurnRows", () => { ]); }); + it("leads the copy text with the prompt that opened the turn", () => { + const done = agentTurn( + "d", + [ + sessionUpdate("d1", { + turnComplete: true, + timestamp: 1, + text: "reply", + }), + ], + userMessage("u1"), + ); + expect(flattenTurnRows([done]).at(-1)?.turnCopyText).toBe( + "msg u1\n\nreply", + ); + }); + it("leaves copy text off a turn that is still streaming", () => { const streaming = agentTurn("s", [ sessionUpdate("s1", { text: "partial" }), diff --git a/packages/ui/src/features/sessions/components/chat-thread/threadVirtualization.ts b/packages/ui/src/features/sessions/components/chat-thread/threadVirtualization.ts index 5aed57f403..5375b512e9 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/threadVirtualization.ts +++ b/packages/ui/src/features/sessions/components/chat-thread/threadVirtualization.ts @@ -9,7 +9,16 @@ export type ThreadItem = ConversationItem | ToolGroupItem; * A contiguous run of non-user rows (assistant prose, tools, git actions, ...) shown as one * block with tight internal spacing. Broken only by a user message. */ -export type AgentTurn = { type: "agent_turn"; id: string; items: ThreadItem[] }; +export type AgentTurn = { + type: "agent_turn"; + id: string; + items: ThreadItem[]; + /** + * The user-initiated row that opened this turn — grouping emits it as a standalone row, so + * without it "Copy turn" would carry the agent's prose but not the prompt it answers. + */ + prompt?: ThreadItem; +}; /** Top-level row: a standalone user message, or a grouped agent turn. */ export type TurnRow = ThreadItem | AgentTurn; @@ -104,7 +113,9 @@ export function flattenTurnRows(rows: TurnRow[]): FlatThreadRow[] { const copyText = timestamp == null ? undefined - : (buildTurnCopyText(row.items) ?? undefined); + : (buildTurnCopyText( + row.prompt ? [row.prompt, ...row.items] : row.items, + ) ?? undefined); for (let i = 0; i < row.items.length; i++) { const item = row.items[i]; const isTrailing = i === row.items.length - 1; From c35dffd8e04c74b52eb66e099fdcd50bd471472d Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Wed, 29 Jul 2026 12:41:22 +0100 Subject: [PATCH 41/43] new masonry icon --- .../ui/src/features/canvas/components/ArtifactsViewToggle.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ArtifactsViewToggle.tsx b/packages/ui/src/features/canvas/components/ArtifactsViewToggle.tsx index ce21ec6f35..c2b91d3aae 100644 --- a/packages/ui/src/features/canvas/components/ArtifactsViewToggle.tsx +++ b/packages/ui/src/features/canvas/components/ArtifactsViewToggle.tsx @@ -1,4 +1,4 @@ -import { LayoutIcon, ListIcon, SquaresFourIcon } from "@phosphor-icons/react"; +import { Kanban, ListIcon, SquaresFourIcon } from "@phosphor-icons/react"; import { ToggleGroup, ToggleGroupItem, @@ -21,7 +21,7 @@ const OPTIONS: { }[] = [ { mode: "list", label: "List", Icon: ListIcon }, { mode: "grid", label: "Grid", Icon: SquaresFourIcon }, - { mode: "masonry", label: "Masonry", Icon: LayoutIcon }, + { mode: "masonry", label: "Masonry", Icon: Kanban }, ]; function isViewMode(value: string | undefined): value is ArtifactsViewMode { From 777ff79a9543e4449ebb9de91902ed80d38b4546 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Wed, 29 Jul 2026 12:46:19 +0100 Subject: [PATCH 42/43] fix(canvas): artifacts fill the pane The list was capped at 680px and the cards at 1400px, which stranded whitespace to the right of the header on a wide window. Every layout here is a scannable list rather than prose, so drop the caps; the grid and masonry gain a fourth column past 2xl now that they have the room. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BYPVPkN4Lm3gygchrpcoqr --- .../components/WebsiteChannelArtifacts.tsx | 23 ++++++++----------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx index 05beaab443..d9c9569358 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx @@ -188,15 +188,10 @@ export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) { ) : null}
- {/* Left-aligned, like the page header above it — a centred column - drifts away from the title as the window widens. The list keeps a - readable measure; the card layouts spread. */} -
+ {/* Full width, flush with the page header above it — every layout + here (rows and cards alike) is a scannable list, not prose, so a + measure cap just strands whitespace on wide windows. */} +
{!spacesLayout && (
@@ -235,7 +230,7 @@ export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) { // items-stretch + a full-height card: a PR tile (no preview to // show) matches the canvas cards in its row instead of ending // short. -
+
{items.map((item) => ( +
{items.map((item) => (
{title} - + {subtitle} ); From 16f14725d87a3e1365a960c6d321a1697fd70962 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Wed, 29 Jul 2026 12:52:04 +0100 Subject: [PATCH 43/43] fix(ui): clear react-doctor findings from this branch - call useChannelsLayout unconditionally, not inside a ternary, in the two places that derive a "space"/"channel" noun from it - move inboxTabFromPath / inboxScopeApplies to core beside the tab routes they read, so the tab bar file exports components only - render the Activity feed as a memo'd child instead of JSX built in a useMemo above the parent's early return Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BYPVPkN4Lm3gygchrpcoqr --- packages/core/src/inbox/reportMembership.ts | 16 ++ .../canvas/components/ActivityView.tsx | 150 +++++++++++------- .../components/WebsiteDashboardsIndex.tsx | 3 +- .../canvas/components/WebsiteLayout.tsx | 3 +- .../inbox/components/InboxPageHeader.tsx | 10 +- .../features/inbox/components/InboxTabBar.tsx | 18 +-- 6 files changed, 121 insertions(+), 79 deletions(-) diff --git a/packages/core/src/inbox/reportMembership.ts b/packages/core/src/inbox/reportMembership.ts index edfca5f9e8..b6a38db5c4 100644 --- a/packages/core/src/inbox/reportMembership.ts +++ b/packages/core/src/inbox/reportMembership.ts @@ -124,6 +124,22 @@ export function isInboxDetailPath(pathname: string): boolean { return INBOX_DETAIL_PATH_RE.test(pathname); } +/** Which tab a list pathname belongs to; anything unrecognised reads as Pulls. */ +export function inboxTabFromPath(pathname: string): InboxTabKey { + if (pathname.startsWith(INBOX_TAB_LIST_ROUTE.reports)) return "reports"; + if (pathname.startsWith(INBOX_TAB_LIST_ROUTE.runs)) return "runs"; + if (pathname.startsWith(INBOX_TAB_LIST_ROUTE.dismissed)) return "dismissed"; + return "pulls"; +} + +/** + * Whether the reviewer-scope control means anything on this tab: Runs is + * unscoped and the Archive is a terminal list, so neither filters by reviewer. + */ +export function inboxScopeApplies(tab: InboxTabKey): boolean { + return tab !== "runs" && tab !== "dismissed"; +} + /** * PR tab membership: Responder shipped a draft PR and it is `ready` for review. * PRs that have already been merged/closed (`resolved`) or are still running diff --git a/packages/ui/src/features/canvas/components/ActivityView.tsx b/packages/ui/src/features/canvas/components/ActivityView.tsx index 046c534ce8..cb3e01eba2 100644 --- a/packages/ui/src/features/canvas/components/ActivityView.tsx +++ b/packages/ui/src/features/canvas/components/ActivityView.tsx @@ -47,7 +47,7 @@ import { import { track } from "@posthog/ui/shell/analytics"; import { Text } from "@radix-ui/themes"; import type { ReactNode } from "react"; -import { useCallback, useEffect, useMemo } from "react"; +import { memo, useCallback, useEffect, useMemo } from "react"; import { activityReadPayload, channelIdForName, @@ -316,64 +316,18 @@ export function ActivityView() { [unreadCount, unreadItems.length, isMarkingRead, markAllRead], ); - const feed = useMemo( - () => ( - <> - {isLoading && items.length === 0 ? ( -
- -
- ) : items.length === 0 ? ( - - - - - - No activity yet - - Tasks you create, get tagged in, or reply to across{" "} - {spacesLayout ? "spaces" : "channels"} land here. - - - - ) : ( -
- {items.map((item) => ( - - ))} - {hasNextPage && ( - - )} -
- )} - - ), - [ - isLoading, - items, - spacesLayout, - folderChannelIdFor, - markRead, - currentUser, - hasNextPage, - isFetchingNextPage, - fetchNextPage, - ], + const feed = ( + ); // The shared page header ships with the spaces layout; without it the page @@ -428,3 +382,81 @@ export function ActivityView() {
); } + +/** + * The feed body. A memo'd child rather than JSX built in the parent: the parent + * picks between two page shells and returns early, and this way the branch it + * doesn't take costs nothing. + */ +const ActivityFeed = memo(function ActivityFeed({ + items, + isLoading, + spacesLayout, + folderChannelIdFor, + markRead, + currentUser, + hasNextPage, + isFetchingNextPage, + fetchNextPage, +}: { + items: TaskActivityItem[]; + isLoading: boolean; + spacesLayout: boolean; + folderChannelIdFor: (channelName: string | null) => string | null; + markRead: (item: TaskActivityItem) => void; + currentUser?: UserBasic | null; + hasNextPage: boolean; + isFetchingNextPage: boolean; + fetchNextPage: () => void; +}) { + if (isLoading && items.length === 0) { + return ( +
+ +
+ ); + } + + if (items.length === 0) { + return ( + + + + + + No activity yet + + Tasks you create, get tagged in, or reply to across{" "} + {spacesLayout ? "spaces" : "channels"} land here. + + + + ); + } + + return ( +
+ {items.map((item) => ( + + ))} + {hasNextPage && ( + + )} +
+ ); +}); diff --git a/packages/ui/src/features/canvas/components/WebsiteDashboardsIndex.tsx b/packages/ui/src/features/canvas/components/WebsiteDashboardsIndex.tsx index e88cf4efc9..2e396f584c 100644 --- a/packages/ui/src/features/canvas/components/WebsiteDashboardsIndex.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteDashboardsIndex.tsx @@ -186,7 +186,8 @@ function DashboardCardMenu({ channelId: string; }) { const [open, setOpen] = useState(false); - const containerNoun = useChannelsLayout() ? "space" : "channel"; + const spacesLayout = useChannelsLayout(); + const containerNoun = spacesLayout ? "space" : "channel"; // "Delete…" opens a confirmation rather than deleting inline — the canvas and // its version history go away for everyone in the space. const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); diff --git a/packages/ui/src/features/canvas/components/WebsiteLayout.tsx b/packages/ui/src/features/canvas/components/WebsiteLayout.tsx index 032b1988b5..a98cd7256d 100644 --- a/packages/ui/src/features/canvas/components/WebsiteLayout.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteLayout.tsx @@ -84,7 +84,8 @@ function FreeformEditControls({ const navigate = useNavigate(); // Pinning is scoped to whatever holds the canvas; the new layout calls that a // space, the old one a channel. - const containerNoun = useChannelsLayout() ? "space" : "channel"; + const spacesLayout = useChannelsLayout(); + const containerNoun = spacesLayout ? "space" : "channel"; const editing = useIsDashboardEditing(dashboardId); const setEditing = useDashboardEditStore((s) => s.setEditing); const { dashboard } = useDashboard(dashboardId); diff --git a/packages/ui/src/features/inbox/components/InboxPageHeader.tsx b/packages/ui/src/features/inbox/components/InboxPageHeader.tsx index 63beacda05..1217391dcc 100644 --- a/packages/ui/src/features/inbox/components/InboxPageHeader.tsx +++ b/packages/ui/src/features/inbox/components/InboxPageHeader.tsx @@ -1,11 +1,13 @@ -import type { InboxTabCounts } from "@posthog/core/inbox/reportMembership"; +import { + type InboxTabCounts, + inboxScopeApplies, + inboxTabFromPath, +} from "@posthog/core/inbox/reportMembership"; import { useBluebirdFlag } from "@posthog/ui/features/feature-flags/useBluebirdFlag"; import { InboxScopeSelect } from "@posthog/ui/features/inbox/components/InboxScopeSelect"; import { - activeTabFromPath, InboxTabBar, InboxTabs, - inboxScopeApplies, } from "@posthog/ui/features/inbox/components/InboxTabBar"; import { PageHeader, @@ -44,7 +46,7 @@ export function InboxPageHeader({ counts }: InboxPageHeaderProps) { - {inboxScopeApplies(activeTabFromPath(pathname)) && ( + {inboxScopeApplies(inboxTabFromPath(pathname)) && ( diff --git a/packages/ui/src/features/inbox/components/InboxTabBar.tsx b/packages/ui/src/features/inbox/components/InboxTabBar.tsx index 1c2bb84c77..21131e6d29 100644 --- a/packages/ui/src/features/inbox/components/InboxTabBar.tsx +++ b/packages/ui/src/features/inbox/components/InboxTabBar.tsx @@ -4,6 +4,8 @@ import { INBOX_TAB_LIST_ROUTE, type InboxTabCounts, type InboxTabKey, + inboxScopeApplies, + inboxTabFromPath, } from "@posthog/core/inbox/reportMembership"; import { Tabs, TabsList, TabsTrigger } from "@posthog/quill"; import { InboxScopeSelect } from "@posthog/ui/features/inbox/components/InboxScopeSelect"; @@ -14,17 +16,10 @@ interface InboxTabBarProps { counts: InboxTabCounts; } -export function activeTabFromPath(pathname: string): InboxTabKey { - if (pathname.startsWith(INBOX_TAB_LIST_ROUTE.reports)) return "reports"; - if (pathname.startsWith(INBOX_TAB_LIST_ROUTE.runs)) return "runs"; - if (pathname.startsWith(INBOX_TAB_LIST_ROUTE.dismissed)) return "dismissed"; - return "pulls"; -} - /** The legacy header's row: tabs with the reviewer-scope select alongside. */ export function InboxTabBar({ counts }: InboxTabBarProps) { const pathname = useRouterState({ select: (s) => s.location.pathname }); - const activeKey = activeTabFromPath(pathname); + const activeKey = inboxTabFromPath(pathname); return ( @@ -34,16 +29,11 @@ export function InboxTabBar({ counts }: InboxTabBarProps) { ); } -/** Whether the reviewer-scope control means anything on this tab. */ -export function inboxScopeApplies(tab: InboxTabKey): boolean { - return tab !== "runs" && tab !== "dismissed"; -} - /** Just the tab strip — the header slots its own filters beside it. */ export function InboxTabs({ counts }: InboxTabBarProps) { const navigate = useNavigate(); const pathname = useRouterState({ select: (s) => s.location.pathname }); - const activeKey = activeTabFromPath(pathname); + const activeKey = inboxTabFromPath(pathname); return (