From 258446cd707b2e44e15964e2eba148735626d14e Mon Sep 17 00:00:00 2001 From: "omegent-app[bot]" <306514130+omegent-app[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:41:27 +0000 Subject: [PATCH 1/4] fix(identity): always surface claim gate when operate is blocked Show Who are you? on active/primary env; force-open after claim-required send failures; mount gate for hosted-static remotes; clearer save CTA. Co-authored-by: Patrick Roza <42661+patroza@users.noreply.github.com> --- apps/web/src/components/ChatView.tsx | 22 ++- .../components/identity/IdentityClaimGate.tsx | 139 +++++++++++++++++- apps/web/src/forkSurfaceExistence.test.ts | 5 + apps/web/src/routes/__root.tsx | 5 +- 4 files changed, 155 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 431b8b785381..3433972bcb50 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -72,6 +72,10 @@ import { import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import { isTransportConnectionErrorMessage } from "@t3tools/client-runtime/errors"; +import { + isIdentityClaimRequiredMessage, + requestIdentityClaimGate, +} from "./identity/IdentityClaimGate"; import { isElectron } from "../env"; import { readLocalApi } from "../localApi"; import { useDiffPanelStore } from "../diffPanelStore"; @@ -5229,17 +5233,19 @@ function ChatViewContent(props: ChatViewProps) { } if (!isAtomCommandInterrupted(failure)) { const error = squashAtomCommandFailure(failure); - setThreadError( - threadIdForSend, - error instanceof Error ? error.message : "Failed to send message.", - ); + const message = error instanceof Error ? error.message : "Failed to send message."; + if (isIdentityClaimRequiredMessage(message)) { + requestIdentityClaimGate(); + } + setThreadError(threadIdForSend, message); } } } catch (error) { - setThreadError( - threadIdForSend, - error instanceof Error ? error.message : "Failed to send message.", - ); + const message = error instanceof Error ? error.message : "Failed to send message."; + if (isIdentityClaimRequiredMessage(message)) { + requestIdentityClaimGate(); + } + setThreadError(threadIdForSend, message); } finally { sendInFlightRef.current = false; if (!turnStartSucceeded && baseBranchForWorktree) { diff --git a/apps/web/src/components/identity/IdentityClaimGate.tsx b/apps/web/src/components/identity/IdentityClaimGate.tsx index cdc709cad35c..01c43e8fb085 100644 --- a/apps/web/src/components/identity/IdentityClaimGate.tsx +++ b/apps/web/src/components/identity/IdentityClaimGate.tsx @@ -8,8 +8,9 @@ import { type EnvironmentId, type IdentityPersonPublic, } from "@t3tools/contracts"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { useActiveEnvironmentId } from "../../state/entities"; import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments"; import { identityEnvironment } from "../../state/identity"; import { useEnvironmentQuery } from "../../state/query"; @@ -18,14 +19,50 @@ import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { IdentityAvatar } from "./IdentityAvatar"; +/** + * Force-open the claim modal (e.g. after a dispatch error). Cleared when claim succeeds. + */ +let forceClaimOpen = false; +const forceClaimListeners = new Set<() => void>(); + +export function requestIdentityClaimGate(): void { + forceClaimOpen = true; + for (const listener of forceClaimListeners) { + listener(); + } +} + +function useForceClaimOpen(): boolean { + const [open, setOpen] = useState(forceClaimOpen); + useEffect(() => { + const listener = () => setOpen(forceClaimOpen); + forceClaimListeners.add(listener); + return () => { + forceClaimListeners.delete(listener); + }; + }, []); + return open; +} + +function clearForceClaimOpen(): void { + forceClaimOpen = false; + for (const listener of forceClaimListeners) { + listener(); + } +} + /** * Full-screen "Who are you?" gate when the active environment has a closed * identity map and this auth session has not claimed yet. */ export function IdentityClaimGate() { + const activeEnvironmentId = useActiveEnvironmentId(); const primaryEnvironmentId = usePrimaryEnvironmentId(); const { environments } = useEnvironments(); + const forceOpen = useForceClaimOpen(); + const environmentId = + activeEnvironmentId ?? primaryEnvironmentId ?? environments.find((env) => env.entry.target._tag === "PrimaryConnectionTarget") ?.environmentId ?? @@ -36,10 +73,13 @@ export function IdentityClaimGate() { return null; } - return ; + return ; } -function IdentityClaimGateForEnvironment(props: { readonly environmentId: EnvironmentId }) { +function IdentityClaimGateForEnvironment(props: { + readonly environmentId: EnvironmentId; + readonly forceOpen: boolean; +}) { const target = useMemo( () => ({ environmentId: props.environmentId, input: {} as const }), [props.environmentId], @@ -65,7 +105,77 @@ function IdentityClaimGateForEnvironment(props: { readonly environmentId: Enviro ); }, [query, snapshotQuery.data]); - if (!needsClaim || !snapshotQuery.data) { + // Show when: map requires claim, or user forced open after a dispatch error. + // Keep showing while loading if forceOpen (so the error path isn't silent). + const showGate = + needsClaim || + (props.forceOpen && (needsClaim || snapshotQuery.isPending || snapshotQuery.data !== null)) || + (props.forceOpen && snapshotQuery.error !== null); + + if (!showGate) { + return null; + } + + // Still loading map/claim — block operate with a clear panel, not a toast. + if (snapshotQuery.isPending && snapshotQuery.data === null) { + return ( +
+
+

+ Checking identity… +

+

+ Loading this server’s identity map before you can send turns. +

+
+
+ ); + } + + if (snapshotQuery.error !== null && snapshotQuery.data === null) { + return ( +
+
+

+ Could not load identity +

+

{snapshotQuery.error}

+
+ +
+
+
+ ); + } + + if (!snapshotQuery.data?.enabled) { + // Map off but forceOpen from a stale error — clear. + if (props.forceOpen) clearForceClaimOpen(); + return null; + } + + if (!needsClaim) { + if (props.forceOpen) clearForceClaimOpen(); return null; } @@ -98,6 +208,7 @@ function IdentityClaimGateForEnvironment(props: { readonly environmentId: Enviro } claimQuery.refresh(); snapshotQuery.refresh(); + clearForceClaimOpen(); } catch (cause) { setError(cause instanceof Error ? cause.message : "Could not claim identity."); } finally { @@ -122,7 +233,9 @@ function IdentityClaimGateForEnvironment(props: { readonly environmentId: Enviro

This server uses a closed identity map. Type at least {IDENTITY_CLAIM_TYPEAHEAD_MIN_CHARS}{" "} - characters of your username, then choose a match. Free-form names are not allowed. + characters of your username (for example{" "} + pat + …), then choose a match. Free-form names are not allowed.