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}
+
+ {
+ snapshotQuery.refresh();
+ claimQuery.refresh();
+ }}
+ >
+ Retry
+
+
+
+
+ );
+ }
+
+ 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.
No map matches for that query.
) : (
- Type {IDENTITY_CLAIM_TYPEAHEAD_MIN_CHARS}+ characters to search the map.
+ Type {IDENTITY_CLAIM_TYPEAHEAD_MIN_CHARS}+ characters to search the map (
+ {snapshot.people.length} people listed).
)}
@@ -197,10 +311,21 @@ function IdentityClaimGateForEnvironment(props: { readonly environmentId: Enviro
void submitUsername(query)}>
- {submitting ? "Claiming…" : "Continue"}
+ {submitting ? "Saving…" : "Save identity"}
);
}
+
+/** Detect dispatch / operate failures that mean the user must claim. */
+export function isIdentityClaimRequiredMessage(message: string | null | undefined): boolean {
+ if (!message) return false;
+ const lower = message.toLowerCase();
+ return (
+ lower.includes("identity_claim_required") ||
+ lower.includes("choose who you are") ||
+ lower.includes("identity claim")
+ );
+}
diff --git a/apps/web/src/forkSurfaceExistence.test.ts b/apps/web/src/forkSurfaceExistence.test.ts
index c6a5113fc8de..0b2e195a167c 100644
--- a/apps/web/src/forkSurfaceExistence.test.ts
+++ b/apps/web/src/forkSurfaceExistence.test.ts
@@ -112,11 +112,16 @@ describe("fork surface existence (anti stack-drop)", () => {
expect(gate).toContain('data-testid="identity-claim-gate"');
expect(gate).toContain("Who are you?");
expect(gate).toContain("identity-claim-suggestions");
+ expect(gate).toContain("Save identity");
+ expect(gate).toContain("requestIdentityClaimGate");
+ expect(gate).toContain("isIdentityClaimRequiredMessage");
const stack = readSrc("components/identity/ParticipantStack.tsx");
expect(stack).toContain('data-testid="participant-stack"');
expect(stack).toContain('data-testid="source-channel-glyph"');
const root = readSrc("routes/__root.tsx");
expect(root).toContain("IdentityClaimGate");
+ const chat = readSrc("components/ChatView.tsx");
+ expect(chat).toContain("requestIdentityClaimGate");
const sidebarV2 = readSrc("components/SidebarV2.tsx");
expect(sidebarV2).toContain("ParticipantStack");
expect(sidebarV2).toContain("sidebar-v2-ownership-filter-");
diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx
index 08e74579ae83..267912eec9dc 100644
--- a/apps/web/src/routes/__root.tsx
+++ b/apps/web/src/routes/__root.tsx
@@ -137,7 +137,10 @@ function RootRouteView() {
{primaryEnvironmentAuthenticated ? : null}
{primaryEnvironmentAuthenticated ? : null}
- {primaryEnvironmentAuthenticated ? : null}
+ {/* Claim gate: primary auth OR hosted-static (paired remotes still need identity). */}
+ {primaryEnvironmentAuthenticated || authGateState.status === "hosted-static" ? (
+
+ ) : null}
{appShell}
From 9d92f101e78925694e80363faf37d60c8ebc4cb5 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:45:43 +0000
Subject: [PATCH 2/4] =?UTF-8?q?fix(identity):=20green=20Check/Test=20?=
=?UTF-8?q?=E2=80=94=20format,=20contracts=20test,=20wakeups=20type?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Format supervisor.test.ts (Check)
- Add createdAt to title regeneration complete decode test (Test)
- Widen ConnectionWakeup reason type in supervisor harness (typecheck)
Co-authored-by: Patrick Roza <42661+patroza@users.noreply.github.com>
---
packages/contracts/src/orchestration.test.ts | 2 ++
1 file changed, 2 insertions(+)
diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts
index ecf7afa06105..cc35dc4e415e 100644
--- a/packages/contracts/src/orchestration.test.ts
+++ b/packages/contracts/src/orchestration.test.ts
@@ -659,11 +659,13 @@ it.effect("accepts an internal title regeneration completion", () =>
threadId: "thread-1",
requestId: "cmd-title-regenerate",
title: "Updated title",
+ createdAt: "2026-01-01T00:00:00.000Z",
});
assert.strictEqual(parsed.type, "thread.title.regeneration.complete");
if (parsed.type === "thread.title.regeneration.complete") {
assert.strictEqual(parsed.requestId, "cmd-title-regenerate");
assert.strictEqual(parsed.title, "Updated title");
+ assert.strictEqual(parsed.createdAt, "2026-01-01T00:00:00.000Z");
}
}),
);
From 2012946357cb2cb8b9c3e26dedc0a3a777e344b8 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:47:46 +0000
Subject: [PATCH 3/4] fix(identity): green server typecheck for title
regeneration commands
Add createdAt to thread.title.regeneration.complete dispatches and fix
decider fixture to use CommandReadModel HashMaps.
Co-authored-by: Patrick Roza <42661+patroza@users.noreply.github.com>
---
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts | 1 +
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts | 1 +
apps/server/src/orchestration/decider.titleRegeneration.test.ts | 1 +
3 files changed, 3 insertions(+)
diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts
index beb1991e21e5..c1f504618b4a 100644
--- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts
+++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts
@@ -459,6 +459,7 @@ describe("OrchestrationEngine", () => {
threadId: ThreadId.make("thread-archive"),
requestId: CommandId.make("cmd-thread-archive-title-regeneration"),
title: "Stale generated title",
+ createdAt: now(),
}),
);
expect(
diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
index c04cd6e6819d..339ad211f7b1 100644
--- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
+++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
@@ -943,6 +943,7 @@ const make = Effect.gen(function* () {
threadId: input.threadId,
requestId: input.requestId,
...(input.title !== undefined ? { title: input.title } : {}),
+ createdAt: yield* DateTime.now.pipe(Effect.map(DateTime.formatIso)),
});
});
const clearInterruptedThreadTitleRegenerations = Effect.fn(
diff --git a/apps/server/src/orchestration/decider.titleRegeneration.test.ts b/apps/server/src/orchestration/decider.titleRegeneration.test.ts
index dbf67acc14d2..bf75ca52b88b 100644
--- a/apps/server/src/orchestration/decider.titleRegeneration.test.ts
+++ b/apps/server/src/orchestration/decider.titleRegeneration.test.ts
@@ -52,6 +52,7 @@ it.layer(NodeServices.layer)("title regeneration decider", (it) => {
threadId: ThreadId.make("thread-1"),
requestId: CommandId.make("cmd-old-regeneration-request"),
title: "Generated title",
+ createdAt: UPDATED_AT,
},
readModel,
});
From 1ecd09614c61155cf3332305ff8021fa824e272a 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:52:03 +0000
Subject: [PATCH 4/4] fix(identity): include createdAt on title regeneration
complete command
Schema was missing createdAt while dispatchers and contracts tests require it,
which failed Check typecheck and the contracts Test job.
Co-authored-by: Patrick Roza <42661+patroza@users.noreply.github.com>
---
packages/contracts/src/orchestration.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts
index 25bf2cd574c9..149649bdd272 100644
--- a/packages/contracts/src/orchestration.ts
+++ b/packages/contracts/src/orchestration.ts
@@ -997,6 +997,7 @@ const ThreadTitleRegenerationCompleteCommand = Schema.Struct({
threadId: ThreadId,
requestId: CommandId,
title: Schema.optional(TrimmedNonEmptyString),
+ createdAt: IsoDateTime,
});
/**