Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
22 changes: 14 additions & 8 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down
139 changes: 132 additions & 7 deletions apps/web/src/components/identity/IdentityClaimGate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 ??
Expand All @@ -36,10 +73,13 @@ export function IdentityClaimGate() {
return null;
}

return <IdentityClaimGateForEnvironment environmentId={environmentId} />;
return <IdentityClaimGateForEnvironment environmentId={environmentId} forceOpen={forceOpen} />;
}

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],
Expand All @@ -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 (
<div
className="fixed inset-0 z-[80] flex items-center justify-center bg-background/92 px-4 backdrop-blur-sm"
data-testid="identity-claim-gate-loading"
role="dialog"
aria-modal="true"
aria-labelledby="identity-claim-title"
>
<section className="w-full max-w-md rounded-2xl border border-border/80 bg-card p-6 shadow-2xl">
<h1 id="identity-claim-title" className="text-xl font-semibold tracking-tight">
Checking identity…
</h1>
<p className="mt-2 text-sm text-muted-foreground">
Loading this server’s identity map before you can send turns.
</p>
</section>
</div>
);
}

if (snapshotQuery.error !== null && snapshotQuery.data === null) {
return (
<div
className="fixed inset-0 z-[80] flex items-center justify-center bg-background/92 px-4 backdrop-blur-sm"
data-testid="identity-claim-gate-error"
role="dialog"
aria-modal="true"
aria-labelledby="identity-claim-title"
>
<section className="w-full max-w-md rounded-2xl border border-border/80 bg-card p-6 shadow-2xl">
<h1 id="identity-claim-title" className="text-xl font-semibold tracking-tight">
Could not load identity
</h1>
<p className="mt-2 text-sm text-muted-foreground">{snapshotQuery.error}</p>
<div className="mt-5 flex justify-end gap-2">
<Button
type="button"
onClick={() => {
snapshotQuery.refresh();
claimQuery.refresh();
}}
>
Retry
</Button>
</div>
</section>
</div>
);
}

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;
}

Expand Down Expand Up @@ -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 {
Expand All @@ -122,7 +233,9 @@ function IdentityClaimGateForEnvironment(props: { readonly environmentId: Enviro
</h1>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
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{" "}
<span className="font-medium text-foreground">pat</span>
…), then choose a match. Free-form names are not allowed.
</p>

<label
Expand Down Expand Up @@ -189,18 +302,30 @@ function IdentityClaimGateForEnvironment(props: { readonly environmentId: Enviro
<p className="mt-2 text-xs text-muted-foreground">No map matches for that query.</p>
) : (
<p className="mt-2 text-xs text-muted-foreground">
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).
</p>
)}

{error ? <p className="mt-3 text-sm text-destructive">{error}</p> : null}

<div className="mt-5 flex justify-end gap-2">
<Button type="button" disabled={submitting} onClick={() => void submitUsername(query)}>
{submitting ? "Claiming…" : "Continue"}
{submitting ? "Saving…" : "Save identity"}
</Button>
</div>
</section>
</div>
);
}

/** 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")
);
}
5 changes: 5 additions & 0 deletions apps/web/src/forkSurfaceExistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-");
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,10 @@ function RootRouteView() {
<HostedStaticEnvironmentBootstrap />
{primaryEnvironmentAuthenticated ? <EventRouter /> : null}
{primaryEnvironmentAuthenticated ? <ProviderUpdateLaunchNotification /> : null}
{primaryEnvironmentAuthenticated ? <IdentityClaimGate /> : null}
{/* Claim gate: primary auth OR hosted-static (paired remotes still need identity). */}
{primaryEnvironmentAuthenticated || authGateState.status === "hosted-static" ? (
<IdentityClaimGate />
) : null}
{appShell}
</AnchoredToastProvider>
</ToastProvider>
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/orchestration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}),
);
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/orchestration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -997,6 +997,7 @@ const ThreadTitleRegenerationCompleteCommand = Schema.Struct({
threadId: ThreadId,
requestId: CommandId,
title: Schema.optional(TrimmedNonEmptyString),
createdAt: IsoDateTime,
});

/**
Expand Down
Loading