diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 1526965427f3d..a69cda53bf46a 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.0.31", + "version": "0.0.32", "private": true, "type": "module", "main": "dist-electron/main.cjs", diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 9a51373cfd052..c684689fb2641 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -161,7 +161,7 @@ const config: ExpoConfig = { slug: "t3-code", platforms: ["ios", "android"], scheme: variant.scheme, - version: "1.0.1", + version: "1.0.2", runtimeVersion: { // Fingerprint (not appVersion) so an OTA only reaches binaries whose native // project — native deps, config plugins, AND patches/ — matches the update. diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift index 659837fc7abd7..cee4d0427c603 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -535,6 +535,12 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { return } + if buffer.isEmpty { + feedData(Data("\u{1B}[3J\u{1B}[H\u{1B}[2J".utf8)) + lastAppliedBuffer = "" + return + } + if buffer.hasPrefix(lastAppliedBuffer) { let suffix = String(buffer.dropFirst(lastAppliedBuffer.count)) feedData(Data(suffix.utf8)) diff --git a/apps/mobile/src/components/CompactBrandTitle.tsx b/apps/mobile/src/components/CompactBrandTitle.tsx index f0710e85d36dc..28f7cfe57a7fb 100644 --- a/apps/mobile/src/components/CompactBrandTitle.tsx +++ b/apps/mobile/src/components/CompactBrandTitle.tsx @@ -16,6 +16,18 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../native/native-glass"; const IOS_NATIVE_LEADING_TITLE_OFFSET = -6; const IPAD_NATIVE_LEADING_TITLE_OFFSET = 7; +/** + * Horizontal correction applied to content rendered in the brand title slot, + * shared with the connection-status swap so both align identically. + */ +export function brandTitleOffset(nativeLeadingItem: boolean): number { + if (Platform.OS !== "ios") return 0; + if (nativeLeadingItem) { + return Platform.isPad ? IPAD_NATIVE_LEADING_TITLE_OFFSET : IOS_NATIVE_LEADING_TITLE_OFFSET; + } + return Platform.isPad ? IPAD_HOME_TITLE_OFFSET : 0; +} + /** * Compact brand lockup sized for native navigation bars. */ @@ -28,16 +40,7 @@ export function CompactBrandTitle( const mutedColor = useThemeColor("--color-foreground-muted"); const subtleColor = useThemeColor("--color-subtle"); const stageLabel = resolveMobileStageLabel(Constants.expoConfig?.extra?.appVariant); - const titleOffset = - Platform.OS !== "ios" - ? 0 - : props.nativeLeadingItem - ? Platform.isPad - ? IPAD_NATIVE_LEADING_TITLE_OFFSET - : IOS_NATIVE_LEADING_TITLE_OFFSET - : Platform.isPad - ? IPAD_HOME_TITLE_OFFSET - : 0; + const titleOffset = brandTitleOffset(props.nativeLeadingItem === true); return ( Effect.acquireRelease( Effect.sync(() => { - let active = true; const networkSubscription = Network.addNetworkStateListener((state) => { Queue.offerUnsafe(queue, networkStatus(state)); }); - const appStateSubscription = AppState.addEventListener("change", (state) => { - if (state !== "active") { - return; - } - void Network.getNetworkStateAsync() - .then((current) => { - if (active) { - Queue.offerUnsafe(queue, networkStatus(current)); - } - }) - .catch(() => undefined); - }); - return { - close: () => { - active = false; - networkSubscription.remove(); - appStateSubscription.remove(); - }, - }; + return networkSubscription; }), - ({ close }) => Effect.sync(close), + (subscription) => Effect.sync(() => subscription.remove()), ).pipe(Effect.asVoid), ), }); diff --git a/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx index 45132fd9ced92..464477ffc874b 100644 --- a/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx +++ b/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx @@ -58,8 +58,8 @@ export function ConnectionsRouteScreen() { contentInsetAdjustmentBehavior="automatic" showsVerticalScrollIndicator={false} className="flex-1" - contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }} contentContainerStyle={{ + paddingBottom: Math.max(insets.bottom, 18) + 18, paddingHorizontal: 20, paddingTop: 16, }} diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index a209dbd76239d..f3d33934a9b2e 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -21,6 +21,7 @@ import { NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, } from "../layout/native-mail-search-toolbar"; import type { HomeProjectSortOrder } from "./homeThreadList"; +import { WorkspaceConnectionTitle } from "./WorkspaceConnectionTitle"; import { buildHomeListFilterMenu, type HomeListFilterMenuEnvironment, @@ -47,6 +48,7 @@ export function HomeHeader(props: { readonly onProjectChange: (projectKey: string | null) => void; readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; + readonly onOpenEnvironments: () => void; readonly onOpenSettings: () => void; readonly onStartNewTask: () => void; }) { @@ -207,18 +209,27 @@ function AndroidHomeHeader(props: HomeHeaderProps) { > - - {/* Mirrors the desktop SidebarBrand: T3 mark + muted "Code". */} - - - Code - - - - {stageLabel} - - - + {/* Brand slot doubles as the connection status surface: while an + environment reconnects, the lockup fades to a status label in + place (no layout shift in the list below). */} + + {/* Mirrors the desktop SidebarBrand: T3 mark + muted "Code". */} + + + Code + + + + {stageLabel} + + + + } + /> navigation.navigate("NewTaskSheet", { screen: "NewTask" })} > <> - {/* Restore the compact title after the split branch blanks the detail header. */} - + {/* Restore the compact title after the split branch blanks the detail + header. The brand slot doubles as the connection status surface: + while an environment reconnects, the lockup fades to a status label + in place (no layout shift in the list below). */} + + navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }), + })} + /> + navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }) + } onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })} onProjectSortOrderChange={setProjectSortOrder} onSearchQueryChange={setSearchQuery} @@ -159,11 +171,9 @@ export function HomeRouteScreen() { onUnsettleThread={unsettleThread} onPinThread={pinThread} onUnpinThread={unpinThread} + onMovePinnedThread={movePinnedThread} onEnvironmentChange={setSelectedEnvironmentId} onProjectChange={setSelectedProjectKey} - onOpenEnvironments={() => - navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }) - } onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })} onProjectSortOrderChange={setProjectSortOrder} onSearchQueryChange={setSearchQuery} diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index ba32dc6b60957..64f0480d22313 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -11,6 +11,7 @@ import { threadSearchMatchKey, type EnvironmentThreadSearchMatch, } from "@t3tools/client-runtime/state/thread-search"; +import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; import type { EnvironmentId, SidebarProjectGroupingMode, @@ -71,8 +72,6 @@ import { type HomeProjectSortOrder, } from "./homeThreadList"; import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions"; -import { WorkspaceConnectionStatus } from "./WorkspaceConnectionStatus"; -import { shouldShowWorkspaceConnectionStatus } from "./workspace-connection-status"; /* ─── Types ──────────────────────────────────────────────────────────── */ @@ -97,7 +96,6 @@ interface HomeScreenProps { readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; readonly onAddConnection: () => void; - readonly onOpenEnvironments: () => void; readonly onOpenSettings: () => void; readonly onStartNewTask: () => void; readonly onSelectThread: (thread: EnvironmentThreadShell) => void; @@ -113,6 +111,10 @@ interface HomeScreenProps { readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; readonly onPinThread: (thread: EnvironmentThreadShell) => Promise; readonly onUnpinThread: (thread: EnvironmentThreadShell) => Promise; + readonly onMovePinnedThread: ( + thread: EnvironmentThreadShell, + direction: "up" | "down", + ) => Promise; readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; readonly onNewThreadInProject: (project: EnvironmentProject) => void; @@ -524,6 +526,12 @@ export function HomeScreen(props: HomeScreenProps) { }, [props.onPinThread], ); + const handleMovePinnedThread = useCallback( + (thread: EnvironmentThreadShell, direction: "up" | "down") => { + void props.onMovePinnedThread(thread, direction); + }, + [props.onMovePinnedThread], + ); const handleUnpinThread = useCallback( (thread: EnvironmentThreadShell) => { void props.onUnpinThread(thread); @@ -598,6 +606,29 @@ export function HomeScreen(props: HomeScreenProps) { } return supported; }, [serverConfigs]); + const pinReorderEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadPinReorder === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); + // Canonical arranged pinned order (reorder-capable threads only) for the + // Move up/down position flags. Computed from all shells, not the rendered + // list, so search/scope filtering never disables or misdirects a move. + const arrangedPinnedKeys = useMemo(() => { + const pinned = sortPinnedThreadsByOrderKey( + props.threads.filter( + (thread) => + thread.pinnedAt != null && + thread.archivedAt === null && + pinReorderEnvironmentIds.has(thread.environmentId), + ), + ); + return pinned.map((thread) => `${thread.environmentId}:${thread.id}`); + }, [pinReorderEnvironmentIds, props.threads]); const threadListV2Layout = useMemo(() => { if (!threadListV2Enabled) return { @@ -784,11 +815,18 @@ export function HomeScreen(props: HomeScreenProps) { onSettleThread={handleSettleThread} snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} pinningSupported={pinningEnvironmentIds.has(thread.environmentId)} + pinReorderSupported={pinReorderEnvironmentIds.has(thread.environmentId)} + canMovePinnedUp={arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`) > 0} + canMovePinnedDown={(() => { + const index = arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`); + return index !== -1 && index < arrangedPinnedKeys.length - 1; + })()} onSnoozeThread={handleSnoozeThread} onUnsnoozeThread={handleUnsnoozeThread} onUnsettleThread={handleUnsettleThread} onPinThread={handlePinThread} onUnpinThread={handleUnpinThread} + onMovePinnedThread={handleMovePinnedThread} onChangeRequestState={handleChangeRequestState} projectCwd={ projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null @@ -801,6 +839,8 @@ export function HomeScreen(props: HomeScreenProps) { [ handleChangeRequestState, handleDeleteThread, + arrangedPinnedKeys, + handleMovePinnedThread, handlePinThread, handleSettleThread, handleSnoozeThread, @@ -810,6 +850,7 @@ export function HomeScreen(props: HomeScreenProps) { handleSwipeableWillOpen, handleUnsettleThread, pinningEnvironmentIds, + pinReorderEnvironmentIds, projectByKey, projectCwdByKey, props.onArchiveThread, @@ -976,20 +1017,13 @@ export function HomeScreen(props: HomeScreenProps) { ? null : (props.savedConnectionsById[props.selectedEnvironmentId]?.environmentLabel ?? "this environment"); - const shouldShowConnectionStatus = shouldShowWorkspaceConnectionStatus(props.catalogState); + // Connection state surfaces in the header title slot + // (WorkspaceConnectionTitle) — nothing renders inside the list, so + // reconnects never shift the rows. const emptyState = deriveEmptyState({ catalogState: props.catalogState, projectCount: props.projects.length, }); - const connectionStatus = - shouldShowConnectionStatus && Platform.OS !== "ios" ? ( - - - - ) : null; if (!hasAnyThreads) { return ( @@ -1008,41 +1042,17 @@ export function HomeScreen(props: HomeScreenProps) { onAction={!props.catalogState.hasReadyEnvironment ? props.onAddConnection : undefined} variant="plain" /> - {emptyState.loading && !shouldShowConnectionStatus ? ( + {emptyState.loading ? ( ) : null} - {shouldShowConnectionStatus && Platform.OS === "ios" ? ( - - - - ) : null} - {connectionStatus} ); } - const listHeader = ( - <> - {Platform.OS === "ios" ? null : } - - {shouldShowConnectionStatus && Platform.OS === "ios" ? ( - - - - ) : null} - - ); + const listHeader = Platform.OS === "ios" ? null : ; // Project scoping lives in the header filter menu (no inline chip row on // mobile — the menu is the one filter surface). @@ -1123,7 +1133,6 @@ export function HomeScreen(props: HomeScreenProps) { }} /> - {connectionStatus} ); } @@ -1178,7 +1187,6 @@ export function HomeScreen(props: HomeScreenProps) { } /> - {connectionStatus} ); } diff --git a/apps/mobile/src/features/home/WorkspaceConnectionStatus.tsx b/apps/mobile/src/features/home/WorkspaceConnectionStatus.tsx deleted file mode 100644 index 1e986ad1a5063..0000000000000 --- a/apps/mobile/src/features/home/WorkspaceConnectionStatus.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { SymbolView } from "../../components/AppSymbol"; -import { ActivityIndicator, Pressable } from "react-native"; - -import { AppText as Text } from "../../components/AppText"; -import { useThemeColor } from "../../lib/useThemeColor"; -import type { WorkspaceState } from "../../state/workspaceModel"; -import { workspaceConnectionStatusLabel } from "./workspace-connection-status"; - -export function WorkspaceConnectionStatus(props: { - readonly state: WorkspaceState; - readonly onPress: () => void; - readonly variant?: "floating" | "sidebar"; -}) { - const iconColor = useThemeColor("--color-icon-muted"); - const isSynchronizing = - props.state.networkStatus !== "offline" && - props.state.connectionError === null && - (props.state.connectingEnvironments.length > 0 || props.state.hasPendingShellSnapshot); - const variant = props.variant ?? "floating"; - - return ( - - {isSynchronizing ? ( - - ) : ( - - )} - - {workspaceConnectionStatusLabel(props.state)} - - {variant === "sidebar" ? ( - - ) : null} - - ); -} diff --git a/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx b/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx new file mode 100644 index 0000000000000..1867042988bad --- /dev/null +++ b/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx @@ -0,0 +1,194 @@ +import type { + NativeStackHeaderItem, + NativeStackNavigationOptions, +} from "@react-navigation/native-stack"; +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { ActivityIndicator, Animated, Platform, Pressable, View } from "react-native"; + +import { SymbolView } from "../../components/AppSymbol"; +import { AppText as Text } from "../../components/AppText"; +import { brandTitleOffset, CompactBrandTitle } from "../../components/CompactBrandTitle"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; +import { useWorkspaceState } from "../../state/workspace"; +import { + workspaceConnectionStatusPresentation, + type WorkspaceConnectionStatusPresentation, +} from "./workspace-connection-status"; + +/** + * Delay before a connection interruption surfaces in the title slot. Sub-second + * blips (the common reconnect case) resolve without any UI at all. + */ +const STATUS_SHOW_DELAY_MS = 800; +const FADE_IN_MS = 250; + +/** + * Connection status presentation, debounced for display: null until the + * workspace has been in a non-connected state for STATUS_SHOW_DELAY_MS, + * then live-updating until the workspace reconnects (null again immediately). + */ +function useDelayedConnectionStatus(): WorkspaceConnectionStatusPresentation | null { + const { state } = useWorkspaceState(); + const presentation = workspaceConnectionStatusPresentation(state); + const hasStatus = presentation !== null; + const [visible, setVisible] = useState(false); + + useEffect(() => { + if (!hasStatus) { + setVisible(false); + return; + } + const timer = setTimeout(() => setVisible(true), STATUS_SHOW_DELAY_MS); + return () => clearTimeout(timer); + }, [hasStatus]); + + return visible ? presentation : null; +} + +/** + * One-shot entrance fade for the status label. Deliberately JS-driven: this can + * mount inside a native header item (RNSScreenStackHeaderSubview), where + * native-driver animated nodes blank the re-hosted view entirely. The JS driver + * updates opacity through the ordinary style path, which those subviews handle. + */ +function StatusFadeIn(props: { readonly children: ReactNode; readonly grow?: boolean }) { + const opacity = useRef(new Animated.Value(0)).current; + + useEffect(() => { + const animation = Animated.timing(opacity, { + duration: FADE_IN_MS, + toValue: 1, + useNativeDriver: false, + }); + animation.start(); + return () => animation.stop(); + }, [opacity]); + + return ( + + {props.children} + + ); +} + +/** + * Renders the brand/title slot of a thread-list surface, swapping the brand + * for the workspace connection status while an environment is unavailable. + * + * Both states occupy the same slot, so connection changes never shift the + * layout below. While connected the brand renders untouched — no wrapper — + * keeping the native header item on the exact element tree that predates the + * status swap. Replaces the old WorkspaceConnectionStatus pill, which inserted + * a row above the thread list. + */ +export function WorkspaceConnectionTitle(props: { + /** Content shown while connected (brand lockup or a screen title). */ + readonly brand: ReactNode; + /** Opens environment settings. Status is not pressable when omitted. */ + readonly onPress?: () => void; + /** Fill the available row width (in-flow headers) instead of hugging content (native title slots). */ + readonly grow?: boolean; + readonly size?: "navbar" | "pageTitle"; + /** Horizontal correction so the status aligns with the brand in native title slots. */ + readonly statusOffset?: number; +}) { + const iconColor = String(useThemeColor("--color-icon-muted")); + const status = useDelayedConnectionStatus(); + const size = props.size ?? "navbar"; + + if (status === null) { + return props.grow ? ( + + {props.brand} + + ) : ( + <>{props.brand} + ); + } + + return ( + + + {status.showsProgress ? ( + + ) : ( + + )} + + {status.label} + + + + ); +} + +/** + * getCompactBrandHeaderOptions with the brand slot upgraded to the + * connection-status swap. Screens with an environment-settings callback apply + * this over the static brand options at mount. + */ +export function getConnectionAwareBrandHeaderOptions(opts: { + readonly onOpenEnvironments: () => void; + readonly fallbackTitleStyle?: NativeStackNavigationOptions["headerTitleStyle"]; +}): NativeStackNavigationOptions { + if (Platform.OS === "ios" && NATIVE_LIQUID_GLASS_SUPPORTED) { + return { + headerTitle: "Threads", + headerTitleStyle: { color: "transparent", fontSize: 18, fontWeight: "800" }, + title: "Threads", + unstable_headerLeftItems: (): NativeStackHeaderItem[] => [ + { + element: ( + } + onPress={opts.onOpenEnvironments} + statusOffset={brandTitleOffset(true)} + /> + ), + hidesSharedBackground: true, + type: "custom", + }, + ], + }; + } + + return { + headerTitle: () => ( + } + onPress={opts.onOpenEnvironments} + statusOffset={brandTitleOffset(false)} + /> + ), + headerTitleStyle: opts.fallbackTitleStyle, + title: "Threads", + }; +} diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index dcea2b6791b05..3103c5379be2f 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -8,9 +8,14 @@ import { Alert } from "react-native"; import { showConfirmDialog } from "../../components/ConfirmDialogHost"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots"; +import { + pinOrderKeyBetween, + planPinnedMove, + sortPinnedThreadsByOrderKey, +} from "@t3tools/client-runtime/state/thread-sort"; import { appAtomRegistry } from "../../state/atom-registry"; import { environmentServerConfigsAtom } from "../../state/server"; -import { threadEnvironment } from "../../state/threads"; +import { environmentThreadShells, threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; /** Version skew: never send settle/unsettle to a server that predates them @@ -36,6 +41,13 @@ function environmentSupportsPinning(environmentId: EnvironmentThreadShell["envir ); } +function environmentSupportsPinReorder(environmentId: EnvironmentThreadShell["environmentId"]) { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .threadPinReorder === true + ); +} + type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle"; const ACTION_VERBS: Record = { @@ -211,6 +223,10 @@ export function useThreadListActions(): { readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise; readonly pinThread: (thread: EnvironmentThreadShell) => Promise; readonly unpinThread: (thread: EnvironmentThreadShell) => Promise; + readonly movePinnedThread: ( + thread: EnvironmentThreadShell, + direction: "up" | "down", + ) => Promise; } { const executeAction = useThreadActionExecutor(); const snoozeMutation = useAtomCommand(threadEnvironment.snooze, { reportFailure: false }); @@ -331,9 +347,21 @@ export function useThreadListActions(): { return false; } selectionHaptic(); + // Same placement as web: a fresh pin takes the top of the arranged + // run. Servers that predate reordering get the bare pin (keyless). + let orderKey: string | undefined; + if (environmentSupportsPinReorder(thread.environmentId)) { + const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom); + let firstKey: string | null = null; + for (const shell of shells) { + if (shell.pinnedAt == null || shell.pinOrderKey == null) continue; + if (firstKey === null || shell.pinOrderKey < firstKey) firstKey = shell.pinOrderKey; + } + orderKey = pinOrderKeyBetween(null, firstKey) ?? undefined; + } const result = await pinMutation({ environmentId: thread.environmentId, - input: { threadId: thread.id }, + input: { threadId: thread.id, ...(orderKey !== undefined ? { orderKey } : {}) }, }); if (result._tag === "Failure") { const error = Cause.squash(result.cause); @@ -378,6 +406,85 @@ export function useThreadListActions(): { [unpinMutation], ); + // Move up / Move down for the pinned block. Computed against the CANONICAL + // keyed pinned order (not the rendered list), so the move is valid even + // while search or a project scope filters rows: the same fractional-key + // scheme web dragging uses, one write to one thread per move (plus a + // one-time section materialization when legacy keyless pins are involved). + const reorderPinnedMutation = useAtomCommand(threadEnvironment.reorderPin, { + reportFailure: false, + }); + // One move at a time: a second tap before the first write's event lands + // would plan from the same stale snapshot and silently collapse two moves + // into one — same double-dispatch guard as snoozeThread. + const movePinnedInFlightRef = useRef(false); + const movePinnedThread = useCallback( + async (thread: EnvironmentThreadShell, direction: "up" | "down") => { + if (movePinnedInFlightRef.current) return false; + if (!environmentSupportsPinReorder(thread.environmentId)) { + Alert.alert( + "Could not move thread", + "This environment's server does not support pinned reordering yet. Update the server to reorder pins.", + ); + return false; + } + const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom); + const pinned = sortPinnedThreadsByOrderKey( + shells.filter( + (shell) => + shell.pinnedAt != null && + shell.archivedAt === null && + environmentSupportsPinReorder(shell.environmentId), + ), + ); + const orderedIds = pinned.map((shell) => scopedThreadKey(shell.environmentId, shell.id)); + const assignments = planPinnedMove({ + orderedIds, + keysById: new Map( + pinned.map((shell) => [ + scopedThreadKey(shell.environmentId, shell.id), + shell.pinOrderKey ?? null, + ]), + ), + movedId: scopedThreadKey(thread.environmentId, thread.id), + direction, + }); + if (assignments === null || assignments.length === 0) return false; + const shellByKey = new Map( + pinned.map((shell) => [scopedThreadKey(shell.environmentId, shell.id), shell]), + ); + selectionHaptic(); + movePinnedInFlightRef.current = true; + try { + for (const assignment of assignments) { + const target = shellByKey.get(assignment.id); + if (target === undefined) continue; + const result = await reorderPinnedMutation({ + environmentId: target.environmentId, + input: { threadId: target.id, orderKey: assignment.orderKey }, + }); + if (result._tag === "Failure") { + const error = Cause.squash(result.cause); + Alert.alert( + "Could not move thread", + error instanceof Error && error.message.trim().length > 0 + ? error.message + : "The pinned thread could not be moved.", + ); + // No rollback: keys already written are valid orderings on their + // own (each write is a complete, consistent placement), so a + // partial materialization leaves the list sensible, not corrupt. + return false; + } + } + return true; + } finally { + movePinnedInFlightRef.current = false; + } + }, + [reorderPinnedMutation], + ); + const confirmDeleteThread = useConfirmDeleteThread(executeAction); return { @@ -389,6 +496,7 @@ export function useThreadListActions(): { unsettleThread, pinThread, unpinThread, + movePinnedThread, }; } diff --git a/apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts b/apps/mobile/src/features/home/workspace-connection-status.test.ts similarity index 72% rename from apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts rename to apps/mobile/src/features/home/workspace-connection-status.test.ts index 8c3c873cc9e84..15a990bb1cbe8 100644 --- a/apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts +++ b/apps/mobile/src/features/home/workspace-connection-status.test.ts @@ -4,6 +4,7 @@ import type { WorkspaceState } from "../../state/workspaceModel"; import { shouldShowWorkspaceConnectionStatus, workspaceConnectionStatusLabel, + workspaceConnectionStatusPresentation, } from "./workspace-connection-status"; function workspaceState(overrides: Partial = {}): WorkspaceState { @@ -84,4 +85,36 @@ describe("workspace connection status", () => { expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true); expect(workspaceConnectionStatusLabel(state)).toBe("Loading threads..."); }); + + it("presents nothing while connected", () => { + expect(workspaceConnectionStatusPresentation(workspaceState())).toBeNull(); + }); + + it("presents progress while reconnecting but not while offline", () => { + const reconnecting = workspaceState({ + hasConnectingEnvironment: true, + hasReadyEnvironment: false, + connectingEnvironments: [ + { + environmentId: "environment-1" as never, + environmentLabel: "Julius’s Mac mini", + displayUrl: "", + isRelayManaged: false, + connectionState: "reconnecting", + connectionError: null, + connectionErrorTraceId: null, + }, + ], + }); + expect(workspaceConnectionStatusPresentation(reconnecting)).toEqual({ + label: "Reconnecting to Julius’s Mac mini", + showsProgress: true, + }); + + const offline = workspaceState({ networkStatus: "offline", hasReadyEnvironment: false }); + expect(workspaceConnectionStatusPresentation(offline)).toEqual({ + label: "You are offline", + showsProgress: false, + }); + }); }); diff --git a/apps/mobile/src/features/home/workspace-connection-status.ts b/apps/mobile/src/features/home/workspace-connection-status.ts index d8eed4383b180..6f9898b1bb01f 100644 --- a/apps/mobile/src/features/home/workspace-connection-status.ts +++ b/apps/mobile/src/features/home/workspace-connection-status.ts @@ -1,5 +1,11 @@ import type { WorkspaceState } from "../../state/workspaceModel"; +export interface WorkspaceConnectionStatusPresentation { + readonly label: string; + /** True while actively working (connecting/syncing) — render a spinner. False for offline/error/idle states — render a wifi-slash icon. */ + readonly showsProgress: boolean; +} + export function shouldShowWorkspaceConnectionStatus(state: WorkspaceState): boolean { return ( state.networkStatus === "offline" || @@ -24,3 +30,17 @@ export function workspaceConnectionStatusLabel(state: WorkspaceState): string { } return "Not connected"; } + +/** Header-title presentation of the connection state, or null while connected. */ +export function workspaceConnectionStatusPresentation( + state: WorkspaceState, +): WorkspaceConnectionStatusPresentation | null { + if (!shouldShowWorkspaceConnectionStatus(state)) return null; + return { + label: workspaceConnectionStatusLabel(state), + showsProgress: + state.networkStatus !== "offline" && + state.connectionError === null && + (state.connectingEnvironments.length > 0 || state.hasPendingShellSnapshot), + }; +} diff --git a/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx index f6f92f5fc845e..e4efdf70c3178 100644 --- a/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx @@ -2,7 +2,7 @@ import { useAuth } from "@clerk/expo"; import { AuthView, UserProfileView } from "@clerk/expo/native"; import { StackActions, useNavigation } from "@react-navigation/native"; import { NativeStackScreenOptions } from "../../native/StackHeader"; -import { useEffect } from "react"; +import { useCallback, useEffect } from "react"; import { View } from "react-native"; import { hasCloudPublicConfig } from "../cloud/publicConfig"; @@ -21,16 +21,18 @@ export function SettingsAuthRouteScreen() { function ConfiguredSettingsAuthRouteScreen() { const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); + const navigation = useNavigation(); + const handleHostBack = useCallback(() => navigation.goBack(), [navigation]); return ( <> - + {isLoaded ? ( isSignedIn ? ( - + ) : ( - + ) ) : null} diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index f592182592880..bc044d905b474 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -235,10 +235,9 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps + {/* The backdrop gradient lives on a plain View: Reanimated's Animated.View + silently drops experimental_backgroundImage on Android, which left this + strip fully transparent and the feed text legible through the composer. */} + { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadPinReorder === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); + // Canonical arranged pinned order for Move up/down flags — computed from + // all shells so search/scope filtering never disables a valid move. + const arrangedPinnedKeys = useMemo(() => { + const pinned = sortPinnedThreadsByOrderKey( + threads.filter( + (thread) => + thread.pinnedAt != null && + thread.archivedAt === null && + pinReorderEnvironmentIds.has(thread.environmentId), + ), + ); + return pinned.map((thread) => `${thread.environmentId}:${thread.id}`); + }, [pinReorderEnvironmentIds, threads]); const threadListV2Layout = useMemo(() => { if (!threadListV2Enabled) return { @@ -600,7 +626,6 @@ function ThreadNavigationSidebarPane( threadListV2Enabled, threadListV2Layout, ]); - const showsConnectionStatus = shouldShowWorkspaceConnectionStatus(catalogState); const listMenuActions = useMemo( () => [ { @@ -929,11 +954,20 @@ function ThreadNavigationSidebarPane( onSettleThread={settleThread} snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} pinningSupported={pinningEnvironmentIds.has(thread.environmentId)} + pinReorderSupported={pinReorderEnvironmentIds.has(thread.environmentId)} + canMovePinnedUp={ + arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`) > 0 + } + canMovePinnedDown={(() => { + const index = arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`); + return index !== -1 && index < arrangedPinnedKeys.length - 1; + })()} onSnoozeThread={snoozeThread} onUnsnoozeThread={unsnoozeThread} onUnsettleThread={unsettleThread} onPinThread={pinThread} onUnpinThread={unpinThread} + onMovePinnedThread={movePinnedThread} onChangeRequestState={handleChangeRequestState} projectCwd={projectCwdByKey.get(scopeKey) ?? null} onSwipeableClose={handleSwipeableClose} @@ -1054,13 +1088,16 @@ function ThreadNavigationSidebarPane( }, [ archiveThread, + arrangedPinnedKeys, confirmDeletePendingTask, confirmDeleteThread, handleChangeRequestState, handleSelectThread, handleSwipeableClose, handleSwipeableWillOpen, + movePinnedThread, openPendingTask, + pinReorderEnvironmentIds, pinThread, pinningEnvironmentIds, projectByKey, @@ -1153,6 +1190,13 @@ function ThreadNavigationSidebarPane( - - - ) : null - } ListEmptyComponent={listEmpty} /> @@ -1304,9 +1337,19 @@ function ThreadNavigationSidebarPane( - - Threads - + {/* Title slot doubles as the connection status surface: while an + environment reconnects, "Threads" fades to a status label in + place (no layout shift in the list below). */} + + Threads + + } + /> - - {showsConnectionStatus ? ( - - - - ) : null} ); diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 24f7166916bca..eb618146172e7 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -354,6 +354,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly snoozeSupported: boolean; /** False on servers that predate thread.pin/unpin. */ readonly pinningSupported: boolean; + /** False on servers that predate thread.pin.reorder. Gates the pinned + Move up / Move down menu items. */ + readonly pinReorderSupported?: boolean; + readonly onMovePinnedThread?: (thread: EnvironmentThreadShell, direction: "up" | "down") => void; + /** Position flags for the pinned block so the menu disables the move that + would fall off the end of the list. */ + readonly canMovePinnedUp?: boolean; + readonly canMovePinnedDown?: boolean; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; readonly onSwipeableClose: (methods: SwipeableMethods) => void; /** Reports this row's live PR state up so the partition can auto-settle @@ -382,6 +390,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onArchiveThread, onPinThread, onUnpinThread, + onMovePinnedThread, onChangeRequestState, } = props; const snoozedRow = props.snoozed === true; @@ -416,6 +425,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const handleUnsettle = useCallback(() => onUnsettleThread(thread), [onUnsettleThread, thread]); const handlePin = useCallback(() => onPinThread(thread), [onPinThread, thread]); const handleUnpin = useCallback(() => onUnpinThread(thread), [onUnpinThread, thread]); + const handleMovePinnedUp = useCallback( + () => onMovePinnedThread?.(thread, "up"), + [onMovePinnedThread, thread], + ); + const handleMovePinnedDown = useCallback( + () => onMovePinnedThread?.(thread, "down"), + [onMovePinnedThread, thread], + ); const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); // Swipe: the v2 primary action is the lifecycle transition. Every settled @@ -459,12 +476,34 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { () => props.pinningSupported ? [ + ...(pinnedRow && props.pinReorderSupported === true + ? [ + { + id: "move-pin-up", + title: "Move up", + image: "arrow.up", + attributes: { disabled: props.canMovePinnedUp !== true }, + } satisfies MenuAction, + { + id: "move-pin-down", + title: "Move down", + image: "arrow.down", + attributes: { disabled: props.canMovePinnedDown !== true }, + } satisfies MenuAction, + ] + : []), pinnedRow ? { id: "unpin", title: "Unpin", image: "pin.slash" } : { id: "pin", title: "Pin", image: "pin" }, ] : [], - [pinnedRow, props.pinningSupported], + [ + pinnedRow, + props.canMovePinnedDown, + props.canMovePinnedUp, + props.pinReorderSupported, + props.pinningSupported, + ], ); const snoozableCardMenuActions = useMemo( () => [ @@ -491,6 +530,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { if (nativeEvent.event === "unsnooze") handleUnsnooze(); if (nativeEvent.event === "pin") handlePin(); if (nativeEvent.event === "unpin") handleUnpin(); + if (nativeEvent.event === "move-pin-up") handleMovePinnedUp(); + if (nativeEvent.event === "move-pin-down") handleMovePinnedDown(); if (nativeEvent.event === "archive") handleArchive(); if (nativeEvent.event === "delete") handleDelete(); const snoozeSelection = resolveThreadListV2SnoozeMenuSelection({ @@ -507,6 +548,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { [ handleArchive, handleDelete, + handleMovePinnedDown, + handleMovePinnedUp, handlePin, handleSettle, handleSnooze, diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index fa5f58d5d0ee7..ef9216ad96f03 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -9,6 +9,7 @@ import { import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; +import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; @@ -383,8 +384,8 @@ export function buildThreadListV2Items(input: { input.changeRequestStateByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null; // Visibility parity with web: snooze outranks everything, including a // pin — a snoozed thread leaves the list until it wakes (or raises its - // hand). The pin survives underneath, so a woken thread reappears at - // its original spot in the creation-ordered pinned block. + // hand). The pin (and its pinOrderKey) survives underneath, so a woken + // thread reappears at its exact spot in the pinned block. if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) { snoozed.push(thread); if ( @@ -444,7 +445,7 @@ export function buildThreadListV2Items(input: { ); const items: ThreadListV2Item[] = []; - for (const thread of sortThreadsForListV2(pinned)) { + for (const thread of sortPinnedThreadsByOrderKey(pinned)) { items.push({ thread, variant: "card", diff --git a/apps/server/integration/providerService.integration.test.ts b/apps/server/integration/providerService.integration.test.ts index e703af4b1f45b..c57d289f99292 100644 --- a/apps/server/integration/providerService.integration.test.ts +++ b/apps/server/integration/providerService.integration.test.ts @@ -8,6 +8,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts"; @@ -54,35 +55,58 @@ interface IntegrationFixture { readonly layer: Layer.Layer; } -const makeIntegrationFixture = Effect.gen(function* () { - const cwd = yield* makeWorkspaceDirectory; - const harness = yield* makeTestProviderAdapterHarness(); - - const registry = makeAdapterRegistryMock({ - [ProviderDriverKind.make("codex")]: harness.adapter, - }); +interface RecordedAnalyticsEvent { + readonly event: string; + readonly properties: Readonly> | undefined; +} - const directoryLayer = ProviderSessionDirectoryLive.pipe( - Layer.provide(ProviderSessionRuntime.layer), +/** + * Analytics layer that keeps captured events in memory so tests can assert on + * telemetry payloads. `AnalyticsService.layerTest` discards them. + */ +const makeRecordingAnalytics = Effect.gen(function* () { + const recorded = yield* Ref.make>([]); + const layer = Layer.succeed( + AnalyticsService, + AnalyticsService.of({ + record: (event, properties) => + Ref.update(recorded, (current) => [...current, { event, properties }]), + flush: Effect.void, + }), ); - - const shared = Layer.mergeAll( - directoryLayer, - Layer.succeed(ProviderAdapterRegistry, registry), - ServerSettingsService.layerTest(DEFAULT_SERVER_SETTINGS), - AnalyticsService.layerTest, - Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers), - ).pipe(Layer.provide(SqlitePersistenceMemory)); - - const layer = makeProviderServiceLive().pipe(Layer.provide(shared)); - - return { - cwd, - harness, - layer, - } satisfies IntegrationFixture; + return { layer, get: Ref.get(recorded) } as const; }); +const makeIntegrationFixture = (options?: { readonly analytics?: Layer.Layer }) => + Effect.gen(function* () { + const cwd = yield* makeWorkspaceDirectory; + const harness = yield* makeTestProviderAdapterHarness(); + + const registry = makeAdapterRegistryMock({ + [ProviderDriverKind.make("codex")]: harness.adapter, + }); + + const directoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(ProviderSessionRuntime.layer), + ); + + const shared = Layer.mergeAll( + directoryLayer, + Layer.succeed(ProviderAdapterRegistry, registry), + ServerSettingsService.layerTest(DEFAULT_SERVER_SETTINGS), + options?.analytics ?? AnalyticsService.layerTest, + Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers), + ).pipe(Layer.provide(SqlitePersistenceMemory)); + + const layer = makeProviderServiceLive().pipe(Layer.provide(shared)); + + return { + cwd, + harness, + layer, + } satisfies IntegrationFixture; + }); + const collectEventsDuring = ( stream: Stream.Stream, count: number, @@ -126,7 +150,7 @@ const runTurn = (input: { it.live("replays typed runtime fixture events", () => Effect.gen(function* () { - const fixture = yield* makeIntegrationFixture; + const fixture = yield* makeIntegrationFixture(); yield* Effect.gen(function* () { const provider = yield* ProviderService; @@ -161,7 +185,7 @@ it.live("replays typed runtime fixture events", () => it.live("replays file-changing fixture turn events", () => Effect.gen(function* () { - const fixture = yield* makeIntegrationFixture; + const fixture = yield* makeIntegrationFixture(); const { join } = yield* Path.Path; const { writeFileString } = yield* FileSystem.FileSystem; @@ -198,7 +222,7 @@ it.live("replays file-changing fixture turn events", () => it.live("runs multi-turn tool/approval flow", () => Effect.gen(function* () { - const fixture = yield* makeIntegrationFixture; + const fixture = yield* makeIntegrationFixture(); const { join } = yield* Path.Path; const { writeFileString } = yield* FileSystem.FileSystem; @@ -250,7 +274,7 @@ it.live("runs multi-turn tool/approval flow", () => it.live("rolls back provider conversation state only", () => Effect.gen(function* () { - const fixture = yield* makeIntegrationFixture; + const fixture = yield* makeIntegrationFixture(); const { join } = yield* Path.Path; const { writeFileString, readFileString } = yield* FileSystem.FileSystem; @@ -302,3 +326,59 @@ it.live("rolls back provider conversation state only", () => }).pipe(Effect.provide(fixture.layer)); }).pipe(Effect.provide(NodeServices.layer)), ); + +it.live("reports runtime mode per turn and on mode transitions", () => + Effect.gen(function* () { + const analytics = yield* makeRecordingAnalytics; + const fixture = yield* makeIntegrationFixture({ analytics: analytics.layer }); + const threadId = ThreadId.make("thread-integration-runtime-mode"); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + const startSession = (runtimeMode: "approval-required" | "full-access") => + provider.startSession(threadId, { + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + cwd: fixture.cwd, + runtimeMode, + }); + + yield* startSession("approval-required"); + yield* runTurn({ + provider, + harness: fixture.harness, + threadId, + userText: "supervised turn", + response: { events: codexTurnTextFixture }, + }); + + // Toggling the mode restarts the session, which is the only place the + // transition is observable. + yield* startSession("full-access"); + yield* runTurn({ + provider, + harness: fixture.harness, + threadId, + userText: "full access turn", + response: { events: codexTurnTextFixture }, + }); + + const recorded = yield* analytics.get; + + assert.deepEqual( + recorded + .filter((entry) => entry.event === "provider.turn.sent") + .map((entry) => entry.properties?.runtimeMode), + ["approval-required", "full-access"], + ); + + assert.deepEqual( + recorded + .filter((entry) => entry.event === "provider.runtime_mode.changed") + .map((entry) => [entry.properties?.from, entry.properties?.to]), + [["approval-required", "full-access"]], + ); + }).pipe(Effect.provide(fixture.layer)); + }).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/package.json b/apps/server/package.json index 8e7b5b385912e..360f00569b5db 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.0.31", + "version": "0.0.32", "license": "MIT", "repository": { "type": "git", diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index b6eedb87e667b..c697b4bd98f55 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -146,6 +146,7 @@ export const make = Effect.gen(function* () { threadSettlement: true, threadSnooze: true, threadPinning: true, + threadPinReorder: true, threadTitleRegeneration: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 7776e374ee238..38a70240d973c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -612,6 +612,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti snoozedUntil: null, snoozedAt: null, pinnedAt: null, + pinOrderKey: null, titleRegenerationRequestId: null, titleRegenerationStartedAt: null, latestUserMessageAt: null, @@ -728,6 +729,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti yield* projectionThreadRepository.upsert({ ...existingRow.value, pinnedAt: event.payload.pinnedAt, + ...(event.payload.pinOrderKey !== undefined + ? { pinOrderKey: event.payload.pinOrderKey } + : {}), updatedAt: event.payload.updatedAt, }); return; @@ -743,6 +747,22 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti yield* projectionThreadRepository.upsert({ ...existingRow.value, pinnedAt: null, + pinOrderKey: null, + updatedAt: event.payload.updatedAt, + }); + return; + } + + case "thread.pin-reordered": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + pinOrderKey: event.payload.orderKey, updatedAt: event.payload.updatedAt, }); return; diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index d5dda7aa86b20..c89124751b562 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -87,6 +87,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { pending_approval_count, pending_user_input_count, has_actionable_proposed_plan, + pinned_at, + pin_order_key, created_at, updated_at, deleted_at @@ -105,6 +107,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { 1, 0, 0, + '2026-02-24T00:00:01.000Z', + 'gm', '2026-02-24T00:00:02.000Z', '2026-02-24T00:00:03.000Z', NULL @@ -317,7 +321,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { settledAt: null, snoozedUntil: null, snoozedAt: null, - pinnedAt: null, + pinnedAt: "2026-02-24T00:00:01.000Z", + pinOrderKey: "gm", titleRegeneration: null, deletedAt: null, messages: [ @@ -433,7 +438,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { settledAt: null, snoozedUntil: null, snoozedAt: null, - pinnedAt: null, + pinnedAt: "2026-02-24T00:00:01.000Z", + pinOrderKey: "gm", titleRegeneration: null, session: { threadId: ThreadId.make("thread-1"), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 9633f162d2b41..e744574a73cc7 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -423,6 +423,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", + pin_order_key AS "pinOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -458,6 +459,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", + pin_order_key AS "pinOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -495,6 +497,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", + pin_order_key AS "pinOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -932,6 +935,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", + pin_order_key AS "pinOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -1562,6 +1566,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, + pinOrderKey: row.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), deletedAt: row.deletedAt, messages: messagesByThread.get(row.threadId) ?? [], @@ -1766,6 +1771,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, + pinOrderKey: row.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), deletedAt: row.deletedAt, messages: [], @@ -1901,6 +1907,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, + pinOrderKey: row.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, @@ -2045,6 +2052,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, + pinOrderKey: row.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, @@ -2321,6 +2329,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: threadRow.value.snoozedUntil, snoozedAt: threadRow.value.snoozedAt, pinnedAt: threadRow.value.pinnedAt, + pinOrderKey: threadRow.value.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(threadRow.value), session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, latestUserMessageAt: threadRow.value.latestUserMessageAt, @@ -2441,6 +2450,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: threadRow.value.snoozedUntil, snoozedAt: threadRow.value.snoozedAt, pinnedAt: threadRow.value.pinnedAt, + pinOrderKey: threadRow.value.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(threadRow.value), deletedAt: null, messages: messageRows.map((row) => { diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index ee96e422945ae..7e866cf89592b 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -15,6 +15,7 @@ import { ThreadUnsnoozedPayload as ContractsThreadUnsnoozedPayloadSchema, ThreadPinnedPayload as ContractsThreadPinnedPayloadSchema, ThreadUnpinnedPayload as ContractsThreadUnpinnedPayloadSchema, + ThreadPinReorderedPayload as ContractsThreadPinReorderedPayloadSchema, ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema, ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema, @@ -46,6 +47,7 @@ export const ThreadSnoozedPayload = ContractsThreadSnoozedPayloadSchema; export const ThreadUnsnoozedPayload = ContractsThreadUnsnoozedPayloadSchema; export const ThreadPinnedPayload = ContractsThreadPinnedPayloadSchema; export const ThreadUnpinnedPayload = ContractsThreadUnpinnedPayloadSchema; +export const ThreadPinReorderedPayload = ContractsThreadPinReorderedPayloadSchema; export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema; diff --git a/apps/server/src/orchestration/decider.pinned.test.ts b/apps/server/src/orchestration/decider.pinned.test.ts index bed41e13a17e4..4ad00ba994b47 100644 --- a/apps/server/src/orchestration/decider.pinned.test.ts +++ b/apps/server/src/orchestration/decider.pinned.test.ts @@ -16,6 +16,7 @@ const PINNED_AT = "1969-12-30T00:00:00.000Z"; function makeReadModel(input: { readonly pinnedAt?: string | null; + readonly pinOrderKey?: string | null; readonly archivedAt?: string | null; readonly settledOverride?: "settled" | "active" | null; readonly settledAt?: string | null; @@ -44,6 +45,7 @@ function makeReadModel(input: { snoozedUntil: input.snoozedUntil ?? null, snoozedAt: input.snoozedAt ?? (input.snoozedUntil != null ? PINNED_AT : null), pinnedAt: input.pinnedAt ?? null, + pinOrderKey: input.pinOrderKey ?? null, deletedAt: null, messages: [], proposedPlans: [], @@ -223,4 +225,100 @@ it.layer(NodeServices.layer)("pinned thread decider", (it) => { expect(error._tag).toBe("OrchestrationCommandInvariantError"); }), ); + + it.effect("a fresh pin carries the client's order key", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.pin", + commandId: CommandId.make("cmd-pin-keyed"), + threadId: ThreadId.make("thread-1"), + orderKey: "g", + }, + readModel: makeReadModel({}), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events[0]?.type).toBe("thread.pinned"); + if (events[0]?.type === "thread.pinned") { + expect(events[0].payload.pinOrderKey).toBe("g"); + } + }), + ); + + it.effect( + "re-pinning ignores the incoming order key so raced pins cannot move a placed thread", + () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.pin", + commandId: CommandId.make("cmd-pin-keyed-again"), + threadId: ThreadId.make("thread-1"), + orderKey: "t", + }, + readModel: makeReadModel({ pinnedAt: PINNED_AT, pinOrderKey: "g" }), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events[0]?.type).toBe("thread.pinned"); + if (events[0]?.type === "thread.pinned") { + expect(events[0].payload.pinOrderKey).toBeUndefined(); + } + }), + ); + + it.effect("reorders a pinned thread, stamping the new key", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.pin.reorder", + commandId: CommandId.make("cmd-reorder"), + threadId: ThreadId.make("thread-1"), + orderKey: "m", + }, + readModel: makeReadModel({ pinnedAt: PINNED_AT, pinOrderKey: "g" }), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events[0]?.type).toBe("thread.pin-reordered"); + if (events[0]?.type === "thread.pin-reordered") { + expect(events[0].payload.orderKey).toBe("m"); + // A real move stamps the command time (the test clock), not the + // thread's previous updatedAt. + expect(events[0].payload.updatedAt).not.toBe(NOW); + } + }), + ); + + it.effect("reordering onto the same key preserves updatedAt", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.pin.reorder", + commandId: CommandId.make("cmd-reorder-noop"), + threadId: ThreadId.make("thread-1"), + orderKey: "g", + }, + readModel: makeReadModel({ pinnedAt: PINNED_AT, pinOrderKey: "g" }), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events[0]?.type).toBe("thread.pin-reordered"); + if (events[0]?.type === "thread.pin-reordered") { + expect(events[0].payload.updatedAt).toBe(NOW); + } + }), + ); + + it.effect("rejects reordering an unpinned thread", () => + Effect.gen(function* () { + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.pin.reorder", + commandId: CommandId.make("cmd-reorder-unpinned"), + threadId: ThreadId.make("thread-1"), + orderKey: "m", + }, + readModel: makeReadModel({}), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); }); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 5e5579ae93dfb..3de2592c884fb 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -676,6 +676,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" payload: { threadId: command.threadId, pinnedAt: existingPinnedAt ?? occurredAt, + // A fresh pin takes the client's slot in the arranged order; on a + // re-pin the existing key wins so raced duplicates cannot move a + // thread the user already placed. + ...(existingPinnedAt === null && command.orderKey !== undefined + ? { pinOrderKey: command.orderKey } + : {}), updatedAt: existingPinnedAt !== null ? thread.updatedAt : occurredAt, }, }; @@ -745,6 +751,43 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.pin.reorder": { + const thread = yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + // Only pinned threads have a slot in the arranged order. Rejecting + // (rather than silently pinning) keeps a raced reorder-after-unpin + // from resurrecting a pin the user just cleared. + if (thread.pinnedAt == null) { + return yield* Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} is not pinned and cannot be reordered`, + }), + ); + } + // Idempotent by re-emission (see thread.settle): a duplicate drop on + // the same slot keeps the existing updatedAt so it projects as a no-op. + const keyUnchanged = thread.pinOrderKey === command.orderKey; + const occurredAt = yield* nowIso; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.pin-reordered", + payload: { + threadId: command.threadId, + orderKey: command.orderKey, + updatedAt: keyUnchanged ? thread.updatedAt : occurredAt, + }, + }; + } + case "thread.meta.update": { const thread = yield* requireThread({ readModel, diff --git a/apps/server/src/orchestration/projector.pinned.test.ts b/apps/server/src/orchestration/projector.pinned.test.ts index 35bd063667a86..791bd4b75e61c 100644 --- a/apps/server/src/orchestration/projector.pinned.test.ts +++ b/apps/server/src/orchestration/projector.pinned.test.ts @@ -75,3 +75,80 @@ it.effect("projects pin lifecycle events", () => expect(unpinned.threads[0]?.pinnedAt).toBeNull(); }), ); + +it.effect("projects pin order key lifecycle", () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + const created = yield* projectEvent( + createEmptyReadModel(now), + makeEvent({ + sequence: 1, + type: "thread.created", + payload: { + threadId: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { provider: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ); + expect(created.threads[0]?.pinOrderKey ?? null).toBeNull(); + + // Fresh pin carries the client's slot in the arranged order. + const pinned = yield* projectEvent( + created, + makeEvent({ + sequence: 2, + type: "thread.pinned", + payload: { + threadId: ThreadId.make("thread-1"), + pinnedAt: now, + pinOrderKey: "g", + updatedAt: now, + }, + }), + ); + expect(pinned.threads[0]?.pinOrderKey).toBe("g"); + + // Re-pins and events from pre-reorder servers omit the field entirely; + // the existing key must survive rather than being nulled out. + const repinned = yield* projectEvent( + pinned, + makeEvent({ + sequence: 3, + type: "thread.pinned", + payload: { threadId: ThreadId.make("thread-1"), pinnedAt: now, updatedAt: now }, + }), + ); + expect(repinned.threads[0]?.pinOrderKey).toBe("g"); + + // A drag persists the new slot. + const reordered = yield* projectEvent( + repinned, + makeEvent({ + sequence: 4, + type: "thread.pin-reordered", + payload: { threadId: ThreadId.make("thread-1"), orderKey: "m", updatedAt: now }, + }), + ); + expect(reordered.threads[0]?.pinOrderKey).toBe("m"); + + // Unpin clears the slot: re-pinning is "pin again", not "restore an + // ancient position". + const unpinned = yield* projectEvent( + reordered, + makeEvent({ + sequence: 5, + type: "thread.unpinned", + payload: { threadId: ThreadId.make("thread-1"), updatedAt: now }, + }), + ); + expect(unpinned.threads[0]?.pinOrderKey).toBeNull(); + }), +); diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index ed4b084e4f960..5acf3ee6968e8 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -24,6 +24,7 @@ import { ThreadRuntimeModeSetPayload, ThreadSettledPayload, ThreadPinnedPayload, + ThreadPinReorderedPayload, ThreadSnoozedPayload, ThreadUnpinnedPayload, ThreadUnarchivedPayload, @@ -402,6 +403,7 @@ export function projectEvent( ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { pinnedAt: payload.pinnedAt, + ...(payload.pinOrderKey !== undefined ? { pinOrderKey: payload.pinOrderKey } : {}), updatedAt: payload.updatedAt, }), })), @@ -413,6 +415,20 @@ export function projectEvent( ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { pinnedAt: null, + // Unpin clears the slot: re-pinning is "pin again", not "restore + // an ancient position". + pinOrderKey: null, + updatedAt: payload.updatedAt, + }), + })), + ); + + case "thread.pin-reordered": + return decodeForEvent(ThreadPinReorderedPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + pinOrderKey: payload.orderKey, updatedAt: payload.updatedAt, }), })), diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 0e2adeecf3b2e..b7d8ae137473a 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -48,6 +48,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_until, snoozed_at, pinned_at, + pin_order_key, title_regeneration_request_id, title_regeneration_started_at, latest_user_message_at, @@ -74,6 +75,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.snoozedUntil}, ${row.snoozedAt}, ${row.pinnedAt}, + ${row.pinOrderKey ?? null}, ${row.titleRegenerationRequestId ?? null}, ${row.titleRegenerationStartedAt ?? null}, ${row.latestUserMessageAt}, @@ -100,6 +102,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_until = excluded.snoozed_until, snoozed_at = excluded.snoozed_at, pinned_at = excluded.pinned_at, + pin_order_key = excluded.pin_order_key, title_regeneration_request_id = excluded.title_regeneration_request_id, title_regeneration_started_at = excluded.title_regeneration_started_at, latest_user_message_at = excluded.latest_user_message_at, @@ -133,6 +136,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", + pin_order_key AS "pinOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -168,6 +172,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", + pin_order_key AS "pinOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 1f335bdfda73e..733c52fab3e15 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -50,6 +50,7 @@ import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts"; import Migration0035 from "./Migrations/035_ProjectionThreadTitleRegeneration.ts"; import Migration0036 from "./Migrations/036_ProjectionThreadsPinned.ts"; import Migration0037 from "./Migrations/037_ProjectionTurnsKeysetIndex.ts"; +import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts"; /** * Migration loader with all migrations defined inline. @@ -99,6 +100,7 @@ export const migrationEntries = [ [35, "ProjectionThreadTitleRegeneration", Migration0035], [36, "ProjectionThreadsPinned", Migration0036], [37, "ProjectionTurnsKeysetIndex", Migration0037], + [38, "ProjectionThreadsPinOrderKey", Migration0038], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/038_ProjectionThreadsPinOrderKey.ts b/apps/server/src/persistence/Migrations/038_ProjectionThreadsPinOrderKey.ts new file mode 100644 index 0000000000000..d6735ebdbfbe0 --- /dev/null +++ b/apps/server/src/persistence/Migrations/038_ProjectionThreadsPinOrderKey.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + + if (!columns.some((column) => column.name === "pin_order_key")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN pin_order_key TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index a0cee8e3298ee..c572e1d11ccd8 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -42,6 +42,7 @@ export const ProjectionThread = Schema.Struct({ snoozedUntil: Schema.NullOr(IsoDateTime), snoozedAt: Schema.NullOr(IsoDateTime), pinnedAt: Schema.NullOr(IsoDateTime), + pinOrderKey: Schema.optional(Schema.NullOr(Schema.String)), titleRegenerationRequestId: Schema.optional(Schema.NullOr(CommandId)), titleRegenerationStartedAt: Schema.optional(Schema.NullOr(IsoDateTime)), latestUserMessageAt: Schema.NullOr(IsoDateTime), diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index ecf26a914c136..d0acc1039c301 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -459,6 +459,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( adapter, instanceId, threadId: input.threadId, + runtimeMode: binding.runtimeMode, isActive: true, } as const; } @@ -468,6 +469,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( adapter, instanceId, threadId: input.threadId, + runtimeMode: binding.runtimeMode, isActive: false, } as const; } @@ -480,6 +482,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( adapter: recovered.adapter, instanceId, threadId: input.threadId, + runtimeMode: recovered.session.runtimeMode, isActive: true, } as const; }); @@ -629,6 +632,19 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( input.modelSelection.model.trim().length > 0, }); + // Changing runtime mode restarts the session, so the transition is only + // observable here, by diffing against the mode the previous session for + // this thread was bound to. Recording it separately is what makes the + // "started supervised, switched to full access" funnel answerable. + const previousRuntimeMode = persistedBinding?.runtimeMode; + if (previousRuntimeMode !== undefined && previousRuntimeMode !== input.runtimeMode) { + yield* analytics.record("provider.runtime_mode.changed", { + provider: sessionWithInstance.provider, + from: previousRuntimeMode, + to: input.runtimeMode, + }); + } + return sessionWithInstance; }).pipe( withMetrics({ @@ -703,6 +719,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( provider: routed.adapter.provider, model: input.modelSelection?.model, interactionMode: input.interactionMode, + // Session-start events alone skew runtime mode toward users who toggle + // often, since every toggle restarts the session. Recording it per turn + // gives a usage-weighted view and lets it cross with interactionMode. + runtimeMode: routed.runtimeMode, attachmentCount: input.attachments.length, hasInput: typeof input.input === "string" && input.input.trim().length > 0, }); diff --git a/apps/web/package.json b/apps/web/package.json index 83f55abcfd585..f396bff7a5eb8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.0.31", + "version": "0.0.32", "private": true, "type": "module", "scripts": { @@ -21,7 +21,7 @@ "@dnd-kit/utilities": "^3.2.2", "@effect/atom-react": "catalog:", "@formkit/auto-animate": "^0.9.0", - "@legendapp/list": "3.2.0", + "@legendapp/list": "3.3.3", "@lexical/react": "^0.41.0", "@pierre/diffs": "catalog:", "@pierre/trees": "1.0.0-beta.4", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 84f8b7ee509ba..8b2b6f61357fd 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1934,6 +1934,8 @@ function ChatViewContent(props: ChatViewProps) { items.push({ id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, variant: "default", + // Live connection status: calm styling, but it must front the stack. + urgent: true, icon: ( (null); const anchorUserScrollGenerationRef = useRef(0); const liveFollowUserScrollGenerationRef = useRef(0); - const pendingAnchorScrollRestoreRef = useRef<{ - readonly messageId: MessageId; - readonly offset: number; - readonly userScrollGeneration: number; - } | null>(null); - const anchorScrollRestoreFrameRef = useRef(null); + // Manual navigation stops live-follow without removing anchored end space. + // Collapsing that space during a gesture clamps the viewport back to the end. const cancelTimelineLiveFollowForUserNavigation = useCallback(() => { anchorUserScrollGenerationRef.current += 1; timelineScrollModeRef.current = "free-scrolling"; @@ -3542,11 +3543,6 @@ function ChatViewContent(props: ChatViewProps) { positionedTimelineAnchorRef.current = null; settledTimelineAnchorRef.current = null; activeTimelineAnchorIndexRef.current = null; - pendingAnchorScrollRestoreRef.current = null; - if (anchorScrollRestoreFrameRef.current !== null) { - cancelAnimationFrame(anchorScrollRestoreFrameRef.current); - anchorScrollRestoreFrameRef.current = null; - } }, []); const cancelTimelineLiveFollowForUserNavigationRef = useRef( cancelTimelineLiveFollowForUserNavigation, @@ -3602,7 +3598,6 @@ function ChatViewContent(props: ChatViewProps) { }, [composerOverlayHeight], ); - // Live-follow stays active after send/thread-open until an actual list scroll // gesture opts out. const scrollToEnd = useCallback((animated = false) => { @@ -3614,7 +3609,12 @@ function ChatViewContent(props: ChatViewProps) { activeTimelineAnchorIndexRef.current = null; showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); - void legendListRef.current?.scrollToEnd?.({ animated }); + setTimelineAnchor((current) => + current.messageId === null ? current : { ...current, messageId: null }, + ); + requestAnimationFrame(() => { + void legendListRef.current?.scrollToEnd?.({ animated }); + }); }, []); useEffect(() => { let removeListeners: (() => void) | null = null; @@ -3753,75 +3753,23 @@ function ChatViewContent(props: ChatViewProps) { } return; } - const scrollNode = list.getScrollableNode(); - let finished = false; - const finishAnimatedPositioning = () => { - if (finished) { - return; - } - finished = true; - window.clearTimeout(fallbackTimer); - scrollNode.removeEventListener("scrollend", finishAnimatedPositioning); - if (positionedTimelineAnchorRef.current !== messageId) { - return; - } - const scrollOffset = list.getState().scroll; - void list.scrollToOffset({ offset: scrollOffset, animated: false }); - settledTimelineAnchorRef.current = messageId; - }; - const fallbackTimer = window.setTimeout(finishAnimatedPositioning, 750); - scrollNode.addEventListener("scrollend", finishAnimatedPositioning, { once: true }); - void list.scrollToIndex({ - index: anchorIndex, - animated: true, - viewPosition: 0, - viewOffset: CHAT_LIST_ANCHOR_OFFSET, - }); + void list + .scrollToIndex({ + index: anchorIndex, + animated: true, + viewPosition: 0, + viewOffset: CHAT_LIST_ANCHOR_OFFSET, + }) + .then(() => { + if (positionedTimelineAnchorRef.current !== messageId) { + return; + } + settledTimelineAnchorRef.current = messageId; + }); }); }; requestAnimationFrame(() => positionAnchor(12)); }, []); - const onTimelineAnchorSizeChanged = useCallback((messageId: MessageId) => { - if (settledTimelineAnchorRef.current !== messageId) { - return; - } - if (liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current) { - return; - } - const scrollOffset = legendListRef.current?.getState().scroll; - if (scrollOffset === undefined) { - return; - } - if (pendingAnchorScrollRestoreRef.current === null) { - pendingAnchorScrollRestoreRef.current = { - messageId, - offset: scrollOffset, - userScrollGeneration: anchorUserScrollGenerationRef.current, - }; - } - if (anchorScrollRestoreFrameRef.current !== null) { - return; - } - anchorScrollRestoreFrameRef.current = requestAnimationFrame(() => { - anchorScrollRestoreFrameRef.current = null; - const pending = pendingAnchorScrollRestoreRef.current; - pendingAnchorScrollRestoreRef.current = null; - if ( - pending && - settledTimelineAnchorRef.current === pending.messageId && - pending.userScrollGeneration === anchorUserScrollGenerationRef.current - ) { - const list = legendListRef.current; - const currentScrollOffset = list?.getState().scroll; - if ( - typeof currentScrollOffset === "number" && - Math.abs(currentScrollOffset - pending.offset) <= 2 - ) { - void list?.scrollToOffset({ offset: pending.offset, animated: false }); - } - } - }); - }, []); const onIsAtEndChange = useCallback((isAtEnd: boolean) => { if ( @@ -3847,6 +3795,9 @@ function ChatViewContent(props: ChatViewProps) { } }, []); + // Anchored end space intentionally disables LegendList's normal end-follow so + // the sent message can stay near the top. T3 only owns streaming adjustments + // during that mode; LegendList owns ordinary end-follow everywhere else. useEffect(() => { if (!activeThread?.id) { return; @@ -3854,6 +3805,9 @@ function ChatViewContent(props: ChatViewProps) { if (liveFollowUserScrollGenerationRef.current !== anchorUserScrollGenerationRef.current) { return; } + if (timelineScrollModeRef.current !== "anchoring-new-turn") { + return; + } let secondFrame: number | null = null; const frame = requestAnimationFrame(() => { @@ -3875,28 +3829,13 @@ function ChatViewContent(props: ChatViewProps) { return; } - if (timelineScrollModeRef.current === "anchoring-new-turn") { - const metrics = getActiveTimelineTurnMetrics(list); - if (!metrics) { - return; - } - if (metrics.scrollDeltaToRevealEnd <= 1) { - return; - } - - const nextOffset = list.getState().scroll + metrics.scrollDeltaToRevealEnd; - void list.scrollToOffset({ offset: nextOffset, animated: false }); - return; - } - - if (timelineScrollModeRef.current !== "following-end") { - return; - } - if (!timelineRealContentOverflowsViewport(list)) { + const metrics = getActiveTimelineTurnMetrics(list); + if (!metrics || metrics.scrollDeltaToRevealEnd <= 1) { return; } - void list.scrollToEnd?.({ animated: false }); + const nextOffset = list.getState().scroll + metrics.scrollDeltaToRevealEnd; + void list.scrollToOffset({ offset: nextOffset, animated: false }); }); }); @@ -3906,12 +3845,7 @@ function ChatViewContent(props: ChatViewProps) { cancelAnimationFrame(secondFrame); } }; - }, [ - activeThread?.id, - timelineEntries, - getActiveTimelineTurnMetrics, - timelineRealContentOverflowsViewport, - ]); + }, [activeThread?.id, timelineEntries, getActiveTimelineTurnMetrics]); useEffect(() => { setPullRequestDialogState(null); @@ -4372,8 +4306,12 @@ function ChatViewContent(props: ChatViewProps) { }; }, [acknowledgeActiveThreadWoke, activeThread?.id, activeThreadWokeVisible]); // The stack renders items[0] front-most and tucks the rest behind hover, so - // ordering is priority: system banners, then the branch-mismatch notice, - // and the informational parked-thread banner last — it must never cover another. + // ordering is priority: urgent system banners (error/warning variants plus + // calm-styled live states flagged `urgent`, like update progress), then + // background liveness — its Stop button is the only stop affordance for + // settled turns, so a passive "update available" notice must not cover it — + // then calm system banners, the woke and branch-mismatch notices, and the + // informational parked-thread banner last — it must never cover another. const parkedThreadBannerItem = useMemo(() => { if (!activeThreadSnoozed && !activeThreadSettled) { return null; @@ -4423,21 +4361,27 @@ function ChatViewContent(props: ChatViewProps) { void handleSwitchCheckoutToThread(); }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]); const composerBannerItems = useMemo(() => { + const isUrgentSystemItem = (item: ComposerBannerStackItem) => + item.urgent === true || item.variant === "error" || item.variant === "warning"; + const urgentSystemItems = systemComposerBannerItems.filter(isUrgentSystemItem); + const calmSystemItems = systemComposerBannerItems.filter((item) => !isUrgentSystemItem(item)); const backgroundLivenessItems = backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem]; const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem]; const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { return [ - ...systemComposerBannerItems, + ...urgentSystemItems, ...backgroundLivenessItems, + ...calmSystemItems, ...wokeThreadItems, ...parkedThreadItems, ]; } return [ - ...systemComposerBannerItems, + ...urgentSystemItems, ...backgroundLivenessItems, + ...calmSystemItems, ...wokeThreadItems, { id: `branch-mismatch:${activeBranchMismatchKey}`, @@ -6035,6 +5979,8 @@ function ChatViewContent(props: ChatViewProps) { activeThreadId={activeThread.id} {...(routeKind === "draft" && draftId ? { draftId } : {})} activeThreadTitle={activeThread.title} + isServerThread={isServerThread} + changeRequestState={activeThreadPr?.state ?? null} activeProjectName={activeProject?.title} activeProjectCwd={activeProject?.workspaceRoot ?? null} openInCwd={gitCwd} @@ -6103,7 +6049,6 @@ function ChatViewContent(props: ChatViewProps) { skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS} anchorMessageId={timelineAnchorMessageId} onAnchorReady={onTimelineAnchorReady} - onAnchorSizeChanged={onTimelineAnchorSizeChanged} contentInsetEndAdjustment={composerOverlayHeight} liveFollowEnabled={timelineLiveFollowEnabled} onIsAtEndChange={onIsAtEndChange} diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index ac2716a196ef5..d15433e56b9e6 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -26,6 +26,9 @@ import { shouldClearThreadSelectionOnMouseDown, sortLogicalProjectsForSidebar, sortSettledThreadsForSidebarV2, + pinOrderKeyBetween, + planPinnedReorder, + sortPinnedThreadsForSidebarV2, sortThreadsForSidebarV2, sortProjectsForSidebar, sortScopedProjectsForSidebar, @@ -738,6 +741,136 @@ describe("sortThreadsForSidebarV2", () => { }); }); +describe("pinOrderKeyBetween", () => { + it("produces keys that sort between their bounds", () => { + const middle = pinOrderKeyBetween(null, null)!; + const top = pinOrderKeyBetween(null, middle)!; + const bottom = pinOrderKeyBetween(middle, null)!; + expect(top < middle).toBe(true); + expect(middle < bottom).toBe(true); + + const between = pinOrderKeyBetween(top, middle)!; + expect(top < between && between < middle).toBe(true); + }); + + it("extends into new digits when bounds are adjacent", () => { + const key = pinOrderKeyBetween("g", "h")!; + expect("g" < key && key < "h").toBe(true); + }); + + it("stays strictly ordered under repeated top insertion", () => { + // Every new pin lands at the head of the arranged run; keys must keep + // sorting before the previous head without ever bottoming out. + let head: string | null = null; + const keys: string[] = []; + for (let i = 0; i < 100; i += 1) { + const key: string = pinOrderKeyBetween(null, head)!; + expect(key).not.toBeNull(); + if (head !== null) expect(key < head).toBe(true); + keys.push(key); + head = key; + } + expect(new Set(keys).size).toBe(100); + }); + + it("stays strictly ordered under repeated middle insertion", () => { + let low = pinOrderKeyBetween(null, null)!; + let high = pinOrderKeyBetween(low, null)!; + for (let i = 0; i < 100; i += 1) { + const key: string = pinOrderKeyBetween(low, high)!; + expect(low < key && key < high).toBe(true); + if (i % 2 === 0) low = key; + else high = key; + } + }); + + it("returns null for corrupt or out-of-order bounds instead of throwing", () => { + expect(pinOrderKeyBetween("z", "a")).toBeNull(); + expect(pinOrderKeyBetween("A!", null)).toBeNull(); + expect(pinOrderKeyBetween(null, "ma")).toBeNull(); + expect(pinOrderKeyBetween("m", "m")).toBeNull(); + }); +}); + +describe("planPinnedReorder", () => { + it("writes only the moved thread when neighbors are keyed", () => { + const assignments = planPinnedReorder({ + orderedIds: ["a", "c", "b"], + keysById: new Map([ + ["a", "f"], + ["b", "m"], + ["c", "t"], + ]), + movedId: "c", + }); + expect(assignments).toHaveLength(1); + expect(assignments[0]!.id).toBe("c"); + expect(assignments[0]!.orderKey > "f" && assignments[0]!.orderKey < "m").toBe(true); + }); + + it("treats list edges as open bounds", () => { + const assignments = planPinnedReorder({ + orderedIds: ["b", "a"], + keysById: new Map([ + ["a", "m"], + ["b", null], + ]), + movedId: "b", + }); + expect(assignments).toHaveLength(1); + expect(assignments[0]!.orderKey < "m").toBe(true); + }); + + it("materializes keys for the whole section when a neighbor is keyless", () => { + const assignments = planPinnedReorder({ + orderedIds: ["b", "a", "c"], + keysById: new Map([ + ["a", null], + ["b", "m"], + ["c", null], + ]), + movedId: "b", + }); + expect(assignments.map((entry) => entry.id)).toEqual(["b", "a", "c"]); + const keys = assignments.map((entry) => entry.orderKey); + expect([...keys].sort()).toEqual(keys); + expect(new Set(keys).size).toBe(keys.length); + }); +}); + +describe("sortPinnedThreadsForSidebarV2", () => { + const pinnable = (input: { id: string; createdAt: string; pinOrderKey?: string | null }) => ({ + id: input.id, + createdAt: input.createdAt, + pinOrderKey: input.pinOrderKey ?? null, + }); + + it("sorts keyed threads by key ahead of keyless threads in creation order", () => { + const sorted = sortPinnedThreadsForSidebarV2([ + pinnable({ id: "keyless-old", createdAt: "2026-03-09T08:00:00.000Z" }), + pinnable({ id: "second", createdAt: "2026-03-09T09:00:00.000Z", pinOrderKey: "t" }), + pinnable({ id: "keyless-new", createdAt: "2026-03-09T12:00:00.000Z" }), + pinnable({ id: "first", createdAt: "2026-03-09T07:00:00.000Z", pinOrderKey: "g" }), + ]); + + expect(sorted.map((thread) => thread.id)).toEqual([ + "first", + "second", + "keyless-new", + "keyless-old", + ]); + }); + + it("breaks equal keys by id so raced writes render identically everywhere", () => { + const sorted = sortPinnedThreadsForSidebarV2([ + pinnable({ id: "b", createdAt: "2026-03-09T10:00:00.000Z", pinOrderKey: "m" }), + pinnable({ id: "a", createdAt: "2026-03-09T11:00:00.000Z", pinOrderKey: "m" }), + ]); + + expect(sorted.map((thread) => thread.id)).toEqual(["a", "b"]); + }); +}); + describe("sortSettledThreadsForSidebarV2", () => { const settled = (input: { id: string; diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 677e85c3746b9..e516822fd56a6 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -510,6 +510,15 @@ export function sortThreadsForSidebarV2< ); } +// Pinned-reorder key math and the keyed sort live in client-runtime +// (state/thread-sort) so web and mobile compute identical pinned orders. +export { + generateSpreadPinOrderKeys, + pinOrderKeyBetween, + planPinnedReorder, +} from "@t3tools/client-runtime/state/thread-sort"; +export { sortPinnedThreadsByOrderKey as sortPinnedThreadsForSidebarV2 } from "@t3tools/client-runtime/state/thread-sort"; + /** * Search the already-ordered sidebar thread collection by title only. * Keeping the input order means lifecycle ordering (active, snoozed, settled) diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 89419d428f643..2a34b65cf2a96 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -1,5 +1,21 @@ import { autoAnimate } from "@formkit/auto-animate"; import { useAtomValue } from "@effect/atom-react"; +import { + DndContext, + PointerSensor, + closestCenter, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + arrayMove, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; +import { CSS } from "@dnd-kit/utilities"; import { canSnooze, effectiveSettled, @@ -108,6 +124,7 @@ import { import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat"; import type { SidebarThreadSummary } from "../types"; import { cn } from "~/lib/utils"; +import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; import { buildBulkTitleRegenerationContextMenuItem, formatWorkingDurationLabel, @@ -115,6 +132,7 @@ import { hasUnseenCompletion, isTrailingDoubleClick, orderItemsByPreferredIds, + planPinnedReorder, resolveAdjacentThreadId, resolveSettledTimestamp, resolveSidebarV2Status, @@ -122,6 +140,7 @@ import { resolveWorkingStartedAt, shouldNavigateAfterProjectRemoval, sortLogicalProjectsForSidebar, + sortPinnedThreadsForSidebarV2, sortSettledThreadsForSidebarV2, sortThreadsForSidebarV2, } from "./Sidebar.logic"; @@ -389,6 +408,26 @@ function SnoozePopoverButton(props: { ); } +// Subset of useSortable applied to a pinned card's root
  • . Listeners go +// on the whole card (no dedicated handle): the pointer sensor's distance +// constraint keeps plain clicks working, and we skip dnd-kit's aria +// attributes since there is no keyboard sensor and the card body already +// carries its own button semantics. +type SortablePinnedRowBag = Pick< + ReturnType, + "listeners" | "setNodeRef" | "transform" | "transition" | "isDragging" +>; + +function SortablePinnedThreadRow(props: { + id: string; + children: (bag: SortablePinnedRowBag) => ReactNode; +}) { + const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: props.id, + }); + return props.children({ listeners, setNodeRef, transform, transition, isDragging }); +} + const SidebarV2Row = memo(function SidebarV2Row(props: { thread: SidebarThreadSummary; variant: "card" | "slim"; @@ -402,9 +441,17 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { snoozeSupported: boolean; // Renders the pin glyph. Pinned cards keep the full settle/snooze quick // actions: settling clears the pin server-side, and snoozing hides the - // card until wake with the pin intact underneath. Pin/unpin themselves - // live in the context menu only. + // card until wake with the pin intact underneath. The glyph is also the + // in-row pin state cue (the pinned block has no header), so it always + // shows while pinned; it only becomes a clickable unpin quick-action once + // the pinning capability is confirmed, and stays a passive marker while + // the descriptor is not loaded. Pinning itself lives in the context menu. + pinningSupported: boolean; isPinned: boolean; + // Present only on pinned cards whose server supports reordering: dnd-kit + // sortable bag applied to the card root so the whole card drags (the + // pointer sensor's distance constraint keeps plain clicks working). + sortable?: SortablePinnedRowBag | undefined; // Compact wake countdown ("2h") for rows in the snoozed shelf. snoozeWakeLabelText: string | null; // When a snooze ended (timer or early wake); drives the Woke pill until @@ -431,6 +478,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { onUnsettle: (threadRef: ScopedThreadRef) => void; onSnooze: (threadRef: ScopedThreadRef, preset: SnoozePreset) => void; onUnsnooze: (threadRef: ScopedThreadRef) => void; + onUnpin: (threadRef: ScopedThreadRef) => void; onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void; onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void; }) { @@ -449,6 +497,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { onThreadClick, onUnsettle, onUnsnooze, + onUnpin, renamingTitle, thread, variant, @@ -703,6 +752,14 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { }, [onUnsnooze, threadRef], ); + const handleUnpinClick = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onUnpin(threadRef); + }, + [onUnpin, threadRef], + ); const handleSnoozePreset = useCallback( (preset: SnoozePreset) => { onSnooze(threadRef, preset); @@ -959,10 +1016,24 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { const diff = latestTurnDiff(thread); + const sortable = props.sortable; return (
  • )} {props.isPinned ? ( - + props.pinningSupported ? ( + + ) : ( + + ) ) : null} {/* The visible state owns this slot's width: status at rest, actions on hover/keyboard focus or while the popover is open. Keeping @@ -1290,6 +1373,7 @@ export default function SidebarV2() { unsnoozeThread, pinThread, unpinThread, + reorderPinnedThread, deleteThread, } = useThreadActions(); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { @@ -1675,83 +1759,100 @@ export default function SidebarV2() { // merging, no optimistic holds. Archived threads remain hidden here — // archive keeps its original "remove from sidebar" meaning. const serverConfigs = useAtomValue(environmentServerConfigsAtom); - const { pinnedThreads, activeThreads, snoozedThreads, settledThreads, snoozeNow } = - useMemo(() => { - const now = `${nowMinute}:00.000Z`; - // Snooze classification uses a REAL clock, not the quantized minute: - // wake times are second-precise and a woken thread must not linger on - // the shelf for the rest of the minute. snoozeWakeTick re-runs this - // memo exactly at the next wake boundary. - void snoozeWakeTick; - const preciseNow = new Date().toISOString(); - const visible = threads.filter( - (thread) => - thread.archivedAt === null && - (scopedProjectKeys === null || - scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), - ); - const pinned: EnvironmentThreadShell[] = []; - const active: EnvironmentThreadShell[] = []; - const snoozed: EnvironmentThreadShell[] = []; - const settled: EnvironmentThreadShell[] = []; - for (const thread of visible) { - // Threads on servers without the settlement capability (old server, - // or descriptor not loaded yet) never classify as settled: the user - // could neither un-settle nor pin them, so auto-settling them would - // strand rows in a tail with no working affordances. - const supportsSettlement = - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === - true; - const supportsSnooze = - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; - const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null; - // Snooze outranks everything, including a pin: "hide until Tuesday" - // temporarily suspends "keep on top". The pin survives underneath — - // pinned cards are creation-ordered, so on wake the thread reappears - // at its original spot in the pinned block. (For unpinned threads - // this is also the snooze-beats-auto-settle rule: the wake time is a - // stronger statement about when the thread matters again.) - if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { - snoozed.push(thread); - // A pin otherwise overrides the lifecycle: pinned threads never - // auto-settle out of sight. (The decider clears settled state on - // pin and the pin on settle, so pin-vs-settled conflicts only - // arise from stale or raced writes.) - } else if (thread.pinnedAt != null) { - pinned.push(thread); - } else if ( - supportsSettlement && - effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) - ) { - settled.push(thread); - } else { - active.push(thread); - } + const { + pinnedThreads, + reorderablePinnedKeys, + activeThreads, + snoozedThreads, + settledThreads, + snoozeNow, + } = useMemo(() => { + const now = `${nowMinute}:00.000Z`; + // Snooze classification uses a REAL clock, not the quantized minute: + // wake times are second-precise and a woken thread must not linger on + // the shelf for the rest of the minute. snoozeWakeTick re-runs this + // memo exactly at the next wake boundary. + void snoozeWakeTick; + const preciseNow = new Date().toISOString(); + const visible = threads.filter( + (thread) => + thread.archivedAt === null && + (scopedProjectKeys === null || + scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), + ); + const pinned: EnvironmentThreadShell[] = []; + const active: EnvironmentThreadShell[] = []; + const snoozed: EnvironmentThreadShell[] = []; + const settled: EnvironmentThreadShell[] = []; + for (const thread of visible) { + // Threads on servers without the settlement capability (old server, + // or descriptor not loaded yet) never classify as settled: the user + // could neither un-settle nor pin them, so auto-settling them would + // strand rows in a tail with no working affordances. + const supportsSettlement = + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true; + const supportsSnooze = + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null; + // Snooze outranks everything, including a pin: "hide until Tuesday" + // temporarily suspends "keep on top". The pin survives underneath — + // and so does its pinOrderKey, so on wake the thread reappears at + // its exact slot in the pinned block. (For unpinned threads + // this is also the snooze-beats-auto-settle rule: the wake time is a + // stronger statement about when the thread matters again.) + if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { + snoozed.push(thread); + // A pin otherwise overrides the lifecycle: pinned threads never + // auto-settle out of sight. (The decider clears settled state on + // pin and the pin on settle, so pin-vs-settled conflicts only + // arise from stale or raced writes.) + } else if (thread.pinnedAt != null) { + pinned.push(thread); + } else if ( + supportsSettlement && + effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) + ) { + settled.push(thread); + } else { + active.push(thread); } - return { - // Same static creation order as the inbox: a pin freezes prominence, - // it does not introduce a new ordering scheme. - pinnedThreads: sortThreadsForSidebarV2(pinned), - activeThreads: sortThreadsForSidebarV2(active), - // Soonest wake first: "what comes back next" is the shelf's question. - snoozedThreads: snoozed.toSorted( - (left, right) => - firstValidTimestampMs(left.snoozedUntil ?? null) - - firstValidTimestampMs(right.snoozedUntil ?? null), - ), - settledThreads: sortSettledThreadsForSidebarV2(settled), - snoozeNow: preciseNow, - }; - }, [ - autoSettleAfterDays, - changeRequestStateByKey, - nowMinute, - scopedProjectKeys, - serverConfigs, - snoozeWakeTick, - threads, - ]); + } + // One shared rule on every platform (see sortPinnedThreadsByOrderKey): + // user-arranged keys first, keyless threads in creation order below. + // Server capability only gates DRAGGING — it must not influence the + // sort, or mixed-version fleets would render different pinned orders on + // web and mobile from the same data. + return { + pinnedThreads: sortPinnedThreadsForSidebarV2(pinned), + reorderablePinnedKeys: new Set( + pinned + .filter( + (thread) => + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadPinReorder === + true, + ) + .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), + ), + activeThreads: sortThreadsForSidebarV2(active), + // Soonest wake first: "what comes back next" is the shelf's question. + snoozedThreads: snoozed.toSorted( + (left, right) => + firstValidTimestampMs(left.snoozedUntil ?? null) - + firstValidTimestampMs(right.snoozedUntil ?? null), + ), + settledThreads: sortSettledThreadsForSidebarV2(settled), + snoozeNow: preciseNow, + }; + }, [ + autoSettleAfterDays, + changeRequestStateByKey, + nowMinute, + scopedProjectKeys, + serverConfigs, + snoozeWakeTick, + threads, + ]); const threadSearchInputRef = useRef(null); const [threadSearchQuery, setThreadSearchQuery] = useState(""); @@ -2166,9 +2267,70 @@ export default function SidebarV2() { }, [unsnoozeThread], ); + // Drag-to-reorder for the pinned block. A drop computes ONE fractional key + // for the moved thread and sends it to that thread's own server (see + // planPinnedReorder for the keyless-neighbor materialization case). The + // optimistic order keeps the card where it was dropped until the + // confirming event round-trips; canonical order matching it releases the + // override, and a failed write clears it (the card snaps back) with a toast. + // ANY membership change (new pin, unpin, snooze/wake) also releases it: + // the override can't say where members it never saw belong, and holding it + // would misplace them and launder the stale order into later drags. + const pinnedDndSensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + ); + const [optimisticPinnedOrder, setOptimisticPinnedOrder] = useState<{ + readonly order: readonly string[]; + /** pinOrderKey per thread as of the drop, so ANY landed write (ours + confirming, or a concurrent one from another client) releases the + override rather than fighting canonical state. */ + readonly keysAtDrop: ReadonlyMap; + } | null>(null); + const orderedPinnedThreads = useMemo(() => { + if (optimisticPinnedOrder === null) return pinnedThreads; + return orderItemsByPreferredIds({ + items: pinnedThreads, + preferredIds: optimisticPinnedOrder.order, + getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + }); + }, [optimisticPinnedOrder, pinnedThreads]); + useEffect(() => { + if (optimisticPinnedOrder === null) return; + const canonical = pinnedThreads.filter((thread) => + reorderablePinnedKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), + ); + const canonicalKeys = canonical.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ); + // The override represents one drop against one snapshot of the world. + // Release it as soon as the world moves on in any way: membership + // changed (pin/unpin/snooze/wake — the override can't say where members + // it never saw belong), a key changed (our write confirming, or a + // concurrent client's reorder that must win), or canonical already + // matches. Holding it longer would misplace newcomers and launder the + // stale order into later drags. + const membershipChanged = + canonicalKeys.length !== optimisticPinnedOrder.order.length || + canonicalKeys.some((key) => !optimisticPinnedOrder.order.includes(key)); + const anyKeyLanded = canonical.some( + (thread, index) => + optimisticPinnedOrder.keysAtDrop.get(canonicalKeys[index]!) !== + (thread.pinOrderKey ?? null), + ); + const orderConfirmed = + !membershipChanged && + canonicalKeys.every((key, index) => key === optimisticPinnedOrder.order[index]); + if (membershipChanged || anyKeyLanded || orderConfirmed) { + setOptimisticPinnedOrder(null); + } + }, [optimisticPinnedOrder, pinnedThreads, reorderablePinnedKeys]); const attemptPin = useCallback( (threadRef: ScopedThreadRef) => { void (async () => { + // Fresh pins take the top of the arranged run: pinThread computes a + // key before the smallest key across ALL pinned shells — including + // snoozed pins hidden from this list, whose keys are still part of + // the run — so the new pin can't land beneath a hidden head. const result = await pinThread(threadRef); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); @@ -2202,6 +2364,63 @@ export default function SidebarV2() { }, [unpinThread], ); + + const handlePinnedDragEnd = useCallback( + (event: DragEndEvent) => { + const activeKey = String(event.active.id); + const overKey = event.over === null ? null : String(event.over.id); + if (overKey === null || activeKey === overKey) return; + const reorderable = orderedPinnedThreads.filter((thread) => + reorderablePinnedKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), + ); + const keys = reorderable.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ); + const fromIndex = keys.indexOf(activeKey); + const toIndex = keys.indexOf(overKey); + if (fromIndex === -1 || toIndex === -1) return; + const newOrder = arrayMove([...keys], fromIndex, toIndex); + const threadByKey = new Map(reorderable.map((thread, index) => [keys[index]!, thread])); + const keysAtDrop = new Map( + reorderable.map((thread, index) => [keys[index]!, thread.pinOrderKey ?? null]), + ); + const assignments = planPinnedReorder({ + orderedIds: newOrder, + keysById: keysAtDrop, + movedId: activeKey, + }); + if (assignments.length === 0) return; + setOptimisticPinnedOrder({ order: newOrder, keysAtDrop }); + void (async () => { + // Sequential, stop on first failure. There is deliberately no + // rollback: every key write is a complete, valid placement on its + // own, so a partial materialization leaves a sensible order (and + // the next drag repairs the rest) — unwinding writes across + // servers would trade that for real inconsistency windows. + for (const assignment of assignments) { + const thread = threadByKey.get(assignment.id); + if (thread === undefined) continue; + const result = await reorderPinnedThread( + scopeThreadRef(thread.environmentId, thread.id), + assignment.orderKey, + ); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + setOptimisticPinnedOrder(null); + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to reorder pinned threads", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + return; + } + } + })(); + }, + [orderedPinnedThreads, reorderPinnedThread, reorderablePinnedKeys], + ); // One snooze per thread at a time — same double-dispatch guard as settle. const snoozingThreadKeysRef = useRef(new Set()); const performSnooze = useCallback( @@ -2539,62 +2758,21 @@ export default function SidebarV2() { const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat); const clicked = await settlePromise(() => api.contextMenu.show( - [ - ...(thread.branch - ? [ - { - id: "new-thread-on-branch", - label: `New thread on ${thread.branch}`, - }, - ] - : []), - ...(supportsPinning - ? [ - isPinned - ? { id: "unpin", label: "Unpin thread" } - : { id: "pin", 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. - ...(supportsSettlement - ? [ - isSettled - ? { id: "unsettle", label: "Un-settle thread" } - : { id: "settle", label: "Settle thread" }, - ] - : []), - ...(supportsSnooze - ? [ - isSnoozed - ? { id: "unsnooze", label: "Wake thread" } - : { - id: "snooze", - label: "Snooze", - disabled: !canSnooze(thread, { now: new Date().toISOString() }), - children: snoozePresets.map((preset) => ({ - id: `snooze:${preset.id}`, - label: `${preset.label} (${preset.whenLabel})`, - })), - }, - ] - : []), - { id: "rename", label: "Rename thread" }, - ...(supportsTitleRegeneration - ? [ - { - id: "regenerate-title", - label: isRegeneratingTitle ? "Regenerating…" : "Regenerate title", - disabled: isRegeneratingTitle, - }, - ] - : []), - { id: "mark-unread", label: "Mark unread" }, - { id: "copy-path", label: "Copy path", icon: "copy" }, - ...(thread.branch ? [{ id: "copy-branch", label: "Copy branch", icon: "copy" }] : []), - { id: "delete", label: "Delete", destructive: true, icon: "trash" }, - ], + buildThreadActionMenuItems({ + branch: thread.branch ?? null, + isPinned, + isSettled, + isSnoozed, + canSnoozeNow: canSnooze(thread, { now: new Date().toISOString() }), + isRegeneratingTitle, + supports: { + settlement: supportsSettlement, + snooze: supportsSnooze, + pinning: supportsPinning, + titleRegeneration: supportsTitleRegeneration, + }, + snoozePresets, + }), position, ), ); @@ -2830,11 +3008,13 @@ export default function SidebarV2() { openCommandPalette({ open: "new-thread-in" }); }, [isMobile, newThreadContext, projectGroups.length, setOpenMobile]); - // Same resolution as v1: prefer the local-thread binding, fall back to - // chat.new, no platform gating — web users have working shortcuts too. + // The button mirrors chat.new: in multi-project setups both route through + // the command palette's "New thread in..." picker, and in single-project + // setups both create immediately. chat.newLocal always creates directly, so + // it is only a correct label when chat.new is unbound. const newThreadShortcutLabel = - shortcutLabelForCommand(keybindings, "chat.newLocal") ?? - shortcutLabelForCommand(keybindings, "chat.new"); + shortcutLabelForCommand(keybindings, "chat.new") ?? + shortcutLabelForCommand(keybindings, "chat.newLocal"); return ( <> @@ -3081,6 +3261,7 @@ export default function SidebarV2() { const renderThreadRow = ( thread: EnvironmentThreadShell, section: "pinned" | "active" | "snoozed" | "settled", + sortable?: SortablePinnedRowBag, ) => { const threadKey = scopedThreadKey( scopeThreadRef(thread.environmentId, thread.id), @@ -3120,7 +3301,12 @@ export default function SidebarV2() { serverConfigs.get(thread.environmentId)?.environment.capabilities .threadSnooze === true } + pinningSupported={ + serverConfigs.get(thread.environmentId)?.environment.capabilities + .threadPinning === true + } isPinned={section === "pinned"} + sortable={sortable} snoozeWakeLabelText={ section === "snoozed" && thread.snoozedUntil != null ? snoozeWakeLabel(thread.snoozedUntil, { @@ -3160,6 +3346,7 @@ export default function SidebarV2() { onUnsettle={attemptUnsettle} onSnooze={attemptSnooze} onUnsnooze={attemptUnsnooze} + onUnpin={attemptUnpin} onAcknowledgeWoke={acknowledgeWoke} onChangeRequestState={handleChangeRequestState} /> @@ -3168,9 +3355,41 @@ export default function SidebarV2() { // Pinned block: full cards above the inbox, closed by a // thin divider (the pin glyphs carry the meaning, so no // header text). Vanishes entirely at count 0. - const items: ReactNode[] = pinnedThreads.map((thread) => - renderThreadRow(thread, "pinned"), - ); + // Rows render in the one shared pinned order; only + // reorder-capable rows register as sortable (legacy-server + // pins render in place as plain rows). + const items: ReactNode[] = [ + + + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ) + .filter((threadKey) => reorderablePinnedKeys.has(threadKey))} + strategy={verticalListSortingStrategy} + > + {orderedPinnedThreads.map((thread) => { + const threadKey = scopedThreadKey( + scopeThreadRef(thread.environmentId, thread.id), + ); + if (!reorderablePinnedKeys.has(threadKey)) { + return renderThreadRow(thread, "pinned"); + } + return ( + + {(bag) => renderThreadRow(thread, "pinned", bag)} + + ); + })} + + , + ]; if (pinnedThreads.length > 0) { items.push(
  • { const primaryEnvironmentId = EnvironmentId.make("environment-primary"); @@ -46,3 +46,24 @@ describe("shouldShowOpenInPicker", () => { ).toBe(false); }); }); + +describe("resolveRenameCommit", () => { + it("commits a trimmed changed title", () => { + expect(resolveRenameCommit({ title: " New title ", originalTitle: "Old" })).toEqual({ + action: "commit", + title: "New title", + }); + }); + + it("rejects empty and whitespace-only titles", () => { + expect(resolveRenameCommit({ title: " ", originalTitle: "Old" })).toEqual({ + action: "reject-empty", + }); + }); + + it("no-ops when the trimmed title is unchanged", () => { + expect(resolveRenameCommit({ title: " Old ", originalTitle: "Old" })).toEqual({ + action: "noop", + }); + }); +}); diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index b11e2136770d3..68e1743bccb6e 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -6,10 +6,25 @@ import { type ThreadId, } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { memo } from "react"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import type { ChangeRequestStateLike } from "@t3tools/client-runtime/state/thread-settled"; +import { ChevronDownIcon } from "lucide-react"; +import { + memo, + useCallback, + useMemo, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, + type MouseEvent as ReactMouseEvent, +} from "react"; import GitActionsControl from "../GitActionsControl"; import { type DraftId } from "~/composerDraftStore"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { toastManager } from "../ui/toast"; import ProjectScriptsControl, { type NewProjectScriptInput, type ProjectScriptActionResult, @@ -17,6 +32,9 @@ import ProjectScriptsControl, { import { OpenInPicker } from "./OpenInPicker"; import { usePrimaryEnvironmentId } from "../../state/environments"; import { useT3ProjectFileScripts } from "~/hooks/useT3ProjectFileScripts"; +import { useThreadActionMenu } from "~/hooks/useThreadActionMenu"; +import { threadEnvironment } from "../../state/threads"; +import { useAtomCommand } from "../../state/use-atom-command"; import { ProjectFavicon } from "../ProjectFavicon"; import { cn } from "~/lib/utils"; @@ -25,6 +43,10 @@ interface ChatHeaderProps { activeThreadId: ThreadId; draftId?: DraftId; activeThreadTitle: string; + /** Drafts have no server thread yet, so the title carries no action menu. */ + isServerThread: boolean; + /** PR state feeding the settled classification, resolved by ChatView. */ + changeRequestState: ChangeRequestStateLike | null; activeProjectName: string | undefined; activeProjectCwd: string | null; openInCwd: string | null; @@ -44,6 +66,20 @@ interface ChatHeaderProps { onDeleteProjectScript: (scriptId: string) => Promise; } +/** + * Rename commit rule shared with the sidebar's inline rename: trim, reject + * empty (the caller toasts), and skip the mutation when nothing changed. + */ +export function resolveRenameCommit(input: { + readonly title: string; + readonly originalTitle: string; +}): { action: "commit"; title: string } | { action: "reject-empty" } | { action: "noop" } { + const trimmed = input.title.trim(); + if (trimmed.length === 0) return { action: "reject-empty" }; + if (trimmed === input.originalTitle) return { action: "noop" }; + return { action: "commit", title: trimmed }; +} + export function shouldShowOpenInPicker(input: { readonly activeProjectName: string | undefined; readonly activeThreadEnvironmentId: EnvironmentId; @@ -61,6 +97,8 @@ export const ChatHeader = memo(function ChatHeader({ activeThreadId, draftId, activeThreadTitle, + isServerThread, + changeRequestState, activeProjectName, activeProjectCwd, openInCwd, @@ -86,8 +124,91 @@ export const ChatHeader = memo(function ChatHeader({ activeThreadEnvironmentId, primaryEnvironmentId, }); + const activeThreadRef = useMemo( + () => scopeThreadRef(activeThreadEnvironmentId, activeThreadId), + [activeThreadEnvironmentId, activeThreadId], + ); + const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { + reportFailure: false, + }); + // Inline rename, keyed by thread: navigating away drops an in-progress + // rename instead of committing stale text. Cleared on thread change (not + // just hidden) so returning to the thread doesn't revive the old draft. + const [renaming, setRenaming] = useState<{ threadId: ThreadId; title: string } | null>(null); + if (renaming !== null && renaming.threadId !== activeThreadId) { + setRenaming(null); + } + const renamingTitle = renaming?.threadId === activeThreadId ? renaming.title : null; + const renameCommittedRef = useRef(false); + const startRename = useCallback(() => { + renameCommittedRef.current = false; + setRenaming({ threadId: activeThreadId, title: activeThreadTitle }); + }, [activeThreadId, activeThreadTitle]); + const commitRename = useCallback( + (title: string) => { + setRenaming(null); + const resolution = resolveRenameCommit({ title, originalTitle: activeThreadTitle }); + if (resolution.action === "reject-empty") { + toastManager.add({ type: "warning", title: "Thread title cannot be empty" }); + return; + } + if (resolution.action === "noop") return; + void updateThreadMetadata({ + environmentId: activeThreadEnvironmentId, + input: { threadId: activeThreadId, title: resolution.title }, + }).then((result) => { + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add({ + type: "error", + title: "Failed to rename thread", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + }); + }, + [activeThreadEnvironmentId, activeThreadId, activeThreadTitle, updateThreadMetadata], + ); + const { openMenu } = useThreadActionMenu({ + threadRef: isServerThread ? activeThreadRef : null, + projectCwd: activeProjectCwd, + changeRequestState, + onStartRename: startRename, + }); + const titleButtonRef = useRef(null); + const openMenuFromTitle = useCallback(() => { + const rect = titleButtonRef.current?.getBoundingClientRect(); + if (!rect) return; + openMenu({ x: rect.left, y: rect.bottom + 4 }); + }, [openMenu]); + const handleHeaderContextMenu = useCallback( + (event: ReactMouseEvent) => { + if (!isServerThread || renamingTitle !== null) return; + // The right-side controls (git, scripts, open-in) keep their own + // behavior; only the breadcrumb area opens the thread menu. + if ((event.target as HTMLElement).closest("[data-chat-header-actions]")) return; + event.preventDefault(); + openMenu({ x: event.clientX, y: event.clientY }); + }, + [isServerThread, openMenu, renamingTitle], + ); + const handleRenameKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + if (event.key === "Enter") { + renameCommittedRef.current = true; + commitRename(event.currentTarget.value); + } else if (event.key === "Escape") { + renameCommittedRef.current = true; + setRenaming(null); + } + }, + [commitRename], + ); return ( -
    +
    {/* 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