Fix SPROUT_SHARE_IDENTITY worktree dev workflow breaks (#702)

This commit is contained in:
Will Pfleger
2026-05-21 17:53:20 -04:00
committed by GitHub
parent 4404162f0e
commit 091e38ff0a
9 changed files with 118 additions and 27 deletions
+3
View File
@@ -53,3 +53,6 @@ doc/
# Local identity files
identity.key
**/identity.key
# Claude Code worktrees
.claude/worktrees/
@@ -37,6 +37,17 @@ pub fn get_default_relay_url() -> String {
relay::relay_ws_url()
}
#[tauri::command]
pub fn is_shared_identity() -> bool {
std::env::var("SPROUT_SHARE_IDENTITY")
.map(|v| v == "1")
.unwrap_or(false)
&& std::env::var("SPROUT_PRIVATE_KEY")
.ok()
.and_then(|k| Keys::parse(k.trim()).ok())
.is_some()
}
#[tauri::command]
pub fn get_relay_ws_url(state: State<'_, AppState>) -> String {
relay_ws_url_with_override(&state)
+1
View File
@@ -461,6 +461,7 @@ pub fn run() {
get_presence,
set_presence,
get_default_relay_url,
is_shared_identity,
get_relay_ws_url,
get_relay_http_url,
get_media_proxy_port,
+26 -4
View File
@@ -16,6 +16,7 @@ import { useWorkspaceInit } from "@/features/workspaces/useWorkspaceInit";
import { useWorkspaces } from "@/features/workspaces/useWorkspaces";
import { WelcomeSetup } from "@/features/workspaces/ui/WelcomeSetup";
import { createSproutQueryClient } from "@/shared/api/queryClient";
import { isSharedIdentity as isSharedIdentityCmd } from "@/shared/api/tauri";
import { listenForDeepLinks } from "@/shared/deep-link";
function AppLoadingGate() {
@@ -44,8 +45,8 @@ function WorkspaceQueryProvider({ children }: { children: ReactNode }) {
);
}
function AppReady() {
const onboarding = useAppOnboardingState();
function AppReady({ isSharedIdentity }: { isSharedIdentity: boolean }) {
const onboarding = useAppOnboardingState(isSharedIdentity);
if (onboarding.stage === "onboarding") {
return (
@@ -69,6 +70,16 @@ export function App() {
void getCurrentWindow().show();
}, []);
const [sharedIdentity, setSharedIdentity] = useState<boolean | null>(null);
useEffect(() => {
isSharedIdentityCmd()
.then(setSharedIdentity)
.catch((err) => {
console.warn("is_shared_identity command failed:", err);
setSharedIdentity(false);
});
}, []);
const {
activeWorkspace,
reinitKey,
@@ -90,7 +101,11 @@ export function App() {
// Composite key: changes when workspace ID changes OR when
// the active workspace's config is updated (relayUrl/token).
const workspaceKey = `${activeWorkspace?.id ?? "none"}-${reinitKey}`;
const workspace = useWorkspaceInit(activeWorkspace, workspaceKey);
const workspace = useWorkspaceInit(
activeWorkspace,
workspaceKey,
sharedIdentity ?? false,
);
const handleSetupComplete = useCallback(() => {
// Force a full reload so useWorkspaces re-initializes from localStorage.
@@ -98,6 +113,13 @@ export function App() {
window.location.reload();
}, []);
// Wait for the shared-identity IPC call to resolve before rendering
// anything that depends on it. Without this gate, children briefly see
// isSharedIdentity=false and may flash WelcomeSetup or the onboarding flow.
if (sharedIdentity === null) {
return <AppLoadingGate />;
}
// Show welcome setup for first-run users with no workspaces
if (workspace.needsSetup) {
return (
@@ -118,7 +140,7 @@ export function App() {
return (
<WorkspaceQueryProvider key={workspaceKey}>
<AppReady key={workspaceKey} />
<AppReady key={workspaceKey} isSharedIdentity={sharedIdentity} />
</WorkspaceQueryProvider>
);
}
+35 -2
View File
@@ -36,6 +36,7 @@ type UseFirstRunOnboardingGateOptions = {
hasExistingProfile: boolean;
identityIsFetching: boolean;
identityStatus: QueryStatus;
isSharedIdentity: boolean;
profileStatus: QueryStatus;
};
@@ -129,13 +130,15 @@ export function useFirstRunOnboardingGate({
hasExistingProfile,
identityIsFetching,
identityStatus,
isSharedIdentity,
profileStatus,
}: UseFirstRunOnboardingGateOptions) {
const [gateState, setGateState] = React.useState<OnboardingGateState>(() =>
createOnboardingGateState(currentPubkey),
);
const activeGateState = resolveActiveGateState(gateState, currentPubkey);
const { hasSettledCurrentPubkey } = activeGateState;
const { hasCompletedCurrentPubkey, hasSettledCurrentPubkey } =
activeGateState;
React.useEffect(() => {
setGateState((current) =>
@@ -146,6 +149,33 @@ export function useFirstRunOnboardingGate({
}, [currentPubkey]);
React.useEffect(() => {
// Fast-path: shared identity worktrees have already onboarded in the
// main checkout. Skip unconditionally without waiting for the relay
// profile query. Guarded by !hasCompletedCurrentPubkey so it fires once.
if (
isSharedIdentity &&
currentPubkey &&
identityStatus === "success" &&
!hasCompletedCurrentPubkey
) {
if (typeof window !== "undefined") {
window.localStorage.setItem(
onboardingCompletionStorageKey(currentPubkey),
"true",
);
}
setGateState((current) =>
updateActiveGateState(current, currentPubkey, (activeGateState) => ({
...activeGateState,
hasCompletedCurrentPubkey: true,
hasSettledCurrentPubkey: true,
isOpen: false,
})),
);
return;
}
// Original guard — restored to simple form.
if (hasSettledCurrentPubkey || !currentPubkey) {
return;
}
@@ -194,9 +224,11 @@ export function useFirstRunOnboardingGate({
);
}, [
currentPubkey,
hasCompletedCurrentPubkey,
hasExistingProfile,
hasSettledCurrentPubkey,
identityStatus,
isSharedIdentity,
profileStatus,
]);
@@ -246,7 +278,7 @@ function hasRealDisplayName(displayName?: string | null): boolean {
return !lower.startsWith("npub1") && !lower.startsWith("nostr:npub1");
}
export function useAppOnboardingState() {
export function useAppOnboardingState(isSharedIdentity: boolean) {
const queryClient = useQueryClient();
const identityQuery = useIdentityQuery();
const identity = identityQuery.data;
@@ -257,6 +289,7 @@ export function useAppOnboardingState() {
hasExistingProfile: hasRealDisplayName(profileQuery.data?.displayName),
identityIsFetching: identityQuery.fetchStatus === "fetching",
identityStatus: identityQuery.status,
isSharedIdentity,
profileStatus: profileQuery.status,
});
const gateComplete = onboardingGate.complete;
@@ -4,13 +4,7 @@ import { getIdentity } from "@/shared/api/tauri";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import type { Workspace } from "../types";
import {
deriveWorkspaceName,
normalizeRelayUrl,
saveActiveWorkspaceId,
saveWorkspaces,
} from "../workspaceStorage";
import { initFirstWorkspace, deriveWorkspaceName } from "../workspaceStorage";
const LOCAL_RELAY_URL = "ws://localhost:3000";
@@ -35,7 +29,6 @@ export function WelcomeSetup({
return;
}
const normalizedUrl = normalizeRelayUrl(trimmedUrl);
setIsConnecting(true);
setError(null);
@@ -44,17 +37,7 @@ export function WelcomeSetup({
// labels, etc.). The private key lives on disk in `identity.key` and
// is the single source of truth — never copied into localStorage.
const identity = await getIdentity();
const workspace: Workspace = {
id: crypto.randomUUID(),
name: deriveWorkspaceName(normalizedUrl),
relayUrl: normalizedUrl,
pubkey: identity.pubkey,
addedAt: new Date().toISOString(),
};
saveWorkspaces([workspace]);
saveActiveWorkspaceId(workspace.id);
initFirstWorkspace(trimmedUrl, identity.pubkey);
// The reload triggered by onComplete() will re-run useWorkspaceInit,
// which calls applyWorkspace with the saved config. No need to apply here.
@@ -1,12 +1,17 @@
import { useEffect, useRef, useState } from "react";
import { relayClient } from "@/shared/api/relayClient";
import { applyWorkspace, getDefaultRelayUrl } from "@/shared/api/tauri";
import {
applyWorkspace,
getDefaultRelayUrl,
getIdentity,
} from "@/shared/api/tauri";
import { resetMediaCaches } from "@/shared/lib/mediaUrl";
import { clearSearchHitEventCache } from "@/app/navigation/searchHitEventCache";
import { clearAllDrafts } from "@/features/messages/lib/useDrafts";
import { resetAgentObserverStore } from "@/features/agents/observerRelayStore";
import { initFirstWorkspace } from "./workspaceStorage";
import type { Workspace } from "./types";
/**
@@ -43,6 +48,7 @@ type WorkspaceInitResult =
export function useWorkspaceInit(
activeWorkspace: Workspace | null,
workspaceKey: string,
isSharedIdentity: boolean,
): WorkspaceInitResult {
const [result, setResult] = useState<WorkspaceInitResult>({
isReady: false,
@@ -60,9 +66,19 @@ export function useWorkspaceInit(
async function init() {
if (!activeWorkspace) {
// No workspace — need setup
try {
const defaultRelayUrl = await getDefaultRelayUrl();
if (isSharedIdentity) {
const identity = await getIdentity();
if (cancelled) return;
initFirstWorkspace(defaultRelayUrl, identity.pubkey);
if (!cancelled) {
window.location.reload();
}
return;
}
if (!cancelled) {
setResult({
isReady: false,
@@ -136,6 +152,7 @@ export function useWorkspaceInit(
activeWorkspace?.id,
activeWorkspace?.relayUrl,
activeWorkspace?.token,
isSharedIdentity,
workspaceKey,
]);
@@ -79,3 +79,20 @@ export function deriveWorkspaceName(relayUrl: string): string {
return "Workspace";
}
}
export function initFirstWorkspace(
relayUrl: string,
pubkey: string,
): Workspace {
const normalizedUrl = normalizeRelayUrl(relayUrl);
const workspace: Workspace = {
id: crypto.randomUUID(),
name: deriveWorkspaceName(normalizedUrl),
relayUrl: normalizedUrl,
pubkey,
addedAt: new Date().toISOString(),
};
saveWorkspaces([workspace]);
saveActiveWorkspaceId(workspace.id);
return workspace;
}
+4
View File
@@ -519,6 +519,10 @@ export function getDefaultRelayUrl(): Promise<string> {
return invokeTauri<string>("get_default_relay_url");
}
export function isSharedIdentity(): Promise<boolean> {
return invokeTauri<boolean>("is_shared_identity");
}
export function getRelayWsUrl(): Promise<string> {
return invokeTauri<string>("get_relay_ws_url");
}