fix(desktop): worktree support — workspace boundary + shared identity (#610)

This commit is contained in:
Will Pfleger
2026-05-18 19:11:34 -04:00
committed by GitHub
parent 6e1418e571
commit 50b350dc58
4 changed files with 82 additions and 12 deletions
+2
View File
@@ -1,3 +1,5 @@
[workspace]
[package]
name = "sprout"
version = "0.1.0"
+40 -6
View File
@@ -33,6 +33,7 @@ type OnboardingGateStage = "blocking" | "onboarding" | "ready";
type UseFirstRunOnboardingGateOptions = {
currentPubkey: string | null;
hasExistingProfile: boolean;
identityIsFetching: boolean;
identityStatus: QueryStatus;
profileStatus: QueryStatus;
@@ -125,6 +126,7 @@ function resolveOnboardingGateStage({
export function useFirstRunOnboardingGate({
currentPubkey,
hasExistingProfile,
identityIsFetching,
identityStatus,
profileStatus,
@@ -166,14 +168,37 @@ export function useFirstRunOnboardingGate({
return;
}
// If the relay already has a real profile for this pubkey, the user has
// been onboarded elsewhere — skip the gate and persist the completion so
// future launches in this data dir don't re-check.
if (hasExistingProfile) {
if (typeof window !== "undefined" && currentPubkey) {
window.localStorage.setItem(
onboardingCompletionStorageKey(currentPubkey),
"true",
);
}
}
setGateState((current) =>
updateActiveGateState(current, currentPubkey, (activeGateState) => ({
...activeGateState,
hasSettledCurrentPubkey: true,
isOpen: !activeGateState.hasCompletedCurrentPubkey,
})),
updateActiveGateState(current, currentPubkey, (activeGateState) => {
const alreadyOnboarded =
activeGateState.hasCompletedCurrentPubkey || hasExistingProfile;
return {
...activeGateState,
hasCompletedCurrentPubkey: alreadyOnboarded,
hasSettledCurrentPubkey: true,
isOpen: !alreadyOnboarded,
};
}),
);
}, [currentPubkey, hasSettledCurrentPubkey, identityStatus, profileStatus]);
}, [
currentPubkey,
hasExistingProfile,
hasSettledCurrentPubkey,
identityStatus,
profileStatus,
]);
const skipForNow = React.useCallback(() => {
setGateState((current) =>
@@ -213,6 +238,14 @@ export function useFirstRunOnboardingGate({
};
}
function hasRealDisplayName(displayName?: string | null): boolean {
if (!displayName) return false;
const trimmed = displayName.trim();
if (trimmed.length === 0) return false;
const lower = trimmed.toLowerCase();
return !lower.startsWith("npub1") && !lower.startsWith("nostr:npub1");
}
export function useAppOnboardingState() {
const queryClient = useQueryClient();
const identityQuery = useIdentityQuery();
@@ -221,6 +254,7 @@ export function useAppOnboardingState() {
const profileQuery = useProfileQuery();
const onboardingGate = useFirstRunOnboardingGate({
currentPubkey,
hasExistingProfile: hasRealDisplayName(profileQuery.data?.displayName),
identityIsFetching: identityQuery.fetchStatus === "fetching",
identityStatus: identityQuery.status,
profileStatus: profileQuery.status,
+20 -5
View File
@@ -9,6 +9,10 @@ const BLANK_TYLER_IDENTITY = {
...TEST_IDENTITIES.tyler,
username: "",
};
const FIRST_RUN_ALICE = {
...TEST_IDENTITIES.alice,
username: "",
};
type TestIdentity = {
privateKey: string;
@@ -139,17 +143,16 @@ test("page 1 accepts an avatar URL as the secondary avatar path", async ({
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 seedActiveIdentity(page, FIRST_RUN_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 expect(page.getByTestId("onboarding-display-name")).toHaveValue("");
await expectNoHomeSeenEntries(page);
await page.getByTestId("onboarding-display-name").fill("Alice");
await continueToSetupPage(page);
await expectShellHidden(page);
await expect(page.getByTestId("onboarding-provider-goose")).toBeVisible();
@@ -161,6 +164,17 @@ test("first-run onboarding keeps the shell hidden through both pages and only ma
await expectHomeSeenCount(page, 2);
});
test("existing relay profile auto-skips onboarding without localStorage completion", async ({
page,
}) => {
await seedActiveIdentity(page, TEST_IDENTITIES.alice);
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
await page.goto("/");
await expect(page.getByTestId("onboarding-gate")).toHaveCount(0);
await expect(page.getByTestId("chat-title")).toHaveText("Home");
});
test("finishing onboarding auto-joins the #general channel for a new member", async ({
page,
}) => {
@@ -179,7 +193,7 @@ test("finishing onboarding auto-joins the #general channel for a new member", as
test("page 2 falls back to Doctor guidance when ACP tools are not installed", async ({
page,
}) => {
await seedActiveIdentity(page, TEST_IDENTITIES.alice);
await seedActiveIdentity(page, FIRST_RUN_ALICE);
await installMockBridge(
page,
{
@@ -189,6 +203,7 @@ test("page 2 falls back to Doctor guidance when ACP tools are not installed", as
);
await page.goto("/");
await page.getByTestId("onboarding-display-name").fill("Alice");
await continueToSetupPage(page);
await expect(page.getByTestId("onboarding-acp-empty")).toBeVisible();
await expect(
+20 -1
View File
@@ -5,6 +5,7 @@
# SPROUT_RELAY_PORT, SPROUT_RELAY_URL
# SPROUT_INSTANCE_SLUG, SPROUT_WORKTREE_LABEL, VITE_DEV_BRANCH (worktrees only)
# SPROUT_TAURI_CONFIG
# SPROUT_PRIVATE_KEY (worktrees only, when SPROUT_SHARE_IDENTITY=1)
WORKTREE_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
@@ -24,13 +25,31 @@ unset VITE_DEV_BRANCH
# In worktrees, extract a label from the branch name and derive a unique app
# identity and icon so multiple local desktop instances can run side by side.
#
# Worktree detection: compare --git-dir to --git-common-dir. In the main
# working tree these are identical; in any worktree (whether under .worktrees/,
# .claude/worktrees/, or elsewhere on disk) they differ.
if git rev-parse --is-inside-work-tree &>/dev/null; then
GIT_DIR=$(git rev-parse --git-dir)
if [[ "$GIT_DIR" == *".git/worktrees/"* ]]; then
GIT_COMMON_DIR=$(git rev-parse --git-common-dir 2>/dev/null)
if [[ -n "$GIT_COMMON_DIR" && "$GIT_DIR" != "$GIT_COMMON_DIR" ]]; then
BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD)
export SPROUT_WORKTREE_LABEL="${BRANCH_NAME##*/}"
export SPROUT_INSTANCE_SLUG=$(echo "$BRANCH_NAME" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//' | sed 's/-$//')
# SPROUT_SHARE_IDENTITY=1: reuse the main dev checkout's Nostr key so
# worktrees skip onboarding and share the same identity. The per-worktree
# identifier is kept so concurrent instances don't collide on
# tauri-plugin-single-instance or the app data directory.
if [[ "${SPROUT_SHARE_IDENTITY:-0}" == "1" ]]; then
CANONICAL_KEY="$HOME/Library/Application Support/xyz.block.sprout.app.dev/identity.key"
if [[ -f "$CANONICAL_KEY" ]]; then
export SPROUT_PRIVATE_KEY="$(cat "$CANONICAL_KEY")"
else
echo "⚠ SPROUT_SHARE_IDENTITY=1 but no identity found at $CANONICAL_KEY — run Sprout from repo root first" >&2
fi
fi
ICON_DIR="$(pwd)/src-tauri/target/dev-icons"
mkdir -p "$ICON_DIR"
DEV_ICON="$ICON_DIR/icon.icns"