Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/code/src/renderer/desktop-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -441,3 +445,7 @@ container
.inSingletonScope();

container.bind(SETUP_STORE).toConstantValue(setupStore);

container
.bind(HOST_CAPABILITIES)
.toConstantValue({ localWorkspaces: true } satisfies HostCapabilities);
5 changes: 5 additions & 0 deletions apps/code/src/renderer/di/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions apps/code/src/renderer/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
58 changes: 58 additions & 0 deletions packages/di/src/hostCapabilities.test.ts
Original file line number Diff line number Diff line change
@@ -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));
});
});
46 changes: 46 additions & 0 deletions packages/di/src/hostCapabilities.ts
Original file line number Diff line number Diff line change
@@ -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<T>(TOKEN)`'s type
* argument is supplied by the caller, not derived from any host's binding map,
* so a `TypedContainer<HostBindings>` 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<ServiceContainer, "isBound">,
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.",
);
}
4 changes: 4 additions & 0 deletions packages/platform/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
18 changes: 18 additions & 0 deletions packages/platform/src/host-capabilities.ts
Original file line number Diff line number Diff line change
@@ -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",
);
1 change: 1 addition & 0 deletions packages/platform/tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -40,6 +41,7 @@ export function CloudGitInteractionHeader({
GIT_CACHE_KEY_PROVIDER,
);
const localHandoff = useService<LocalHandoffService>(LOCAL_HANDOFF_SERVICE);
const { localWorkspaces } = useHostCapabilities();
const cloudHandoffEnabled =
useFeatureFlag(CLOUD_HANDOFF_FLAG) || import.meta.env.DEV;

Expand Down Expand Up @@ -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;
Expand Down
47 changes: 35 additions & 12 deletions packages/ui/src/features/onboarding/components/SelectRepoStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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),
Expand Down Expand Up @@ -96,18 +104,31 @@ export function SelectRepoStep({
</Text>
</Flex>
<Text className="text-(--gray-11) text-sm">
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."}
</Text>
</Flex>
<FolderPicker
variant="field"
value={selectedDirectory}
onChange={onDirectoryChange}
placeholder="Select repository..."
/>
{localWorkspaces ? (
<FolderPicker
variant="field"
value={selectedDirectory}
onChange={onDirectoryChange}
placeholder="Select repository..."
/>
) : (
<GitHubRepoPicker
value={selectedDirectory || null}
onChange={(repo) => onDirectoryChange(repo ?? "")}
repositories={repositories}
isLoading={isLoadingRepos}
onRefresh={refreshRepositories}
isRefreshing={isRefreshingRepos}
placeholder="Select repository..."
/>
)}
<AnimatePresence mode="wait">
{isDetectingRepo && (
{localWorkspaces && isDetectingRepo && (
<motion.div
key="detecting"
initial={{ opacity: 0 }}
Expand All @@ -126,7 +147,8 @@ export function SelectRepoStep({
</Flex>
</motion.div>
)}
{!isDetectingRepo &&
{localWorkspaces &&
!isDetectingRepo &&
selectedDirectory &&
detectedRepo && (
<motion.div
Expand Down Expand Up @@ -161,7 +183,8 @@ export function SelectRepoStep({
</Flex>
</motion.div>
)}
{!isDetectingRepo &&
{localWorkspaces &&
!isDetectingRepo &&
selectedDirectory &&
!detectedRepo && (
<motion.div
Expand Down
29 changes: 27 additions & 2 deletions packages/ui/src/features/onboarding/hooks/useOnboardingFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,26 +17,34 @@ import { useHostTRPCClient } from "@posthog/host-router/react";
import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events";
import { useAuthStateValue } from "@posthog/ui/features/auth/store";
import { useOnboardingStore } from "@posthog/ui/features/onboarding/onboardingStore";
import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore";
import { useActiveRepoStore } from "@posthog/ui/shell/activeRepoStore";
import { track } from "@posthog/ui/shell/analytics";
import { useHostCapabilities } from "@posthog/ui/shell/useHostCapabilities";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useHasImportableConfig } from "./useHasImportableConfig";

export type { DetectedRepo };

export function useOnboardingFlow() {
const hostClient = useHostTRPCClient();
const { localWorkspaces } = useHostCapabilities();
const currentStep = useOnboardingStore((state) => 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<DetectedRepo | null>(null);
const [isDetectingRepo, setIsDetectingRepo] = useState(false);
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);
Expand All @@ -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);
Expand All @@ -75,7 +95,12 @@ export function useOnboardingFlow() {
setIsDetectingRepo(false);
}
},
[setSelectedDirectory, hostClient],
[
setSelectedDirectory,
setLastUsedCloudRepository,
hostClient,
localWorkspaces,
],
);

const hasCodeAccess = useAuthStateValue((state) => state.hasCodeAccess);
Expand Down
Loading
Loading