diff --git a/apps/code/src/renderer/desktop-services.ts b/apps/code/src/renderer/desktop-services.ts index f4e878c880..140113505c 100644 --- a/apps/code/src/renderer/desktop-services.ts +++ b/apps/code/src/renderer/desktop-services.ts @@ -50,6 +50,10 @@ import { } from "@posthog/core/speech/identifiers"; import { resolveService } from "@posthog/di/container"; import { ROOT_LOGGER, type RootLogger } from "@posthog/di/logger"; +import { + HOST_CAPABILITIES, + type HostCapabilities, +} from "@posthog/platform/host-capabilities"; import { type INotifications, NOTIFICATIONS_SERVICE, @@ -441,3 +445,7 @@ container .inSingletonScope(); container.bind(SETUP_STORE).toConstantValue(setupStore); + +container + .bind(HOST_CAPABILITIES) + .toConstantValue({ localWorkspaces: true } satisfies HostCapabilities); diff --git a/apps/code/src/renderer/di/bindings.ts b/apps/code/src/renderer/di/bindings.ts index dbdca0c7df..369d4bc937 100644 --- a/apps/code/src/renderer/di/bindings.ts +++ b/apps/code/src/renderer/di/bindings.ts @@ -139,6 +139,10 @@ import { HOST_TRPC_CLIENT, type HostTrpcClient, } from "@posthog/host-router/client"; +import { + HOST_CAPABILITIES, + type HostCapabilities, +} from "@posthog/platform/host-capabilities"; import { type INotifications, NOTIFICATIONS_SERVICE, @@ -338,6 +342,7 @@ export interface RendererBindings { [FEATURE_FLAGS]: FeatureFlags; [AUTH_SIDE_EFFECTS]: IAuthSideEffects; [SETUP_STORE]: ISetupStore; + [HOST_CAPABILITIES]: HostCapabilities; // --- desktop-contributions.ts --- [CONTRIBUTION]: Contribution; diff --git a/apps/code/src/renderer/main.tsx b/apps/code/src/renderer/main.tsx index c85b2d79d2..ce2c1f2c93 100644 --- a/apps/code/src/renderer/main.tsx +++ b/apps/code/src/renderer/main.tsx @@ -19,10 +19,12 @@ import { Providers } from "@components/Providers"; import { DevToolbarHost } from "@features/dev-toolbar/DevToolbarHost"; import { preloadHighlighter } from "@pierre/diffs"; import { boot } from "@posthog/di/contribution"; +import { assertHostCapabilities } from "@posthog/di/hostCapabilities"; import { ServiceProvider } from "@posthog/di/react"; import App from "@posthog/ui/shell/App"; import { logger } from "@posthog/ui/shell/logger"; import { initializePostHog } from "@posthog/ui/shell/posthogAnalyticsImpl"; +import { REQUIRED_HOST_CAPABILITIES } from "@posthog/ui/shell/requiredHostCapabilities"; import { registerDesktopContributions } from "@renderer/desktop-contributions"; import { container } from "@renderer/di/container"; import "@renderer/desktop-services"; @@ -95,6 +97,11 @@ const root = ReactDOM.createRoot(rootElement); try { registerDesktopContributions(); + // Fail loudly (into BootErrorScreen) if a capability the shared app resolves + // via service location is unbound, rather than deferring to the first + // navigation that needs it. The renderer container backs every useService, so + // all required tokens must resolve here. Shared with the web host. + assertHostCapabilities(container, REQUIRED_HOST_CAPABILITIES); boot(container).catch((error: unknown) => { bootLog.error("Renderer boot sequence failed", error); // Replaces the mounted tree without running effect cleanup; acceptable diff --git a/packages/di/src/hostCapabilities.test.ts b/packages/di/src/hostCapabilities.test.ts new file mode 100644 index 0000000000..810da9c069 --- /dev/null +++ b/packages/di/src/hostCapabilities.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { assertHostCapabilities } from "./hostCapabilities"; + +const TOKEN_A = Symbol.for("posthog.test.capabilityA"); +const TOKEN_B = Symbol.for("posthog.test.capabilityB"); + +function containerBinding(bound: symbol[]): { + isBound: (t: symbol) => boolean; +} { + const set = new Set(bound); + return { isBound: (t) => set.has(t) }; +} + +describe("assertHostCapabilities", () => { + it("passes when every required token is bound", () => { + const container = containerBinding([TOKEN_A, TOKEN_B]); + expect(() => + assertHostCapabilities(container, [ + { token: TOKEN_A, description: "A" }, + { token: TOKEN_B, description: "B" }, + ]), + ).not.toThrow(); + }); + + it("passes when there are no requirements", () => { + expect(() => + assertHostCapabilities(containerBinding([]), []), + ).not.toThrow(); + }); + + it("throws listing every missing token and its description", () => { + const container = containerBinding([TOKEN_A]); + expect(() => + assertHostCapabilities(container, [ + { token: TOKEN_A, description: "A is fine" }, + { token: TOKEN_B, description: "B drives cloud runs" }, + ]), + ).toThrowError(/missing 1 required capability/); + }); + + it("reports all missing tokens at once, not just the first", () => { + const container = containerBinding([]); + let message = ""; + try { + assertHostCapabilities(container, [ + { token: TOKEN_A, description: "A drives X" }, + { token: TOKEN_B, description: "B drives Y" }, + ]); + } catch (error) { + message = (error as Error).message; + } + expect(message).toContain("missing 2 required capability"); + expect(message).toContain("A drives X"); + expect(message).toContain("B drives Y"); + expect(message).toContain(String(TOKEN_A)); + expect(message).toContain(String(TOKEN_B)); + }); +}); diff --git a/packages/di/src/hostCapabilities.ts b/packages/di/src/hostCapabilities.ts new file mode 100644 index 0000000000..d242f83862 --- /dev/null +++ b/packages/di/src/hostCapabilities.ts @@ -0,0 +1,46 @@ +import type { ServiceIdentifier } from "inversify"; +import type { ServiceContainer } from "./container"; + +/** + * A DI token that shared `@posthog/ui` / `@posthog/core` resolves via service + * location (`useService` / `resolveService`) and that every host mounting the + * shared app must therefore bind in its composition root. + */ +export interface HostCapabilityRequirement { + /** The token the shared app resolves at runtime. */ + readonly token: ServiceIdentifier; + /** What breaks when it's missing — surfaced in the error message. */ + readonly description: string; +} + +/** + * Throw if the host forgot to bind a capability the shared app resolves at + * runtime. + * + * These gaps are invisible to the compiler: `useService(TOKEN)`'s type + * argument is supplied by the caller, not derived from any host's binding map, + * so a `TypedContainer` only checks the *provider* side. A missing + * binding otherwise surfaces only when a user reaches the code path that + * resolves the token (as happened with the inbox `reportModelResolver` on web). + * + * Call this synchronously at the end of each composition root, once every + * binding is registered, so a broken container fails to start instead of + * limping to the first unlucky navigation. + */ +export function assertHostCapabilities( + container: Pick, + requirements: readonly HostCapabilityRequirement[], +): void { + const missing = requirements.filter((r) => !container.isBound(r.token)); + if (missing.length === 0) { + return; + } + + const lines = missing.map((r) => ` - ${String(r.token)}: ${r.description}`); + throw new Error( + `Host container is missing ${missing.length} required capability ` + + `binding(s) that shared UI/core resolves at runtime:\n${lines.join("\n")}\n` + + "Bind them in this host's composition root. See REQUIRED_HOST_CAPABILITIES " + + "in @posthog/ui/shell/requiredHostCapabilities.", + ); +} diff --git a/packages/platform/package.json b/packages/platform/package.json index 5b1ede9f13..d59ff4b010 100644 --- a/packages/platform/package.json +++ b/packages/platform/package.json @@ -4,6 +4,10 @@ "description": "Host-agnostic platform port interfaces. Zero runtime deps; implemented by per-host adapters (Electron, Node server, React Native, web).", "type": "module", "exports": { + "./host-capabilities": { + "types": "./dist/host-capabilities.d.ts", + "import": "./dist/host-capabilities.js" + }, "./url-launcher": { "types": "./dist/url-launcher.d.ts", "import": "./dist/url-launcher.js" diff --git a/packages/platform/src/host-capabilities.ts b/packages/platform/src/host-capabilities.ts new file mode 100644 index 0000000000..a65a05bb9f --- /dev/null +++ b/packages/platform/src/host-capabilities.ts @@ -0,0 +1,18 @@ +/** + * Coarse host capabilities the shared UI branches on. Kept deliberately small: + * a capability belongs here only when the UI must render differently by host + * (not merely bind a different adapter). Hosts bind a constant value. + */ +export interface HostCapabilities { + /** + * Whether the host can access the local filesystem — local repository + * folders, git worktrees, and a terminal. Desktop (Electron) has it; the + * cloud-only browser host does not, so the UI falls back to remote + * (connected-GitHub-org) repositories and cloud workspaces. + */ + readonly localWorkspaces: boolean; +} + +export const HOST_CAPABILITIES = Symbol.for( + "posthog.platform.hostCapabilities", +); diff --git a/packages/platform/tsup.config.ts b/packages/platform/tsup.config.ts index ba5543d15e..2caee254ae 100644 --- a/packages/platform/tsup.config.ts +++ b/packages/platform/tsup.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from "tsup"; export default defineConfig({ entry: [ + "src/host-capabilities.ts", "src/url-launcher.ts", "src/storage-paths.ts", "src/app-meta.ts", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index a4f453d04d..88873fd0b8 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -225,7 +225,11 @@ export type { SkillSource, UploadableSkillSource, } from "./skills"; -export { SKILL_EXISTS_MARKER, stripFrontmatter } from "./skills"; +export { + SKILL_EXISTS_MARKER, + serializeSkillMarkdown, + stripFrontmatter, +} from "./skills"; export type { ArtifactType, PostHogAPIConfig, diff --git a/packages/shared/src/skills.ts b/packages/shared/src/skills.ts index 540929b7be..540b92eba5 100644 --- a/packages/shared/src/skills.ts +++ b/packages/shared/src/skills.ts @@ -33,6 +33,46 @@ export interface ExportedSkill { files: ExportedSkillFile[]; } +/** + * Serializes a SKILL.md file from frontmatter metadata plus a markdown body. + * + * The output must round-trip through `parseSkillFrontmatter` and also be valid + * YAML for the agents that consume these files, so scalars fall back from plain + * → double-quoted → literal block as they get more hostile. Lives here (shared) + * so both the workspace-server bundler and the web-host bundler produce the + * exact same SKILL.md — this is a serialization contract consumed by the cloud + * sandbox, so it must not drift between hosts. + */ +export function serializeSkillMarkdown( + meta: { name: string; description: string }, + body: string, +): string { + const frontmatter = [ + "---", + `name: ${serializeSkillScalar(meta.name)}`, + `description: ${serializeSkillScalar(meta.description)}`, + "---", + ].join("\n"); + + const trimmedBody = body.replace(/^\n+/, ""); + return `${frontmatter}\n\n${trimmedBody.trimEnd()}\n`; +} + +const SKILL_PLAIN_SAFE = /^[A-Za-z0-9][A-Za-z0-9 _.,;()/-]*$/; + +function serializeSkillScalar(value: string): string { + if (value === "") return '""'; + if (!value.includes("\n")) { + if (SKILL_PLAIN_SAFE.test(value) && !value.endsWith(" ")) return value; + if (!value.includes('"') && !value.includes("\\")) return `"${value}"`; + } + // Literal block: survives quotes, backslashes, and newlines. + const lines = value + .split("\n") + .map((line) => (line.trim() ? ` ${line}` : "")); + return `|-\n${lines.join("\n")}`; +} + /** * Server "skill already exists" messages must include this marker verbatim; * the UI keys its overwrite-confirmation flow on it. diff --git a/packages/ui/src/features/git-interaction/components/CloudGitInteractionHeader.tsx b/packages/ui/src/features/git-interaction/components/CloudGitInteractionHeader.tsx index bab4553982..82e08e7167 100644 --- a/packages/ui/src/features/git-interaction/components/CloudGitInteractionHeader.tsx +++ b/packages/ui/src/features/git-interaction/components/CloudGitInteractionHeader.tsx @@ -9,6 +9,7 @@ import { } from "@posthog/ui/features/sessions/localHandoffService"; import { useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; +import { useHostCapabilities } from "../../../shell/useHostCapabilities"; import { useFeatureFlag } from "../../feature-flags/useFeatureFlag"; import { DirtyTreeDialog } from "../../sessions/components/DirtyTreeDialog"; import { HandoffConfirmDialog } from "../../sessions/components/HandoffConfirmDialog"; @@ -40,6 +41,7 @@ export function CloudGitInteractionHeader({ GIT_CACHE_KEY_PROVIDER, ); const localHandoff = useService(LOCAL_HANDOFF_SERVICE); + const { localWorkspaces } = useHostCapabilities(); const cloudHandoffEnabled = useFeatureFlag(CLOUD_HANDOFF_FLAG) || import.meta.env.DEV; @@ -106,7 +108,8 @@ export function CloudGitInteractionHeader({ await localHandoff.afterCommit(); }; - if (!cloudHandoffEnabled) return null; + // "Continue locally" hands the task off to a local checkout + if (!cloudHandoffEnabled || !localWorkspaces) return null; if (task.origin_product === "image_builder") return null; const inProgress = session?.handoffInProgress ?? false; diff --git a/packages/ui/src/features/onboarding/components/SelectRepoStep.tsx b/packages/ui/src/features/onboarding/components/SelectRepoStep.tsx index 1e02b832d8..828bcfe07a 100644 --- a/packages/ui/src/features/onboarding/components/SelectRepoStep.tsx +++ b/packages/ui/src/features/onboarding/components/SelectRepoStep.tsx @@ -8,12 +8,14 @@ import { } from "@phosphor-icons/react"; import { repoMatchesGitHubRepos } from "@posthog/core/onboarding/repoProvider"; import { cn } from "@posthog/quill"; +import { useHostCapabilities } from "@posthog/ui/shell/useHostCapabilities"; import { Box, Button, Flex, Text } from "@radix-ui/themes"; import { AnimatePresence, motion } from "framer-motion"; import { useMemo } from "react"; import { builderHog } from "../../../assets/hedgehogs"; import { OnboardingHogTip } from "../../../primitives/OnboardingHogTip"; import { FolderPicker } from "../../folder-picker/FolderPicker"; +import { GitHubRepoPicker } from "../../folder-picker/GitHubRepoPicker"; import { useUserRepositoryIntegration } from "../../integrations/useIntegrations"; import type { DetectedRepo } from "../types"; import { OptionalBadge } from "./OptionalBadge"; @@ -37,7 +39,13 @@ export function SelectRepoStep({ isDetectingRepo, onDirectoryChange, }: SelectRepoStepProps) { - const { repositories } = useUserRepositoryIntegration(); + const { localWorkspaces } = useHostCapabilities(); + const { + repositories, + isLoadingRepos, + isRefreshingRepos, + refreshRepositories, + } = useUserRepositoryIntegration(); const repoMatchesGitHub = useMemo( () => repoMatchesGitHubRepos(detectedRepo, repositories), @@ -96,18 +104,31 @@ export function SelectRepoStep({ - Select a single repository folder, not a parent folder - that contains multiple repos. + {localWorkspaces + ? "Select a single repository folder, not a parent folder that contains multiple repos." + : "Pick a repository from your connected GitHub organizations."} - + {localWorkspaces ? ( + + ) : ( + onDirectoryChange(repo ?? "")} + repositories={repositories} + isLoading={isLoadingRepos} + onRefresh={refreshRepositories} + isRefreshing={isRefreshingRepos} + placeholder="Select repository..." + /> + )} - {isDetectingRepo && ( + {localWorkspaces && isDetectingRepo && ( )} - {!isDetectingRepo && + {localWorkspaces && + !isDetectingRepo && selectedDirectory && detectedRepo && ( )} - {!isDetectingRepo && + {localWorkspaces && + !isDetectingRepo && selectedDirectory && !detectedRepo && ( state.currentStep); const setCurrentStep = useOnboardingStore((state) => state.setCurrentStep); const selectedDirectory = useActiveRepoStore((state) => state.path); const setSelectedDirectory = useActiveRepoStore((state) => state.setPath); + const setLastUsedCloudRepository = useSettingsStore( + (state) => state.setLastUsedCloudRepository, + ); const directionRef = useRef<1 | -1>(1); const [detectedRepo, setDetectedRepo] = useState(null); @@ -37,6 +43,8 @@ export function useOnboardingFlow() { const hasRehydrated = useRef(false); useEffect(() => { + // Cloud-only hosts have no local git to detect against. + if (!localWorkspaces) return; if (hasRehydrated.current || !selectedDirectory) return; hasRehydrated.current = true; setIsDetectingRepo(true); @@ -45,12 +53,24 @@ export function useOnboardingFlow() { .then((result) => setDetectedRepo(toDetectedRepo(result))) .catch(() => {}) .finally(() => setIsDetectingRepo(false)); - }, [selectedDirectory, hostClient]); + }, [selectedDirectory, hostClient, localWorkspaces]); const handleDirectoryChange = useCallback( async (path: string) => { setSelectedDirectory(path); setDetectedRepo(null); + + // Cloud-only: `path` is a remote "owner/repo" reference. Persist it as the + // last-used cloud repository so the task input prefills it. + if (!localWorkspaces) { + setLastUsedCloudRepository(path || null); + track(ANALYTICS_EVENTS.ONBOARDING_FOLDER_SELECTED, { + has_git_remote: !!path, + repository_provider: "github", + }); + return; + } + if (!path) return; setIsDetectingRepo(true); @@ -75,7 +95,12 @@ export function useOnboardingFlow() { setIsDetectingRepo(false); } }, - [setSelectedDirectory, hostClient], + [ + setSelectedDirectory, + setLastUsedCloudRepository, + hostClient, + localWorkspaces, + ], ); const hasCodeAccess = useAuthStateValue((state) => state.hasCodeAccess); diff --git a/packages/ui/src/features/panels/components/LeafNodeRenderer.tsx b/packages/ui/src/features/panels/components/LeafNodeRenderer.tsx index 0d6568a6b9..dbd38de4c8 100644 --- a/packages/ui/src/features/panels/components/LeafNodeRenderer.tsx +++ b/packages/ui/src/features/panels/components/LeafNodeRenderer.tsx @@ -3,6 +3,7 @@ import type { Task } from "@posthog/shared/domain-types"; import { Flex, Text } from "@radix-ui/themes"; import type React from "react"; import { useMemo } from "react"; +import { useHostCapabilities } from "../../../shell/useHostCapabilities"; import { useIsWorkspaceCloudRun } from "../../workspace/useWorkspace"; import { useTabInjection } from "../hooks/usePanelLayoutHooks"; import type { SplitDirection } from "../panelLayoutStore"; @@ -41,12 +42,15 @@ export const LeafNodeRenderer: React.FC = ({ onSplitPanel, }) => { const isCloud = useIsWorkspaceCloudRun(taskId); + const { localWorkspaces } = useHostCapabilities(); + // Hide the terminal for cloud runs, and on cloud-only hosts (web). + const hideTerminal = isCloud || !localWorkspaces; const inputTabs = useMemo( () => - isCloud + hideTerminal ? node.content.tabs.filter((t) => t.data.type !== "terminal") : node.content.tabs, - [node.content.tabs, isCloud], + [node.content.tabs, hideTerminal], ); const tabs = useTabInjection(inputTabs, node.id, taskId, task, closeTab); const activeTabId = tabs.some((t) => t.id === node.content.activeTabId) @@ -90,7 +94,7 @@ export const LeafNodeRenderer: React.FC = ({ onPanelFocus={onPanelFocus} draggingTabId={draggingTabId} draggingTabPanelId={draggingTabPanelId} - onAddTerminal={isCloud ? undefined : () => onAddTerminal(node.id)} + onAddTerminal={hideTerminal ? undefined : () => onAddTerminal(node.id)} onSplitPanel={(direction) => onSplitPanel(node.id, direction)} emptyState={cloudEmptyState} /> diff --git a/packages/ui/src/features/settings/components/SettingsPanel.tsx b/packages/ui/src/features/settings/components/SettingsPanel.tsx index aac9d8afca..49870121fd 100644 --- a/packages/ui/src/features/settings/components/SettingsPanel.tsx +++ b/packages/ui/src/features/settings/components/SettingsPanel.tsx @@ -48,6 +48,7 @@ import { useSettingsPageStore } from "@posthog/ui/features/settings/stores/setti import type { SettingsCategory } from "@posthog/ui/features/settings/types"; import { useSpendAnalysisEnabled } from "@posthog/ui/features/usage/useSpendAnalysisEnabled"; import * as nav from "@posthog/ui/router/navigationBridge"; +import { useHostCapabilities } from "@posthog/ui/shell/useHostCapabilities"; import { Avatar, Box, Flex, ScrollArea, Text } from "@radix-ui/themes"; import { type ReactNode, useMemo } from "react"; import { useHotkeys } from "react-hotkeys-hook"; @@ -82,6 +83,18 @@ const SIDEBAR_ITEMS: SidebarItem[] = [ { id: "advanced", label: "Advanced", icon: }, ]; +// Settings that only make sense with a local filesystem/host (local worktrees, +// terminal, the local `claude` CLI, the desktop app itself). Hidden on the +// cloud-only web host. +const LOCAL_ONLY_CATEGORIES: ReadonlySet = new Set([ + "workspaces", + "worktrees", + "terminal", + "claude-code", + "discord", + "updates", +]); + const CATEGORY_TITLES: Record = { general: "General", notifications: "Notifications", @@ -155,17 +168,34 @@ export function SettingsPanel({ const { data: user } = useCurrentUser({ client }); const { seat, planLabel } = useSeat(); const billingEnabled = useFeatureFlag(BILLING_FLAG); + const { localWorkspaces } = useHostCapabilities(); const logoutMutation = useLogoutMutation(); const spendAnalysisEnabled = useSpendAnalysisEnabled(); const sidebarItems = useMemo( () => - billingEnabled || spendAnalysisEnabled - ? SIDEBAR_ITEMS - : SIDEBAR_ITEMS.filter((item) => item.id !== "plan-usage"), - [billingEnabled, spendAnalysisEnabled], + SIDEBAR_ITEMS.filter((item) => { + if ( + item.id === "plan-usage" && + !billingEnabled && + !spendAnalysisEnabled + ) + return false; + if (!localWorkspaces && LOCAL_ONLY_CATEGORIES.has(item.id)) + return false; + return true; + }), + [billingEnabled, spendAnalysisEnabled, localWorkspaces], ); + // Guard direct navigation (URL, deep link, programmatic openSettings) to a + // category hidden on this host. Fall back to General so a hidden section is + // never rendered. + const resolvedCategory: SettingsCategory = + !localWorkspaces && LOCAL_ONLY_CATEGORIES.has(activeCategory) + ? "general" + : activeCategory; + useHotkeys("escape", close, { enabled: true, enableOnContentEditable: true, @@ -173,12 +203,12 @@ export function SettingsPanel({ preventDefault: true, }); - const ActiveComponent = CATEGORY_COMPONENTS[activeCategory]; + const ActiveComponent = CATEGORY_COMPONENTS[resolvedCategory]; const activeCategoryIcon = SIDEBAR_ITEMS.find( (item) => - item.id === activeCategory || - (item.id === "environments" && activeCategory === "cloud-environments"), + item.id === resolvedCategory || + (item.id === "environments" && resolvedCategory === "cloud-environments"), )?.icon; const initials = getUserInitials(user); @@ -226,9 +256,9 @@ export function SettingsPanel({
{sidebarItems.map((item) => { const isActive = - activeCategory === item.id || + resolvedCategory === item.id || (item.id === "environments" && - activeCategory === "cloud-environments"); + resolvedCategory === "cloud-environments"); return ( {activeCategoryIcon} )} - {CATEGORY_TITLES[activeCategory]} + {CATEGORY_TITLES[resolvedCategory]} )} diff --git a/packages/ui/src/features/settings/sections/GeneralSettings.tsx b/packages/ui/src/features/settings/sections/GeneralSettings.tsx index e1563633c4..bdf56b0ffe 100644 --- a/packages/ui/src/features/settings/sections/GeneralSettings.tsx +++ b/packages/ui/src/features/settings/sections/GeneralSettings.tsx @@ -20,6 +20,7 @@ import { import { track } from "@posthog/ui/shell/analytics"; import type { ThemePreference } from "@posthog/ui/shell/themeStore"; import { useThemeStore } from "@posthog/ui/shell/themeStore"; +import { useHostCapabilities } from "@posthog/ui/shell/useHostCapabilities"; import { Button, Flex, Link, Select, Switch, Text } from "@radix-ui/themes"; import { useMutation, useQuery } from "@tanstack/react-query"; import { useCallback, useEffect } from "react"; @@ -35,13 +36,18 @@ export function GeneralSettings() { const theme = useThemeStore((state) => state.theme); const setTheme = useThemeStore((state) => state.setTheme); // Power state + const { localWorkspaces } = useHostCapabilities(); const { preventSleepWhileRunning, setPreventSleepWhileRunning } = useSettingsStore(); const { data: serverPreventSleep } = useQuery( - hostTRPC.sleep.getEnabled.queryOptions(), + hostTRPC.sleep.getEnabled.queryOptions(undefined, { + enabled: localWorkspaces, + }), ); const { data: hasBuiltInBattery } = useQuery( - hostTRPC.sleep.hasBuiltInBattery.queryOptions(), + hostTRPC.sleep.hasBuiltInBattery.queryOptions(undefined, { + enabled: localWorkspaces, + }), ); const preventSleepMutation = useMutation( hostTRPC.sleep.setEnabled.mutationOptions(), @@ -431,25 +437,29 @@ export function GeneralSettings() { {/* Power */} - - Power - - - - - + {localWorkspaces && ( + <> + + Power + + + + + + + )} {/* Fun */} diff --git a/packages/ui/src/features/settings/sections/NotificationsSettings.tsx b/packages/ui/src/features/settings/sections/NotificationsSettings.tsx index 0e675377c1..2414f0a56f 100644 --- a/packages/ui/src/features/settings/sections/NotificationsSettings.tsx +++ b/packages/ui/src/features/settings/sections/NotificationsSettings.tsx @@ -30,6 +30,7 @@ import { useTasks } from "@posthog/ui/features/tasks/useTasks"; import { Tooltip } from "@posthog/ui/primitives/Tooltip"; import { toast } from "@posthog/ui/primitives/toast"; import { track } from "@posthog/ui/shell/analytics"; +import { useHostCapabilities } from "@posthog/ui/shell/useHostCapabilities"; import { formatDurationSeconds } from "@posthog/ui/utils/customSound"; import { playCompletionSound } from "@posthog/ui/utils/sounds"; import { @@ -73,6 +74,8 @@ export function NotificationsSettings() { const notifications = useServiceOptional( NOTIFICATIONS_SERVICE, ); + // Dock badge/bounce are macOS desktop-dock features. + const { localWorkspaces } = useHostCapabilities(); // Canvases only exist behind the bluebird flag, so only mention them when on. const canvasEnabled = useFeatureFlag( @@ -209,27 +212,31 @@ export function NotificationsSettings() { /> - - - + {localWorkspaces && ( + <> + + + - - - + + + + + )} bus?.notify({ @@ -756,27 +767,30 @@ function NotificationTestHarness({ - - - + + + )} - + + + )} - - - + + + )} - - - + + + )} ); } diff --git a/packages/ui/src/features/settings/sections/environments/EnvironmentsSettings.tsx b/packages/ui/src/features/settings/sections/environments/EnvironmentsSettings.tsx index e3f7a36305..5d7b7ab44a 100644 --- a/packages/ui/src/features/settings/sections/environments/EnvironmentsSettings.tsx +++ b/packages/ui/src/features/settings/sections/environments/EnvironmentsSettings.tsx @@ -1,6 +1,7 @@ import { Cloud, HardDrives } from "@phosphor-icons/react"; import { useSettingsPageStore } from "@posthog/ui/features/settings/stores/settingsPageStore"; import { navigateToSettings } from "@posthog/ui/router/navigationBridge"; +import { useHostCapabilities } from "@posthog/ui/shell/useHostCapabilities"; import { Flex, SegmentedControl, Text } from "@radix-ui/themes"; import { useRouterState } from "@tanstack/react-router"; import { CloudEnvironmentsSettings } from "./CloudEnvironmentsSettings"; @@ -10,6 +11,9 @@ type Segment = "local" | "cloud"; export function EnvironmentsSettings() { const formMode = useSettingsPageStore((s) => s.formMode); + // Cloud-only hosts (web) have no local project environments (only sandboxes), + // so drop the local/cloud toggle and show cloud environments only. + const { localWorkspaces } = useHostCapabilities(); const activeCategory = useRouterState({ select: (s) => { const match = s.matches.find((m) => m.routeId === "/settings/$category"); @@ -19,7 +23,9 @@ export function EnvironmentsSettings() { }); const segment: Segment = - activeCategory === "cloud-environments" ? "cloud" : "local"; + !localWorkspaces || activeCategory === "cloud-environments" + ? "cloud" + : "local"; const handleSegmentChange = (value: string) => { // Replace rather than push so switching tabs doesn't pile up history @@ -32,7 +38,13 @@ export function EnvironmentsSettings() { return ( - {!formMode && ( + {!formMode && !localWorkspaces && ( + + A cloud environment configures the remote sandbox the agent works + inside when you start a task. + + )} + {!formMode && localWorkspaces && ( <> An environment defines what the agent works inside when you start a diff --git a/packages/ui/src/features/task-detail/components/TaskInput.tsx b/packages/ui/src/features/task-detail/components/TaskInput.tsx index 11b35d10e4..1f7981cb2c 100644 --- a/packages/ui/src/features/task-detail/components/TaskInput.tsx +++ b/packages/ui/src/features/task-detail/components/TaskInput.tsx @@ -25,6 +25,7 @@ import { useConnectivity } from "../../../hooks/useConnectivity"; import { DotPatternBackground } from "../../../primitives/DotPatternBackground"; import { toast } from "../../../primitives/toast"; import { useActiveRepoStore } from "../../../shell/activeRepoStore"; +import { useHostCapabilities } from "../../../shell/useHostCapabilities"; import { FOCUSABLE_SELECTOR } from "../../../utils/overlay"; import { useAuthStateValue } from "../../auth/store"; import { AutoresearchComposerControls } from "../../autoresearch/AutoresearchComposerControls"; @@ -317,6 +318,8 @@ export function TaskInput({ hasGithubIntegration, } = useUserRepositoryIntegration(); + // Force cloud mode on cloud-only hosts (web). + const { localWorkspaces } = useHostCapabilities(); const cloudModeEnabled = useCloudModeEnabled(); const flagsLoaded = useFeatureFlagsLoaded(); const reposReady = areReposReady({ @@ -327,6 +330,7 @@ export function TaskInput({ const [workspaceMode, setWorkspaceModeState] = useState(() => { if (initialCloudRepository) return "cloud"; + if (!localWorkspaces) return "cloud"; return resolveWorkspaceModePreference({ preferredMode: lastUsedWorkspaceMode || DEFAULT_WORKSPACE_MODE, cloudModeEnabled, @@ -353,6 +357,7 @@ export function TaskInput({ const preferredMode = lastUsedWorkspaceMode || DEFAULT_WORKSPACE_MODE; if (preferredMode === "cloud" && !cloudSignalsSettled) return; didResolveWorkspaceModeRef.current = true; + if (!localWorkspaces) return; setWorkspaceModeState( resolveWorkspaceModePreference({ preferredMode, @@ -365,6 +370,7 @@ export function TaskInput({ settingsHydrated, lastUsedWorkspaceMode, initialCloudRepository, + localWorkspaces, cloudSignalsSettled, cloudModeEnabled, hasGithubIntegration, diff --git a/packages/ui/src/features/task-detail/components/TaskLogsPanel.tsx b/packages/ui/src/features/task-detail/components/TaskLogsPanel.tsx index b30345ce69..15b6f565f7 100644 --- a/packages/ui/src/features/task-detail/components/TaskLogsPanel.tsx +++ b/packages/ui/src/features/task-detail/components/TaskLogsPanel.tsx @@ -4,6 +4,7 @@ import { Box, Flex } from "@radix-ui/themes"; import { useCallback, useEffect } from "react"; import { BackgroundWrapper } from "../../../primitives/BackgroundWrapper"; import { ErrorBoundary } from "../../../primitives/ErrorBoundary"; +import { useHostCapabilities } from "../../../shell/useHostCapabilities"; import { useFolders } from "../../folders/useFolders"; import { useDraftStore } from "../../message-editor/draftStore"; import { ProvisioningView } from "../../provisioning/ProvisioningView"; @@ -28,6 +29,12 @@ interface TaskLogsPanelProps { } export function TaskLogsPanel({ taskId, task, hideInput }: TaskLogsPanelProps) { + // The folder-picker setup prompt is a local-workspace surface (local git repo + // detection, folder picker). A cloud-only host has no local workspaces, so it + // must never render — otherwise, in the window before a task is known-cloud + // (isCloud derives from the workspace entry / latest run), the !isCloud branch + // would mount it and resolve local-only services the host doesn't bind. + const { localWorkspaces } = useHostCapabilities(); const isWorkspaceLoaded = useWorkspaceLoaded(); const { isPending: isCreatingWorkspace } = useCreateWorkspace(); const repoKey = getTaskRepository(task); @@ -116,6 +123,7 @@ export function TaskLogsPanel({ taskId, task, hideInput }: TaskLogsPanelProps) { // branch below, whose !hasDirectoryMapping gate wouldn't fire here since the // task's repo folder is already registered. if ( + localWorkspaces && provisioningError && !repoPath && !isCloud && @@ -133,6 +141,7 @@ export function TaskLogsPanel({ taskId, task, hideInput }: TaskLogsPanelProps) { } if ( + localWorkspaces && !repoPath && !isCloud && !isSuspended && diff --git a/packages/ui/src/features/task-detail/components/WorkspaceModeSelect.tsx b/packages/ui/src/features/task-detail/components/WorkspaceModeSelect.tsx index 37fee7eef9..d836572451 100644 --- a/packages/ui/src/features/task-detail/components/WorkspaceModeSelect.tsx +++ b/packages/ui/src/features/task-detail/components/WorkspaceModeSelect.tsx @@ -23,6 +23,7 @@ import { } from "@posthog/quill"; import type { WorkspaceMode } from "@posthog/shared"; import { openSettings } from "@posthog/ui/features/settings/hooks/useOpenSettings"; +import { useHostCapabilities } from "@posthog/ui/shell/useHostCapabilities"; import { useCallback, useMemo, useState } from "react"; import { useSandboxCustomImages } from "../../settings/sections/environments/useSandboxCustomImages"; import { useSandboxEnvironments } from "../../settings/sections/environments/useSandboxEnvironments"; @@ -74,6 +75,7 @@ export function WorkspaceModeSelect({ selectedCustomImageId, onCustomImageChange, }: WorkspaceModeSelectProps) { + const { localWorkspaces } = useHostCapabilities(); const cloudModeEnabled = useCloudModeEnabled(); const { environments } = useSandboxEnvironments(); @@ -96,10 +98,13 @@ export function WorkspaceModeSelect({ const localModes = useMemo( () => - LOCAL_MODES.filter( - (m) => !overrideModes || overrideModes.includes(m.mode), - ), - [overrideModes], + // Hide worktree/local modes on cloud-only hosts. + localWorkspaces + ? LOCAL_MODES.filter( + (m) => !overrideModes || overrideModes.includes(m.mode), + ) + : [], + [overrideModes, localWorkspaces], ); const selectedEnvName = useMemo(() => { diff --git a/packages/ui/src/features/task-detail/hooks/useCloudModeEnabled.ts b/packages/ui/src/features/task-detail/hooks/useCloudModeEnabled.ts index 5d75a3767e..47d153cb61 100644 --- a/packages/ui/src/features/task-detail/hooks/useCloudModeEnabled.ts +++ b/packages/ui/src/features/task-detail/hooks/useCloudModeEnabled.ts @@ -1,5 +1,12 @@ +import { useHostCapabilities } from "@posthog/ui/shell/useHostCapabilities"; import { useFeatureFlag } from "../../feature-flags/useFeatureFlag"; export function useCloudModeEnabled(): boolean { - return useFeatureFlag("twig-cloud-mode-toggle") || import.meta.env.DEV; + // Cloud mode is always on for cloud-only hosts (web). + const { localWorkspaces } = useHostCapabilities(); + return ( + useFeatureFlag("twig-cloud-mode-toggle") || + import.meta.env.DEV || + !localWorkspaces + ); } diff --git a/packages/ui/src/router/routes/__root.tsx b/packages/ui/src/router/routes/__root.tsx index 1ab7d8c4a7..f2483c7cf9 100644 --- a/packages/ui/src/router/routes/__root.tsx +++ b/packages/ui/src/router/routes/__root.tsx @@ -69,6 +69,7 @@ import { logger } from "@posthog/ui/shell/logger"; import { onFeatureFlagsLoaded } from "@posthog/ui/shell/posthogAnalyticsImpl"; import { SpaceSwitcher } from "@posthog/ui/shell/SpaceSwitcher"; import { useShortcutsSheetStore } from "@posthog/ui/shell/shortcutsSheetStore"; +import { useHostCapabilities } from "@posthog/ui/shell/useHostCapabilities"; import { openUrlInBrowser } from "@posthog/ui/utils/browser"; import { isMac, isWindows } from "@posthog/ui/utils/platform"; import { getPostHogUrl } from "@posthog/ui/utils/urls"; @@ -103,6 +104,9 @@ function RootLayout() { const view = useAppView(); const router = useRouter(); const canGoBack = useCanGoBack(); + // Cloud-only hosts (web) run in a real browser tab that already provides + // native back/forward chrome, so the in-app history buttons are redundant. + const { localWorkspaces } = useHostCapabilities(); // Width of the Channels sidebar below — used to right-align the back/forward // buttons in the title bar with the sidebar's (and project switcher's) right edge. const channelsSidebarWidth = useChannelsSidebarStore((state) => state.width); @@ -387,28 +391,30 @@ function RootLayout() { )} - - - - - - + {localWorkspaces && ( + + + + + + + )} {/* Tabs work in both spaces: channel tabs under /website and plain task tabs in the Code experience. The strip's route→tab effect diff --git a/packages/ui/src/shell/requiredHostCapabilities.ts b/packages/ui/src/shell/requiredHostCapabilities.ts new file mode 100644 index 0000000000..826b45ec9c --- /dev/null +++ b/packages/ui/src/shell/requiredHostCapabilities.ts @@ -0,0 +1,72 @@ +import { REPORT_MODEL_RESOLVER } from "@posthog/core/inbox/identifiers"; +import type { HostCapabilityRequirement } from "@posthog/di/hostCapabilities"; +import { HOST_CAPABILITIES } from "@posthog/platform/host-capabilities"; +import { AUTH_SIDE_EFFECTS } from "@posthog/ui/features/auth/identifiers"; +import { REVIEW_HOST } from "@posthog/ui/features/code-review/reviewHost"; +import { CONNECTIVITY_CLIENT } from "@posthog/ui/features/connectivity/connectivityClient"; +import { FEATURE_FLAGS } from "@posthog/ui/features/feature-flags/identifiers"; +import { GIT_CACHE_KEY_PROVIDER } from "@posthog/ui/features/git-interaction/gitCacheProvider"; +import { UPDATES_CLIENT } from "@posthog/ui/features/updates/updatesClient"; +import { DIFF_WORKER_FACTORY } from "@posthog/ui/shell/diffWorkerHost"; + +/** + * Host capabilities every host mounting the shared app must bind. + * + * The contract: shared `@posthog/ui` / `@posthog/core` resolve these tokens via + * `useService` / `resolveService`, so any host that renders the shared UI has to + * provide an implementation. Unlike a `TypedContainer` (which only + * type-checks the provider side) or a tRPC procedure stub (which may legitimately + * `NOT_FOUND` at call time), a missing binding here is always a bug — it just + * hides until a user reaches the resolving code path. + * + * Each host passes this list to `assertHostCapabilities` at the end of its + * composition root (see `apps/web/src/web-container.ts` and the desktop renderer + * container) so a gap fails at startup rather than at the first navigation that + * needs it — which the web e2e boot test (`apps/web/tests/e2e`) turns into a CI + * gate: an unbound capability throws at container load, the app never mounts, + * and the boot spec fails. Add an entry whenever shared code starts resolving a + * new host-provided token via service location. + * + * Excluded on purpose: core-module services (they fail at module load, a + * different and already-loud failure mode) and host-specific/local-only + * capabilities (e.g. file watchers, local handoff) that not every host provides. + */ +export const REQUIRED_HOST_CAPABILITIES: readonly HostCapabilityRequirement[] = + [ + { + token: HOST_CAPABILITIES, + description: "coarse host capability flags the shared UI branches on", + }, + { + token: FEATURE_FLAGS, + description: "feature-flag gating across the app", + }, + { + token: AUTH_SIDE_EFFECTS, + description: "auth lifecycle side effects (login/logout/session)", + }, + { + token: CONNECTIVITY_CLIENT, + description: "online/offline status the UI reacts to", + }, + { + token: UPDATES_CLIENT, + description: "app-update status surfaced in settings", + }, + { + token: GIT_CACHE_KEY_PROVIDER, + description: "git query cache keys", + }, + { + token: REVIEW_HOST, + description: "code-review page host wiring", + }, + { + token: DIFF_WORKER_FACTORY, + description: "diff computation worker for review/diffs", + }, + { + token: REPORT_MODEL_RESOLVER, + description: "default cloud-run model resolution (canvas/home/inbox)", + }, + ]; diff --git a/packages/ui/src/shell/useHostCapabilities.ts b/packages/ui/src/shell/useHostCapabilities.ts new file mode 100644 index 0000000000..756ad86c6f --- /dev/null +++ b/packages/ui/src/shell/useHostCapabilities.ts @@ -0,0 +1,18 @@ +import { useServiceOptional } from "@posthog/di/react"; +import { + HOST_CAPABILITIES, + type HostCapabilities, +} from "@posthog/platform/host-capabilities"; + +// Hosts that predate the token (and Storybook/tests, which have no host binding) +// default to the desktop posture, so existing behavior is unchanged unless a host +// explicitly opts out. +const DEFAULT_CAPABILITIES: HostCapabilities = { localWorkspaces: true }; + +/** Read the current host's coarse capabilities. Safe when unbound. */ +export function useHostCapabilities(): HostCapabilities { + return ( + useServiceOptional(HOST_CAPABILITIES) ?? + DEFAULT_CAPABILITIES + ); +} diff --git a/packages/workspace-server/src/services/skills/write-skill-frontmatter.ts b/packages/workspace-server/src/services/skills/write-skill-frontmatter.ts index 0bc24979b1..b597453ada 100644 --- a/packages/workspace-server/src/services/skills/write-skill-frontmatter.ts +++ b/packages/workspace-server/src/services/skills/write-skill-frontmatter.ts @@ -1,36 +1,4 @@ -/** - * Serializes a SKILL.md file from frontmatter metadata plus a markdown body. - * - * The output must round-trip through `parseSkillFrontmatter` and also be - * valid YAML for the agents that consume these files, so scalars fall back - * from plain → double-quoted → literal block as they get more hostile. - */ -export function serializeSkillMarkdown( - meta: { name: string; description: string }, - body: string, -): string { - const frontmatter = [ - "---", - `name: ${serializeScalar(meta.name)}`, - `description: ${serializeScalar(meta.description)}`, - "---", - ].join("\n"); - - const trimmedBody = body.replace(/^\n+/, ""); - return `${frontmatter}\n\n${trimmedBody.trimEnd()}\n`; -} - -const PLAIN_SAFE = /^[A-Za-z0-9][A-Za-z0-9 _.,;()/-]*$/; - -function serializeScalar(value: string): string { - if (value === "") return '""'; - if (!value.includes("\n")) { - if (PLAIN_SAFE.test(value) && !value.endsWith(" ")) return value; - if (!value.includes('"') && !value.includes("\\")) return `"${value}"`; - } - // Literal block: survives quotes, backslashes, and newlines. - const lines = value - .split("\n") - .map((line) => (line.trim() ? ` ${line}` : "")); - return `|-\n${lines.join("\n")}`; -} +// The SKILL.md serializer lives in @posthog/shared so the workspace-server +// bundler and the web-host bundler emit byte-identical SKILL.md files — this is +// a serialization contract the cloud sandbox consumes and must not drift. +export { serializeSkillMarkdown } from "@posthog/shared";