{/* The project always leads the header: knowing which project a
thread lives in is priority zero, and the thread title alone
@@ -119,19 +240,58 @@ export const ChatHeader = memo(function ChatHeader({
) : null}
-
-
+ {renamingTitle !== null ? (
+ {
+ if (renameCommittedRef.current) return;
+ commitRename(event.currentTarget.value);
+ }}
+ onFocus={(event) => event.currentTarget.select()}
+ onKeyDown={handleRenameKeyDown}
+ />
+ ) : isServerThread ? (
+
+
+ }
+ >
+
{activeThreadTitle}
- }
- />
- {activeThreadTitle}
-
+
+
+ {activeThreadTitle}
+
+ ) : (
+
+
+ {activeThreadTitle}
+
+ }
+ />
+ {activeThreadTitle}
+
+ )}
({
+const banner = (
+ id: string,
+ variant: ComposerBannerStackItem["variant"] = "warning",
+): ComposerBannerStackItem => ({
id,
- variant: "warning",
+ variant,
icon:
!,
title: `${id} warning`,
});
@@ -29,6 +32,19 @@ describe("ComposerBannerStack", () => {
expect(markup).toContain("group-focus-within/banner-stack:visible");
});
+ it("colors the collapsed stack cap by the hidden banner's variant, not a fixed warning", () => {
+ const neutralBehind = renderToStaticMarkup(
+
,
+ );
+ expect(neutralBehind).toContain("border-border");
+ expect(neutralBehind).not.toContain("border-warning/24");
+
+ const warningBehind = renderToStaticMarkup(
+
,
+ );
+ expect(warningBehind).toContain("border-warning/24");
+ });
+
it("does not render an expandable region for a single banner", () => {
const markup = renderToStaticMarkup(
);
diff --git a/apps/web/src/components/chat/ComposerBannerStack.tsx b/apps/web/src/components/chat/ComposerBannerStack.tsx
index 548bd0f4262ed..41a717d07ce42 100644
--- a/apps/web/src/components/chat/ComposerBannerStack.tsx
+++ b/apps/web/src/components/chat/ComposerBannerStack.tsx
@@ -22,9 +22,23 @@ const exitTransitionStyle = {
transition: `transform ${DISMISS_TRANSITION_MS}ms ease-in, opacity ${DISMISS_TRANSITION_MS}ms ease-in`,
} satisfies CSSProperties;
+// The collapsed cap peeking above the front banner is the only hint that more
+// banners are stacked behind it, so its border must match the severity of the
+// first hidden banner — a neutral banner must not masquerade as a warning.
+const stackCapBorderClass: Record
= {
+ default: "border-border",
+ error: "border-destructive/24",
+ info: "border-info/24",
+ success: "border-success/24",
+ warning: "border-warning/24",
+};
+
export interface ComposerBannerStackItem {
readonly id: string;
readonly variant: "default" | "error" | "info" | "success" | "warning";
+ // Ordering hint for stack assemblers: front this banner even though its
+ // variant is calm (e.g. live update progress). The stack itself ignores it.
+ readonly urgent?: boolean;
readonly icon: ReactNode;
readonly title: ReactNode;
readonly description?: ReactNode;
@@ -67,6 +81,7 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
const stackedItems = items.slice(1);
const hasStack = stackedItems.length > 0;
const showCollapsedStackCap = hasStack && exitingItemId !== frontItem.id;
+ const firstStackedItem = stackedItems[0];
const requestDismiss = (item: ComposerBannerStackItem) => {
if (!item.onDismiss || exitingItemId) {
@@ -90,11 +105,12 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
hasStack ? "group-hover/banner-stack:z-50 group-focus-within/banner-stack:z-50" : null,
)}
>
- {showCollapsedStackCap ? (
+ {showCollapsedStackCap && firstStackedItem ? (
{
anchorMaxSize?: number;
anchorOffset?: number;
onReady?: (info: { anchorIndex: number }) => void;
- onSizeChanged?: (size: number) => void;
};
contentInsetEndAdjustment?: number;
className?: string;
@@ -42,7 +41,6 @@ vi.mock("@legendapp/list/react", async () => {
ref?: Ref
;
}) => {
if (props.anchoredEndSpace) {
- props.anchoredEndSpace.onSizeChanged?.(240);
props.anchoredEndSpace.onReady?.({ anchorIndex: props.anchoredEndSpace.anchorIndex });
}
return (
@@ -90,6 +88,11 @@ vi.mock("@legendapp/list/react", async () => {
? props.maintainVisibleContentPosition.size
: undefined
}
+ data-maintain-visible-content-position-restore={
+ typeof props.maintainVisibleContentPosition === "object"
+ ? Boolean(props.maintainVisibleContentPosition.shouldRestorePosition)
+ : undefined
+ }
>
{props.ListHeaderComponent}
{props.data.map((item) => (
@@ -192,7 +195,6 @@ function buildProps() {
workspaceRoot: undefined,
anchorMessageId: null,
onAnchorReady: () => {},
- onAnchorSizeChanged: () => {},
contentInsetEndAdjustment: 0,
liveFollowEnabled: true,
onIsAtEndChange: () => {},
@@ -386,7 +388,6 @@ describe("MessagesTimeline", () => {
it("anchors a sent attachment message using its measured height", () => {
const onAnchorReady = vi.fn();
- const onAnchorSizeChanged = vi.fn();
const firstEntry = buildUserTimelineEntry("First prompt.");
const secondEntry = {
...buildUserTimelineEntry("Newest prompt."),
@@ -411,7 +412,6 @@ describe("MessagesTimeline", () => {
{...buildProps()}
anchorMessageId={secondEntry.message.id}
onAnchorReady={onAnchorReady}
- onAnchorSizeChanged={onAnchorSizeChanged}
contentInsetEndAdjustment={144}
timelineEntries={[firstEntry, secondEntry]}
/>,
@@ -426,10 +426,10 @@ describe("MessagesTimeline", () => {
expect(markup).not.toContain('data-maintain-scroll-at-end="enabled"');
expect(markup).toContain('data-maintain-visible-content-position="object"');
expect(markup).toContain('data-maintain-visible-content-position-data="true"');
- expect(markup).toContain('data-maintain-visible-content-position-size="false"');
+ expect(markup).toContain('data-maintain-visible-content-position-size="true"');
+ expect(markup).toContain('data-maintain-visible-content-position-restore="true"');
expect(onAnchorReady).toHaveBeenCalledOnce();
expect(onAnchorReady).toHaveBeenCalledWith(secondEntry.message.id, 1);
- expect(onAnchorSizeChanged).toHaveBeenCalledWith(secondEntry.message.id, 240);
});
it("renders collapse controls for long user messages", () => {
diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx
index ec9ef1bf708a4..c6e28dcef5c53 100644
--- a/apps/web/src/components/chat/MessagesTimeline.tsx
+++ b/apps/web/src/components/chat/MessagesTimeline.tsx
@@ -29,7 +29,6 @@ import {
type MouseEvent,
type ReactNode,
} from "react";
-import { flushSync } from "react-dom";
import { LegendList, type LegendListRef } from "@legendapp/list/react";
import { FileDiff } from "@pierre/diffs/react";
import {
@@ -142,7 +141,7 @@ interface TimelineRowSharedState {
onImageExpand: (preview: ExpandedImagePreview) => void;
onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
onToggleTurnFold: (turnId: TurnId) => void;
- onToggleWorkGroup: (groupId: string, anchorElement?: HTMLElement) => void;
+ onToggleWorkGroup: (groupId: string, anchorKey: string) => void;
agentPanelModel: AgentPanelModel;
onOpenAgents: () => void;
}
@@ -189,6 +188,14 @@ function TimelineLoadEarlierHeader({
}
const TIMELINE_LIST_FOOTER = ;
const EMPTY_TIMELINE_SKILLS: ReadonlyArray> = [];
+const TIMELINE_MAINTAIN_SCROLL_AT_END = {
+ animated: false,
+ on: {
+ dataChange: true,
+ itemLayout: true,
+ layout: true,
+ },
+} as const;
// ---------------------------------------------------------------------------
// Props (public API)
@@ -220,7 +227,6 @@ interface MessagesTimelineProps {
skills?: ReadonlyArray>;
anchorMessageId: MessageId | null;
onAnchorReady: (messageId: MessageId, anchorIndex: number) => void;
- onAnchorSizeChanged: (messageId: MessageId, size: number) => void;
contentInsetEndAdjustment: number;
/**
* Whether the timeline should keep pinning to the live edge as content
@@ -267,7 +273,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({
skills = EMPTY_TIMELINE_SKILLS,
anchorMessageId,
onAnchorReady,
- onAnchorSizeChanged,
contentInsetEndAdjustment,
liveFollowEnabled,
onIsAtEndChange,
@@ -278,51 +283,85 @@ export const MessagesTimeline = memo(function MessagesTimeline({
}: MessagesTimelineProps) {
const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set());
const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(new Set());
+ const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false);
const [minimapStripMap] = useState(() => new Map());
+ const disclosureAnchorKeyRef = useRef(null);
+ const disclosureSettleFrameRef = useRef(null);
+ const disclosureSettleSecondFrameRef = useRef(null);
- const onToggleTurnFold = useCallback((turnId: TurnId) => {
- setExpandedTurnIds((existing) => {
- const next = new Set(existing);
- if (next.has(turnId)) {
- next.delete(turnId);
- } else {
- next.add(turnId);
+ useEffect(() => {
+ return () => {
+ if (disclosureSettleFrameRef.current !== null) {
+ cancelAnimationFrame(disclosureSettleFrameRef.current);
}
- return next;
- });
+ if (disclosureSettleSecondFrameRef.current !== null) {
+ cancelAnimationFrame(disclosureSettleSecondFrameRef.current);
+ }
+ };
}, []);
- const onToggleWorkGroup = useCallback(
- (groupId: string, anchorElement?: HTMLElement) => {
- const anchorBottomBeforeToggle = anchorElement?.getBoundingClientRect().bottom ?? null;
- flushSync(() => {
- setExpandedWorkGroupIds((existing) => {
- const next = new Set(existing);
- if (next.has(groupId)) {
- next.delete(groupId);
- } else {
- next.add(groupId);
- }
- return next;
- });
+ const suspendEndScrollMaintenanceForDisclosure = useCallback((anchorKey: string) => {
+ disclosureAnchorKeyRef.current = anchorKey;
+ setDisclosureToggleSettling(true);
+ if (disclosureSettleFrameRef.current !== null) {
+ cancelAnimationFrame(disclosureSettleFrameRef.current);
+ }
+ if (disclosureSettleSecondFrameRef.current !== null) {
+ cancelAnimationFrame(disclosureSettleSecondFrameRef.current);
+ }
+ disclosureSettleFrameRef.current = requestAnimationFrame(() => {
+ disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => {
+ disclosureAnchorKeyRef.current = null;
+ setDisclosureToggleSettling(false);
+ disclosureSettleFrameRef.current = null;
+ disclosureSettleSecondFrameRef.current = null;
});
+ });
+ }, []);
- if (anchorBottomBeforeToggle === null || !anchorElement) {
- return;
- }
+ const shouldRestoreVisibleContentPosition = useCallback((row: MessagesTimelineRow) => {
+ const disclosureAnchorKey = disclosureAnchorKeyRef.current;
+ return disclosureAnchorKey === null || row.id === disclosureAnchorKey;
+ }, []);
- const delta = anchorElement.getBoundingClientRect().bottom - anchorBottomBeforeToggle;
- if (Math.abs(delta) < 0.5) {
- return;
- }
+ const maintainVisibleContentPosition = useMemo(
+ () => ({
+ data: true,
+ size: true,
+ shouldRestorePosition: shouldRestoreVisibleContentPosition,
+ }),
+ [shouldRestoreVisibleContentPosition],
+ );
- const list = listRef.current;
- const currentScroll = list?.getState?.().scroll;
- if (list && typeof currentScroll === "number") {
- list.scrollToOffset({ offset: currentScroll + delta, animated: false });
- }
+ const onToggleTurnFold = useCallback(
+ (turnId: TurnId) => {
+ suspendEndScrollMaintenanceForDisclosure(`turn-fold:${turnId}`);
+ setExpandedTurnIds((existing) => {
+ const next = new Set(existing);
+ if (next.has(turnId)) {
+ next.delete(turnId);
+ } else {
+ next.add(turnId);
+ }
+ return next;
+ });
+ },
+ [suspendEndScrollMaintenanceForDisclosure],
+ );
+ const onToggleWorkGroup = useCallback(
+ (groupId: string, anchorKey: string) => {
+ suspendEndScrollMaintenanceForDisclosure(anchorKey);
+ setExpandedWorkGroupIds((existing) => {
+ const next = new Set(existing);
+ if (next.has(groupId)) {
+ next.delete(groupId);
+ } else {
+ next.add(groupId);
+ }
+ return next;
+ });
},
- [listRef],
+ [suspendEndScrollMaintenanceForDisclosure],
);
// An in-session interrupt leaves its turn expanded so the user keeps their
@@ -394,22 +433,12 @@ export const MessagesTimeline = memo(function MessagesTimeline({
},
[anchorMessageId, onAnchorReady],
);
- const handleAnchorSizeChanged = useCallback(
- (size: number) => {
- if (anchorMessageId !== null) {
- onAnchorSizeChanged(anchorMessageId, size);
- }
- },
- [anchorMessageId, onAnchorSizeChanged],
- );
const anchoredEndSpace = useMemo(() => {
const config = resolveChatListAnchoredEndSpace(rows, anchorMessageId, (row) =>
row.kind === "message" ? row.message.id : null,
);
- return config
- ? { ...config, onReady: handleAnchorReady, onSizeChanged: handleAnchorSizeChanged }
- : undefined;
- }, [anchorMessageId, handleAnchorReady, handleAnchorSizeChanged, rows]);
+ return config ? { ...config, onReady: handleAnchorReady } : undefined;
+ }, [anchorMessageId, handleAnchorReady, rows]);
const handleScroll = useCallback(() => {
const state = listRef.current?.getState?.();
@@ -554,21 +583,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({
{...(anchoredEndSpace ? { anchoredEndSpace } : {})}
contentInsetEndAdjustment={contentInsetEndAdjustment}
maintainScrollAtEnd={
- anchoredEndSpace || !liveFollowEnabled
+ anchoredEndSpace || !liveFollowEnabled || disclosureToggleSettling
? false
- : {
- animated: false,
- on: {
- dataChange: true,
- itemLayout: true,
- layout: true,
- },
- }
+ : TIMELINE_MAINTAIN_SCROLL_AT_END
}
- maintainVisibleContentPosition={{
- data: true,
- size: false,
- }}
+ maintainVisibleContentPosition={maintainVisibleContentPosition}
onScroll={handleScroll}
className={cn(
"scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5",
@@ -1381,11 +1400,7 @@ function WorkGroupToggleTimelineRow({
type="button"
className="flex w-full cursor-pointer items-center gap-1.5 rounded-md px-0.5 py-0.5 text-left text-[12px] leading-5 transition-colors duration-150 hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
aria-expanded={row.expanded}
- onClick={(event) => {
- const anchorElement =
- event.currentTarget.closest("[data-timeline-row-id]") ?? event.currentTarget;
- ctx.onToggleWorkGroup(row.groupId, anchorElement);
- }}
+ onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
>
item.id);
+}
+
+describe("buildThreadActionMenuItems", () => {
+ it("hides lifecycle items when the environment lacks the capabilities", () => {
+ expect(
+ ids({
+ ...baseState,
+ supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false },
+ }),
+ ).toEqual(["rename", "mark-unread", "copy-path", "delete"]);
+ });
+
+ it("includes branch items only for threads with a branch", () => {
+ const withBranch = ids({ ...baseState, branch: "feat/menu" });
+ expect(withBranch).toContain("new-thread-on-branch");
+ expect(withBranch).toContain("copy-branch");
+ expect(ids(baseState)).not.toContain("new-thread-on-branch");
+ expect(ids(baseState)).not.toContain("copy-branch");
+ });
+
+ it("flips lifecycle labels with thread state", () => {
+ expect(ids({ ...baseState, isPinned: true, isSettled: true, isSnoozed: true })).toEqual(
+ expect.arrayContaining(["unpin", "unsettle", "unsnooze"]),
+ );
+ expect(ids(baseState)).toEqual(expect.arrayContaining(["pin", "settle", "snooze"]));
+ });
+
+ it("disables snooze when the thread cannot snooze, keeping presets visible", () => {
+ const snooze = buildThreadActionMenuItems({ ...baseState, canSnoozeNow: false }).find(
+ (item) => item.id === "snooze",
+ );
+ expect(snooze?.disabled).toBe(true);
+ expect(snooze?.children?.map((child) => child.id)).toEqual(["snooze:hour"]);
+ });
+
+ it("disables title regeneration while one is in flight", () => {
+ const item = buildThreadActionMenuItems({ ...baseState, isRegeneratingTitle: true }).find(
+ (candidate) => candidate.id === "regenerate-title",
+ );
+ expect(item).toMatchObject({ label: "Regenerating…", disabled: true });
+ });
+
+ it("marks delete as destructive and keeps it last", () => {
+ const items = buildThreadActionMenuItems({ ...baseState, branch: "main" });
+ expect(items.at(-1)).toMatchObject({ id: "delete", destructive: true });
+ });
+});
diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts
new file mode 100644
index 0000000000000..66aaf3debf541
--- /dev/null
+++ b/apps/web/src/components/threadActionMenu.logic.ts
@@ -0,0 +1,105 @@
+import type { ContextMenuItem } from "@t3tools/contracts";
+import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled";
+
+/**
+ * Ids for the per-thread action menu. Snooze presets are dispatched as
+ * `snooze:` so the union stays closed while the preset list
+ * remains data-driven.
+ */
+export type ThreadActionMenuId =
+ | "new-thread-on-branch"
+ | "pin"
+ | "unpin"
+ | "settle"
+ | "unsettle"
+ | "snooze"
+ | `snooze:${string}`
+ | "unsnooze"
+ | "rename"
+ | "regenerate-title"
+ | "mark-unread"
+ | "copy-path"
+ | "copy-branch"
+ | "delete";
+
+export interface ThreadActionMenuState {
+ readonly branch: string | null;
+ readonly isPinned: boolean;
+ readonly isSettled: boolean;
+ readonly isSnoozed: boolean;
+ readonly canSnoozeNow: boolean;
+ readonly isRegeneratingTitle: boolean;
+ readonly supports: {
+ readonly settlement: boolean;
+ readonly snooze: boolean;
+ readonly pinning: boolean;
+ readonly titleRegeneration: boolean;
+ };
+ readonly snoozePresets: ReadonlyArray;
+}
+
+/**
+ * Single source for the per-thread action menu: the sidebar row's right-click
+ * menu and the chat header menu both render exactly this list, so labels,
+ * ordering, and capability gating cannot drift between the two surfaces.
+ */
+export function buildThreadActionMenuItems(
+ state: ThreadActionMenuState,
+): ReadonlyArray> {
+ return [
+ ...(state.branch
+ ? [
+ {
+ id: "new-thread-on-branch" as const,
+ label: `New thread on ${state.branch}`,
+ },
+ ]
+ : []),
+ ...(state.supports.pinning
+ ? [
+ state.isPinned
+ ? { id: "unpin" as const, label: "Unpin thread" }
+ : { id: "pin" as const, label: "Pin thread" },
+ ]
+ : []),
+ // Both lifecycle actions stay available on pinned threads: settling
+ // clears the pin ("done" beats "keep on top"), and snoozing hides the
+ // card until wake with the pin intact.
+ ...(state.supports.settlement
+ ? [
+ state.isSettled
+ ? { id: "unsettle" as const, label: "Un-settle thread" }
+ : { id: "settle" as const, label: "Settle thread" },
+ ]
+ : []),
+ ...(state.supports.snooze
+ ? [
+ state.isSnoozed
+ ? { id: "unsnooze" as const, label: "Wake thread" }
+ : {
+ id: "snooze" as const,
+ label: "Snooze",
+ disabled: !state.canSnoozeNow,
+ children: state.snoozePresets.map((preset) => ({
+ id: `snooze:${preset.id}` as const,
+ label: `${preset.label} (${preset.whenLabel})`,
+ })),
+ },
+ ]
+ : []),
+ { id: "rename", label: "Rename thread" },
+ ...(state.supports.titleRegeneration
+ ? [
+ {
+ id: "regenerate-title" as const,
+ label: state.isRegeneratingTitle ? "Regenerating…" : "Regenerate title",
+ disabled: state.isRegeneratingTitle,
+ },
+ ]
+ : []),
+ { id: "mark-unread", label: "Mark unread" },
+ { id: "copy-path", label: "Copy path", icon: "copy" },
+ ...(state.branch ? [{ id: "copy-branch" as const, label: "Copy branch", icon: "copy" }] : []),
+ { id: "delete", label: "Delete", destructive: true, icon: "trash" },
+ ];
+}
diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts
new file mode 100644
index 0000000000000..85ffde776b43a
--- /dev/null
+++ b/apps/web/src/hooks/useThreadActionMenu.ts
@@ -0,0 +1,299 @@
+import { scopeProjectRef, scopedThreadKey } from "@t3tools/client-runtime/environment";
+import {
+ type AtomCommandResult,
+ isAtomCommandInterrupted,
+ settlePromise,
+ squashAtomCommandFailure,
+} from "@t3tools/client-runtime/state/runtime";
+import {
+ canSnooze,
+ effectiveSettled,
+ effectiveSnoozed,
+ type ChangeRequestStateLike,
+} from "@t3tools/client-runtime/state/thread-settled";
+import type { ScopedThreadRef } from "@t3tools/contracts";
+import { useCallback } from "react";
+
+import { resolveSnoozePresets, snoozeWakeDescription } from "../components/Sidebar.snooze";
+import {
+ buildThreadActionMenuItems,
+ type ThreadActionMenuId,
+} from "../components/threadActionMenu.logic";
+import { stackedThreadToast, toastManager } from "../components/ui/toast";
+import { threadEnvironment } from "../state/threads";
+import { useAtomCommand } from "../state/use-atom-command";
+import {
+ readEnvironmentSupportsPinning,
+ readEnvironmentSupportsSettlement,
+ readEnvironmentSupportsSnooze,
+ readEnvironmentSupportsTitleRegeneration,
+ readThreadShell,
+} from "../state/entities";
+import { readLocalApi } from "../localApi";
+import { useUiStateStore } from "../uiStateStore";
+import { useCopyToClipboard } from "./useCopyToClipboard";
+import { useNewThreadHandler } from "./useHandleNewThread";
+import { useClientSettings } from "./useSettings";
+import { useThreadActions } from "./useThreadActions";
+
+function failureToast(title: string, error: unknown) {
+ toastManager.add(
+ stackedThreadToast({
+ type: "error",
+ title,
+ description: error instanceof Error ? error.message : "An error occurred.",
+ }),
+ );
+}
+
+/**
+ * The per-thread action menu (pin, settle, snooze, rename, copy, delete…) as
+ * a self-contained hook, for surfaces other than the sidebar row — today the
+ * chat header. Renders through the native context-menu bridge and dispatches
+ * through the same mutations the sidebar uses.
+ *
+ * Unlike the sidebar, settle and snooze here never navigate away: the caller
+ * is acting on the thread they are reading, and ChatView's parked-thread
+ * banner already offers the way back.
+ */
+export function useThreadActionMenu(input: {
+ readonly threadRef: ScopedThreadRef | null;
+ /** Fallback for "Copy path" when the thread has no worktree. */
+ readonly projectCwd: string | null;
+ /** PR state feeding auto-settle classification, as resolved by the caller. */
+ readonly changeRequestState: ChangeRequestStateLike | null;
+ readonly onStartRename: () => void;
+}) {
+ const { threadRef, projectCwd, changeRequestState, onStartRename } = input;
+ const {
+ settleThread,
+ unsettleThread,
+ snoozeThread,
+ unsnoozeThread,
+ pinThread,
+ unpinThread,
+ deleteThread,
+ } = useThreadActions();
+ const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
+ reportFailure: false,
+ });
+ const handleNewThread = useNewThreadHandler();
+ const markThreadUnread = useUiStateStore((s) => s.markThreadUnread);
+ const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays);
+ const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
+ const timestampFormat = useClientSettings((s) => s.timestampFormat);
+ const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({
+ onCopy: ({ path }) => {
+ toastManager.add({ type: "success", title: "Path copied", description: path });
+ },
+ onError: (error) => failureToast("Failed to copy path", error),
+ });
+ const { copyToClipboard: copyBranchToClipboard } = useCopyToClipboard<{ branch: string }>({
+ target: "branch name",
+ onCopy: ({ branch }) => {
+ toastManager.add({ type: "success", title: "Branch copied", description: branch });
+ },
+ onError: (error) => failureToast("Failed to copy branch", error),
+ });
+
+ const openMenu = useCallback(
+ (position: { x: number; y: number }) => {
+ if (threadRef === null) return;
+ void (async () => {
+ const api = readLocalApi();
+ if (!api) return;
+ // Snapshot at open time — the menu is modal, so state read now is
+ // what the user is looking at.
+ const thread = readThreadShell(threadRef);
+ if (!thread) return;
+ const now = new Date();
+ const supports = {
+ settlement: readEnvironmentSupportsSettlement(threadRef.environmentId),
+ snooze: readEnvironmentSupportsSnooze(threadRef.environmentId),
+ pinning: readEnvironmentSupportsPinning(threadRef.environmentId),
+ titleRegeneration: readEnvironmentSupportsTitleRegeneration(threadRef.environmentId),
+ };
+ const isRegeneratingTitle = thread.titleRegeneration != null;
+ const snoozePresets = resolveSnoozePresets(now, timestampFormat);
+ const items = buildThreadActionMenuItems({
+ branch: thread.branch ?? null,
+ isPinned: thread.pinnedAt != null,
+ isSettled:
+ supports.settlement &&
+ effectiveSettled(thread, {
+ // Minute-quantized like useNowMinute, so this classification
+ // can never disagree with the sidebar partition or ChatView's
+ // parked-thread banner within the same minute.
+ now: `${now.toISOString().slice(0, 16)}:00.000Z`,
+ autoSettleAfterDays,
+ changeRequestState,
+ }),
+ isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }),
+ canSnoozeNow: canSnooze(thread, { now: now.toISOString() }),
+ isRegeneratingTitle,
+ supports,
+ snoozePresets,
+ });
+ const clicked = await settlePromise(() => api.contextMenu.show(items, position));
+ if (clicked._tag === "Failure" || clicked.value === null) return;
+ const action: ThreadActionMenuId = clicked.value;
+ if (action.startsWith("snooze:")) {
+ const preset = snoozePresets.find((candidate) => `snooze:${candidate.id}` === action);
+ if (!preset) return;
+ const result = await snoozeThread(threadRef, preset.snoozedUntil);
+ if (result._tag === "Failure") {
+ if (!isAtomCommandInterrupted(result)) {
+ failureToast("Failed to snooze thread", squashAtomCommandFailure(result));
+ }
+ return;
+ }
+ toastManager.add(
+ stackedThreadToast({
+ type: "success",
+ title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date(), timestampFormat)}`,
+ timeout: 5_000,
+ actionProps: {
+ children: "Undo",
+ onClick: () => {
+ void unsnoozeThread(threadRef).then((undone) => {
+ if (undone._tag === "Failure" && !isAtomCommandInterrupted(undone)) {
+ failureToast("Failed to wake thread", squashAtomCommandFailure(undone));
+ }
+ });
+ },
+ },
+ }),
+ );
+ return;
+ }
+ const reportFailure = async (
+ title: string,
+ run: () => Promise>,
+ ) => {
+ const result = await run();
+ if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
+ failureToast(title, squashAtomCommandFailure(result));
+ }
+ };
+ switch (action) {
+ case "new-thread-on-branch": {
+ // Explicit branch carry-over: reuse the thread's worktree when it
+ // has one, otherwise its branch on the local checkout.
+ const result = await settlePromise(() =>
+ handleNewThread(scopeProjectRef(threadRef.environmentId, thread.projectId), {
+ branch: thread.branch,
+ worktreePath: thread.worktreePath,
+ envMode: thread.worktreePath ? "worktree" : "local",
+ startFromOrigin: false,
+ }),
+ );
+ if (result._tag === "Failure") {
+ failureToast("Could not create thread", squashAtomCommandFailure(result));
+ }
+ return;
+ }
+ case "settle":
+ await reportFailure("Failed to settle thread", () => settleThread(threadRef));
+ return;
+ case "unsettle":
+ await reportFailure("Failed to un-settle thread", () => unsettleThread(threadRef));
+ return;
+ case "unsnooze":
+ await reportFailure("Failed to wake thread", () => unsnoozeThread(threadRef));
+ return;
+ case "pin":
+ await reportFailure("Failed to pin thread", () => pinThread(threadRef));
+ return;
+ case "unpin":
+ await reportFailure("Failed to unpin thread", () => unpinThread(threadRef));
+ return;
+ case "rename":
+ onStartRename();
+ return;
+ case "regenerate-title":
+ if (isRegeneratingTitle) return;
+ await reportFailure("Failed to regenerate thread title", () =>
+ updateThreadMetadata({
+ environmentId: threadRef.environmentId,
+ input: { threadId: threadRef.threadId, regenerateTitle: true },
+ }),
+ );
+ return;
+ case "mark-unread":
+ markThreadUnread(scopedThreadKey(threadRef), thread.latestTurn?.completedAt);
+ return;
+ case "copy-path": {
+ const workspacePath = thread.worktreePath ?? projectCwd;
+ if (!workspacePath) {
+ toastManager.add(
+ stackedThreadToast({
+ type: "error",
+ title: "Path unavailable",
+ description: "This thread does not have a workspace path to copy.",
+ }),
+ );
+ return;
+ }
+ copyPathToClipboard(workspacePath, { path: workspacePath });
+ return;
+ }
+ case "copy-branch":
+ if (thread.branch) {
+ copyBranchToClipboard(thread.branch, { branch: thread.branch });
+ }
+ return;
+ case "delete": {
+ if (confirmThreadDelete) {
+ const confirmed = await settlePromise(() =>
+ api.dialogs.confirm(
+ [
+ `Delete thread "${thread.title}"?`,
+ "This permanently clears conversation history for this thread.",
+ ].join("\n"),
+ ),
+ );
+ if (confirmed._tag === "Failure" || !confirmed.value) return;
+ }
+ const deleted = await deleteThread(threadRef);
+ if (
+ deleted._tag === "Failure" &&
+ !isAtomCommandInterrupted(deleted) &&
+ // A failure with the thread already gone is worktree cleanup
+ // failing after a successful delete — deleteThread has toasted
+ // that itself, and "Failed to delete thread" would be a lie.
+ readThreadShell(threadRef) !== null
+ ) {
+ failureToast("Failed to delete thread", squashAtomCommandFailure(deleted));
+ }
+ return;
+ }
+ default:
+ return;
+ }
+ })();
+ },
+ [
+ autoSettleAfterDays,
+ changeRequestState,
+ confirmThreadDelete,
+ copyBranchToClipboard,
+ copyPathToClipboard,
+ deleteThread,
+ handleNewThread,
+ markThreadUnread,
+ onStartRename,
+ pinThread,
+ projectCwd,
+ settleThread,
+ snoozeThread,
+ threadRef,
+ timestampFormat,
+ unpinThread,
+ unsettleThread,
+ unsnoozeThread,
+ updateThreadMetadata,
+ ],
+ );
+
+ return { openMenu };
+}
diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts
index e47fce1d3bc91..22548b23360c9 100644
--- a/apps/web/src/hooks/useThreadActions.ts
+++ b/apps/web/src/hooks/useThreadActions.ts
@@ -13,7 +13,7 @@ import { AsyncResult } from "effect/unstable/reactivity";
import { useRouter } from "@tanstack/react-router";
import { useCallback, useMemo, useRef } from "react";
-import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic";
+import { getFallbackThreadIdAfterDelete, pinOrderKeyBetween } from "../components/Sidebar.logic";
import { useComposerDraftStore } from "../composerDraftStore";
import { terminalEnvironment } from "../state/terminal";
import { threadEnvironment } from "../state/threads";
@@ -23,11 +23,13 @@ import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsStat
import { readLocalApi } from "../localApi";
import {
readEnvironmentSupportsPinning,
+ readEnvironmentSupportsPinReorder,
readEnvironmentSupportsSettlement,
readEnvironmentSupportsSnooze,
readEnvironmentThreadRefs,
readProject,
readThreadShell,
+ readThreadShells,
} from "../state/entities";
import { useTerminalUiStateStore } from "../terminalUiStateStore";
import { useUiStateStore } from "../uiStateStore";
@@ -97,6 +99,18 @@ export class ThreadSnoozeBlockedError extends Schema.TaggedErrorClass()(
"ThreadPinningUnsupportedError",
{
@@ -109,6 +123,18 @@ export class ThreadPinningUnsupportedError extends Schema.TaggedErrorClass()(
+ "ThreadPinReorderUnsupportedError",
+ {
+ environmentId: EnvironmentId,
+ threadId: ThreadId,
+ },
+) {
+ override get message(): string {
+ return "This environment's server does not support reordering pinned threads yet. Update the server to reorder pins.";
+ }
+}
+
export function useThreadActions() {
const closeTerminal = useAtomCommand(terminalEnvironment.close);
const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, {
@@ -132,6 +158,9 @@ export function useThreadActions() {
const unpinThreadMutation = useAtomCommand(threadEnvironment.unpin, {
reportFailure: false,
});
+ const reorderPinnedThreadMutation = useAtomCommand(threadEnvironment.reorderPin, {
+ reportFailure: false,
+ });
const snoozeThreadMutation = useAtomCommand(threadEnvironment.snooze, {
reportFailure: false,
});
@@ -506,7 +535,7 @@ export function useThreadActions() {
);
const pinThread = useCallback(
- async (target: ScopedThreadRef) => {
+ async (target: ScopedThreadRef, opts: { orderKey?: string } = {}) => {
// Version skew: never send the command to a server that predates it.
if (!readEnvironmentSupportsPinning(target.environmentId)) {
return AsyncResult.failure(
@@ -518,9 +547,21 @@ export function useThreadActions() {
),
);
}
+ // Every pin path places the thread at the top of the arranged run:
+ // callers with a better anchor (the sidebar, which knows the displayed
+ // order) pass their own key; everyone else (chat header, context menus)
+ // gets the default so the same action never places differently.
+ // orderKey rides only to servers that decode it; pre-reorder servers
+ // get the bare pin they understand and the thread stays keyless.
+ const orderKey = readEnvironmentSupportsPinReorder(target.environmentId)
+ ? (opts.orderKey ?? topOfPinnedRunOrderKey())
+ : undefined;
return pinThreadMutation({
environmentId: target.environmentId,
- input: { threadId: target.threadId },
+ input: {
+ threadId: target.threadId,
+ ...(orderKey !== undefined ? { orderKey } : {}),
+ },
});
},
[pinThreadMutation],
@@ -546,6 +587,29 @@ export function useThreadActions() {
[unpinThreadMutation],
);
+ const reorderPinnedThread = useCallback(
+ async (target: ScopedThreadRef, orderKey: string) => {
+ // Callers (the sidebar drag handler) only enable dragging on
+ // reorder-capable environments; this guard covers races around
+ // capability changes mid-drag.
+ if (!readEnvironmentSupportsPinReorder(target.environmentId)) {
+ return AsyncResult.failure(
+ Cause.fail(
+ new ThreadPinReorderUnsupportedError({
+ environmentId: target.environmentId,
+ threadId: target.threadId,
+ }),
+ ),
+ );
+ }
+ return reorderPinnedThreadMutation({
+ environmentId: target.environmentId,
+ input: { threadId: target.threadId, orderKey },
+ });
+ },
+ [reorderPinnedThreadMutation],
+ );
+
const snoozeThread = useCallback(
async (target: ScopedThreadRef, snoozedUntil: string) => {
// Version skew: never send the command to a server that predates it.
@@ -641,12 +705,14 @@ export function useThreadActions() {
unsnoozeThread,
pinThread,
unpinThread,
+ reorderPinnedThread,
}),
[
archiveThread,
confirmAndDeleteThread,
deleteThread,
pinThread,
+ reorderPinnedThread,
settleThread,
snoozeThread,
unarchiveThread,
diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts
index 3f82973045cc5..7bca311823794 100644
--- a/apps/web/src/state/entities.ts
+++ b/apps/web/src/state/entities.ts
@@ -250,6 +250,24 @@ export function readEnvironmentSupportsPinning(environmentId: EnvironmentId): bo
);
}
+/** Whether the environment's server understands thread title regeneration.
+ Same version-skew contract as settlement. */
+export function readEnvironmentSupportsTitleRegeneration(environmentId: EnvironmentId): boolean {
+ return (
+ appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
+ .threadTitleRegeneration === true
+ );
+}
+
+/** Whether the environment's server understands thread.pin.reorder (and
+ orderKey on thread.pin). Same version-skew contract as settlement. */
+export function readEnvironmentSupportsPinReorder(environmentId: EnvironmentId): boolean {
+ return (
+ appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
+ .threadPinReorder === true
+ );
+}
+
export function readThreadDetail(ref: ScopedThreadRef): EnvironmentThread | null {
return appAtomRegistry.get(environmentThreadDetails.detailAtom(ref));
}
@@ -264,6 +282,10 @@ export function readThreadRefs(): ReadonlyArray {
return appAtomRegistry.get(environmentThreadShells.threadRefsAtom);
}
+export function readThreadShells(): ReadonlyArray {
+ return appAtomRegistry.get(environmentThreadShells.threadShellsAtom);
+}
+
export function findThreadRef(threadId: ThreadId): ScopedThreadRef | null {
return (
appAtomRegistry
diff --git a/docs/README.md b/docs/README.md
index bc359826a04a3..b0006e954f6e3 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -5,6 +5,7 @@
- [Install and first run](./user/install.md)
- [Permission modes](./user/permission-modes.md)
- [Keyboard shortcuts](./user/keybindings.md)
+- [Organizing threads](./user/thread-sidebar.md)
- [Remote access](./user/remote-access.md)
- [Keeping app and server in sync](./user/updating.md)
- [Source control integrations](./user/source-control.md)
diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md
new file mode 100644
index 0000000000000..99c180bafdd2f
--- /dev/null
+++ b/docs/user/thread-sidebar.md
@@ -0,0 +1,13 @@
+# Organizing threads
+
+Pin a thread from its context menu to keep it in the pinned section above your active work.
+Pinned threads are shown independently of their project, including when you connect to more than
+one environment.
+
+On web and desktop, drag a pinned thread to change its position. On mobile, open the thread's menu
+and choose **Move up** or **Move down**. The order is stored by the server and appears on your
+other connected devices.
+
+If reordering is unavailable for one environment, update the T3 Code server running in that
+environment. Older servers can still pin and unpin threads, but do not understand synced ordering;
+their pinned threads keep the default newest-first order below the ones you have arranged.
diff --git a/packages/client-runtime/src/connection/connectivity.test.ts b/packages/client-runtime/src/connection/connectivity.test.ts
new file mode 100644
index 0000000000000..ac4078fd5e1c2
--- /dev/null
+++ b/packages/client-runtime/src/connection/connectivity.test.ts
@@ -0,0 +1,155 @@
+import { describe, expect, it } from "@effect/vitest";
+import * as Deferred from "effect/Deferred";
+import * as Effect from "effect/Effect";
+import * as Fiber from "effect/Fiber";
+import * as Ref from "effect/Ref";
+import * as Stream from "effect/Stream";
+
+import * as ConnectionWakeups from "./wakeups.ts";
+import * as Connectivity from "./connectivity.ts";
+
+describe("followNetworkStatus", () => {
+ it.effect("applies the current status when the follower starts", () =>
+ Effect.gen(function* () {
+ const applied = yield* Ref.make>([]);
+
+ yield* Connectivity.followNetworkStatus({
+ apply: (status) => Ref.update(applied, (current) => [...current, status]),
+ });
+
+ expect(yield* Ref.get(applied)).toEqual(["offline"]);
+ }).pipe(
+ Effect.provideService(
+ Connectivity.Connectivity,
+ Connectivity.Connectivity.of({ status: Effect.succeed("offline"), changes: Stream.never }),
+ ),
+ Effect.provideService(
+ ConnectionWakeups.ConnectionWakeups,
+ ConnectionWakeups.ConnectionWakeups.of({ changes: Stream.never }),
+ ),
+ Effect.scoped,
+ ),
+ );
+
+ it.effect("does not let a duplicate report invalidate an in-flight wakeup snapshot", () =>
+ Effect.gen(function* () {
+ const statusReads = yield* Ref.make(0);
+ const wake = yield* Deferred.make();
+ const duplicate = yield* Deferred.make();
+ const duplicateHandled = yield* Deferred.make();
+ const secondReadStarted = yield* Deferred.make();
+ const releaseSecondRead = yield* Deferred.make();
+ const wakeHandled = yield* Deferred.make();
+ const applied = yield* Ref.make>([]);
+
+ yield* Effect.gen(function* () {
+ yield* Connectivity.followNetworkStatus({
+ apply: (status) => Ref.update(applied, (current) => [...current, status]),
+ });
+
+ yield* Deferred.succeed(wake, undefined);
+ yield* Deferred.await(secondReadStarted);
+ yield* Deferred.succeed(duplicate, undefined);
+ yield* Deferred.await(duplicateHandled);
+ yield* Deferred.succeed(releaseSecondRead, undefined);
+ yield* Deferred.await(wakeHandled);
+
+ expect(yield* Ref.get(statusReads)).toBe(2);
+ expect(yield* Ref.get(applied)).toEqual(["offline", "online"]);
+ }).pipe(
+ Effect.provideService(
+ Connectivity.Connectivity,
+ Connectivity.Connectivity.of({
+ status: Ref.updateAndGet(statusReads, (count) => count + 1).pipe(
+ Effect.flatMap((read) =>
+ read === 1
+ ? Effect.succeed("offline" as const)
+ : Deferred.succeed(secondReadStarted, undefined).pipe(
+ Effect.andThen(Deferred.await(releaseSecondRead)),
+ Effect.as("online" as const),
+ ),
+ ),
+ ),
+ changes: Stream.fromEffect(
+ Deferred.await(duplicate).pipe(Effect.as("offline" as const)),
+ ).pipe(
+ Stream.concat(
+ Stream.fromEffect(
+ Deferred.succeed(duplicateHandled, undefined).pipe(Effect.andThen(Effect.never)),
+ ),
+ ),
+ ),
+ }),
+ ),
+ Effect.provideService(
+ ConnectionWakeups.ConnectionWakeups,
+ ConnectionWakeups.ConnectionWakeups.of({
+ changes: Stream.fromEffect(
+ Deferred.await(wake).pipe(Effect.as("application-active" as const)),
+ ).pipe(
+ Stream.concat(
+ Stream.fromEffect(
+ Deferred.succeed(wakeHandled, undefined).pipe(Effect.andThen(Effect.never)),
+ ),
+ ),
+ ),
+ }),
+ ),
+ Effect.scoped,
+ );
+ }),
+ );
+
+ it.effect("lets a wakeup refresh supersede the pending initial snapshot", () =>
+ Effect.gen(function* () {
+ const reads = yield* Ref.make(0);
+ const firstReadStarted = yield* Deferred.make();
+ const releaseFirstRead = yield* Deferred.make();
+ const wake = yield* Deferred.make();
+ const onlineApplied = yield* Deferred.make();
+
+ yield* Effect.gen(function* () {
+ const follower = yield* Connectivity.followNetworkStatus({
+ apply: (status) =>
+ status === "online"
+ ? Deferred.succeed(onlineApplied, undefined).pipe(Effect.asVoid)
+ : Effect.void,
+ }).pipe(Effect.forkChild);
+
+ yield* Deferred.await(firstReadStarted);
+ yield* Deferred.succeed(wake, undefined);
+ yield* Deferred.await(onlineApplied);
+ yield* Deferred.succeed(releaseFirstRead, undefined);
+ yield* Fiber.join(follower);
+
+ expect(yield* Ref.get(reads)).toBe(2);
+ }).pipe(
+ Effect.provideService(
+ Connectivity.Connectivity,
+ Connectivity.Connectivity.of({
+ status: Ref.updateAndGet(reads, (count) => count + 1).pipe(
+ Effect.flatMap((read) =>
+ read === 1
+ ? Deferred.succeed(firstReadStarted, undefined).pipe(
+ Effect.andThen(Deferred.await(releaseFirstRead)),
+ Effect.as("offline" as const),
+ )
+ : Effect.succeed("online" as const),
+ ),
+ ),
+ changes: Stream.never,
+ }),
+ ),
+ Effect.provideService(
+ ConnectionWakeups.ConnectionWakeups,
+ ConnectionWakeups.ConnectionWakeups.of({
+ changes: Stream.fromEffect(
+ Deferred.await(wake).pipe(Effect.as("application-active" as const)),
+ ).pipe(Stream.concat(Stream.never)),
+ }),
+ ),
+ Effect.scoped,
+ );
+ }),
+ );
+});
diff --git a/packages/client-runtime/src/connection/connectivity.ts b/packages/client-runtime/src/connection/connectivity.ts
index 6b40680ce35c5..f83ef7a74201a 100644
--- a/packages/client-runtime/src/connection/connectivity.ts
+++ b/packages/client-runtime/src/connection/connectivity.ts
@@ -1,9 +1,13 @@
import * as Context from "effect/Context";
-import type * as Effect from "effect/Effect";
+import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
-import type * as Stream from "effect/Stream";
+import * as Option from "effect/Option";
+import * as Ref from "effect/Ref";
+import * as Semaphore from "effect/Semaphore";
+import * as Stream from "effect/Stream";
import type { NetworkStatus } from "./model.ts";
+import * as ConnectionWakeups from "./wakeups.ts";
export class Connectivity extends Context.Service<
Connectivity,
@@ -17,3 +21,74 @@ export const make = (service: Connectivity["Service"]) => Connectivity.of(servic
export const layer = (service: Connectivity["Service"]) =>
Layer.succeed(Connectivity, make(service));
+
+/**
+ * Follows reported connectivity changes and repairs status drift whenever the
+ * application becomes active. Mobile platforms can suspend network listeners,
+ * which is especially visible while a VPN tunnel is restoring in the
+ * background. A report that arrives during the asynchronous status read always
+ * wins over that older snapshot.
+ */
+export const followNetworkStatus = Effect.fnUntraced(function* (options: {
+ readonly apply: (status: NetworkStatus) => Effect.Effect;
+ readonly onWakeup?: (reason: ConnectionWakeups.ConnectionWakeup) => Effect.Effect;
+}) {
+ const connectivity = yield* Connectivity;
+ const wakeups = yield* ConnectionWakeups.ConnectionWakeups;
+ const revision = yield* Ref.make(0);
+ const appliedStatus = yield* Ref.make>(Option.none());
+ const applyLock = yield* Semaphore.make(1);
+
+ const applyStatus = Effect.fnUntraced(function* (status: NetworkStatus) {
+ const changed = yield* Ref.modify(appliedStatus, (current) =>
+ Option.isSome(current) && current.value === status
+ ? ([false, current] as const)
+ : ([true, Option.some(status)] as const),
+ );
+ if (changed) {
+ yield* options.apply(status);
+ }
+ return changed;
+ });
+
+ const refreshCurrentStatus = Effect.gen(function* () {
+ const startedAt = yield* Ref.updateAndGet(revision, (current) => current + 1);
+ const status = yield* connectivity.status;
+ yield* applyLock.withPermits(1)(
+ Effect.gen(function* () {
+ if ((yield* Ref.get(revision)) === startedAt) {
+ yield* applyStatus(status);
+ }
+ }),
+ );
+ });
+
+ yield* connectivity.changes.pipe(
+ Stream.runForEach((status) =>
+ applyLock.withPermits(1)(
+ applyStatus(status).pipe(
+ Effect.flatMap((changed) =>
+ changed ? Ref.update(revision, (current) => current + 1) : Effect.void,
+ ),
+ ),
+ ),
+ ),
+ Effect.forkScoped({ startImmediately: true }),
+ );
+
+ yield* wakeups.changes.pipe(
+ Stream.runForEach((reason) =>
+ Effect.gen(function* () {
+ if (options.onWakeup) {
+ yield* options.onWakeup(reason);
+ }
+ if (ConnectionWakeups.isApplicationActiveWakeup(reason)) {
+ yield* refreshCurrentStatus;
+ }
+ }),
+ ),
+ Effect.forkScoped({ startImmediately: true }),
+ );
+
+ yield* refreshCurrentStatus;
+});
diff --git a/packages/client-runtime/src/connection/registry.ts b/packages/client-runtime/src/connection/registry.ts
index a3a36272f326d..3c9831b889322 100644
--- a/packages/client-runtime/src/connection/registry.ts
+++ b/packages/client-runtime/src/connection/registry.ts
@@ -652,10 +652,9 @@ export const make = Effect.gen(function* () {
),
),
);
- yield* connectivity.changes.pipe(
- Stream.runForEach((status) => SubscriptionRef.set(networkStatus, status)),
- Effect.forkScoped,
- );
+ yield* Connectivity.followNetworkStatus({
+ apply: (status) => SubscriptionRef.set(networkStatus, status),
+ });
return EnvironmentRegistry.of({
entries,
diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts
index 5e50c44d9610b..5e27bd401351d 100644
--- a/packages/client-runtime/src/connection/supervisor.test.ts
+++ b/packages/client-runtime/src/connection/supervisor.test.ts
@@ -116,9 +116,10 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?:
readonly ready?: (attempt: number) => Effect.Effect;
readonly probe?: (attempt: number) => Effect.Effect;
}) {
- const networkStatus = yield* SubscriptionRef.make(
+ const reportedNetworkStatus = yield* SubscriptionRef.make(
options?.networkStatus ?? "online",
);
+ const liveNetworkStatus = yield* Ref.make(options?.networkStatus ?? "online");
const prepareCount = yield* Ref.make(0);
const sessionCount = yield* Ref.make(0);
const releaseCount = yield* Ref.make(0);
@@ -134,8 +135,8 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?:
>([]);
const connectivity = Connectivity.Connectivity.of({
- status: SubscriptionRef.get(networkStatus),
- changes: SubscriptionRef.changes(networkStatus),
+ status: Ref.get(liveNetworkStatus),
+ changes: SubscriptionRef.changes(reportedNetworkStatus),
});
const prepare = Effect.fn("TestConnectionDriver.prepare")(function* (target: ConnectionTarget) {
@@ -197,7 +198,11 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?:
prepareCount,
sessionCount,
releaseCount,
- setNetworkStatus: (status: NetworkStatus) => SubscriptionRef.set(networkStatus, status),
+ setNetworkStatus: (status: NetworkStatus) =>
+ Ref.set(liveNetworkStatus, status).pipe(
+ Effect.andThen(SubscriptionRef.set(reportedNetworkStatus, status)),
+ ),
+ setNetworkStatusWithoutNotifying: (status: NetworkStatus) => Ref.set(liveNetworkStatus, status),
wake: (reason: ConnectionWakeups.ConnectionWakeup) =>
SubscriptionRef.update(wakeups, (event) => ({
sequence: event.sequence + 1,
@@ -311,6 +316,28 @@ describe("EnvironmentSupervisor", () => {
}),
);
+ it.effect("recovers a network transition missed while the application was suspended", () =>
+ Effect.gen(function* () {
+ const harness = yield* makeHarness({ networkStatus: "offline" });
+ const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, {
+ initiallyDesired: true,
+ }).pipe(Effect.provide(harness.dependencies));
+
+ yield* awaitState(supervisor.state, (state) => state.phase === "offline");
+ yield* harness.setNetworkStatusWithoutNotifying("online");
+ yield* harness.wake("application-active-reconnect");
+
+ const ready = yield* awaitState(supervisor.state, (state) => state.phase === "connected");
+ expect(ready).toMatchObject({
+ desired: true,
+ network: "online",
+ phase: "connected",
+ lastFailure: null,
+ });
+ expect(yield* Ref.get(harness.prepareCount)).toBe(1);
+ }),
+ );
+
it.effect("resets retries when activation arrives before the network returns", () =>
Effect.gen(function* () {
const harness = yield* makeHarness();
diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts
index 85fda10ef1a7c..8908dba198acc 100644
--- a/packages/client-runtime/src/connection/supervisor.ts
+++ b/packages/client-runtime/src/connection/supervisor.ts
@@ -10,7 +10,6 @@ import * as Option from "effect/Option";
import * as Queue from "effect/Queue";
import * as Ref from "effect/Ref";
import * as Scope from "effect/Scope";
-import * as Stream from "effect/Stream";
import * as SubscriptionRef from "effect/SubscriptionRef";
import * as Tracer from "effect/Tracer";
@@ -224,7 +223,6 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
const connectivity = yield* Connectivity.Connectivity;
const driver = yield* ConnectionDriver.ConnectionDriver;
- const wakeups = yield* ConnectionWakeups.ConnectionWakeups;
const initialIntent: SupervisorIntent = {
desired: options?.initiallyDesired ?? false,
network: yield* connectivity.status,
@@ -755,22 +753,19 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
}
});
- yield* connectivity.changes.pipe(
- Stream.runForEach((network) =>
- Ref.modify(intent, (current) =>
- current.network === network ? [false, current] : ([true, { ...current, network }] as const),
- ).pipe(
- Effect.flatMap((changed) =>
- changed ? signal({ _tag: "NetworkChanged", network }) : Effect.void,
- ),
- ),
- ),
- Effect.forkScoped,
- );
- yield* wakeups.changes.pipe(
- Stream.runForEach((reason) => signal({ _tag: "Wakeup", reason })),
- Effect.forkScoped,
- );
+ const applyNetworkStatus = Effect.fnUntraced(function* (network: NetworkStatus) {
+ const changed = yield* Ref.modify(intent, (current) =>
+ current.network === network ? [false, current] : ([true, { ...current, network }] as const),
+ );
+ if (changed) {
+ yield* signal({ _tag: "NetworkChanged", network });
+ }
+ });
+
+ yield* Connectivity.followNetworkStatus({
+ apply: applyNetworkStatus,
+ onWakeup: (reason) => signal({ _tag: "Wakeup", reason }),
+ });
yield* run().pipe(Effect.forkScoped);
const connect = Ref.update(intent, (current) => ({
diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts
index ee200d3a22df8..cb74f117b772d 100644
--- a/packages/client-runtime/src/operations/commands.ts
+++ b/packages/client-runtime/src/operations/commands.ts
@@ -41,6 +41,7 @@ export type SnoozeThreadInput = CommandInput<"thread.snooze">;
export type UnsnoozeThreadInput = CommandInput<"thread.unsnooze">;
export type PinThreadInput = CommandInput<"thread.pin">;
export type UnpinThreadInput = CommandInput<"thread.unpin">;
+export type ReorderPinnedThreadInput = CommandInput<"thread.pin.reorder">;
export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">;
export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">;
export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">;
@@ -219,6 +220,16 @@ export const unpinThread: (input: UnpinThreadInput) => CommandEffect = Effect.fn
});
});
+export const reorderPinnedThread: (input: ReorderPinnedThreadInput) => CommandEffect = Effect.fn(
+ "EnvironmentCommands.reorderPinnedThread",
+)(function* (input) {
+ return yield* dispatch({
+ ...input,
+ type: "thread.pin.reorder",
+ commandId: yield* commandId(input),
+ });
+});
+
export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => CommandEffect = Effect.fn(
"EnvironmentCommands.updateThreadMetadata",
)(function* (input) {
diff --git a/packages/client-runtime/src/relay/discovery.test.ts b/packages/client-runtime/src/relay/discovery.test.ts
index 8cb963000bd38..b6274abbb9e8f 100644
--- a/packages/client-runtime/src/relay/discovery.test.ts
+++ b/packages/client-runtime/src/relay/discovery.test.ts
@@ -55,8 +55,13 @@ function status(
};
}
-const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () {
- const networkStatus = yield* SubscriptionRef.make("online");
+const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* (
+ initialNetworkStatus: NetworkStatus = "online",
+) {
+ const networkStatus = yield* SubscriptionRef.make(initialNetworkStatus);
+ const pauseNextStatusRead = yield* Ref.make(false);
+ const pausedStatusRead = yield* Deferred.make();
+ const resumeStatusRead = yield* Deferred.make();
const listCalls = yield* Ref.make(0);
const listFailure = yield* Ref.make(null);
const secondListCall = yield* Deferred.make();
@@ -116,7 +121,14 @@ const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () {
resetTokenCache: Effect.void,
} satisfies ManagedRelay.ManagedRelayClient["Service"]);
const connectivity = Connectivity.Connectivity.of({
- status: SubscriptionRef.get(networkStatus),
+ status: Effect.gen(function* () {
+ const status = yield* SubscriptionRef.get(networkStatus);
+ if (yield* Ref.getAndSet(pauseNextStatusRead, false)) {
+ yield* Deferred.succeed(pausedStatusRead, undefined);
+ yield* Deferred.await(resumeStatusRead);
+ }
+ return status;
+ }),
changes: SubscriptionRef.changes(networkStatus),
});
const layer = RelayEnvironmentDiscovery.layer.pipe(
@@ -160,6 +172,9 @@ const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () {
listFailure,
clerkToken,
networkStatus,
+ pauseNextStatusRead,
+ pausedStatusRead,
+ resumeStatusRead,
secondListCall,
statusRequests,
wake: (reason: "application-active" | "credentials-changed") =>
@@ -252,6 +267,70 @@ describe("RelayEnvironmentDiscovery", () => {
}),
);
+ it.effect("clears an initial offline state on the first online transition", () =>
+ Effect.gen(function* () {
+ const harness = yield* makeHarness("offline");
+ yield* Effect.gen(function* () {
+ const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery;
+ const offline = yield* SubscriptionRef.changes(discovery.state).pipe(
+ Stream.filter((state) => state.offline),
+ Stream.runHead,
+ Effect.forkChild,
+ );
+ yield* Fiber.join(offline);
+
+ const online = yield* SubscriptionRef.changes(discovery.state).pipe(
+ Stream.filter((state) => !state.offline),
+ Stream.runHead,
+ Effect.forkChild,
+ );
+ yield* SubscriptionRef.set(harness.networkStatus, "online");
+ yield* Fiber.join(online);
+
+ expect((yield* SubscriptionRef.get(discovery.state)).offline).toBe(false);
+ expect(yield* Ref.get(harness.listCalls)).toBe(0);
+ }).pipe(Effect.provide(harness.layer));
+ }),
+ );
+
+ it.effect("keeps the offline state when connectivity changes during a refresh status read", () =>
+ Effect.gen(function* () {
+ const harness = yield* makeHarness();
+ yield* Effect.gen(function* () {
+ const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery;
+ const requests = yield* Ref.get(harness.statusRequests);
+ for (const environment of environments) {
+ yield* Deferred.succeed(
+ requests.get(environment.environmentId)!,
+ status(environment, "online"),
+ );
+ }
+ yield* discovery.refresh;
+
+ yield* SubscriptionRef.set(harness.networkStatus, "offline");
+ yield* SubscriptionRef.changes(discovery.state).pipe(
+ Stream.filter((state) => state.offline),
+ Stream.runHead,
+ );
+
+ yield* Ref.set(harness.pauseNextStatusRead, true);
+ yield* SubscriptionRef.set(harness.networkStatus, "online");
+ yield* Deferred.await(harness.pausedStatusRead);
+ const offlineFiber = yield* SubscriptionRef.changes(discovery.state).pipe(
+ Stream.filter((state) => state.offline),
+ Stream.runHead,
+ Effect.forkChild,
+ );
+ yield* SubscriptionRef.set(harness.networkStatus, "offline");
+ yield* Deferred.succeed(harness.resumeStatusRead, undefined);
+ yield* Fiber.join(offlineFiber);
+ yield* Deferred.await(harness.secondListCall);
+
+ expect((yield* SubscriptionRef.get(discovery.state)).offline).toBe(true);
+ }).pipe(Effect.provide(harness.layer));
+ }),
+ );
+
it.effect("publishes listing failures without rejecting the refresh command", () =>
Effect.gen(function* () {
const networkStatus = yield* SubscriptionRef.make("online");
diff --git a/packages/client-runtime/src/relay/discovery.ts b/packages/client-runtime/src/relay/discovery.ts
index 855bb2654edbc..a6d30666c95ed 100644
--- a/packages/client-runtime/src/relay/discovery.ts
+++ b/packages/client-runtime/src/relay/discovery.ts
@@ -107,8 +107,10 @@ export const make = Effect.fn("RelayEnvironmentDiscovery.make")(function* () {
const session = yield* ClientCapabilities.CloudSession;
const connectivity = yield* Connectivity.Connectivity;
const wakeups = yield* ConnectionWakeups.ConnectionWakeups;
+ const scope = yield* Effect.scope;
const state = yield* SubscriptionRef.make(EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE);
const refreshLock = yield* Semaphore.make(1);
+ const networkStateLock = yield* Semaphore.make(1);
const hasRefreshed = yield* Ref.make(false);
const accountGeneration = yield* Ref.make(0);
const activeAccountId = yield* Ref.make>(Option.none());
@@ -204,23 +206,32 @@ export const make = Effect.fn("RelayEnvironmentDiscovery.make")(function* () {
const refresh = refreshLock.withPermits(1)(
Effect.gen(function* () {
yield* Ref.set(hasRefreshed, true);
- if ((yield* connectivity.status) === "offline") {
- yield* SubscriptionRef.update(state, (current) => ({
- ...current,
- refreshing: false,
- offline: true,
- }));
+ const canRefresh = yield* networkStateLock.withPermits(1)(
+ Effect.gen(function* () {
+ if ((yield* connectivity.status) === "offline") {
+ yield* SubscriptionRef.update(state, (current) => ({
+ ...current,
+ refreshing: false,
+ offline: true,
+ }));
+ return false;
+ }
+
+ yield* SubscriptionRef.set(state, {
+ environments: new Map(),
+ refreshing: true,
+ offline: false,
+ error: Option.none(),
+ });
+ return true;
+ }),
+ );
+ if (!canRefresh) {
return;
}
let generation = yield* Ref.get(accountGeneration);
yield* Ref.set(refreshGeneration, generation);
- yield* SubscriptionRef.set(state, {
- environments: new Map(),
- refreshing: true,
- offline: false,
- error: Option.none(),
- });
// Signed out is the idle state, not a failure: the proactive refresh on
// credentials-changed also runs on sign-out and must settle back to a
@@ -309,21 +320,26 @@ export const make = Effect.fn("RelayEnvironmentDiscovery.make")(function* () {
),
);
- yield* connectivity.changes.pipe(
- Stream.changes,
- Stream.runForEach((networkStatus) =>
- networkStatus === "offline"
- ? SubscriptionRef.update(state, (current) => ({
- ...current,
- refreshing: false,
- offline: true,
- }))
- : Ref.get(hasRefreshed).pipe(
- Effect.flatMap((shouldRefresh) => (shouldRefresh ? refresh : Effect.void)),
- ),
- ),
- Effect.forkScoped,
- );
+ yield* Connectivity.followNetworkStatus({
+ apply: (networkStatus) =>
+ networkStateLock.withPermits(1)(
+ networkStatus === "offline"
+ ? SubscriptionRef.update(state, (current) => ({
+ ...current,
+ refreshing: false,
+ offline: true,
+ }))
+ : Effect.gen(function* () {
+ yield* SubscriptionRef.update(state, (current) => ({
+ ...current,
+ offline: false,
+ }));
+ if (yield* Ref.get(hasRefreshed)) {
+ yield* refresh.pipe(Effect.forkIn(scope));
+ }
+ }),
+ ),
+ });
yield* wakeups.changes.pipe(
Stream.runForEach((reason) =>
reason === "credentials-changed"
diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts
index 2eabc5aec16d1..ed3537e4f83be 100644
--- a/packages/client-runtime/src/state/threadCommands.ts
+++ b/packages/client-runtime/src/state/threadCommands.ts
@@ -13,6 +13,7 @@ import {
type SetThreadInteractionModeInput,
type SetThreadRuntimeModeInput,
type PinThreadInput,
+ type ReorderPinnedThreadInput,
type SettleThreadInput,
type SnoozeThreadInput,
type StartThreadTurnInput,
@@ -32,6 +33,7 @@ import {
setThreadInteractionMode,
setThreadRuntimeMode,
pinThread,
+ reorderPinnedThread,
settleThread,
snoozeThread,
startThreadTurn,
@@ -55,6 +57,7 @@ export type {
SetThreadInteractionModeInput,
SetThreadRuntimeModeInput,
PinThreadInput,
+ ReorderPinnedThreadInput,
SettleThreadInput,
SnoozeThreadInput,
StartThreadTurnInput,
@@ -136,6 +139,12 @@ export function createThreadEnvironmentAtoms(
scheduler,
concurrency,
}),
+ reorderPin: createEnvironmentCommand(runtime, {
+ label: "environment-data:commands:thread:reorder-pin",
+ execute: (input: ReorderPinnedThreadInput) => reorderPinnedThread(input),
+ scheduler,
+ concurrency,
+ }),
updateMetadata: createEnvironmentCommand(runtime, {
label: "environment-data:commands:thread:update-metadata",
execute: (input: UpdateThreadMetadataInput) => updateThreadMetadata(input),
diff --git a/packages/client-runtime/src/state/threadDetail.ts b/packages/client-runtime/src/state/threadDetail.ts
index 30e8ef5824893..5a2ffa442e04e 100644
--- a/packages/client-runtime/src/state/threadDetail.ts
+++ b/packages/client-runtime/src/state/threadDetail.ts
@@ -62,6 +62,7 @@ export function mergeEnvironmentThread(
snoozedUntil: shell.snoozedUntil,
snoozedAt: shell.snoozedAt,
pinnedAt: shell.pinnedAt,
+ pinOrderKey: shell.pinOrderKey,
session: shell.session,
};
}
diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts
index 0c6649f38681d..970fd94b1a160 100644
--- a/packages/client-runtime/src/state/threadReducer.ts
+++ b/packages/client-runtime/src/state/threadReducer.ts
@@ -173,6 +173,9 @@ export function applyThreadDetailEvent(
thread: {
...thread,
pinnedAt: event.payload.pinnedAt,
+ ...(event.payload.pinOrderKey !== undefined
+ ? { pinOrderKey: event.payload.pinOrderKey }
+ : {}),
updatedAt: event.payload.updatedAt,
},
};
@@ -183,6 +186,17 @@ export function applyThreadDetailEvent(
thread: {
...thread,
pinnedAt: null,
+ pinOrderKey: null,
+ updatedAt: event.payload.updatedAt,
+ },
+ };
+
+ case "thread.pin-reordered":
+ return {
+ kind: "updated",
+ thread: {
+ ...thread,
+ pinOrderKey: event.payload.orderKey,
updatedAt: event.payload.updatedAt,
},
};
diff --git a/packages/client-runtime/src/state/threadSort.test.ts b/packages/client-runtime/src/state/threadSort.test.ts
index dd6f8c3a295e9..f4ea270a001e1 100644
--- a/packages/client-runtime/src/state/threadSort.test.ts
+++ b/packages/client-runtime/src/state/threadSort.test.ts
@@ -1,6 +1,11 @@
import { describe, expect, it } from "vite-plus/test";
-import { sortThreads, type ThreadSortInput } from "./threadSort.ts";
+import {
+ planPinnedMove,
+ sortPinnedThreadsByOrderKey,
+ sortThreads,
+ type ThreadSortInput,
+} from "./threadSort.ts";
type TestThread = { readonly id: string } & ThreadSortInput;
@@ -69,3 +74,69 @@ describe("sortThreads", () => {
expect(sorted.map((thread) => thread.id)).toEqual(["thread-1", "thread-2"]);
});
});
+
+describe("planPinnedMove", () => {
+ it("moves a thread up with a single key write", () => {
+ const assignments = planPinnedMove({
+ orderedIds: ["a", "b", "c"],
+ keysById: new Map([
+ ["a", "f"],
+ ["b", "m"],
+ ["c", "t"],
+ ]),
+ movedId: "c",
+ direction: "up",
+ });
+ expect(assignments).toHaveLength(1);
+ expect(assignments![0]!.id).toBe("c");
+ expect(assignments![0]!.orderKey > "f" && assignments![0]!.orderKey < "m").toBe(true);
+ });
+
+ it("returns null when the move falls off the end of the list", () => {
+ const input = {
+ orderedIds: ["a", "b"],
+ keysById: new Map([
+ ["a", "f"],
+ ["b", "m"],
+ ]),
+ };
+ expect(planPinnedMove({ ...input, movedId: "a", direction: "up" })).toBeNull();
+ expect(planPinnedMove({ ...input, movedId: "b", direction: "down" })).toBeNull();
+ });
+
+ it("materializes keys for the whole section when a neighbor is keyless", () => {
+ const assignments = planPinnedMove({
+ orderedIds: ["a", "b", "c"],
+ keysById: new Map([
+ ["a", null],
+ ["b", "m"],
+ ["c", null],
+ ]),
+ movedId: "b",
+ direction: "up",
+ });
+ expect(assignments).not.toBeNull();
+ const keys = assignments!.map((entry) => entry.orderKey);
+ expect([...keys].sort()).toEqual(keys);
+ });
+});
+
+describe("sortPinnedThreadsByOrderKey", () => {
+ it("breaks equal keys by id THEN environment so merged lists are stable everywhere", () => {
+ const sorted = sortPinnedThreadsByOrderKey([
+ {
+ id: "thread-1",
+ createdAt: "2026-03-09T10:00:00.000Z",
+ pinOrderKey: "m",
+ environmentId: "env-b",
+ },
+ {
+ id: "thread-1",
+ createdAt: "2026-03-09T11:00:00.000Z",
+ pinOrderKey: "m",
+ environmentId: "env-a",
+ },
+ ]);
+ expect(sorted.map((thread) => thread.environmentId)).toEqual(["env-a", "env-b"]);
+ });
+});
diff --git a/packages/client-runtime/src/state/threadSort.ts b/packages/client-runtime/src/state/threadSort.ts
index aed63cd442d7c..9352d58dbc82c 100644
--- a/packages/client-runtime/src/state/threadSort.ts
+++ b/packages/client-runtime/src/state/threadSort.ts
@@ -102,3 +102,179 @@ export function getLatestThreadForProject<
)[0] ?? null
);
}
+
+// ── Pinned reorder: fractional index keys ──────────────────────────────
+// Pinned threads carry an optional pinOrderKey (a base-26 string). The
+// pinned block sorts keyed threads by plain string comparison, so a drag
+// (web) or Move up/down (mobile) writes ONE key to ONE thread on that
+// thread's own server — neighbors, possibly living on other servers, are
+// never touched, and every client connected to the same servers converges
+// on the same order.
+const PIN_ORDER_DIGITS = "abcdefghijklmnopqrstuvwxyz";
+
+function isValidPinOrderKey(key: string): boolean {
+ if (key.length === 0) return false;
+ for (const char of key) {
+ if (!PIN_ORDER_DIGITS.includes(char)) return false;
+ }
+ // A trailing minimum digit would leave no room to sort a key immediately
+ // before this one; generators never produce it, so treat it as corrupt.
+ return key.at(-1) !== PIN_ORDER_DIGITS[0];
+}
+
+/** Midpoint of two digit strings interpreted as fractions in (0, 1).
+ "" stands for the open bound on either side. Requires a < b. */
+function pinOrderMidpoint(a: string, b: string): string {
+ if (b !== "" && a >= b) throw new Error("pinOrderMidpoint: bounds out of order");
+ if (b !== "") {
+ // Recurse past the longest common prefix ("a" pads the shorter side).
+ let n = 0;
+ while ((a.charAt(n) || PIN_ORDER_DIGITS[0]) === b.charAt(n)) n += 1;
+ if (n > 0) return b.slice(0, n) + pinOrderMidpoint(a.slice(n), b.slice(n));
+ }
+ const digitA = a === "" ? 0 : PIN_ORDER_DIGITS.indexOf(a.charAt(0));
+ const digitB = b === "" ? PIN_ORDER_DIGITS.length : PIN_ORDER_DIGITS.indexOf(b.charAt(0));
+ if (digitB - digitA > 1) {
+ return PIN_ORDER_DIGITS.charAt(Math.round((digitA + digitB) / 2));
+ }
+ // Consecutive leading digits: either b has spare digits to shorten into,
+ // or we extend a (never producing a trailing minimum digit — the base
+ // case midpoint("", "") is the middle of the alphabet).
+ if (b.length > 1) return b.charAt(0);
+ return PIN_ORDER_DIGITS.charAt(digitA) + pinOrderMidpoint(a.slice(1), "");
+}
+
+/** Key that sorts strictly between two neighbors; null bounds mean "top of
+ the pinned block" / "bottom of the keyed run". Returns null instead of
+ throwing when existing keys are corrupt or out of order — callers fall
+ back to rewriting the section. */
+export function pinOrderKeyBetween(before: string | null, after: string | null): string | null {
+ const a = before ?? "";
+ const b = after ?? "";
+ if (a !== "" && !isValidPinOrderKey(a)) return null;
+ if (b !== "" && !isValidPinOrderKey(b)) return null;
+ if (b !== "" && a >= b) return null;
+ return pinOrderMidpoint(a, b);
+}
+
+/** Evenly spaced keys for rewriting a whole pinned section (used when a
+ drop lands next to keyless threads, so single-key insertion has nothing
+ to anchor on). Two base-26 digits give 675 slots — far beyond any real
+ pinned section — with monotonicity enforced as a belt-and-braces. */
+export function generateSpreadPinOrderKeys(count: number): string[] {
+ const space = PIN_ORDER_DIGITS.length * PIN_ORDER_DIGITS.length;
+ const step = space / (count + 1);
+ const keys: string[] = [];
+ let previous = 0;
+ for (let i = 0; i < count; i += 1) {
+ let value = Math.max(Math.round(step * (i + 1)), previous + 1);
+ // Skip values whose low digit is the minimum (a trailing "a" key).
+ if (value % PIN_ORDER_DIGITS.length === 0) value += 1;
+ value = Math.min(value, space - 1);
+ previous = value;
+ keys.push(
+ PIN_ORDER_DIGITS.charAt(Math.floor(value / PIN_ORDER_DIGITS.length)) +
+ PIN_ORDER_DIGITS.charAt(value % PIN_ORDER_DIGITS.length),
+ );
+ }
+ return keys;
+}
+
+/**
+ * Assignments needed to realize a new pinned order. When the moved thread
+ * sits between two keyed (or absent) neighbors, this is a single write to
+ * the moved thread. When a neighbor is keyless (threads pinned before
+ * reordering shipped), the whole section gets fresh spread keys — a
+ * one-time materialization; every move after that is single-write.
+ */
+export function planPinnedReorder(input: {
+ /** Thread ids in the desired visual order (after the move). */
+ readonly orderedIds: readonly string[];
+ readonly keysById: ReadonlyMap;
+ readonly movedId: string;
+}): ReadonlyArray<{ readonly id: string; readonly orderKey: string }> {
+ const { orderedIds, keysById, movedId } = input;
+ const movedIndex = orderedIds.indexOf(movedId);
+ if (movedIndex === -1) return [];
+ const beforeId = movedIndex > 0 ? orderedIds[movedIndex - 1] : null;
+ const afterId = movedIndex < orderedIds.length - 1 ? orderedIds[movedIndex + 1] : null;
+ const beforeKey = beforeId != null ? (keysById.get(beforeId) ?? null) : null;
+ const afterKey = afterId != null ? (keysById.get(afterId) ?? null) : null;
+ const beforeUsable = beforeId === null || beforeKey != null;
+ const afterUsable = afterId === null || afterKey != null;
+ if (beforeUsable && afterUsable) {
+ const key = pinOrderKeyBetween(beforeKey, afterKey);
+ if (key !== null) return [{ id: movedId, orderKey: key }];
+ }
+ // Keyless neighbor (or corrupt keys): rewrite the section in the new order.
+ const keys = generateSpreadPinOrderKeys(orderedIds.length);
+ return orderedIds.flatMap((id, index) => {
+ const key = keys[index]!;
+ return keysById.get(id) === key ? [] : [{ id, orderKey: key }];
+ });
+}
+
+/**
+ * Pinned block order: user-arranged keys first (string comparison, id
+ * tiebreak), then keyless threads newest-created first — so threads on
+ * servers that predate reordering keep the static creation order at the
+ * bottom of the block instead of breaking the section.
+ */
+export function sortPinnedThreadsByOrderKey<
+ T extends {
+ readonly id: string;
+ readonly createdAt: string;
+ readonly pinOrderKey?: string | null | undefined;
+ /** Thread ids are only unique within an environment, and the pinned
+ block merges environments — the tiebreak needs both parts or two
+ clients could render equal-key threads in stream-arrival order. */
+ readonly environmentId?: string | undefined;
+ },
+>(threads: readonly T[]): T[] {
+ const keyed: T[] = [];
+ const keyless: T[] = [];
+ for (const thread of threads) {
+ (thread.pinOrderKey != null ? keyed : keyless).push(thread);
+ }
+ const identityTiebreak = (left: T, right: T) =>
+ left.id.localeCompare(right.id) ||
+ (left.environmentId ?? "").localeCompare(right.environmentId ?? "");
+ keyed.sort((left, right) => {
+ const leftKey = left.pinOrderKey!;
+ const rightKey = right.pinOrderKey!;
+ return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : identityTiebreak(left, right);
+ });
+ keyless.sort((left, right) => {
+ const leftMs = Date.parse(left.createdAt);
+ const rightMs = Date.parse(right.createdAt);
+ return (
+ (Number.isNaN(rightMs) ? 0 : rightMs) - (Number.isNaN(leftMs) ? 0 : leftMs) ||
+ identityTiebreak(left, right)
+ );
+ });
+ return [...keyed, ...keyless];
+}
+
+/**
+ * planPinnedReorder specialized for mobile's Move up / Move down menu
+ * actions: swap the moved thread with its displayed neighbor. Null when the
+ * move falls off either end of the list. Same single-write-per-move
+ * semantics as a web drag.
+ */
+export function planPinnedMove(input: {
+ /** Reorder-capable pinned thread ids in displayed order. */
+ readonly orderedIds: readonly string[];
+ readonly keysById: ReadonlyMap;
+ readonly movedId: string;
+ readonly direction: "up" | "down";
+}): ReadonlyArray<{ readonly id: string; readonly orderKey: string }> | null {
+ const { orderedIds, keysById, movedId, direction } = input;
+ const from = orderedIds.indexOf(movedId);
+ if (from === -1) return null;
+ const to = direction === "up" ? from - 1 : from + 1;
+ if (to < 0 || to >= orderedIds.length) return null;
+ const newOrder = [...orderedIds];
+ newOrder.splice(from, 1);
+ newOrder.splice(to, 0, movedId);
+ return planPinnedReorder({ orderedIds: newOrder, keysById, movedId });
+}
diff --git a/packages/contracts/package.json b/packages/contracts/package.json
index c3bd819023b74..357156ec039e4 100644
--- a/packages/contracts/package.json
+++ b/packages/contracts/package.json
@@ -1,6 +1,6 @@
{
"name": "@t3tools/contracts",
- "version": "0.0.31",
+ "version": "0.0.32",
"private": true,
"files": [
"dist"
diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts
index 4c44a9596558e..329ff911503ba 100644
--- a/packages/contracts/src/environment.ts
+++ b/packages/contracts/src/environment.ts
@@ -50,6 +50,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
/** Server understands thread.pin / thread.unpin commands. Same
version-skew contract as threadSettlement. */
threadPinning: Schema.optionalKey(Schema.Boolean),
+ /** Server understands thread.pin.reorder (and orderKey on thread.pin).
+ Same version-skew contract as threadSettlement. */
+ threadPinReorder: Schema.optionalKey(Schema.Boolean),
/** Server understands regenerateTitle on thread.meta.update. Absent on
older servers, so clients hide the action instead of sending it. */
threadTitleRegeneration: Schema.optionalKey(Schema.Boolean),
diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts
index 262049619230c..87270d98c1f97 100644
--- a/packages/contracts/src/orchestration.ts
+++ b/packages/contracts/src/orchestration.ts
@@ -379,6 +379,11 @@ export const OrchestrationThread = Schema.Struct({
// thread renders in the pinned block and never classifies into a shelf.
// Optional so payloads from pre-pinning servers still decode.
pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
+ // Fractional index for user-arranged pinned order. Keyed threads sort by
+ // string comparison ahead of keyless ones (which keep creation order), so
+ // servers never need each other's threads to agree on the merged list.
+ // Optional so payloads from pre-reorder servers still decode.
+ pinOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
// Pending-only state. Optional so older servers remain compatible.
titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)),
deletedAt: Schema.NullOr(IsoDateTime),
@@ -434,6 +439,7 @@ export const OrchestrationThreadShell = Schema.Struct({
snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)),
snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
+ pinOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)),
session: Schema.NullOr(OrchestrationSession),
latestUserMessageAt: Schema.NullOr(IsoDateTime),
@@ -702,6 +708,10 @@ const ThreadPinCommand = Schema.Struct({
type: Schema.Literal("thread.pin"),
commandId: CommandId,
threadId: ThreadId,
+ // Initial slot in the user-arranged pinned order (see ThreadPinReorderCommand).
+ // Optional: clients on pre-reorder servers omit it, and the pinned block
+ // falls back to creation order for keyless threads.
+ orderKey: Schema.optional(TrimmedNonEmptyString),
});
const ThreadUnpinCommand = Schema.Struct({
@@ -710,6 +720,17 @@ const ThreadUnpinCommand = Schema.Struct({
threadId: ThreadId,
});
+const ThreadPinReorderCommand = Schema.Struct({
+ type: Schema.Literal("thread.pin.reorder"),
+ commandId: CommandId,
+ threadId: ThreadId,
+ // Fractional index key: pinned threads sort by plain string comparison of
+ // these keys, so a drag writes one key to one thread — neighbors (possibly
+ // on other servers) are never touched. Clients compute a key that sorts
+ // between the dropped position's neighbors.
+ orderKey: TrimmedNonEmptyString,
+});
+
const ThreadMetaUpdateCommand = Schema.Struct({
type: Schema.Literal("thread.meta.update"),
commandId: CommandId,
@@ -865,6 +886,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([
ThreadUnsnoozeCommand,
ThreadPinCommand,
ThreadUnpinCommand,
+ ThreadPinReorderCommand,
ThreadMetaUpdateCommand,
ThreadRuntimeModeSetCommand,
ThreadInteractionModeSetCommand,
@@ -892,6 +914,7 @@ export const ClientOrchestrationCommand = Schema.Union([
ThreadUnsnoozeCommand,
ThreadPinCommand,
ThreadUnpinCommand,
+ ThreadPinReorderCommand,
ThreadMetaUpdateCommand,
ThreadRuntimeModeSetCommand,
ThreadInteractionModeSetCommand,
@@ -1009,6 +1032,7 @@ export const OrchestrationEventType = Schema.Literals([
"thread.unsnoozed",
"thread.pinned",
"thread.unpinned",
+ "thread.pin-reordered",
"thread.meta-updated",
"thread.runtime-mode-set",
"thread.interaction-mode-set",
@@ -1120,6 +1144,9 @@ export const ThreadUnsnoozedPayload = Schema.Struct({
export const ThreadPinnedPayload = Schema.Struct({
threadId: ThreadId,
pinnedAt: IsoDateTime,
+ // Absent on re-pins of an already-pinned thread (the existing key wins)
+ // and on pins from clients that predate reordering.
+ pinOrderKey: Schema.optional(TrimmedNonEmptyString),
updatedAt: IsoDateTime,
});
@@ -1128,6 +1155,12 @@ export const ThreadUnpinnedPayload = Schema.Struct({
updatedAt: IsoDateTime,
});
+export const ThreadPinReorderedPayload = Schema.Struct({
+ threadId: ThreadId,
+ orderKey: TrimmedNonEmptyString,
+ updatedAt: IsoDateTime,
+});
+
export const ThreadMetaUpdatedPayload = Schema.Struct({
threadId: ThreadId,
title: Schema.optional(TrimmedNonEmptyString),
@@ -1332,6 +1365,11 @@ export const OrchestrationEvent = Schema.Union([
type: Schema.Literal("thread.unpinned"),
payload: ThreadUnpinnedPayload,
}),
+ Schema.Struct({
+ ...EventBaseFields,
+ type: Schema.Literal("thread.pin-reordered"),
+ payload: ThreadPinReorderedPayload,
+ }),
Schema.Struct({
...EventBaseFields,
type: Schema.Literal("thread.meta-updated"),
diff --git a/patches/@react-native__gradle-plugin@0.85.3.patch b/patches/@react-native__gradle-plugin@0.85.3.patch
new file mode 100644
index 0000000000000..7106ccca4ac3f
--- /dev/null
+++ b/patches/@react-native__gradle-plugin@0.85.3.patch
@@ -0,0 +1,13 @@
+diff --git a/settings.gradle.kts b/settings.gradle.kts
+index b3c46a3bf5eb21c0bb1c9435b5582aecbc57d57a..14f1e9c2063ef10ca3f2ecbb2336b8a4aacfcc9c 100644
+--- a/settings.gradle.kts
++++ b/settings.gradle.kts
+@@ -13,7 +13,7 @@ pluginManagement {
+ }
+ }
+
+-plugins { id("org.gradle.toolchains.foojay-resolver-convention").version("0.5.0") }
++plugins { id("org.gradle.toolchains.foojay-resolver-convention").version("1.0.0") }
+
+ include(
+ ":react-native-gradle-plugin",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1eb0379e84af9..9b99999318371 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -36,7 +36,7 @@ catalogs:
overrides:
'@clerk/backend': 3.14.0
- '@clerk/clerk-js': 6.25.12
+ '@clerk/clerk-js': 6.25.13
'@clerk/clerk-js>@base-org/account': '-'
'@clerk/clerk-js>@coinbase/wallet-sdk': '-'
'@clerk/clerk-js>@solana/wallet-adapter-base': '-'
@@ -45,9 +45,9 @@ overrides:
'@clerk/clerk-js>@wallet-standard/core': '-'
'@clerk/electron': 0.0.24
'@clerk/electron-passkeys': 0.0.3
- '@clerk/expo': 4.1.2
- '@clerk/react': 6.12.9
- '@clerk/shared': 4.25.9
+ '@clerk/expo': 4.2.0
+ '@clerk/react': 6.12.10
+ '@clerk/shared': 4.25.10
'@effect/atom-react': 4.0.0-beta.103
'@effect/platform-bun': 4.0.0-beta.103
'@effect/platform-node': 4.0.0-beta.103
@@ -75,6 +75,7 @@ patchedDependencies:
'@legendapp/list@3.3.3': d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09
'@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa
'@react-native-menu/menu@2.0.0': 5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae
+ '@react-native/gradle-plugin@0.85.3': c1b594a16e682d621b6a960926f8ce13fc92edfb397884cfe7fcb0518996a784
'@react-navigation/native-stack@7.17.6': c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273
effect@4.0.0-beta.103: af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6
expo-modules-jsi@56.0.10: 9170f8074ae4e35a0a086e756c8f815794fd3abe51eac67ca3ba02804225ec1f
@@ -195,8 +196,8 @@ importers:
specifier: ^0.7.1
version: 0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
'@clerk/expo':
- specifier: 4.1.2
- version: 4.1.2(expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)
+ specifier: 4.2.0
+ version: 4.2.0(expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)
'@effect/atom-react':
specifier: 4.0.0-beta.103
version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(react@19.2.3)(scheduler@0.27.0)
@@ -515,8 +516,8 @@ importers:
specifier: 0.0.24
version: 0.0.24(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@clerk/react':
- specifier: 6.12.9
- version: 6.12.9(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ specifier: 6.12.10
+ version: 6.12.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@dnd-kit/core':
specifier: ^6.3.1
version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -536,8 +537,8 @@ importers:
specifier: ^0.9.0
version: 0.9.0
'@legendapp/list':
- specifier: 3.2.0
- version: 3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ specifier: 3.3.3
+ version: 3.3.3(patch_hash=d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@lexical/react':
specifier: ^0.41.0
version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31)
@@ -1691,8 +1692,8 @@ packages:
resolution: {integrity: sha512-WsphTvDFHDuQilKI7dyVE5qmt7USu8qSrujAq6SqpEHbVFfNBfINDNuzxlF9M2skOTfHFfgiTE8Jjb1egIBGLg==}
engines: {node: '>=20.9.0'}
- '@clerk/clerk-js@6.25.12':
- resolution: {integrity: sha512-N91mFKenMF5e+LRQ0NsqFb8bw+icbwnF8MzjzS4cux72xG4Gekh772tiLIetbURe44Srgjw5lj8jkWbt+yH/FA==}
+ '@clerk/clerk-js@6.25.13':
+ resolution: {integrity: sha512-QXhNzo0BjVSEItBsj9ukreofsi9XSrR5OJpB03ZDMb0QwWB5oXDHBBukC7DyHNmWt+dNHq01k4XUmA6cCvnNjA==}
engines: {node: '>=20.9.0'}
'@clerk/electron-passkeys-darwin-arm64@0.0.3':
@@ -1736,8 +1737,8 @@ packages:
react-dom:
optional: true
- '@clerk/expo@4.1.2':
- resolution: {integrity: sha512-ZyGozbbizcGwEyK0CEXsPHnHnQoaa2CbTsXAqWBygzKEZyFjLjCHwQsQBKj6WgjAgn8mtw2ByIP9qLpItZffgw==}
+ '@clerk/expo@4.2.0':
+ resolution: {integrity: sha512-S2TYZn1Tltm/gXfe9J1NCuLRCmQX/3gfD0c+i/B1RWGbIJpSZX7KGRzvW70ABaqrK9IUcribLVmMN6unmpuCtQ==}
engines: {node: '>=20.9.0'}
peerDependencies:
'@clerk/expo-google-signin': '>=0.1.0'
@@ -1775,15 +1776,15 @@ packages:
react-dom:
optional: true
- '@clerk/react@6.12.9':
- resolution: {integrity: sha512-lQ1UOJhHGDweLCDf4IgT5r3rgkch1Gx21hNqK2/kOohJrJd/XM89fq2F7VK9Kg/Byc14TsfCh64Dm8ej2MyRWw==}
+ '@clerk/react@6.12.10':
+ resolution: {integrity: sha512-VDYyCyzRdMsNW6qQsMp1+L2VOXxFwpLfWBxOWPvUNOYCruecnAZjUYEJWS+E+i5MAPhSew9HjuweS4bO0FclTQ==}
engines: {node: '>=20.9.0'}
peerDependencies:
react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0
react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0
- '@clerk/shared@4.25.9':
- resolution: {integrity: sha512-nw3maGzqPrwmUKjGffOM9Zvw24IyOllq0TkQGzsTfQX312WLu2u5CNvjKntMb6Ok6kXa1Zdc77zmPLn1/EAIFA==}
+ '@clerk/shared@4.25.10':
+ resolution: {integrity: sha512-Vjqk74nQGJ8y+cm+eSshoBzRvBhIzuKLOs3Gt13gEHdyQ6yV798Dl2SrBKt0jw2u6I1tOcD3s8/i9P4yJYo0dw==}
engines: {node: '>=20.9.0'}
peerDependencies:
react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0
@@ -2949,18 +2950,6 @@ packages:
resolution: {integrity: sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==}
engines: {node: '>=12'}
- '@legendapp/list@3.2.0':
- resolution: {integrity: sha512-bN+g/oQYjFz+UAyuBN4cmYJAwdJS1TdNcZZOVlh3+VwCQUWrsg0PH46Mvm76gdZSCYMfoFanPY4dKnILcYEzeg==}
- peerDependencies:
- react: '*'
- react-dom: '*'
- react-native: '*'
- peerDependenciesMeta:
- react-dom:
- optional: true
- react-native:
- optional: true
-
'@legendapp/list@3.3.3':
resolution: {integrity: sha512-p3g4xG6f//s4XQKhuus2189GCQgOHEIbJXHePqeDxj+6UQQQyij4YBjyArNSCgqoP0c03sxDPSOuCFB128Ql6g==}
peerDependencies:
@@ -11522,16 +11511,16 @@ snapshots:
'@clerk/backend@3.14.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@clerk/shared': 4.25.9(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@clerk/shared': 4.25.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
standardwebhooks: 1.0.0
tslib: 2.8.1
transitivePeerDependencies:
- react
- react-dom
- '@clerk/clerk-js@6.25.12(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+ '@clerk/clerk-js@6.25.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
- '@clerk/shared': 4.25.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@clerk/shared': 4.25.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@stripe/stripe-js': 5.6.0
'@swc/helpers': 0.5.21
'@tanstack/query-core': 5.100.14
@@ -11546,9 +11535,9 @@ snapshots:
- react
- react-dom
- '@clerk/clerk-js@6.25.12(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
+ '@clerk/clerk-js@6.25.13(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@clerk/shared': 4.25.9(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@clerk/shared': 4.25.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@stripe/stripe-js': 5.6.0
'@swc/helpers': 0.5.21
'@tanstack/query-core': 5.100.14
@@ -11584,9 +11573,9 @@ snapshots:
'@clerk/electron@0.0.24(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@clerk/clerk-js': 6.25.12(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
- '@clerk/react': 6.12.9(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
- '@clerk/shared': 4.25.9(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@clerk/clerk-js': 6.25.13(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@clerk/react': 6.12.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@clerk/shared': 4.25.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
electron: 41.5.0
react: 19.2.6
tslib: 2.8.1
@@ -11595,11 +11584,11 @@ snapshots:
electron-store: 8.2.0
react-dom: 19.2.6(react@19.2.6)
- '@clerk/expo@4.1.2(expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)':
+ '@clerk/expo@4.2.0(expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)':
dependencies:
- '@clerk/clerk-js': 6.25.12(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
- '@clerk/react': 6.12.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
- '@clerk/shared': 4.25.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@clerk/clerk-js': 6.25.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@clerk/react': 6.12.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@clerk/shared': 4.25.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@expo/config-plugins': 56.0.9(typescript@6.0.3)
base-64: 1.0.0
expo: 56.0.12(8895228379997a2a064f9644cda56ed0)
@@ -11618,21 +11607,21 @@ snapshots:
- supports-color
- typescript
- '@clerk/react@6.12.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+ '@clerk/react@6.12.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
- '@clerk/shared': 4.25.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@clerk/shared': 4.25.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
tslib: 2.8.1
- '@clerk/react@6.12.9(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
+ '@clerk/react@6.12.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@clerk/shared': 4.25.9(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@clerk/shared': 4.25.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
tslib: 2.8.1
- '@clerk/shared@4.25.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+ '@clerk/shared@4.25.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@tanstack/query-core': 5.100.14
dequal: 2.0.3
@@ -11642,7 +11631,7 @@ snapshots:
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
- '@clerk/shared@4.25.9(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
+ '@clerk/shared@4.25.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@tanstack/query-core': 5.100.14
dequal: 2.0.3
@@ -12998,13 +12987,6 @@ snapshots:
dependencies:
jsbi: 4.3.2
- '@legendapp/list@3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
- dependencies:
- react: 19.2.6
- use-sync-external-store: 1.6.0(react@19.2.6)
- optionalDependencies:
- react-dom: 19.2.6(react@19.2.6)
-
'@legendapp/list@3.3.3(patch_hash=d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)':
dependencies:
react: 19.2.3
@@ -13013,6 +12995,13 @@ snapshots:
react-dom: 19.2.3(react@19.2.3)
react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
+ '@legendapp/list@3.3.3(patch_hash=d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
+ dependencies:
+ react: 19.2.6
+ use-sync-external-store: 1.6.0(react@19.2.6)
+ optionalDependencies:
+ react-dom: 19.2.6(react@19.2.6)
+
'@lexical/clipboard@0.41.0':
dependencies:
'@lexical/html': 0.41.0
@@ -14210,7 +14199,7 @@ snapshots:
- supports-color
- utf-8-validate
- '@react-native/gradle-plugin@0.85.3': {}
+ '@react-native/gradle-plugin@0.85.3(patch_hash=c1b594a16e682d621b6a960926f8ce13fc92edfb397884cfe7fcb0518996a784)': {}
'@react-native/js-polyfills@0.85.3': {}
@@ -20059,7 +20048,7 @@ snapshots:
'@react-native/assets-registry': 0.85.3
'@react-native/codegen': 0.85.3(@babel/core@7.29.7)
'@react-native/community-cli-plugin': 0.85.3(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(utf-8-validate@6.0.6)
- '@react-native/gradle-plugin': 0.85.3
+ '@react-native/gradle-plugin': 0.85.3(patch_hash=c1b594a16e682d621b6a960926f8ce13fc92edfb397884cfe7fcb0518996a784)
'@react-native/js-polyfills': 0.85.3
'@react-native/normalize-colors': 0.85.3
'@react-native/virtualized-lists': 0.85.3(@types/react@19.2.16)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
@@ -20104,7 +20093,7 @@ snapshots:
'@react-native/assets-registry': 0.85.3
'@react-native/codegen': 0.85.3(@babel/core@7.29.7)
'@react-native/community-cli-plugin': 0.85.3(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(utf-8-validate@6.0.6)
- '@react-native/gradle-plugin': 0.85.3
+ '@react-native/gradle-plugin': 0.85.3(patch_hash=c1b594a16e682d621b6a960926f8ce13fc92edfb397884cfe7fcb0518996a784)
'@react-native/js-polyfills': 0.85.3
'@react-native/normalize-colors': 0.85.3
'@react-native/virtualized-lists': 0.85.3(@types/react@19.2.16)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 5ce125d991c33..27d86fd178492 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -24,12 +24,12 @@ allowBuilds:
catalog:
"@clerk/backend": 3.14.0
- "@clerk/clerk-js": 6.25.12
+ "@clerk/clerk-js": 6.25.13
"@clerk/electron": 0.0.24
"@clerk/electron-passkeys": 0.0.3
- "@clerk/expo": 4.1.2
- "@clerk/react": 6.12.9
- "@clerk/shared": 4.25.9
+ "@clerk/expo": 4.2.0
+ "@clerk/react": 6.12.10
+ "@clerk/shared": 4.25.10
"@effect/atom-react": 4.0.0-beta.103
"@effect/openapi-generator": 4.0.0-beta.103
"@effect/platform-bun": 4.0.0-beta.103
@@ -53,11 +53,11 @@ catalog:
minimumReleaseAgeExclude:
- "@clerk/backend@3.14.0"
- - "@clerk/clerk-js@6.25.12"
+ - "@clerk/clerk-js@6.25.13"
- "@clerk/electron@0.0.24"
- - "@clerk/expo@4.1.2"
- - "@clerk/react@6.12.9"
- - "@clerk/shared@4.25.9"
+ - "@clerk/expo@4.2.0"
+ - "@clerk/react@6.12.10"
+ - "@clerk/shared@4.25.10"
- "@distilled.cloud/aws@0.30.2"
- "@distilled.cloud/axiom@0.30.2"
- "@distilled.cloud/cloudflare@0.30.2"
@@ -128,6 +128,7 @@ patchedDependencies:
"@legendapp/list@3.3.3": patches/@legendapp__list@3.3.3.patch
"@pierre/diffs@1.3.0-beta.10": patches/@pierre%2Fdiffs@1.3.0-beta.10.patch
"@react-native-menu/menu@2.0.0": patches/@react-native-menu__menu@2.0.0.patch
+ "@react-native/gradle-plugin@0.85.3": patches/@react-native__gradle-plugin@0.85.3.patch
"@react-navigation/native-stack@7.17.6": patches/@react-navigation%2Fnative-stack@7.17.6.patch
effect@4.0.0-beta.103: patches/effect@4.0.0-beta.103.patch
expo-modules-jsi@56.0.10: patches/expo-modules-jsi@56.0.10.patch