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,
});
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
+ Loading this server’s identity map before you can send turns. +
+{snapshotQuery.error}
+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.