mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): first-run onboarding flow + Lilac accent (#386)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -34,6 +34,7 @@ export default defineConfig({
|
||||
name: "integration",
|
||||
testMatch: [
|
||||
"**/agents.spec.ts",
|
||||
"**/onboarding.spec.ts",
|
||||
"**/stream.spec.ts",
|
||||
"**/integration.spec.ts",
|
||||
"**/profile.spec.ts",
|
||||
|
||||
@@ -3,11 +3,48 @@ import { RouterProvider } from "@tanstack/react-router";
|
||||
import { useLayoutEffect } from "react";
|
||||
|
||||
import { router } from "@/app/router";
|
||||
import { useAppOnboardingState } from "@/features/onboarding/hooks";
|
||||
import { OnboardingFlow } from "@/features/onboarding/ui/OnboardingFlow";
|
||||
|
||||
function AppLoadingGate() {
|
||||
return (
|
||||
<div className="flex min-h-dvh items-center justify-center bg-[radial-gradient(circle_at_top,hsl(var(--primary)/0.14),transparent_48%),linear-gradient(180deg,hsl(var(--background)),hsl(var(--muted)/0.55))] px-4 py-8">
|
||||
<div className="w-full max-w-sm rounded-[28px] border border-border/70 bg-background/92 p-8 shadow-2xl backdrop-blur">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">
|
||||
Sprout
|
||||
</p>
|
||||
<h1 className="mt-3 text-2xl font-semibold tracking-tight text-foreground">
|
||||
Checking your setup
|
||||
</h1>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
||||
One sec while we load your profile.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
useLayoutEffect(() => {
|
||||
void getCurrentWindow().show();
|
||||
}, []);
|
||||
|
||||
const onboarding = useAppOnboardingState();
|
||||
|
||||
if (onboarding.stage === "onboarding") {
|
||||
return (
|
||||
<OnboardingFlow
|
||||
actions={onboarding.flow.actions}
|
||||
initialProfile={onboarding.flow.initialProfile}
|
||||
key={onboarding.currentPubkey ?? "anonymous"}
|
||||
notifications={onboarding.flow.notifications}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (onboarding.stage === "blocking") {
|
||||
return <AppLoadingGate />;
|
||||
}
|
||||
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
|
||||
@@ -170,21 +170,10 @@ export function useNotificationSettings(pubkey?: string) {
|
||||
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
|
||||
const [isUpdatingDesktopEnabled, setIsUpdatingDesktopEnabled] =
|
||||
React.useState(false);
|
||||
const isNewUserRef = React.useRef(
|
||||
normalizedPubkey.length > 0 &&
|
||||
window.localStorage.getItem(
|
||||
notificationSettingsStorageKey(normalizedPubkey),
|
||||
) === null,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSettings(readStoredNotificationSettings(normalizedPubkey));
|
||||
setErrorMessage(null);
|
||||
isNewUserRef.current =
|
||||
normalizedPubkey.length > 0 &&
|
||||
window.localStorage.getItem(
|
||||
notificationSettingsStorageKey(normalizedPubkey),
|
||||
) === null;
|
||||
}, [normalizedPubkey]);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -202,33 +191,6 @@ export function useNotificationSettings(pubkey?: string) {
|
||||
void refreshPermission();
|
||||
}, [normalizedPubkey]);
|
||||
|
||||
// Auto-request desktop notification permission for new users
|
||||
React.useEffect(() => {
|
||||
if (!normalizedPubkey || !isNewUserRef.current) return;
|
||||
isNewUserRef.current = false;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const currentPermission = await getDesktopNotificationPermissionState();
|
||||
if (cancelled || currentPermission !== "default") return;
|
||||
const result = await requestDesktopNotificationAccess();
|
||||
if (cancelled) return;
|
||||
setPermission(result);
|
||||
if (result === "granted") {
|
||||
setSettings((current) => ({ ...current, desktopEnabled: true }));
|
||||
}
|
||||
} catch {
|
||||
// Best-effort auto-request; silently ignore errors
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [normalizedPubkey]);
|
||||
|
||||
const setDesktopEnabled = React.useCallback(async (enabled: boolean) => {
|
||||
if (!enabled) {
|
||||
setErrorMessage(null);
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import * as React from "react";
|
||||
import { useQueryClient, type QueryStatus } from "@tanstack/react-query";
|
||||
|
||||
import { channelsQueryKey } from "@/features/channels/hooks";
|
||||
import { useNotificationSettings } from "@/features/notifications/hooks";
|
||||
import { useProfileQuery } from "@/features/profile/hooks";
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import { getChannels, joinChannel } from "@/shared/api/tauri";
|
||||
|
||||
const DEFAULT_AUTO_JOIN_CHANNEL_NAME = "general";
|
||||
|
||||
async function autoJoinDefaultChannel(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
) {
|
||||
try {
|
||||
const channels = await getChannels();
|
||||
const target = channels.find(
|
||||
(channel) =>
|
||||
channel.name === DEFAULT_AUTO_JOIN_CHANNEL_NAME && !channel.isMember,
|
||||
);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
await joinChannel(target.id);
|
||||
await queryClient.invalidateQueries({ queryKey: channelsQueryKey });
|
||||
} catch {
|
||||
// Silent — auto-join is best-effort; the user can still find and join
|
||||
// the channel manually from the channel browser.
|
||||
}
|
||||
}
|
||||
|
||||
const ONBOARDING_COMPLETION_STORAGE_KEY = "sprout-onboarding-complete.v1";
|
||||
type OnboardingGateStage = "blocking" | "onboarding" | "ready";
|
||||
|
||||
type UseFirstRunOnboardingGateOptions = {
|
||||
currentPubkey: string | null;
|
||||
identityIsFetching: boolean;
|
||||
identityStatus: QueryStatus;
|
||||
profileStatus: QueryStatus;
|
||||
};
|
||||
|
||||
type OnboardingGateState = {
|
||||
currentPubkey: string | null;
|
||||
hasCompletedCurrentPubkey: boolean;
|
||||
hasSettledCurrentPubkey: boolean;
|
||||
isOpen: boolean;
|
||||
};
|
||||
|
||||
function onboardingCompletionStorageKey(pubkey: string) {
|
||||
return `${ONBOARDING_COMPLETION_STORAGE_KEY}:${pubkey}`;
|
||||
}
|
||||
|
||||
function readOnboardingCompletion(pubkey: string | null) {
|
||||
if (typeof window === "undefined" || !pubkey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
window.localStorage.getItem(onboardingCompletionStorageKey(pubkey)) ===
|
||||
"true"
|
||||
);
|
||||
}
|
||||
|
||||
function createOnboardingGateState(pubkey: string | null): OnboardingGateState {
|
||||
const hasCompletedCurrentPubkey = readOnboardingCompletion(pubkey);
|
||||
|
||||
return {
|
||||
currentPubkey: pubkey,
|
||||
hasCompletedCurrentPubkey,
|
||||
hasSettledCurrentPubkey: hasCompletedCurrentPubkey,
|
||||
isOpen: false,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveActiveGateState(
|
||||
gateState: OnboardingGateState,
|
||||
currentPubkey: string | null,
|
||||
) {
|
||||
return gateState.currentPubkey === currentPubkey
|
||||
? gateState
|
||||
: createOnboardingGateState(currentPubkey);
|
||||
}
|
||||
|
||||
function updateActiveGateState(
|
||||
gateState: OnboardingGateState,
|
||||
currentPubkey: string | null,
|
||||
update: (activeGateState: OnboardingGateState) => OnboardingGateState,
|
||||
) {
|
||||
return update(resolveActiveGateState(gateState, currentPubkey));
|
||||
}
|
||||
|
||||
function isSettledQueryStatus(status: QueryStatus) {
|
||||
return status === "success" || status === "error";
|
||||
}
|
||||
|
||||
function resolveOnboardingGateStage({
|
||||
currentPubkey,
|
||||
gateState,
|
||||
identityIsFetching,
|
||||
identityStatus,
|
||||
}: {
|
||||
currentPubkey: string | null;
|
||||
gateState: OnboardingGateState;
|
||||
identityIsFetching: boolean;
|
||||
identityStatus: QueryStatus;
|
||||
}): OnboardingGateStage {
|
||||
const isBlockingCurrentPubkey =
|
||||
currentPubkey !== null &&
|
||||
!gateState.hasCompletedCurrentPubkey &&
|
||||
(gateState.isOpen || !gateState.hasSettledCurrentPubkey);
|
||||
|
||||
if (gateState.isOpen) {
|
||||
return "onboarding";
|
||||
}
|
||||
|
||||
if (
|
||||
identityIsFetching ||
|
||||
!isSettledQueryStatus(identityStatus) ||
|
||||
isBlockingCurrentPubkey
|
||||
) {
|
||||
return "blocking";
|
||||
}
|
||||
|
||||
return "ready";
|
||||
}
|
||||
|
||||
export function useFirstRunOnboardingGate({
|
||||
currentPubkey,
|
||||
identityIsFetching,
|
||||
identityStatus,
|
||||
profileStatus,
|
||||
}: UseFirstRunOnboardingGateOptions) {
|
||||
const [gateState, setGateState] = React.useState<OnboardingGateState>(() =>
|
||||
createOnboardingGateState(currentPubkey),
|
||||
);
|
||||
const activeGateState = resolveActiveGateState(gateState, currentPubkey);
|
||||
const { hasSettledCurrentPubkey } = activeGateState;
|
||||
|
||||
React.useEffect(() => {
|
||||
setGateState((current) =>
|
||||
current.currentPubkey === currentPubkey
|
||||
? current
|
||||
: createOnboardingGateState(currentPubkey),
|
||||
);
|
||||
}, [currentPubkey]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hasSettledCurrentPubkey || !currentPubkey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (identityStatus === "error") {
|
||||
setGateState((current) =>
|
||||
updateActiveGateState(current, currentPubkey, (activeGateState) => ({
|
||||
...activeGateState,
|
||||
hasSettledCurrentPubkey: true,
|
||||
})),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (identityStatus !== "success") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isSettledQueryStatus(profileStatus)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setGateState((current) =>
|
||||
updateActiveGateState(current, currentPubkey, (activeGateState) => ({
|
||||
...activeGateState,
|
||||
hasSettledCurrentPubkey: true,
|
||||
isOpen: !activeGateState.hasCompletedCurrentPubkey,
|
||||
})),
|
||||
);
|
||||
}, [currentPubkey, hasSettledCurrentPubkey, identityStatus, profileStatus]);
|
||||
|
||||
const skipForNow = React.useCallback(() => {
|
||||
setGateState((current) =>
|
||||
updateActiveGateState(current, currentPubkey, (activeGateState) => ({
|
||||
...activeGateState,
|
||||
hasSettledCurrentPubkey: true,
|
||||
isOpen: false,
|
||||
})),
|
||||
);
|
||||
}, [currentPubkey]);
|
||||
|
||||
const complete = React.useCallback(() => {
|
||||
if (typeof window !== "undefined" && currentPubkey) {
|
||||
window.localStorage.setItem(
|
||||
onboardingCompletionStorageKey(currentPubkey),
|
||||
"true",
|
||||
);
|
||||
}
|
||||
|
||||
setGateState({
|
||||
currentPubkey,
|
||||
hasCompletedCurrentPubkey: true,
|
||||
hasSettledCurrentPubkey: true,
|
||||
isOpen: false,
|
||||
});
|
||||
}, [currentPubkey]);
|
||||
|
||||
return {
|
||||
complete,
|
||||
skipForNow,
|
||||
stage: resolveOnboardingGateStage({
|
||||
currentPubkey,
|
||||
gateState: activeGateState,
|
||||
identityIsFetching,
|
||||
identityStatus,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function useAppOnboardingState() {
|
||||
const queryClient = useQueryClient();
|
||||
const identityQuery = useIdentityQuery();
|
||||
const identity = identityQuery.data;
|
||||
const currentPubkey = identity?.pubkey ?? null;
|
||||
const profileQuery = useProfileQuery();
|
||||
const notificationState = useNotificationSettings(currentPubkey ?? undefined);
|
||||
const onboardingGate = useFirstRunOnboardingGate({
|
||||
currentPubkey,
|
||||
identityIsFetching: identityQuery.fetchStatus === "fetching",
|
||||
identityStatus: identityQuery.status,
|
||||
profileStatus: profileQuery.status,
|
||||
});
|
||||
const gateComplete = onboardingGate.complete;
|
||||
const completeAndAutoJoin = React.useCallback(() => {
|
||||
gateComplete();
|
||||
void autoJoinDefaultChannel(queryClient);
|
||||
}, [gateComplete, queryClient]);
|
||||
const flow = {
|
||||
actions: {
|
||||
complete: completeAndAutoJoin,
|
||||
skipForNow: onboardingGate.skipForNow,
|
||||
},
|
||||
initialProfile: {
|
||||
profile: profileQuery.data,
|
||||
},
|
||||
notifications: {
|
||||
errorMessage: notificationState.errorMessage,
|
||||
isUpdatingDesktopEnabled: notificationState.isUpdatingDesktopEnabled,
|
||||
permission: notificationState.permission,
|
||||
setDesktopEnabled: notificationState.setDesktopEnabled,
|
||||
settings: notificationState.settings,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
currentPubkey,
|
||||
flow,
|
||||
stage: onboardingGate.stage,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { useUpdateProfileMutation } from "@/features/profile/hooks";
|
||||
import { uploadMediaBytes } from "@/shared/api/tauri";
|
||||
import { ProfileStep } from "./ProfileStep";
|
||||
import { SetupStep } from "./SetupStep";
|
||||
import type {
|
||||
OnboardingActions,
|
||||
OnboardingNotifications,
|
||||
OnboardingPage,
|
||||
OnboardingProfileSeed,
|
||||
OnboardingProfileValues,
|
||||
ProfileStepState,
|
||||
} from "./types";
|
||||
|
||||
type OnboardingFlowProps = {
|
||||
actions: OnboardingActions;
|
||||
initialProfile: OnboardingProfileSeed;
|
||||
notifications: OnboardingNotifications;
|
||||
};
|
||||
|
||||
function isFallbackDisplayName(value?: string | null) {
|
||||
const normalizedValue = value?.trim().toLowerCase() ?? "";
|
||||
return (
|
||||
normalizedValue.startsWith("npub1") ||
|
||||
normalizedValue.startsWith("nostr:npub1")
|
||||
);
|
||||
}
|
||||
|
||||
function sanitizeDisplayName(value?: string | null) {
|
||||
const trimmedValue = value?.trim() ?? "";
|
||||
return isFallbackDisplayName(trimmedValue) ? "" : trimmedValue;
|
||||
}
|
||||
|
||||
function resolveSavedProfile({
|
||||
profile,
|
||||
}: OnboardingProfileSeed): OnboardingProfileValues {
|
||||
return {
|
||||
avatarUrl: profile?.avatarUrl ?? "",
|
||||
displayName: sanitizeDisplayName(profile?.displayName),
|
||||
};
|
||||
}
|
||||
|
||||
function createProfileUpdatePayload({
|
||||
draftProfile,
|
||||
savedProfile,
|
||||
}: {
|
||||
draftProfile: OnboardingProfileValues;
|
||||
savedProfile: OnboardingProfileValues;
|
||||
}) {
|
||||
const nextDisplayName = draftProfile.displayName.trim();
|
||||
const nextAvatarUrl = draftProfile.avatarUrl.trim();
|
||||
const updatePayload: {
|
||||
avatarUrl?: string;
|
||||
displayName?: string;
|
||||
} = {};
|
||||
|
||||
if (
|
||||
nextDisplayName.length > 0 &&
|
||||
nextDisplayName !== savedProfile.displayName
|
||||
) {
|
||||
updatePayload.displayName = nextDisplayName;
|
||||
}
|
||||
|
||||
if (nextAvatarUrl.length > 0 && nextAvatarUrl !== savedProfile.avatarUrl) {
|
||||
updatePayload.avatarUrl = nextAvatarUrl;
|
||||
}
|
||||
|
||||
return updatePayload;
|
||||
}
|
||||
|
||||
function resolveProfileSaveRecovery(
|
||||
errorMessage: string | null,
|
||||
savedDisplayName: string,
|
||||
): ProfileStepState["saveRecovery"] {
|
||||
return {
|
||||
canAdvanceWithoutSaving:
|
||||
errorMessage !== null && savedDisplayName.length > 0,
|
||||
canSkipForNow: errorMessage !== null && savedDisplayName.length === 0,
|
||||
errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
const AVATAR_IMAGE_TYPES = [
|
||||
"image/gif",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
];
|
||||
|
||||
export function OnboardingFlow({
|
||||
actions,
|
||||
initialProfile,
|
||||
notifications,
|
||||
}: OnboardingFlowProps) {
|
||||
const { complete, skipForNow } = actions;
|
||||
const { setDesktopEnabled } = notifications;
|
||||
const savedProfile = resolveSavedProfile(initialProfile);
|
||||
const avatarInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const profileUpdateMutation = useUpdateProfileMutation();
|
||||
const { error: profileSaveError, isPending: isSavingProfile } =
|
||||
profileUpdateMutation;
|
||||
const [currentPage, setCurrentPage] =
|
||||
React.useState<OnboardingPage>("profile");
|
||||
const [profileDraft, setProfileDraft] =
|
||||
React.useState<OnboardingProfileValues>(savedProfile);
|
||||
const [avatarErrorMessage, setAvatarErrorMessage] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [isUploadingAvatar, setIsUploadingAvatar] = React.useState(false);
|
||||
|
||||
const openAvatarPicker = React.useCallback(() => {
|
||||
avatarInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const resetProfileSaveError = React.useCallback(() => {
|
||||
profileUpdateMutation.reset();
|
||||
}, [profileUpdateMutation]);
|
||||
|
||||
const updateProfileDraft = React.useCallback(
|
||||
(
|
||||
patch: Partial<OnboardingProfileValues>,
|
||||
options?: { clearAvatarError?: boolean },
|
||||
) => {
|
||||
resetProfileSaveError();
|
||||
if (options?.clearAvatarError) {
|
||||
setAvatarErrorMessage(null);
|
||||
}
|
||||
setProfileDraft((current) => ({
|
||||
...current,
|
||||
...patch,
|
||||
}));
|
||||
},
|
||||
[resetProfileSaveError],
|
||||
);
|
||||
|
||||
const handleAvatarFileChange = React.useCallback(
|
||||
async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!AVATAR_IMAGE_TYPES.includes(file.type)) {
|
||||
setAvatarErrorMessage("Choose a PNG, JPG, GIF, or WebP image.");
|
||||
return;
|
||||
}
|
||||
|
||||
resetProfileSaveError();
|
||||
setIsUploadingAvatar(true);
|
||||
setAvatarErrorMessage(null);
|
||||
|
||||
try {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const uploaded = await uploadMediaBytes([...new Uint8Array(buffer)]);
|
||||
updateProfileDraft(
|
||||
{ avatarUrl: uploaded.url },
|
||||
{ clearAvatarError: true },
|
||||
);
|
||||
} catch (error) {
|
||||
setAvatarErrorMessage(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Could not upload that avatar.",
|
||||
);
|
||||
} finally {
|
||||
setIsUploadingAvatar(false);
|
||||
}
|
||||
},
|
||||
[resetProfileSaveError, updateProfileDraft],
|
||||
);
|
||||
|
||||
const showSetupPage = React.useCallback(() => {
|
||||
setCurrentPage("setup");
|
||||
}, []);
|
||||
|
||||
const showProfilePage = React.useCallback(() => {
|
||||
setCurrentPage("profile");
|
||||
}, []);
|
||||
|
||||
const saveProfileAndContinue = React.useCallback(async () => {
|
||||
if (profileDraft.displayName.trim().length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatePayload = createProfileUpdatePayload({
|
||||
draftProfile: profileDraft,
|
||||
savedProfile,
|
||||
});
|
||||
|
||||
if (Object.keys(updatePayload).length > 0) {
|
||||
try {
|
||||
await profileUpdateMutation.mutateAsync(updatePayload);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
showSetupPage();
|
||||
}, [profileDraft, profileUpdateMutation, savedProfile, showSetupPage]);
|
||||
|
||||
const updateDisplayNameDraft = React.useCallback(
|
||||
(value: string) => {
|
||||
updateProfileDraft({ displayName: value });
|
||||
},
|
||||
[updateProfileDraft],
|
||||
);
|
||||
|
||||
const updateAvatarUrlDraft = React.useCallback(
|
||||
(value: string) => {
|
||||
updateProfileDraft({ avatarUrl: value }, { clearAvatarError: true });
|
||||
},
|
||||
[updateProfileDraft],
|
||||
);
|
||||
|
||||
const resetAvatarDraft = React.useCallback(() => {
|
||||
updateProfileDraft(
|
||||
{ avatarUrl: savedProfile.avatarUrl },
|
||||
{ clearAvatarError: true },
|
||||
);
|
||||
}, [savedProfile.avatarUrl, updateProfileDraft]);
|
||||
|
||||
const handleEnableDesktopNotifications = React.useCallback(() => {
|
||||
void setDesktopEnabled(true);
|
||||
}, [setDesktopEnabled]);
|
||||
const saveErrorMessage =
|
||||
profileSaveError instanceof Error ? profileSaveError.message : null;
|
||||
const profileStepState: ProfileStepState = {
|
||||
avatar: {
|
||||
draftUrl: profileDraft.avatarUrl,
|
||||
errorMessage: avatarErrorMessage,
|
||||
inputRef: avatarInputRef,
|
||||
isUploading: isUploadingAvatar,
|
||||
savedUrl: savedProfile.avatarUrl,
|
||||
},
|
||||
isSaving: isSavingProfile,
|
||||
name: {
|
||||
draftValue: profileDraft.displayName,
|
||||
savedValue: savedProfile.displayName,
|
||||
},
|
||||
saveRecovery: resolveProfileSaveRecovery(
|
||||
saveErrorMessage,
|
||||
savedProfile.displayName,
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-dvh items-center justify-center bg-[radial-gradient(circle_at_top,hsl(var(--primary)/0.16),transparent_44%),linear-gradient(180deg,hsl(var(--background)),hsl(var(--muted)/0.5))] px-4 py-8"
|
||||
data-testid="onboarding-gate"
|
||||
>
|
||||
<div className="w-full max-w-xl rounded-[32px] border border-border/70 bg-background/94 p-6 shadow-2xl backdrop-blur sm:p-8">
|
||||
{currentPage === "profile" ? (
|
||||
<ProfileStep
|
||||
actions={{
|
||||
advanceWithoutSaving: showSetupPage,
|
||||
clearAvatarDraft: resetAvatarDraft,
|
||||
openAvatarPicker,
|
||||
skipForNow,
|
||||
submit: () => {
|
||||
void saveProfileAndContinue();
|
||||
},
|
||||
updateAvatarUrl: updateAvatarUrlDraft,
|
||||
updateDisplayName: updateDisplayNameDraft,
|
||||
uploadAvatarFile: (event) => {
|
||||
void handleAvatarFileChange(event);
|
||||
},
|
||||
}}
|
||||
state={profileStepState}
|
||||
/>
|
||||
) : (
|
||||
<SetupStep
|
||||
actions={{
|
||||
back: showProfilePage,
|
||||
complete,
|
||||
enableDesktopNotifications: handleEnableDesktopNotifications,
|
||||
}}
|
||||
notifications={notifications}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { Camera, Link2, Loader2, UserRound } from "lucide-react";
|
||||
|
||||
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import type { ProfileStepActions, ProfileStepState } from "./types";
|
||||
|
||||
type ProfileStepProps = {
|
||||
actions: ProfileStepActions;
|
||||
state: ProfileStepState;
|
||||
};
|
||||
|
||||
function ErrorBanner({ message }: { message: string | null }) {
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{message}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
type AvatarSectionProps = {
|
||||
actions: Pick<
|
||||
ProfileStepActions,
|
||||
| "clearAvatarDraft"
|
||||
| "openAvatarPicker"
|
||||
| "updateAvatarUrl"
|
||||
| "uploadAvatarFile"
|
||||
>;
|
||||
avatar: ProfileStepState["avatar"];
|
||||
isSaving: boolean;
|
||||
previewName: string;
|
||||
};
|
||||
|
||||
function AvatarSection({
|
||||
actions,
|
||||
avatar,
|
||||
isSaving,
|
||||
previewName,
|
||||
}: AvatarSectionProps) {
|
||||
const {
|
||||
clearAvatarDraft,
|
||||
openAvatarPicker,
|
||||
updateAvatarUrl,
|
||||
uploadAvatarFile,
|
||||
} = actions;
|
||||
const { draftUrl, inputRef, isUploading, savedUrl } = avatar;
|
||||
const hasAvatarDraftChanges = draftUrl.length > 0 && draftUrl !== savedUrl;
|
||||
const isAvatarInputDisabled = isSaving || isUploading;
|
||||
|
||||
return (
|
||||
<div className="rounded-[28px] border border-border/70 bg-muted/20 p-5">
|
||||
<div className="flex flex-col gap-5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative h-20 w-20 shrink-0">
|
||||
<ProfileAvatar
|
||||
avatarUrl={draftUrl || null}
|
||||
className="h-full w-full rounded-3xl text-xl"
|
||||
iconClassName="h-6 w-6"
|
||||
label={previewName}
|
||||
testId="onboarding-avatar-preview"
|
||||
/>
|
||||
<div className="absolute -bottom-1 -right-1 flex h-8 w-8 items-center justify-center rounded-full border border-background bg-primary text-primary-foreground shadow-sm">
|
||||
<Camera className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Add a profile photo</p>
|
||||
<p className="max-w-sm text-sm text-muted-foreground">
|
||||
Optional, but it makes conversations easier to scan.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-stretch gap-2 sm:min-w-[220px]">
|
||||
<Button
|
||||
className="w-full justify-center"
|
||||
data-testid="onboarding-avatar-upload"
|
||||
disabled={isAvatarInputDisabled}
|
||||
onClick={openAvatarPicker}
|
||||
size="lg"
|
||||
type="button"
|
||||
>
|
||||
{isUploading ? <Loader2 className="animate-spin" /> : <Camera />}
|
||||
{isUploading ? "Uploading..." : "Upload photo"}
|
||||
</Button>
|
||||
{hasAvatarDraftChanges ? (
|
||||
<Button
|
||||
data-testid="onboarding-avatar-clear"
|
||||
onClick={clearAvatarDraft}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Undo
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
You can always add one later.
|
||||
</p>
|
||||
)}
|
||||
<input
|
||||
accept="image/gif,image/jpeg,image/png,image/webp"
|
||||
className="hidden"
|
||||
onChange={uploadAvatarFile}
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="onboarding-avatar-url">
|
||||
Avatar URL
|
||||
</label>
|
||||
<div className="relative min-w-0">
|
||||
<Link2 className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
data-testid="onboarding-avatar-url"
|
||||
disabled={isAvatarInputDisabled}
|
||||
id="onboarding-avatar-url"
|
||||
onChange={(event) => updateAvatarUrl(event.target.value)}
|
||||
placeholder="https://example.com/avatar.png"
|
||||
value={draftUrl}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Prefer a link instead? Paste it here and we'll save that instead.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProfileStep({ actions, state }: ProfileStepProps) {
|
||||
const {
|
||||
advanceWithoutSaving,
|
||||
clearAvatarDraft,
|
||||
openAvatarPicker,
|
||||
skipForNow,
|
||||
submit,
|
||||
updateAvatarUrl,
|
||||
updateDisplayName,
|
||||
uploadAvatarFile,
|
||||
} = actions;
|
||||
const { avatar, isSaving, name, saveRecovery } = state;
|
||||
const { errorMessage: avatarErrorMessage } = avatar;
|
||||
const { draftValue: displayNameDraft, savedValue: savedDisplayName } = name;
|
||||
const isSubmittingDisabled = isSaving || avatar.isUploading;
|
||||
const canSubmit = displayNameDraft.trim().length > 0 && !isSubmittingDisabled;
|
||||
const avatarPreviewLabel =
|
||||
displayNameDraft.trim() || savedDisplayName || "You";
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-testid="onboarding-page-1">
|
||||
<div className="space-y-3">
|
||||
<Badge variant="info">First run</Badge>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-semibold tracking-tight text-foreground">
|
||||
Set up your profile
|
||||
</h1>
|
||||
<p className="max-w-xl text-sm leading-6 text-muted-foreground">
|
||||
Add the name people will see in Sprout. A photo is optional, but it
|
||||
helps people spot you faster.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
htmlFor="onboarding-display-name"
|
||||
>
|
||||
Display name
|
||||
</label>
|
||||
<div className="relative">
|
||||
<UserRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
autoFocus
|
||||
className="pl-9"
|
||||
data-testid="onboarding-display-name"
|
||||
disabled={isSubmittingDisabled}
|
||||
id="onboarding-display-name"
|
||||
onChange={(event) => updateDisplayName(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && canSubmit) {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
placeholder="How people should see you"
|
||||
value={displayNameDraft}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
You can change this later in Profile settings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AvatarSection
|
||||
actions={{
|
||||
clearAvatarDraft,
|
||||
openAvatarPicker,
|
||||
updateAvatarUrl,
|
||||
uploadAvatarFile,
|
||||
}}
|
||||
avatar={avatar}
|
||||
isSaving={isSaving}
|
||||
previewName={avatarPreviewLabel}
|
||||
/>
|
||||
|
||||
<ErrorBanner message={avatarErrorMessage} />
|
||||
<ErrorBanner message={saveRecovery.errorMessage} />
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{saveRecovery.canSkipForNow ? (
|
||||
<Button
|
||||
data-testid="onboarding-skip"
|
||||
onClick={skipForNow}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Skip for now
|
||||
</Button>
|
||||
) : null}
|
||||
{saveRecovery.canAdvanceWithoutSaving ? (
|
||||
<Button
|
||||
data-testid="onboarding-next-without-saving"
|
||||
onClick={advanceWithoutSaving}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Continue without saving
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
data-testid="onboarding-next"
|
||||
disabled={!canSubmit}
|
||||
onClick={submit}
|
||||
type="button"
|
||||
>
|
||||
{isSaving ? "Saving..." : "Next"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { BellRing, Check, Loader2, TerminalSquare } from "lucide-react";
|
||||
|
||||
import { useAcpProvidersQuery } from "@/features/agents/hooks";
|
||||
import type { BadgeProps } from "@/shared/ui/badge";
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import type {
|
||||
OnboardingNotifications,
|
||||
SetupStepActions,
|
||||
SetupStepState,
|
||||
} from "./types";
|
||||
|
||||
type SetupStepProps = {
|
||||
actions: SetupStepActions;
|
||||
notifications: OnboardingNotifications;
|
||||
};
|
||||
|
||||
type SetupStepContentProps = {
|
||||
actions: SetupStepActions;
|
||||
state: SetupStepState;
|
||||
};
|
||||
|
||||
type NotificationStatusDescriptor = {
|
||||
detail: string;
|
||||
label: string;
|
||||
variant: NonNullable<BadgeProps["variant"]>;
|
||||
};
|
||||
|
||||
function getNotificationStatus(
|
||||
desktopEnabled: boolean,
|
||||
permission: SetupStepState["notifications"]["permission"],
|
||||
): NotificationStatusDescriptor {
|
||||
if (permission === "unsupported") {
|
||||
return {
|
||||
detail: "Desktop alerts are unavailable here.",
|
||||
label: "Unavailable",
|
||||
variant: "warning",
|
||||
};
|
||||
}
|
||||
|
||||
if (permission === "denied") {
|
||||
return {
|
||||
detail: "Sprout is blocked from sending alerts. You can fix that later.",
|
||||
label: "Blocked",
|
||||
variant: "destructive",
|
||||
};
|
||||
}
|
||||
|
||||
if (desktopEnabled) {
|
||||
return {
|
||||
detail: "Mentions and needs-action alerts can break through to desktop.",
|
||||
label: "Enabled",
|
||||
variant: "success",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
detail:
|
||||
"Mentions and needs-action still land in Home. Desktop alerts stay optional.",
|
||||
label: "Optional",
|
||||
variant: "outline",
|
||||
};
|
||||
}
|
||||
|
||||
function useSetupStepState(
|
||||
notifications: OnboardingNotifications,
|
||||
): SetupStepState {
|
||||
const providersQuery = useAcpProvidersQuery();
|
||||
const items = providersQuery.data ?? [];
|
||||
const isChecking = providersQuery.isLoading;
|
||||
const errorMessage =
|
||||
providersQuery.error instanceof Error ? providersQuery.error.message : null;
|
||||
|
||||
return {
|
||||
notifications,
|
||||
runtimeProviders: {
|
||||
errorMessage,
|
||||
isChecking,
|
||||
items,
|
||||
showSetupLaterHint:
|
||||
errorMessage === null && !isChecking && items.length === 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function RuntimeProvidersSection({
|
||||
runtimeProviders,
|
||||
}: {
|
||||
runtimeProviders: SetupStepState["runtimeProviders"];
|
||||
}) {
|
||||
const { errorMessage, isChecking, items, showSetupLaterHint } =
|
||||
runtimeProviders;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-[28px] border border-border/70 bg-muted/20 p-5">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<TerminalSquare className="h-4 w-4 text-primary" />
|
||||
<p className="text-sm font-medium">Detected runtimes</p>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
We only list runtimes the app can actually see on this machine.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{items.length > 0 ? (
|
||||
<div className="grid gap-2">
|
||||
{items.map((provider) => (
|
||||
<div
|
||||
className="rounded-2xl border border-border/70 bg-background/85 px-4 py-3"
|
||||
data-testid={`onboarding-provider-${provider.id}`}
|
||||
key={provider.id}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{provider.label}
|
||||
</p>
|
||||
<Badge variant="outline">{provider.command}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : isChecking ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Looking for compatible runtimes...
|
||||
</p>
|
||||
) : errorMessage ? null : (
|
||||
<p
|
||||
className="text-sm text-muted-foreground"
|
||||
data-testid="onboarding-acp-empty"
|
||||
>
|
||||
No compatible ACP runtimes detected yet.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{showSetupLaterHint ? (
|
||||
<p className="rounded-2xl border border-border/70 bg-background/85 px-4 py-3 text-sm text-muted-foreground">
|
||||
Nothing is installed yet. That's fine. You can finish setup now
|
||||
and come back later in Settings > Doctor.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{errorMessage ? (
|
||||
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SetupStepContent({ actions, state }: SetupStepContentProps) {
|
||||
const { notifications, runtimeProviders } = state;
|
||||
const notificationStatus = getNotificationStatus(
|
||||
notifications.settings.desktopEnabled,
|
||||
notifications.permission,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-testid="onboarding-page-2">
|
||||
<div className="space-y-3">
|
||||
<Badge variant="info">First run</Badge>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-semibold tracking-tight text-foreground">
|
||||
Alerts and ACP runtimes
|
||||
</h1>
|
||||
<p className="max-w-xl text-sm leading-6 text-muted-foreground">
|
||||
Desktop alerts are optional. ACP runtimes only matter when you want
|
||||
Sprout to launch local tools from this machine.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-[28px] border border-border/70 bg-background/85 p-5">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">Desktop alerts</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{notificationStatus.detail}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={notificationStatus.variant}>
|
||||
{notificationStatus.label}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full justify-center"
|
||||
data-testid="onboarding-notifications-enable"
|
||||
disabled={
|
||||
notifications.isUpdatingDesktopEnabled ||
|
||||
notifications.settings.desktopEnabled ||
|
||||
notifications.permission === "unsupported"
|
||||
}
|
||||
onClick={actions.enableDesktopNotifications}
|
||||
type="button"
|
||||
variant={
|
||||
notifications.settings.desktopEnabled ? "secondary" : "outline"
|
||||
}
|
||||
>
|
||||
{notifications.isUpdatingDesktopEnabled ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" />
|
||||
Requesting permission...
|
||||
</>
|
||||
) : notifications.settings.desktopEnabled ? (
|
||||
<>
|
||||
<Check />
|
||||
Alerts enabled
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<BellRing />
|
||||
Enable desktop alerts
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{notifications.errorMessage ? (
|
||||
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{notifications.errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<RuntimeProvidersSection runtimeProviders={runtimeProviders} />
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Button
|
||||
data-testid="onboarding-back"
|
||||
onClick={actions.back}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="onboarding-finish"
|
||||
onClick={actions.complete}
|
||||
type="button"
|
||||
>
|
||||
Finish
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SetupStep({ actions, notifications }: SetupStepProps) {
|
||||
const state = useSetupStepState(notifications);
|
||||
|
||||
return <SetupStepContent actions={actions} state={state} />;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type * as React from "react";
|
||||
|
||||
import type {
|
||||
DesktopNotificationPermissionState,
|
||||
NotificationSettings,
|
||||
} from "@/features/notifications/hooks";
|
||||
import type { AcpProvider, Profile } from "@/shared/api/types";
|
||||
|
||||
export type OnboardingPage = "profile" | "setup";
|
||||
|
||||
export type OnboardingActions = {
|
||||
complete: () => void;
|
||||
skipForNow: () => void;
|
||||
};
|
||||
|
||||
export type OnboardingProfileSeed = {
|
||||
profile?: Profile;
|
||||
};
|
||||
|
||||
export type OnboardingProfileValues = {
|
||||
avatarUrl: string;
|
||||
displayName: string;
|
||||
};
|
||||
|
||||
export type OnboardingNotifications = {
|
||||
errorMessage: string | null;
|
||||
isUpdatingDesktopEnabled: boolean;
|
||||
permission: DesktopNotificationPermissionState;
|
||||
setDesktopEnabled: (enabled: boolean) => Promise<boolean>;
|
||||
settings: NotificationSettings;
|
||||
};
|
||||
|
||||
export type ProfileStepSaveRecovery = {
|
||||
canAdvanceWithoutSaving: boolean;
|
||||
canSkipForNow: boolean;
|
||||
errorMessage: string | null;
|
||||
};
|
||||
|
||||
export type ProfileStepNameState = {
|
||||
draftValue: string;
|
||||
savedValue: string;
|
||||
};
|
||||
|
||||
export type ProfileStepAvatarState = {
|
||||
draftUrl: string;
|
||||
errorMessage: string | null;
|
||||
inputRef: React.RefObject<HTMLInputElement | null>;
|
||||
isUploading: boolean;
|
||||
savedUrl: string;
|
||||
};
|
||||
|
||||
export type ProfileStepState = {
|
||||
avatar: ProfileStepAvatarState;
|
||||
isSaving: boolean;
|
||||
name: ProfileStepNameState;
|
||||
saveRecovery: ProfileStepSaveRecovery;
|
||||
};
|
||||
|
||||
export type ProfileStepActions = {
|
||||
advanceWithoutSaving: () => void;
|
||||
clearAvatarDraft: () => void;
|
||||
openAvatarPicker: () => void;
|
||||
skipForNow: () => void;
|
||||
submit: () => void;
|
||||
updateAvatarUrl: (value: string) => void;
|
||||
updateDisplayName: (value: string) => void;
|
||||
uploadAvatarFile: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
};
|
||||
|
||||
export type SetupStepActions = {
|
||||
back: () => void;
|
||||
complete: () => void;
|
||||
enableDesktopNotifications: () => void;
|
||||
};
|
||||
|
||||
export type SetupStepRuntimeState = {
|
||||
errorMessage: string | null;
|
||||
isChecking: boolean;
|
||||
items: AcpProvider[];
|
||||
showSetupLaterHint: boolean;
|
||||
};
|
||||
|
||||
export type SetupStepState = {
|
||||
notifications: OnboardingNotifications;
|
||||
runtimeProviders: SetupStepRuntimeState;
|
||||
};
|
||||
@@ -26,6 +26,7 @@ export const ACCENT_COLORS = [
|
||||
{ name: "Orange", value: "#f97316" },
|
||||
{ name: "Red", value: "#ef4444" },
|
||||
{ name: "Pink", value: "#ec4899" },
|
||||
{ name: "Lilac", value: "#c0a2f1" },
|
||||
{ name: "Purple", value: "#a855f7" },
|
||||
{ name: "Indigo", value: "#6366f1" },
|
||||
] as const;
|
||||
|
||||
+359
-110
@@ -11,10 +11,32 @@ type TestIdentity = {
|
||||
username: string;
|
||||
};
|
||||
|
||||
type MockAcpProvider = {
|
||||
id: string;
|
||||
label: string;
|
||||
command: string;
|
||||
binaryPath: string;
|
||||
defaultArgs: string[];
|
||||
};
|
||||
|
||||
type MockCommandAvailability = {
|
||||
available?: boolean;
|
||||
command?: string;
|
||||
resolvedPath?: string | null;
|
||||
};
|
||||
|
||||
type E2eConfig = {
|
||||
mode?: "mock" | "relay";
|
||||
mock?: {
|
||||
acpProviders?: MockAcpProvider[];
|
||||
managedAgentPrereqs?: {
|
||||
acp?: MockCommandAvailability;
|
||||
mcp?: MockCommandAvailability;
|
||||
};
|
||||
mintTokenError?: string;
|
||||
profileReadDelayMs?: number;
|
||||
profileReadError?: string;
|
||||
profileUpdateError?: string;
|
||||
seededTokens?: RawMockTokenSeed[];
|
||||
};
|
||||
relayHttpUrl?: string;
|
||||
@@ -418,6 +440,7 @@ declare global {
|
||||
|
||||
const DEFAULT_RELAY_HTTP_URL = "http://localhost:3000";
|
||||
const DEFAULT_RELAY_WS_URL = "ws://localhost:3000";
|
||||
const E2E_IDENTITY_OVERRIDE_STORAGE_KEY = "sprout:e2e-identity-override.v1";
|
||||
const DEFAULT_MOCK_IDENTITY = {
|
||||
pubkey: "deadbeef".repeat(8),
|
||||
display_name: "npub1mock...",
|
||||
@@ -457,7 +480,12 @@ function cloneMembers(members: RawChannelMember[]): RawChannelMember[] {
|
||||
return members.map((member) => ({ ...member }));
|
||||
}
|
||||
|
||||
function toRawChannel(channel: MockChannel): RawChannelWithMembership {
|
||||
function toRawChannel(
|
||||
channel: MockChannel,
|
||||
config?: E2eConfig,
|
||||
): RawChannelWithMembership {
|
||||
const currentPubkey = getMockMemberPubkey(config).toLowerCase();
|
||||
|
||||
return {
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
@@ -474,16 +502,17 @@ function toRawChannel(channel: MockChannel): RawChannelWithMembership {
|
||||
ttl_seconds: channel.ttl_seconds ?? null,
|
||||
ttl_deadline: channel.ttl_deadline ?? null,
|
||||
is_member: channel.members.some(
|
||||
(member) =>
|
||||
member.pubkey === MOCK_IDENTITY_PUBKEY ||
|
||||
member.pubkey === DEFAULT_REAL_IDENTITY.pubkey,
|
||||
(member) => member.pubkey.toLowerCase() === currentPubkey,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function toRawChannelDetail(channel: MockChannel): RawChannelDetail {
|
||||
function toRawChannelDetail(
|
||||
channel: MockChannel,
|
||||
config?: E2eConfig,
|
||||
): RawChannelDetail {
|
||||
return {
|
||||
...toRawChannel(channel),
|
||||
...toRawChannel(channel, config),
|
||||
created_by: channel.created_by,
|
||||
created_at: channel.created_at,
|
||||
updated_at: channel.updated_at,
|
||||
@@ -758,8 +787,8 @@ function listMockProfiles(): RawProfile[] {
|
||||
.filter((profile): profile is RawProfile => profile !== null);
|
||||
}
|
||||
|
||||
function listMockChannels(): RawChannelWithMembership[] {
|
||||
return mockChannels.map(toRawChannel);
|
||||
function listMockChannels(config?: E2eConfig): RawChannelWithMembership[] {
|
||||
return mockChannels.map((channel) => toRawChannel(channel, config));
|
||||
}
|
||||
|
||||
function getMockChannel(channelId: string): MockChannel {
|
||||
@@ -772,11 +801,11 @@ function getMockChannel(channelId: string): MockChannel {
|
||||
}
|
||||
|
||||
function getMockMemberPubkey(config: E2eConfig | undefined): string {
|
||||
return getIdentity(config)?.pubkey ?? getMockIdentity().pubkey;
|
||||
return getActiveIdentity(config)?.pubkey ?? getMockIdentity().pubkey;
|
||||
}
|
||||
|
||||
function getMockMemberDisplayName(config: E2eConfig | undefined): string {
|
||||
return getIdentity(config)?.username ?? getMockIdentity().displayName;
|
||||
return getActiveIdentity(config)?.username ?? getMockIdentity().displayName;
|
||||
}
|
||||
|
||||
function createCurrentMember(
|
||||
@@ -1016,10 +1045,10 @@ const mockChannels: MockChannel[] = [
|
||||
created_minutes_ago: 720,
|
||||
updated_minutes_ago: 720,
|
||||
participants: ["alice", "tyler"],
|
||||
participant_pubkeys: [ALICE_PUBKEY, DEFAULT_REAL_IDENTITY.pubkey],
|
||||
participant_pubkeys: [ALICE_PUBKEY, MOCK_IDENTITY_PUBKEY],
|
||||
members: [
|
||||
createMockMember(ALICE_PUBKEY, "member", 720),
|
||||
createMockMember(DEFAULT_REAL_IDENTITY.pubkey, "member", 720),
|
||||
createMockMember(MOCK_IDENTITY_PUBKEY, "member", 720),
|
||||
],
|
||||
}),
|
||||
createMockChannel({
|
||||
@@ -1043,10 +1072,10 @@ const mockChannels: MockChannel[] = [
|
||||
created_minutes_ago: 700,
|
||||
updated_minutes_ago: 700,
|
||||
participants: ["bob", "tyler"],
|
||||
participant_pubkeys: [BOB_PUBKEY, DEFAULT_REAL_IDENTITY.pubkey],
|
||||
participant_pubkeys: [BOB_PUBKEY, MOCK_IDENTITY_PUBKEY],
|
||||
members: [
|
||||
createMockMember(BOB_PUBKEY, "member", 700),
|
||||
createMockMember(DEFAULT_REAL_IDENTITY.pubkey, "member", 700),
|
||||
createMockMember(MOCK_IDENTITY_PUBKEY, "member", 700),
|
||||
],
|
||||
}),
|
||||
];
|
||||
@@ -1374,6 +1403,36 @@ function getConfig(): E2eConfig | undefined {
|
||||
return window.__SPROUT_E2E__;
|
||||
}
|
||||
|
||||
function readStoredIdentityOverride(): TestIdentity | undefined {
|
||||
try {
|
||||
const rawValue = window.localStorage.getItem(
|
||||
E2E_IDENTITY_OVERRIDE_STORAGE_KEY,
|
||||
);
|
||||
if (!rawValue) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(rawValue);
|
||||
if (
|
||||
!parsed ||
|
||||
typeof parsed !== "object" ||
|
||||
typeof parsed.privateKey !== "string" ||
|
||||
typeof parsed.pubkey !== "string" ||
|
||||
typeof parsed.username !== "string"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
privateKey: parsed.privateKey,
|
||||
pubkey: parsed.pubkey,
|
||||
username: parsed.username,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function isRelayMode(config: E2eConfig | undefined): boolean {
|
||||
return config?.mode === "relay";
|
||||
}
|
||||
@@ -1394,6 +1453,10 @@ function getIdentity(config: E2eConfig | undefined): TestIdentity | undefined {
|
||||
return config?.identity ?? DEFAULT_REAL_IDENTITY;
|
||||
}
|
||||
|
||||
function getActiveIdentity(config: E2eConfig | undefined) {
|
||||
return readStoredIdentityOverride() ?? getIdentity(config);
|
||||
}
|
||||
|
||||
function ensureMockProfile(config: E2eConfig | undefined): RawProfile {
|
||||
const pubkey = getMockMemberPubkey(config);
|
||||
const existing = mockProfiles.get(pubkey);
|
||||
@@ -2016,7 +2079,7 @@ async function submitSignedEvent(
|
||||
async function handleGetChannels(config: E2eConfig | undefined) {
|
||||
const identity = getIdentity(config);
|
||||
if (!identity) {
|
||||
return listMockChannels();
|
||||
return listMockChannels(config);
|
||||
}
|
||||
|
||||
return relayJsonRequest<RawChannel[]>(config, "/api/channels");
|
||||
@@ -2025,6 +2088,18 @@ async function handleGetChannels(config: E2eConfig | undefined) {
|
||||
async function handleGetProfile(config: E2eConfig | undefined) {
|
||||
const identity = getIdentity(config);
|
||||
if (!identity) {
|
||||
const profileReadDelayMs = config?.mock?.profileReadDelayMs ?? 0;
|
||||
if (profileReadDelayMs > 0) {
|
||||
await new Promise<void>((resolve) => {
|
||||
window.setTimeout(resolve, profileReadDelayMs);
|
||||
});
|
||||
}
|
||||
|
||||
const profileReadError = config?.mock?.profileReadError;
|
||||
if (profileReadError) {
|
||||
throw new Error(profileReadError);
|
||||
}
|
||||
|
||||
return cloneProfile(ensureMockProfile(config));
|
||||
}
|
||||
|
||||
@@ -2042,6 +2117,14 @@ async function handleUpdateProfile(
|
||||
) {
|
||||
const identity = getIdentity(config);
|
||||
if (!identity) {
|
||||
const profileUpdateError = config?.mock?.profileUpdateError;
|
||||
if (profileUpdateError) {
|
||||
if (config?.mock) {
|
||||
config.mock.profileUpdateError = undefined;
|
||||
}
|
||||
throw new Error(profileUpdateError);
|
||||
}
|
||||
|
||||
const profile = ensureMockProfile(config);
|
||||
const nextDisplayName = args.displayName?.trim();
|
||||
const nextAvatarUrl = args.avatarUrl?.trim();
|
||||
@@ -2301,7 +2384,7 @@ async function handleCreateChannel(
|
||||
members: [owner],
|
||||
});
|
||||
mockChannels.push(channel);
|
||||
return toRawChannel(channel);
|
||||
return toRawChannel(channel, config);
|
||||
}
|
||||
|
||||
const channelId = crypto.randomUUID();
|
||||
@@ -2349,7 +2432,7 @@ async function handleOpenDm(
|
||||
]);
|
||||
const existingChannel = findMockDmByParticipantPubkeys(participantPubkeys);
|
||||
if (existingChannel) {
|
||||
return toRawChannel(existingChannel);
|
||||
return toRawChannel(existingChannel, config);
|
||||
}
|
||||
|
||||
const identity = getIdentity(config);
|
||||
@@ -2384,7 +2467,7 @@ async function handleOpenDm(
|
||||
});
|
||||
syncMockChannel(channel);
|
||||
mockChannels.push(channel);
|
||||
return toRawChannel(channel);
|
||||
return toRawChannel(channel, config);
|
||||
}
|
||||
|
||||
const response = await relayJsonRequest<RawOpenDmResponse>(
|
||||
@@ -2432,7 +2515,7 @@ async function handleGetChannelDetails(
|
||||
) {
|
||||
const identity = getIdentity(config);
|
||||
if (!identity) {
|
||||
return toRawChannelDetail(getMockChannel(args.channelId));
|
||||
return toRawChannelDetail(getMockChannel(args.channelId), config);
|
||||
}
|
||||
|
||||
return relayJsonRequest<RawChannelDetail>(
|
||||
@@ -2478,7 +2561,7 @@ async function handleUpdateChannel(
|
||||
channel.description = args.description;
|
||||
}
|
||||
touchMockChannel(channel);
|
||||
return toRawChannelDetail(channel);
|
||||
return toRawChannelDetail(channel, config);
|
||||
}
|
||||
|
||||
const tags: string[][] = [["h", args.channelId]];
|
||||
@@ -2787,81 +2870,218 @@ async function handleGetFeed(
|
||||
const includeType = (type: string) =>
|
||||
wantedTypes.length === 0 || wantedTypes.includes(type);
|
||||
|
||||
const defaultFeed: RawHomeFeedResponse["feed"] = {
|
||||
mentions: [
|
||||
{
|
||||
id: "mock-feed-mention",
|
||||
kind: 9,
|
||||
pubkey:
|
||||
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f",
|
||||
content: "Please review the release checklist.",
|
||||
created_at: now - 90,
|
||||
channel_id: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
|
||||
channel_name: "general",
|
||||
tags: [
|
||||
["e", "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"],
|
||||
["p", DEFAULT_MOCK_IDENTITY.pubkey],
|
||||
],
|
||||
category: "mention" as const,
|
||||
},
|
||||
],
|
||||
needs_action: [
|
||||
{
|
||||
id: "mock-feed-reminder",
|
||||
kind: 40007,
|
||||
pubkey:
|
||||
"0000000000000000000000000000000000000000000000000000000000000000",
|
||||
content: "Reminder: update the launch plan before lunch.",
|
||||
created_at: now - 15 * 60,
|
||||
channel_id: "94a444a4-c0a3-5966-ab05-530c6ddc2301",
|
||||
channel_name: "agents",
|
||||
tags: [
|
||||
["e", "94a444a4-c0a3-5966-ab05-530c6ddc2301"],
|
||||
["p", DEFAULT_MOCK_IDENTITY.pubkey],
|
||||
],
|
||||
category: "needs_action" as const,
|
||||
},
|
||||
],
|
||||
activity: [
|
||||
{
|
||||
id: "mock-feed-self-activity",
|
||||
kind: 9,
|
||||
pubkey: DEFAULT_MOCK_IDENTITY.pubkey,
|
||||
content: "I posted a note about the launch checklist.",
|
||||
created_at: now - 25 * 60,
|
||||
channel_id: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
|
||||
channel_name: "general",
|
||||
tags: [["e", "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"]],
|
||||
category: "activity" as const,
|
||||
},
|
||||
{
|
||||
id: "mock-feed-activity",
|
||||
kind: 9,
|
||||
pubkey:
|
||||
"bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260",
|
||||
content: "Engineering shipped the desktop build.",
|
||||
created_at: now - 42 * 60,
|
||||
channel_id: "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9",
|
||||
channel_name: "engineering",
|
||||
tags: [["e", "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9"]],
|
||||
category: "activity" as const,
|
||||
},
|
||||
],
|
||||
agent_activity: [
|
||||
{
|
||||
id: "mock-feed-agent",
|
||||
kind: 43003,
|
||||
pubkey:
|
||||
"db0b028cd36f4d3e36c8300cce87252c1f7fc9495ffecc53f393fcac341ffd36",
|
||||
content: "Agent progress: channel index complete.",
|
||||
created_at: now - 2 * 60 * 60,
|
||||
channel_id: "94a444a4-c0a3-5966-ab05-530c6ddc2301",
|
||||
channel_name: "agents",
|
||||
tags: [["e", "94a444a4-c0a3-5966-ab05-530c6ddc2301"]],
|
||||
category: "agent_activity" as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
const currentPubkey = getMockMemberPubkey(config).toLowerCase();
|
||||
const defaultFeed: RawHomeFeedResponse["feed"] =
|
||||
currentPubkey === ALICE_PUBKEY
|
||||
? {
|
||||
mentions: [
|
||||
{
|
||||
id: "mock-feed-alice-mention",
|
||||
kind: 9,
|
||||
pubkey: BOB_PUBKEY,
|
||||
content: "Alice, can you sanity-check the new design mocks?",
|
||||
created_at: now - 90,
|
||||
channel_id: "b5e2f8a1-3c44-5912-9e67-4a8d1f2b3c4e",
|
||||
channel_name: "design",
|
||||
tags: [
|
||||
["e", "b5e2f8a1-3c44-5912-9e67-4a8d1f2b3c4e"],
|
||||
["p", ALICE_PUBKEY],
|
||||
],
|
||||
category: "mention" as const,
|
||||
},
|
||||
],
|
||||
needs_action: [
|
||||
{
|
||||
id: "mock-feed-alice-reminder",
|
||||
kind: 40007,
|
||||
pubkey:
|
||||
"0000000000000000000000000000000000000000000000000000000000000000",
|
||||
content: "Reminder: post the engineering launch note.",
|
||||
created_at: now - 15 * 60,
|
||||
channel_id: "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9",
|
||||
channel_name: "engineering",
|
||||
tags: [
|
||||
["e", "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9"],
|
||||
["p", ALICE_PUBKEY],
|
||||
],
|
||||
category: "needs_action" as const,
|
||||
},
|
||||
],
|
||||
activity: [
|
||||
{
|
||||
id: "mock-feed-alice-self-activity",
|
||||
kind: 9,
|
||||
pubkey: ALICE_PUBKEY,
|
||||
content: "I posted the latest design review summary.",
|
||||
created_at: now - 25 * 60,
|
||||
channel_id: "b5e2f8a1-3c44-5912-9e67-4a8d1f2b3c4e",
|
||||
channel_name: "design",
|
||||
tags: [["e", "b5e2f8a1-3c44-5912-9e67-4a8d1f2b3c4e"]],
|
||||
category: "activity" as const,
|
||||
},
|
||||
{
|
||||
id: "mock-feed-alice-activity",
|
||||
kind: 9,
|
||||
pubkey: BOB_PUBKEY,
|
||||
content: "Engineering signed off on the desktop build.",
|
||||
created_at: now - 42 * 60,
|
||||
channel_id: "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9",
|
||||
channel_name: "engineering",
|
||||
tags: [["e", "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9"]],
|
||||
category: "activity" as const,
|
||||
},
|
||||
],
|
||||
agent_activity: [
|
||||
{
|
||||
id: "mock-feed-alice-agent",
|
||||
kind: 43003,
|
||||
pubkey:
|
||||
"db0b028cd36f4d3e36c8300cce87252c1f7fc9495ffecc53f393fcac341ffd36",
|
||||
content: "Agent progress: design review summary complete.",
|
||||
created_at: now - 2 * 60 * 60,
|
||||
channel_id: "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9",
|
||||
channel_name: "engineering",
|
||||
tags: [["e", "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9"]],
|
||||
category: "agent_activity" as const,
|
||||
},
|
||||
],
|
||||
}
|
||||
: currentPubkey === DEFAULT_REAL_IDENTITY.pubkey.toLowerCase()
|
||||
? {
|
||||
mentions: [
|
||||
{
|
||||
id: "mock-feed-tyler-mention",
|
||||
kind: 9,
|
||||
pubkey: ALICE_PUBKEY,
|
||||
content: "Tyler, can you review the DM onboarding copy?",
|
||||
created_at: now - 90,
|
||||
channel_id: "f48efb06-0c93-5025-aac9-2e646bb6bfa8",
|
||||
channel_name: "alice-tyler",
|
||||
tags: [
|
||||
["e", "f48efb06-0c93-5025-aac9-2e646bb6bfa8"],
|
||||
["p", DEFAULT_REAL_IDENTITY.pubkey],
|
||||
],
|
||||
category: "mention" as const,
|
||||
},
|
||||
],
|
||||
needs_action: [
|
||||
{
|
||||
id: "mock-feed-tyler-reminder",
|
||||
kind: 40007,
|
||||
pubkey:
|
||||
"0000000000000000000000000000000000000000000000000000000000000000",
|
||||
content: "Reminder: answer Bob in the launch DM thread.",
|
||||
created_at: now - 15 * 60,
|
||||
channel_id: "7eb9f239-9393-50b0-bd76-d85eef0511c7",
|
||||
channel_name: "bob-tyler",
|
||||
tags: [
|
||||
["e", "7eb9f239-9393-50b0-bd76-d85eef0511c7"],
|
||||
["p", DEFAULT_REAL_IDENTITY.pubkey],
|
||||
],
|
||||
category: "needs_action" as const,
|
||||
},
|
||||
],
|
||||
activity: [
|
||||
{
|
||||
id: "mock-feed-tyler-self-activity",
|
||||
kind: 9,
|
||||
pubkey: DEFAULT_REAL_IDENTITY.pubkey,
|
||||
content: "I sent the follow-up in the Alice DM.",
|
||||
created_at: now - 25 * 60,
|
||||
channel_id: "f48efb06-0c93-5025-aac9-2e646bb6bfa8",
|
||||
channel_name: "alice-tyler",
|
||||
tags: [["e", "f48efb06-0c93-5025-aac9-2e646bb6bfa8"]],
|
||||
category: "activity" as const,
|
||||
},
|
||||
],
|
||||
agent_activity: [
|
||||
{
|
||||
id: "mock-feed-tyler-agent",
|
||||
kind: 43003,
|
||||
pubkey:
|
||||
"db0b028cd36f4d3e36c8300cce87252c1f7fc9495ffecc53f393fcac341ffd36",
|
||||
content: "Agent progress: DM summary complete.",
|
||||
created_at: now - 2 * 60 * 60,
|
||||
channel_id: "f48efb06-0c93-5025-aac9-2e646bb6bfa8",
|
||||
channel_name: "alice-tyler",
|
||||
tags: [["e", "f48efb06-0c93-5025-aac9-2e646bb6bfa8"]],
|
||||
category: "agent_activity" as const,
|
||||
},
|
||||
],
|
||||
}
|
||||
: {
|
||||
mentions: [
|
||||
{
|
||||
id: "mock-feed-mention",
|
||||
kind: 9,
|
||||
pubkey: ALICE_PUBKEY,
|
||||
content: "Please review the release checklist.",
|
||||
created_at: now - 90,
|
||||
channel_id: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
|
||||
channel_name: "general",
|
||||
tags: [
|
||||
["e", "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"],
|
||||
["p", currentPubkey],
|
||||
],
|
||||
category: "mention" as const,
|
||||
},
|
||||
],
|
||||
needs_action: [
|
||||
{
|
||||
id: "mock-feed-reminder",
|
||||
kind: 40007,
|
||||
pubkey:
|
||||
"0000000000000000000000000000000000000000000000000000000000000000",
|
||||
content: "Reminder: update the launch plan before lunch.",
|
||||
created_at: now - 15 * 60,
|
||||
channel_id: "94a444a4-c0a3-5966-ab05-530c6ddc2301",
|
||||
channel_name: "agents",
|
||||
tags: [
|
||||
["e", "94a444a4-c0a3-5966-ab05-530c6ddc2301"],
|
||||
["p", currentPubkey],
|
||||
],
|
||||
category: "needs_action" as const,
|
||||
},
|
||||
],
|
||||
activity: [
|
||||
{
|
||||
id: "mock-feed-self-activity",
|
||||
kind: 9,
|
||||
pubkey: currentPubkey,
|
||||
content: "I posted a note about the launch checklist.",
|
||||
created_at: now - 25 * 60,
|
||||
channel_id: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
|
||||
channel_name: "general",
|
||||
tags: [["e", "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"]],
|
||||
category: "activity" as const,
|
||||
},
|
||||
{
|
||||
id: "mock-feed-activity",
|
||||
kind: 9,
|
||||
pubkey: BOB_PUBKEY,
|
||||
content: "Engineering shipped the desktop build.",
|
||||
created_at: now - 42 * 60,
|
||||
channel_id: "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9",
|
||||
channel_name: "engineering",
|
||||
tags: [["e", "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9"]],
|
||||
category: "activity" as const,
|
||||
},
|
||||
],
|
||||
agent_activity: [
|
||||
{
|
||||
id: "mock-feed-agent",
|
||||
kind: 43003,
|
||||
pubkey:
|
||||
"db0b028cd36f4d3e36c8300cce87252c1f7fc9495ffecc53f393fcac341ffd36",
|
||||
content: "Agent progress: channel index complete.",
|
||||
created_at: now - 2 * 60 * 60,
|
||||
channel_id: "94a444a4-c0a3-5966-ab05-530c6ddc2301",
|
||||
channel_name: "agents",
|
||||
tags: [["e", "94a444a4-c0a3-5966-ab05-530c6ddc2301"]],
|
||||
category: "agent_activity" as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mergeFeedCategory = (
|
||||
category: keyof RawHomeFeedResponse["feed"],
|
||||
@@ -3029,7 +3249,20 @@ async function handleListRelayAgents(): Promise<RawRelayAgent[]> {
|
||||
return mockRelayAgents.map(cloneRelayAgent);
|
||||
}
|
||||
|
||||
async function handleDiscoverAcpProviders(): Promise<RawAcpProvider[]> {
|
||||
async function handleDiscoverAcpProviders(
|
||||
config: E2eConfig | undefined,
|
||||
): Promise<RawAcpProvider[]> {
|
||||
const configuredProviders = config?.mock?.acpProviders;
|
||||
if (configuredProviders) {
|
||||
return configuredProviders.map((provider) => ({
|
||||
id: provider.id,
|
||||
label: provider.label,
|
||||
command: provider.command,
|
||||
binary_path: provider.binaryPath,
|
||||
default_args: [...provider.defaultArgs],
|
||||
}));
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: "goose",
|
||||
@@ -3048,22 +3281,37 @@ async function handleDiscoverAcpProviders(): Promise<RawAcpProvider[]> {
|
||||
];
|
||||
}
|
||||
|
||||
async function handleDiscoverManagedAgentPrereqs(args: {
|
||||
input?: {
|
||||
acpCommand?: string;
|
||||
mcpCommand?: string;
|
||||
};
|
||||
}): Promise<RawManagedAgentPrereqs> {
|
||||
async function handleDiscoverManagedAgentPrereqs(
|
||||
args: {
|
||||
input?: {
|
||||
acpCommand?: string;
|
||||
mcpCommand?: string;
|
||||
};
|
||||
},
|
||||
config: E2eConfig | undefined,
|
||||
): Promise<RawManagedAgentPrereqs> {
|
||||
const configuredPrereqs = config?.mock?.managedAgentPrereqs;
|
||||
|
||||
return {
|
||||
acp: {
|
||||
command: args.input?.acpCommand ?? "sprout-acp",
|
||||
resolved_path: "/Users/wesb/dev/sprout/target/debug/sprout-acp",
|
||||
available: true,
|
||||
command:
|
||||
configuredPrereqs?.acp?.command ??
|
||||
args.input?.acpCommand ??
|
||||
"sprout-acp",
|
||||
resolved_path:
|
||||
configuredPrereqs?.acp?.resolvedPath ??
|
||||
"/Users/wesb/dev/sprout/target/debug/sprout-acp",
|
||||
available: configuredPrereqs?.acp?.available ?? true,
|
||||
},
|
||||
mcp: {
|
||||
command: args.input?.mcpCommand ?? "sprout-mcp-server",
|
||||
resolved_path: "/Users/wesb/dev/sprout/target/debug/sprout-mcp-server",
|
||||
available: true,
|
||||
command:
|
||||
configuredPrereqs?.mcp?.command ??
|
||||
args.input?.mcpCommand ??
|
||||
"sprout-mcp-server",
|
||||
resolved_path:
|
||||
configuredPrereqs?.mcp?.resolvedPath ??
|
||||
"/Users/wesb/dev/sprout/target/debug/sprout-mcp-server",
|
||||
available: configuredPrereqs?.mcp?.available ?? true,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -4086,7 +4334,7 @@ export function maybeInstallE2eTauriMocks() {
|
||||
};
|
||||
const handleMockCommand = async (command: string, payload: unknown) => {
|
||||
const activeConfig = getConfig();
|
||||
const identity = getIdentity(activeConfig);
|
||||
const identity = getActiveIdentity(activeConfig);
|
||||
window.__SPROUT_E2E_COMMANDS__?.push(command);
|
||||
|
||||
switch (command) {
|
||||
@@ -4143,7 +4391,7 @@ export function maybeInstallE2eTauriMocks() {
|
||||
case "get_relay_http_url":
|
||||
return getRelayHttpUrl(activeConfig);
|
||||
case "discover_acp_providers":
|
||||
return handleDiscoverAcpProviders();
|
||||
return handleDiscoverAcpProviders(activeConfig);
|
||||
case "discover_backend_providers":
|
||||
return [];
|
||||
case "probe_backend_provider":
|
||||
@@ -4151,6 +4399,7 @@ export function maybeInstallE2eTauriMocks() {
|
||||
case "discover_managed_agent_prereqs":
|
||||
return handleDiscoverManagedAgentPrereqs(
|
||||
payload as Parameters<typeof handleDiscoverManagedAgentPrereqs>[0],
|
||||
activeConfig,
|
||||
);
|
||||
case "get_channels":
|
||||
return handleGetChannels(activeConfig);
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
|
||||
|
||||
const E2E_IDENTITY_OVERRIDE_STORAGE_KEY = "sprout:e2e-identity-override.v1";
|
||||
const HOME_SEEN_STORAGE_KEY_PREFIX = "sprout-home-feed-seen.v1:";
|
||||
const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8);
|
||||
const BLANK_TYLER_IDENTITY = {
|
||||
...TEST_IDENTITIES.tyler,
|
||||
username: "",
|
||||
};
|
||||
|
||||
type TestIdentity = {
|
||||
privateKey: string;
|
||||
pubkey: string;
|
||||
username: string;
|
||||
};
|
||||
|
||||
async function seedActiveIdentity(page: Page, identity: TestIdentity) {
|
||||
await page.addInitScript(
|
||||
({ identity: nextIdentity, storageKey }) => {
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(nextIdentity));
|
||||
},
|
||||
{
|
||||
identity,
|
||||
storageKey: E2E_IDENTITY_OVERRIDE_STORAGE_KEY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function seedOnboardingCompletion(page: Page, pubkey: string) {
|
||||
await page.addInitScript(
|
||||
({ storageKey }) => {
|
||||
window.localStorage.setItem(storageKey, "true");
|
||||
},
|
||||
{
|
||||
storageKey: `sprout-onboarding-complete.v1:${pubkey}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function readHomeSeenStorageKeys(page: Page) {
|
||||
return page.evaluate((prefix) => {
|
||||
return Object.keys(window.localStorage).filter((key) =>
|
||||
key.startsWith(prefix),
|
||||
);
|
||||
}, HOME_SEEN_STORAGE_KEY_PREFIX);
|
||||
}
|
||||
|
||||
async function expectNoHomeSeenEntries(page: Page) {
|
||||
await expect.poll(async () => readHomeSeenStorageKeys(page)).toEqual([]);
|
||||
}
|
||||
|
||||
async function expectHomeSeenCount(page: Page, expectedCount: number) {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return page.evaluate((prefix) => {
|
||||
const seenEntries = Object.entries(window.localStorage).filter(
|
||||
([key]) => key.startsWith(prefix),
|
||||
);
|
||||
if (seenEntries.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const [, rawValue] = seenEntries[0];
|
||||
const parsed = JSON.parse(rawValue ?? "[]");
|
||||
return Array.isArray(parsed) ? parsed.length : 0;
|
||||
}, HOME_SEEN_STORAGE_KEY_PREFIX);
|
||||
})
|
||||
.toBe(expectedCount);
|
||||
}
|
||||
|
||||
async function expectShellHidden(page: Page) {
|
||||
await expect(page.getByTestId("app-sidebar")).toHaveCount(0);
|
||||
await expect(page.getByTestId("chat-title")).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function expectIncompleteOnboarding(page: Page) {
|
||||
await expect(page.getByTestId("onboarding-gate")).toBeVisible();
|
||||
await expectShellHidden(page);
|
||||
await expect(page.getByTestId("onboarding-page-1")).toBeVisible();
|
||||
await expect(page.getByTestId("onboarding-display-name")).toHaveValue("");
|
||||
}
|
||||
|
||||
async function continueToSetupPage(page: Page) {
|
||||
await page.getByTestId("onboarding-next").click();
|
||||
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
|
||||
}
|
||||
|
||||
test("completed users skip the loading gate while profile is still settling", async ({
|
||||
page,
|
||||
}) => {
|
||||
await seedOnboardingCompletion(page, DEFAULT_MOCK_PUBKEY);
|
||||
await installMockBridge(page, {
|
||||
profileReadDelayMs: 3_000,
|
||||
});
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.getByTestId("onboarding-gate")).toHaveCount(0);
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("Home");
|
||||
});
|
||||
|
||||
test("identity fallback text does not count as a real onboarding name", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
|
||||
await page.goto("/");
|
||||
|
||||
await expectIncompleteOnboarding(page);
|
||||
await expect(page.getByTestId("onboarding-avatar-upload")).toHaveText(
|
||||
"Upload photo",
|
||||
);
|
||||
await expect(page.getByTestId("onboarding-avatar-url")).toHaveValue("");
|
||||
await expect(page.getByTestId("onboarding-next")).toBeDisabled();
|
||||
});
|
||||
|
||||
test("page 1 accepts an avatar URL as the secondary avatar path", async ({
|
||||
page,
|
||||
}) => {
|
||||
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
|
||||
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
|
||||
await page.goto("/");
|
||||
|
||||
await page.getByTestId("onboarding-display-name").fill("Morty QA");
|
||||
await page
|
||||
.getByTestId("onboarding-avatar-url")
|
||||
.fill("https://example.com/morty.png");
|
||||
|
||||
const preview = page.getByTestId("onboarding-avatar-preview");
|
||||
await expect(preview).toBeVisible();
|
||||
const box = await preview.boundingBox();
|
||||
expect(box?.width).toBeCloseTo(80, 0);
|
||||
expect(box?.height).toBeCloseTo(80, 0);
|
||||
|
||||
await continueToSetupPage(page);
|
||||
await expect(page.getByTestId("onboarding-provider-goose")).toBeVisible();
|
||||
});
|
||||
|
||||
test("first-run onboarding keeps the shell hidden through both pages and only marks Home seen after finish", async ({
|
||||
page,
|
||||
}) => {
|
||||
await seedActiveIdentity(page, TEST_IDENTITIES.alice);
|
||||
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.getByTestId("onboarding-gate")).toBeVisible();
|
||||
await expect(page.getByTestId("onboarding-page-1")).toBeVisible();
|
||||
await expect(page.getByTestId("onboarding-display-name")).toHaveValue(
|
||||
"alice",
|
||||
);
|
||||
await expectNoHomeSeenEntries(page);
|
||||
|
||||
await continueToSetupPage(page);
|
||||
await expectShellHidden(page);
|
||||
await expect(page.getByTestId("onboarding-provider-goose")).toBeVisible();
|
||||
await expectNoHomeSeenEntries(page);
|
||||
|
||||
await page.getByTestId("onboarding-finish").click();
|
||||
await expect(page.getByTestId("onboarding-gate")).toHaveCount(0);
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("Home");
|
||||
await expectHomeSeenCount(page, 2);
|
||||
});
|
||||
|
||||
test("finishing onboarding auto-joins the #general channel for a new member", async ({
|
||||
page,
|
||||
}) => {
|
||||
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
|
||||
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
|
||||
await page.goto("/");
|
||||
|
||||
await page.getByTestId("onboarding-display-name").fill("Morty QA");
|
||||
await continueToSetupPage(page);
|
||||
await page.getByTestId("onboarding-finish").click();
|
||||
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("Home");
|
||||
await expect(page.getByTestId("channel-general")).toBeVisible();
|
||||
});
|
||||
|
||||
test("page 2 falls back to Doctor guidance when ACP tools are not installed", async ({
|
||||
page,
|
||||
}) => {
|
||||
await seedActiveIdentity(page, TEST_IDENTITIES.alice);
|
||||
await installMockBridge(
|
||||
page,
|
||||
{
|
||||
acpProviders: [],
|
||||
},
|
||||
{ skipOnboardingSeed: true },
|
||||
);
|
||||
await page.goto("/");
|
||||
|
||||
await continueToSetupPage(page);
|
||||
await expect(page.getByTestId("onboarding-acp-empty")).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("Settings > Doctor", { exact: false }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("initial profile read failures still hold incomplete users in onboarding", async ({
|
||||
page,
|
||||
}) => {
|
||||
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
|
||||
await installMockBridge(
|
||||
page,
|
||||
{
|
||||
profileReadError: "Temporary profile read failure.",
|
||||
},
|
||||
{ skipOnboardingSeed: true },
|
||||
);
|
||||
await page.goto("/");
|
||||
|
||||
await expectIncompleteOnboarding(page);
|
||||
});
|
||||
|
||||
test("failed first profile saves can be skipped for the current session", async ({
|
||||
page,
|
||||
}) => {
|
||||
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
|
||||
await installMockBridge(
|
||||
page,
|
||||
{
|
||||
profileUpdateError: "Temporary profile sync failure.",
|
||||
},
|
||||
{ skipOnboardingSeed: true },
|
||||
);
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.getByTestId("onboarding-gate")).toBeVisible();
|
||||
await expect(page.getByTestId("onboarding-display-name")).toHaveValue("");
|
||||
|
||||
await page.getByTestId("onboarding-display-name").fill("Morty QA");
|
||||
await page.getByTestId("onboarding-next").click();
|
||||
|
||||
await expect(page.getByText("Temporary profile sync failure.")).toBeVisible();
|
||||
await page.getByTestId("onboarding-skip").click();
|
||||
|
||||
await expect(page.getByTestId("onboarding-gate")).toHaveCount(0);
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("Home");
|
||||
});
|
||||
@@ -35,8 +35,30 @@ export const TEST_IDENTITIES = {
|
||||
|
||||
type BridgeMode = "mock" | "relay";
|
||||
|
||||
type MockAcpProvider = {
|
||||
id: string;
|
||||
label: string;
|
||||
command: string;
|
||||
binaryPath: string;
|
||||
defaultArgs: string[];
|
||||
};
|
||||
|
||||
type MockCommandAvailability = {
|
||||
available?: boolean;
|
||||
command?: string;
|
||||
resolvedPath?: string | null;
|
||||
};
|
||||
|
||||
type MockBridgeOptions = {
|
||||
acpProviders?: MockAcpProvider[];
|
||||
managedAgentPrereqs?: {
|
||||
acp?: MockCommandAvailability;
|
||||
mcp?: MockCommandAvailability;
|
||||
};
|
||||
mintTokenError?: string;
|
||||
profileReadDelayMs?: number;
|
||||
profileReadError?: string;
|
||||
profileUpdateError?: string;
|
||||
seededTokens?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -55,15 +77,39 @@ type BridgeOptions = {
|
||||
mock?: MockBridgeOptions;
|
||||
relayHttpUrl?: string;
|
||||
relayWsUrl?: string;
|
||||
skipOnboardingSeed?: boolean;
|
||||
user?: keyof typeof TEST_IDENTITIES;
|
||||
};
|
||||
|
||||
const ONBOARDING_COMPLETION_STORAGE_KEY_PREFIX =
|
||||
"sprout-onboarding-complete.v1:";
|
||||
const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8);
|
||||
|
||||
async function seedOnboardingCompletionForKnownIdentities(page: Page) {
|
||||
const pubkeys = [
|
||||
DEFAULT_MOCK_PUBKEY,
|
||||
...Object.values(TEST_IDENTITIES).map(({ pubkey }) => pubkey),
|
||||
];
|
||||
await page.addInitScript(
|
||||
({ prefix, pubkeys: pubkeysToSeed }) => {
|
||||
for (const pubkey of pubkeysToSeed) {
|
||||
window.localStorage.setItem(`${prefix}${pubkey}`, "true");
|
||||
}
|
||||
},
|
||||
{ prefix: ONBOARDING_COMPLETION_STORAGE_KEY_PREFIX, pubkeys },
|
||||
);
|
||||
}
|
||||
|
||||
export async function installBridge(page: Page, options: BridgeOptions) {
|
||||
const identity =
|
||||
options.mode === "relay"
|
||||
? TEST_IDENTITIES[options.user ?? "tyler"]
|
||||
: undefined;
|
||||
|
||||
if (!options.skipOnboardingSeed) {
|
||||
await seedOnboardingCompletionForKnownIdentities(page);
|
||||
}
|
||||
|
||||
await page.addInitScript(
|
||||
({ identity: bridgeIdentity, mock, mode, relayHttpUrl, relayWsUrl }) => {
|
||||
const notificationLog: Array<{
|
||||
@@ -146,8 +192,16 @@ export async function installBridge(page: Page, options: BridgeOptions) {
|
||||
);
|
||||
}
|
||||
|
||||
export async function installMockBridge(page: Page, mock?: MockBridgeOptions) {
|
||||
await installBridge(page, { mode: "mock", mock });
|
||||
export async function installMockBridge(
|
||||
page: Page,
|
||||
mock?: MockBridgeOptions,
|
||||
options?: { skipOnboardingSeed?: boolean },
|
||||
) {
|
||||
await installBridge(page, {
|
||||
mode: "mock",
|
||||
mock,
|
||||
skipOnboardingSeed: options?.skipOnboardingSeed,
|
||||
});
|
||||
}
|
||||
|
||||
export async function installRelayBridge(
|
||||
|
||||
Reference in New Issue
Block a user