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:
Wes
2026-04-22 14:06:35 -07:00
committed by GitHub
co-authored by Claude Opus 4.7
parent e2c94a8538
commit 36c97a6e2e
12 changed files with 1827 additions and 150 deletions
+1
View File
@@ -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",
+37
View File
@@ -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);
+257
View File
@@ -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&apos;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&apos;s fine. You can finish setup now
and come back later in Settings &gt; 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
View File
@@ -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);
+239
View File
@@ -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");
});
+56 -2
View File
@@ -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(