Settled shelf
diff --git a/apps/web/src/components/board/BoardView.tsx b/apps/web/src/components/board/BoardView.tsx
index b148d812736..6544783b220 100644
--- a/apps/web/src/components/board/BoardView.tsx
+++ b/apps/web/src/components/board/BoardView.tsx
@@ -28,6 +28,10 @@ import { useNavigate } from "@tanstack/react-router";
import * as Schema from "effect/Schema";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import {
+ isIdentityClaimRequiredMessage,
+ requestIdentityClaimGate,
+} from "../identity/IdentityClaimGate";
import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal";
import { isElectron } from "../../env";
import { useNewThreadHandler } from "../../hooks/useHandleNewThread";
@@ -119,16 +123,24 @@ interface BoardThreadGitContext {
}
/** Error toast for a failed thread action; interruptions and successes are silent. */
-function reportThreadActionFailure(result: AtomCommandResult
, title: string) {
+function reportThreadActionFailure(
+ result: AtomCommandResult,
+ title: string,
+ environmentId?: EnvironmentId | null,
+) {
if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) {
return;
}
const error = squashAtomCommandFailure(result);
+ const message = error instanceof Error ? error.message : "An error occurred.";
+ if (isIdentityClaimRequiredMessage(message)) {
+ requestIdentityClaimGate(environmentId);
+ }
toastManager.add(
stackedThreadToast({
type: "error",
title,
- description: error instanceof Error ? error.message : "An error occurred.",
+ description: message,
}),
);
}
@@ -763,6 +775,7 @@ function BoardContent() {
reportThreadActionFailure(
result,
clicked === "settle" ? "Failed to settle thread" : "Failed to un-settle thread",
+ threadRef.environmentId,
);
return;
}
diff --git a/apps/web/src/components/chat/ComposerBannerStack.tsx b/apps/web/src/components/chat/ComposerBannerStack.tsx
index 75d81aa03da..697bbdb9062 100644
--- a/apps/web/src/components/chat/ComposerBannerStack.tsx
+++ b/apps/web/src/components/chat/ComposerBannerStack.tsx
@@ -25,7 +25,7 @@ const exitTransitionStyle = {
export interface ComposerBannerStackItem {
readonly id: string;
- readonly variant: "default" | "error" | "info" | "success" | "warning";
+ readonly variant: "error" | "info" | "success" | "warning";
readonly icon: ReactNode;
readonly title: ReactNode;
readonly description?: ReactNode;
diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx
index 6593acd8e1f..a9f910ec064 100644
--- a/apps/web/src/components/chat/TraitsPicker.tsx
+++ b/apps/web/src/components/chat/TraitsPicker.tsx
@@ -476,13 +476,7 @@ export const TraitsPicker = memo(function TraitsPicker({
});
const fastModeIcon = showFastModeIcon ? (
<>
-
+
Fast mode on
>
) : null;
diff --git a/apps/web/src/components/identity/IdentityAvatar.tsx b/apps/web/src/components/identity/IdentityAvatar.tsx
new file mode 100644
index 00000000000..cee49d7b440
--- /dev/null
+++ b/apps/web/src/components/identity/IdentityAvatar.tsx
@@ -0,0 +1,45 @@
+import { identityAvatar } from "@t3tools/shared/identityAvatar";
+import { cn } from "~/lib/utils";
+
+export function IdentityAvatar(props: {
+ readonly personId?: string | null | undefined;
+ readonly username?: string | null | undefined;
+ readonly name?: string | null | undefined;
+ readonly size?: "micro" | "sm" | "md";
+ readonly className?: string;
+ readonly highlighted?: boolean;
+ /** `null` suppresses the native title when a parent owns richer tooltip content. */
+ readonly title?: string | null;
+}) {
+ const model = identityAvatar({
+ personId: props.personId,
+ username: props.username,
+ name: props.name,
+ });
+ const sizeClass =
+ props.size === "md"
+ ? "size-7 text-[11px]"
+ : props.size === "sm"
+ ? "size-6 text-[10px]"
+ : "size-3.5 text-[8px]";
+
+ const title = props.title === null ? undefined : (props.title ?? model.label);
+
+ return (
+
+ {model.initials}
+
+ );
+}
diff --git a/apps/web/src/components/identity/IdentityClaimGate.tsx b/apps/web/src/components/identity/IdentityClaimGate.tsx
new file mode 100644
index 00000000000..11710c4556e
--- /dev/null
+++ b/apps/web/src/components/identity/IdentityClaimGate.tsx
@@ -0,0 +1,377 @@
+import {
+ filterPeopleForTypeahead,
+ identityClaimRequired,
+} from "@t3tools/client-runtime/state/identity";
+import {
+ IDENTITY_CLAIM_TYPEAHEAD_MIN_CHARS,
+ IdentityUsername,
+ type EnvironmentId,
+ type IdentityPersonPublic,
+} from "@t3tools/contracts";
+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";
+import { useAtomCommand } from "../../state/use-atom-command";
+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). Optionally target
+ * the environment that rejected the operate (critical for multi-env: primary
+ * smart has no map while secondary t3vm requires claim).
+ */
+let forceClaimOpen = false;
+let forceClaimEnvironmentId: EnvironmentId | null = null;
+const forceClaimListeners = new Set<() => void>();
+
+export function requestIdentityClaimGate(environmentId?: EnvironmentId | null): void {
+ forceClaimOpen = true;
+ forceClaimEnvironmentId = environmentId ?? null;
+ for (const listener of forceClaimListeners) {
+ listener();
+ }
+}
+
+function useForceClaimState(): {
+ readonly open: boolean;
+ readonly environmentId: EnvironmentId | null;
+} {
+ const [state, setState] = useState({
+ open: forceClaimOpen,
+ environmentId: forceClaimEnvironmentId,
+ });
+ useEffect(() => {
+ const listener = () =>
+ setState({ open: forceClaimOpen, environmentId: forceClaimEnvironmentId });
+ forceClaimListeners.add(listener);
+ return () => {
+ forceClaimListeners.delete(listener);
+ };
+ }, []);
+ return state;
+}
+
+function clearForceClaimOpen(): void {
+ forceClaimOpen = false;
+ forceClaimEnvironmentId = null;
+ for (const listener of forceClaimListeners) {
+ listener();
+ }
+}
+
+/**
+ * Full-screen "Who are you?" gate when *any* connected environment has a
+ * closed identity map and this auth session has not claimed there yet.
+ *
+ * Multi-env: primary may be smart (no map) while secondary is t3vm (map on).
+ * Gate every environment that requires a claim, not only primary/active.
+ */
+export function IdentityClaimGate() {
+ const activeEnvironmentId = useActiveEnvironmentId();
+ const primaryEnvironmentId = usePrimaryEnvironmentId();
+ const { environments } = useEnvironments();
+ const force = useForceClaimState();
+
+ const orderedEnvironmentIds = useMemo(() => {
+ const ids: EnvironmentId[] = [];
+ const add = (id: EnvironmentId | null | undefined) => {
+ if (id !== null && id !== undefined && !ids.includes(id)) {
+ ids.push(id);
+ }
+ };
+ // Forced env first so settle/send errors open the right dialog.
+ add(force.environmentId);
+ add(activeEnvironmentId);
+ add(primaryEnvironmentId);
+ for (const environment of environments) {
+ add(environment.environmentId);
+ }
+ return ids;
+ }, [activeEnvironmentId, environments, force.environmentId, primaryEnvironmentId]);
+
+ if (orderedEnvironmentIds.length === 0) {
+ return null;
+ }
+
+ // One gate body per env (hooks). Only the first that needs claim / force
+ // renders a modal (others return null).
+ return (
+ <>
+ {orderedEnvironmentIds.map((environmentId) => {
+ const label =
+ environments.find((env) => env.environmentId === environmentId)?.label ?? null;
+ const forceOpen =
+ force.open && (force.environmentId === null || force.environmentId === environmentId);
+ return (
+
+ );
+ })}
+ >
+ );
+}
+
+function IdentityClaimGateForEnvironment(props: {
+ readonly environmentId: EnvironmentId;
+ readonly environmentLabel: string | null;
+ readonly forceOpen: boolean;
+}) {
+ const target = useMemo(
+ () => ({ environmentId: props.environmentId, input: {} as const }),
+ [props.environmentId],
+ );
+ const snapshotQuery = useEnvironmentQuery(identityEnvironment.snapshot(target));
+ const claimQuery = useEnvironmentQuery(identityEnvironment.sessionClaim(target));
+ const claimCommand = useAtomCommand(identityEnvironment.claim, {
+ label: "identity-claim",
+ reportFailure: true,
+ });
+
+ const needsClaim = identityClaimRequired(snapshotQuery.data, claimQuery.data);
+ const [query, setQuery] = useState("");
+ const [error, setError] = useState(null);
+ const [submitting, setSubmitting] = useState(false);
+
+ const suggestions = useMemo(() => {
+ if (!snapshotQuery.data) return [] as ReadonlyArray;
+ return filterPeopleForTypeahead(
+ snapshotQuery.data.people,
+ query,
+ IDENTITY_CLAIM_TYPEAHEAD_MIN_CHARS,
+ );
+ }, [query, 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 on *this* env (e.g. smart). Do NOT clear forceOpen — a sibling
+ // env (t3vm) may still need the claim dialog.
+ return null;
+ }
+
+ if (!needsClaim) {
+ // Already claimed on this env; clear force only when we targeted it.
+ if (props.forceOpen) clearForceClaimOpen();
+ return null;
+ }
+
+ const snapshot = snapshotQuery.data;
+ const envLabel = props.environmentLabel?.trim() || "this environment";
+
+ const submitUsername = async (username: string) => {
+ const normalized = username.trim().toLowerCase();
+ if (normalized.length === 0) {
+ setError("Type your username, then pick a match from the list.");
+ return;
+ }
+ const exact = snapshot.people.find((person) => person.username === normalized);
+ if (!exact) {
+ setError("That identity is not on this server’s map. Keep typing to see matches.");
+ return;
+ }
+ setSubmitting(true);
+ setError(null);
+ try {
+ const result = await claimCommand({
+ environmentId: props.environmentId,
+ input: {
+ username: IdentityUsername.make(exact.username),
+ method: "typeahead",
+ },
+ });
+ if (result._tag === "Failure") {
+ setError("Could not claim identity on this server.");
+ return;
+ }
+ claimQuery.refresh();
+ snapshotQuery.refresh();
+ clearForceClaimOpen();
+ } catch (cause) {
+ setError(cause instanceof Error ? cause.message : "Could not claim identity.");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
+ Shared environment · {envLabel}
+
+
+ Who are you?
+
+
+ {envLabel} uses a closed identity
+ map. Type at least {IDENTITY_CLAIM_TYPEAHEAD_MIN_CHARS} characters of your username (for
+ example pat
+ …), then choose a match. Free-form names are not allowed.
+
+
+
+ {
+ setQuery(event.currentTarget.value);
+ setError(null);
+ }}
+ onKeyDown={(event) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ void submitUsername(query);
+ }
+ }}
+ />
+
+ {suggestions.length > 0 ? (
+
+ {suggestions.map((person) => (
+ -
+
+
+ ))}
+
+ ) : query.trim().length >= IDENTITY_CLAIM_TYPEAHEAD_MIN_CHARS ? (
+ No map matches for that query.
+ ) : (
+
+ Type {IDENTITY_CLAIM_TYPEAHEAD_MIN_CHARS}+ characters to search the map (
+ {snapshot.people.length} people listed).
+
+ )}
+
+ {error ? {error}
: null}
+
+
+
+
+
+
+ );
+}
+
+/** 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/components/identity/ParticipantStack.logic.test.ts b/apps/web/src/components/identity/ParticipantStack.logic.test.ts
new file mode 100644
index 00000000000..2f9e4af99ba
--- /dev/null
+++ b/apps/web/src/components/identity/ParticipantStack.logic.test.ts
@@ -0,0 +1,28 @@
+import { IdentityUsername, PersonId } from "@t3tools/contracts";
+import { describe, expect, it } from "vite-plus/test";
+import { participantDisplayLabel } from "./ParticipantStack.logic";
+
+describe("participantDisplayLabel", () => {
+ it("shows all devices for one person in first-seen order", () => {
+ expect(
+ participantDisplayLabel({
+ personId: PersonId.make("patroza"),
+ username: IdentityUsername.make("patroza"),
+ firstChannel: "desktop",
+ channels: ["desktop", "discord"],
+ firstParticipatedAt: "2026-07-30T12:00:00.000Z",
+ }),
+ ).toBe("patroza@desktop,discord");
+ });
+
+ it("supports summaries persisted before channel lists were added", () => {
+ expect(
+ participantDisplayLabel({
+ personId: PersonId.make("patroza"),
+ username: IdentityUsername.make("patroza"),
+ firstChannel: "discord",
+ firstParticipatedAt: "2026-07-30T12:00:00.000Z",
+ }),
+ ).toBe("patroza@discord");
+ });
+});
diff --git a/apps/web/src/components/identity/ParticipantStack.logic.ts b/apps/web/src/components/identity/ParticipantStack.logic.ts
new file mode 100644
index 00000000000..0a2a9024810
--- /dev/null
+++ b/apps/web/src/components/identity/ParticipantStack.logic.ts
@@ -0,0 +1,7 @@
+import type { ThreadParticipantSummary } from "@t3tools/contracts";
+
+export function participantDisplayLabel(person: ThreadParticipantSummary): string {
+ const channels =
+ person.channels ?? (person.firstChannel === undefined ? [] : [person.firstChannel]);
+ return channels.length === 0 ? person.username : `${person.username}@${channels.join(",")}`;
+}
diff --git a/apps/web/src/components/identity/ParticipantStack.tsx b/apps/web/src/components/identity/ParticipantStack.tsx
new file mode 100644
index 00000000000..979e6a2d4c1
--- /dev/null
+++ b/apps/web/src/components/identity/ParticipantStack.tsx
@@ -0,0 +1,139 @@
+import { useAtomValue } from "@effect/atom-react";
+import {
+ claimPersonIdForEnvironment,
+ isClaimedNonStarterParticipant,
+} from "@t3tools/client-runtime/state/identity";
+import type { ThreadParticipantSummary } from "@t3tools/contracts";
+import { identityClaimPersonIdByEnvironmentAtom } from "../../state/identity";
+import { IdentityAvatar } from "./IdentityAvatar";
+import { participantDisplayLabel } from "./ParticipantStack.logic";
+import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
+import { cn } from "~/lib/utils";
+
+/**
+ * Creator face + +N extras for thread list rows.
+ * Hover/focus expands remaining participants (design: participant stack).
+ */
+export function ParticipantStack(props: {
+ readonly environmentId: string;
+ readonly participants: ReadonlyArray;
+ readonly className?: string;
+}) {
+ const people = props.participants;
+ const claimPersonIdByEnvironment = useAtomValue(identityClaimPersonIdByEnvironmentAtom);
+ const claimPersonId = claimPersonIdForEnvironment(
+ claimPersonIdByEnvironment,
+ props.environmentId,
+ );
+ const youParticipated = isClaimedNonStarterParticipant({
+ claimPersonId,
+ participants: people,
+ });
+ if (people.length === 0) return null;
+
+ const lead = people[0]!;
+ const extras = people.slice(1);
+ const label =
+ extras.length === 0
+ ? `Started by ${lead.username}`
+ : `Started by ${lead.username}, ${extras.length} other participant${extras.length === 1 ? "" : "s"}`;
+ const accessibleLabel = youParticipated ? `${label}. You participated` : label;
+
+ const stack = (
+
+
+ {extras.length > 0 ? (
+
+ +{extras.length}
+
+ ) : null}
+
+ );
+
+ return (
+
+
+
+ {people.map((person) => (
+
+
+
+ {participantDisplayLabel(person)}
+ {person.personId === claimPersonId ? (
+ · You
+ ) : null}
+
+
+ ))}
+
+
+ );
+}
+
+export function SourceChannelGlyph(props: {
+ readonly channel: string | null | undefined;
+ readonly className?: string;
+}) {
+ if (!props.channel) return null;
+ const short =
+ props.channel === "desktop"
+ ? "D"
+ : props.channel === "web"
+ ? "W"
+ : props.channel === "mobile"
+ ? "M"
+ : props.channel === "discord"
+ ? "Δ"
+ : props.channel === "jira"
+ ? "J"
+ : props.channel === "github"
+ ? "G"
+ : props.channel.slice(0, 1).toUpperCase();
+ return (
+
+ {short}
+
+ );
+}
diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx
index ca45ec74ecd..30619db4fde 100644
--- a/apps/web/src/components/settings/ConnectionsSettings.tsx
+++ b/apps/web/src/components/settings/ConnectionsSettings.tsx
@@ -1445,6 +1445,7 @@ function SavedBackendListRow({
@@ -3021,6 +3022,7 @@ export function ConnectionsSettings() {
primaryServerUpdateState.status !== "idle" ? (
) : primaryVersionMismatch ? (
diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx
index c5c35ad4d35..f59226bb5a8 100644
--- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx
+++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx
@@ -14,7 +14,6 @@ import {
shouldShowDesktopUpdateButton,
shouldToastDesktopUpdateActionResult,
} from "../desktopUpdate.logic";
-import { showDesktopUpdateDownloadedToast } from "../desktopUpdate.toast";
import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
import { Separator } from "../ui/separator";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
@@ -81,7 +80,11 @@ export function SidebarUpdatePill() {
.downloadUpdate()
.then((result) => {
if (result.completed) {
- showDesktopUpdateDownloadedToast(bridge, result.state);
+ toastManager.add({
+ type: "success",
+ title: "Update downloaded",
+ description: "Restart the app from the update button to install it.",
+ });
}
if (!shouldToastDesktopUpdateActionResult(result)) return;
const actionError = getDesktopUpdateActionError(result);
diff --git a/apps/web/src/forkSurfaceExistence.test.ts b/apps/web/src/forkSurfaceExistence.test.ts
index 0a4d03a10d1..98990a0b463 100644
--- a/apps/web/src/forkSurfaceExistence.test.ts
+++ b/apps/web/src/forkSurfaceExistence.test.ts
@@ -130,4 +130,47 @@ describe("fork surface existence (anti stack-drop)", () => {
expect(chips).toContain('aria-label="Edit queued message"');
expect(chips).toContain("Steer: send now, interrupting the current step");
});
+
+ it("identity claim gate and participant stack surfaces exist", () => {
+ const gate = readSrc("components/identity/IdentityClaimGate.tsx");
+ 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");
+ // Multi-env: claim gate must not only target primary (smart-without-map + t3vm).
+ expect(gate).toContain("forceClaimEnvironmentId");
+ expect(gate).toContain("orderedEnvironmentIds");
+ const stack = readSrc("components/identity/ParticipantStack.tsx");
+ expect(stack).toContain('data-testid="participant-stack"');
+ expect(stack).toContain('data-testid="participant-stack-popup"');
+ expect(stack).toContain(" {
shortcutLabelForCommand(DEFAULT_BINDINGS, "commandPalette.toggle", "MacIntel"),
"⌘K",
);
- assert.strictEqual(
- shortcutLabelForCommand(DEFAULT_BINDINGS, "filePicker.toggle", "MacIntel"),
- "⌘P",
- );
- assert.strictEqual(
- shortcutLabelForCommand(DEFAULT_BINDINGS, "projectSearch.toggle", "MacIntel"),
- "⇧⌘F",
- );
assert.strictEqual(
shortcutLabelForCommand(DEFAULT_BINDINGS, "modelPicker.toggle", "Linux"),
"Ctrl+Shift+M",
@@ -542,40 +524,6 @@ describe("chat/editor shortcuts", () => {
);
});
- it("matches filePicker.toggle shortcut outside terminal focus", () => {
- assert.strictEqual(
- resolveShortcutCommand(event({ key: "p", metaKey: true }), DEFAULT_BINDINGS, {
- platform: "MacIntel",
- context: { terminalFocus: false },
- }),
- "filePicker.toggle",
- );
- assert.notStrictEqual(
- resolveShortcutCommand(event({ key: "p", metaKey: true }), DEFAULT_BINDINGS, {
- platform: "MacIntel",
- context: { terminalFocus: true },
- }),
- "filePicker.toggle",
- );
- });
-
- it("matches projectSearch.toggle shortcut outside terminal focus", () => {
- assert.strictEqual(
- resolveShortcutCommand(event({ key: "f", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, {
- platform: "MacIntel",
- context: { terminalFocus: false },
- }),
- "projectSearch.toggle",
- );
- assert.notStrictEqual(
- resolveShortcutCommand(event({ key: "f", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, {
- platform: "MacIntel",
- context: { terminalFocus: true },
- }),
- "projectSearch.toggle",
- );
- });
-
it("matches diff.toggle shortcut outside terminal focus", () => {
assert.isTrue(
isDiffToggleShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, {
diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx
index c07ac97e5fa..32f2120b131 100644
--- a/apps/web/src/routes/__root.tsx
+++ b/apps/web/src/routes/__root.tsx
@@ -19,6 +19,7 @@ import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstall
import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog";
import { OmegentDeepLinkCoordinator } from "../components/OmegentDeepLinkCoordinator";
import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification";
+import { IdentityClaimGate } from "../components/identity/IdentityClaimGate";
import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator";
import { hasThreadDeepLinkIntent } from "../deepLinkStore";
import { Button } from "../components/ui/button";
@@ -139,6 +140,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}
diff --git a/apps/web/src/state/identity.ts b/apps/web/src/state/identity.ts
new file mode 100644
index 00000000000..2f808489cba
--- /dev/null
+++ b/apps/web/src/state/identity.ts
@@ -0,0 +1,34 @@
+import { createIdentityEnvironmentAtoms } from "@t3tools/client-runtime/state/identity";
+import type { EnvironmentId } from "@t3tools/contracts";
+import * as Option from "effect/Option";
+import { AsyncResult, Atom } from "effect/unstable/reactivity";
+
+import { environmentCatalog } from "../connection/catalog";
+import { connectionAtomRuntime } from "../connection/runtime";
+
+export const identityEnvironment = createIdentityEnvironmentAtoms(connectionAtomRuntime);
+
+const EMPTY_CLAIM_INPUT = {} as const;
+
+/**
+ * Session claim personId per connected environment.
+ *
+ * Ownership filters must key by the *thread's* environment, not primary.
+ * Desktop primary=smart (no map) while secondary=t3vm (claimed) must still
+ * Mine-filter t3vm threads correctly.
+ */
+export const identityClaimPersonIdByEnvironmentAtom = Atom.make((get) => {
+ const catalog = get(environmentCatalog.catalogValueAtom);
+ const out = new Map();
+ for (const environmentId of catalog.entries.keys()) {
+ const result = get(
+ identityEnvironment.sessionClaim({
+ environmentId: environmentId as EnvironmentId,
+ input: EMPTY_CLAIM_INPUT,
+ }),
+ );
+ const claimResult = Option.getOrNull(AsyncResult.value(result));
+ out.set(environmentId, claimResult?.claim?.personId ?? null);
+ }
+ return out;
+}).pipe(Atom.withLabel("web-identity-claim-person-by-environment"));
diff --git a/docs/user/jira-issue-conversations.md b/docs/user/jira-issue-conversations.md
index 5688d9681ca..35cd68e887b 100644
--- a/docs/user/jira-issue-conversations.md
+++ b/docs/user/jira-issue-conversations.md
@@ -181,9 +181,35 @@ are logged and never block the turn.
Responses are posted as issue comments authored by the service account, preferably as a
**threaded reply** under the triggering mention (REST body field `parentId` — supported on Jira
-Cloud even though it is lightly documented). When the user mentioned the bot inside an existing
-reply thread, the bridge parents under that thread’s **root** (Jira rejects nesting under a child).
-If threading is rejected (invalid parent), the bridge falls back once to a top-level comment.
+Cloud even though it is lightly documented). The body **@-mentions** the human requester via an ADF `mention` node:
+
+```json
+{
+ "type": "mention",
+ "attrs": {
+ "id": "",
+ "text": "@Display Name",
+ "accessLevel": ""
+ }
+}
+```
+
+`attrs.id` is the **bare** account id from the webhook author (e.g. `6331c323…` or
+`712020:uuid`) — the same shape returned by GET comment bodies on this site — not the wiki
+`[~accountid:…]` form and not plain `@Name` text alone.
+
+**Inline threading (required for reliable replies):** Jira Cloud only accepts children under a
+**root** comment. POST with `parentId` set to a nested reply id returns **400**
+(`Parent comment not found, and no child comments exist`) — the bridge used to fall back flat.
+Before posting, `JiraAppClient` GETs `/rest/api/3/issue/{key}/comment/{id}` and uses that
+comment’s `parentId` when set (else the id itself). Webhooks should send either `comment.parentId`
+(REST shape) or `comment.parent.id` when known; empty Automation fields are ignored. Only if every
+resolved root is rejected does the bridge fall back to a top-level comment (logged as error when
+the create response lacks `parentId`).
+
+**Busy threads:** follow-up mentions always dispatch `thread.turn.start`. Orchestration queues the
+message while a turn is running; the bridge does **not** post an “already working” short-circuit.
+The reply still posts asynchronously when the turn completes (`bridgeTurn` is fork-detached).
Prefer Markdown converted to a minimal ADF document for API v3. Do not @-spam watchers unless the
agent explicitly mentions users.
diff --git a/infra/relay/README.md b/infra/relay/README.md
index 0085c9c5b6b..114d5e9b07f 100644
--- a/infra/relay/README.md
+++ b/infra/relay/README.md
@@ -1,7 +1,7 @@
# T3 Connect Relay
-> [!NOTE]
-> Sign in to T3 Connect from the app under Settings > Connections.
+> [!WARNING]
+> T3 Connect is currently in private beta. Join the waitlist in the app under Settings > T3 Connect.
The relay is the hosted control plane for T3 Connect. It helps clients discover and connect to
remote environments, manages the cloud-side records needed for those connections, and delivers
@@ -9,7 +9,7 @@ optional mobile notifications and Live Activities.
The relay is intentionally not in the hot path for normal T3 Code traffic. After a client connects,
regular API and WebSocket traffic goes directly between that client and the selected environment.
-See the [T3 Connect architecture overview](../../docs/internals/t3-code-connect-auth-flow.html) for the larger system
+See the [T3 Connect architecture overview](../../docs/cloud/t3-code-connect-auth-flow.html) for the larger system
design.
## Responsibilities
@@ -25,7 +25,7 @@ The relay currently owns:
- Persisting relay state and exposing relay-specific traces for diagnostics.
The environment server and relay have separate credentials and trust boundaries. Read
-[Environment Authentication Profile](../../docs/internals/environment-auth.md) before changing token,
+[Environment Authentication Profile](../../docs/environment-auth.md) before changing token,
credential, or authorization behavior.
## Code Map
@@ -159,8 +159,8 @@ and hosted web builds.
See:
-- [T3 Connect Clerk Setup](../../docs/internals/t3-connect.md) for Clerk keys, JWT templates, and sign-up restrictions
+- [T3 Connect Clerk Setup](../../docs/cloud/t3-connect-clerk.md) for Clerk keys, JWT templates, and waitlist
setup.
-- [Relay Observability](../../docs/operations/relay-observability.md) for deployment tracing and diagnostics.
-- [T3 Connect Architecture Overview](../../docs/internals/t3-code-connect-auth-flow.html) for the full link,
+- [Relay Observability](../../docs/relay-observability.md) for deployment tracing and diagnostics.
+- [T3 Connect Architecture Overview](../../docs/cloud/t3-code-connect-auth-flow.html) for the full link,
connect, endpoint, and notification flows.
diff --git a/infra/relay/src/environments/EnvironmentConnector.ts b/infra/relay/src/environments/EnvironmentConnector.ts
index d840f809e5a..db662aee94d 100644
--- a/infra/relay/src/environments/EnvironmentConnector.ts
+++ b/infra/relay/src/environments/EnvironmentConnector.ts
@@ -13,7 +13,6 @@ import {
RelayEnvironmentMintResponse,
RelayEnvironmentMintResponseProofPayload,
RelayCloudMintCredentialProofPayload,
- RelayEnvironmentConnectNotAuthorizedReason,
type RelayEnvironmentConnectResponse,
type RelayEnvironmentStatusResponse,
} from "@t3tools/contracts/relay";
@@ -45,8 +44,21 @@ import * as ManagedEndpointAllocations from "./ManagedEndpointAllocations.ts";
import * as RelayConfiguration from "../Config.ts";
import { isManagedEndpointHostname } from "../deploymentConfig.ts";
+export const EnvironmentConnectNotAuthorizedReason = Schema.Literals([
+ "client_proof_key_thumbprint_missing",
+ "environment_link_not_found",
+ "endpoint_provider_not_managed",
+ "managed_endpoint_allocation_not_found",
+ "managed_endpoint_base_domain_not_configured",
+ "managed_endpoint_allocation_not_ready",
+ "managed_endpoint_hostname_invalid",
+ "managed_endpoint_mismatch",
+]);
+export type EnvironmentConnectNotAuthorizedReason =
+ typeof EnvironmentConnectNotAuthorizedReason.Type;
+
function environmentConnectNotAuthorizedReasonMessage(
- reason: RelayEnvironmentConnectNotAuthorizedReason,
+ reason: EnvironmentConnectNotAuthorizedReason,
): string {
switch (reason) {
case "client_proof_key_thumbprint_missing":
@@ -73,7 +85,7 @@ export class EnvironmentConnectNotAuthorized extends Schema.TaggedErrorClass
+ EnvironmentConnectNotAuthorized: (_error, traceId) =>
new RelayEnvironmentConnectNotAuthorizedError({
code: "environment_connect_not_authorized",
- reason: error.reason,
traceId,
}),
EnvironmentMintRequestFailed: (_error, traceId) =>
@@ -821,10 +820,9 @@ export const dpopClientApi = HttpApiBuilder.group(
},
mapRelayCommonApiErrors("invalid_dpop"),
mapErrorTags({
- EnvironmentConnectNotAuthorized: (error, traceId) =>
+ EnvironmentConnectNotAuthorized: (_error, traceId) =>
new RelayEnvironmentConnectNotAuthorizedError({
code: "environment_connect_not_authorized",
- reason: error.reason,
traceId,
}),
EnvironmentMintRequestFailed: (_error, traceId) =>
diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json
index 6889fe64248..695a92dc772 100644
--- a/packages/client-runtime/package.json
+++ b/packages/client-runtime/package.json
@@ -47,6 +47,10 @@
"types": "./src/state/aiUsagePresentation.ts",
"default": "./src/state/aiUsagePresentation.ts"
},
+ "./state/identity": {
+ "types": "./src/state/identity.ts",
+ "default": "./src/state/identity.ts"
+ },
"./state/auth": {
"types": "./src/state/auth.ts",
"default": "./src/state/auth.ts"
diff --git a/packages/client-runtime/src/rpc/session.test.ts b/packages/client-runtime/src/rpc/session.test.ts
index 147843eb589..21f25c75699 100644
--- a/packages/client-runtime/src/rpc/session.test.ts
+++ b/packages/client-runtime/src/rpc/session.test.ts
@@ -336,40 +336,6 @@ describe("RpcSessionFactory", () => {
}),
);
- it.effect("reaches ready when a newer server sends unknown config members", () =>
- Effect.gen(function* () {
- const { factory, sockets } = yield* makeFactory();
- const session = yield* factory.connect(PREPARED);
- const readyFiber = yield* Effect.forkChild(session.ready);
- const socket = yield* awaitSocket(sockets);
- socket.open();
-
- const shortcut = {
- key: "p",
- metaKey: false,
- ctrlKey: false,
- shiftKey: false,
- altKey: false,
- modKey: true,
- };
- yield* completeInitialConfig(socket, {
- ...ENCODED_SERVER_CONFIG,
- keybindings: [
- { command: "someFuture.toggle", shortcut },
- { command: "terminal.toggle", shortcut },
- ],
- issues: [{ kind: "keybindings.future-issue", message: "From a newer server" }],
- availableEditors: ["some-future-editor", "zed"],
- });
- yield* Fiber.join(readyFiber);
-
- const config = yield* session.initialConfig;
- expect(config.keybindings).toEqual([{ command: "terminal.toggle", shortcut }]);
- expect(config.issues).toEqual([]);
- expect(config.availableEditors).toEqual(["zed"]);
- }),
- );
-
it.effect("uses the legacy config RPC for probes when the server lacks the capability", () =>
Effect.scoped(
Effect.gen(function* () {
diff --git a/packages/client-runtime/src/state/identity.test.ts b/packages/client-runtime/src/state/identity.test.ts
new file mode 100644
index 00000000000..306f81cc0f4
--- /dev/null
+++ b/packages/client-runtime/src/state/identity.test.ts
@@ -0,0 +1,351 @@
+import { IdentityUsername, PersonId, type ThreadParticipantSummary } from "@t3tools/contracts";
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ claimPersonIdForEnvironment,
+ filterPeopleForTypeahead,
+ identityClaimRequired,
+ isClaimedNonStarterParticipant,
+ threadMatchesMine,
+} from "./identity.ts";
+
+describe("identityClaimRequired", () => {
+ it("is false when identity is off", () => {
+ expect(
+ identityClaimRequired({ enabled: false, claimRequired: false, people: [] }, { claim: null }),
+ ).toBe(false);
+ });
+
+ it("is true when map enabled and no claim", () => {
+ expect(
+ identityClaimRequired(
+ {
+ enabled: true,
+ claimRequired: true,
+ people: [
+ {
+ personId: "patroza" as never,
+ username: "patroza" as never,
+ links: {},
+ },
+ ],
+ },
+ { claim: null },
+ ),
+ ).toBe(true);
+ });
+});
+
+describe("filterPeopleForTypeahead", () => {
+ const people = [
+ {
+ personId: "patroza" as never,
+ username: "patroza" as never,
+ name: "Patrick Roza",
+ links: {},
+ },
+ {
+ personId: "julius" as never,
+ username: "julius" as never,
+ name: "Julius",
+ links: {},
+ },
+ ];
+
+ it("requires min chars", () => {
+ expect(filterPeopleForTypeahead(people, "pa", 3)).toEqual([]);
+ });
+
+ it("matches username and name after min chars", () => {
+ expect(filterPeopleForTypeahead(people, "pat", 3).map((p) => p.username)).toEqual(["patroza"]);
+ expect(filterPeopleForTypeahead(people, "roza", 3).map((p) => p.username)).toEqual(["patroza"]);
+ });
+});
+
+describe("threadMatchesMine", () => {
+ it("filters mine vs theirs when threads are person-attributed", () => {
+ expect(
+ threadMatchesMine({
+ claimPersonId: "patroza",
+ originPersonId: "patroza",
+ mode: "mine",
+ }),
+ ).toBe(true);
+ expect(
+ threadMatchesMine({
+ claimPersonId: "patroza",
+ originPersonId: "julius",
+ mode: "mine",
+ }),
+ ).toBe(false);
+ expect(
+ threadMatchesMine({
+ claimPersonId: "patroza",
+ originPersonId: "julius",
+ mode: "theirs",
+ }),
+ ).toBe(true);
+ });
+
+ it("treats unattributed threads as mine (legacy / channel-only / identity off)", () => {
+ // No origin or participants — old threads, { channel: "desktop" } only, etc.
+ expect(
+ threadMatchesMine({
+ claimPersonId: "patroza",
+ originPersonId: null,
+ participantPersonIds: [],
+ mode: "mine",
+ }),
+ ).toBe(true);
+ expect(
+ threadMatchesMine({
+ claimPersonId: "patroza",
+ originPersonId: null,
+ participantPersonIds: [],
+ mode: "theirs",
+ }),
+ ).toBe(false);
+ // Identity-disabled env: no claim, no person tags → Mine still shows work.
+ expect(
+ threadMatchesMine({
+ claimPersonId: null,
+ originPersonId: undefined,
+ participantPersonIds: undefined,
+ mode: "mine",
+ }),
+ ).toBe(true);
+ expect(
+ threadMatchesMine({
+ claimPersonId: null,
+ originPersonId: undefined,
+ mode: "theirs",
+ }),
+ ).toBe(false);
+ });
+
+ it("treats attributed threads without a session claim as theirs", () => {
+ expect(
+ threadMatchesMine({
+ claimPersonId: null,
+ originPersonId: "julius",
+ mode: "mine",
+ }),
+ ).toBe(false);
+ expect(
+ threadMatchesMine({
+ claimPersonId: null,
+ originPersonId: "julius",
+ mode: "theirs",
+ }),
+ ).toBe(true);
+ });
+
+ it("includes participant-only matches as mine", () => {
+ expect(
+ threadMatchesMine({
+ claimPersonId: "patroza",
+ originPersonId: "julius",
+ participantPersonIds: ["patroza"],
+ mode: "mine",
+ }),
+ ).toBe(true);
+ });
+
+ it("supports created / participated / both relation sub-filters", () => {
+ // Mine: created is origin only; participated is join-without-start
+ expect(
+ threadMatchesMine({
+ claimPersonId: "patroza",
+ originPersonId: "patroza",
+ participantPersonIds: ["julius"],
+ mode: "mine",
+ relation: "created",
+ }),
+ ).toBe(true);
+ expect(
+ threadMatchesMine({
+ claimPersonId: "patroza",
+ originPersonId: "julius",
+ participantPersonIds: ["patroza"],
+ mode: "mine",
+ relation: "created",
+ }),
+ ).toBe(false);
+ expect(
+ threadMatchesMine({
+ claimPersonId: "patroza",
+ originPersonId: "julius",
+ participantPersonIds: ["patroza"],
+ mode: "mine",
+ relation: "participated",
+ }),
+ ).toBe(true);
+ expect(
+ threadMatchesMine({
+ claimPersonId: "patroza",
+ originPersonId: "patroza",
+ participantPersonIds: [],
+ mode: "mine",
+ relation: "participated",
+ }),
+ ).toBe(false);
+ expect(
+ threadMatchesMine({
+ claimPersonId: "patroza",
+ originPersonId: "julius",
+ participantPersonIds: ["patroza"],
+ mode: "mine",
+ relation: "both",
+ }),
+ ).toBe(true);
+
+ // Theirs never includes threads I joined — Created is not wider than Both
+ expect(
+ threadMatchesMine({
+ claimPersonId: "patroza",
+ originPersonId: "julius",
+ participantPersonIds: ["patroza"],
+ mode: "theirs",
+ relation: "created",
+ }),
+ ).toBe(false);
+ expect(
+ threadMatchesMine({
+ claimPersonId: "patroza",
+ originPersonId: "julius",
+ participantPersonIds: ["patroza"],
+ mode: "theirs",
+ relation: "both",
+ }),
+ ).toBe(false);
+ // Foreign thread: others started → theirs for created and both
+ expect(
+ threadMatchesMine({
+ claimPersonId: "patroza",
+ originPersonId: "julius",
+ participantPersonIds: ["alice"],
+ mode: "theirs",
+ relation: "created",
+ }),
+ ).toBe(true);
+ expect(
+ threadMatchesMine({
+ claimPersonId: "patroza",
+ originPersonId: "julius",
+ participantPersonIds: ["alice"],
+ mode: "theirs",
+ relation: "both",
+ }),
+ ).toBe(true);
+ // Foreign with participants only (no origin): participated ⊆ both, not created
+ expect(
+ threadMatchesMine({
+ claimPersonId: "patroza",
+ originPersonId: null,
+ participantPersonIds: ["julius"],
+ mode: "theirs",
+ relation: "created",
+ }),
+ ).toBe(false);
+ expect(
+ threadMatchesMine({
+ claimPersonId: "patroza",
+ originPersonId: null,
+ participantPersonIds: ["julius"],
+ mode: "theirs",
+ relation: "participated",
+ }),
+ ).toBe(true);
+ expect(
+ threadMatchesMine({
+ claimPersonId: "patroza",
+ originPersonId: null,
+ participantPersonIds: ["julius"],
+ mode: "theirs",
+ relation: "both",
+ }),
+ ).toBe(true);
+
+ // Fully unattributed only under mine + both
+ expect(
+ threadMatchesMine({
+ claimPersonId: "patroza",
+ originPersonId: null,
+ participantPersonIds: [],
+ mode: "mine",
+ relation: "created",
+ }),
+ ).toBe(false);
+ expect(
+ threadMatchesMine({
+ claimPersonId: "patroza",
+ originPersonId: null,
+ participantPersonIds: [],
+ mode: "mine",
+ relation: "both",
+ }),
+ ).toBe(true);
+ });
+});
+
+describe("claimPersonIdForEnvironment", () => {
+ it("returns the claim for the thread environment only", () => {
+ const map = new Map([
+ ["smart", null],
+ ["t3vm", "patroza"],
+ ]);
+ expect(claimPersonIdForEnvironment(map, "t3vm")).toBe("patroza");
+ expect(claimPersonIdForEnvironment(map, "smart")).toBeNull();
+ expect(claimPersonIdForEnvironment(map, "missing")).toBeNull();
+ });
+});
+
+describe("isClaimedNonStarterParticipant", () => {
+ const participants = [
+ {
+ personId: PersonId.make("joshua"),
+ username: IdentityUsername.make("joshuadima"),
+ firstChannel: "discord",
+ firstParticipatedAt: "2026-07-30T12:00:00.000Z",
+ },
+ {
+ personId: PersonId.make("patroza"),
+ username: IdentityUsername.make("patroza"),
+ firstChannel: "desktop",
+ firstParticipatedAt: "2026-07-30T12:01:00.000Z",
+ },
+ ] satisfies ReadonlyArray;
+
+ it("marks a claimed person hidden among later participants", () => {
+ expect(
+ isClaimedNonStarterParticipant({
+ claimPersonId: "PATROZA",
+ participants,
+ }),
+ ).toBe(true);
+ });
+
+ it("does not redundantly mark the visible starter", () => {
+ expect(
+ isClaimedNonStarterParticipant({
+ claimPersonId: "joshua",
+ participants,
+ }),
+ ).toBe(false);
+ });
+
+ it("does not mark an unclaimed or absent person", () => {
+ expect(
+ isClaimedNonStarterParticipant({
+ claimPersonId: null,
+ participants,
+ }),
+ ).toBe(false);
+ expect(
+ isClaimedNonStarterParticipant({
+ claimPersonId: "someone-else",
+ participants,
+ }),
+ ).toBe(false);
+ });
+});
diff --git a/packages/client-runtime/src/state/identity.ts b/packages/client-runtime/src/state/identity.ts
new file mode 100644
index 00000000000..f5af55bb837
--- /dev/null
+++ b/packages/client-runtime/src/state/identity.ts
@@ -0,0 +1,196 @@
+/**
+ * Per-environment session identity (closed-set claim against the server map).
+ */
+import {
+ WS_METHODS,
+ type IdentityClaimInput,
+ type IdentitySnapshot,
+ type IdentitySessionClaimResult,
+ type SessionIdentityClaim,
+ type ThreadParticipantSummary,
+} from "@t3tools/contracts";
+import type { EnvironmentRegistry } from "../connection/registry.ts";
+import { Atom } from "effect/unstable/reactivity";
+import { createEnvironmentRpcCommand, createEnvironmentRpcQueryAtomFamily } from "./runtime.ts";
+
+export function createIdentityEnvironmentAtoms(
+ runtime: Atom.AtomRuntime,
+) {
+ const snapshot = createEnvironmentRpcQueryAtomFamily(runtime, {
+ label: "identity-snapshot",
+ tag: WS_METHODS.identityGetSnapshot,
+ staleTimeMs: 30_000,
+ idleTtlMs: 60_000,
+ });
+
+ const sessionClaim = createEnvironmentRpcQueryAtomFamily(runtime, {
+ label: "identity-session-claim",
+ tag: WS_METHODS.identityGetSessionClaim,
+ staleTimeMs: 5_000,
+ idleTtlMs: 60_000,
+ });
+
+ const claim = createEnvironmentRpcCommand(runtime, {
+ label: "identity-claim",
+ tag: WS_METHODS.identityClaim,
+ });
+
+ const clearClaim = createEnvironmentRpcCommand(runtime, {
+ label: "identity-clear-claim",
+ tag: WS_METHODS.identityClearClaim,
+ });
+
+ return {
+ snapshot,
+ sessionClaim,
+ claim,
+ clearClaim,
+ };
+}
+
+export type IdentityEnvironmentAtoms = ReturnType;
+
+export function identityClaimRequired(
+ snapshot: IdentitySnapshot | null | undefined,
+ claimResult: IdentitySessionClaimResult | null | undefined,
+): boolean {
+ if (snapshot === null || snapshot === undefined) return false;
+ if (!snapshot.enabled || !snapshot.claimRequired) return false;
+ return claimResult?.claim == null;
+}
+
+export function filterPeopleForTypeahead(
+ people: IdentitySnapshot["people"],
+ query: string,
+ minChars: number,
+): IdentitySnapshot["people"] {
+ const q = query.trim().toLowerCase();
+ if (q.length < minChars) return [];
+ return people.filter((person) => {
+ if (person.username.includes(q)) return true;
+ if (person.name?.toLowerCase().includes(q)) return true;
+ return false;
+ });
+}
+
+/**
+ * Sub-filter for Mine / Theirs. Always a *refinement* of the mode:
+ * - `both` (default) — created or participated (widest for that mode)
+ * - `created` — starter/origin role only
+ * - `participated` — joined without being the starter
+ *
+ * For a given mode, `both` is always a superset of `created` and of
+ * `participated`. Looking only at one field for Theirs used to invert that
+ * (Created could show more than Created-or-participated).
+ */
+export type OwnershipRelation = "created" | "participated" | "both";
+
+export const DEFAULT_OWNERSHIP_RELATION: OwnershipRelation = "both";
+
+export function isOwnershipRelation(value: unknown): value is OwnershipRelation {
+ return value === "created" || value === "participated" || value === "both";
+}
+
+/**
+ * Match a thread for Mine / Theirs ownership filters.
+ *
+ * **Mine** (relation `both`, default) includes:
+ * - threads the claim person started or later joined
+ * - threads with **no person attribution** (channel-only stamps, identity off,
+ * legacy) — treated as "ours" so filters stay useful offline of a map
+ *
+ * **Mine** + `created` / `participated` narrows to that role only (unattributed
+ * threads are not included).
+ *
+ * **Theirs** is attributed threads the claim person is not on. Relation then
+ * narrows by how *others* appear: origin set (`created`), non-starter
+ * participants (`participated`), or either (`both`).
+ */
+export function threadMatchesMine(input: {
+ readonly claimPersonId: string | null | undefined;
+ readonly originPersonId?: string | null | undefined;
+ readonly participantPersonIds?: ReadonlyArray | null | undefined;
+ readonly mode: "mine" | "theirs" | "any";
+ /** Defaults to `both` (created or participated). */
+ readonly relation?: OwnershipRelation;
+}): boolean {
+ if (input.mode === "any") return true;
+
+ const relation = input.relation ?? DEFAULT_OWNERSHIP_RELATION;
+ const origin = input.originPersonId?.trim().toLowerCase() ?? "";
+ const participants = new Set();
+ for (const id of input.participantPersonIds ?? []) {
+ const personId = id?.trim().toLowerCase() ?? "";
+ if (personId.length > 0) participants.add(personId);
+ }
+
+ const fullyUnattributed = origin.length === 0 && participants.size === 0;
+ // Unattributed stays under Mine only for the default "both" relation.
+ if (fullyUnattributed) {
+ return input.mode === "mine" && relation === "both";
+ }
+
+ const claimId = input.claimPersonId?.trim().toLowerCase() ?? "";
+ const fullPeople = new Set(participants);
+ if (origin.length > 0) fullPeople.add(origin);
+
+ // No session claim: attributed work is someone else's.
+ if (claimId.length === 0) {
+ if (input.mode === "mine") return false;
+ return matchesTheirsRelation({ relation, origin, participants });
+ }
+
+ const createdByMe = origin.length > 0 && origin === claimId;
+ // Joined without starting. Origin counts as created, not participated.
+ const participatedByMe = !createdByMe && fullPeople.has(claimId);
+ const involved = createdByMe || participatedByMe;
+
+ if (input.mode === "mine") {
+ if (relation === "created") return createdByMe;
+ if (relation === "participated") return participatedByMe;
+ return involved;
+ }
+
+ // Theirs: not on the thread at all, then refine by others' roles.
+ if (involved) return false;
+ return matchesTheirsRelation({ relation, origin, participants });
+}
+
+function matchesTheirsRelation(input: {
+ readonly relation: OwnershipRelation;
+ readonly origin: string;
+ readonly participants: ReadonlySet;
+}): boolean {
+ const othersCreated = input.origin.length > 0;
+ // Participant list may restate the origin; any non-empty roster is enough
+ // for "someone participated" when we already know the claim is not on it.
+ const othersParticipated = input.participants.size > 0;
+ if (input.relation === "created") return othersCreated;
+ if (input.relation === "participated") return othersParticipated;
+ return othersCreated || othersParticipated;
+}
+
+/** Whether the claimed person participated after someone else started the thread. */
+export function isClaimedNonStarterParticipant(input: {
+ readonly claimPersonId: string | null | undefined;
+ readonly participants: ReadonlyArray;
+}): boolean {
+ const claimId = input.claimPersonId?.trim().toLowerCase() ?? "";
+ if (claimId.length === 0) return false;
+ return input.participants
+ .slice(1)
+ .some((participant) => participant.personId.trim().toLowerCase() === claimId);
+}
+
+/** Look up the claim person for a thread's environment (multi-env clients). */
+export function claimPersonIdForEnvironment(
+ claimPersonIdByEnvironment: ReadonlyMap,
+ environmentId: string,
+): string | null {
+ const value = claimPersonIdByEnvironment.get(environmentId);
+ if (value === undefined || value === null) return null;
+ const trimmed = value.trim();
+ return trimmed.length > 0 ? trimmed : null;
+}
+
+export type { IdentityClaimInput, IdentitySnapshot, SessionIdentityClaim };
diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts
index 11ab7df36d7..553491c4b1b 100644
--- a/packages/client-runtime/src/state/threadReducer.test.ts
+++ b/packages/client-runtime/src/state/threadReducer.test.ts
@@ -4,7 +4,9 @@ import {
CheckpointRef,
CommandId,
EventId,
+ IdentityUsername,
MessageId,
+ PersonId,
ProjectId,
ProviderInstanceId,
ThreadId,
@@ -304,6 +306,41 @@ describe("applyThreadDetailEvent", () => {
}
});
+ it("preserves server-authored source attribution on live messages", () => {
+ const result = applyThreadDetailEvent(baseThread, {
+ ...baseEventFields,
+ sequence: 7,
+ occurredAt: "2026-04-01T06:01:00.000Z",
+ aggregateKind: "thread",
+ aggregateId: ThreadId.make("thread-1"),
+ type: "thread.message-sent",
+ payload: {
+ threadId: ThreadId.make("thread-1"),
+ messageId: MessageId.make("msg-sourced"),
+ role: "user",
+ text: "Sent from desktop",
+ turnId: null,
+ streaming: false,
+ source: {
+ channel: "desktop",
+ personId: PersonId.make("patroza"),
+ username: IdentityUsername.make("patroza"),
+ },
+ createdAt: "2026-04-01T06:01:00.000Z",
+ updatedAt: "2026-04-01T06:01:00.000Z",
+ },
+ });
+
+ expect(result.kind).toBe("updated");
+ if (result.kind === "updated") {
+ expect(result.thread.messages[0]?.source).toEqual({
+ channel: "desktop",
+ personId: "patroza",
+ username: "patroza",
+ });
+ }
+ });
+
it("appends text for streaming messages", () => {
const threadWithMessage: OrchestrationThread = {
...baseThread,
diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts
index 69a2964a8e6..3b6dc3e6f9a 100644
--- a/packages/client-runtime/src/state/threadReducer.ts
+++ b/packages/client-runtime/src/state/threadReducer.ts
@@ -288,6 +288,7 @@ export function applyThreadDetailEvent(
...(event.payload.attachments !== undefined
? { attachments: event.payload.attachments }
: {}),
+ ...(event.payload.source !== undefined ? { source: event.payload.source } : {}),
turnId: event.payload.turnId,
streaming: event.payload.streaming,
createdAt: event.payload.createdAt,
@@ -312,6 +313,9 @@ export function applyThreadDetailEvent(
...(message.attachments !== undefined
? { attachments: message.attachments }
: {}),
+ ...(entry.source === undefined && message.source !== undefined
+ ? { source: message.source }
+ : {}),
},
)
: Arr.append(thread.messages, message);
@@ -389,6 +393,7 @@ export function applyThreadDetailEvent(
...(event.payload.sourceProposedPlan !== undefined
? { sourceProposedPlan: event.payload.sourceProposedPlan }
: {}),
+ ...(event.payload.source !== undefined ? { source: event.payload.source } : {}),
queuedAt: event.payload.queuedAt,
};
return {
diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts
index 2d40dad60cc..f95fb82808a 100644
--- a/packages/contracts/src/environmentHttp.ts
+++ b/packages/contracts/src/environmentHttp.ts
@@ -56,6 +56,9 @@ export const EnvironmentRequestInvalidReason = Schema.Literals([
"invalid_scope",
"scope_not_granted",
"invalid_command",
+ "identity_claim_required",
+ "identity_unknown_person",
+ "identity_map_invalid",
]);
export type EnvironmentRequestInvalidReason = typeof EnvironmentRequestInvalidReason.Type;
diff --git a/packages/contracts/src/identity.test.ts b/packages/contracts/src/identity.test.ts
new file mode 100644
index 00000000000..5c7d50231bf
--- /dev/null
+++ b/packages/contracts/src/identity.test.ts
@@ -0,0 +1,161 @@
+import { describe, expect, it } from "vite-plus/test";
+import * as Schema from "effect/Schema";
+
+import {
+ IDENTITY_CLAIM_TYPEAHEAD_MIN_CHARS,
+ IDENTITY_HANDLE_SOFT_MAX_LENGTH,
+ ClientSourceHint,
+ IdentityClaimInput,
+ IdentityError,
+ IdentityPersonPublic,
+ IdentitySnapshot,
+ IdentityUsername,
+ PersonId,
+ SessionIdentityClaim,
+ SourceRef,
+ ThreadParticipantSummary,
+} from "./identity.ts";
+import { AuthSessionId } from "./baseSchemas.ts";
+
+const decodeUsername = Schema.decodeUnknownSync(IdentityUsername);
+const decodePersonId = Schema.decodeUnknownSync(PersonId);
+const decodeSourceRef = Schema.decodeUnknownSync(SourceRef);
+const decodeClientHint = Schema.decodeUnknownSync(ClientSourceHint);
+const decodeSnapshot = Schema.decodeUnknownSync(IdentitySnapshot);
+const decodeClaimInput = Schema.decodeUnknownSync(IdentityClaimInput);
+const decodeClaim = Schema.decodeUnknownSync(SessionIdentityClaim);
+const decodePerson = Schema.decodeUnknownSync(IdentityPersonPublic);
+const decodeParticipant = Schema.decodeUnknownSync(ThreadParticipantSummary);
+
+describe("IdentityUsername / PersonId handles", () => {
+ it.each(["a", "pat", "patroza", "a_b-c", "julius", "user.name", "x1"])("accepts %s", (value) => {
+ expect(decodeUsername(value)).toBe(value.toLowerCase());
+ expect(decodePersonId(value)).toBe(value.toLowerCase());
+ });
+
+ it("normalizes case to lowercase", () => {
+ expect(decodeUsername("PatRoza")).toBe("patroza");
+ expect(decodePersonId("PatRoza")).toBe("patroza");
+ });
+
+ it("accepts usernames longer than 16 chars within soft max", () => {
+ const long = `a${"b".repeat(40)}`;
+ expect(decodeUsername(long)).toBe(long);
+ });
+
+ it.each([
+ ["empty", ""],
+ ["spaces", "pat roza"],
+ ["control char", "foo\nbar"],
+ ["leading dash", "-pat"],
+ ["leading underscore", "_pat"],
+ ["at-sign", "pat@roza"],
+ ["leading dot", ".pat"],
+ ])("rejects %s", (_label, value) => {
+ expect(() => decodeUsername(value)).toThrow();
+ expect(() => decodePersonId(value)).toThrow();
+ });
+
+ it("rejects past soft max", () => {
+ expect(() => decodeUsername("a".repeat(IDENTITY_HANDLE_SOFT_MAX_LENGTH + 1))).toThrow();
+ });
+
+ it("exports typeahead threshold of 3 characters", () => {
+ expect(IDENTITY_CLAIM_TYPEAHEAD_MIN_CHARS).toBe(3);
+ });
+});
+
+describe("SourceRef vs ClientSourceHint", () => {
+ it("decodes a server stamp with person", () => {
+ const parsed = decodeSourceRef({
+ channel: "desktop",
+ personId: "patroza",
+ username: "patroza",
+ });
+ expect(parsed.channel).toBe("desktop");
+ expect(parsed.personId).toBe("patroza");
+ });
+
+ it("client hint has no person fields", () => {
+ const hint = decodeClientHint({
+ channel: "discord",
+ location: { guildId: "1", channelId: "2" },
+ actor: { platformId: "9", displayName: "Patrick" },
+ });
+ expect(hint.channel).toBe("discord");
+ expect("personId" in hint).toBe(false);
+ });
+
+ it("rejects unknown channel", () => {
+ expect(() => decodeSourceRef({ channel: "irc" })).toThrow();
+ });
+});
+
+describe("IdentitySnapshot + claim", () => {
+ it("decodes an enabled map snapshot", () => {
+ const parsed = decodeSnapshot({
+ enabled: true,
+ claimRequired: true,
+ people: [
+ {
+ personId: "patroza",
+ username: "patroza",
+ name: "Patrick Roza",
+ links: {
+ discordId: "95218063095377920",
+ githubLogin: "patroza",
+ },
+ },
+ ],
+ });
+ expect(parsed.enabled).toBe(true);
+ expect(parsed.people[0]?.username).toBe("patroza");
+ });
+
+ it("defaults empty links on person", () => {
+ const person = decodePerson({
+ personId: PersonId.make("julius"),
+ username: "julius",
+ });
+ expect(person.links).toEqual({});
+ });
+
+ it("accepts claim by username or personId with optional method", () => {
+ expect(decodeClaimInput({ username: "patroza" })).toEqual({ username: "patroza" });
+ expect(decodeClaimInput({ personId: "patroza", method: "settings" })).toEqual({
+ personId: "patroza",
+ method: "settings",
+ });
+ });
+
+ it("decodes a session claim", () => {
+ const claim = decodeClaim({
+ sessionId: AuthSessionId.make("00000000-0000-4000-8000-000000000001"),
+ personId: "patroza",
+ username: "patroza",
+ claimedAt: "2026-07-30T12:00:00.000Z",
+ method: "typeahead",
+ });
+ expect(claim.method).toBe("typeahead");
+ });
+
+ it("decodes participant summary", () => {
+ const row = decodeParticipant({
+ personId: "patroza",
+ username: "patroza",
+ firstChannel: "discord",
+ channels: ["discord", "desktop"],
+ firstParticipatedAt: "2026-07-30T12:00:00.000Z",
+ });
+ expect(row.firstChannel).toBe("discord");
+ expect(row.channels).toEqual(["discord", "desktop"]);
+ });
+
+ it("constructs IdentityError codes", () => {
+ const err = new IdentityError({
+ code: "identity_unknown_person",
+ message: "not in map",
+ });
+ expect(err.code).toBe("identity_unknown_person");
+ });
+});
diff --git a/packages/contracts/src/identity.ts b/packages/contracts/src/identity.ts
new file mode 100644
index 00000000000..e9d578ac4af
--- /dev/null
+++ b/packages/contracts/src/identity.ts
@@ -0,0 +1,210 @@
+/**
+ * Session identity + message/thread source attribution.
+ *
+ * Closed-set people come from a server identity map file. Interactive clients
+ * claim a map person on their auth session; free-form usernames are rejected.
+ *
+ * Trust note (v1): interactive claim is **map membership only** — any paired
+ * session can claim any listed person. That is intentional for trusted-team
+ * shared environments, not anti-impersonation. “Mine” is claim-based and
+ * spoofable by peers with a session. Discord/Jira auto-claim binds via platform id.
+ *
+ * See docs/architecture/source-and-identity.md
+ */
+import * as Effect from "effect/Effect";
+import * as Schema from "effect/Schema";
+import * as SchemaTransformation from "effect/SchemaTransformation";
+import { AuthSessionId, TrimmedNonEmptyString, IsoDateTime } from "./baseSchemas.ts";
+
+// ── Username / person ──────────────────────────────────────────
+
+/**
+ * Soft max for wire abuse only — not a product length rule.
+ * Charset keeps handles safe for `user@channel` display and logs.
+ */
+export const IDENTITY_HANDLE_SOFT_MAX_LENGTH = 128;
+
+/** Minimum typed characters before the claim UI shows map suggestions. */
+export const IDENTITY_CLAIM_TYPEAHEAD_MIN_CHARS = 3;
+
+/**
+ * Handle charset: leading alnum, then alnum / `.` / `_` / `-`.
+ * No spaces or control chars. No minimum length product rule (single char OK).
+ */
+export const IDENTITY_HANDLE_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
+
+const normalizeHandle = (value: string) => value.trim().toLowerCase();
+
+const IdentityHandleString = TrimmedNonEmptyString.pipe(
+ Schema.decodeTo(
+ Schema.String,
+ SchemaTransformation.transformOrFail({
+ decode: (value) => Effect.succeed(normalizeHandle(value)),
+ encode: (value) => Effect.succeed(value),
+ }),
+ ),
+).check(
+ Schema.isMaxLength(IDENTITY_HANDLE_SOFT_MAX_LENGTH),
+ Schema.isPattern(IDENTITY_HANDLE_PATTERN),
+);
+
+export const IdentityUsername = IdentityHandleString.pipe(Schema.brand("IdentityUsername"));
+export type IdentityUsername = typeof IdentityUsername.Type;
+
+/** Same normalization as username so mine/theirs compares stay case-stable. */
+export const PersonId = IdentityHandleString.pipe(Schema.brand("PersonId"));
+export type PersonId = typeof PersonId.Type;
+
+// ── Channels / SourceRef ───────────────────────────────────────
+
+export const SourceChannel = Schema.Literals([
+ "desktop",
+ "vscode",
+ "web",
+ "mobile",
+ "discord",
+ "github",
+ "jira",
+ "slack",
+ "teams",
+ "bot",
+ "unknown",
+]);
+export type SourceChannel = typeof SourceChannel.Type;
+
+export const SourceLocation = Schema.Struct({
+ guildId: Schema.optionalKey(TrimmedNonEmptyString),
+ channelId: Schema.optionalKey(TrimmedNonEmptyString),
+ threadId: Schema.optionalKey(TrimmedNonEmptyString),
+ owner: Schema.optionalKey(TrimmedNonEmptyString),
+ repo: Schema.optionalKey(TrimmedNonEmptyString),
+ number: Schema.optionalKey(Schema.Int),
+ kind: Schema.optionalKey(Schema.Literals(["pr", "issue"])),
+ projectKey: Schema.optionalKey(TrimmedNonEmptyString),
+ issueKey: Schema.optionalKey(TrimmedNonEmptyString),
+});
+export type SourceLocation = typeof SourceLocation.Type;
+
+export const SourceActor = Schema.Struct({
+ platformId: Schema.optionalKey(TrimmedNonEmptyString),
+ displayName: Schema.optionalKey(TrimmedNonEmptyString),
+});
+export type SourceActor = typeof SourceActor.Type;
+
+/**
+ * Client may only hint non-person fields. Server stamps person from the
+ * session claim (or platform map for bots). Never trust client personId/username.
+ */
+export const ClientSourceHint = Schema.Struct({
+ channel: Schema.optionalKey(SourceChannel),
+ location: Schema.optionalKey(SourceLocation),
+ actor: Schema.optionalKey(SourceActor),
+});
+export type ClientSourceHint = typeof ClientSourceHint.Type;
+
+/**
+ * Server-authored provenance for a user-originated message / thread origin.
+ * personId/username absent only when an external actor is unmapped.
+ */
+export const SourceRef = Schema.Struct({
+ channel: SourceChannel,
+ personId: Schema.optionalKey(PersonId),
+ username: Schema.optionalKey(IdentityUsername),
+ location: Schema.optionalKey(SourceLocation),
+ actor: Schema.optionalKey(SourceActor),
+});
+export type SourceRef = typeof SourceRef.Type;
+
+/** Ordered participant on a thread shell (origin first when known). */
+export const ThreadParticipantSummary = Schema.Struct({
+ personId: PersonId,
+ username: IdentityUsername,
+ name: Schema.optionalKey(TrimmedNonEmptyString),
+ firstChannel: Schema.optionalKey(SourceChannel),
+ channels: Schema.optionalKey(Schema.Array(SourceChannel)),
+ firstParticipatedAt: IsoDateTime,
+});
+export type ThreadParticipantSummary = typeof ThreadParticipantSummary.Type;
+
+// ── Public identity map (client-safe) ──────────────────────────
+
+export const IdentityPlatformLinkPublic = Schema.Struct({
+ discordId: Schema.optionalKey(TrimmedNonEmptyString),
+ discordUsername: Schema.optionalKey(TrimmedNonEmptyString),
+ githubLogin: Schema.optionalKey(TrimmedNonEmptyString),
+ jiraAccountId: Schema.optionalKey(TrimmedNonEmptyString),
+});
+export type IdentityPlatformLinkPublic = typeof IdentityPlatformLinkPublic.Type;
+
+export const IdentityPersonPublic = Schema.Struct({
+ personId: PersonId,
+ username: IdentityUsername,
+ name: Schema.optionalKey(TrimmedNonEmptyString),
+ links: IdentityPlatformLinkPublic.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
+});
+export type IdentityPersonPublic = typeof IdentityPersonPublic.Type;
+
+/**
+ * Snapshot of the closed identity set.
+ * v1: `claimRequired === enabled` (both true when map has people).
+ * Full people[] is intentional roster share for typeahead (not privacy isolation).
+ */
+export const IdentitySnapshot = Schema.Struct({
+ /** False when map file missing/empty — no claim gate. */
+ enabled: Schema.Boolean,
+ people: Schema.Array(IdentityPersonPublic),
+ /** v1 always equals `enabled`. */
+ claimRequired: Schema.Boolean,
+});
+export type IdentitySnapshot = typeof IdentitySnapshot.Type;
+
+export const SessionIdentityClaimMethod = Schema.Literals([
+ "typeahead",
+ "settings",
+ "auto-discord",
+ "auto-jira",
+ "bootstrap",
+]);
+export type SessionIdentityClaimMethod = typeof SessionIdentityClaimMethod.Type;
+
+export const SessionIdentityClaim = Schema.Struct({
+ sessionId: AuthSessionId,
+ personId: PersonId,
+ username: IdentityUsername,
+ claimedAt: IsoDateTime,
+ method: SessionIdentityClaimMethod,
+});
+export type SessionIdentityClaim = typeof SessionIdentityClaim.Type;
+
+export const IdentityClaimInput = Schema.Union([
+ Schema.Struct({
+ personId: PersonId,
+ method: Schema.optionalKey(Schema.Literals(["typeahead", "settings", "bootstrap"])),
+ }),
+ Schema.Struct({
+ username: IdentityUsername,
+ method: Schema.optionalKey(Schema.Literals(["typeahead", "settings", "bootstrap"])),
+ }),
+]);
+export type IdentityClaimInput = typeof IdentityClaimInput.Type;
+
+export const IdentityClaimResult = Schema.Struct({
+ claim: SessionIdentityClaim,
+});
+export type IdentityClaimResult = typeof IdentityClaimResult.Type;
+
+export const IdentitySessionClaimResult = Schema.Struct({
+ claim: Schema.NullOr(SessionIdentityClaim),
+});
+export type IdentitySessionClaimResult = typeof IdentitySessionClaimResult.Type;
+
+export class IdentityError extends Schema.TaggedErrorClass()("IdentityError", {
+ code: Schema.Literals([
+ "identity_map_disabled",
+ "identity_unknown_person",
+ "identity_claim_required",
+ "identity_claim_missing",
+ "identity_map_invalid",
+ ]),
+ message: Schema.String,
+}) {}
diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts
index d7064a7cf62..579815e8ad4 100644
--- a/packages/contracts/src/index.ts
+++ b/packages/contracts/src/index.ts
@@ -1,5 +1,6 @@
export * from "./baseSchemas.ts";
export * from "./auth.ts";
+export * from "./identity.ts";
export * from "./environment.ts";
export * from "./environmentHttp.ts";
export * from "./relayClient.ts";
diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts
index ecf7afa0610..cc35dc4e415 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");
}
}),
);
diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts
index b9440e12051..5601fd7b90d 100644
--- a/packages/contracts/src/orchestration.ts
+++ b/packages/contracts/src/orchestration.ts
@@ -22,6 +22,7 @@ import {
TurnId,
} from "./baseSchemas.ts";
import { ProviderInstanceId } from "./providerInstance.ts";
+import { ClientSourceHint, SourceRef, ThreadParticipantSummary } from "./identity.ts";
export const ORCHESTRATION_WS_METHODS = {
dispatchCommand: "orchestration.dispatchCommand",
@@ -234,6 +235,8 @@ export const OrchestrationMessage = Schema.Struct({
attachments: Schema.optional(Schema.Array(ChatAttachment)),
turnId: Schema.NullOr(TurnId),
streaming: Schema.Boolean,
+ /** Server-authored provenance; absent on legacy / assistant messages. */
+ source: Schema.optional(SourceRef),
createdAt: IsoDateTime,
updatedAt: IsoDateTime,
});
@@ -361,6 +364,8 @@ export const OrchestrationQueuedMessage = Schema.Struct({
attachments: Schema.Array(ChatAttachment),
modelSelection: Schema.optional(ModelSelection),
sourceProposedPlan: Schema.optional(SourceProposedPlanReference),
+ /** Server-stamped at enqueue; preserved when the queue drains to message-sent. */
+ source: Schema.optional(SourceRef),
queuedAt: IsoDateTime,
});
export type OrchestrationQueuedMessage = typeof OrchestrationQueuedMessage.Type;
@@ -420,6 +425,9 @@ export const OrchestrationThread = Schema.Struct({
hasMoreActivities: Schema.optional(Schema.Boolean),
checkpoints: Schema.Array(OrchestrationCheckpointSummary),
session: Schema.NullOr(OrchestrationSession),
+ originSource: Schema.optional(Schema.NullOr(SourceRef)),
+ // Optional without default so legacy fixtures omit the field; clients use ?? [].
+ participantSummaries: Schema.optional(Schema.Array(ThreadParticipantSummary)),
});
export type OrchestrationThread = typeof OrchestrationThread.Type;
@@ -470,6 +478,13 @@ export const OrchestrationThreadShell = Schema.Struct({
hasPendingApprovals: Schema.Boolean,
hasPendingUserInput: Schema.Boolean,
hasActionableProposedPlan: Schema.Boolean,
+ /** First user message SourceRef; null/absent on legacy threads. */
+ originSource: Schema.optional(Schema.NullOr(SourceRef)),
+ /**
+ * Distinct people on user messages: origin person first, then first-participation order.
+ * Used for creator + +N participant stack. Absent on legacy shells (clients use ?? []).
+ */
+ participantSummaries: Schema.optional(Schema.Array(ThreadParticipantSummary)),
});
export type OrchestrationThreadShell = typeof OrchestrationThreadShell.Type;
@@ -739,6 +754,17 @@ export const ThreadTurnStartCommand = Schema.Struct({
),
bootstrap: Schema.optional(ThreadTurnStartBootstrap),
sourceProposedPlan: Schema.optional(SourceProposedPlanReference),
+ /**
+ * Server-authored only. Gate layer stamps from session claim + deviceType
+ * or resolves platform actors from `sourceHint` (bots / integrations).
+ * Clients must not send trusted person fields.
+ */
+ source: Schema.optional(SourceRef),
+ /**
+ * Non-person hints from trusted integrations (Discord bot, etc.).
+ * Server resolves personId via the identity map; never trusts client person fields.
+ */
+ sourceHint: Schema.optional(ClientSourceHint),
createdAt: IsoDateTime,
});
@@ -758,6 +784,8 @@ const ClientThreadTurnStartCommand = Schema.Struct({
interactionMode: ProviderInteractionMode,
bootstrap: Schema.optional(ThreadTurnStartBootstrap),
sourceProposedPlan: Schema.optional(SourceProposedPlanReference),
+ /** Platform actor/location only — server stamps person from the identity map. */
+ sourceHint: Schema.optional(ClientSourceHint),
createdAt: IsoDateTime,
});
@@ -982,6 +1010,7 @@ const ThreadTitleRegenerationCompleteCommand = Schema.Struct({
threadId: ThreadId,
requestId: CommandId,
title: Schema.optional(TrimmedNonEmptyString),
+ createdAt: IsoDateTime,
});
/**
@@ -1184,6 +1213,8 @@ export const ThreadMessageSentPayload = Schema.Struct({
attachments: Schema.optional(Schema.Array(ChatAttachment)),
turnId: Schema.NullOr(TurnId),
streaming: Schema.Boolean,
+ /** Server-authored only; clients must not invent person fields. */
+ source: Schema.optional(SourceRef),
createdAt: IsoDateTime,
updatedAt: IsoDateTime,
});
@@ -1195,6 +1226,8 @@ export const ThreadMessageQueuedPayload = Schema.Struct({
attachments: Schema.Array(ChatAttachment),
modelSelection: Schema.optional(ModelSelection),
sourceProposedPlan: Schema.optional(SourceProposedPlanReference),
+ /** Server-stamped provenance for the queued user message. */
+ source: Schema.optional(SourceRef),
queuedAt: IsoDateTime,
});
@@ -1704,6 +1737,8 @@ export class OrchestrationDispatchCommandError extends Schema.TaggedErrorClass()(
"RelayEnvironmentConnectNotAuthorizedError",
{
code: Schema.Literal("environment_connect_not_authorized"),
- // Optional so responses from relays deployed before the reason was
- // threaded through still decode.
- reason: Schema.optional(RelayEnvironmentConnectNotAuthorizedReason),
traceId: TrimmedNonEmptyString,
},
{ httpApiStatus: 403 },
) {
override get message(): string {
- return this.reason
- ? `Relay environment connection is not authorized: ${this.reason}`
- : "Relay environment connection is not authorized";
+ return "Relay environment connection is not authorized";
}
}
diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts
index ad7afc32277..3b2f894c038 100644
--- a/packages/contracts/src/rpc.ts
+++ b/packages/contracts/src/rpc.ts
@@ -8,6 +8,13 @@ import {
AuthAccessStreamEvent,
EnvironmentAuthorizationError,
} from "./auth.ts";
+import {
+ IdentityClaimInput,
+ IdentityClaimResult,
+ IdentityError,
+ IdentitySessionClaimResult,
+ IdentitySnapshot,
+} from "./identity.ts";
import {
BackgroundPolicySnapshot,
ClientActivityReportInput,
@@ -259,6 +266,12 @@ export const WS_METHODS = {
cloudGetRelayClientStatus: "cloud.getRelayClientStatus",
cloudInstallRelayClient: "cloud.installRelayClient",
+ // Session identity (closed-set map claim)
+ identityGetSnapshot: "identity.getSnapshot",
+ identityGetSessionClaim: "identity.getSessionClaim",
+ identityClaim: "identity.claim",
+ identityClearClaim: "identity.clearClaim",
+
// Source control methods
sourceControlLookupRepository: "sourceControl.lookupRepository",
sourceControlCloneRepository: "sourceControl.cloneRepository",
@@ -426,6 +439,30 @@ export const WsCloudInstallRelayClientRpc = Rpc.make(WS_METHODS.cloudInstallRela
stream: true,
});
+export const WsIdentityGetSnapshotRpc = Rpc.make(WS_METHODS.identityGetSnapshot, {
+ payload: Schema.Struct({}),
+ success: IdentitySnapshot,
+ error: Schema.Union([IdentityError, EnvironmentAuthorizationError]),
+});
+
+export const WsIdentityGetSessionClaimRpc = Rpc.make(WS_METHODS.identityGetSessionClaim, {
+ payload: Schema.Struct({}),
+ success: IdentitySessionClaimResult,
+ error: Schema.Union([IdentityError, EnvironmentAuthorizationError]),
+});
+
+export const WsIdentityClaimRpc = Rpc.make(WS_METHODS.identityClaim, {
+ payload: IdentityClaimInput,
+ success: IdentityClaimResult,
+ error: Schema.Union([IdentityError, EnvironmentAuthorizationError]),
+});
+
+export const WsIdentityClearClaimRpc = Rpc.make(WS_METHODS.identityClearClaim, {
+ payload: Schema.Struct({}),
+ success: Schema.Struct({ cleared: Schema.Boolean }),
+ error: Schema.Union([IdentityError, EnvironmentAuthorizationError]),
+});
+
export const WsServerReportClientActivityRpc = Rpc.make(WS_METHODS.serverReportClientActivity, {
payload: ClientActivityReportInput,
error: EnvironmentAuthorizationError,
@@ -880,6 +917,10 @@ export const WsRpcGroup = RpcGroup.make(
WsServerGetBackgroundPolicyRpc,
WsCloudGetRelayClientStatusRpc,
WsCloudInstallRelayClientRpc,
+ WsIdentityGetSnapshotRpc,
+ WsIdentityGetSessionClaimRpc,
+ WsIdentityClaimRpc,
+ WsIdentityClearClaimRpc,
WsSourceControlLookupRepositoryRpc,
WsSourceControlCloneRepositoryRpc,
WsSourceControlPublishRepositoryRpc,
diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts
index 078e9fcbf33..c906f86f4dc 100644
--- a/packages/contracts/src/server.test.ts
+++ b/packages/contracts/src/server.test.ts
@@ -1,11 +1,9 @@
import * as Schema from "effect/Schema";
import { describe, expect, it } from "vite-plus/test";
-import { ServerConfig, ServerProvider, ServerUpsertKeybindingResult } from "./server.ts";
+import { ServerProvider } from "./server.ts";
const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider);
-const decodeUpsertKeybindingResult = Schema.decodeUnknownSync(ServerUpsertKeybindingResult);
-const decodeAvailableEditors = Schema.decodeUnknownSync(ServerConfig.fields.availableEditors);
describe("ServerProvider", () => {
it("defaults capability arrays when decoding provider snapshots", () => {
@@ -74,25 +72,3 @@ describe("ServerProvider", () => {
expect(parsed.continuation?.groupKey).toBe("codex:home:/Users/julius/.codex");
});
});
-
-describe("server config forward compatibility", () => {
- it("drops config issues with kinds this build does not know", () => {
- const parsed = decodeUpsertKeybindingResult({
- keybindings: [],
- issues: [
- { kind: "keybindings.invalid-entry", message: "Bad entry", index: 2 },
- { kind: "keybindings.future-issue", message: "From a newer server" },
- ],
- });
-
- expect(parsed.issues).toEqual([
- { kind: "keybindings.invalid-entry", message: "Bad entry", index: 2 },
- ]);
- });
-
- it("drops editor ids this build does not know", () => {
- const parsed = decodeAvailableEditors(["zed", "some-future-editor", "vscode"]);
-
- expect(parsed).toEqual(["zed", "vscode"]);
- });
-});
diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts
index cff26e0a3ba..6ddfa3cdb67 100644
--- a/packages/contracts/src/server.ts
+++ b/packages/contracts/src/server.ts
@@ -3,7 +3,6 @@ import * as Schema from "effect/Schema";
import { ExecutionEnvironmentDescriptor, ServerSelfUpdateMethod } from "./environment.ts";
import { ServerAuthDescriptor } from "./auth.ts";
import {
- ForwardCompatibleArray,
IsoDateTime,
NonNegativeInt,
PositiveInt,
@@ -39,9 +38,7 @@ export const ServerConfigIssue = Schema.Union([
]);
export type ServerConfigIssue = typeof ServerConfigIssue.Type;
-// Issue kinds grow over time; older clients must not fail the whole config
-// decode over a kind they cannot render.
-const ServerConfigIssues = ForwardCompatibleArray(ServerConfigIssue);
+const ServerConfigIssues = Schema.Array(ServerConfigIssue);
export const ServerProviderState = Schema.Literals(["ready", "warning", "error", "disabled"]);
export type ServerProviderState = typeof ServerProviderState.Type;
@@ -420,9 +417,7 @@ export const ServerConfig = Schema.Struct({
keybindings: ResolvedKeybindingsConfig,
issues: ServerConfigIssues,
providers: ServerProviders,
- // Editor ids grow over time; drop ones this build does not know rather than
- // failing the whole config decode.
- availableEditors: ForwardCompatibleArray(EditorId),
+ availableEditors: Schema.Array(EditorId),
observability: ServerObservability,
settings: ServerSettings,
/** Whether shell subscriptions can emit an opt-in catch-up completion marker. */
diff --git a/packages/shared/package.json b/packages/shared/package.json
index 4214d57b953..ff115b6e9b7 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -99,6 +99,22 @@
"types": "./src/String.ts",
"import": "./src/String.ts"
},
+ "./identityAvatar": {
+ "types": "./src/identityAvatar.ts",
+ "import": "./src/identityAvatar.ts"
+ },
+ "./identityMap": {
+ "types": "./src/identityMap.ts",
+ "import": "./src/identityMap.ts"
+ },
+ "./sourceAttribution": {
+ "types": "./src/sourceAttribution.ts",
+ "import": "./src/sourceAttribution.ts"
+ },
+ "./threadAttributeSearch": {
+ "types": "./src/threadAttributeSearch.ts",
+ "import": "./src/threadAttributeSearch.ts"
+ },
"./projectScripts": {
"types": "./src/projectScripts.ts",
"import": "./src/projectScripts.ts"
diff --git a/packages/shared/src/identityAvatar.test.ts b/packages/shared/src/identityAvatar.test.ts
new file mode 100644
index 00000000000..d7480c89a24
--- /dev/null
+++ b/packages/shared/src/identityAvatar.test.ts
@@ -0,0 +1,80 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ identityAvatar,
+ identityAvatarColors,
+ identityInitials,
+ IDENTITY_AVATAR_PALETTE,
+} from "./identityAvatar.ts";
+
+describe("identityInitials", () => {
+ it("uses two words from display name", () => {
+ expect(identityInitials({ username: "patroza", name: "Patrick Roza" })).toBe("PR");
+ });
+
+ it("uses first two letters of a single name token", () => {
+ expect(identityInitials({ name: "Julius" })).toBe("JU");
+ });
+
+ it("falls back to username", () => {
+ expect(identityInitials({ username: "patroza" })).toBe("PA");
+ });
+
+ it("handles short username", () => {
+ expect(identityInitials({ username: "ab" })).toBe("AB");
+ expect(identityInitials({ username: "x" })).toBe("X");
+ });
+
+ it("returns ? when empty", () => {
+ expect(identityInitials({})).toBe("?");
+ expect(identityInitials({ username: " ", name: "" })).toBe("?");
+ });
+
+ it("handles CJK name without surrogate splits", () => {
+ expect(identityInitials({ name: "田中 太郎" })).toBe("田太");
+ });
+
+ it("handles CJK username", () => {
+ expect(identityInitials({ username: "田中" })).toBe("田中");
+ });
+
+ it("skips emoji-only name to username when possible", () => {
+ // emoji has no L/N letters — falls through to code points of name
+ const initials = identityInitials({ name: "😀😀", username: "pat" });
+ expect(initials.length).toBeGreaterThan(0);
+ expect(initials).not.toMatch(/[\uD800-\uDFFF]/u);
+ });
+});
+
+describe("identityAvatarColors", () => {
+ it("is deterministic for the same seed", () => {
+ expect(identityAvatarColors("patroza")).toEqual(identityAvatarColors("patroza"));
+ });
+
+ it("varies across different seeds when possible", () => {
+ const a = identityAvatarColors("patroza");
+ const b = identityAvatarColors("julius");
+ expect(IDENTITY_AVATAR_PALETTE).toContainEqual(a);
+ expect(IDENTITY_AVATAR_PALETTE).toContainEqual(b);
+ });
+});
+
+describe("identityAvatar", () => {
+ it("combines initials, label, and colors", () => {
+ const avatar = identityAvatar({
+ personId: "patroza",
+ username: "patroza",
+ name: "Patrick Roza",
+ });
+ expect(avatar.initials).toBe("PR");
+ expect(avatar.label).toBe("Patrick Roza");
+ expect(avatar.backgroundColor).toMatch(/^#/);
+ expect(avatar.color).toBe("#FFFFFF");
+ });
+
+ it("keeps color seed on personId when username changes", () => {
+ const a = identityAvatar({ personId: "p1", username: "old" });
+ const b = identityAvatar({ personId: "p1", username: "new" });
+ expect(a.backgroundColor).toBe(b.backgroundColor);
+ });
+});
diff --git a/packages/shared/src/identityAvatar.ts b/packages/shared/src/identityAvatar.ts
new file mode 100644
index 00000000000..ab817efe3b0
--- /dev/null
+++ b/packages/shared/src/identityAvatar.ts
@@ -0,0 +1,122 @@
+/**
+ * Deterministic micro-avatars from identity usernames (initials + color).
+ *
+ * Pure presentation helpers for web/mobile — no network, no assets.
+ * Real photo URLs can replace these later; seed stays `personId` / `username`.
+ *
+ * See docs/architecture/source-and-identity.md
+ */
+
+export type IdentityAvatarColors = {
+ readonly backgroundColor: string;
+ readonly color: string;
+};
+
+export type IdentityAvatarModel = IdentityAvatarColors & {
+ /** 1–2 uppercase letters for the chip. */
+ readonly initials: string;
+ /** Accessible label, usually the username or display name. */
+ readonly label: string;
+};
+
+/**
+ * Fixed palette (background + readable foreground). Indexed by a stable hash of
+ * the person key so the same user always gets the same chip across clients.
+ * Colors are slightly muted so dense lists stay calm on dark/light UIs.
+ */
+export const IDENTITY_AVATAR_PALETTE: ReadonlyArray = [
+ { backgroundColor: "#3B5BDB", color: "#FFFFFF" },
+ { backgroundColor: "#0CA678", color: "#FFFFFF" },
+ { backgroundColor: "#E67700", color: "#FFFFFF" },
+ { backgroundColor: "#9C36B5", color: "#FFFFFF" },
+ { backgroundColor: "#0B7285", color: "#FFFFFF" },
+ { backgroundColor: "#C2255C", color: "#FFFFFF" },
+ { backgroundColor: "#2F9E44", color: "#FFFFFF" },
+ { backgroundColor: "#364FC7", color: "#FFFFFF" },
+ { backgroundColor: "#D9480F", color: "#FFFFFF" },
+ { backgroundColor: "#5F3DC4", color: "#FFFFFF" },
+ { backgroundColor: "#087F5B", color: "#FFFFFF" },
+ { backgroundColor: "#A61E4D", color: "#FFFFFF" },
+] as const;
+
+/** FNV-1a 32-bit — fast, stable, no deps. Not part of the public chip API. */
+function hashIdentitySeed(seed: string): number {
+ let hash = 0x811c9dc5;
+ for (let i = 0; i < seed.length; i++) {
+ hash ^= seed.charCodeAt(i);
+ hash = Math.imul(hash, 0x01000193);
+ }
+ return hash >>> 0;
+}
+
+export function identityAvatarColors(seed: string): IdentityAvatarColors {
+ const index = hashIdentitySeed(seed) % IDENTITY_AVATAR_PALETTE.length;
+ return IDENTITY_AVATAR_PALETTE[index]!;
+}
+
+/** First up to `count` Unicode code points (not UTF-16 units). */
+function takeCodePoints(value: string, count: number): string {
+ const points: Array = [];
+ for (const point of value) {
+ if (point.trim().length === 0) continue;
+ points.push(point);
+ if (points.length >= count) break;
+ }
+ return points.join("");
+}
+
+/**
+ * Initials from display name when present, otherwise username.
+ * Uses code points so non-BMP / CJK handles do not split surrogates.
+ * - "Patrick Roza" → "PR"
+ * - "patroza" → "PA"
+ * - "田中" → "田中"
+ * - empty → "?"
+ */
+export function identityInitials(input: {
+ readonly username?: string | null | undefined;
+ readonly name?: string | null | undefined;
+}): string {
+ const name = input.name?.trim() ?? "";
+ if (name.length > 0) {
+ const words = name.replace(/[_-]+/gu, " ").split(/\s+/u).filter(Boolean);
+ if (words.length >= 2) {
+ const a = takeCodePoints(words[0]!, 1);
+ const b = takeCodePoints(words[1]!, 1);
+ const pair = `${a}${b}`;
+ if (pair.length > 0) return pair.toLocaleUpperCase();
+ }
+ if (words.length === 1) {
+ const two = takeCodePoints(words[0]!, 2);
+ if (two.length > 0) return two.toLocaleUpperCase();
+ }
+ }
+
+ const username = input.username?.trim() ?? "";
+ if (username.length === 0) return "?";
+ // Prefer letter/number-like code points; fall back to raw username points.
+ const alnumLike = [...username].filter((ch) => /[\p{L}\p{N}]/u.test(ch)).join("");
+ const source = alnumLike.length > 0 ? alnumLike : username;
+ const two = takeCodePoints(source, 2);
+ return two.length > 0 ? two.toLocaleUpperCase() : "?";
+}
+
+/**
+ * Build a micro-avatar model. Prefer `personId` as color seed when available so
+ * renames keep the same chip; fall back to username.
+ */
+export function identityAvatar(input: {
+ readonly personId?: string | null | undefined;
+ readonly username?: string | null | undefined;
+ readonly name?: string | null | undefined;
+}): IdentityAvatarModel {
+ const username = input.username?.trim() ?? "";
+ const name = input.name?.trim() ?? "";
+ const seed = (input.personId?.trim() || username || name || "?").toLowerCase();
+ const colors = identityAvatarColors(seed);
+ return {
+ initials: identityInitials({ username, name }),
+ label: name.length > 0 ? name : username.length > 0 ? username : "Unknown",
+ ...colors,
+ };
+}
diff --git a/packages/shared/src/identityMap.lookup.test.ts b/packages/shared/src/identityMap.lookup.test.ts
new file mode 100644
index 00000000000..475e2490627
--- /dev/null
+++ b/packages/shared/src/identityMap.lookup.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ findPersonByDiscordId,
+ findPersonByGithubId,
+ findPersonByGithubLogin,
+ findPersonByJiraAccountId,
+ findPersonByJiraEmail,
+ parseIdentityMapDocument,
+} from "./identityMap.ts";
+
+const people = parseIdentityMapDocument({
+ people: {
+ patroza: {
+ username: "patroza",
+ name: "Patrick",
+ discord: { id: "95218063095377920" },
+ github: { login: "patroza", id: "42661" },
+ jira: { accountId: "jira-pat", email: "patrick@example.com" },
+ },
+ julius: {
+ username: "julius",
+ github: { login: "juliusmarminge" },
+ },
+ },
+});
+
+describe("identity map platform lookups", () => {
+ it("finds by discord id", () => {
+ expect(findPersonByDiscordId(people, "95218063095377920")?.username).toBe("patroza");
+ expect(findPersonByDiscordId(people, "0")).toBeNull();
+ });
+
+ it("finds by github login and id", () => {
+ expect(findPersonByGithubLogin(people, "Patroza")?.username).toBe("patroza");
+ expect(findPersonByGithubId(people, 42661)?.username).toBe("patroza");
+ });
+
+ it("finds by jira account and email", () => {
+ expect(findPersonByJiraAccountId(people, "jira-pat")?.username).toBe("patroza");
+ expect(findPersonByJiraEmail(people, "patrick@example.com")?.username).toBe("patroza");
+ });
+});
diff --git a/packages/shared/src/identityMap.test.ts b/packages/shared/src/identityMap.test.ts
new file mode 100644
index 00000000000..dda0239d851
--- /dev/null
+++ b/packages/shared/src/identityMap.test.ts
@@ -0,0 +1,69 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ IdentityMapParseError,
+ normalizeJiraAccountId,
+ parseIdentityMapDocument,
+ resolvePersonByJiraAccountId,
+} from "./identityMap.ts";
+
+describe("parseIdentityMapDocument", () => {
+ it("parses people map with usernames", () => {
+ const people = parseIdentityMapDocument({
+ people: {
+ patroza: {
+ username: "patroza",
+ name: "Patrick Roza",
+ discord: { id: "95218063095377920" },
+ github: { login: "patroza", id: "42661" },
+ },
+ julius: {
+ username: "Julius",
+ name: "Julius",
+ },
+ },
+ });
+ expect(people).toHaveLength(2);
+ expect(people[0]?.username).toBe("patroza");
+ expect(people[1]?.username).toBe("julius");
+ expect(people[1]?.personId).toBe("julius");
+ });
+
+ it("rejects free-form invalid usernames", () => {
+ expect(() =>
+ parseIdentityMapDocument({
+ people: [{ username: "pat roza", name: "Bad" }],
+ }),
+ ).toThrow(IdentityMapParseError);
+ });
+
+ it("rejects duplicate usernames", () => {
+ expect(() =>
+ parseIdentityMapDocument({
+ people: [
+ { username: "a", personId: "a" },
+ { username: "a", personId: "b" },
+ ],
+ }),
+ ).toThrow(/duplicate username/);
+ });
+
+ it("returns empty for empty document", () => {
+ expect(parseIdentityMapDocument({})).toEqual([]);
+ expect(parseIdentityMapDocument({ people: [] })).toEqual([]);
+ });
+
+ it("resolves people by Jira accountId", () => {
+ const people = parseIdentityMapDocument({
+ people: {
+ patroza: {
+ username: "patroza",
+ jira: { accountId: "712020:abc" },
+ },
+ },
+ });
+ expect(normalizeJiraAccountId("accountid:712020:ABC")).toBe("712020:abc");
+ expect(resolvePersonByJiraAccountId(people, "712020:abc")?.username).toBe("patroza");
+ expect(resolvePersonByJiraAccountId(people, "nope")).toBeNull();
+ });
+});
diff --git a/packages/shared/src/identityMap.ts b/packages/shared/src/identityMap.ts
new file mode 100644
index 00000000000..9ba48aedf29
--- /dev/null
+++ b/packages/shared/src/identityMap.ts
@@ -0,0 +1,314 @@
+/**
+ * Parse closed-set identity map documents (YAML/JSON).
+ * Shared by server (and later Discord bot) so ops keep one file format.
+ *
+ * See docs/architecture/source-and-identity.md
+ */
+import * as Schema from "effect/Schema";
+
+export type IdentityMapDiscordRef = {
+ readonly id: string;
+ readonly username?: string | undefined;
+};
+
+export type IdentityMapGitHubRef = {
+ readonly login: string;
+ readonly id?: string | undefined;
+ readonly email?: string | undefined;
+ readonly name?: string | undefined;
+};
+
+export type IdentityMapJiraRef = {
+ readonly accountId?: string | undefined;
+ readonly email?: string | undefined;
+ readonly displayName?: string | undefined;
+};
+
+export type IdentityMapPerson = {
+ readonly personId: string;
+ readonly username: string;
+ readonly name?: string | undefined;
+ readonly discord?: IdentityMapDiscordRef | undefined;
+ readonly github?: IdentityMapGitHubRef | undefined;
+ readonly jira?: IdentityMapJiraRef | undefined;
+};
+
+export class IdentityMapParseError extends Error {
+ readonly _tag = "IdentityMapParseError";
+ readonly pathLabel: string;
+ constructor(pathLabel: string, message: string) {
+ super(message);
+ this.name = "IdentityMapParseError";
+ this.pathLabel = pathLabel;
+ }
+}
+
+const HANDLE_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
+const HANDLE_MAX = 128;
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function asNonEmptyString(value: unknown): string | undefined {
+ if (typeof value !== "string") return undefined;
+ const trimmed = value.trim();
+ return trimmed.length > 0 ? trimmed : undefined;
+}
+
+function normalizeHandle(value: string, field: string, label: string): string {
+ const normalized = value.trim().toLowerCase();
+ if (
+ normalized.length === 0 ||
+ normalized.length > HANDLE_MAX ||
+ !HANDLE_PATTERN.test(normalized)
+ ) {
+ throw new IdentityMapParseError(
+ label,
+ `${field} must be a non-empty handle (max ${HANDLE_MAX}, pattern ${HANDLE_PATTERN}): got ${JSON.stringify(value)}`,
+ );
+ }
+ return normalized;
+}
+
+function asDiscordSnowflake(value: unknown): string | undefined {
+ const raw = asNonEmptyString(value);
+ if (raw === undefined) return undefined;
+ if (!/^\d{1,32}$/u.test(raw)) return undefined;
+ return raw;
+}
+
+function normalizeLogin(value: unknown): string | undefined {
+ const raw = asNonEmptyString(value);
+ if (raw === undefined) return undefined;
+ const login = raw.replace(/^@/u, "").trim();
+ if (login.length === 0) return undefined;
+ if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/u.test(login)) return undefined;
+ return login;
+}
+
+function parsePerson(raw: unknown, indexLabel: string, keyHint?: string): IdentityMapPerson {
+ if (!isRecord(raw)) {
+ throw new IdentityMapParseError(indexLabel, "person entry must be an object");
+ }
+
+ const discordNested = isRecord(raw.discord) ? raw.discord : undefined;
+ const githubNested = isRecord(raw.github) ? raw.github : undefined;
+ const jiraNested = isRecord(raw.jira) ? raw.jira : undefined;
+
+ const usernameRaw =
+ asNonEmptyString(raw.username) ??
+ asNonEmptyString(raw.userName) ??
+ (keyHint !== undefined && !/^\d+$/u.test(keyHint) ? keyHint : undefined);
+ if (usernameRaw === undefined) {
+ throw new IdentityMapParseError(indexLabel, 'missing required "username"');
+ }
+ const username = normalizeHandle(usernameRaw, "username", indexLabel);
+
+ const personIdRaw = asNonEmptyString(raw.personId) ?? asNonEmptyString(raw.id) ?? username;
+ const personId = normalizeHandle(personIdRaw, "personId", indexLabel);
+
+ const name = asNonEmptyString(raw.name);
+
+ const discordId =
+ asDiscordSnowflake(discordNested?.id) ??
+ asDiscordSnowflake(raw.discordId) ??
+ asDiscordSnowflake(raw.discord_id) ??
+ (keyHint !== undefined ? asDiscordSnowflake(keyHint) : undefined);
+ const discordUsername =
+ asNonEmptyString(discordNested?.username) ??
+ asNonEmptyString(raw.discordUsername) ??
+ asNonEmptyString(raw.discord_username);
+
+ const githubLogin =
+ normalizeLogin(githubNested?.login) ??
+ normalizeLogin(raw.githubLogin) ??
+ normalizeLogin(raw.github_login) ??
+ normalizeLogin(raw.github);
+ const githubId =
+ asNonEmptyString(githubNested?.id)?.replace(/\D/gu, "") ||
+ asNonEmptyString(raw.githubId)?.replace(/\D/gu, "") ||
+ asNonEmptyString(raw.github_id)?.replace(/\D/gu, "") ||
+ undefined;
+ const githubEmail =
+ asNonEmptyString(githubNested?.email) ??
+ asNonEmptyString(raw.githubEmail) ??
+ asNonEmptyString(raw.github_email);
+ const githubName =
+ asNonEmptyString(githubNested?.name) ??
+ asNonEmptyString(raw.githubName) ??
+ asNonEmptyString(raw.github_name);
+
+ const jiraAccountId =
+ asNonEmptyString(jiraNested?.accountId) ??
+ asNonEmptyString(raw.jiraAccountId) ??
+ asNonEmptyString(raw.jira_account_id);
+ const jiraEmail =
+ asNonEmptyString(jiraNested?.email) ??
+ asNonEmptyString(raw.jiraEmail) ??
+ asNonEmptyString(raw.jira_email);
+ const jiraDisplayName =
+ asNonEmptyString(jiraNested?.displayName) ??
+ asNonEmptyString(raw.jiraDisplayName) ??
+ asNonEmptyString(raw.jira_display_name);
+
+ return {
+ personId,
+ username,
+ ...(name !== undefined ? { name } : {}),
+ ...(discordId !== undefined
+ ? {
+ discord: {
+ id: discordId,
+ ...(discordUsername !== undefined ? { username: discordUsername } : {}),
+ },
+ }
+ : {}),
+ ...(githubLogin !== undefined
+ ? {
+ github: {
+ login: githubLogin,
+ ...(githubId !== undefined && githubId.length > 0 ? { id: githubId } : {}),
+ ...(githubEmail !== undefined ? { email: githubEmail } : {}),
+ ...(githubName !== undefined ? { name: githubName } : {}),
+ },
+ }
+ : {}),
+ ...(jiraAccountId !== undefined || jiraEmail !== undefined
+ ? {
+ jira: {
+ ...(jiraAccountId !== undefined ? { accountId: jiraAccountId } : {}),
+ ...(jiraEmail !== undefined ? { email: jiraEmail } : {}),
+ ...(jiraDisplayName !== undefined ? { displayName: jiraDisplayName } : {}),
+ },
+ }
+ : {}),
+ };
+}
+
+/**
+ * Parse identity map document object (already JSON/YAML-parsed).
+ */
+export function parseIdentityMapDocument(document: unknown): ReadonlyArray {
+ if (document === null || document === undefined) return [];
+ if (!isRecord(document)) {
+ throw new IdentityMapParseError("root", "Identity map root must be an object.");
+ }
+
+ const peopleNode = document.people;
+ let people: ReadonlyArray;
+
+ if (Array.isArray(peopleNode)) {
+ people = peopleNode.map((entry, index) => parsePerson(entry, `[${index}]`));
+ } else if (isRecord(peopleNode)) {
+ people = Object.entries(peopleNode).map(([key, value]) =>
+ parsePerson(value, `people["${key}"]`, key),
+ );
+ } else {
+ const reserved = new Set(["version", "schema", "$schema"]);
+ const entries = Object.entries(document).filter(([key]) => !reserved.has(key));
+ if (entries.length === 0) return [];
+ people = entries.map(([key, value]) => parsePerson(value, `["${key}"]`, key));
+ }
+
+ const usernames = new Set();
+ const personIds = new Set();
+ for (const person of people) {
+ if (usernames.has(person.username)) {
+ throw new IdentityMapParseError(person.username, `duplicate username "${person.username}"`);
+ }
+ if (personIds.has(person.personId)) {
+ throw new IdentityMapParseError(person.personId, `duplicate personId "${person.personId}"`);
+ }
+ usernames.add(person.username);
+ personIds.add(person.personId);
+ }
+
+ return people;
+}
+
+export function toIdentityPersonPublic(person: IdentityMapPerson) {
+ return {
+ personId: person.personId,
+ username: person.username,
+ ...(person.name !== undefined ? { name: person.name } : {}),
+ links: {
+ ...(person.discord?.id !== undefined ? { discordId: person.discord.id } : {}),
+ ...(person.discord?.username !== undefined
+ ? { discordUsername: person.discord.username }
+ : {}),
+ ...(person.github?.login !== undefined ? { githubLogin: person.github.login } : {}),
+ ...(person.jira?.accountId !== undefined ? { jiraAccountId: person.jira.accountId } : {}),
+ },
+ };
+}
+
+/** Closed-set platform lookups (case-insensitive where appropriate). */
+export function findPersonByDiscordId(
+ people: ReadonlyArray,
+ discordId: string,
+): IdentityMapPerson | null {
+ const id = discordId.trim();
+ if (id.length === 0) return null;
+ return people.find((person) => person.discord?.id === id) ?? null;
+}
+
+export function findPersonByGithubLogin(
+ people: ReadonlyArray,
+ login: string,
+): IdentityMapPerson | null {
+ const normalized = login.trim().replace(/^@/u, "").toLowerCase();
+ if (normalized.length === 0) return null;
+ return people.find((person) => person.github?.login.toLowerCase() === normalized) ?? null;
+}
+
+export function findPersonByGithubId(
+ people: ReadonlyArray,
+ githubId: string | number,
+): IdentityMapPerson | null {
+ const id = String(githubId).replace(/\D/gu, "");
+ if (id.length === 0) return null;
+ return people.find((person) => person.github?.id === id) ?? null;
+}
+
+/** Normalize Atlassian account ids for map lookup (`accountid:` prefix, case). */
+export function normalizeJiraAccountId(accountId: string | null | undefined): string | null {
+ if (accountId === null || accountId === undefined) return null;
+ const trimmed = accountId.trim();
+ if (trimmed.length === 0) return null;
+ const withoutPrefix = trimmed.replace(/^accountid:/iu, "");
+ return withoutPrefix.length > 0 ? withoutPrefix.toLowerCase() : null;
+}
+
+/** Resolve a closed-set person by Jira Cloud accountId (prefix/case-insensitive). */
+export function resolvePersonByJiraAccountId(
+ people: ReadonlyArray,
+ accountId: string | null | undefined,
+): IdentityMapPerson | null {
+ const normalized = normalizeJiraAccountId(accountId);
+ if (normalized === null) return null;
+ for (const person of people) {
+ const mapped = normalizeJiraAccountId(person.jira?.accountId);
+ if (mapped !== null && mapped === normalized) return person;
+ }
+ return null;
+}
+
+export function findPersonByJiraAccountId(
+ people: ReadonlyArray,
+ accountId: string,
+): IdentityMapPerson | null {
+ return resolvePersonByJiraAccountId(people, accountId);
+}
+
+export function findPersonByJiraEmail(
+ people: ReadonlyArray,
+ email: string,
+): IdentityMapPerson | null {
+ const normalized = email.trim().toLowerCase();
+ if (normalized.length === 0) return null;
+ return people.find((person) => person.jira?.email?.toLowerCase() === normalized) ?? null;
+}
+
+/** Schema re-export helper for tests that want branded contracts after parse. */
+export const IdentityMapPersonCount = Schema.Number;
diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts
index 21f8dd47dc5..81793bcf88b 100644
--- a/packages/shared/src/keybindings.ts
+++ b/packages/shared/src/keybindings.ts
@@ -35,8 +35,6 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [
{ key: "mod+-", command: "preview.zoomOut", when: "previewFocus" },
{ key: "mod+0", command: "preview.resetZoom", when: "previewFocus" },
{ key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" },
- { key: "mod+p", command: "filePicker.toggle", when: "!terminalFocus" },
- { key: "mod+shift+f", command: "projectSearch.toggle", when: "!terminalFocus" },
{ key: "mod+s", command: "composer.stash", when: "!terminalFocus" },
{ key: "mod+t", command: "board.open" },
{ key: "mod+n", command: "chat.new", when: "!terminalFocus" },
diff --git a/packages/shared/src/schemaJson.test.ts b/packages/shared/src/schemaJson.test.ts
index 4a4d16da0b3..c808a9b7c51 100644
--- a/packages/shared/src/schemaJson.test.ts
+++ b/packages/shared/src/schemaJson.test.ts
@@ -57,15 +57,6 @@ Done.`),
expect(() => decodeLenientJson('{ "enabled": true,, }')).toThrow();
});
- it("preserves commas before brackets inside string values", () => {
- // A comma inside a string value that happens to precede `}`/`]` must not
- // be stripped as if it were a trailing comma.
- expect(decodeLenientJson('{"note":"a,]"}')).toEqual({ note: "a,]" });
- expect(decodeLenientJson('{"list":["x,}"]}')).toEqual({ list: ["x,}"] });
- // Genuine trailing commas are still removed.
- expect(decodeLenientJson('{"values":[1, 2,],}')).toEqual({ values: [1, 2] });
- });
-
it("formats schema failures with paths without exposing invalid values", () => {
const decodeCredential = decodeJsonResult(Schema.Struct({ token: Schema.Number }));
const decoded = decodeCredential('{"token":"credential=secret-value"}');
diff --git a/packages/shared/src/schemaJson.ts b/packages/shared/src/schemaJson.ts
index 77b1fa5d548..04d26d9c229 100644
--- a/packages/shared/src/schemaJson.ts
+++ b/packages/shared/src/schemaJson.ts
@@ -190,14 +190,8 @@ const parseLenientJsonGetter = SchemaGetter.onSome((input: string) => {
(match, stringLiteral: string | undefined) => (stringLiteral ? match : ""),
);
- // Strip trailing commas before `}` or `]`. The alternation preserves quoted
- // strings so a comma inside a string value (e.g. `{"note":"a,]"}`) is not
- // mistaken for a trailing comma and removed.
- stripped = stripped.replace(
- /("(?:[^"\\]|\\.)*")|,(\s*[}\]])/g,
- (match, stringLiteral: string | undefined, bracket: string | undefined) =>
- stringLiteral ? match : (bracket ?? ""),
- );
+ // Strip trailing commas before `}` or `]`.
+ stripped = stripped.replace(/,(\s*[}\]])/g, "$1");
return decodeJsonString(stripped).pipe(
Effect.map(Option.some),
diff --git a/packages/shared/src/semver.test.ts b/packages/shared/src/semver.test.ts
index ed3e1896aaf..8cbbc150fc9 100644
--- a/packages/shared/src/semver.test.ts
+++ b/packages/shared/src/semver.test.ts
@@ -1,11 +1,6 @@
import { describe, expect, it } from "vite-plus/test";
-import {
- compareSemverVersions,
- normalizeSemverVersion,
- parseSemver,
- satisfiesSemverRange,
-} from "./semver.ts";
+import { compareSemverVersions, normalizeSemverVersion, satisfiesSemverRange } from "./semver.ts";
describe("semver helpers", () => {
it("matches supported range groups", () => {
@@ -23,25 +18,6 @@ describe("semver helpers", () => {
expect(normalizeSemverVersion("2.1")).toBe("2.1.0");
});
- it("normalizes and parses shorthand major-only versions", () => {
- expect(normalizeSemverVersion("20")).toBe("20.0.0");
- expect(normalizeSemverVersion("v18")).toBe("v18.0.0");
- expect(normalizeSemverVersion("20-rc.1")).toBe("20.0.0-rc.1");
- expect(parseSemver("20")).toEqual({ major: 20, minor: 0, patch: 0, prerelease: [] });
- });
-
- it("compares shorthand versions numerically instead of lexically", () => {
- // Regression: "20" vs "9" previously fell back to string comparison, which
- // ordered "20" before "9" ("2" < "9").
- expect(compareSemverVersions("20", "9")).toBeGreaterThan(0);
- expect(compareSemverVersions("18", "18.0.0")).toBe(0);
- });
-
- it("still rejects non-numeric shorthand and keeps empty input empty", () => {
- expect(parseSemver("abc")).toBeNull();
- expect(normalizeSemverVersion("")).toBe("");
- });
-
it("compares prerelease versions before stable versions", () => {
expect(compareSemverVersions("2.1.111-beta.1", "2.1.111")).toBeLessThan(0);
});
diff --git a/packages/shared/src/semver.ts b/packages/shared/src/semver.ts
index a765b065fc6..1a73e33042f 100644
--- a/packages/shared/src/semver.ts
+++ b/packages/shared/src/semver.ts
@@ -17,12 +17,7 @@ export function normalizeSemverVersion(version: string): string {
}
}
- // Pad shorthand versions ("20" or "20.1") up to three segments so major-only
- // and minor-only inputs parse and compare numerically. This matches
- // satisfiesSemverRange, which already treats a missing minor/patch as 0. The
- // length > 0 guard keeps empty/garbage input empty (parseSemver still
- // rejects it), and inputs with more than three segments are left untouched.
- while (segments.length > 0 && segments.length < 3) {
+ if (segments.length === 2) {
segments.push("0");
}
diff --git a/packages/shared/src/sourceAttribution.test.ts b/packages/shared/src/sourceAttribution.test.ts
new file mode 100644
index 00000000000..ce8f831673e
--- /dev/null
+++ b/packages/shared/src/sourceAttribution.test.ts
@@ -0,0 +1,120 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ buildSourceRefFromClaim,
+ mergeParticipantSummaries,
+ nextOriginSource,
+ resolveSourceChannel,
+ sourceChannelFromDeviceType,
+} from "./sourceAttribution.ts";
+
+describe("sourceChannelFromDeviceType", () => {
+ it("maps known device types", () => {
+ expect(sourceChannelFromDeviceType("desktop")).toBe("desktop");
+ expect(sourceChannelFromDeviceType("mobile")).toBe("mobile");
+ expect(sourceChannelFromDeviceType("tablet")).toBe("mobile");
+ expect(sourceChannelFromDeviceType("bot")).toBe("bot");
+ expect(sourceChannelFromDeviceType("unknown")).toBe("unknown");
+ expect(sourceChannelFromDeviceType(undefined)).toBe("unknown");
+ });
+});
+
+describe("resolveSourceChannel", () => {
+ it("accepts the VS Code integration channel hint", () => {
+ expect(resolveSourceChannel({ deviceType: "desktop", channelHint: "vscode" })).toBe("vscode");
+ });
+
+ it("prefers explicit channel hints", () => {
+ expect(resolveSourceChannel({ deviceType: "desktop", channelHint: "discord" })).toBe("discord");
+ });
+
+ it("falls back to device type", () => {
+ expect(resolveSourceChannel({ deviceType: "mobile" })).toBe("mobile");
+ });
+});
+
+describe("buildSourceRefFromClaim", () => {
+ it("stamps person fields from claim", () => {
+ expect(
+ buildSourceRefFromClaim({
+ personId: "patroza",
+ username: "patroza",
+ channel: "desktop",
+ }),
+ ).toEqual({
+ channel: "desktop",
+ personId: "patroza",
+ username: "patroza",
+ });
+ });
+});
+
+describe("nextOriginSource", () => {
+ it("sets origin from first user source only", () => {
+ const first = buildSourceRefFromClaim({
+ personId: "patroza",
+ username: "patroza",
+ channel: "web",
+ });
+ const second = buildSourceRefFromClaim({
+ personId: "julius",
+ username: "julius",
+ channel: "desktop",
+ });
+ expect(nextOriginSource({ current: null, messageSource: first, role: "user" })).toEqual(first);
+ expect(nextOriginSource({ current: first, messageSource: second, role: "user" })).toEqual(
+ first,
+ );
+ expect(nextOriginSource({ current: null, messageSource: first, role: "assistant" })).toBeNull();
+ });
+});
+
+describe("mergeParticipantSummaries", () => {
+ it("appends distinct people and keeps origin first", () => {
+ const origin = {
+ personId: "patroza",
+ username: "patroza",
+ firstChannel: "discord" as const,
+ firstParticipatedAt: "2026-01-01T00:00:00.000Z",
+ };
+ const merged = mergeParticipantSummaries({
+ existing: [origin],
+ source: { personId: "julius", username: "julius", channel: "desktop" },
+ participatedAt: "2026-01-01T00:01:00.000Z",
+ originPersonId: "patroza",
+ });
+ expect(merged.map((entry) => entry.personId)).toEqual(["patroza", "julius"]);
+ });
+
+ it("ignores unmapped sources and folds a person's channels into one summary", () => {
+ const existing = [
+ {
+ personId: "patroza",
+ username: "patroza",
+ firstChannel: "discord" as const,
+ firstParticipatedAt: "2026-01-01T00:00:00.000Z",
+ },
+ ];
+ expect(
+ mergeParticipantSummaries({
+ existing,
+ source: { channel: "discord" },
+ participatedAt: "2026-01-01T00:01:00.000Z",
+ }),
+ ).toEqual(existing);
+ const folded = mergeParticipantSummaries({
+ existing,
+ source: { personId: "patroza", username: "patroza", channel: "desktop" },
+ participatedAt: "2026-01-01T00:02:00.000Z",
+ });
+ expect(folded).toHaveLength(1);
+ expect(folded[0]?.channels).toEqual(["discord", "desktop"]);
+ expect(
+ mergeParticipantSummaries({
+ existing: folded,
+ source: { personId: "patroza", username: "patroza", channel: "desktop" },
+ participatedAt: "2026-01-01T00:03:00.000Z",
+ }),
+ ).toBe(folded);
+ });
+});
diff --git a/packages/shared/src/sourceAttribution.ts b/packages/shared/src/sourceAttribution.ts
new file mode 100644
index 00000000000..ec9cbd8284e
--- /dev/null
+++ b/packages/shared/src/sourceAttribution.ts
@@ -0,0 +1,176 @@
+/**
+ * Server-side SourceRef helpers and thread participant denormalization.
+ *
+ * Clients never invent personId/username — those come from session claim or
+ * platform map resolution. Channel is derived from auth client device type
+ * (or an explicit SourceChannel for integrations).
+ *
+ * See docs/architecture/source-and-identity.md
+ */
+import type { AuthClientMetadataDeviceType, SourceChannel } from "@t3tools/contracts";
+
+export type SourceRefLike = {
+ readonly channel: SourceChannel;
+ readonly personId?: string | undefined;
+ readonly username?: string | undefined;
+ readonly location?: {
+ readonly guildId?: string | undefined;
+ readonly channelId?: string | undefined;
+ readonly threadId?: string | undefined;
+ readonly owner?: string | undefined;
+ readonly repo?: string | undefined;
+ readonly number?: number | undefined;
+ readonly kind?: "pr" | "issue" | undefined;
+ readonly projectKey?: string | undefined;
+ readonly issueKey?: string | undefined;
+ };
+ readonly actor?: {
+ readonly platformId?: string | undefined;
+ readonly displayName?: string | undefined;
+ };
+};
+
+/** Map auth client deviceType → SourceChannel. */
+export function sourceChannelFromDeviceType(
+ deviceType: AuthClientMetadataDeviceType | undefined | null,
+): SourceChannel {
+ switch (deviceType) {
+ case "desktop":
+ return "desktop";
+ case "mobile":
+ case "tablet":
+ return "mobile";
+ case "bot":
+ return "bot";
+ case "unknown":
+ case undefined:
+ case null:
+ return "unknown";
+ default: {
+ const _exhaustive: never = deviceType;
+ void _exhaustive;
+ return "unknown";
+ }
+ }
+}
+
+/**
+ * Prefer an explicit channel (ClientSourceHint / integration) when present;
+ * otherwise derive from session deviceType. Web is not a deviceType today —
+ * browser clients often report desktop; accept explicit "web" when hinted.
+ */
+export function resolveSourceChannel(input: {
+ readonly deviceType?: AuthClientMetadataDeviceType | null | undefined;
+ readonly channelHint?: SourceChannel | null | undefined;
+}): SourceChannel {
+ if (input.channelHint !== undefined && input.channelHint !== null) {
+ return input.channelHint;
+ }
+ return sourceChannelFromDeviceType(input.deviceType);
+}
+
+export function buildSourceRefFromClaim(input: {
+ readonly personId: string;
+ readonly username: string;
+ readonly channel: SourceChannel;
+ readonly location?: SourceRefLike["location"];
+ readonly actor?: SourceRefLike["actor"];
+}): SourceRefLike {
+ return {
+ channel: input.channel,
+ personId: input.personId,
+ username: input.username,
+ ...(input.location !== undefined ? { location: input.location } : {}),
+ ...(input.actor !== undefined ? { actor: input.actor } : {}),
+ };
+}
+
+export type ParticipantSummaryLike = {
+ readonly personId: string;
+ readonly username: string;
+ readonly name?: string | undefined;
+ readonly firstChannel?: SourceChannel | undefined;
+ readonly channels?: ReadonlyArray | undefined;
+ readonly firstParticipatedAt: string;
+};
+
+/**
+ * Merge a user message SourceRef into ordered participant summaries.
+ * Origin person stays first when already present; new people append by
+ * first-participation time (caller passes chronological events).
+ */
+export function mergeParticipantSummaries(input: {
+ readonly existing: ReadonlyArray;
+ readonly source: {
+ readonly personId?: string | undefined;
+ readonly username?: string | undefined;
+ readonly channel: SourceChannel;
+ readonly name?: string | undefined;
+ };
+ readonly participatedAt: string;
+ readonly originPersonId?: string | null | undefined;
+}): ReadonlyArray {
+ const personId = input.source.personId;
+ if (personId === undefined || personId === null || personId.length === 0) {
+ return input.existing;
+ }
+ const username = input.source.username;
+ if (username === undefined || username === null || username.length === 0) {
+ return input.existing;
+ }
+
+ const existingIndex = input.existing.findIndex((entry) => entry.personId === personId);
+ if (existingIndex !== -1) {
+ const existingEntry = input.existing[existingIndex]!;
+ const channels =
+ existingEntry.channels ??
+ (existingEntry.firstChannel === undefined ? [] : [existingEntry.firstChannel]);
+ if (channels.includes(input.source.channel)) {
+ return input.existing;
+ }
+ return input.existing.map((entry, index) =>
+ index === existingIndex
+ ? {
+ ...entry,
+ channels: [...channels, input.source.channel],
+ }
+ : entry,
+ );
+ }
+
+ const nextEntry: ParticipantSummaryLike = {
+ personId,
+ username,
+ ...(input.source.name !== undefined ? { name: input.source.name } : {}),
+ firstChannel: input.source.channel,
+ channels: [input.source.channel],
+ firstParticipatedAt: input.participatedAt,
+ };
+
+ const originId = input.originPersonId ?? null;
+ if (originId !== null && personId === originId) {
+ return [nextEntry, ...input.existing];
+ }
+
+ // Keep origin lead if present, then append by first-seen order.
+ if (originId !== null) {
+ const origin = input.existing.find((entry) => entry.personId === originId);
+ const rest = input.existing.filter((entry) => entry.personId !== originId);
+ if (origin !== undefined) {
+ return [origin, ...rest, nextEntry];
+ }
+ }
+
+ return [...input.existing, nextEntry];
+}
+
+/** First user-message SourceRef becomes origin when none set yet. */
+export function nextOriginSource(input: {
+ readonly current: SourceRefLike | null | undefined;
+ readonly messageSource: SourceRefLike | undefined;
+ readonly role: string;
+}): SourceRefLike | null | undefined {
+ if (input.role !== "user") return input.current;
+ if (input.current !== undefined && input.current !== null) return input.current;
+ return input.messageSource ?? input.current ?? null;
+}
diff --git a/packages/shared/src/threadAttributeSearch.test.ts b/packages/shared/src/threadAttributeSearch.test.ts
new file mode 100644
index 00000000000..9ba392600e3
--- /dev/null
+++ b/packages/shared/src/threadAttributeSearch.test.ts
@@ -0,0 +1,104 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ buildThreadAttributeSearchTerms,
+ threadAttributeSearchMatches,
+ threadMatchesAttributeQuery,
+} from "./threadAttributeSearch.ts";
+
+const sample = {
+ title: "Fix gate SA-123 for multi-user claims",
+ branch: "pr/4521-identity-search",
+ originSource: {
+ channel: "discord" as const,
+ personId: "patroza",
+ username: "patroza",
+ location: {
+ issueKey: "SA-123",
+ number: 4521,
+ kind: "pr" as const,
+ },
+ },
+ participantSummaries: [
+ {
+ personId: "patroza",
+ username: "patroza",
+ name: "Patrick Roza",
+ firstChannel: "discord" as const,
+ },
+ {
+ personId: "julius",
+ username: "julius",
+ firstChannel: "desktop" as const,
+ },
+ ],
+ extraTerms: ["t3-code"],
+};
+
+describe("buildThreadAttributeSearchTerms", () => {
+ it("includes identity handles and channels", () => {
+ const terms = buildThreadAttributeSearchTerms(sample);
+ expect(terms).toEqual(
+ expect.arrayContaining([
+ "patroza",
+ "@patroza",
+ "patroza@discord",
+ "@discord",
+ "discord",
+ "julius",
+ "@julius",
+ "julius@desktop",
+ "@desktop",
+ "desktop",
+ "patrick roza",
+ ]),
+ );
+ });
+
+ it("includes PR and Jira tokens", () => {
+ const terms = buildThreadAttributeSearchTerms(sample);
+ expect(terms).toEqual(
+ expect.arrayContaining(["#4521", "4521", "pr/4521", "pr-4521", "sa-123"]),
+ );
+ });
+
+ it("includes title and branch", () => {
+ const terms = buildThreadAttributeSearchTerms(sample);
+ expect(terms).toEqual(
+ expect.arrayContaining(["fix gate sa-123 for multi-user claims", "pr/4521-identity-search"]),
+ );
+ });
+});
+
+describe("threadMatchesAttributeQuery", () => {
+ it.each([
+ ["@patroza"],
+ ["patroza@discord"],
+ ["@desktop"],
+ ["#4521"],
+ ["4521"],
+ ["SA-123"],
+ ["sa-123"],
+ ["julius"],
+ ["multi-user"],
+ ])("matches %s", (query) => {
+ expect(threadMatchesAttributeQuery(sample, query)).toBe(true);
+ });
+
+ it("rejects unrelated queries", () => {
+ expect(threadMatchesAttributeQuery(sample, "@theo")).toBe(false);
+ expect(threadMatchesAttributeQuery(sample, "#9999")).toBe(false);
+ expect(threadMatchesAttributeQuery(sample, "ZZ-1")).toBe(false);
+ });
+
+ it("empty query matches all", () => {
+ expect(threadMatchesAttributeQuery(sample, " ")).toBe(true);
+ });
+});
+
+describe("threadAttributeSearchMatches", () => {
+ it("matches partial username prefixes", () => {
+ const terms = buildThreadAttributeSearchTerms(sample);
+ expect(threadAttributeSearchMatches(terms, "@patr")).toBe(true);
+ });
+});
diff --git a/packages/shared/src/threadAttributeSearch.ts b/packages/shared/src/threadAttributeSearch.ts
new file mode 100644
index 00000000000..481bb74db82
--- /dev/null
+++ b/packages/shared/src/threadAttributeSearch.ts
@@ -0,0 +1,208 @@
+/**
+ * Search terms and match helpers for thread attributes beyond title/branch:
+ * identity handles (`@user`, `user@channel`, `@channel`), PR numbers (`#123`),
+ * and Jira keys (`SA-123`).
+ *
+ * Pure string helpers for web/mobile command palette and list filters.
+ * See docs/architecture/source-and-identity.md
+ */
+
+export type ThreadAttributeSourceLike = {
+ readonly channel?: string | null | undefined;
+ readonly personId?: string | null | undefined;
+ readonly username?: string | null | undefined;
+ readonly location?:
+ | {
+ readonly number?: number | null | undefined;
+ readonly issueKey?: string | null | undefined;
+ readonly kind?: string | null | undefined;
+ }
+ | null
+ | undefined;
+};
+
+export type ThreadAttributeParticipantLike = {
+ readonly personId?: string | null | undefined;
+ readonly username?: string | null | undefined;
+ readonly name?: string | null | undefined;
+ readonly firstChannel?: string | null | undefined;
+};
+
+export type ThreadAttributeSearchInput = {
+ readonly title?: string | null | undefined;
+ readonly branch?: string | null | undefined;
+ readonly originSource?: ThreadAttributeSourceLike | null | undefined;
+ readonly participantSummaries?: ReadonlyArray | null | undefined;
+ /** Additional free-form terms (project title, etc.). */
+ readonly extraTerms?: ReadonlyArray | null | undefined;
+};
+
+/** Jira-style issue keys: PROJ-123, SA-49, … */
+const JIRA_KEY_PATTERN = /\b([A-Za-z][A-Za-z0-9]+-\d+)\b/g;
+/** Explicit PR markers in free text / branch names. */
+const PR_HASH_PATTERN = /#(\d+)\b/g;
+const PR_SLUG_PATTERN = /\b(?:pr|pull)[-_/]?(\d+)\b/gi;
+
+function addTerm(into: Set, raw: string | null | undefined): void {
+ if (raw === null || raw === undefined) return;
+ const trimmed = raw.trim().toLowerCase();
+ if (trimmed.length === 0) return;
+ into.add(trimmed);
+}
+
+function addPersonTerms(
+ into: Set,
+ person: {
+ readonly username?: string | null | undefined;
+ readonly personId?: string | null | undefined;
+ readonly name?: string | null | undefined;
+ readonly channel?: string | null | undefined;
+ },
+): void {
+ const username = person.username?.trim().toLowerCase() ?? "";
+ const personId = person.personId?.trim().toLowerCase() ?? "";
+ const channel = person.channel?.trim().toLowerCase() ?? "";
+ const name = person.name?.trim().toLowerCase() ?? "";
+
+ if (username.length > 0) {
+ addTerm(into, username);
+ addTerm(into, `@${username}`);
+ if (channel.length > 0) {
+ addTerm(into, `${username}@${channel}`);
+ }
+ }
+ if (personId.length > 0 && personId !== username) {
+ addTerm(into, personId);
+ addTerm(into, `@${personId}`);
+ if (channel.length > 0) {
+ addTerm(into, `${personId}@${channel}`);
+ }
+ }
+ if (name.length > 0) {
+ addTerm(into, name);
+ }
+}
+
+function addChannelTerms(into: Set, channel: string | null | undefined): void {
+ const normalized = channel?.trim().toLowerCase() ?? "";
+ if (normalized.length === 0) return;
+ addTerm(into, normalized);
+ addTerm(into, `@${normalized}`);
+}
+
+function addPrNumber(into: Set, value: number | string): void {
+ const digits = String(value).replace(/\D/g, "");
+ if (digits.length === 0) return;
+ addTerm(into, digits);
+ addTerm(into, `#${digits}`);
+ addTerm(into, `pr-${digits}`);
+ addTerm(into, `pr/${digits}`);
+}
+
+function extractFromText(into: Set, text: string | null | undefined): void {
+ if (text === null || text === undefined || text.trim().length === 0) return;
+ const source = text;
+
+ for (const match of source.matchAll(JIRA_KEY_PATTERN)) {
+ const key = match[1];
+ if (key !== undefined) addTerm(into, key);
+ }
+ for (const match of source.matchAll(PR_HASH_PATTERN)) {
+ const n = match[1];
+ if (n !== undefined) addPrNumber(into, n);
+ }
+ for (const match of source.matchAll(PR_SLUG_PATTERN)) {
+ const n = match[1];
+ if (n !== undefined) addPrNumber(into, n);
+ }
+}
+
+/**
+ * Build a deduped, lowercased bag of search terms for a thread.
+ * Suitable for command-palette `searchTerms` and list filters.
+ */
+export function buildThreadAttributeSearchTerms(
+ input: ThreadAttributeSearchInput,
+): ReadonlyArray {
+ const terms = new Set();
+
+ addTerm(terms, input.title);
+ addTerm(terms, input.branch);
+ if (input.branch !== null && input.branch !== undefined && input.branch.trim().length > 0) {
+ addTerm(terms, `#${input.branch.trim()}`);
+ }
+
+ extractFromText(terms, input.title);
+ extractFromText(terms, input.branch);
+
+ const origin = input.originSource ?? null;
+ if (origin !== null) {
+ addChannelTerms(terms, origin.channel);
+ addPersonTerms(terms, {
+ username: origin.username,
+ personId: origin.personId,
+ channel: origin.channel,
+ });
+ if (origin.location?.number !== undefined && origin.location.number !== null) {
+ addPrNumber(terms, origin.location.number);
+ }
+ if (origin.location?.issueKey) {
+ addTerm(terms, origin.location.issueKey);
+ }
+ }
+
+ for (const participant of input.participantSummaries ?? []) {
+ addPersonTerms(terms, {
+ username: participant.username,
+ personId: participant.personId,
+ name: participant.name,
+ channel: participant.firstChannel,
+ });
+ addChannelTerms(terms, participant.firstChannel);
+ }
+
+ for (const extra of input.extraTerms ?? []) {
+ addTerm(terms, extra);
+ extractFromText(terms, extra);
+ }
+
+ return [...terms];
+}
+
+/**
+ * Whether any search term matches the query (substring, case-insensitive).
+ * Query is normalized the same way as terms (trim + lower).
+ */
+export function threadAttributeSearchMatches(terms: ReadonlyArray, query: string): boolean {
+ const normalizedQuery = query.trim().toLowerCase().replace(/\s+/g, " ");
+ if (normalizedQuery.length === 0) return true;
+ if (terms.length === 0) return false;
+
+ // Direct term substring (covers @user, user@channel, #123, sa-123, title words).
+ for (const term of terms) {
+ if (term.includes(normalizedQuery) || normalizedQuery.includes(term)) {
+ // Prefer: query is a prefix/substring of a term (user typed partial handle).
+ if (term.includes(normalizedQuery)) return true;
+ }
+ }
+
+ // Joined haystack for multi-word title queries.
+ const haystack = terms.join(" ");
+ if (haystack.includes(normalizedQuery)) return true;
+
+ // `#42` vs bare `42` already both in terms when PR-linked.
+ // `@desktop` is stored as both `desktop` and `@desktop`.
+ return false;
+}
+
+/**
+ * Convenience: build terms and match in one call.
+ */
+export function threadMatchesAttributeQuery(
+ input: ThreadAttributeSearchInput,
+ query: string,
+): boolean {
+ const normalizedQuery = query.trim();
+ if (normalizedQuery.length === 0) return true;
+ return threadAttributeSearchMatches(buildThreadAttributeSearchTerms(input), normalizedQuery);
+}
diff --git a/scripts/mobile-showcase-environment.ts b/scripts/mobile-showcase-environment.ts
index f7854675351..9c04c7e9dd1 100644
--- a/scripts/mobile-showcase-environment.ts
+++ b/scripts/mobile-showcase-environment.ts
@@ -1,4 +1,4 @@
-// @effect-diagnostics nodeBuiltinImport:off globalTimers:off globalDate:off - This host-side fixture creates an isolated local T3 environment.
+// @effect-diagnostics nodeBuiltinImport:off globalDate:off - This host-side fixture creates an isolated local T3 environment.
import * as NodeChildProcess from "node:child_process";
import * as NodeFSP from "node:fs/promises";
import * as NodePath from "node:path";
@@ -387,48 +387,6 @@ function insertThread(
.run(input.id, isWorking ? "running" : "ready", isWorking ? turnId : null, updatedAt);
}
-const SEEDED_PROJECTION_TABLES = [
- "projection_pending_approvals",
- "projection_thread_proposed_plans",
- "projection_thread_activities",
- "projection_thread_messages",
- "projection_thread_sessions",
- "projection_turns",
- "projection_threads",
- "projection_projects",
- "projection_state",
-] as const;
-
-function hasSeedableSchema(dbPath: string): boolean {
- let database: NodeSqlite.DatabaseSync;
- try {
- database = new NodeSqlite.DatabaseSync(dbPath, { readOnly: true });
- } catch {
- return false;
- }
- try {
- const row = database
- .prepare(
- `SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name IN (${SEEDED_PROJECTION_TABLES.map(() => "?").join(", ")})`,
- )
- .get(...SEEDED_PROJECTION_TABLES) as { count: number };
- return row.count === SEEDED_PROJECTION_TABLES.length;
- } catch {
- return false;
- } finally {
- database.close();
- }
-}
-
-async function waitForSeedableSchema(dbPath: string, timeoutMs = 60_000): Promise {
- const deadline = Date.now() + timeoutMs;
- while (Date.now() < deadline) {
- if (hasSeedableSchema(dbPath)) return;
- await new Promise((resolve) => setTimeout(resolve, 250));
- }
- throw new Error(`The environment server did not migrate ${dbPath} within ${timeoutMs}ms.`);
-}
-
function seedDatabase(
dbPath: string,
workspaceRoots: ReadonlyMap,
@@ -443,7 +401,17 @@ function seedDatabase(
const database = new NodeSqlite.DatabaseSync(dbPath, { timeout: 30_000 });
try {
database.exec("BEGIN IMMEDIATE");
- for (const table of SEEDED_PROJECTION_TABLES) {
+ for (const table of [
+ "projection_pending_approvals",
+ "projection_thread_proposed_plans",
+ "projection_thread_activities",
+ "projection_thread_messages",
+ "projection_thread_sessions",
+ "projection_turns",
+ "projection_threads",
+ "projection_projects",
+ "projection_state",
+ ]) {
database.exec(`DELETE FROM ${table}`);
}
const insertProject = database.prepare(
@@ -626,9 +594,6 @@ export async function seedShowcaseEnvironment(input: {
});
}),
);
- // The environment server begins listening before it finishes migrating the
- // database, so wait for the schema before deleting from and reseeding it.
- await waitForSeedableSchema(dbPath);
seedDatabase(dbPath, workspaceRoots, projects, threads, now);
const terminalDirectory = NodePath.join(input.baseDir, "userdata", "logs", "terminals");
diff --git a/scripts/mobile-showcase.config.ts b/scripts/mobile-showcase.config.ts
index 4643c06ccfd..ee2fd325257 100644
--- a/scripts/mobile-showcase.config.ts
+++ b/scripts/mobile-showcase.config.ts
@@ -124,13 +124,12 @@ const config: ShowcaseConfig = {
simulator: "iPad Pro 13-inch (M5)",
simulatorDeviceType: "com.apple.CoreSimulator.SimDeviceType.iPad-Pro-13-inch-M5-16GB",
appearance: "dark",
- orientation: "landscape",
scenes: ["thread", "terminal", "review", "threads", "environments"],
storeAsset: {
store: "apple",
directory: "apple/ipad-13",
- width: 2752,
- height: 2064,
+ width: 2064,
+ height: 2752,
minimumUploadCount: 1,
maximumUploadCount: 10,
},
diff --git a/scripts/mobile-showcase.test.ts b/scripts/mobile-showcase.test.ts
index 16fb3e230bb..a0deadbef65 100644
--- a/scripts/mobile-showcase.test.ts
+++ b/scripts/mobile-showcase.test.ts
@@ -216,18 +216,17 @@ it("configures every default device with an exact upload-ready store target", ()
assert.deepStrictEqual(
showcaseConfig.devices.map((device) => [
device.id,
- device.platform === "ios" ? (device.orientation ?? "portrait") : null,
device.storeAsset.directory,
device.storeAsset.width,
device.storeAsset.height,
]),
[
- ["iphone-6.9", "portrait", "apple/iphone-6.9", 1320, 2868],
- ["iphone-6.5", "portrait", "apple/iphone-6.5", 1284, 2778],
- ["ipad-13", "landscape", "apple/ipad-13", 2752, 2064],
- ["pixel", null, "google-play/phone", 1080, 1920],
- ["android-tablet-7", null, "google-play/tablet-7", 1080, 1920],
- ["android-tablet-10", null, "google-play/tablet-10", 1440, 2560],
+ ["iphone-6.9", "apple/iphone-6.9", 1320, 2868],
+ ["iphone-6.5", "apple/iphone-6.5", 1284, 2778],
+ ["ipad-13", "apple/ipad-13", 2064, 2752],
+ ["pixel", "google-play/phone", 1080, 1920],
+ ["android-tablet-7", "google-play/tablet-7", 1080, 1920],
+ ["android-tablet-10", "google-play/tablet-10", 1440, 2560],
],
);
});