diff --git a/.agents/upstream-review.md b/.agents/upstream-review.md index 0c4194b88a..81437b6fb3 100644 --- a/.agents/upstream-review.md +++ b/.agents/upstream-review.md @@ -55,6 +55,7 @@ Historical groups are indexed in the linked archive. This file migration changes | Live activity motion parity / `6c583620ff7ad3235b135af7107c0543467eecfa` | Corrects Pylon's rendering of already-classified `c7c1dfe4df` (#9709), `ce4712d5b0` (#9799) and the #444 port of `6cf0c6ea55` (#9093) / `c3b8825bf4` (#9606) | Pylon had diverged: `cdde7f3150` gave every active row the sweeping overlay and deleted `live-tool-shine`, while upstream reserves the overlay for label-only rows (`active && shimmer`, passed only by the Thinking row) and keeps the shine for tool labels. Restores upstream's `shimmer` prop, `animated = active && !failed` guard, row-level observer ref, overlay on worktree setup and compaction, and both CSS utilities byte-identical to upstream. That same commit also deleted `@utility visible-animate-spin` as dead while `spinner.tsx` and `refresh-icon.tsx` still applied it, so every spinner and refresh glyph was static; restored. Preserved: Pylon's `workingStepLabel`, and the `document.hasFocus()` gate AGENTS.md requires and upstream lacks. Cursor unchanged. | [Live activity parity #452](https://github.com/pylon-code/pylon/pull/452); 161 focused tests, web typecheck, scoped lint/format, region diffed against `t3code-upstream/main`. | | Skeleton loading pulse / `6c583620ff7ad3235b135af7107c0543467eecfa` | `21b9dda5afb00a33e228a68d2ccc885bba7285dc` (#9448) | Adopted. The shared `Skeleton` drops its swept gradient band for upstream's single stepped opacity pulse, whose keyframes were already byte-identical to the `ghost-pulse` Pylon ran on the pull-request ghosts; `ghost-pulse` is retired and every loading state now uses `animate-skeleton`. Preserve Pylon's `!seed` gate on the detail ghost, so a panel already showing real content does not breathe, and Pylon's own usage fill-in timing. The maintainer chose upstream theming and styling with only the logo staying Pylon's, which settles this kind of visual divergence in upstream's favour by default. Cursor unchanged. | [Skeleton pulse #451](https://github.com/pylon-code/pylon/pull/451); 339 focused tests, web typecheck, scoped lint/format. | | Panel links, markdown galleries, shared title model, usage columns, sidebar file drops, terminal link overrides, Codex limit naming and turn token telemetry / `6c583620ff7ad3235b135af7107c0543467eecfa` | Full ten-source list in PR #458, from #9132 through #10060 | Nine adopted; `b5d89038ae72142038dfa8cf69d49b7a607fe98e` is already covered as an empty duplicate of #7892. Turn telemetry stays inert without a Pylon PostHog key, is recorded only after runtime generation and session incarnation fences, and treats Prime, Cursor, Grok and Antigravity usage as unavailable; OpenCode uses the final #10116 step-retention design. Codex limit errors carry the session incarnation and Pylon keeps relaying every rate-limit notification. Preserve Pylon PR detection, environment-scoped PR caches, provider-instance settings writes, hub account keys, older-server window names, change-request row props and terminal external-browser fallback. New product analytics and usage data docs. Cursor unchanged. | [Web panels #458](https://github.com/pylon-code/pylon/pull/458); 1,687 focused tests, six package typechecks (web, server, shared, client-runtime, contracts, mobile), scoped lint/format; upstream UI evidence linked, no local client pass. | +| Mobile new-task drafts, outbox visibility, new-task handoff and chat feed fixes / `6c583620ff7ad3235b135af7107c0543467eecfa` | Full 19-source list in [#460](https://github.com/pylon-code/pylon/pull/460), from #10260 through #10496 | All 19 adopted, including upstream's legacy Working pill shade (`357b8d5217cd62826767b6ce6d7c47915f2777d2`). Adopted Unsent drafts with several per project, outbox icons, queueing during uploads, pending timeline rows, outbox-first new tasks with setup pill and failure card, branch threads, feedback banners, account badges, Arrange threads, and feed, pill and LegendList fixes. Preserve Pylon delivery holds (Held labels, held-creation card that carries setup edits into the pending-task editor, Manage pending sends), provider-bound drafts, Prime follow-up upload gate, rollback revert action and LegendList scroll-follow deferral. The Headers fix is ported into Effect beta.103's patch. No partial scope. Cursor unchanged. | [Mobile drafts #460](https://github.com/pylon-code/pylon/pull/460); 1,286 focused tests, four package typechecks, scoped lint, format and export checks; no local client pass. | ## Deferred register diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index eddce11b55..8095205a55 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -9,6 +9,7 @@ import { SafeAreaProvider } from "react-native-safe-area-context"; import { createStaticNavigation } from "@react-navigation/native"; import { RegistryContext } from "@effect/atom-react"; +import { ThreadArrangementHost } from "./features/threads/ThreadArrangementSheet"; import { ConfirmDialogHost } from "./components/ConfirmDialogHost"; import { CloudAuthProvider } from "./features/cloud/CloudAuthProvider"; import { prepareNativeShowcaseCapture } from "./features/showcase/nativeShowcaseScene"; @@ -104,6 +105,7 @@ function AppContent() { + {/* Anchored-menu overlays render here — in-window, so the keyboard stays up while a dropdown is open. */} diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index 58a1178d9a..491493c0e8 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -53,12 +53,14 @@ import IconKeyboardHide from "@tabler/icons-react-native/IconKeyboardHide"; import IconLayoutColumns from "@tabler/icons-react-native/IconLayoutColumns"; import IconLayoutSidebar from "@tabler/icons-react-native/IconLayoutSidebar"; import IconLetterSpacing from "@tabler/icons-react-native/IconLetterSpacing"; +import IconMenu2 from "@tabler/icons-react-native/IconMenu2"; import IconLink from "@tabler/icons-react-native/IconLink"; import IconMessage from "@tabler/icons-react-native/IconMessage"; import IconMinus from "@tabler/icons-react-native/IconMinus"; import IconMoon from "@tabler/icons-react-native/IconMoon"; import IconNetwork from "@tabler/icons-react-native/IconNetwork"; import IconPalette from "@tabler/icons-react-native/IconPalette"; +import IconPencil from "@tabler/icons-react-native/IconPencil"; import IconPhoto from "@tabler/icons-react-native/IconPhoto"; import IconPin from "@tabler/icons-react-native/IconPin"; import IconPinnedOff from "@tabler/icons-react-native/IconPinnedOff"; @@ -79,6 +81,7 @@ import IconTextIncrease from "@tabler/icons-react-native/IconTextIncrease"; import IconTool from "@tabler/icons-react-native/IconTool"; import IconTrash from "@tabler/icons-react-native/IconTrash"; import IconTypography from "@tabler/icons-react-native/IconTypography"; +import IconUpload from "@tabler/icons-react-native/IconUpload"; import IconUserCircle from "@tabler/icons-react-native/IconUserCircle"; import IconWifiOff from "@tabler/icons-react-native/IconWifiOff"; import IconWorld from "@tabler/icons-react-native/IconWorld"; @@ -138,11 +141,13 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { gearshape: IconSettings, "info.circle": IconInfoCircle, link: IconLink, + "line.3.horizontal": IconMenu2, "line.3.horizontal.decrease": IconFilter, "line.3.horizontal.decrease.circle": IconFilter, "line.3.horizontal.decrease.circle.fill": IconFilterFilled, magnifyingglass: IconSearch, paintbrush: IconPalette, + pencil: IconPencil, "person.crop.circle": IconUserCircle, photo: IconPhoto, pin: IconPin, @@ -168,6 +173,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { "textformat.size": IconTypography, "textformat.size.larger": IconTextIncrease, "textformat.size.smaller": IconTextDecrease, + "tray.and.arrow.up": IconUpload, trash: IconTrash, "wifi.slash": IconWifiOff, xmark: IconX, diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index 7bee969739..8959b1bc45 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -1,8 +1,11 @@ import { Image } from "expo-image"; import { Circle, Path, Svg } from "react-native-svg"; +import { View } from "react-native"; +import { providerInstanceInitials } from "@t3tools/client-runtime/state/provider-instance-display"; import { providerIconKind } from "./providerIconKind"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; +import { AppText as Text } from "./AppText"; type ProviderIconProps = { readonly provider: string | null | undefined; @@ -118,3 +121,58 @@ export function ProviderIcon(props: ProviderIconProps) { ); } + +/** + * `ProviderIcon` plus the web sidebar's account badge: an accent-color + * initials bubble in the bottom-right corner, drawn when `showBadge` is set + * (accent color present, or several instances share this driver). The glyph + * dims to 60% opacity while the badge stays fully saturated, matching + * `apps/web/src/components/chat/ProviderInstanceIcon.tsx`. + */ +export function ProviderInstanceIcon(props: { + readonly provider: string | null | undefined; + readonly size?: number; + readonly displayName: string; + readonly accentColor?: string; + readonly showBadge?: boolean; + readonly surfaceColor: string; +}) { + return ( + + + + + {props.showBadge ? ( + + + {providerInstanceInitials(props.displayName)} + + + ) : null} + + ); +} diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index c2d5b6cf65..97f9c5b69d 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -422,6 +422,8 @@ function ArchivedThreadRow(props: { ); return ( { + navigation.navigate("NewTaskSheet", { + screen: "NewTaskDraft", + params: { + environmentId: String(thread.environmentId), + projectId: String(thread.projectId), + branch: thread.branch, + worktreePath: thread.worktreePath, + }, + }); + }} onNewThreadInProject={(project) => { navigation.navigate("NewTaskSheet", { screen: "NewTaskDraft", diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 2b1b4167b2..774692ae7c 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -1,4 +1,5 @@ import { resolveEnvironmentMachineKind } from "@t3tools/contracts"; +import type { ThreadMoveDestination } from "../threads/threadOrder"; import { createThreadMovePlanner } from "../threads/threadOrder"; import { LegendList, @@ -37,7 +38,9 @@ import { useThreadSearch } from "../../state/queries"; import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { usePendingThreadOrder } from "../../state/thread-order"; import { environmentServerConfigsAtom } from "../../state/server"; +import { resolvePendingTaskDelivery } from "../../state/pending-new-tasks-model"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; +import { useQueuedThreadKeys } from "../../state/use-thread-outbox"; import { PendingTaskListRow, ThreadListGroupHeader, @@ -50,6 +53,7 @@ import { ThreadListV2SettledShelfHeader, ThreadListV2SnoozedShelfHeader, } from "../threads/thread-list-v2-items"; +import { resolveThreadProviderInstance } from "../threads/thread-provider-instance"; import { buildThreadListV2Items, getThreadListV2OrderedSection, @@ -118,11 +122,12 @@ interface HomeScreenProps { readonly onUnpinThread: (thread: EnvironmentThreadShell) => Promise; readonly onMoveThread: ( thread: EnvironmentThreadShell, - direction: "up" | "down", + direction: ThreadMoveDestination, ) => Promise; readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => Promise; readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; + readonly onNewThreadOnBranch: (thread: EnvironmentThreadShell) => void; readonly onNewThreadInProject: (project: EnvironmentProject) => void; } @@ -212,6 +217,7 @@ export function HomeScreen(props: HomeScreenProps) { >(() => new Map()); const preferencesResult = useAtomValue(mobilePreferencesAtom); const threadListV2Enabled = useThreadListV2Enabled(); + const queuedThreadKeys = useQueuedThreadKeys(); const savePreferences = useAtomSet(updateMobilePreferencesAtom); const openSwipeableRef = useRef(null); const listRef = useRef(null); @@ -370,7 +376,7 @@ export function HomeScreen(props: HomeScreenProps) { ? props.pendingTasks : props.pendingTasks.filter((pendingTask) => selectedProjectRefKeys.has( - scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), + scopedProjectKey(pendingTask.environmentId, pendingTask.projectId), ), ), [threadListV2Enabled, props.pendingTasks, selectedProjectRefKeys], @@ -384,6 +390,7 @@ export function HomeScreen(props: HomeScreenProps) { projects: scopedProjects, threads: scopedThreads, pendingTasks: scopedPendingTasks, + queuedThreadKeys, environmentId: props.selectedEnvironmentId, searchQuery: props.searchQuery, matchedThreadKeys, @@ -393,6 +400,7 @@ export function HomeScreen(props: HomeScreenProps) { }), [ threadListV2Enabled, + queuedThreadKeys, props.projectGroupingMode, props.projectSortOrder, props.searchQuery, @@ -518,7 +526,7 @@ export function HomeScreen(props: HomeScreenProps) { [props.onPinThread], ); const handleMoveThread = useCallback( - (thread: EnvironmentThreadShell, direction: "up" | "down") => { + (thread: EnvironmentThreadShell, direction: ThreadMoveDestination) => { void props.onMoveThread(thread, direction); }, [props.onMoveThread], @@ -577,6 +585,26 @@ export function HomeScreen(props: HomeScreenProps) { // Threads on servers without the settlement capability never classify as // settled (the user could neither un-settle nor pin them). const serverConfigs = useAtomValue(environmentServerConfigsAtom); + const connectedEnvironmentIds = useMemo( + () => + new Set( + props.environments + .filter((environment) => environment.connectionState === "connected") + .map((environment) => environment.environmentId), + ), + [props.environments], + ); + const pendingTaskDelivery = useCallback( + (pendingTask: PendingNewTask) => + pendingTask.kind === "pending" + ? resolvePendingTaskDelivery({ + message: pendingTask.message, + connected: connectedEnvironmentIds.has(pendingTask.environmentId), + serverConfig: serverConfigs.get(pendingTask.environmentId), + }) + : null, + [connectedEnvironmentIds, serverConfigs], + ); const settlementEnvironmentIds = useMemo(() => { const supported = new Set(); for (const [environmentId, config] of serverConfigs) { @@ -663,6 +691,7 @@ export function HomeScreen(props: HomeScreenProps) { now: new Date().toISOString(), settlementEnvironmentIds, snoozeEnvironmentIds, + queuedThreadKeys, }), }); return { pinned: sectionPlanner("pinned"), active: sectionPlanner("active") }; @@ -670,6 +699,7 @@ export function HomeScreen(props: HomeScreenProps) { serverConfigs, props.threads, pendingOrder, + queuedThreadKeys, settlementEnvironmentIds, snoozeEnvironmentIds, nowMinute, @@ -697,6 +727,7 @@ export function HomeScreen(props: HomeScreenProps) { matchedThreadKeys, settlementEnvironmentIds, snoozeEnvironmentIds, + queuedThreadKeys, settledLimit: settledVisibleCount, now: new Date().toISOString(), snoozedShelfExpanded, @@ -705,6 +736,7 @@ export function HomeScreen(props: HomeScreenProps) { }); }, [ pendingOrder, + queuedThreadKeys, nowMinute, snoozeWakeTick, snoozedShelfExpanded, @@ -743,10 +775,10 @@ export function HomeScreen(props: HomeScreenProps) { props.pendingTasks.filter( (pendingTask) => (props.selectedEnvironmentId === null || - pendingTask.message.environmentId === props.selectedEnvironmentId) && + pendingTask.environmentId === props.selectedEnvironmentId) && (v2ScopedProjectKeys === null || v2ScopedProjectKeys.has( - scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), + scopedProjectKey(pendingTask.environmentId, pendingTask.projectId), )) && (v2SearchQuery.length === 0 || pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)), @@ -777,21 +809,22 @@ export function HomeScreen(props: HomeScreenProps) { (nextItem?.type === "v2-pending" && !nextItem.showPendingDivider); if (item.type === "v2-pending") { const pendingScopeKey = scopedProjectKey( - item.pendingTask.message.environmentId, - item.pendingTask.creation.projectId, + item.pendingTask.environmentId, + item.pendingTask.projectId, ); return ( 1 - ? (props.savedConnectionsById[item.pendingTask.message.environmentId] - ?.environmentLabel ?? null) + ? (props.savedConnectionsById[item.pendingTask.environmentId]?.environmentLabel ?? + null) : null } - environmentMachine={machineByEnvironmentId.get(item.pendingTask.message.environmentId)} + environmentMachine={machineByEnvironmentId.get(item.pendingTask.environmentId)} showPendingDivider={item.showPendingDivider} showTrailingDivider={showTrailingDivider} onSelectPendingTask={props.onSelectPendingTask} @@ -824,8 +857,10 @@ export function HomeScreen(props: HomeScreenProps) { const movedId = `${thread.environmentId}:${thread.id}`; return ( - provider.instanceId === - (thread.session?.providerInstanceId ?? thread.modelSelection.instanceId), - )?.driver ?? null - } + providerInstance={resolveThreadProviderInstance(serverConfigs, thread)} environmentLabel={ Object.keys(props.savedConnectionsById).length > 1 ? (props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null) @@ -894,6 +921,7 @@ export function HomeScreen(props: HomeScreenProps) { activeReorderEnvironmentIds, threadMovePlanners, pendingOrder, + queuedThreadKeys, handleMoveThread, handlePinThread, handleRegenerateThreadTitle, @@ -909,10 +937,12 @@ export function HomeScreen(props: HomeScreenProps) { pinReorderEnvironmentIds, projectByKey, projectCwdByKey, + pendingTaskDelivery, props.onArchiveThread, props.onDeletePendingTask, props.onSelectPendingTask, props.onSelectThread, + props.onNewThreadOnBranch, props.savedConnectionsById, serverConfigs, shelfPreferencesLoaded, @@ -993,13 +1023,11 @@ export function HomeScreen(props: HomeScreenProps) { { expect(group?.threads.map((thread) => thread.id)).toEqual(["recent-1", "recent-2", "old"]); }); + it("keeps an old thread in the default view while a message waits in its outbox", () => { + const environmentId = EnvironmentId.make("environment-1"); + const project = makeProject({ + environmentId, + id: ProjectId.make("project-1"), + title: "T3 Code", + }); + const threads = [ + makeThread({ + environmentId, + id: ThreadId.make("recent"), + projectId: project.id, + title: "Today", + updatedAt: "2026-06-28T00:00:00.000Z", + }), + makeThread({ + environmentId, + id: ThreadId.make("old-queued"), + projectId: project.id, + title: "Two weeks ago, follow-up queued offline", + updatedAt: "2026-06-14T00:00:00.000Z", + }), + makeThread({ + environmentId, + id: ThreadId.make("old"), + projectId: project.id, + title: "Two weeks ago", + updatedAt: "2026-06-13T00:00:00.000Z", + }), + ]; + + const group = buildGroups([project], threads, { + queuedThreadKeys: new Set([`${environmentId}:old-queued`]), + })[0]; + expect(group?.recentThreads.map((thread) => thread.id)).toEqual(["recent", "old-queued"]); + }); + it("falls back to the most recent 3 threads when none are within 5 days", () => { const environmentId = EnvironmentId.make("environment-1"); const project = makeProject({ diff --git a/apps/mobile/src/features/home/homeThreadList.ts b/apps/mobile/src/features/home/homeThreadList.ts index 2a9e0ec2cb..d7a9d38ebe 100644 --- a/apps/mobile/src/features/home/homeThreadList.ts +++ b/apps/mobile/src/features/home/homeThreadList.ts @@ -24,7 +24,7 @@ import * as Arr from "effect/Array"; import * as Option from "effect/Option"; import * as Order from "effect/Order"; -import { scopedProjectKey } from "../../lib/scopedEntities"; +import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; export type HomeProjectSortOrder = Exclude; @@ -105,10 +105,8 @@ export function sortHomeProjectScopes(input: { } for (const pendingTask of input.pendingTasks) { recordActivity( - scopeKeyByProjectRef.get( - scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), - ), - Date.parse(pendingTask.message.createdAt), + scopeKeyByProjectRef.get(scopedProjectKey(pendingTask.environmentId, pendingTask.projectId)), + Date.parse(pendingTask.createdAt), ); } @@ -177,7 +175,7 @@ function groupSortTimestamp(group: HomeThreadGroup, sortOrder: HomeProjectSortOr Number.NEGATIVE_INFINITY, ); return group.pendingTasks.reduce((latest, pendingTask) => { - const timestamp = Date.parse(pendingTask.message.createdAt); + const timestamp = Date.parse(pendingTask.createdAt); return Number.isNaN(timestamp) ? latest : Math.max(latest, timestamp); }, latestThread); } @@ -192,10 +190,15 @@ function selectRecentThreads( sortedThreads: ReadonlyArray, threadSortOrder: SidebarThreadSortOrder, now: number, + queuedThreadKeys: ReadonlySet | undefined, ): ReadonlyArray { const cutoff = now - RECENT_THREAD_WINDOW_MS; + // A thread with a message waiting in the outbox has work the user is + // waiting on, however old its last activity; it never trims away. const recent = sortedThreads.filter( - (thread) => getThreadSortTimestamp(thread, threadSortOrder) >= cutoff, + (thread) => + getThreadSortTimestamp(thread, threadSortOrder) >= cutoff || + queuedThreadKeys?.has(scopedThreadKey(thread.environmentId, thread.id)) === true, ); return recent.length > 0 ? recent : sortedThreads.slice(0, RECENT_THREAD_FALLBACK_COUNT); } @@ -204,6 +207,8 @@ export function buildHomeThreadGroups(input: { readonly projects: ReadonlyArray; readonly threads: ReadonlyArray; readonly pendingTasks?: ReadonlyArray; + /** Thread keys with a message waiting in the outbox; kept in the default view. */ + readonly queuedThreadKeys?: ReadonlySet; readonly environmentId: EnvironmentId | null; readonly searchQuery: string; readonly matchedThreadKeys?: ReadonlySet; @@ -235,14 +240,11 @@ export function buildHomeThreadGroups(input: { } for (const pendingTask of input.pendingTasks ?? []) { - if (input.environmentId !== null && pendingTask.message.environmentId !== input.environmentId) { + if (input.environmentId !== null && pendingTask.environmentId !== input.environmentId) { continue; } - const physicalKey = scopedProjectKey( - pendingTask.message.environmentId, - pendingTask.creation.projectId, - ); + const physicalKey = scopedProjectKey(pendingTask.environmentId, pendingTask.projectId); let groupKey = groupKeyByProjectKey.get(physicalKey); if (!groupKey) { // The project shell is not loaded (environment offline / project gone). @@ -254,16 +256,15 @@ export function buildHomeThreadGroups(input: { key: groupKey, projects: [ { - environmentId: pendingTask.message.environmentId, - id: pendingTask.creation.projectId, - title: pendingTask.creation.projectTitle ?? "Unknown project", - workspaceRoot: - pendingTask.creation.projectCwd ?? String(pendingTask.creation.projectId), + environmentId: pendingTask.environmentId, + id: pendingTask.projectId, + title: pendingTask.projectTitle ?? "Unknown project", + workspaceRoot: pendingTask.projectCwd ?? String(pendingTask.projectId), repositoryIdentity: null, defaultModelSelection: null, scripts: [], - createdAt: pendingTask.message.createdAt, - updatedAt: pendingTask.message.createdAt, + createdAt: pendingTask.createdAt, + updatedAt: pendingTask.createdAt, }, ], pendingTasks: [], @@ -332,7 +333,7 @@ export function buildHomeThreadGroups(input: { // only trims the default (no-query) view. const recentThreads = query.length === 0 - ? selectRecentThreads(sortedThreads, input.threadSortOrder, now) + ? selectRecentThreads(sortedThreads, input.threadSortOrder, now, input.queuedThreadKeys) : sortedThreads; // A stale project id still resolves to the canonical member with the same diff --git a/apps/mobile/src/features/home/thread-dismissal.test.ts b/apps/mobile/src/features/home/thread-dismissal.test.ts new file mode 100644 index 0000000000..97299ab78a --- /dev/null +++ b/apps/mobile/src/features/home/thread-dismissal.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { registerThreadDismissal, withThreadDismissal } from "./thread-dismissal"; + +describe("thread dismissal", () => { + it("waits for every visible copy before changing thread state", async () => { + const home = Promise.withResolvers(); + const sidebar = Promise.withResolvers(); + const restore = vi.fn(); + const unregisterHome = registerThreadDismissal("env:thread", () => ({ + finished: home.promise, + restore, + })); + const unregisterSidebar = registerThreadDismissal("env:thread", () => ({ + finished: sidebar.promise, + restore, + })); + const command = vi.fn(async () => true); + try { + const result = withThreadDismissal("env:thread", command, Boolean); + expect(command).not.toHaveBeenCalled(); + home.resolve(); + await home.promise; + expect(command).not.toHaveBeenCalled(); + sidebar.resolve(); + expect(await result).toBe(true); + expect(command).toHaveBeenCalledOnce(); + expect(restore).not.toHaveBeenCalled(); + } finally { + unregisterHome(); + unregisterSidebar(); + } + }); + + it.each([false, "throw"])( + "restores dismissed rows when the command returns %s", + async (outcome) => { + const restore = vi.fn(); + const unregister = registerThreadDismissal("env:thread", () => ({ + finished: Promise.resolve(), + restore, + })); + try { + const result = withThreadDismissal( + "env:thread", + async () => { + if (outcome === "throw") throw new Error("Disconnected"); + return outcome; + }, + Boolean, + ); + if (outcome === "throw") await expect(result).rejects.toThrow("Disconnected"); + else expect(await result).toBe(false); + expect(restore).toHaveBeenCalledOnce(); + } finally { + unregister(); + } + }, + ); + + it("does not animate another environment or a recycled row", async () => { + const dismiss = vi.fn(() => ({ finished: Promise.resolve(), restore: vi.fn() })); + const unregisterOther = registerThreadDismissal("other:thread", dismiss); + const unregisterRecycled = registerThreadDismissal("env:thread", dismiss); + unregisterRecycled(); + try { + expect(await withThreadDismissal("env:thread", async () => true, Boolean)).toBe(true); + expect(dismiss).not.toHaveBeenCalled(); + } finally { + unregisterOther(); + } + }); +}); diff --git a/apps/mobile/src/features/home/thread-dismissal.ts b/apps/mobile/src/features/home/thread-dismissal.ts new file mode 100644 index 0000000000..1e1561570d --- /dev/null +++ b/apps/mobile/src/features/home/thread-dismissal.ts @@ -0,0 +1,35 @@ +interface ThreadDismissal { + readonly finished: Promise; + readonly restore: () => void; +} + +const rows = new Map ThreadDismissal>>(); + +/** A thread can be visible in Home and the navigation sidebar at once. */ +export function registerThreadDismissal(key: string, dismiss: () => ThreadDismissal) { + const registrations = rows.get(key) ?? new Set(); + registrations.add(dismiss); + rows.set(key, registrations); + return () => { + registrations.delete(dismiss); + if (registrations.size === 0) rows.delete(key); + }; +} + +/** Finish the exit before mutating the list; failed commands put the rows back. */ +export async function withThreadDismissal( + key: string, + action: () => Promise, + succeeded: (result: T) => boolean, +): Promise { + const dismissals = Array.from(rows.get(key) ?? [], (dismiss) => dismiss()); + let committed = false; + try { + await Promise.all(dismissals.map(({ finished }) => finished)); + const result = await action(); + committed = succeeded(result); + return result; + } finally { + if (!committed) dismissals.forEach(({ restore }) => restore()); + } +} diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index 93085fec6c..44f3c36e66 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -20,7 +20,7 @@ import type { StyleProp, ViewStyle, } from "react-native"; -import { Alert, Pressable, View } from "react-native"; +import { Pressable, View } from "react-native"; import ReanimatedSwipeable, { type SwipeableMethods, } from "react-native-gesture-handler/ReanimatedSwipeable"; @@ -40,6 +40,7 @@ import Animated, { } from "react-native-reanimated"; import { AppText as Text } from "../../components/AppText"; +import { registerThreadDismissal } from "./thread-dismissal"; // Wide enough for the longest action label ("Unarchive"). const ACTION_ITEM_WIDTH = 58; @@ -68,13 +69,6 @@ interface ThreadSwipeAction { readonly onPress: () => void; } -/** Dismiss before committing; false restores the row, success changes its resetKey or removes it. */ -type ThreadSwipePrimaryAction = Omit & - ( - | { readonly dismissOnPress: true; readonly onPress: () => Promise } - | { readonly dismissOnPress?: false; readonly onPress: () => void } - ); - interface ThreadSwipeSecondaryAction extends ThreadSwipeAction { readonly tone: "primary" | "secondary" | "danger"; } @@ -252,7 +246,8 @@ interface ThreadSwipeableProps { readonly onDelete: () => void; readonly onSwipeableClose?: (methods: SwipeableMethods) => void; readonly onSwipeableWillOpen?: (methods: SwipeableMethods) => void; - readonly primaryAction: ThreadSwipePrimaryAction; + readonly primaryAction: ThreadSwipeAction; + readonly threadKey: string; /** * Omitted keeps the v1 destructive Delete action. Explicit null opts out of * a secondary action entirely so a gated Snooze can never fall back to an @@ -288,40 +283,35 @@ function ThreadSwipeableRow(props: ThreadSwipeableProps) { const close = useCallback(() => swipeableRef.current?.close(), []); const gateEnabled = use(SwipeableScrollGateContext); const mountedRef = useRef(true); - const pendingDismissRef = useRef<(() => Promise) | null>(null); + const dismissalRef = useRef<{ finished: Promise; restore: () => void } | null>(null); + const pendingDismissRef = useRef<(() => void) | null>(null); const activeTranslationRef = useRef | null>(null); const [isDismissing, setIsDismissing] = useState(false); const dismissing = useSharedValue(false); const rowHeight = useSharedValue(0); const rowWidth = useSharedValue(props.fullSwipeWidth); const collapse = useSharedValue(0); + const fallbackTranslation = useSharedValue(0); const actionOpacity = useSharedValue(1); const primaryAction = props.primaryAction; const onSwipeableClose = props.onSwipeableClose; const restoreRow = useCallback(() => { - swipeableRef.current?.close(); + if (!mountedRef.current) return; + dismissalRef.current = null; + swipeableRef.current?.reset(); + fallbackTranslation.set(0); collapse.set(0); actionOpacity.set(1); dismissing.set(false); setIsDismissing(false); - }, [actionOpacity, collapse, dismissing]); + }, [actionOpacity, collapse, dismissing, fallbackTranslation]); - const finishDismiss = useCallback(async () => { - const action = pendingDismissRef.current; - if (!action) return; + const finishDismiss = useCallback(() => { + const finish = pendingDismissRef.current; pendingDismissRef.current = null; - try { - const succeeded = await action(); - if (!succeeded && mountedRef.current) restoreRow(); - } catch (error) { - if (mountedRef.current) restoreRow(); - Alert.alert( - "Could not settle thread", - error instanceof Error ? error.message : "The thread could not be settled.", - ); - } - }, [restoreRow]); + finish?.(); + }, []); useLayoutEffect(() => { mountedRef.current = true; @@ -329,34 +319,18 @@ function ThreadSwipeableRow(props: ThreadSwipeableProps) { mountedRef.current = false; cancelAnimation(collapse); cancelAnimation(actionOpacity); + cancelAnimation(fallbackTranslation); if (activeTranslationRef.current) cancelAnimation(activeTranslationRef.current); - // Scrolling a committed row out of the recycled list must still settle it. - void finishDismiss(); + // Recycling a row must not prevent the waiting action from running. + finishDismiss(); }; - }, [actionOpacity, collapse, finishDismiss]); - - const beginDismiss = useCallback( - (translation: SharedValue) => { - if (!primaryAction.dismissOnPress) return; - pendingDismissRef.current = primaryAction.onPress; - activeTranslationRef.current = translation; - fullSwipeArmedRef.current = false; - if (!mountedRef.current) { - void finishDismiss(); - return; - } - setIsDismissing(true); - if (swipeableRef.current) onSwipeableClose?.(swipeableRef.current); - }, - [finishDismiss, primaryAction, onSwipeableClose], - ); + }, [actionOpacity, collapse, fallbackTranslation, finishDismiss]); const dismiss = useCallback( (translation: SharedValue) => { "worklet"; if (dismissing.value) return; dismissing.set(true); - runOnJS(beginDismiss)(translation); const timing = { duration: 220, easing: Easing.out(Easing.cubic), @@ -375,30 +349,46 @@ function ThreadSwipeableRow(props: ThreadSwipeableProps) { }), ); }, - [actionOpacity, beginDismiss, collapse, dismissing, finishDismiss, rowWidth], + [actionOpacity, collapse, dismissing, finishDismiss, rowWidth], + ); + useLayoutEffect( + () => + registerThreadDismissal(props.threadKey, () => { + if (dismissalRef.current) return dismissalRef.current; + const finished = new Promise((resolve) => { + pendingDismissRef.current = resolve; + }); + fullSwipeArmedRef.current = false; + setIsDismissing(true); + if (swipeableRef.current) onSwipeableClose?.(swipeableRef.current); + runOnUI(dismiss)(activeTranslationRef.current ?? fallbackTranslation); + dismissalRef.current = { finished, restore: restoreRow }; + return dismissalRef.current; + }), + [dismiss, fallbackTranslation, onSwipeableClose, props.threadKey, restoreRow], ); const dismissStyle = useAnimatedStyle(() => ({ height: dismissing.value ? rowHeight.value * (1 - collapse.value) : undefined, pointerEvents: dismissing.value ? "none" : "auto", overflow: "hidden", + transform: [{ translateX: fallbackTranslation.value }], })); const actionStyle = useAnimatedStyle(() => ({ opacity: actionOpacity.value, height: "100%" })); - const dismissOnPress = primaryAction.dismissOnPress === true; + const commitPrimaryAction = useCallback(() => { + primaryAction.onPress(); + if (!pendingDismissRef.current) swipeableRef.current?.close(); + }, [primaryAction]); const handleRelease = useCallback( (translation: SharedValue) => { "worklet"; if (dismissing.value) return true; - if ( - dismissOnPress && - fullSwipeAction === "primary" && - -translation.value >= fullSwipeThreshold - ) { - dismiss(translation); + if (fullSwipeAction === "primary" && -translation.value >= fullSwipeThreshold) { + runOnJS(commitPrimaryAction)(); return true; } return false; }, - [dismiss, dismissing, dismissOnPress, fullSwipeAction, fullSwipeThreshold], + [commitPrimaryAction, dismissing, fullSwipeAction, fullSwipeThreshold], ); const handleFullSwipeArmedChange = useCallback((armed: boolean) => { if (armed && !fullSwipeArmedRef.current) { @@ -448,20 +438,21 @@ function ThreadSwipeableRow(props: ThreadSwipeableProps) { } props.onSwipeableWillOpen?.(methods); - if (fullSwipeArmedRef.current && !(dismissOnPress && fullSwipeAction === "primary")) { + if (fullSwipeArmedRef.current && fullSwipeAction !== "primary") { fullSwipeArmedRef.current = false; methods.close(); - if (fullSwipeAction === "primary") { - props.primaryAction.onPress(); - } else { - props.onDelete(); - } + props.onDelete(); } }} overshootFriction={1} overshootRight renderRightActions={(_progress, translation, methods) => ( - + { + activeTranslationRef.current = translation; + }} + style={actionStyle} + > { - if (primaryAction.dismissOnPress) { - runOnUI(dismiss)(translation); - } else { - methods.close(); - primaryAction.onPress(); - } - }, + onPress: commitPrimaryAction, }} secondaryAction={resolveSecondaryAction({ close: () => methods.close(), diff --git a/apps/mobile/src/features/home/usePendingTaskListActions.ts b/apps/mobile/src/features/home/usePendingTaskListActions.ts index 403c3af391..dcf127ccae 100644 --- a/apps/mobile/src/features/home/usePendingTaskListActions.ts +++ b/apps/mobile/src/features/home/usePendingTaskListActions.ts @@ -3,6 +3,7 @@ import { useCallback } from "react"; import { Alert } from "react-native"; import { removeThreadOutboxMessage } from "../../state/thread-outbox-removal"; +import { clearComposerDraftContent } from "../../state/use-composer-drafts"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { releaseEditingQueuedMessage } from "../../state/use-thread-outbox"; @@ -17,9 +18,11 @@ export function usePendingTaskListActions(): { navigation.navigate("NewTaskSheet", { screen: "NewTaskDraft", params: { - environmentId: String(pendingTask.message.environmentId), - projectId: String(pendingTask.creation.projectId), - pendingTaskId: String(pendingTask.message.messageId), + environmentId: String(pendingTask.environmentId), + projectId: String(pendingTask.projectId), + ...(pendingTask.kind === "pending" + ? { pendingTaskId: String(pendingTask.message.messageId) } + : { draftId: pendingTask.draftKey }), }, }); }, @@ -27,6 +30,24 @@ export function usePendingTaskListActions(): { ); const confirmDeletePendingTask = useCallback((pendingTask: PendingNewTask) => { + if (pendingTask.kind === "draft") { + Alert.alert("Discard draft?", `“${pendingTask.title}” will be removed.`, [ + { text: "Cancel", style: "cancel" }, + { + text: "Discard", + style: "destructive", + onPress: () => { + // Same reset a submit performs: the next task in this project + // re-resolves project defaults instead of inheriting the pick. + clearComposerDraftContent(pendingTask.draftKey, { + clearModelSelection: true, + clearWorkspaceSelection: true, + }); + }, + }, + ]); + return; + } Alert.alert( "Delete pending task?", `“${pendingTask.title}” has not been sent yet and will be removed from the outbox.`, diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 7b0b7b7011..72e45c9bbb 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -1,10 +1,12 @@ +import type { ThreadMoveDestination } from "../threads/threadOrder"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { canSnooze } from "@t3tools/client-runtime/state/thread-settled"; +import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; import * as Cause from "effect/Cause"; import * as Haptics from "expo-haptics"; import { useCallback, useRef } from "react"; import { Alert } from "react-native"; +import { withThreadDismissal } from "./thread-dismissal"; import { showConfirmDialog } from "../../components/ConfirmDialogHost"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots"; @@ -12,9 +14,18 @@ import { pinOrderKeyBetween } from "@t3tools/client-runtime/state/thread-sort"; import { appAtomRegistry } from "../../state/atom-registry"; import { environmentServerConfigsAtom } from "../../state/server"; import { environmentThreadShells, threadEnvironment } from "../../state/threads"; +import { queuedThreadKeysAtom } from "../../state/use-thread-outbox"; import { useAtomCommand } from "../../state/use-atom-command"; -import { beginPendingThreadOrder, getPendingThreadOrder } from "../../state/thread-order"; -import { createPendingThreadOrder, createThreadMovePlanner } from "../threads/threadOrder"; +import { + beginPendingThreadOrder, + getPendingThreadOrder, + threadDropBusyAtom, +} from "../../state/thread-order"; +import { + createPendingThreadOrder, + createThreadMovePlanner, + threadDropLifecycle, +} from "../threads/threadOrder"; import { getThreadListV2OrderedSection } from "../threads/threadListV2"; /** Version skew: never send settle/unsettle to a server that predates them @@ -130,26 +141,30 @@ function useThreadActionExecutor( ); return false; } - const result = - action === "unsettle" - ? // reason "user" pins the thread active: auto-settle stays - // suppressed until real activity clears the pin server-side. - await unsettleMutation({ - environmentId: thread.environmentId, - input: { threadId: thread.id, reason: "user" }, - }) - : await ( - action === "settle" - ? settleMutation - : action === "archive" - ? archiveMutation - : action === "unarchive" - ? unarchiveMutation - : deleteMutation - )({ - environmentId: thread.environmentId, - input: { threadId: thread.id }, - }); + const result = await withThreadDismissal( + key, + async () => + action === "unsettle" + ? // reason "user" pins the thread active: auto-settle stays + // suppressed until real activity clears the pin server-side. + await unsettleMutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id, reason: "user" }, + }) + : await ( + action === "settle" + ? settleMutation + : action === "archive" + ? archiveMutation + : action === "unarchive" + ? unarchiveMutation + : deleteMutation + )({ + environmentId: thread.environmentId, + input: { threadId: thread.id }, + }), + (result) => result._tag === "Success", + ); if (result._tag === "Failure") { Alert.alert(actionFailureTitle(action), actionFailureMessage(action, result.cause)); return false; @@ -223,7 +238,7 @@ export function useThreadListActions(): { readonly unpinThread: (thread: EnvironmentThreadShell) => Promise; readonly moveThread: ( thread: EnvironmentThreadShell, - direction: "up" | "down", + direction: ThreadMoveDestination, ) => Promise; readonly regenerateThreadTitle: (thread: EnvironmentThreadShell) => Promise; } { @@ -274,13 +289,18 @@ export function useThreadListActions(): { } selectionHaptic(); - const result = await snoozeMutation({ - environmentId: thread.environmentId, - input: { - threadId: thread.id, - snoozedUntil, - }, - }); + const result = await withThreadDismissal( + key, + () => + snoozeMutation({ + environmentId: thread.environmentId, + input: { + threadId: thread.id, + snoozedUntil, + }, + }), + (result) => result._tag === "Success", + ); if (result._tag === "Failure") { const error = Cause.squash(result.cause); Alert.alert( @@ -315,10 +335,15 @@ export function useThreadListActions(): { } selectionHaptic(); - const result = await unsnoozeMutation({ - environmentId: thread.environmentId, - input: { threadId: thread.id, reason: "user" }, - }); + const result = await withThreadDismissal( + key, + () => + unsnoozeMutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id, reason: "user" }, + }), + (result) => result._tag === "Success", + ); if (result._tag === "Failure") { const error = Cause.squash(result.cause); Alert.alert( @@ -458,9 +483,29 @@ export function useThreadListActions(): { reportFailure: false, }); const moveThread = useCallback( - async (thread: EnvironmentThreadShell, direction: "up" | "down") => { - if (getPendingThreadOrder() !== null) return false; - const section = thread.pinnedAt != null ? "pinned" : "active"; + async (thread: EnvironmentThreadShell, direction: ThreadMoveDestination) => { + if (getPendingThreadOrder() !== null || appAtomRegistry.get(threadDropBusyAtom)) return false; + const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom); + const current = shells.find( + (row) => row.id === thread.id && row.environmentId === thread.environmentId, + ); + if (!current || current.archivedAt !== null) return false; + thread = current; + const section = + typeof direction === "object" && direction.section !== undefined + ? direction.section + : thread.pinnedAt != null + ? "pinned" + : "active"; + if (section === "settled") { + if (!environmentSupportsSettlement(thread.environmentId)) return false; + appAtomRegistry.set(threadDropBusyAtom, true); + try { + return await settleThread(thread); + } finally { + appAtomRegistry.set(threadDropBusyAtom, false); + } + } const configs = appAtomRegistry.get(environmentServerConfigsAtom); const supportsReorder = (environmentId: EnvironmentThreadShell["environmentId"]) => { const capabilities = configs.get(environmentId)?.environment.capabilities; @@ -475,11 +520,11 @@ export function useThreadListActions(): { ); return false; } - const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom); const ordered = getThreadListV2OrderedSection({ threads: shells, section, now: new Date().toISOString(), + queuedThreadKeys: appAtomRegistry.get(queuedThreadKeysAtom), settlementEnvironmentIds: new Set( [...configs].flatMap(([id, config]) => config.environment.capabilities.threadSettlement === true ? [id] : [], @@ -498,24 +543,67 @@ export function useThreadListActions(): { reorderableEnvironmentIds: new Set([...configs.keys()].filter(supportsReorder)), })(scopedThreadKey(thread.environmentId, thread.id), direction); if (assignments === null) return false; + const lifecycle = threadDropLifecycle(thread, section, new Date().toISOString()); + const crossSection = !ordered.some( + (row) => row.id === thread.id && row.environmentId === thread.environmentId, + ); + if ( + crossSection && + (((section === "pinned" || thread.pinnedAt != null) && + !environmentSupportsPinning(thread.environmentId)) || + (thread.settledOverride === "settled" && + !environmentSupportsSettlement(thread.environmentId)) || + (effectiveSnoozed(thread, { now: new Date().toISOString() }) && + !environmentSupportsSnooze(thread.environmentId))) + ) + return false; const shellByKey = new Map( - ordered.map((shell) => [scopedThreadKey(shell.environmentId, shell.id), shell]), + shells.map((shell) => [scopedThreadKey(shell.environmentId, shell.id), shell]), ); selectionHaptic(); - const pending = beginPendingThreadOrder( - createPendingThreadOrder({ - section, - ordered, - movedId: scopedThreadKey(thread.environmentId, thread.id), - direction, - assignments, - }), - ); + appAtomRegistry.set(threadDropBusyAtom, true); + const pending = crossSection + ? null + : beginPendingThreadOrder( + createPendingThreadOrder({ + section, + ordered, + movedId: scopedThreadKey(thread.environmentId, thread.id), + direction, + assignments, + }), + ); let succeeded = false; const reorder = section === "pinned" ? reorderPinnedMutation : reorderActiveMutation; try { + if (crossSection) { + if (section === "pinned") { + const orderKey = assignments.find( + ({ id }) => id === scopedThreadKey(thread.environmentId, thread.id), + )?.orderKey; + const result = await pinMutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id, ...(orderKey === undefined ? {} : { orderKey }) }, + }); + if (result._tag === "Failure") { + Alert.alert("Could not pin thread", String(Cause.squash(result.cause))); + return false; + } + } else { + if (lifecycle.unpin && !(await unpinThread(thread))) return false; + if (lifecycle.unsettle && !(await unsettleThread(thread))) return false; + if (lifecycle.unsnooze && !(await unsnoozeThread(thread))) return false; + } + } for (const assignment of assignments) { - if (!pending.isPending()) return false; + if ( + crossSection && + section === "pinned" && + thread.pinnedAt == null && + assignment.id === scopedThreadKey(thread.environmentId, thread.id) + ) + continue; + if (pending !== null && !pending.isPending()) return false; const target = shellByKey.get(assignment.id); if (target === undefined) continue; const result = await reorder({ @@ -535,13 +623,22 @@ export function useThreadListActions(): { } } succeeded = true; - pending.complete(); + pending?.complete(); return true; } finally { - if (!succeeded) pending.cancel(); + if (!succeeded) pending?.cancel(); + appAtomRegistry.set(threadDropBusyAtom, false); } }, - [reorderActiveMutation, reorderPinnedMutation], + [ + settleThread, + reorderActiveMutation, + reorderPinnedMutation, + pinMutation, + unpinThread, + unsettleThread, + unsnoozeThread, + ], ); const confirmDeleteThread = useConfirmDeleteThread(executeAction); diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 9ea8eb88d4..34bc450a4e 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -449,6 +449,21 @@ function AdaptiveWorkspaceLayoutContent( }); }, [navigation]); + const handleNewThreadOnBranch = useCallback( + (thread: EnvironmentThreadShell) => { + navigation.navigate("NewTaskSheet", { + screen: "NewTaskDraft", + params: { + environmentId: String(thread.environmentId), + projectId: String(thread.projectId), + branch: thread.branch, + worktreePath: thread.worktreePath, + }, + }); + }, + [navigation], + ); + const handleNewThreadInProject = useCallback( (project: EnvironmentProject) => { navigation.navigate("NewTaskSheet", { @@ -543,6 +558,7 @@ function AdaptiveWorkspaceLayoutContent( onOpenSettings={handleOpenSettings} onOpenEnvironmentSettings={handleOpenEnvironmentSettings} onNewThreadInProject={handleNewThreadInProject} + onNewThreadOnBranch={handleNewThreadOnBranch} onSelectThread={handleSelectThread} onSearchQueryChange={setPrimarySidebarSearchQuery} searchQuery={primarySidebarSearchQuery} diff --git a/apps/mobile/src/features/threads/ComposerFeedback.tsx b/apps/mobile/src/features/threads/ComposerFeedback.tsx new file mode 100644 index 0000000000..dbbdc166d1 --- /dev/null +++ b/apps/mobile/src/features/threads/ComposerFeedback.tsx @@ -0,0 +1,63 @@ +import { + codexFeedbackNotice, + type CodexFeedbackSubmission, +} from "@t3tools/client-runtime/state/threads"; +import { Pressable, View } from "react-native"; + +import { AppText as Text } from "../../components/AppText"; +import { SymbolView } from "../../components/AppSymbol"; +import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; + +export function ComposerFeedback({ + submission, + onDismiss, +}: { + readonly submission: CodexFeedbackSubmission; + readonly onDismiss: () => void; +}) { + const notice = codexFeedbackNotice(submission); + if (!notice) return null; + return ( + + + + + {notice.title} + + {submission.status !== "uploading" ? ( + + + + ) : null} + + {notice.description ? ( + + {notice.description} + + ) : null} + {submission.status === "sent" ? ( + + copyTextWithHaptic(submission.feedbackId, { target: "Codex feedback thread ID" }) + } + className="self-start py-1 active:opacity-60" + > + Copy ID + + ) : null} + + + ); +} diff --git a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx index 96bd743805..ef066feca5 100644 --- a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx +++ b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx @@ -35,7 +35,7 @@ import { NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, } from "../layout/native-mail-search-toolbar"; import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; -import { shouldCheckoutNewTaskBranch } from "./new-task-context-presentation"; +import { checkoutNewTaskBranch } from "./checkout-new-task-branch"; function SelectionRow(props: { readonly icon?: "arrow.triangle.branch" | ReactNode; @@ -257,43 +257,29 @@ export function NewTaskBranchPickerRouteScreen() { void Haptics.selectionAsync(); try { - let selectedBranch = branch; - const needsCheckout = shouldCheckoutNewTaskBranch({ - branchIsCurrent: branch.current, - branchWorktreePath: branch.worktreePath, + if (!flow.selectedProject) return; + setSwitchingBranchName(branch.name); + const result = await checkoutNewTaskBranch({ + branch, + project: flow.selectedProject, workspaceMode: flow.workspaceMode, + switchRef, }); - if (needsCheckout && flow.selectedProject) { - setSwitchingBranchName(branch.name); - const result = await switchRef({ - environmentId: flow.selectedProject.environmentId, - input: { - cwd: flow.selectedProject.workspaceRoot, - refName: branch.name, - }, - }); - if (result._tag === "Failure") { - if (mountedRef.current && navigation.isFocused() && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - Alert.alert( - "Could not switch branch", - error instanceof Error ? error.message : "The branch could not be checked out.", - ); - } - return; + if (result._tag === "Failure") { + if (mountedRef.current && navigation.isFocused() && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + Alert.alert( + "Could not switch branch", + error instanceof Error ? error.message : "The branch could not be checked out.", + ); } - selectedBranch = { - ...branch, - current: true, - isRemote: false, - name: result.value.refName ?? branch.name, - }; + return; } // The checkout has already changed the repository. Persist the matching // draft selection even if the native sheet was dismissed while the // command was in flight; only visible-screen work is focus-gated below. - flow.selectBranch(selectedBranch); + flow.selectBranch(result.value); if (!mountedRef.current || !navigation.isFocused()) { return; } diff --git a/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx index 8e6819378a..aab423a379 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx @@ -1,5 +1,16 @@ -import type { StaticScreenProps } from "@react-navigation/native"; -import { useMemo } from "react"; +import { useNavigation, usePreventRemove, type StaticScreenProps } from "@react-navigation/native"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { Alert, View } from "react-native"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { AppText as Text } from "../../components/AppText"; +import { useProjects } from "../../state/entities"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { useWorkspaceState } from "../../state/workspace"; +import { vcsEnvironment } from "../../state/vcs"; +import { checkoutNewTaskBranch } from "./checkout-new-task-branch"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { NewTaskDraftScreen } from "./NewTaskDraftScreen"; @@ -7,13 +18,24 @@ import { NewTaskDraftScreen } from "./NewTaskDraftScreen"; type NewTaskDraftRouteParams = { readonly environmentId?: string | string[]; readonly projectId?: string | string[]; + readonly branch?: string | null; + readonly worktreePath?: string | null; readonly title?: string | string[]; readonly pendingTaskId?: string | string[]; + readonly draftId?: string | string[]; readonly incomingShareId?: string | string[]; }; export function NewTaskDraftRouteScreen({ route }: StaticScreenProps) { - const params = route.params ?? {}; + const params = useMemo(() => route.params ?? {}, [route.params]); + const pendingTaskId = Array.isArray(params.pendingTaskId) + ? params.pendingTaskId[0] + : params.pendingTaskId; + const draftId = Array.isArray(params.draftId) ? params.draftId[0] : params.draftId; + const projects = useProjects(); + const { state: catalogState } = useWorkspaceState(); + const navigation = useNavigation(); + const switchRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); // Keyed on the params object so a fresh navigation to this (already // mounted) screen produces a new reference, letting the draft screen @@ -24,10 +46,104 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps>; + workspaceRoot: string | undefined; + } | null>(null); + const project = projects.find( + (candidate) => + candidate.environmentId === initialProjectRef.environmentId && + candidate.id === initialProjectRef.projectId, + ); + const environmentId = project?.environmentId; + const workspaceRoot = project?.workspaceRoot; + const needsPreparation = Boolean(initialProjectRef.branch && !pendingTaskId && !draftId); + + const [pendingCheckouts, setPendingCheckouts] = useState(0); + const checkoutTail = useRef(Promise.resolve()); + const waitingForProject = + !project && + (catalogState.isLoadingConnections || + (!catalogState.hasLoadedShellSnapshot && + catalogState.hasConnectingEnvironment && + catalogState.connectionError === null)); + + useEffect(() => { + if (!needsPreparation || !initialProjectRef.branch || waitingForProject) return; + const branchName = initialProjectRef.branch; + let active = true; + setPendingCheckouts((count) => count + 1); + // Serialize replacements: ignoring a stale result cannot undo its Git mutation. + checkoutTail.current = checkoutTail.current.then(async () => { + if (!active) { + setPendingCheckouts((count) => count - 1); + return; + } + const result = await checkoutNewTaskBranch({ + // A thread's branch is historical; only switchRef can establish that + // the shared project checkout now matches it. + branch: { + name: branchName, + current: false, + isDefault: false, + worktreePath: initialProjectRef.worktreePath ?? null, + }, + project: environmentId && workspaceRoot ? { environmentId, workspaceRoot } : null, + workspaceMode: "local", + switchRef, + }); + setPendingCheckouts((count) => count - 1); + if (active) setPreparation({ request: initialProjectRef, result, workspaceRoot }); + }); + return () => { + active = false; + }; + }, [ + environmentId, + workspaceRoot, + initialProjectRef, + needsPreparation, + switchRef, + waitingForProject, + ]); + + const result = + preparation?.request === initialProjectRef && preparation.workspaceRoot === workspaceRoot + ? preparation.result + : null; + // The native-stack guard covers iOS swipe dismissal as well as back actions. + // A replaced request must settle too before the shared checkout is left behind. + const checkoutPending = pendingCheckouts > 0 || (needsPreparation && result === null); + usePreventRemove(checkoutPending, () => undefined); + useEffect(() => { + if (checkoutPending || result?._tag !== "Failure") return; + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + Alert.alert( + "Could not switch branch", + error instanceof Error ? error.message : "The branch could not be checked out.", + ); + } + navigation.goBack(); + }, [checkoutPending, result, navigation]); + + const preparedProjectRef = useMemo( + () => + result?._tag === "Success" + ? { ...initialProjectRef, branch: result.value.name } + : initialProjectRef, + [initialProjectRef, result], + ); + // Send/queue remain unavailable on failure while the unlocked route closes. + const preparingBranch = checkoutPending || (needsPreparation && result?._tag !== "Success"); + return ( <> - + {preparingBranch ? ( + + Switching branch... + + ) : ( + + )} ); } diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index a11851876d..45699c245d 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -22,10 +22,6 @@ import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { useFontFamily } from "../../lib/useFontFamily"; import { getProviderAdmissionUnavailableReason } from "@t3tools/client-runtime/providerAvailability"; -import { - isAtomCommandInterrupted, - squashAtomCommandFailure, -} from "@t3tools/client-runtime/state/runtime"; import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, resolveEnvironmentMachineKind, @@ -44,6 +40,7 @@ import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStri import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; import { composerAttachmentUploadBlockReason, + composerAttachmentsStillUploading, composerAttachmentUploadsAtom, } from "../../state/composer-attachment-uploads"; import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; @@ -52,7 +49,6 @@ import { ProviderIcon } from "../../components/ProviderIcon"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; import { COMPOSER_LAYOUT_TRANSITION, ComposerSurface } from "./ThreadComposer"; -import { ShimmeringWorkContent } from "./thread-work-log"; import { ComposerCommandPopover } from "./ComposerCommandPopover"; import { ProviderUnavailableNotice } from "./ProviderUnavailableNotice"; import { useComposerCommandMenu } from "./use-composer-command-menu"; @@ -79,12 +75,13 @@ import { import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; import { clearComposerDraftContent, - flushComposerDrafts, getComposerDraftSnapshot, mergeComposerDraftContent, restoreComposerDraftSnapshot, + updateComposerDraftSettings, scheduleUnusedComposerAttachmentCleanup, type ComposerDraft, + waitForComposerDraftsLoaded, } from "../../state/use-composer-drafts"; import { useEnvironmentServerConfig, useProjects } from "../../state/entities"; import { resolveSelectableModelSelection } from "../../lib/modelOptions"; @@ -95,7 +92,6 @@ import { removeThreadOutboxMessage } from "../../state/thread-outbox-removal"; import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry"; import { useNewTaskFlow } from "./new-task-flow-provider"; import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation"; -import { useCreateProjectThread } from "./use-project-actions"; import { resolveDraftProjectSelection } from "./new-task-project-selection"; import { resolveNewTaskBranchLabel, @@ -145,14 +141,17 @@ export function NewTaskDraftScreen(props: { readonly initialProjectRef?: { readonly environmentId?: string; readonly projectId?: string; + readonly branch?: string | null; + readonly worktreePath?: string | null; }; /** Queued outbox message id when editing an existing pending task. */ readonly pendingTaskId?: string; + /** Existing new-task draft key to resume (a Draft row in the thread list). */ + readonly draftId?: string; /** Durable native share inbox item to merge into this project draft. */ readonly incomingShareId?: string; }) { const projects = useProjects(); - const createProjectThread = useCreateProjectThread(); const flow = useNewTaskFlow(); const navigation = useNavigation(); const { @@ -195,6 +194,18 @@ export function NewTaskDraftScreen(props: { states: uploadStates, }) : null; + // A connected composer with uploads still in flight queues the task rather + // than making the user wait: the outbox drain finishes the upload and sends. + const attachmentsUploading = + environmentConnected && + selectedProject !== null && + composerAttachmentsStillUploading({ + environmentId: selectedProject.environmentId, + attachments: flow.attachments, + serverConfig: selectedEnvironmentServerConfig, + states: uploadStates, + }); + const queuesInsteadOfStarting = !environmentConnected || attachmentsUploading; const promptInputRef = useRef(null); const loadedBranchesProjectKeyRef = useRef(null); const [isComposerFocused, setIsComposerFocused] = useState(false); @@ -421,7 +432,44 @@ export function NewTaskDraftScreen(props: { }; }, []); - const { beginEditingPendingTask, cancelEditingPendingTask, editingPendingTask } = flow; + const { beginEditingPendingTask, cancelEditingPendingTask, editingPendingTask, openDraft } = flow; + // A Draft row opens its own draft; a fresh New Task never reuses one. + // Drafts hydrate from disk and projects arrive with the shell snapshot, so + // on a cold launch the draft or its project can be missing for a moment; + // wait for hydration and retry while projects load. Attempt each id once + // after that so a draft discarded mid-session does not keep bouncing to + // the picker. + const attemptedDraftIdRef = useRef(null); + useEffect(() => { + if (!props.draftId || props.pendingTaskId) { + return; + } + const draftId = props.draftId; + if (attemptedDraftIdRef.current === draftId) { + return; + } + let cancelled = false; + void waitForComposerDraftsLoaded().then(() => { + if (cancelled || attemptedDraftIdRef.current === draftId) { + return; + } + if (openDraft(draftId)) { + attemptedDraftIdRef.current = draftId; + return; + } + if (getComposerDraftSnapshot(draftId).project !== undefined && projects.length === 0) { + // The draft exists; its project has not arrived yet. Retry on the + // next projects change instead of giving up. + return; + } + attemptedDraftIdRef.current = draftId; + navigation.dispatch(StackActions.replace("NewTask")); + }); + return () => { + cancelled = true; + }; + }, [navigation, openDraft, projects, props.draftId, props.pendingTaskId]); + const attemptedPendingTaskIdRef = useRef(null); useEffect(() => { if (!props.pendingTaskId || editingPendingTask?.messageId === props.pendingTaskId) { @@ -458,9 +506,10 @@ export function NewTaskDraftScreen(props: { const lastInitialProjectRefRef = useRef(props.initialProjectRef); useEffect(() => { - // Pending-task editing owns project selection (and must not fall through - // to the replace("NewTask") fallback while its hydration is in flight). - if (props.pendingTaskId) { + // Pending-task editing and draft resumption own project selection (and + // must not fall through to the replace("NewTask") fallback while their + // hydration is in flight). + if (props.pendingTaskId || props.draftId) { return; } if (lastInitialProjectRefRef.current !== props.initialProjectRef) { @@ -483,6 +532,26 @@ export function NewTaskDraftScreen(props: { if (appliedInitialProjectKeyRef.current === directProjectKey) { return; } + if (props.initialProjectRef?.branch) { + if ( + selectedProject?.environmentId !== directProject.environmentId || + selectedProject.id !== directProject.id + ) { + setProject(directProject); + return; + } + if (!flow.draftKey) return; + // The route completes checkout before mounting this composer. Local + // mode reuses an existing worktree; worktree mode would create another. + updateComposerDraftSettings(flow.draftKey, { + workspaceSelection: { + mode: "local", + branch: props.initialProjectRef.branch, + worktreePath: props.initialProjectRef.worktreePath ?? null, + startFromOrigin: false, + }, + }); + } appliedInitialProjectKeyRef.current = directProjectKey; if ( selectedProject?.environmentId === directProject.environmentId && @@ -516,9 +585,11 @@ export function NewTaskDraftScreen(props: { }, [ projectScopes, projects, + flow.draftKey, props.initialProjectRef, props.incomingShareId, props.pendingTaskId, + props.draftId, navigation, selectedProject, selectedProjectKey, @@ -891,13 +962,6 @@ export function NewTaskDraftScreen(props: { ) ?? flow.selectedModel); const workspaceMode = draft.workspaceSelection?.mode ?? flow.workspaceMode; const selectedBranchName = draft.workspaceSelection?.branch ?? flow.selectedBranchName; - const selectedWorktreePath = - draft.workspaceSelection?.worktreePath ?? flow.selectedWorktreePath; - const startFromOrigin = draft.workspaceSelection?.startFromOrigin ?? flow.startFromOrigin; - const runtimeMode = draft.runtimeMode ?? flow.runtimeMode; - const interactionMode = flow.planModeEnabled - ? (draft.interactionMode ?? flow.interactionMode) - : "default"; const initialMessageText = draft.text.trim(); if ( @@ -934,136 +998,93 @@ export function NewTaskDraftScreen(props: { const retryTurnMetadata = editingPendingTask?.deliveryHold === undefined ? null : makeTurnCommandMetadata(); - if (!environmentConnected) { - // Offline: park the task in the outbox; the drain sends it when the - // environment reconnects. Ordinary edits preserve their identifiers; - // explicitly submitting a held retarget uses the fresh metadata above. - const metadata = - retryTurnMetadata ?? - (editingPendingTask - ? { - threadId: editingPendingTask.threadId, - commandId: editingPendingTask.commandId, - messageId: editingPendingTask.messageId, - createdAt: editingPendingTask.createdAt, - } - : makeTurnCommandMetadata()); - const message = flow.buildPendingTaskMessage(metadata); - if (!message) { - return; - } - flow.setSubmitting(true); - try { - await enqueueThreadOutboxMessage(message); - if ( - editingPendingTask !== null && - editingPendingTask.deliveryHold !== undefined && - editingPendingTask.messageId !== message.messageId - ) { - try { - await removeThreadOutboxMessage(editingPendingTask); - } catch (error) { - // The replacement is already durable and the old entry remains - // held, so neither copy can lose or double-send the content. - console.warn("[new-task] failed to remove retargeted held task", error); + // Every submission goes through the outbox: the drain uploads the + // attachments and delivers the creation, retrying across reconnects. + // When it can send now the thread screen opens immediately with the + // queued prompt and reports setup progress there, like the web draft + // does. Offline, or with uploads still in flight, the task stays a + // pending task and the sheet closes. Ordinary edits preserve their + // identifiers; explicitly submitting a held retarget uses the fresh + // metadata above. + const metadata = + retryTurnMetadata ?? + (editingPendingTask + ? { + threadId: editingPendingTask.threadId, + commandId: editingPendingTask.commandId, + messageId: editingPendingTask.messageId, + createdAt: editingPendingTask.createdAt, } - } - } catch (error) { - Alert.alert( - "Could not queue task", - error instanceof Error ? error.message : "The task could not be saved to the outbox.", - ); - return; - } finally { - flow.setSubmitting(false); - } - if (editingPendingTask) { - flow.finishEditingPendingTask(); - } else { - // Drop draft-local model/workspace selections with the content. The - // next task re-resolves project defaults before sticky app defaults. - clearComposerDraftContent(draftKey, { - clearModelSelection: true, - clearWorkspaceSelection: true, - }); - } - setSubmitNavigationAction(CommonActions.goBack()); + : makeTurnCommandMetadata()); + const message = flow.buildPendingTaskMessage(metadata, { + // A task that waits in the outbox cannot know the checkout it will + // drain against; one that sends now runs against the live one. + currentCheckoutBranch: queuesInsteadOfStarting ? null : flow.currentCheckoutBranchName, + }); + if (!message) { return; } - + if (!queuesInsteadOfStarting) { + // Arm the lock-screen card before the async thread creation: backgrounding + // the app right after tapping submit would otherwise reject the foreground + // -only Activity start. If creation fails, the token registration's replay + // finds no work and ends the card within seconds. + armAgentAwarenessLiveActivityForLocalWork({ + environmentId: selectedProject.environmentId, + threadTitle: deriveThreadTitleFromPrompt(initialMessageText), + projectTitle: selectedProject.title, + }); + } + // Persist before clearing the draft or leaving its editor. This only waits + // for the local outbox write; server and worktree setup run on the thread. flow.setSubmitting(true); - // Arm the lock-screen card before the async thread creation: backgrounding - // the app right after tapping submit would otherwise reject the foreground - // -only Activity start. If creation fails, the token registration's replay - // finds no work and ends the card within seconds. - armAgentAwarenessLiveActivityForLocalWork({ - environmentId: selectedProject.environmentId, - threadTitle: deriveThreadTitleFromPrompt(initialMessageText), - projectTitle: selectedProject.title, - }); - const creationBranch = resolveProjectThreadCreationBranch({ - workspaceMode, - selectedBranch: selectedBranchName, - currentCheckoutBranch: flow.currentCheckoutBranchName, - }); - const result = await createProjectThread({ - project: selectedProject, - modelSelection, - envMode: workspaceMode, - branch: creationBranch, - worktreePath: workspaceMode === "worktree" ? null : selectedWorktreePath, - startFromOrigin, - runtimeMode, - interactionMode, - initialMessageText, - initialAttachments: draft.attachments, - onAttachmentsUploaded: async (attachments) => { - flow.replaceAttachments(attachments); - await flushComposerDrafts(); - }, - ...(editingPendingTask - ? { - turnMetadata: retryTurnMetadata ?? { - threadId: editingPendingTask.threadId, - commandId: editingPendingTask.commandId, - messageId: editingPendingTask.messageId, - createdAt: editingPendingTask.createdAt, - }, - } - : {}), - }); - flow.setSubmitting(false); - - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - Alert.alert( - "Could not start task", - error instanceof Error ? error.message : "The task could not be started.", - ); + try { + await enqueueThreadOutboxMessage(message); + if ( + editingPendingTask !== null && + editingPendingTask.deliveryHold !== undefined && + editingPendingTask.messageId !== message.messageId + ) { + try { + await removeThreadOutboxMessage(editingPendingTask); + } catch (error) { + // The replacement is already durable and the old entry remains + // held, so neither copy can lose or double-send the content. + console.warn("[new-task] failed to remove retargeted held task", error); + } } + } catch (error) { + Alert.alert( + "Could not queue task", + error instanceof Error ? error.message : "The task could not be saved to the outbox.", + ); return; + } finally { + flow.setSubmitting(false); } - + const draftSnapshot = getComposerDraftSnapshot(draftKey); if (editingPendingTask) { - try { - await removeThreadOutboxMessage(editingPendingTask); - } catch (error) { - console.warn("[new-task] failed to remove delivered pending task", error); - } flow.finishEditingPendingTask(); } else { + // Drop draft-local model/workspace selections with the content. The + // next task re-resolves project defaults before sticky app defaults. + // The queued message owns the attachments now, so the sweep is deferred + // until the write confirms it. clearComposerDraftContent(draftKey, { clearModelSelection: true, clearWorkspaceSelection: true, + deferAttachmentCleanup: true, }); } setSubmitNavigationAction( - StackActions.replace("Thread", { - environmentId: String(result.value.environmentId), - threadId: String(result.value.threadId), - }), + queuesInsteadOfStarting + ? CommonActions.goBack() + : StackActions.replace("Thread", { + environmentId: String(message.environmentId), + threadId: String(message.threadId), + }), ); + scheduleUnusedComposerAttachmentCleanup(draftSnapshot.attachments); } if (!selectedProject) { @@ -1217,50 +1238,31 @@ export function NewTaskDraftScreen(props: { const workspaceControls = ( - {flow.submitting && environmentConnected && flow.workspaceMode === "worktree" ? ( - - - - ) : ( - <> - - } - label={workspaceLabel} - maxWidth={flow.workspaceMode === "local" ? 220 : 148} - onPress={() => - flow.setWorkspaceMode(flow.workspaceMode === "local" ? "worktree" : "local") - } - showChevron={false} + + } + label={workspaceLabel} + maxWidth={flow.workspaceMode === "local" ? 220 : 148} + onPress={() => flow.setWorkspaceMode(flow.workspaceMode === "local" ? "worktree" : "local")} + showChevron={false} + /> - openContextPicker("NewTaskBranch")} - /> - - )} + openContextPicker("NewTaskBranch")} + /> ); @@ -1404,12 +1406,14 @@ export function NewTaskDraftScreen(props: { ? "Starting task" : !canStart ? "Start unavailable. Add a message and complete the task setup." - : environmentConnected - ? "Start task" - : "Queue task. The environment is disconnected; this task will remain queued.")) + : attachmentsUploading + ? "Queue task, sends when uploads finish" + : environmentConnected + ? "Start task" + : "Queue task. The environment is disconnected; this task will remain queued.")) } disabled={!canStart} - icon={environmentConnected ? "arrow.up" : "tray.and.arrow.up"} + icon={queuesInsteadOfStarting ? "tray.and.arrow.up" : "arrow.up"} onPress={() => void handleStart()} variant="primary" /> diff --git a/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx b/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx new file mode 100644 index 0000000000..5c9eb7e54a --- /dev/null +++ b/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx @@ -0,0 +1,564 @@ +import { appAtomRegistry } from "../../state/atom-registry"; +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; +import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; +import { Animated, FlatList, Modal, Pressable, View } from "react-native"; +import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler"; +import Reanimated, { ReduceMotion, useAnimatedStyle, withTiming } from "react-native-reanimated"; +import { threadDragGapOffset } from "./threadDragGap"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { AppText as Text } from "../../components/AppText"; +import { SymbolView } from "../../components/AppSymbol"; +import { scopedThreadKey } from "../../lib/scopedEntities"; +import { environmentServerConfigsAtom } from "../../state/server"; +import { environmentThreadShells } from "../../state/threads"; +import { + pendingThreadOrderAtom, + threadDropBusyAtom, + threadArrangementOpenAtom, +} from "../../state/thread-order"; +import { queuedThreadKeysAtom } from "../../state/use-thread-outbox"; +import { useThreadListActions } from "../home/useThreadListActions"; +import { + createThreadMovePlanner, + threadDragAction, + type ThreadMoveDestination, +} from "./threadOrder"; +import { getThreadListV2OrderedSection } from "./threadListV2"; + +const ROW_HEIGHT = 56; +const HEADER_HEIGHT = 48; +const keyOf = (thread: EnvironmentThreadShell) => scopedThreadKey(thread.environmentId, thread.id); +type Section = "pinned" | "active" | "snoozed" | "settled"; +type Destination = Exclude; +type Row = { + key: string; + section: Section; + thread?: EnvironmentThreadShell; + offset: number; + height: number; +}; +type Drag = { + orderVersion: string; + sourceSection: Section; + thread: EnvironmentThreadShell; + startY: number; + translation: number; + destination: Destination | null; +}; + +function ArrangementRow(props: { + height: number; + offset: number; + lifted: boolean; + dragging: boolean; + children: ReactNode; +}) { + const { dragging, offset, lifted } = props; + const style = useAnimatedStyle(() => ({ + transform: [ + { + translateY: dragging + ? withTiming(offset, { duration: 160, reduceMotion: ReduceMotion.System }) + : offset, + }, + ], + opacity: lifted ? 0 : 1, + })); + return ( + + {props.children} + + ); +} + +/** Native pan recognition wins over list scrolling only inside the handle. */ +function DragHandle(props: { + title: string; + disabled: boolean; + onStart: () => void; + onMove: (translation: number) => void; + onEnd: (cancelled: boolean) => void; + onStep: (direction: "up" | "down") => void; + sectionActions: readonly { name: "pinned" | "active" | "settled"; label: string }[]; + onSectionMove: (section: "pinned" | "active" | "settled") => void; + canMoveUp: boolean; + canMoveDown: boolean; +}) { + const latest = useRef(props); + latest.current = props; + const gesture = useMemo( + () => + Gesture.Pan() + .enabled(!props.disabled) + .minDistance(0) + .shouldCancelWhenOutside(false) + .runOnJS(true) + .onStart(() => latest.current.onStart()) + .onUpdate((event) => latest.current.onMove(event.translationY)) + .onEnd((event) => latest.current.onMove(event.translationY)) + .onFinalize((_, success) => latest.current.onEnd(!success)), + [props.disabled], + ); + return ( + + { + if (props.disabled) return; + const sectionAction = props.sectionActions.find( + (action) => action.name === nativeEvent.actionName, + ); + if (sectionAction) props.onSectionMove(sectionAction.name); + if (nativeEvent.actionName === "decrement" && props.canMoveUp) props.onStep("up"); + if (nativeEvent.actionName === "increment" && props.canMoveDown) props.onStep("down"); + }} + style={{ + width: 48, + height: 48, + alignItems: "center", + justifyContent: "center", + opacity: props.disabled ? 0.3 : 1, + }} + > + + + + ); +} + +export function ThreadArrangementSheet(props: { onClose: () => void }) { + const insets = useSafeAreaInsets(); + const threads = useAtomValue(environmentThreadShells.threadShellsAtom); + const configs = useAtomValue(environmentServerConfigsAtom); + const queuedThreadKeys = useAtomValue(queuedThreadKeysAtom); + const pendingOrder = useAtomValue(pendingThreadOrderAtom); + const dropBusy = useAtomValue(threadDropBusyAtom); + const { moveThread } = useThreadListActions(); + const [now, setNow] = useState(() => new Date().toISOString()); + const [expanded, setExpanded] = useState({ snoozed: false, settled: false }); + useEffect(() => { + const wakeAt = Math.min( + ...threads.flatMap((thread) => { + const at = Date.parse(thread.snoozedUntil ?? ""); + return at > Date.parse(now) ? [at] : []; + }), + ); + if (!Number.isFinite(wakeAt)) return; + const timer = setTimeout( + () => setNow(new Date().toISOString()), + Math.min(Math.max(0, wakeAt - Date.now()) + 1, 2_147_483_647), + ); + return () => clearTimeout(timer); + }, [threads, now]); + const sections = useMemo(() => { + const shared = { + threads, + now, + queuedThreadKeys, + pendingOrder, + settlementEnvironmentIds: new Set( + [...configs].flatMap(([id, config]) => + config.environment.capabilities.threadSettlement ? [id] : [], + ), + ), + snoozeEnvironmentIds: new Set( + [...configs].flatMap(([id, config]) => + config.environment.capabilities.threadSnooze ? [id] : [], + ), + ), + }; + const pinned = getThreadListV2OrderedSection({ ...shared, section: "pinned" }); + const active = getThreadListV2OrderedSection({ ...shared, section: "active" }); + const visible = new Set([...pinned, ...active].map(keyOf)); + const parked = threads.filter( + (thread) => thread.archivedAt === null && !visible.has(keyOf(thread)), + ); + return { + pinned, + active, + snoozed: parked.filter((thread) => effectiveSnoozed(thread, { now })), + settled: parked.filter((thread) => !effectiveSnoozed(thread, { now })), + }; + }, [threads, configs, now, queuedThreadKeys, pendingOrder]); + const planners = useMemo(() => { + const planner = (section: "pinned" | "active") => + createThreadMovePlanner({ + ordered: sections[section], + allThreads: threads, + section, + reorderableEnvironmentIds: new Set( + [...configs].flatMap(([id, config]) => + ( + section === "pinned" + ? config.environment.capabilities.threadPinReorder + : config.environment.capabilities.threadActiveReorder + ) + ? [id] + : [], + ), + ), + }); + return { pinned: planner("pinned"), active: planner("active") }; + }, [sections, threads, configs]); + const rows = useMemo(() => { + const result: Row[] = []; + let offset = 0; + for (const section of ["pinned", "active", "snoozed", "settled"] as const) { + if (section === "snoozed" && sections[section].length === 0) continue; + result.push({ key: section, section, offset, height: HEADER_HEIGHT }); + offset += HEADER_HEIGHT; + if ((section === "snoozed" || section === "settled") && !expanded[section]) continue; + for (const thread of sections[section]) { + result.push({ key: keyOf(thread), section, thread, offset, height: ROW_HEIGHT }); + offset += ROW_HEIGHT; + } + } + return result; + }, [sections, expanded]); + const list = useRef>(null); + const geometry = useRef({ height: 0, offset: 0 }); + const drag = useRef(null); + const frame = useRef(null); + const [preview, setPreview] = useState(null); + const translateY = useRef(new Animated.Value(0)).current; + const latest = useRef({ rows, planners, moveThread }); + latest.current = { rows, planners, moveThread }; + + function stop() { + if (frame.current !== null) cancelAnimationFrame(frame.current); + frame.current = null; + drag.current = null; + setPreview(null); + } + const orderVersion = rows + .map((row) => `${row.key}:${row.thread?.pinOrderKey}:${row.thread?.activeOrderKey}`) + .join("|"); + useEffect(() => { + stop(); + }, [orderVersion]); + useEffect( + () => () => { + if (frame.current !== null) cancelAnimationFrame(frame.current); + }, + [], + ); + + function update(translation: number) { + const current = drag.current; + if (current === null) return; + current.translation = translation; + const { height, offset } = geometry.current; + const y = current.startY + translation; + translateY.setValue(Math.max(0, Math.min(height - ROW_HEIGHT, y - ROW_HEIGHT / 2))); + const contentY = Math.max(0, y + offset); + const target = + latest.current.rows.find((row) => contentY < row.offset + row.height) ?? + latest.current.rows.at(-1); + let destination: Destination | null = null; + if ( + target && + y >= 0 && + y <= height && + (target.section === "pinned" || target.section === "active" || target.section === "settled") + ) { + const candidate: Destination = { + section: target.section, + targetId: target.thread ? target.key : null, + placement: + !target.thread || contentY < target.offset + target.height / 2 ? "before" : "after", + }; + if (target.section === "settled") { + if ( + current.sourceSection !== "settled" && + configs.get(current.thread.environmentId)?.environment.capabilities.threadSettlement + ) + destination = { section: "settled", targetId: null, placement: "before" }; + } else if (latest.current.planners[target.section](keyOf(current.thread), candidate) !== null) + destination = candidate; + } + if ( + current.destination?.targetId !== destination?.targetId || + current.destination?.section !== destination?.section || + current.destination?.placement !== destination?.placement + ) { + current.destination = destination; + setPreview({ ...current }); + } + } + function start(row: Row) { + if (!row.thread || preview !== null) return; + drag.current = { + orderVersion, + sourceSection: row.section, + thread: row.thread, + startY: row.offset + ROW_HEIGHT / 2 - geometry.current.offset, + translation: 0, + destination: null, + }; + setPreview({ ...drag.current }); + update(0); + let last = performance.now(); + const tick = () => { + const current = drag.current; + if (!current) return; + const timestamp = performance.now(); + const dt = Math.min(timestamp - last, 32); + last = timestamp; + const bounds = geometry.current; + const y = current.startY + current.translation; + const speed = + y < 48 + ? -Math.min(1, (48 - y) / 48) + : y > bounds.height - 48 + ? Math.min(1, (y - bounds.height + 48) / 48) + : 0; + const tail = latest.current.rows.at(-1); + const maximum = Math.max(0, (tail ? tail.offset + tail.height : 0) - bounds.height); + const offset = Math.max(0, Math.min(maximum, bounds.offset + speed * dt * 0.5)); + if (offset !== bounds.offset) { + bounds.offset = offset; + list.current?.scrollToOffset({ offset, animated: false }); + update(current.translation); + } + frame.current = requestAnimationFrame(tick); + }; + frame.current = requestAnimationFrame(tick); + } + const visiblePreview = preview?.orderVersion === orderVersion ? preview : null; + const sourceRow = visiblePreview + ? rows.find((row) => row.key === keyOf(visiblePreview.thread)) + : undefined; + const destination = visiblePreview?.destination; + const targetRow = destination + ? rows.find( + (row) => + row.section === destination.section && + row.key === (destination.targetId ?? destination.section), + ) + : undefined; + const insertionOffset = targetRow + ? targetRow.offset + + (!targetRow.thread || destination?.placement === "after" ? targetRow.height : 0) + : sourceRow?.offset; + return ( + + + + + Arrange threads + + Done + + + + Drag to reorder, pin, or settle. Changes save when you drop. + + { + geometry.current.height = event.nativeEvent.layout.height; + }} + className="flex-1" + style={{ overflow: "hidden" }} + > + row.key} + scrollEnabled={visiblePreview === null} + removeClippedSubviews={false} + onScroll={(event) => { + geometry.current.offset = event.nativeEvent.contentOffset.y; + }} + scrollEventThrottle={16} + getItemLayout={(_, index) => ({ + length: rows[index]!.height, + offset: rows[index]!.offset, + index, + })} + renderItem={({ item }) => { + const thread = item.thread; + const planner = + item.section === "pinned" || item.section === "active" + ? planners[item.section] + : null; + const capabilities = + thread && configs.get(thread.environmentId)?.environment.capabilities; + const sectionActions = thread + ? (["pinned", "active", "settled"] as const).flatMap<{ + name: "pinned" | "active" | "settled"; + label: string; + }>((section) => { + if (section === item.section) return []; + const label = threadDragAction(item.section, section); + if (!label) return []; + if (section === "settled") + return capabilities?.threadSettlement ? [{ name: section, label }] : []; + if ( + ((section === "pinned" || thread.pinnedAt != null) && + !capabilities?.threadPinning) || + (section === "active" && + item.section === "settled" && + !capabilities?.threadSettlement) || + (section === "active" && + item.section === "snoozed" && + !capabilities?.threadSnooze) + ) + return []; + return planners[section](item.key, { + section, + targetId: null, + placement: "before", + }) + ? [{ name: section, label }] + : []; + }) + : []; + return ( + + {thread ? ( + <> + + {thread.title} + + { + void moveThread(thread, { + section, + targetId: null, + placement: "before", + }); + }} + canMoveUp={planner?.(item.key, "up") != null} + canMoveDown={planner?.(item.key, "down") != null} + onStep={(direction) => { + void moveThread(thread, direction); + }} + onStart={() => start(item)} + onMove={update} + onEnd={(cancelled) => { + const current = drag.current; + if (cancelled || !current?.destination) { + stop(); + return; + } + if (frame.current !== null) cancelAnimationFrame(frame.current); + frame.current = null; + drag.current = null; + // Retain the gap until the saved order arrives, avoiding a flash back. + void moveThread(current.thread, current.destination).finally(stop); + }} + /> + + ) : ( + { + const section = item.section; + if (section === "snoozed" || section === "settled") + setExpanded((value) => ({ ...value, [section]: !value[section] })); + }} + > + + {item.section[0]!.toUpperCase() + item.section.slice(1)} ( + {sections[item.section].length}) + + + )} + + ); + }} + /> + {visiblePreview ? ( + + + {visiblePreview.thread.title} + + {visiblePreview.destination?.section ? ( + + {threadDragAction( + visiblePreview.sourceSection, + visiblePreview.destination.section, + )} + + ) : null} + + ) : null} + + + + + ); +} + +export function ThreadArrangementHost() { + const open = useAtomValue(threadArrangementOpenAtom); + return open ? ( + appAtomRegistry.set(threadArrangementOpenAtom, false)} /> + ) : null; +} diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index bcda2d10be..a851be55f6 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -86,6 +86,7 @@ import { import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; import { composerAttachmentUploadBlockReason, + composerAttachmentsStillUploading, composerAttachmentUploadsAtom, } from "../../state/composer-attachment-uploads"; import Animated, { @@ -231,6 +232,8 @@ export interface ThreadComposerProps { readonly sessionInputBlocked: boolean; readonly environmentId: EnvironmentId; readonly projectCwd: string | null; + /** Why sending is blocked right now (shown as the send button's label), or null. */ + readonly sendBlockedReason?: string | null; readonly editorRef?: RefObject; readonly onChangeDraftMessage: (value: string) => void; readonly onPickDraftMedia: () => Promise; @@ -479,12 +482,27 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer serverConfig: props.serverConfig, states: uploadStates, }); + // An in-flight upload no longer blocks a send: the message waits in the + // outbox and the drain delivers it once the bytes are on the server. + const attachmentsUploading = + props.connectionState === "connected" && + composerAttachmentsStillUploading({ + environmentId: props.environmentId, + attachments: props.draftAttachments, + serverConfig: props.serverConfig, + states: uploadStates, + }); + // A provider follow-up bypasses the outbox and uploads its files itself, so + // it still waits for the background transfer instead of starting another. + const sendBlockedReason = props.sendBlockedReason ?? attachmentBlockReason; + const followUpBlockReason = + sendBlockedReason ?? (attachmentsUploading ? "Attachment still uploading" : null); const canSend = hasContent && !props.sessionInputBlocked && composerAuthority.providerAdmissionAvailable && props.projectCwd !== null && - attachmentBlockReason === null && + sendBlockedReason === null && props.sessionCompactionPendingAction !== "compact" && !isSessionCompactionInProgress(props.sessionCompaction); const activeSessionProviderStatus = useMemo(() => { @@ -750,7 +768,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ? "Queue follow-up" : props.connectionState !== "connected" ? `Save pending send. ${composerAdmissionReason ?? "The environment is disconnected."}` - : props.localOutboxCount > 0 + : props.localOutboxCount > 0 || attachmentsUploading ? "Save pending send" : "Send"; @@ -1787,17 +1805,19 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer /> {canQueueFollowUp ? ( ) : null} ) : ( diff --git a/apps/mobile/src/features/threads/ThreadCreationFailedCard.tsx b/apps/mobile/src/features/threads/ThreadCreationFailedCard.tsx new file mode 100644 index 0000000000..553a5cc2f6 --- /dev/null +++ b/apps/mobile/src/features/threads/ThreadCreationFailedCard.tsx @@ -0,0 +1,38 @@ +import { Pressable, View } from "react-native"; + +import { AppText as Text } from "../../components/AppText"; + +/** + * Shown in place of the composer when the server rejected a new task. The + * prompt and attachments are already back in the project draft, so the only + * action is reopening it. + */ +export function ThreadCreationFailedCard(props: { + readonly reason: string; + /** Where the task's content is kept now. */ + readonly note?: string; + readonly onEditTask: () => void; +}) { + return ( + + + Could not start task + + + {props.reason} + + + {props.note ?? "Your prompt was kept in the project draft."} + + + + Edit task + + + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index d5461ffae1..fc34635711 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -13,7 +13,10 @@ import { appendCodexArtifactTemplateUsePrompt, type CodexArtifactTemplate, } from "@t3tools/client-runtime/codex-artifact-templates"; -import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads"; +import type { + CodexFeedbackSubmission, + EnvironmentThreadStatus, +} from "@t3tools/client-runtime/state/threads"; import { isRollbackActive, type RollbackTarget } from "@t3tools/client-runtime/rollback"; import { getMobileRollbackStatusPresentation } from "./rollback-status-presentation"; import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard"; @@ -51,6 +54,7 @@ import { useState, } from "react"; import { + Alert, AppState, Keyboard, Platform, @@ -82,6 +86,8 @@ import type { StatusTone } from "../../components/StatusPill"; import type { DraftComposerAttachment } from "../../lib/composerImages"; import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout"; import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; +import { editPendingThreadMessage } from "../../state/edit-pending-thread-message"; +import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; import { scopedThreadKey } from "../../lib/scopedEntities"; import type { PendingApproval, @@ -94,9 +100,11 @@ import type { SessionInteractionPresentationState, } from "../../lib/sessionInteractions"; import { PendingApprovalCard } from "./PendingApprovalCard"; +import { ComposerFeedback } from "./ComposerFeedback"; import { PendingUserInputCard } from "./PendingUserInputCard"; import { PendingSessionInteractionCard } from "./PendingSessionInteractionCard"; import { SessionPresentationSurface } from "./SessionPresentationSurface"; +import { ThreadCreationFailedCard } from "./ThreadCreationFailedCard"; import { FLOATING_WORKING_CONTROL_COVERAGE, FloatingWorkingControl, @@ -125,6 +133,8 @@ export interface ThreadDetailScreenProps { readonly screenTone: StatusTone; readonly connectionError: string | null; readonly environmentLabel: string | null; + readonly feedbackSubmissions: ReadonlyArray; + readonly onDismissFeedback: (id: MessageId) => void; readonly selectedThreadFeed: ReadonlyArray; readonly sessionAgents: ReadonlyArray; readonly contextWindow: ContextWindowSnapshot | null; @@ -137,6 +147,21 @@ export interface ThreadDetailScreenProps { readonly sessionCompactionPendingAction: SessionCompactionMenuAction | null; readonly activeWorkStartedAt: string | null; readonly isCompacting: boolean; + /** + * The server has not created this thread yet. "preparing" runs while the + * queued creation is delivered (a worktree may be checking out); "failed" + * is a rejected creation whose content went back to the project draft. + */ + readonly creationState: + | { readonly kind: "preparing"; readonly preparingWorktree: boolean } + | { + readonly kind: "failed"; + readonly reason: string; + /** Where the content went; defaults to the project draft. */ + readonly note?: string; + readonly onEditTask: () => void; + } + | null; readonly activePendingApproval: PendingApproval | null; readonly respondingApprovalId: ApprovalRequestId | null; readonly activePendingUserInput: PendingUserInput | null; @@ -169,6 +194,8 @@ export interface ThreadDetailScreenProps { readonly threadCwd: string | null; readonly localOutboxCount: number; readonly onManagePendingSends: () => void; + readonly queuedMessages: ReadonlyArray; + readonly dispatchingMessageId: MessageId | null; readonly serverConfig: T3ServerConfig | null; readonly layoutVariant?: LayoutVariant; readonly usesAutomaticContentInsets?: boolean; @@ -466,6 +493,15 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread ) { return null; } + if (props.creationState?.kind === "preparing") { + return { + kind: "preparing", + label: props.creationState.preparingWorktree ? "Setting up worktree…" : "Starting…", + }; + } + if (props.creationState?.kind === "failed") { + return null; + } if (threadSyncLabel !== null) { return { kind: "syncing", label: threadSyncLabel }; } @@ -488,6 +524,17 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread return null; })(); const showWorkingControl = floatingStatus !== null; + // Connection and working status occupy the same space. Keep the feed inset + // stable when reconnecting hands off to syncing and then to a running turn. + // A held message waits for the user rather than for delivery, so it + // reserves no status space. + const showFloatingStatus = + showWorkingControl || + props.connectionStateLabel !== "connected" || + props.queuedMessages.some((message) => message.deliveryHold === undefined) || + props.selectedThreadFeed.some( + (entry) => "acknowledged" in entry && entry.acknowledged === true, + ); const selectedThreadFeed = props.selectedThreadFeed; const hasCompactableConversation = selectedThreadFeed.some( @@ -568,14 +615,14 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const userInputInsetProgress = useSharedValue(1); const userInputCardCoverage = useSharedValue(0); const floatingControlCoverage = useSharedValue( - showWorkingControl ? FLOATING_WORKING_CONTROL_COVERAGE : 0, + showFloatingStatus ? FLOATING_WORKING_CONTROL_COVERAGE : 0, ); useEffect(() => { floatingControlCoverage.value = withTiming( - showWorkingControl ? FLOATING_WORKING_CONTROL_COVERAGE : 0, + showFloatingStatus ? FLOATING_WORKING_CONTROL_COVERAGE : 0, { duration: 180, reduceMotion: ReduceMotion.System }, ); - }, [floatingControlCoverage, showWorkingControl]); + }, [floatingControlCoverage, showFloatingStatus]); // Android renders the expanded card in-flow (it cannot hit-test the iOS // overlay outside the bar's bounds), so its measured overlay height already // includes the card — the coverage extra is iOS-only. @@ -635,12 +682,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread useEffect(() => { const previous = previousWorkingControlStateRef.current; const threadChanged = previous.threadKey !== selectedThreadKey; - const visibilityChanged = previous.visible !== showWorkingControl; + const visibilityChanged = previous.visible !== showFloatingStatus; previousWorkingControlStateRef.current = { threadKey: selectedThreadKey, - visible: showWorkingControl, + visible: showFloatingStatus, }; - if ((!threadChanged && !visibilityChanged) || (threadChanged && !showWorkingControl)) { + if ((!threadChanged && !visibilityChanged) || (threadChanged && !showFloatingStatus)) { return; } // LegendList applies the larger inset but does not re-anchor short @@ -648,7 +695,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread // initial load. Re-pin after the finite inset transition; the callback // checks follow state again so a user who scrolled up stays put. scheduleOverlayRepin(230); - }, [scheduleOverlayRepin, selectedThreadKey, showWorkingControl]); + }, [scheduleOverlayRepin, selectedThreadKey, showFloatingStatus]); const handleToggleUserInputCollapsed = useCallback(() => { if (activeUserInputRequestId === null) { return; @@ -734,11 +781,13 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread useEffect(() => { if ( submittedMessageId === null || + anchorMessageId !== submittedMessageId || lastScrolledSubmittedMessageIdRef.current === submittedMessageId || contentPresentationKind !== "ready" || - !selectedThreadFeed.some( + (!selectedThreadFeed.some( (entry) => entry.type === "message" && entry.id === submittedMessageId, - ) + ) && + !props.queuedMessages.some((message) => message.messageId === submittedMessageId)) ) { return; } @@ -777,9 +826,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread }); return () => cancelAnimationFrame(frame); }, [ + anchorMessageId, submittedMessageId, freeze, contentPresentationKind, + props.queuedMessages, selectedThreadFeed, scrollMessageToEnd, selectedThreadKey, @@ -828,6 +879,22 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread return messageId; }, [props.onSendMessage, selectedThreadKey]); + const handleEditPendingMessage = useCallback(async (message: QueuedThreadMessage) => { + try { + if ( + (await editPendingThreadMessage(message)) && + selectedThreadKeyRef.current === scopedThreadKey(message.environmentId, message.threadId) + ) { + composerEditorRef.current?.focus(); + } + } catch (error) { + Alert.alert( + "Could not edit message", + error instanceof Error ? error.message : "Please try again.", + ); + } + }, []); + const collapseComposer = useCallback(() => { composerEditorRef.current?.blur(); }, []); @@ -905,6 +972,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread threadId={props.selectedThread.id} workspaceRoot={props.threadCwd} feed={props.selectedThreadFeed} + queuedMessages={props.queuedMessages} + dispatchingMessageId={props.dispatchingMessageId} + onEditPendingMessage={handleEditPendingMessage} contentPresentation={props.contentPresentation} agentLabel={agentLabel} latestTurn={props.selectedThread.latestTurn} @@ -916,7 +986,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread contentInsetEndAdjustment={combinedContentInsetEndAdjustment} contentTopInset={0} contentBottomInset={ - estimatedOverlayHeight + (showWorkingControl ? FLOATING_WORKING_CONTROL_COVERAGE : 0) + estimatedOverlayHeight + (showFloatingStatus ? FLOATING_WORKING_CONTROL_COVERAGE : 0) } contentMaxWidth={contentMaxWidth} layoutVariant={layoutVariant} @@ -966,6 +1036,13 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread onScrollToEnd={handleScrollToEnd} /> + {props.feedbackSubmissions.map((submission) => ( + props.onDismissFeedback(submission.id)} + /> + ))} {hasAboveEditorPresentation ? ( ) : null} + {props.creationState?.kind === "failed" ? ( + + + + ) : null} {/* Hidden (not unmounted) while a user-input request owns the - composer slot, so composer drafts and editor state survive. */} - + composer slot, so composer drafts and editor state survive. + A rejected creation has no thread to send to; the failure card + owns the slot instead. */} + ; + readonly dispatchingMessageId: MessageId | null; + readonly onEditPendingMessage: (message: QueuedThreadMessage) => void; readonly environmentId: EnvironmentId; readonly threadId: ThreadId; readonly workspaceRoot?: string | null; @@ -1325,7 +1326,7 @@ function useMarkdownStyles( } function renderFeedEntry( - info: { item: ThreadFeedEntry; index: number }, + info: { item: PendingThreadFeedEntry; index: number }, props: Pick< ThreadFeedProps, | "environmentId" @@ -1335,6 +1336,8 @@ function renderFeedEntry( | "rollbackTargetIdle" | "rollbackCommandPending" | "onRevertMessage" + | "dispatchingMessageId" + | "onEditPendingMessage" > & { readonly copiedRowId: string | null; readonly expandedWorkRows: Record; @@ -1480,12 +1483,8 @@ function renderFeedEntry( !message.streaming; if (isUser) { - const enterAnimated = isFreshTimestamp(message.createdAt); return ( - + ) : null} + {entry.pendingMessage?.attachments.map((attachment) => + attachment.type === "image" && attachment.uploadedAttachmentId ? ( + + ) : attachment.type === "image" ? ( + + ) : ( + + ), + )} {attachments.map((attachment) => { return isImageAttachment(attachment) ? ( - {timestampLabel} + {entry.pendingMessage && !entry.acknowledged + ? entry.pendingMessage.deliveryHold + ? "Held" + : "Pending" + : timestampLabel} + {entry.pendingMessage && + !entry.acknowledged && + !entry.pendingMessage.creation && + entry.pendingMessage.messageId !== props.dispatchingMessageId ? ( + { + if (entry.pendingMessage) props.onEditPendingMessage(entry.pendingMessage); + }} + > + + + ) : null} {props.rollbackTargetIdle && props.rollbackTargets.has(message.id) ? ( ) : null} - + ); } @@ -2253,10 +2294,18 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ); const markdownStyles = useMarkdownStyles(onMarkdownLinkPress, renderMarkdownImage); const reviewCommentColors = useReviewCommentColors(); + const unsettledTurnId = + props.latestTurn && + (props.latestTurn.completedAt === null || props.latestTurn.state === "running") + ? props.latestTurn.turnId + : null; // LegendList does not invalidate visible rows when only the renderItem closure changes. - // Keep row-local interaction props in extraData so disclosures and copy feedback repaint. + // Include turn completion so unchanged message rows reveal their footer and spacing + // even when the final message update arrives before the turn settles. const listAppearanceData = useMemo( () => ({ + dispatchingMessageId: props.dispatchingMessageId, + unsettledTurnId, copiedRowId, expandedWorkRows, workRowSizing, @@ -2268,6 +2317,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { viewportWidth, }), [ + props.dispatchingMessageId, + unsettledTurnId, copiedRowId, expandedWorkRows, workRowSizing, @@ -2412,14 +2463,19 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }, [expandedWorkGroups]); const presentedFeed = useMemo( () => - deriveThreadFeedPresentation( + appendPendingThreadMessages( + deriveThreadFeedPresentation( + props.feed, + props.latestTurn, + expandedTurnIds, + expandedWorkGroupIds, + props.activeWorkStartedAt, + ), props.feed, - props.latestTurn, - expandedTurnIds, - expandedWorkGroupIds, - props.activeWorkStartedAt, + props.queuedMessages, ), [ + props.queuedMessages, expandedTurnIds, expandedWorkGroupIds, props.activeWorkStartedAt, @@ -2431,7 +2487,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // content-inset override. Seed the fresh instance synchronously with the // current overlay height before the scroll integration's next reaction; // on Android the declarative contentInset floor covers this same window. - const listMountKey = `${feedThreadKey}:${props.feed.length === 0 ? "empty" : "filled"}`; + const listMountKey = `${feedThreadKey}:${presentedFeed.length === 0 ? "empty" : "filled"}`; useLayoutEffect(() => { const bottom = props.contentInsetEndAdjustment.value; if (bottom > 0) { @@ -2458,12 +2514,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } return new Set(terminalIdsByTurn.values()); }, [props.feed]); - const unsettledTurnId = - props.latestTurn && - (props.latestTurn.completedAt === null || props.latestTurn.state === "running") - ? props.latestTurn.turnId - : null; - useEffect(() => { const previous = previousLatestTurnRef.current; previousLatestTurnRef.current = props.latestTurn; @@ -2676,7 +2726,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // Disclosures can mount existing offscreen rows as well as new work rows. // Fade those in after movement; never retain removed rows over replacements. const renderItem = useCallback( - (info: { item: ThreadFeedEntry; index: number }) => ( + (info: { item: PendingThreadFeedEntry; index: number }) => ( {renderFeedEntry(info, { environmentId: props.environmentId, + dispatchingMessageId: props.dispatchingMessageId, + onEditPendingMessage: props.onEditPendingMessage, copiedRowId, expandedWorkRows, workRowSizing, @@ -2719,6 +2771,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ), [ + props.dispatchingMessageId, + props.onEditPendingMessage, copiedRowId, disclosureToggleSettling, expandedWorkRows, @@ -2753,7 +2807,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ], ); - if (props.contentPresentation.kind === "unavailable") { + if (props.contentPresentation.kind === "unavailable" && props.queuedMessages.length === 0) { return ( - {props.feed.length === 0 && + {presentedFeed.length === 0 && props.activeWorkStartedAt === null && props.contentPresentation.kind === "ready" ? ( diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 30c20e62ba..8647dc1129 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -32,7 +32,9 @@ import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled"; import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences"; import { usePendingThreadOrder } from "../../state/thread-order"; import { environmentServerConfigsAtom } from "../../state/server"; -import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; +import { resolvePendingTaskDelivery } from "../../state/pending-new-tasks-model"; +import { usePendingNewTasks, type PendingNewTask } from "../../state/use-pending-new-tasks"; +import { useQueuedThreadKeys } from "../../state/use-thread-outbox"; import { useWorkspaceState } from "../../state/workspace"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; @@ -77,6 +79,7 @@ import { ThreadListV2SettledShelfHeader, ThreadListV2SnoozedShelfHeader, } from "./thread-list-v2-items"; +import { resolveThreadProviderInstance } from "./thread-provider-instance"; import { buildThreadListV2Items, getThreadListV2OrderedSection, @@ -102,6 +105,7 @@ interface ThreadNavigationSidebarProps { readonly selectedThreadKey: string | null; readonly onOpenSettings: () => void; readonly onOpenEnvironmentSettings: () => void; + readonly onNewThreadOnBranch: (thread: EnvironmentThreadShell) => void; readonly onNewThreadInProject: (project: EnvironmentProject) => void; readonly onSearchQueryChange: (query: string) => void; readonly onSelectThread: (thread: EnvironmentThreadShell) => void; @@ -165,6 +169,7 @@ function ThreadNavigationSidebarPane( } = useThreadListActions(); const threadListV2Enabled = useThreadListV2Enabled(); const pendingTasks = usePendingNewTasks(); + const queuedThreadKeys = useQueuedThreadKeys(); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); const environments = useMemo( () => @@ -300,7 +305,7 @@ function ThreadNavigationSidebarPane( ? pendingTasks : pendingTasks.filter((pendingTask) => selectedProjectRefs.has( - scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), + scopedProjectKey(pendingTask.environmentId, pendingTask.projectId), ), ), [threadListV2Enabled, pendingTasks, selectedProjectRefs], @@ -313,6 +318,7 @@ function ThreadNavigationSidebarPane( projects: scopedProjects, threads: scopedThreads, pendingTasks: scopedPendingTasks, + queuedThreadKeys, environmentId: options.selectedEnvironmentId, searchQuery: props.searchQuery, matchedThreadKeys, @@ -322,6 +328,7 @@ function ThreadNavigationSidebarPane( }), [ threadListV2Enabled, + queuedThreadKeys, matchedThreadKeys, options, props.searchQuery, @@ -410,6 +417,26 @@ function ThreadNavigationSidebarPane( // Threads on servers without the settlement capability never classify as // settled (the user could neither un-settle nor pin them). const serverConfigs = useAtomValue(environmentServerConfigsAtom); + const connectedEnvironmentIds = useMemo( + () => + new Set( + workspaceEnvironments + .filter((environment) => environment.connectionState === "connected") + .map((environment) => environment.environmentId), + ), + [workspaceEnvironments], + ); + const pendingTaskDelivery = useCallback( + (pendingTask: PendingNewTask) => + pendingTask.kind === "pending" + ? resolvePendingTaskDelivery({ + message: pendingTask.message, + connected: connectedEnvironmentIds.has(pendingTask.environmentId), + serverConfig: serverConfigs.get(pendingTask.environmentId), + }) + : null, + [connectedEnvironmentIds, serverConfigs], + ); const settlementEnvironmentIds = useMemo(() => { const supported = new Set(); for (const [environmentId, config] of serverConfigs) { @@ -496,6 +523,7 @@ function ThreadNavigationSidebarPane( now: new Date().toISOString(), settlementEnvironmentIds, snoozeEnvironmentIds, + queuedThreadKeys, }), }); return { pinned: sectionPlanner("pinned"), active: sectionPlanner("active") }; @@ -503,6 +531,7 @@ function ThreadNavigationSidebarPane( serverConfigs, threads, pendingOrder, + queuedThreadKeys, settlementEnvironmentIds, snoozeEnvironmentIds, nowMinute, @@ -528,6 +557,7 @@ function ThreadNavigationSidebarPane( matchedThreadKeys, settlementEnvironmentIds, snoozeEnvironmentIds, + queuedThreadKeys, settledLimit: settledVisibleCount, now: new Date().toISOString(), snoozedShelfExpanded, @@ -536,6 +566,7 @@ function ThreadNavigationSidebarPane( }); }, [ pendingOrder, + queuedThreadKeys, nowMinute, snoozeWakeTick, snoozedShelfExpanded, @@ -576,10 +607,10 @@ function ThreadNavigationSidebarPane( const v2PendingTasks = pendingTasks.filter( (pendingTask) => (options.selectedEnvironmentId === null || - pendingTask.message.environmentId === options.selectedEnvironmentId) && + pendingTask.environmentId === options.selectedEnvironmentId) && (selectedProjectRefs === null || selectedProjectRefs.has( - scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), + scopedProjectKey(pendingTask.environmentId, pendingTask.projectId), )) && (v2SearchQuery.length === 0 || pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)), @@ -861,23 +892,21 @@ function ThreadNavigationSidebarPane( switch (item.type) { case "v2-pending": { const pendingScopeKey = scopedProjectKey( - item.pendingTask.message.environmentId, - item.pendingTask.creation.projectId, + item.pendingTask.environmentId, + item.pendingTask.projectId, ); return ( 1 - ? (savedConnectionsById[item.pendingTask.message.environmentId] - ?.environmentLabel ?? null) + ? (savedConnectionsById[item.pendingTask.environmentId]?.environmentLabel ?? null) : null } - environmentMachine={machineByEnvironmentId.get( - item.pendingTask.message.environmentId, - )} + environmentMachine={machineByEnvironmentId.get(item.pendingTask.environmentId)} pane="sidebar" showPendingDivider={item.showPendingDivider} onSelectPendingTask={openPendingTask} @@ -894,23 +923,17 @@ function ThreadNavigationSidebarPane( const scopeKey = scopedProjectKey(thread.environmentId, thread.projectId); return ( - provider.instanceId === - (thread.session?.providerInstanceId ?? thread.modelSelection.instanceId), - )?.driver ?? null - } + providerInstance={resolveThreadProviderInstance(serverConfigs, thread)} environmentLabel={ Object.keys(savedConnectionsById).length > 1 ? (savedConnectionsById[thread.environmentId]?.environmentLabel ?? null) @@ -1015,13 +1038,11 @@ function ThreadNavigationSidebarPane( { + const creation = selectedThreadCreation?.message; + if (!creation?.creation || routeThreadIdentity === null) { + return; + } + // The drain restored the prompt and attachments into the recovery draft + // the rejected creation owns. Open that draft by id: without it the sheet + // mints a fresh empty one and the restored content is unreachable. + try { + await recoverFailedThreadDraft(creation); + } catch (error) { + Alert.alert( + "Could not restore draft", + error instanceof Error ? error.message : String(error), + ); + return; + } + clearPendingThreadCreationOutcome(routeThreadIdentity); + navigation.dispatch( + StackActions.replace("NewTaskSheet", { + screen: "NewTaskDraft", + params: { + draftId: restoredNewTaskDraftKey(creation.messageId), + environmentId: String(creation.environmentId), + projectId: String(creation.creation.projectId), + ...(selectedThreadProject ? { title: selectedThreadProject.title } : {}), + }, + }), + ); + }, [navigation, routeThreadIdentity, selectedThreadCreation, selectedThreadProject]); + // A creation the drain held (the provider or server refused admission) stays + // in the outbox rather than returning to a draft. Its pending-task editor + // edits or retargets it; deleting it stays in the thread list's menu. + const handleEditHeldCreation = useCallback(async () => { + const creation = selectedThreadCreation?.message; + if (!creation?.creation) { + return; + } + // Text typed here during setup belongs to a thread the server never + // created; carry it into the editor alongside the queued prompt. + try { + await recoverHeldCreationDraft(creation); + } catch (error) { + Alert.alert( + "Could not restore draft", + error instanceof Error ? error.message : String(error), + ); + return; + } + navigation.dispatch( + StackActions.replace("NewTaskSheet", { + screen: "NewTaskDraft", + params: { + environmentId: String(creation.environmentId), + projectId: String(creation.creation.projectId), + pendingTaskId: String(creation.messageId), + ...(selectedThreadProject ? { title: selectedThreadProject.title } : {}), + }, + }), + ); + }, [navigation, selectedThreadCreation, selectedThreadProject]); + const creationState = ((): ThreadDetailScreenProps["creationState"] => { + if (selectedThreadCreation === null) { + return null; + } + const hold = + selectedThreadCreation.outcome === null + ? selectedThreadCreation.message.deliveryHold + : undefined; + if (hold !== undefined) { + return { + kind: "failed", + reason: hold.reason, + note: "The task is held on this device until you edit it.", + onEditTask: handleEditHeldCreation, + }; + } + if (selectedThreadCreation.outcome?.kind === "failed") { + return { + kind: "failed", + reason: selectedThreadCreation.outcome.reason, + onEditTask: handleEditFailedCreation, + }; + } + return { + kind: "preparing", + preparingWorktree: selectedThreadCreation.message.creation?.workspaceMode === "worktree", + }; + })(); // Deep links / cold starts land with Thread as the ONLY route, where the // native back button does not render. Provide an explicit Home escape for // that case; when history exists the native back button is used instead. @@ -951,12 +1050,18 @@ function ThreadRouteContent( return ; } - const contentPresentation = projectThreadContentPresentation({ - hasDetail: selectedThreadDetail !== null, - detailError: Option.getOrNull(selectedThreadDetailState.error), - detailDeleted: selectedThreadDetailState.status === "deleted", - connectionState: routeConnectionState, - }); + // A queued creation renders as ready content: its prompt is the whole + // conversation until the server creates the thread. The subscription's + // not-found error for that window is expected, not a load failure. + const contentPresentation = + creationState !== null + ? { kind: "ready" as const } + : projectThreadContentPresentation({ + hasDetail: selectedThreadDetail !== null, + detailError: Option.getOrNull(selectedThreadDetailState.error), + detailDeleted: selectedThreadDetailState.status === "deleted", + connectionState: routeConnectionState, + }); const serverConfig = routeEnvironmentRuntime?.serverConfig ?? null; const renderThreadRouteBody = (showActionControls: boolean) => ( <> @@ -971,6 +1076,8 @@ function ThreadRouteContent( screenTone={connectionTone(routeConnectionState)} connectionError={routeConnectionError} environmentLabel={selectedEnvironmentConnection?.environmentLabel ?? null} + feedbackSubmissions={composer.feedbackSubmissions} + onDismissFeedback={composer.dismissFeedback} selectedThreadFeed={composer.selectedThreadFeed} sessionAgents={composer.selectedThreadAgents} contextWindow={composer.selectedThreadContextWindow} @@ -983,6 +1090,7 @@ function ThreadRouteContent( sessionCompactionPendingAction={composer.sessionCompactionPendingAction} activeWorkStartedAt={composer.activeWorkStartedAt} isCompacting={composer.isCompacting} + creationState={creationState} activePendingApproval={requests.activePendingApproval} respondingApprovalId={requests.respondingApprovalId} activePendingUserInput={requests.activePendingUserInput} @@ -1011,6 +1119,8 @@ function ThreadRouteContent( threadCwd={selectedThreadCwd} localOutboxCount={composer.selectedThreadQueueCount} onManagePendingSends={composer.onManagePendingSends} + queuedMessages={composer.selectedThreadQueuedMessages} + dispatchingMessageId={composer.dispatchingQueuedMessageId} layoutVariant={layout.variant} usesAutomaticContentInsets={usesNativeHeaderGlass} onOpenConnectionEditor={handleOpenConnectionEditor} diff --git a/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts b/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts new file mode 100644 index 0000000000..6440e63912 --- /dev/null +++ b/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts @@ -0,0 +1,132 @@ +import * as NodeChildProcess from "node:child_process"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeUtil from "node:util"; +import { EnvironmentId } from "@t3tools/contracts"; +import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; + +import { checkoutNewTaskBranch } from "./checkout-new-task-branch"; + +const exec = NodeUtil.promisify(NodeChildProcess.execFile); +let directory: string; +let cwd: string; +const git = (...args: string[]) => exec("git", ["-C", cwd, ...args]); +const branch = { name: "feature/a", current: false, isDefault: false, worktreePath: null }; +const environmentId = EnvironmentId.make("branch-test-environment"); + +beforeEach(async () => { + directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-branch-selection-")); + cwd = NodePath.join(directory, "project"); + await exec("git", ["init", "-b", "main", cwd]); + await git("config", "user.name", "Branch test"); + await git("config", "user.email", "branch-test@example.com"); + await NodeFSP.writeFile(NodePath.join(cwd, "file.txt"), "main\n"); + await git("add", "."); + await git("commit", "-m", "main"); + await git("checkout", "-b", branch.name); + await NodeFSP.writeFile(NodePath.join(cwd, "file.txt"), "feature\n"); + await git("commit", "-am", "feature"); + await git("checkout", "main"); +}); + +afterEach(async () => { + await NodeFSP.rm(directory, { recursive: true, force: true }); +}); + +function selectBranch(switchRef: Parameters[0]["switchRef"]) { + return checkoutNewTaskBranch({ + branch, + project: { environmentId, workspaceRoot: cwd }, + workspaceMode: "local", + switchRef, + }); +} + +const switchRef: Parameters[0]["switchRef"] = (request) => + settlePromise(async () => { + expect(request.environmentId).toBe(environmentId); + await exec("git", ["-C", request.input.cwd, "checkout", request.input.refName]); + const { stdout } = await exec("git", ["-C", request.input.cwd, "branch", "--show-current"]); + return { refName: stdout.trim() }; + }); + +describe("new-task branch checkout", () => { + it("switches main to the older thread's feature branch before returning a selection", async () => { + const result = await selectBranch(switchRef); + expect(result._tag).toBe("Success"); + if (result._tag !== "Success") throw new Error("Checkout failed"); + expect(result.value.name).toBe("feature/a"); + expect(result.value.current).toBe(true); + expect((await git("branch", "--show-current")).stdout.trim()).toBe("feature/a"); + expect(await NodeFSP.readFile(NodePath.join(cwd, "file.txt"), "utf8")).toBe("feature\n"); + }); + + it("does not release the selection while checkout is still pending", async () => { + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + let completed = false; + const selection = selectBranch(async (request) => { + entered.resolve(); + await release.promise; + return switchRef(request); + }).then((result) => { + completed = true; + return result; + }); + await entered.promise; + expect(completed).toBe(false); + expect((await git("branch", "--show-current")).stdout.trim()).toBe("main"); + release.resolve(); + expect((await selection)._tag).toBe("Success"); + expect((await git("branch", "--show-current")).stdout.trim()).toBe("feature/a"); + }); + + it("returns checkout failure without selecting the branch or losing dirty files", async () => { + await NodeFSP.writeFile(NodePath.join(cwd, "file.txt"), "unsaved local changes\n"); + const result = await selectBranch(switchRef); + expect(result._tag).toBe("Failure"); + if (result._tag !== "Failure") throw new Error("Expected checkout to fail"); + expect(String(squashAtomCommandFailure(result))).toContain("would be overwritten"); + expect((await git("branch", "--show-current")).stdout.trim()).toBe("main"); + expect(await NodeFSP.readFile(NodePath.join(cwd, "file.txt"), "utf8")).toBe( + "unsaved local changes\n", + ); + }); + + it("fails when the source project is unavailable instead of releasing a composer selection", async () => { + const result = await checkoutNewTaskBranch({ + branch, + project: null, + workspaceMode: "local", + switchRef: () => { + throw new Error("An unavailable project must not run checkout"); + }, + }); + expect(result._tag).toBe("Failure"); + if (result._tag !== "Failure") throw new Error("Expected an unavailable-project failure"); + expect(String(squashAtomCommandFailure(result))).toContain("selected project is unavailable"); + expect((await git("branch", "--show-current")).stdout.trim()).toBe("main"); + }); + + it("reuses an existing worktree without switching the project checkout", async () => { + const worktreePath = NodePath.join(directory, "worktree"); + await git("worktree", "add", worktreePath, "feature/a"); + const result = await checkoutNewTaskBranch({ + branch: { ...branch, worktreePath }, + project: { environmentId, workspaceRoot: cwd }, + workspaceMode: "local", + switchRef: () => { + throw new Error("Existing worktrees must not switch the project checkout"); + }, + }); + expect(result._tag).toBe("Success"); + if (result._tag !== "Success") throw new Error("Worktree selection failed"); + expect(result.value.worktreePath).toBe(worktreePath); + expect((await git("branch", "--show-current")).stdout.trim()).toBe("main"); + expect(await NodeFSP.readFile(NodePath.join(worktreePath, "file.txt"), "utf8")).toBe( + "feature\n", + ); + }); +}); diff --git a/apps/mobile/src/features/threads/checkout-new-task-branch.ts b/apps/mobile/src/features/threads/checkout-new-task-branch.ts new file mode 100644 index 0000000000..2652581f6e --- /dev/null +++ b/apps/mobile/src/features/threads/checkout-new-task-branch.ts @@ -0,0 +1,49 @@ +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import type { VcsRef } from "@t3tools/client-runtime/state/vcs"; +import { + type AtomCommandResult, + mapAtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; +import type { VcsSwitchRefInput, VcsSwitchRefResult } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; + +import { shouldCheckoutNewTaskBranch } from "./new-task-context-presentation"; + +/** Resolve a composer branch only after its checkout succeeds. Existing worktrees + * and new-worktree base selections already identify a separate workspace. */ +export async function checkoutNewTaskBranch(input: { + readonly branch: VcsRef; + readonly project: Pick | null; + readonly workspaceMode: "local" | "worktree"; + readonly switchRef: (request: { + readonly environmentId: EnvironmentProject["environmentId"]; + readonly input: VcsSwitchRefInput; + }) => Promise>; +}): Promise> { + if (!input.project) { + return AsyncResult.failure( + Cause.fail(new Error("The selected project is unavailable. Reconnect and try again.")), + ); + } + if ( + !shouldCheckoutNewTaskBranch({ + branchIsCurrent: input.branch.current, + branchWorktreePath: input.branch.worktreePath, + workspaceMode: input.workspaceMode, + }) + ) { + return AsyncResult.success(input.branch); + } + + const result = await input.switchRef({ + environmentId: input.project.environmentId, + input: { cwd: input.project.workspaceRoot, refName: input.branch.name }, + }); + return mapAtomCommandResult(result, (value) => ({ + ...input.branch, + current: true, + isRemote: false, + name: value.refName ?? input.branch.name, + })); +} diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx index 1afc871c95..bb55de1b00 100644 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ b/apps/mobile/src/features/threads/floating-working-control.tsx @@ -9,6 +9,7 @@ import { type LayoutChangeEvent, Pressable, Text as SystemText, + useWindowDimensions, View, } from "react-native"; import Animated, { @@ -27,6 +28,7 @@ import { SymbolView } from "../../components/AppSymbol"; import type { FloatingWorkingStatus } from "./floating-working-status"; import { ControlPill } from "../../components/ControlPill"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; +import { ShimmeringWorkContent } from "./thread-work-log"; const CONTROL_HEIGHT = 38.5; // h-11 with the mobile 14px rem // The collapsed composer capsule starts 6 below its overlay's top edge, so @@ -68,6 +70,9 @@ export function FloatingWorkingControl(props: { readonly showScrollToEnd: boolean; readonly onScrollToEnd: () => void; }) { + const { width: windowWidth } = useWindowDimensions(); + const [overlayWidth, setOverlayWidth] = useState(windowWidth); + const labelWidth = Math.max(0, Math.min(overlayWidth, windowWidth) - CONTROL_HEIGHT - 16); const separationProgress = useSharedValue(props.showScrollToEnd ? 1 : 0); useEffect(() => { @@ -81,17 +86,10 @@ export function FloatingWorkingControl(props: { opacity: separationProgress.value, })); - // The label swaps between connection, syncing, compacting, and working while - // the capsule stays mounted. A layout transition on the capsule would move - // its left edge, and labels laid out from that edge slide with it, so the pill - // reads as shifting sideways. Instead an in-flow sizer animates to the - // measured label width and the capsule takes its size from that, while the - // labels sit centered on top. The row re-centers as the capsule grows, so its - // midpoint never moves and the text underneath stays put. - // - // The sizer has to carry the width rather than the capsule itself: the native - // glass view only picks up a size from a real layout pass, so an animated - // width set straight on it leaves the glass stuck at its mounted size. + // Animate an in-flow sizer so native glass receives real layout updates. + // Measure labels in a separate, fixed-width host: measuring against the + // animated capsule constrains the incoming text to each intermediate width + // and repeatedly retargets the animation as it grows. const capsuleWidth = useSharedValue(null); const measuredWidthRef = useRef(null); const handleLabelLayout = (event: LayoutChangeEvent) => { @@ -126,15 +124,27 @@ export function FloatingWorkingControl(props: { // Only the connection label is a button (tap to reconnect); the others // pass touches through to the feed like before. const statusInteractive = props.status?.kind === "connection"; - // Yoga centers an absolute child that has no insets on its parent's align and - // justify, so each label row lands centered on the capsule without measuring - // itself, and the capsule clips whatever a wider label overhangs while it - // catches up. + // The host stays centered on the capsule, but its measurement constraint + // comes from the overlay, independent of the capsule's current width. const statusContent = props.status !== null ? ( <> - + + + ) : null; @@ -143,6 +153,7 @@ export function FloatingWorkingControl(props: { pointerEvents="box-none" className="absolute left-0 right-0 z-20 items-center" style={{ top: -CONTROL_OVERLAY_OFFSET }} + onLayout={(event) => setOverlayWidth(event.nativeEvent.layout.width)} entering={NATIVE_LIQUID_GLASS_SUPPORTED ? undefined : CONTROL_ENTERING} exiting={NATIVE_LIQUID_GLASS_SUPPORTED ? undefined : CONTROL_EXITING} > @@ -285,7 +296,9 @@ function FloatingStatusLabel(props: { animating={animate} hidesWhenStopped={false} /> - {props.status.label} + + {props.status.label} + ); } @@ -312,12 +325,41 @@ function FloatingStatusLabel(props: { ) : ( )} - + {props.status.label} ); } + if (props.status.kind === "preparing") { + return ( + + + + + ); + } return ( ; readonly reset: () => void; readonly setProject: (project: EnvironmentProject) => void; + /** + * Binds the composer to an existing new-task draft (a row in the thread + * list). Returns false when the draft is gone, so the caller can fall back + * to a fresh one. + */ + readonly openDraft: (draftKey: string) => boolean; readonly selectEnvironment: (environmentId: EnvironmentId) => void; readonly setSelectedModelKey: ( key: string | null, @@ -185,7 +192,13 @@ type NewTaskFlowContextValue = { readonly beginEditingPendingTask: (messageId: string) => boolean; readonly finishEditingPendingTask: () => void; readonly cancelEditingPendingTask: () => void; - readonly buildPendingTaskMessage: (metadata: TurnCommandMetadata) => QueuedThreadMessage | null; + readonly buildPendingTaskMessage: ( + metadata: TurnCommandMetadata, + options?: { + /** The live checkout, recorded as a local task's branch when it sends now. */ + readonly currentCheckoutBranch?: string | null; + }, + ) => QueuedThreadMessage | null; readonly setPrompt: (value: string) => void; readonly replaceAttachments: (attachments: ReadonlyArray) => void; /** Appends draft attachments; returns how many the live cap rejected. */ @@ -236,6 +249,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { ? selectedEnvironmentIdOverride : (projects[0]?.environmentId ?? null); const [selectedProjectKey, setSelectedProjectKey] = useState(null); + // The new-task draft the composer is bound to. Null until a project is + // chosen; each New Task entry mints its own, so a project can hold several. + const [activeDraftKey, setActiveDraftKey] = useState(null); const [submitting, setSubmitting] = useState(false); const [branchQuery, setBranchQuery] = useState(""); const [expandedProvider, setExpandedProvider] = useState(null); @@ -251,6 +267,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const reset = useCallback(() => { setSelectedEnvironmentId(null); setSelectedProjectKey(null); + setActiveDraftKey(null); setSubmitting(false); setBranchQuery(""); setExpandedProvider(null); @@ -371,12 +388,28 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedProject?.environmentId ?? null, ); // While a queued pending task is being edited its draft lives under a key - // scoped to the queued message, so per-project new-task drafts stay intact. + // scoped to the queued message, so new-task drafts stay intact. const selectedProjectDraftKey = editingPendingTask ? pendingTaskDraftKey(editingPendingTask.messageId) : selectedProject - ? `new-task:${scopedProjectKey(selectedProject.environmentId, selectedProject.id)}` + ? activeDraftKey : null; + // selectedProject can resolve without setProject ever running (the + // environment's first project is the fallback, and the draft screen skips + // setProject when the route's project already matches it). The composer + // still needs a draft to write into, so bind one the moment a project is + // in view and nothing else owns the key. + useEffect(() => { + if (activeDraftKey !== null || editingPendingTask !== null || selectedProject === null) { + return; + } + setActiveDraftKey( + createNewTaskDraft({ + environmentId: selectedProject.environmentId, + projectId: selectedProject.id, + }), + ); + }, [activeDraftKey, editingPendingTask, selectedProject]); const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey); const prompt = selectedProjectDraft.text; const attachments = selectedProjectDraft.attachments; @@ -672,20 +705,19 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { ); }, [availableBranches, branchQuery]); - // New-task drafts are keyed per (environment, project), so retargeting the - // composer would otherwise show the target's empty draft and strand what the - // user typed under the old key. + // The composer's draft follows the project it will be sent to: switching + // mid-compose keeps the same draft and moves it, so typed text follows the + // user. A pending-task edit owns its own key and is untouched here. const carryDraftContentTo = useCallback( (project: EnvironmentProject) => { - const nextDraftKey = `new-task:${scopedProjectKey(project.environmentId, project.id)}`; - if ( - selectedProjectDraftKey?.startsWith("new-task:") && - selectedProjectDraftKey !== nextDraftKey - ) { - void copyComposerDraftContentIfEmpty(selectedProjectDraftKey, nextDraftKey); + const target = { environmentId: project.environmentId, projectId: project.id }; + if (activeDraftKey !== null && isNewTaskDraftKey(activeDraftKey)) { + retargetNewTaskDraft(activeDraftKey, target); + } else if (!editingPendingTaskRef.current) { + setActiveDraftKey(createNewTaskDraft(target)); } }, - [selectedProjectDraftKey], + [activeDraftKey], ); const setProject = useCallback( @@ -697,6 +729,31 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { [carryDraftContentTo], ); + const openDraft = useCallback( + (draftKey: string): boolean => { + const draft = appAtomRegistry.get(composerDraftsAtom)[draftKey]; + const stamp = draft?.project; + if (!isNewTaskDraftKey(draftKey) || !stamp) { + return false; + } + // The stamped project must be loaded: selectedProject falls back to + // the environment's first project otherwise, and the draft would be + // sent somewhere the user never chose. + const projectLoaded = projects.some( + (project) => + project.environmentId === stamp.environmentId && project.id === stamp.projectId, + ); + if (!projectLoaded) { + return false; + } + setActiveDraftKey(draftKey); + setSelectedEnvironmentId(stamp.environmentId); + setSelectedProjectKey(scopedProjectKey(stamp.environmentId, stamp.projectId)); + return true; + }, + [projects], + ); + const selectEnvironment = useCallback( (environmentId: EnvironmentId) => { const match = resolveEnvironmentProjectMatch( @@ -879,24 +936,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { if (!message?.creation) { return false; } - const draftKey = pendingTaskDraftKey(message.messageId); - // Only hydrate a fresh editing draft; reopening mid-edit keeps newer edits. - if (isComposerDraftEmpty(getComposerDraftSnapshot(draftKey))) { - setComposerDraftText(draftKey, message.text); - replaceComposerDraftAttachments(draftKey, message.attachments); - updateComposerDraftSettings(draftKey, { - modelSelection: message.modelSelection, - providerSelectionExplicit: message.modelSelection !== undefined, - runtimeMode: message.runtimeMode, - interactionMode: message.interactionMode, - workspaceSelection: { - mode: message.creation.workspaceMode, - branch: message.creation.branch, - worktreePath: message.creation.worktreePath, - startFromOrigin: message.creation.startFromOrigin ?? false, - }, - }); - } + hydratePendingTaskEditorDraft(message); setSelectedEnvironmentId(message.environmentId); setSelectedProjectKey(scopedProjectKey(message.environmentId, message.creation.projectId)); activeEditingMessageId = message.messageId; @@ -909,7 +949,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { }, []); const buildPendingTaskMessage = useCallback( - (metadata: TurnCommandMetadata): QueuedThreadMessage | null => { + ( + metadata: TurnCommandMetadata, + options?: { readonly currentCheckoutBranch?: string | null }, + ): QueuedThreadMessage | null => { if (!selectedProject || !selectedProjectDraftKey) { return null; } @@ -973,11 +1016,15 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { ...(projectTitle !== undefined ? { projectTitle } : {}), ...(projectCwd !== undefined ? { projectCwd } : {}), workspaceMode: mode, - // Only an explicit picker choice, never the current checkout: a - // queued local task drains days later against whatever is checked - // out then, so recording a queue-time guess would pin a stale label - // to a thread that ran somewhere else. - branch: workspaceSelection?.branch ?? null, + // An explicit picker choice wins. Otherwise only a task sending now + // records the current checkout: a queued local task drains days + // later against whatever is checked out then, so a queue-time + // guess would pin a stale label to a thread that ran somewhere else. + branch: resolveProjectThreadCreationBranch({ + workspaceMode: mode, + selectedBranch: workspaceSelection?.branch ?? null, + currentCheckoutBranch: options?.currentCheckoutBranch ?? null, + }), worktreePath: mode === "worktree" ? null : (workspaceSelection?.worktreePath ?? null), // The draft only carries the flag when the user touched it; fall // back to the resolved default (server settings) so queued tasks @@ -1143,6 +1190,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { filteredBranches, reset, setProject, + openDraft, selectEnvironment, setSelectedModelKey, setWorkspaceMode, @@ -1208,6 +1256,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedProjectKey, selectedWorktreePath, setProject, + openDraft, selectBranch, selectEnvironment, setInteractionMode, diff --git a/apps/mobile/src/features/threads/pending-thread-feed.test.ts b/apps/mobile/src/features/threads/pending-thread-feed.test.ts new file mode 100644 index 0000000000..2cc0c5d88a --- /dev/null +++ b/apps/mobile/src/features/threads/pending-thread-feed.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vite-plus/test"; +import { CommandId, EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; +import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; +import { appendPendingThreadMessages } from "./pending-thread-feed"; + +const pending = (id: string): QueuedThreadMessage => ({ + environmentId: EnvironmentId.make("env"), + threadId: ThreadId.make("thread"), + messageId: MessageId.make(id), + commandId: CommandId.make(id), + text: id, + attachments: [], + createdAt: "2026-09-06T10:00:00.000Z", +}); + +describe("pending timeline messages", () => { + it("keeps pending messages after newer agent activity in queue order", () => { + const activity = { + type: "thinking", + turnId: null, + id: "thinking", + createdAt: "2026-09-06T11:00:00.000Z", + } as const; + const entries = appendPendingThreadMessages( + [activity], + [], + [pending("first"), pending("second")], + ); + expect(entries.map((entry) => entry.id)).toEqual(["thinking", "first", "second"]); + expect(entries[1]?.pendingMessage?.text).toBe("first"); + }); + + it("reuses the message id and suppresses the pending copy when delivery appears", () => { + const queued = pending("sent"); + const optimistic = appendPendingThreadMessages([], [], [queued])[0]!; + const delivered = { ...optimistic, pendingMessage: undefined }; + expect(appendPendingThreadMessages([delivered], [delivered], [queued])).toEqual([delivered]); + // Folded messages still count as delivered even when absent from the presented rows. + expect(appendPendingThreadMessages([], [delivered], [queued])).toEqual([]); + }); +}); diff --git a/apps/mobile/src/features/threads/pending-thread-feed.ts b/apps/mobile/src/features/threads/pending-thread-feed.ts new file mode 100644 index 0000000000..10b7d113eb --- /dev/null +++ b/apps/mobile/src/features/threads/pending-thread-feed.ts @@ -0,0 +1,41 @@ +import type { ThreadFeedEntry } from "../../lib/threadActivity"; +import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; + +export type PendingThreadFeedEntry = ThreadFeedEntry & { + readonly pendingMessage?: QueuedThreadMessage; + readonly acknowledged?: boolean; +}; + +/** Append the outbox after all presented activity, until the server echoes each message. */ +export function appendPendingThreadMessages( + presentedFeed: ReadonlyArray, + feed: ReadonlyArray, + queuedMessages: ReadonlyArray, +): ReadonlyArray { + if (queuedMessages.length === 0) return presentedFeed; + const deliveredIds = new Set( + feed.flatMap((entry) => (entry.type === "message" ? [entry.message.id] : [])), + ); + return [ + ...presentedFeed, + ...queuedMessages + .filter((message) => !deliveredIds.has(message.messageId)) + .map( + (pendingMessage): PendingThreadFeedEntry => ({ + type: "message", + id: pendingMessage.messageId, + createdAt: pendingMessage.createdAt, + pendingMessage, + message: { + id: pendingMessage.messageId, + role: "user", + text: pendingMessage.text, + createdAt: pendingMessage.createdAt, + updatedAt: pendingMessage.createdAt, + turnId: null, + streaming: false, + }, + }), + ), + ]; +} diff --git a/apps/mobile/src/features/threads/queued-message-icon.tsx b/apps/mobile/src/features/threads/queued-message-icon.tsx new file mode 100644 index 0000000000..c6eacf658f --- /dev/null +++ b/apps/mobile/src/features/threads/queued-message-icon.tsx @@ -0,0 +1,15 @@ +import { SymbolView } from "../../components/AppSymbol"; + +/** Outbox state is independent of the agent's status, so both stay visible. */ +export function QueuedMessageIcon({ selected = false }: { readonly selected?: boolean }) { + return ( + + ); +} diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index 606a5d472b..6df66fae25 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -8,7 +8,7 @@ import type { EnvironmentMachineKind } from "@t3tools/contracts"; import type { MenuAction } from "@react-native-menu/menu"; import { SymbolView } from "../../components/AppSymbol"; import { memo, useCallback, useMemo, type ComponentProps } from "react"; -import { Pressable, useWindowDimensions, View } from "react-native"; +import { Platform, Pressable, useWindowDimensions, View } from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import Svg, { Circle, Path } from "react-native-svg"; @@ -22,11 +22,16 @@ import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics"; import { relativeTime } from "../../lib/time"; import { themeColorWithAlpha } from "../../lib/mobileTheme"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; +import { + PENDING_TASK_DELIVERY_PRESENTATION, + type PendingTaskDelivery, +} from "../../state/pending-new-tasks-model"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { useThreadPr, type ThreadPrPresentation } from "../../state/use-thread-pr"; import type { HomeGroupDisplayAction } from "../home/homeListItems"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; +import { QueuedMessageIcon } from "./queued-message-icon"; import { resolveThreadStatus } from "./threadPresentation"; import { ThreadSearchMatchExcerpt } from "./thread-search-match"; @@ -270,14 +275,23 @@ const PENDING_TASK_MENU_ACTIONS: MenuAction[] = [ { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ]; +const DRAFT_TASK_MENU_ACTIONS: MenuAction[] = [ + { id: "delete", title: "Discard", image: "trash", attributes: { destructive: true } }, +]; + /** - * A queued new task waiting in the outbox for its environment to reconnect. - * Tapping reopens the new-task composer with everything prefilled; the row - * disappears once the task is delivered and the real thread arrives. + * Unsent work: a task queued in the outbox for its environment to reconnect, + * or a draft still sitting in the project's new-task composer. Tapping + * reopens the composer with everything prefilled; the row disappears once + * the work is sent and the real thread arrives. The two kinds differ in what + * happens next, so the pill and icon say which one this is: a queued task + * sends itself, a draft waits for the user. */ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { readonly variant: ThreadListVariant; readonly pendingTask: PendingNewTask; + /** A queued task's next step from this device; null for drafts. */ + readonly delivery: PendingTaskDelivery | null; readonly environmentLabel: string | null; readonly environmentMachine?: EnvironmentMachineKind; readonly isLast: boolean; @@ -289,10 +303,19 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { const mutedColor = useUniwindTheme()["--color-foreground-muted"]; const { pendingTask, onSelectPendingTask, onDeletePendingTask } = props; - const timestamp = relativeTime(pendingTask.message.createdAt); - const subtitleParts = [props.environmentLabel, pendingTask.creation.branch].filter( - (part): part is string => Boolean(part), - ); + const isDraft = pendingTask.kind === "draft"; + const timestamp = isDraft ? null : relativeTime(pendingTask.createdAt); + const delivery = + props.delivery ?? (!isDraft && pendingTask.message.deliveryHold ? "held" : "offline"); + const deliveryPresentation = PENDING_TASK_DELIVERY_PRESENTATION[delivery]; + // The pill only has room for one word, so what happens next goes in the + // subtitle: a queued task sends itself, a draft waits for the user. A held + // task's pill already says so. + const subtitleParts = [ + isDraft || delivery === "held" ? null : deliveryPresentation.label, + props.environmentLabel, + pendingTask.branch, + ].filter((part): part is string => Boolean(part)); const handleMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { @@ -302,10 +325,14 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { [onDeletePendingTask, onSelectPendingTask, pendingTask], ); - const statusPill = ( + const statusPill = isDraft ? ( + + Draft + + ) : ( - {pendingTask.message.deliveryHold ? "Held" : "Pending"} + {delivery === "held" ? "Held" : "Pending"} ); @@ -314,7 +341,7 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { subtitleParts.length > 0 ? ( ) : null; + const accessibilityHint = isDraft + ? "Opens the draft in the new task composer" + : deliveryPresentation.accessibilityHint; + const rowContent = compact ? ( {statusPill} - {timestamp} + {timestamp !== null ? ( + {timestamp} + ) : null} ) : ( {statusPill} - - {timestamp} - + {timestamp !== null ? ( + + {timestamp} + + ) : null} {subtitleRow} @@ -403,7 +438,7 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { return ( @@ -425,6 +460,8 @@ export const ThreadListRow = memo(function ThreadListRow(props: { readonly environmentLabel: string | null; readonly environmentMachine?: EnvironmentMachineKind; readonly projectCwd: string | null; + /** A message for this thread is waiting in the outbox. */ + readonly hasQueuedMessages?: boolean; readonly searchMatch?: EnvironmentThreadSearchMatch; readonly searchQuery?: string; readonly isLast: boolean; @@ -435,6 +472,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { readonly onSelectThread: (thread: EnvironmentThreadShell) => void; readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; + readonly onNewThreadOnBranch: (thread: EnvironmentThreadShell) => void; readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void; readonly titleRegenerationSupported: boolean; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; @@ -458,14 +496,26 @@ export const ThreadListRow = memo(function ThreadListRow(props: { const selectedBackgroundColor = useUniwindTheme()["--color-user-bubble"]; const selectedForegroundColor = useUniwindTheme()["--color-user-bubble-foreground"]; - const { thread, onSelectThread, onArchiveThread, onDeleteThread, onRegenerateThreadTitle } = - props; + const { + thread, + onSelectThread, + onArchiveThread, + onDeleteThread, + onRegenerateThreadTitle, + onNewThreadOnBranch, + } = props; const status = resolveThreadStatus(thread); const pr = useThreadPr(thread, props.projectCwd); const timestamp = relativeTime( thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, ); - const threadAccessibilityLabel = pr ? `${thread.title}, ${pr.accessibilityLabel}` : thread.title; + const threadAccessibilityLabel = [ + thread.title, + pr?.accessibilityLabel, + props.hasQueuedMessages ? "messages queued to send" : null, + ] + .filter(Boolean) + .join(", "); const subtitleParts = [props.environmentLabel, thread.branch].filter((part): part is string => Boolean(part), ); @@ -491,6 +541,16 @@ export const ThreadListRow = memo(function ThreadListRow(props: { ); const menuActions = useMemo( () => [ + ...(thread.branch + ? [ + { + id: "new-thread-on-branch", + title: + Platform.OS === "ios" ? "New thread on branch" : `New thread on ${thread.branch}`, + image: "square.and.pencil", + }, + ] + : []), THREAD_ROW_MENU_ACTIONS[0]!, ...buildThreadTitleRegenerationMenuItems({ supported: props.titleRegenerationSupported, @@ -498,7 +558,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { }), THREAD_ROW_MENU_ACTIONS[1]!, ], - [props.titleRegenerationSupported, thread.titleRegeneration], + [props.titleRegenerationSupported, thread.branch, thread.titleRegeneration], ); const primaryAction = useMemo( () => ({ @@ -511,11 +571,12 @@ export const ThreadListRow = memo(function ThreadListRow(props: { ); const handleMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { + if (nativeEvent.event === "new-thread-on-branch") onNewThreadOnBranch(thread); if (nativeEvent.event === "archive") handleArchive(); if (nativeEvent.event === "regenerate-title") handleRegenerateTitle(); if (nativeEvent.event === "delete") handleDelete(); }, - [handleArchive, handleDelete, handleRegenerateTitle], + [handleArchive, handleDelete, handleRegenerateTitle, onNewThreadOnBranch, thread], ); const statusPill = effectiveStatus ? ( @@ -596,6 +657,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { {thread.title} + {props.hasQueuedMessages ? : null} {statusPill} {timestamp} + {props.hasQueuedMessages ? : null} {statusPill} { @@ -231,7 +250,7 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props {props.project ? ( {projectTitle} - - {pendingTask.message.deliveryHold ? "Held" : "Queued"} - + {isDraft ? ( + + + Draft + + ) : ( + {deliveryPresentation.label} + )} {/* One line, unlike the two an active row allows: a queued title is derived from the whole prompt rather than written as a title, so the @@ -279,15 +308,19 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props return ( <> {props.showPendingDivider ? ( - + ) : null} void; readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; + readonly onNewThreadOnBranch: (thread: EnvironmentThreadShell) => void; readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void; readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise; readonly onSnoozeThread: (thread: EnvironmentThreadShell, snoozedUntil: string) => void; @@ -376,7 +412,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly titleRegenerationSupported: boolean; /** Server supports reordering this card's section. */ readonly reorderSupported?: boolean; - readonly onMoveThread?: (thread: EnvironmentThreadShell, direction: "up" | "down") => void; + readonly onMoveThread?: ( + thread: EnvironmentThreadShell, + direction: ThreadMoveDestination, + ) => void; /** Position flags for the card's section so the menu disables the move that would fall off the end of the list. */ readonly canMoveUp?: boolean; @@ -397,6 +436,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onSelectThread, onDeleteThread, onRegenerateThreadTitle, + onNewThreadOnBranch, onSettleThread, onSnoozeThread, onUnsnoozeThread, @@ -418,6 +458,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const pinTintColor = useUniwindTheme()["--color-foreground-muted"]; const sidebarPane = props.pane === "sidebar"; const selected = props.selected === true; + // The provider badge's border blends into the row's own surface, which + // differs by pane and (for the sidebar pane) selection: the sidebar row + // background becomes the selected fill or the drawer surface, while the + // flat "screen" pane rows always sit on the screen background. + const providerIconSurfaceColor = sidebarPane + ? selected + ? selectedBackgroundColor + : drawerColor + : screenColor; const status = resolveThreadListV2Status(thread); const statusLabel = STATUS_LABEL_BY_STATUS[status]; @@ -489,8 +538,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { // hides the card until wake with the pin intact.) const arrangementMenuItems = useMemo( () => [ - ...(variant === "card" && props.reorderSupported === true + ...(props.reorderSupported === true ? [ + { id: "arrange", title: "Arrange threads…", image: "line.3.horizontal" }, { id: "move-up", title: "Move up", @@ -557,11 +607,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const slimMenuActions = useMemo( () => [ SLIM_MENU_ACTIONS[0]!, - ...(thread.pinnedAt != null ? arrangementMenuItems : []), + ...arrangementMenuItems.filter( + (action) => action.id !== "move-up" && action.id !== "move-down", + ), ...titleRegenerationMenuItems, SLIM_MENU_ACTIONS[1]!, ], - [arrangementMenuItems, thread.pinnedAt, titleRegenerationMenuItems], + [arrangementMenuItems, titleRegenerationMenuItems], ); const snoozedMenuActions = useMemo( () => [SNOOZED_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, SNOOZED_MENU_ACTIONS[1]!], @@ -578,11 +630,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ); const handleMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { + if (nativeEvent.event === "new-thread-on-branch") onNewThreadOnBranch(thread); if (nativeEvent.event === "settle") handleSettle(); if (nativeEvent.event === "unsettle") handleUnsettle(); if (nativeEvent.event === "unsnooze") handleUnsnooze(); if (nativeEvent.event === "pin") handlePin(); if (nativeEvent.event === "unpin") handleUnpin(); + if (nativeEvent.event === "arrange") appAtomRegistry.set(threadArrangementOpenAtom, true); if (nativeEvent.event === "move-up") handleMoveUp(); if (nativeEvent.event === "move-down") handleMoveDown(); if (nativeEvent.event === "archive") handleArchive(); @@ -600,6 +654,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { } }, [ + onNewThreadOnBranch, + thread, handleArchive, handleDelete, handleRegenerateTitle, @@ -645,7 +701,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { accessibilityLabel: `Settle ${thread.title}`, icon: "checkmark" as const, label: "Settle", - dismissOnPress: true as const, onPress: handleSettle, }; }, [ @@ -701,6 +756,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { > {props.projectTitle ?? props.project?.title ?? ""} + {props.hasQueuedMessages ? : null} {pinnedRow ? ( ) : null} @@ -806,10 +862,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { #{pr.label} ) : null} - {props.providerDriver ? ( - - - + {props.providerInstance ? ( + ) : null} @@ -819,7 +880,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { variant === "card" ? ( { @@ -859,7 +922,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ) : ( ) : null} + {props.hasQueuedMessages ? : null} {(close) => ( diff --git a/apps/mobile/src/features/threads/thread-provider-instance.test.ts b/apps/mobile/src/features/threads/thread-provider-instance.test.ts new file mode 100644 index 0000000000..2afef97590 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-provider-instance.test.ts @@ -0,0 +1,98 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { + EnvironmentId, + ProjectId, + ProviderInstanceId, + ThreadId, + type ServerConfig, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { resolveThreadProviderInstance } from "./thread-provider-instance"; + +function makeConfig( + providers: ReadonlyArray<{ + readonly instanceId: string; + readonly driver: string; + readonly displayName?: string; + readonly accentColor?: string; + }>, +): ServerConfig { + return { providers } as unknown as ServerConfig; +} + +function makeThread(environmentId: EnvironmentId, instanceId: string): EnvironmentThreadShell { + return { + environmentId, + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make(instanceId), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + } as unknown as EnvironmentThreadShell; +} + +describe("resolveThreadProviderInstance", () => { + it("resolves two environments with the same default instance id independently", () => { + const environmentA = EnvironmentId.make("environment-a"); + const environmentB = EnvironmentId.make("environment-b"); + const serverConfigs = new Map([ + [ + environmentA, + makeConfig([{ instanceId: "codex", driver: "codex", accentColor: "#ff8800" }]), + ], + [environmentB, makeConfig([{ instanceId: "codex", driver: "codex" }])], + ]); + + const threadA = makeThread(environmentA, "codex"); + const threadB = makeThread(environmentB, "codex"); + + expect(resolveThreadProviderInstance(serverConfigs, threadA)?.accentColor).toBe("#ff8800"); + expect(resolveThreadProviderInstance(serverConfigs, threadB)?.accentColor).toBeUndefined(); + }); + + it("labels a custom instance by its id so its initials differ from the default", () => { + const environmentId = EnvironmentId.make("environment-a"); + const serverConfigs = new Map([ + [ + environmentId, + makeConfig([ + { instanceId: "codex", driver: "codex", displayName: "Codex" }, + { instanceId: "codex_personal", driver: "codex", displayName: "Codex" }, + ]), + ], + ]); + + expect( + resolveThreadProviderInstance(serverConfigs, makeThread(environmentId, "codex"))?.displayName, + ).toBe("Codex"); + expect( + resolveThreadProviderInstance(serverConfigs, makeThread(environmentId, "codex_personal")) + ?.displayName, + ).toBe("Codex Personal"); + }); + + it("hides the badge for a single instance with no accent color", () => { + const environmentId = EnvironmentId.make("environment-a"); + const serverConfigs = new Map([ + [environmentId, makeConfig([{ instanceId: "codex", driver: "codex" }])], + ]); + const thread = makeThread(environmentId, "codex"); + + expect(resolveThreadProviderInstance(serverConfigs, thread)?.showBadge).toBe(false); + }); +}); diff --git a/apps/mobile/src/features/threads/thread-provider-instance.ts b/apps/mobile/src/features/threads/thread-provider-instance.ts new file mode 100644 index 0000000000..29dbe8828b --- /dev/null +++ b/apps/mobile/src/features/threads/thread-provider-instance.ts @@ -0,0 +1,42 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { + normalizeProviderAccentColor, + resolveProviderInstanceDisplayName, + shouldShowInstanceBadge, +} from "@t3tools/client-runtime/state/provider-instance-display"; +import type { EnvironmentId, ProviderDriverKind, ServerConfig } from "@t3tools/contracts"; + +/** What a thread row needs to draw the provider glyph and its account badge. */ +export interface ThreadRowProviderInstance { + readonly driverKind: ProviderDriverKind; + readonly displayName: string; + readonly accentColor?: string | undefined; + readonly showBadge: boolean; +} + +/** + * Resolve the provider instance a thread runs on, scoped to the thread's own + * environment: default instance ids are the driver slug, so the same id + * names a different account on every server. + */ +export function resolveThreadProviderInstance( + serverConfigs: ReadonlyMap, + thread: EnvironmentThreadShell, +): ThreadRowProviderInstance | null { + const providers = serverConfigs.get(thread.environmentId)?.providers ?? []; + const instanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; + const snapshot = providers.find((provider) => provider.instanceId === instanceId); + if (!snapshot) return null; + const entry = { + driverKind: snapshot.driver, + displayName: resolveProviderInstanceDisplayName(snapshot), + accentColor: normalizeProviderAccentColor(snapshot.accentColor), + }; + return { + ...entry, + showBadge: shouldShowInstanceBadge( + entry, + providers.map((provider) => ({ driverKind: provider.driver })), + ), + }; +} diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 7b537c3b41..5ecd753bec 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -139,6 +139,7 @@ export function ThreadDisclosureChevron(props: { } function ShimmerWorkContent(props: { + readonly textClassName?: string; readonly compact?: boolean; readonly environmentId?: EnvironmentId; readonly highlighted: boolean; @@ -180,6 +181,7 @@ function ShimmerWorkContent(props: { "min-w-0 shrink", props.compact ? "text-2xs" : "text-xs", props.highlighted ? "text-foreground" : "text-foreground-muted", + props.textClassName, )} numberOfLines={1} onTextLayout={props.onTextLayout} @@ -191,6 +193,8 @@ function ShimmerWorkContent(props: { } export function ShimmeringWorkContent(props: { + readonly className?: string; + readonly textClassName?: string; /** Secondary line: no icon slot, caption size. */ readonly compact?: boolean; readonly environmentId?: EnvironmentId; @@ -263,10 +267,11 @@ export function ShimmeringWorkContent(props: { return ( setAvailableWidth(event.nativeEvent.layout.width)} > { + // Header, pinned row, Active header, two active rows. Geometry stays fixed for hit testing. + const offsets = [0, 48, 120, 168, 240]; + const shifts = (source: number, insertion: number) => + offsets.map((offset) => threadDragGapOffset(offset, source, 72, insertion)); + + it("moves the Active header and intervening rows up when unpinning", () => { + expect(shifts(48, 312)).toEqual([0, 0, -72, -72, -72]); + }); + it("opens a full gap below the Pinned header when pinning", () => { + expect(shifts(240, 48)).toEqual([0, 72, 72, 72, 0]); + }); + it("leaves the source gap in place for cancellation or its current destination", () => { + expect(shifts(168, 168)).toEqual([0, 0, 0, 0, 0]); + expect(shifts(168, 240)).toEqual([0, 0, 0, 0, 0]); + }); + it("moves only crossed rows for an adjacent reorder", () => { + expect(shifts(168, 312)).toEqual([0, 0, 0, 0, -72]); + expect(shifts(240, 168)).toEqual([0, 0, 0, 72, 0]); + }); +}); + +describe("drag action labels", () => { + it("names the action for each destination instead of its section", () => { + expect(threadDragAction("active", "pinned")).toBe("Pin"); + expect(threadDragAction("pinned", "active")).toBe("Unpin"); + expect(threadDragAction("settled", "active")).toBe("Unsettle"); + expect(threadDragAction("snoozed", "active")).toBe("Unsnooze"); + expect(threadDragAction("active", "settled")).toBe("Settle"); + expect(threadDragAction("pinned", "settled")).toBe("Settle"); + expect(threadDragAction("active", "active")).toBe("Reorder"); + }); + it("does not offer a parked-section reorder or snooze without a wake time", () => { + expect(threadDragAction("settled", "settled")).toBeNull(); + expect(threadDragAction("active", "snoozed")).toBeNull(); + expect( + threadOrderAfterMove(["a", "b"], "a", { + section: "settled", + targetId: null, + placement: "before", + }), + ).toBeNull(); + }); +}); diff --git a/apps/mobile/src/features/threads/threadDragGap.ts b/apps/mobile/src/features/threads/threadDragGap.ts new file mode 100644 index 0000000000..eb65d5911c --- /dev/null +++ b/apps/mobile/src/features/threads/threadDragGap.ts @@ -0,0 +1,13 @@ +/** Keep hit testing in the original layout while rows make room for the lifted item. */ +export function threadDragGapOffset( + rowOffset: number, + sourceOffset: number, + sourceHeight: number, + insertionOffset: number, +): number { + if (rowOffset === sourceOffset) return 0; + if (insertionOffset <= sourceOffset) { + return rowOffset >= insertionOffset && rowOffset < sourceOffset ? sourceHeight : 0; + } + return rowOffset > sourceOffset && rowOffset < insertionOffset ? -sourceHeight : 0; +} diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index d50a51b0f7..0ea460a63d 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -2,6 +2,8 @@ import { planPinnedMove } from "@t3tools/client-runtime/state/thread-sort"; import { createPendingThreadOrder, createThreadMovePlanner, + threadOrderAfterMove, + threadDropLifecycle, reconcilePendingThreadOrder, type PendingThreadOrder, } from "./threadOrder"; @@ -273,6 +275,48 @@ describe("formatThreadPlanProgressLabel", () => { }); }); +describe("queued messages keep a settled thread active", () => { + const threads = [ + makeThread({ id: ThreadId.make("active"), title: "Active" }), + makeThread({ id: ThreadId.make("settled"), title: "Settled", settledOverride: "settled" }), + makeThread({ + id: ThreadId.make("settled-queued"), + title: "Settled with outbox", + settledOverride: "settled", + }), + ]; + const queuedThreadKeys = new Set([`${environmentId}:settled-queued`]); + + it("lists the thread in the active block instead of the settled shelf", () => { + const layout = buildThreadListV2Items({ + threads, + environmentId: null, + searchQuery: "", + now: NOW, + queuedThreadKeys, + }); + expect(layout.items.map((item) => [item.thread.id, item.variant] as const)).toEqual([ + ["active", "card"], + ["settled-queued", "card"], + ["settled", "slim"], + ]); + expect(layout.settledCount).toBe(1); + }); + + it("includes it in the reorderable active section", () => { + expect( + getThreadListV2OrderedSection({ threads, section: "active", now: NOW, queuedThreadKeys }).map( + (thread) => thread.id, + ), + ).toEqual(["active", "settled-queued"]); + expect( + getThreadListV2OrderedSection({ threads, section: "active", now: NOW }).map( + (thread) => thread.id, + ), + ).toEqual(["active"]); + }); +}); + describe("resolveThreadListV2SwipeActions", () => { it("offers settle and snooze for an active snoozable thread", () => { expect( @@ -973,7 +1017,22 @@ describe("buildThreadListV2Items settled paging", () => { }); function makePendingTask(id: string): PendingNewTask { + const creation = { + projectId: ProjectId.make("project-1"), + workspaceMode: "worktree" as const, + branch: null, + worktreePath: null, + }; return { + kind: "pending", + key: `pending-task:${id}`, + environmentId, + projectId: creation.projectId, + projectTitle: undefined, + projectCwd: undefined, + branch: null, + title: id, + createdAt: NOW, message: { environmentId, threadId: ThreadId.make(`thread-${id}`), @@ -982,20 +1041,9 @@ function makePendingTask(id: string): PendingNewTask { text: id, attachments: [], createdAt: NOW, - creation: { - projectId: ProjectId.make("project-1"), - workspaceMode: "worktree", - branch: null, - worktreePath: null, - }, - }, - creation: { - projectId: ProjectId.make("project-1"), - workspaceMode: "worktree", - branch: null, - worktreePath: null, + creation, }, - title: id, + creation, }; } @@ -1337,3 +1385,202 @@ describe("mobile move availability", () => { expect(assignments![0]!.orderKey < "dd").toBe(true); }); }); + +describe("thread drag destinations", () => { + it("moves across multiple rows while keeping hidden anchors in place", () => { + expect( + threadOrderAfterMove(["a", "hidden", "b", "c"], "c", { + targetId: "a", + placement: "before", + }), + ).toEqual(["c", "a", "hidden", "b"]); + expect( + threadOrderAfterMove(["a", "hidden", "b", "c"], "a", { + targetId: "b", + placement: "after", + }), + ).toEqual(["hidden", "b", "a", "c"]); + }); + + it("rejects missing, self, and unchanged destinations", () => { + for (const targetId of ["missing", "a", "b"]) { + expect( + threadOrderAfterMove(["a", "b", "c"], "a", { + targetId, + placement: "before", + }), + ).toBeNull(); + } + expect(threadOrderAfterMove(["a", "b"], "missing", "down")).toBeNull(); + }); + + it.each(["active", "pinned"] as const)( + "persists a dropped %s row and holds its order until confirmed", + (section) => { + const ordered = ["a", "b", "c", "d"].map((id) => + makeThread({ + id: ThreadId.make(id), + title: id, + pinnedAt: section === "pinned" ? NOW : null, + }), + ); + const ids = ordered.map((row) => `${row.environmentId}:${row.id}`); + const direction = { targetId: ids[0]!, placement: "before" as const }; + const assignments = createThreadMovePlanner({ + ordered, + section, + reorderableEnvironmentIds: new Set([environmentId]), + })(ids[3]!, direction)!; + const pending = createPendingThreadOrder({ + section, + ordered, + movedId: ids[3]!, + direction, + assignments, + }); + expect(pending.orderedIds).toEqual([ids[3], ids[0], ids[1], ids[2]]); + const confirmed = ordered.map((row) => ({ + ...row, + [section === "pinned" ? "pinOrderKey" : "activeOrderKey"]: assignments.find( + (a) => a.id === `${row.environmentId}:${row.id}`, + )!.orderKey, + })); + expect( + getThreadListV2OrderedSection({ threads: confirmed, section, now: NOW }).map( + (row) => `${row.environmentId}:${row.id}`, + ), + ).toEqual(pending.orderedIds); + expect( + reconcilePendingThreadOrder({ ...pending, commandsComplete: true }, confirmed), + ).toBeNull(); + }, + ); + + it("refuses a drop that would need to rewrite an old server's keyless row", () => { + const old = EnvironmentId.make("old-server"); + const ordered = [environmentId, old, environmentId].map((env, index) => + makeThread({ + id: ThreadId.make(String(index)), + title: String(index), + environmentId: env, + }), + ); + expect( + createThreadMovePlanner({ + ordered, + section: "active", + reorderableEnvironmentIds: new Set([environmentId]), + })(`${environmentId}:2`, { targetId: `${environmentId}:0`, placement: "before" }), + ).toBeNull(); + }); +}); + +it("allows a long drop past an old server even when both adjacent moves fail", () => { + const old = EnvironmentId.make("old-server"); + const ordered = [ + makeThread({ id: ThreadId.make("a"), title: "a" }), + makeThread({ id: ThreadId.make("b"), title: "b", environmentId: old }), + makeThread({ id: ThreadId.make("c"), title: "c", activeOrderKey: "h" }), + makeThread({ id: ThreadId.make("d"), title: "d", activeOrderKey: "p" }), + ]; + const planner = createThreadMovePlanner({ + ordered, + section: "active", + reorderableEnvironmentIds: new Set([environmentId]), + }); + const movedId = `${environmentId}:a`; + expect(planner(movedId, "up")).toBeNull(); + expect(planner(movedId, "down")).toBeNull(); + const assignments = planner(movedId, { targetId: `${environmentId}:d`, placement: "after" }); + expect(assignments).toHaveLength(1); + expect(assignments![0]!.id).toBe(movedId); + expect(assignments![0]!.orderKey > "p").toBe(true); +}); + +describe("cross-section thread drops", () => { + it.each(["pinned", "active"] as const)("inserts into an empty %s section", (section) => { + const thread = makeThread({ id: ThreadId.make("source"), title: "source" }); + const id = `${thread.environmentId}:${thread.id}`; + const destination = { section, targetId: null, placement: "before" as const }; + expect(threadOrderAfterMove([], id, destination)).toEqual([id]); + const plan = createThreadMovePlanner({ + ordered: [], + allThreads: [thread], + section, + reorderableEnvironmentIds: new Set([environmentId]), + })(id, destination); + expect(plan).toHaveLength(1); + expect(plan![0]!.id).toBe(id); + }); + it("places an incoming row between existing anchors without rewriting them", () => { + const a = makeThread({ id: ThreadId.make("a"), title: "a", pinOrderKey: "h" }); + const b = makeThread({ id: ThreadId.make("b"), title: "b", pinOrderKey: "z" }); + const source = makeThread({ id: ThreadId.make("source"), title: "source" }); + const id = `${environmentId}:source`; + const destination = { + section: "pinned" as const, + targetId: `${environmentId}:b`, + placement: "before" as const, + }; + const plan = createThreadMovePlanner({ + ordered: [a, b], + allThreads: [a, b, source], + section: "pinned", + reorderableEnvironmentIds: new Set([environmentId]), + })(id, destination); + expect(plan).toHaveLength(1); + expect(plan![0]!.orderKey > "h" && plan![0]!.orderKey < "z").toBe(true); + expect( + threadOrderAfterMove([`${environmentId}:a`, `${environmentId}:b`], id, destination), + ).toEqual([`${environmentId}:a`, id, `${environmentId}:b`]); + }); + it("rejects removed targets and unsupported incoming sources", () => { + expect( + threadOrderAfterMove(["a"], "source", { + section: "active", + targetId: "gone", + placement: "before", + }), + ).toBeNull(); + const source = makeThread({ id: ThreadId.make("source"), title: "source" }); + expect( + createThreadMovePlanner({ + ordered: [], + allThreads: [source], + section: "active", + reorderableEnvironmentIds: new Set(), + })(`${environmentId}:source`, { section: "active", targetId: null, placement: "before" }), + ).toBeNull(); + }); + it("clears pinning, settlement and snooze when returning a parked thread to Active", () => { + const thread = makeThread({ + id: ThreadId.make("parked"), + title: "parked", + pinnedAt: NOW, + settledOverride: "settled", + snoozedAt: NOW, + snoozedUntil: "2099-01-01T00:00:00.000Z", + }); + expect(threadDropLifecycle(thread, "active", NOW)).toEqual({ + pin: false, + unpin: true, + unsettle: true, + unsnooze: true, + }); + expect(threadDropLifecycle(thread, "pinned", NOW)).toEqual({ + pin: true, + unpin: false, + unsettle: false, + unsnooze: false, + }); + }); + it("does not send lifecycle commands for an ordinary Active reorder", () => { + expect( + threadDropLifecycle( + makeThread({ id: ThreadId.make("active"), title: "active" }), + "active", + NOW, + ), + ).toEqual({ pin: false, unpin: false, unsettle: false, unsnooze: false }); + }); +}); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 0914b558d8..aa774cf00b 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -211,12 +211,14 @@ export function getThreadListV2OrderedSection(input: { readonly now: string; readonly settlementEnvironmentIds?: ReadonlySet; readonly snoozeEnvironmentIds?: ReadonlySet; + readonly queuedThreadKeys?: ReadonlySet; }): EnvironmentThreadShell[] { const threads = input.threads.filter((thread) => { if (thread.archivedAt !== null) return false; if ( (input.settlementEnvironmentIds?.has(thread.environmentId) ?? true) && - thread.settledOverride === "settled" + thread.settledOverride === "settled" && + input.queuedThreadKeys?.has(`${thread.environmentId}:${thread.id}`) !== true ) { return false; } @@ -334,7 +336,7 @@ export function buildThreadListV2ListItems(input: { const pendingItems = input.pendingTasks.map( (pendingTask, index): ThreadListV2ListItem => ({ type: "v2-pending", - key: `v2-pending:${pendingTask.message.messageId}`, + key: `v2-${pendingTask.key}`, pendingTask, showPendingDivider: index === 0, }), @@ -399,6 +401,10 @@ export function buildThreadListV2Items(input: { /** The selected thread remains visible on an otherwise collapsed shelf so a split-view detail can never lose its navigation row. */ readonly selectedThreadKey?: string | null; + /** Thread keys (`environmentId:threadId`) with a message waiting in the + outbox. Such a thread has work the user is waiting on, so it stays in + the active block even when the server has settled it. */ + readonly queuedThreadKeys?: ReadonlySet; }): ThreadListV2Layout { const now = input.now; const pending = @@ -454,7 +460,9 @@ export function buildThreadListV2Items(input: { } continue; } - if (supportsSettlement && thread.settledOverride === "settled") { + const hasQueuedMessages = + input.queuedThreadKeys?.has(`${thread.environmentId}:${thread.id}`) === true; + if (supportsSettlement && thread.settledOverride === "settled" && !hasQueuedMessages) { settled.push(thread); } else if (thread.pinnedAt != null) { pinned.push(thread); diff --git a/apps/mobile/src/features/threads/threadOrder.ts b/apps/mobile/src/features/threads/threadOrder.ts index 2b722e6942..c71c1040bf 100644 --- a/apps/mobile/src/features/threads/threadOrder.ts +++ b/apps/mobile/src/features/threads/threadOrder.ts @@ -1,7 +1,47 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { planPinnedMove } from "@t3tools/client-runtime/state/thread-sort"; +import { planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort"; +import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentId } from "@t3tools/contracts"; +export type ThreadMoveDestination = + | "up" + | "down" + | { + readonly targetId: string | null; + readonly section?: "pinned" | "active" | "settled"; + readonly placement: "before" | "after"; + }; + +/** Resolve against stable row identities, including rows hidden by a filter. */ +export function threadOrderAfterMove( + orderedIds: readonly string[], + movedId: string, + destination: ThreadMoveDestination, +): string[] | null { + if (typeof destination === "object" && destination.section === "settled") return null; + const from = orderedIds.indexOf(movedId); + if (from < 0 && (typeof destination === "string" || destination.section === undefined)) + return null; + const result = orderedIds.filter((id) => id !== movedId); + let to: number; + if (typeof destination === "string") { + to = from + (destination === "up" ? -1 : 1); + if (to < 0 || to >= orderedIds.length) return null; + } else { + if (destination.targetId === null) { + if (destination.section === undefined) return null; + to = destination.placement === "before" ? 0 : result.length; + } else { + const target = result.indexOf(destination.targetId); + if (target < 0) return null; + to = target + (destination.placement === "after" ? 1 : 0); + } + } + if (to === from) return null; + result.splice(to, 0, movedId); + return result; +} + type OrderRow = Pick< EnvironmentThreadShell, | "id" @@ -49,13 +89,15 @@ export function createThreadMovePlanner(input: { ]), ); const writableIds = new Set( - input.ordered + (input.allThreads ?? input.ordered) .filter((row) => input.reorderableEnvironmentIds.has(row.environmentId)) .map(rowId), ); - return (movedId: string, direction: "up" | "down") => { + return (movedId: string, direction: ThreadMoveDestination) => { if (!writableIds.has(movedId)) return null; - const assignments = planPinnedMove({ orderedIds, keysById, movedId, direction }); + const nextIds = threadOrderAfterMove(orderedIds, movedId, direction); + if (nextIds === null) return null; + const assignments = planPinnedReorder({ orderedIds: nextIds, keysById, movedId }); return assignments === null || assignments.length === 0 || assignments.some((assignment) => !writableIds.has(assignment.id)) @@ -68,13 +110,11 @@ export function createPendingThreadOrder(input: { readonly section: PendingThreadOrder["section"]; readonly ordered: readonly OrderRow[]; readonly movedId: string; - readonly direction: "up" | "down"; + readonly direction: ThreadMoveDestination; readonly assignments: readonly { readonly id: string; readonly orderKey: string }[]; }): PendingThreadOrder { - const orderedIds = input.ordered.map(rowId); - const from = orderedIds.indexOf(input.movedId); - orderedIds.splice(from, 1); - orderedIds.splice(from + (input.direction === "up" ? -1 : 1), 0, input.movedId); + const orderedIds = threadOrderAfterMove(input.ordered.map(rowId), input.movedId, input.direction); + if (orderedIds === null) throw new Error("Cannot begin an invalid thread move"); return { section: input.section, orderedIds, @@ -118,3 +158,32 @@ export function applyPendingThreadOrder( (left, right) => (rank.get(rowId(left)) ?? Infinity) - (rank.get(rowId(right)) ?? Infinity), ); } + +/** Match desktop re-entry: a pin wakes the thread on the server; Active clears + * each underlying parked state before assigning its destination order key. */ +export function threadDropLifecycle( + thread: EnvironmentThreadShell, + section: "pinned" | "active", + now: string, +) { + if (section === "pinned") return { pin: true, unpin: false, unsettle: false, unsnooze: false }; + return { + pin: false, + unpin: thread.pinnedAt != null, + unsettle: thread.settledOverride === "settled", + unsnooze: effectiveSnoozed(thread, { now }), + }; +} + +export type ThreadDragSection = "pinned" | "active" | "snoozed" | "settled"; + +/** The action shown during hover describes the lifecycle change made on drop. */ +export function threadDragAction(source: ThreadDragSection, destination: ThreadDragSection) { + if (destination === "snoozed") return null; + if (destination === "settled") return source === "settled" ? null : "Settle"; + if (source === destination) return "Reorder"; + if (destination === "pinned") return "Pin"; + if (source === "pinned") return "Unpin"; + if (source === "settled") return "Unsettle"; + return "Unsnooze"; +} diff --git a/apps/mobile/src/features/threads/threadPresentation.test.ts b/apps/mobile/src/features/threads/threadPresentation.test.ts index cb80444407..eaa70b7273 100644 --- a/apps/mobile/src/features/threads/threadPresentation.test.ts +++ b/apps/mobile/src/features/threads/threadPresentation.test.ts @@ -57,20 +57,31 @@ describe("resolveThreadStatus", () => { ).toBeNull(); }); - it.each(["running", "starting"] as const)( - "uses upstream sky while the session is %s", - (status) => { - expect( - resolveThreadStatus({ - ...baseThread, - session: status === "running" ? { status, activeTurnId: "turn-1" } : { status }, - } as EnvironmentThreadShell), - ).toMatchObject({ - pillClassName: "bg-adaptive-sky-500-a12-a16", - textClassName: "text-adaptive-sky-700-300", - iconColor: "#0a84ff", - pulse: true, - }); - }, - ); + it("uses upstream's Working shade while a turn runs", () => { + expect( + resolveThreadStatus({ + ...baseThread, + session: { status: "running", activeTurnId: "turn-1" }, + } as EnvironmentThreadShell), + ).toMatchObject({ + pillClassName: "bg-primary/10", + textClassName: "text-adaptive-sky-600-400", + iconColor: "#0a84ff", + pulse: true, + }); + }); + + it("uses sky while the session is starting", () => { + expect( + resolveThreadStatus({ + ...baseThread, + session: { status: "starting" }, + } as EnvironmentThreadShell), + ).toMatchObject({ + pillClassName: "bg-adaptive-sky-500-a12-a16", + textClassName: "text-adaptive-sky-700-300", + iconColor: "#0a84ff", + pulse: true, + }); + }); }); diff --git a/apps/mobile/src/features/threads/threadPresentation.ts b/apps/mobile/src/features/threads/threadPresentation.ts index 16467ef5de..cbfd8c6442 100644 --- a/apps/mobile/src/features/threads/threadPresentation.ts +++ b/apps/mobile/src/features/threads/threadPresentation.ts @@ -67,8 +67,8 @@ export function resolveThreadStatus( return { kind: "working", label: "Working", - pillClassName: "bg-adaptive-sky-500-a12-a16", - textClassName: "text-adaptive-sky-700-300", + pillClassName: "bg-primary/10", + textClassName: "text-adaptive-sky-600-400", iconColor: "#0a84ff", iconBackground: "rgba(10,132,255,0.22)", pulse: true, diff --git a/apps/mobile/src/features/threads/use-project-actions.ts b/apps/mobile/src/features/threads/use-project-actions.ts deleted file mode 100644 index ddb02f2fe6..0000000000 --- a/apps/mobile/src/features/threads/use-project-actions.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { useCallback } from "react"; - -import { scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; -import { mapAtomCommandResult } from "@t3tools/client-runtime/state/runtime"; -import { - ThreadId, - type ModelSelection, - type ProviderInteractionMode, - type RuntimeMode, -} from "@t3tools/contracts"; -import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; -import * as Cause from "effect/Cause"; -import { AsyncResult } from "effect/unstable/reactivity"; - -import { threadEnvironment } from "../../state/threads"; -import type { DraftComposerAttachment } from "../../lib/composerImages"; -import { prepareTurnAttachments, validateDraftFileAttachments } from "../../lib/attachmentUpload"; -import { makeTurnCommandMetadata, type TurnCommandMetadata } from "../../lib/commandMetadata"; -import { buildProjectThreadStartTurnInput } from "../../lib/projectThreadStartTurn"; -import { randomHex } from "../../lib/uuid"; -import { useAtomCommand } from "../../state/use-atom-command"; -import { scheduleUnusedComposerAttachmentCleanup } from "../../state/use-composer-drafts"; -import { setPendingConnectionError } from "../../state/use-remote-environment-registry"; -import { validateProjectThreadCreation } from "./projectThreadCreationValidation"; -import { appAtomRegistry } from "../../state/atom-registry"; -import { serverEnvironment } from "../../state/server"; - -export function useCreateProjectThread() { - const startTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); - - return useCallback( - async (input: { - readonly project: EnvironmentProject; - readonly modelSelection: ModelSelection; - readonly envMode: "local" | "worktree"; - readonly branch: string | null; - readonly worktreePath: string | null; - readonly startFromOrigin?: boolean; - readonly runtimeMode: RuntimeMode; - readonly interactionMode: ProviderInteractionMode; - readonly initialMessageText: string; - readonly initialAttachments: ReadonlyArray; - readonly onAttachmentsUploaded: ( - attachments: ReadonlyArray, - ) => Promise; - /** Reuse identifiers from a queued pending task instead of minting new ones. */ - readonly turnMetadata?: TurnCommandMetadata; - }) => { - const metadata = input.turnMetadata ?? makeTurnCommandMetadata(); - const threadId = ThreadId.make(metadata.threadId); - const initialMessageText = input.initialMessageText.trim(); - - const validationError = validateProjectThreadCreation({ - environmentId: input.project.environmentId, - projectId: input.project.id, - environmentMode: input.envMode, - branch: input.branch, - initialMessageText, - }); - if (validationError !== null) { - setPendingConnectionError(validationError.message); - return AsyncResult.failure(Cause.fail(validationError)); - } - - const validateLiveFileAttachments = ( - attachments: ReadonlyArray, - ): string | null => - validateDraftFileAttachments({ - attachments, - serverConfig: appAtomRegistry.get( - serverEnvironment.configValueAtom(input.project.environmentId), - ), - }); - const initialAttachmentError = validateLiveFileAttachments(input.initialAttachments); - if (initialAttachmentError !== null) { - setPendingConnectionError(initialAttachmentError); - return AsyncResult.failure(Cause.fail(new Error(initialAttachmentError))); - } - - let prepared: Awaited>; - try { - // If persisting the references into the draft throws, the owner call - // deletes the pending uploads it minted before rethrowing. - prepared = await prepareTurnAttachments({ - environmentId: input.project.environmentId, - attachments: input.initialAttachments, - supportsImageUploads: - appAtomRegistry.get(serverEnvironment.configValueAtom(input.project.environmentId)) - ?.environment.capabilities.attachmentUploads === true, - persistUploadedReferences: async (draftAttachments) => { - await input.onAttachmentsUploaded(draftAttachments); - return "persisted"; - }, - }); - } catch (error) { - const message = error instanceof Error ? error.message : "An attachment could not upload."; - setPendingConnectionError(message); - return AsyncResult.failure(Cause.fail(new Error(message))); - } - if (prepared.status !== "ready") { - const message = "The attachments are no longer available."; - setPendingConnectionError(message); - return AsyncResult.failure(Cause.fail(new Error(message))); - } - - const preparedAttachmentError = validateLiveFileAttachments(prepared.draftAttachments); - if (preparedAttachmentError !== null) { - setPendingConnectionError(preparedAttachmentError); - return AsyncResult.failure(Cause.fail(new Error(preparedAttachmentError))); - } - - const result = await startTurn({ - environmentId: input.project.environmentId, - input: buildProjectThreadStartTurnInput({ - projectId: input.project.id, - projectCwd: input.project.workspaceRoot, - threadId: metadata.threadId, - commandId: metadata.commandId, - messageId: metadata.messageId, - createdAt: metadata.createdAt, - text: initialMessageText, - uploadedAttachments: prepared.attachments, - modelSelection: input.modelSelection, - runtimeMode: input.runtimeMode, - interactionMode: input.interactionMode, - workspaceMode: input.envMode, - branch: input.branch, - worktreePath: input.worktreePath, - startFromOrigin: input.startFromOrigin ?? false, - worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex), - }), - }); - if (AsyncResult.isFailure(result)) { - const error = Cause.squash(result.cause); - setPendingConnectionError( - error instanceof Error ? error.message : "The task could not be started.", - ); - return AsyncResult.failure(result.cause); - } - // The started turn holds its own copy of the bytes; a failed delete is - // surfaced without failing the started task. - await prepared.releaseUploads().catch((error) => { - console.warn("[project-thread] could not delete consumed pending uploads", error); - }); - setPendingConnectionError(null); - scheduleUnusedComposerAttachmentCleanup(input.initialAttachments); - - return mapAtomCommandResult(result, () => - scopeThreadRef(input.project.environmentId, threadId), - ); - }, - [startTurn], - ); -} diff --git a/apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts b/apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts index 6b040b698e..ba70a28456 100644 --- a/apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts +++ b/apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { composerAttachmentUploadBlockReason, + composerAttachmentsStillUploading, composerAttachmentUploadKey, composerDraftEnvironmentId, createComposerAttachmentUploadQueue, @@ -230,7 +231,25 @@ describe("draft upload scope and offline submission", () => { ); }); - it("allows offline queuing while a connected composer waits for upload or retry", () => { + it("reads an id-keyed new-task draft's environment from its project stamp", () => { + const stamped = { + project: { + environmentId, + projectId: "project" as never, + createdAt: "2026-09-05T00:00:00.000Z", + }, + }; + expect(composerDraftEnvironmentId("new-task:abc123-def456", [], stamped)).toBe(environmentId); + // An id-keyed draft that lost its stamp belongs to nobody: uploads must + // not start and sign-out must not sweep it into some other environment. + expect(composerDraftEnvironmentId("new-task:abc123-def456", [])).toBeNull(); + // The stamp wins over a legacy-looking key when both are present. + expect(composerDraftEnvironmentId("new-task:environment-2:project", [], stamped)).toBe( + environmentId, + ); + }); + + it("only a failed upload blocks sending; an in-flight one queues instead", () => { const key = composerAttachmentUploadKey(environmentId, "file"); const input = { environmentId, @@ -243,7 +262,16 @@ describe("draft upload scope and offline submission", () => { }, states: {}, }; - expect(composerAttachmentUploadBlockReason(input)).toBe("Attachment still uploading"); + // Not started yet and mid-transfer both let the send through as a queued + // message; the outbox drain finishes (or redoes) the upload. + expect(composerAttachmentUploadBlockReason(input)).toBeNull(); + expect(composerAttachmentsStillUploading(input)).toBe(true); + expect( + composerAttachmentsStillUploading({ + ...input, + states: { [key]: { status: "uploading", progress: 0.6 } }, + }), + ).toBe(true); expect(composerAttachmentUploadBlockReason({ ...input, connected: false })).toBeNull(); expect( composerAttachmentUploadBlockReason({ @@ -251,8 +279,19 @@ describe("draft upload scope and offline submission", () => { states: { [key]: { status: "failed", reason: "Offline" } }, }), ).toBe("Retry or remove the failed attachment"); + expect( + composerAttachmentsStillUploading({ + ...input, + states: { [key]: { status: "failed", reason: "Offline" } }, + }), + ).toBe(false); expect( composerAttachmentUploadBlockReason({ ...input, states: { [key]: { status: "ready" } } }), ).toBeNull(); + expect( + composerAttachmentsStillUploading({ ...input, states: { [key]: { status: "ready" } } }), + ).toBe(false); + // Attachments the environment cannot accept never count as uploading. + expect(composerAttachmentsStillUploading({ ...input, serverConfig: null })).toBe(false); }); }); diff --git a/apps/mobile/src/lib/composerAttachmentUploadQueue.ts b/apps/mobile/src/lib/composerAttachmentUploadQueue.ts index 071afefa4c..3ee30ce8b1 100644 --- a/apps/mobile/src/lib/composerAttachmentUploadQueue.ts +++ b/apps/mobile/src/lib/composerAttachmentUploadQueue.ts @@ -1,6 +1,7 @@ import { EnvironmentId, type ServerConfig } from "@t3tools/contracts"; import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments"; +import { parseLegacyNewTaskDraftKey } from "../state/new-task-draft-key"; import type { DraftComposerAttachment } from "./composerImages"; export interface ComposerAttachmentUploadRequest { @@ -20,12 +21,19 @@ export function composerAttachmentUploadKey( return `${environmentId}:${attachmentId}`; } +/** + * Which environment a composer draft belongs to. Thread drafts carry it in + * the key; pending-task editor drafts borrow it from the queued message; + * new-task drafts carry it in their project stamp (legacy project-keyed + * new-task drafts still parse from the key until they are migrated on load). + */ export function composerDraftEnvironmentId( draftKey: string, queuedMessages: ReadonlyArray<{ readonly messageId: string; readonly environmentId: EnvironmentId; }>, + draft?: { readonly project?: { readonly environmentId: EnvironmentId } }, ): EnvironmentId | null { if (draftKey.startsWith("pending-task:")) { return ( @@ -33,9 +41,15 @@ export function composerDraftEnvironmentId( ?.environmentId ?? null ); } - const scope = draftKey.startsWith("new-task:") ? draftKey.slice("new-task:".length) : draftKey; - const separator = scope.lastIndexOf(":"); - return separator > 0 ? EnvironmentId.make(scope.slice(0, separator)) : null; + if (draftKey.startsWith("new-task:")) { + if (draft?.project) { + return draft.project.environmentId; + } + const legacy = parseLegacyNewTaskDraftKey(draftKey); + return legacy === null ? null : EnvironmentId.make(legacy.environmentId); + } + const separator = draftKey.lastIndexOf(":"); + return separator > 0 ? EnvironmentId.make(draftKey.slice(0, separator)) : null; } type UploadServerConfig = { @@ -61,6 +75,12 @@ export function canUploadComposerAttachment( ); } +/** + * Only a failed upload blocks sending: the outbox drain would hit the same + * failure, so the user has to retry or remove the file first. An upload still + * in flight does not block; the message queues and the drain reuses the + * finished upload (or re-sends the local bytes) when it delivers. + */ export function composerAttachmentUploadBlockReason(input: { readonly environmentId: EnvironmentId; readonly attachments: ReadonlyArray; @@ -73,11 +93,24 @@ export function composerAttachmentUploadBlockReason(input: { if (!canUploadComposerAttachment(attachment, input.serverConfig)) continue; const state = input.states[composerAttachmentUploadKey(input.environmentId, attachment.id)]; if (state?.status === "failed") return "Retry or remove the failed attachment"; - if (state?.status !== "ready") return "Attachment still uploading"; } return null; } +/** Whether any attachment the environment accepts is still being uploaded. */ +export function composerAttachmentsStillUploading(input: { + readonly environmentId: EnvironmentId; + readonly attachments: ReadonlyArray; + readonly serverConfig: UploadServerConfig | null; + readonly states: Readonly>; +}): boolean { + return input.attachments.some((attachment) => { + if (!canUploadComposerAttachment(attachment, input.serverConfig)) return false; + const state = input.states[composerAttachmentUploadKey(input.environmentId, attachment.id)]; + return state?.status !== "ready" && state?.status !== "failed"; + }); +} + /** Bounds transfers across environments; disconnected or discarded drafts keep their local bytes. */ export function createComposerAttachmentUploadQueue(options: { readonly upload: ( diff --git a/apps/mobile/src/lib/http-response.test.ts b/apps/mobile/src/lib/http-response.test.ts new file mode 100644 index 0000000000..8b170cd906 --- /dev/null +++ b/apps/mobile/src/lib/http-response.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vite-plus/test"; +import { Cookies, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +describe("React Native HTTP responses", () => { + it("can inspect a rejected response when native Headers has no getSetCookie", () => { + const response = new Response("Registration rejected", { status: 400 }); + Object.defineProperty(response.headers, "getSetCookie", { value: undefined }); + const result = HttpClientResponse.fromWeb( + HttpClientRequest.post("https://relay.example.test/v1/mobile/devices"), + response, + ); + expect(result.cookies).toEqual(Cookies.empty); + expect(result.status).toBe(400); + }); + + it("preserves cookies on platforms that expose Set-Cookie headers", () => { + const result = HttpClientResponse.fromWeb( + HttpClientRequest.get("https://relay.example.test"), + new Response(null, { headers: { "Set-Cookie": "session=abc; HttpOnly" } }), + ); + expect(result.cookies).toEqual(Cookies.fromSetCookie(["session=abc; HttpOnly"])); + }); +}); diff --git a/apps/mobile/src/lib/projectThreadStartTurn.test.ts b/apps/mobile/src/lib/projectThreadStartTurn.test.ts index bd7918e52d..57df389cff 100644 --- a/apps/mobile/src/lib/projectThreadStartTurn.test.ts +++ b/apps/mobile/src/lib/projectThreadStartTurn.test.ts @@ -66,3 +66,38 @@ describe("project thread title", () => { expect(input.message.text).toBe(text); }); }); + +describe("new thread on an existing branch", () => { + it.each([null, "/worktrees/existing"])( + "reuses the selected workspace %s without preparing a new worktree", + (worktreePath) => { + const input = buildProjectThreadStartTurnInput({ + projectId: ProjectId.make("project"), + projectCwd: "/workspace", + threadId: "new-thread", + commandId: "command", + messageId: "message", + createdAt: "2026-09-06T00:00:00Z", + text: "Start fresh", + uploadedAttachments: [], + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }, + runtimeMode: "full-access", + interactionMode: "default", + workspaceMode: "local", + branch: "feature/existing", + worktreePath, + startFromOrigin: false, + worktreeBranchName: "unused", + }); + + expect(input.bootstrap.createThread).toMatchObject({ + projectId: "project", + branch: "feature/existing", + worktreePath, + }); + expect(input.bootstrap).not.toHaveProperty("prepareWorktree"); + expect(input.bootstrap).not.toHaveProperty("runSetupScript"); + expect(input.threadId).toBe("new-thread"); + }, + ); +}); diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 2d6091b7ee..5d1e6cc19a 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -1,6 +1,5 @@ import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests"; import { describe, expect, it } from "vite-plus/test"; -import { codexFeedbackMessage } from "@t3tools/client-runtime/state/threads"; import { EventId, @@ -27,34 +26,6 @@ import { type WorkLogEntry, } from "./threadActivity"; -describe("Codex feedback pseudo-messages", () => { - it("keeps pending and completed feedback messages in the mobile thread body", () => { - const pending = { - id: MessageId.make("feedback-command"), - command: "/feedback The agent stopped early.", - createdAt: "2026-08-23T00:00:00.000Z", - status: "uploading" as const, - }; - const entries = [codexFeedbackMessage(pending), codexFeedbackMessage(pending, "assistant")].map( - (message) => ({ - type: "message" as const, - id: message.id, - createdAt: message.createdAt, - message, - }), - ); - - expect(deriveThreadFeedPresentation(entries, null, new Set())).toEqual(entries); - expect(entries[1]?.message.text).toBe("Sending feedback to OpenAI..."); - - const completed = codexFeedbackMessage( - { ...pending, status: "sent", feedbackId: "codex-thread-1" }, - "assistant", - ); - expect(completed.text).toContain("codex-thread-1"); - }); -}); - const singleSelectQuestion = { id: "runtime", header: "Runtime", @@ -995,44 +966,6 @@ describe("buildThreadFeed", () => { }, ); - it("keeps older local feedback before newer messages returned by the server", () => { - const submission = { - id: MessageId.make("feedback-command-ordering"), - command: "/feedback The agent stopped early.", - createdAt: "2026-08-23T00:00:01.000Z", - status: "sent" as const, - feedbackId: "codex-thread-1", - }; - const laterMessage = { - id: MessageId.make("later-server-message"), - role: "assistant" as const, - text: "Newer server response", - turnId: null, - createdAt: "2026-08-23T00:00:02.000Z", - updatedAt: "2026-08-23T00:00:02.000Z", - streaming: false, - }; - const thread = makeThread({ - id: ThreadId.make("thread-feedback-ordering"), - projectId: ProjectId.make("project-1"), - title: "Feedback ordering", - messages: [laterMessage], - }); - - const feed = buildThreadFeed(thread, { - localMessages: [ - codexFeedbackMessage(submission), - codexFeedbackMessage(submission, "assistant"), - ], - }); - - expect(feed.map((entry) => entry.id)).toEqual([ - "feedback-command-ordering", - "feedback-command-ordering:feedback", - "later-server-message", - ]); - }); - it("keeps historic work entries attributed to their turns", () => { const thread = makeThread({ id: ThreadId.make("thread-1"), diff --git a/apps/mobile/src/state/acknowledged-thread-messages.ts b/apps/mobile/src/state/acknowledged-thread-messages.ts new file mode 100644 index 0000000000..0ceeb49030 --- /dev/null +++ b/apps/mobile/src/state/acknowledged-thread-messages.ts @@ -0,0 +1,27 @@ +import { Atom } from "effect/unstable/reactivity"; +import { appAtomRegistry } from "./atom-registry"; +import type { QueuedThreadMessage } from "./thread-outbox-model"; + +// A command acknowledgment can precede its message in the subscribed timeline. +// Keep the visible row until that projection arrives, independently of outbox cleanup. +export const acknowledgedThreadMessagesAtom = Atom.make>( + [], +).pipe(Atom.keepAlive); + +export function retainAcknowledgedThreadMessage(message: QueuedThreadMessage) { + const current = appAtomRegistry.get(acknowledgedThreadMessagesAtom); + appAtomRegistry.set( + acknowledgedThreadMessagesAtom, + current.some((entry) => entry.messageId === message.messageId) + ? current + : [...current, message], + ); +} + +export function forgetAcknowledgedThreadMessage(message: QueuedThreadMessage) { + const current = appAtomRegistry.get(acknowledgedThreadMessagesAtom); + appAtomRegistry.set( + acknowledgedThreadMessagesAtom, + current.filter((entry) => entry.messageId !== message.messageId), + ); +} diff --git a/apps/mobile/src/state/composer-attachment-uploads.ts b/apps/mobile/src/state/composer-attachment-uploads.ts index ad38551d59..352367c09a 100644 --- a/apps/mobile/src/state/composer-attachment-uploads.ts +++ b/apps/mobile/src/state/composer-attachment-uploads.ts @@ -25,7 +25,10 @@ import { } from "./use-composer-drafts"; import { useRemoteConnectionStatus } from "./use-remote-environment-registry"; -export { composerAttachmentUploadBlockReason } from "../lib/composerAttachmentUploadQueue"; +export { + composerAttachmentUploadBlockReason, + composerAttachmentsStillUploading, +} from "../lib/composerAttachmentUploadQueue"; export const composerAttachmentUploadsAtom = Atom.make< Readonly> @@ -79,7 +82,7 @@ export function useComposerAttachmentUploadWorker() { let retained = false; for (const [key, draft] of Object.entries(appAtomRegistry.get(composerDraftsAtom))) { if ( - composerDraftEnvironmentId(key, queued) === environmentId && + composerDraftEnvironmentId(key, queued, draft) === environmentId && draft.attachments.some((candidate) => candidate.id === attachment.id) ) { retained = setComposerDraftAttachmentUpload(key, uploaded) || retained; @@ -113,7 +116,7 @@ export function useComposerAttachmentUploadWorker() { .map((environment) => environment.environmentId), ); const requests = Object.entries(drafts).flatMap(([key, draft]) => { - const environmentId = composerDraftEnvironmentId(key, queued); + const environmentId = composerDraftEnvironmentId(key, queued, draft); if (environmentId === null || !connected.has(environmentId)) return []; return draft.attachments .filter((attachment) => diff --git a/apps/mobile/src/state/edit-pending-thread-message.test.ts b/apps/mobile/src/state/edit-pending-thread-message.test.ts new file mode 100644 index 0000000000..87b73e009a --- /dev/null +++ b/apps/mobile/src/state/edit-pending-thread-message.test.ts @@ -0,0 +1,124 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { + CommandId, + EnvironmentId, + MessageId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import type { QueuedThreadMessage } from "./thread-outbox-model"; + +const state = vi.hoisted(() => ({ + dispatching: null as string | null, + held: {} as Record, + draft: { text: "Existing draft", attachments: [] as { id: string }[] }, + confirm: vi.fn(async () => true), + remove: vi.fn(async () => true), + flush: vi.fn(async () => {}), + settings: vi.fn((_key: string, _settings: Record) => {}), +})); +vi.mock("./atom-registry", () => ({ + appAtomRegistry: { + get: (atom: string) => (atom === "dispatching" ? state.dispatching : state.held), + }, +})); +vi.mock("./use-thread-outbox", () => ({ + dispatchingQueuedMessageIdAtom: "dispatching", + editingQueuedMessageIdsAtom: "editing", + holdEditingQueuedMessage: (id: string) => { + state.held[id] = true; + }, + releaseEditingQueuedMessage: (id: string) => { + delete state.held[id]; + }, +})); +vi.mock("./thread-outbox", () => ({ + confirmThreadOutboxMessageQueued: state.confirm, + threadOutboxRevision: () => 1, +})); +vi.mock("./thread-outbox-removal", () => ({ removeThreadOutboxMessage: state.remove })); +vi.mock("./use-composer-drafts", () => ({ + waitForComposerDraftsLoaded: async () => {}, + getComposerDraftSnapshot: () => state.draft, + mergeComposerDraftContent: async (_key: string, message: QueuedThreadMessage) => { + state.draft = { + text: `${state.draft.text}\n\n${message.text}`, + attachments: [...state.draft.attachments, ...message.attachments], + }; + }, + updateComposerDraftSettings: state.settings, + flushComposerDrafts: state.flush, + undoComposerDraftMerge: async (_key: string, snapshot: typeof state.draft) => { + state.draft = snapshot; + }, +})); +import { editPendingThreadMessage } from "./edit-pending-thread-message"; + +const message: QueuedThreadMessage = { + environmentId: EnvironmentId.make("env"), + threadId: ThreadId.make("thread"), + messageId: MessageId.make("message"), + commandId: CommandId.make("command"), + text: "Queued task", + createdAt: "2026-09-06T10:00:00.000Z", + attachments: [ + { + id: "file", + type: "file", + name: "notes.txt", + mimeType: "text/plain", + sizeBytes: 10, + fileUri: "file:///notes.txt", + }, + ], +}; +beforeEach(() => { + vi.clearAllMocks(); + state.dispatching = null; + state.held = {}; + state.draft = { text: "Existing draft", attachments: [] }; + state.confirm.mockResolvedValue(true); + state.remove.mockResolvedValue(true); + state.flush.mockResolvedValue(undefined); +}); +describe("editing a pending message", () => { + it("locks delivery and persists text and attachments before removing the queued copy", async () => { + state.confirm.mockImplementationOnce(async () => { + expect(state.held[message.messageId]).toBe(true); + return true; + }); + state.remove.mockImplementationOnce(async () => { + expect(state.flush).toHaveBeenCalled(); + expect(state.draft.text).toBe("Existing draft\n\nQueued task"); + expect(state.draft.attachments).toEqual(message.attachments); + return true; + }); + expect(await editPendingThreadMessage(message)).toBe(true); + expect(state.held).toEqual({}); + }); + it("restores the queued provider choice as an explicit selection", async () => { + const modelSelection = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }; + expect(await editPendingThreadMessage({ ...message, modelSelection })).toBe(true); + expect(state.settings).toHaveBeenCalledWith("env:thread", { + modelSelection, + providerSelectionExplicit: true, + }); + }); + it("does not reclaim a message already being dispatched", async () => { + state.dispatching = message.messageId; + expect(await editPendingThreadMessage(message)).toBe(false); + expect(state.confirm).not.toHaveBeenCalled(); + expect(state.draft.text).toBe("Existing draft"); + }); + it("rolls back the draft if removing the queued message fails", async () => { + state.remove.mockRejectedValueOnce(new Error("disk error")); + await expect(editPendingThreadMessage(message)).rejects.toThrow("disk error"); + expect(state.draft).toEqual({ text: "Existing draft", attachments: [] }); + expect(state.held).toEqual({}); + }); + it("rolls back when a newer queue revision wins", async () => { + state.remove.mockResolvedValueOnce(false); + expect(await editPendingThreadMessage(message)).toBe(false); + expect(state.draft.text).toBe("Existing draft"); + }); +}); diff --git a/apps/mobile/src/state/edit-pending-thread-message.ts b/apps/mobile/src/state/edit-pending-thread-message.ts new file mode 100644 index 0000000000..655a2c42f5 --- /dev/null +++ b/apps/mobile/src/state/edit-pending-thread-message.ts @@ -0,0 +1,76 @@ +import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "@t3tools/contracts"; +import { scopedThreadKey } from "../lib/scopedEntities"; +import { appAtomRegistry } from "./atom-registry"; +import { + confirmThreadOutboxMessageQueued, + threadOutboxRevision, + type QueuedThreadMessage, +} from "./thread-outbox"; +import { removeThreadOutboxMessage } from "./thread-outbox-removal"; +import { + flushComposerDrafts, + getComposerDraftSnapshot, + mergeComposerDraftContent, + undoComposerDraftMerge, + updateComposerDraftSettings, + waitForComposerDraftsLoaded, +} from "./use-composer-drafts"; +import { + dispatchingQueuedMessageIdAtom, + editingQueuedMessageIdsAtom, + holdEditingQueuedMessage, + releaseEditingQueuedMessage, +} from "./use-thread-outbox"; + +/** Take delivery ownership before any await; the durable draft then takes ownership of the files. */ +export async function editPendingThreadMessage(message: QueuedThreadMessage): Promise { + if ( + message.creation || + appAtomRegistry.get(dispatchingQueuedMessageIdAtom) === message.messageId || + appAtomRegistry.get(editingQueuedMessageIdsAtom)[message.messageId] + ) { + return false; + } + holdEditingQueuedMessage(message.messageId); + const draftKey = scopedThreadKey(message.environmentId, message.threadId); + let rollback: { + snapshot: ReturnType; + merged: ReturnType; + } | null = null; + try { + if (!(await confirmThreadOutboxMessageQueued(message))) return false; + const revision = threadOutboxRevision(message.messageId); + await waitForComposerDraftsLoaded(); + const snapshot = getComposerDraftSnapshot(draftKey); + const attachmentIds = new Set(snapshot.attachments.map((attachment) => attachment.id)); + for (const attachment of message.attachments) attachmentIds.add(attachment.id); + if (attachmentIds.size > PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { + throw new Error("Remove attachments from the composer before editing this message."); + } + try { + await mergeComposerDraftContent(draftKey, message); + } finally { + rollback = { snapshot, merged: getComposerDraftSnapshot(draftKey) }; + } + // Same provider binding a held-send restore records: the queued choice is + // the user's, so it must not be re-seeded from thread defaults. + updateComposerDraftSettings(draftKey, { + ...(message.modelSelection + ? { modelSelection: message.modelSelection, providerSelectionExplicit: true } + : {}), + ...(message.runtimeMode ? { runtimeMode: message.runtimeMode } : {}), + ...(message.interactionMode ? { interactionMode: message.interactionMode } : {}), + }); + rollback = { snapshot, merged: getComposerDraftSnapshot(draftKey) }; + await flushComposerDrafts(); + if (!(await removeThreadOutboxMessage(message, revision))) return false; + rollback = null; + return true; + } finally { + try { + if (rollback) await undoComposerDraftMerge(draftKey, rollback.snapshot, rollback.merged); + } finally { + releaseEditingQueuedMessage(message.messageId); + } + } +} diff --git a/apps/mobile/src/state/new-task-draft-key.ts b/apps/mobile/src/state/new-task-draft-key.ts new file mode 100644 index 0000000000..c425bf95f6 --- /dev/null +++ b/apps/mobile/src/state/new-task-draft-key.ts @@ -0,0 +1,44 @@ +const NEW_TASK_DRAFT_PREFIX = "new-task:"; + +/** Every new-task draft key: `new-task:`. */ +export function newTaskDraftKey(draftId: string): string { + return `${NEW_TASK_DRAFT_PREFIX}${draftId}`; +} + +export function isNewTaskDraftKey(draftKey: string): boolean { + return draftKey.startsWith(NEW_TASK_DRAFT_PREFIX); +} + +/** The editor draft for a queued new task, while it is open for editing. */ +export function pendingTaskDraftKey(messageId: string): string { + return `pending-task:${messageId}`; +} + +/** + * The draft a rejected queued task's content is restored into. The outbox + * drain writes it and the thread screen's "Edit task" action opens it, so both + * sides derive the key here rather than rebuilding the string. + */ +export function restoredNewTaskDraftKey(messageId: string): string { + return newTaskDraftKey(`restored-${messageId}`); +} + +/** + * Builds before drafts were id-keyed used `new-task::`, + * one slot per project. Ids are UUIDs and never contain a colon, so a colon + * after the prefix marks the legacy shape. Returns the split scope, or null + * when the key is not legacy. + */ +export function parseLegacyNewTaskDraftKey( + draftKey: string, +): { readonly environmentId: string; readonly projectId: string } | null { + if (!isNewTaskDraftKey(draftKey)) { + return null; + } + const scope = draftKey.slice(NEW_TASK_DRAFT_PREFIX.length); + const separator = scope.lastIndexOf(":"); + if (separator <= 0 || separator === scope.length - 1) { + return null; + } + return { environmentId: scope.slice(0, separator), projectId: scope.slice(separator + 1) }; +} diff --git a/apps/mobile/src/state/pending-new-tasks-model.test.ts b/apps/mobile/src/state/pending-new-tasks-model.test.ts new file mode 100644 index 0000000000..98a20c938e --- /dev/null +++ b/apps/mobile/src/state/pending-new-tasks-model.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + CommandId, + EnvironmentId, + MessageId, + ProjectId, + type ServerConfig, + ThreadId, +} from "@t3tools/contracts"; +import { Atom, AtomRegistry } from "effect/unstable/reactivity"; + +import type { QueuedThreadMessage } from "./thread-outbox-model"; +import type { ComposerDraft } from "./use-composer-drafts"; +import { + buildPendingNewTasks, + makeListedNewTaskDraftsAtom, + resolvePendingTaskDelivery, + selectListedNewTaskDrafts, +} from "./pending-new-tasks-model"; + +const environmentId = EnvironmentId.make("env-1"); +const projectId = ProjectId.make("project-1"); + +function queuedCreation(id: string, createdAt: string): QueuedThreadMessage { + return { + environmentId, + threadId: ThreadId.make(`thread-${id}`), + messageId: MessageId.make(id), + commandId: CommandId.make(`command-${id}`), + text: `queued ${id}`, + attachments: [], + createdAt, + creation: { + projectId, + workspaceMode: "local", + branch: "main", + worktreePath: null, + }, + }; +} + +function draft( + text: string, + createdAt: string, + overrides: Partial = {}, +): ComposerDraft { + return { + text, + attachments: [], + project: { environmentId, projectId, createdAt }, + ...overrides, + }; +} + +describe("buildPendingNewTasks", () => { + it("surfaces every new-task draft with content alongside queued creations", () => { + const tasks = buildPendingNewTasks({ + queuedMessages: [queuedCreation("a", "2026-09-05T10:00:00.000Z")], + drafts: { + "new-task:draft-old": draft("first idea", "2026-09-05T09:00:00.000Z", { + workspaceSelection: { mode: "worktree", branch: "main", worktreePath: null }, + }), + "new-task:draft-new": draft("second idea", "2026-09-05T11:00:00.000Z"), + }, + }); + + expect(tasks.map((task) => [task.kind, task.title, task.branch])).toEqual([ + ["draft", "second idea", null], + ["draft", "first idea", "main"], + ["pending", "queued a", "main"], + ]); + expect(tasks[1]).toMatchObject({ + key: "draft-task:new-task:draft-old", + environmentId, + projectId, + draftKey: "new-task:draft-old", + createdAt: "2026-09-05T09:00:00.000Z", + }); + }); + + it("hides settings-only drafts, unstamped drafts, and drafts for other surfaces", () => { + const tasks = buildPendingNewTasks({ + queuedMessages: [], + drafts: { + "new-task:settings-only": draft("", "2026-09-05T09:00:00.000Z", { + modelSelection: { instanceId: "codex" as never, model: "gpt" }, + }), + "new-task:blank": draft(" ", "2026-09-05T09:00:00.000Z"), + "new-task:unstamped": { text: "no project", attachments: [] }, + [`${environmentId}:thread-1`]: { text: "thread composer text", attachments: [] }, + "pending-task:message-1": { text: "editor copy of a queued task", attachments: [] }, + }, + }); + + expect(tasks).toEqual([]); + }); + + it("titles an attachment-only draft by its attachment count", () => { + const attachment = { + type: "image", + id: "image-1", + uri: "file:///image-1.png", + mimeType: "image/png", + name: "image-1.png", + width: 1, + height: 1, + sizeBytes: 1, + } as unknown as ComposerDraft["attachments"][number]; + const tasks = buildPendingNewTasks({ + queuedMessages: [], + drafts: { + "new-task:with-image": draft("", "2026-09-05T09:00:00.000Z", { + attachments: [attachment], + }), + }, + }); + + expect(tasks.map((task) => task.title)).toEqual(["1 attachment"]); + }); + + it("orders queued creations newest first and skips existing-thread messages", () => { + const tasks = buildPendingNewTasks({ + queuedMessages: [ + queuedCreation("old", "2026-09-05T08:00:00.000Z"), + { ...queuedCreation("follow-up", "2026-09-05T11:00:00.000Z"), creation: undefined }, + queuedCreation("new", "2026-09-05T10:00:00.000Z"), + ], + drafts: {}, + }); + + expect(tasks.map((task) => task.title)).toEqual(["queued new", "queued old"]); + }); +}); + +describe("listed new-task drafts", () => { + const listed = draft("an idea", "2026-09-05T09:00:00.000Z"); + + it("keeps its identity while unrelated drafts change", () => { + const first = selectListedNewTaskDrafts( + { "new-task:idea": listed, "env-1:thread-1": { text: "typ", attachments: [] } }, + undefined, + ); + expect(first).toEqual({ "new-task:idea": listed }); + + const afterThreadKeystroke = selectListedNewTaskDrafts( + { "new-task:idea": listed, "env-1:thread-1": { text: "typing", attachments: [] } }, + first, + ); + expect(afterThreadKeystroke).toBe(first); + + // A model pick on an empty new-task draft is not listed either. + const afterEmptyDraftSettings = selectListedNewTaskDrafts( + { + "new-task:idea": listed, + "new-task:empty": draft("", "2026-09-05T10:00:00.000Z", { runtimeMode: "full-access" }), + }, + first, + ); + expect(afterEmptyDraftSettings).toBe(first); + }); + + it("changes when a listed draft is edited, added, or removed", () => { + const first = selectListedNewTaskDrafts({ "new-task:idea": listed }, undefined); + const edited = { ...listed, text: "an idea, refined" }; + const afterEdit = selectListedNewTaskDrafts({ "new-task:idea": edited }, first); + expect(afterEdit).not.toBe(first); + expect(afterEdit).toEqual({ "new-task:idea": edited }); + const added = selectListedNewTaskDrafts( + { "new-task:idea": listed, "new-task:other": draft("other", "2026-09-05T10:00:00.000Z") }, + first, + ); + expect(added).not.toBe(first); + expect(selectListedNewTaskDrafts({}, first)).toEqual({}); + }); + + it("does not notify list subscribers when a thread composer changes", () => { + const registry = AtomRegistry.make(); + const source = Atom.make>>({ + "new-task:idea": listed, + }); + const derived = makeListedNewTaskDraftsAtom(source); + const initial = registry.get(derived); + let notifications = 0; + const unsubscribe = registry.subscribe(derived, () => { + notifications += 1; + }); + + registry.set(source, { + ...registry.get(source), + "env-1:thread-1": { text: "typing a follow-up", attachments: [] }, + }); + expect(registry.get(derived)).toBe(initial); + expect(notifications).toBe(0); + + registry.set(source, { + ...registry.get(source), + "new-task:idea": { ...listed, text: "an idea, refined" }, + }); + expect(registry.get(derived)).not.toBe(initial); + expect(notifications).toBe(1); + unsubscribe(); + }); +}); + +describe("resolvePendingTaskDelivery", () => { + const uploadConfig = { + environment: { + capabilities: { attachmentUploads: true, fileAttachments: { maxUploadBytes: 1_000_000 } }, + }, + } as unknown as ServerConfig; + const image = { + id: "image-1", + type: "image", + name: "screen.png", + mimeType: "image/png", + sizeBytes: 10, + previewUri: "data:image/png;base64,AAAA", + } as const; + + it("says Held for a held task whatever the connection", () => { + const held: QueuedThreadMessage = { + ...queuedCreation("held", "2026-09-05T10:00:00.000Z"), + deliveryHold: { kind: "admission-rejected", reason: "Provider refused the turn" }, + }; + expect( + resolvePendingTaskDelivery({ message: held, connected: true, serverConfig: uploadConfig }), + ).toBe("held"); + expect( + resolvePendingTaskDelivery({ message: held, connected: false, serverConfig: uploadConfig }), + ).toBe("held"); + }); + + it("only says it sends on reconnect while disconnected", () => { + const queued = { ...queuedCreation("q", "2026-09-05T10:00:00.000Z"), attachments: [image] }; + expect( + resolvePendingTaskDelivery({ message: queued, connected: false, serverConfig: uploadConfig }), + ).toBe("offline"); + expect( + resolvePendingTaskDelivery({ message: queued, connected: true, serverConfig: uploadConfig }), + ).toBe("uploading"); + expect( + resolvePendingTaskDelivery({ + message: { + ...queued, + attachments: [ + { ...image, uploadedAttachmentId: "upload-1", uploadEnvironmentId: environmentId }, + ], + }, + connected: true, + serverConfig: uploadConfig, + }), + ).toBe("sending"); + expect( + resolvePendingTaskDelivery({ + message: queuedCreation("plain", "2026-09-05T10:00:00.000Z"), + connected: true, + serverConfig: uploadConfig, + }), + ).toBe("sending"); + }); +}); diff --git a/apps/mobile/src/state/pending-new-tasks-model.ts b/apps/mobile/src/state/pending-new-tasks-model.ts new file mode 100644 index 0000000000..d16ba3dac9 --- /dev/null +++ b/apps/mobile/src/state/pending-new-tasks-model.ts @@ -0,0 +1,191 @@ +import type { EnvironmentId, ProjectId, ServerConfig } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { Atom } from "effect/unstable/reactivity"; + +import { canUploadComposerAttachment } from "../lib/composerAttachmentUploadQueue"; +import { deriveThreadTitleFromPrompt } from "../lib/projectThreadStartTurn"; +import type { QueuedThreadCreation, QueuedThreadMessage } from "./thread-outbox-model"; +import { isNewTaskDraftKey } from "./new-task-draft-key"; +import type { ComposerDraft } from "./use-composer-drafts"; + +/** + * Unsent work that will become a thread, shaped for thread-list presentation. + * A `pending` task sits in the outbox and sends itself when its environment + * reconnects; a `draft` is new-task composer content, which only sends when + * the user submits it. Both share the list slot so the user can find + * everything they have written but not yet started in one place. + */ +export type PendingNewTask = PendingQueuedTask | PendingDraftTask; + +export interface PendingQueuedTask { + readonly kind: "pending"; + readonly key: string; + readonly environmentId: EnvironmentId; + readonly projectId: ProjectId; + readonly projectTitle: string | undefined; + readonly projectCwd: string | undefined; + readonly branch: string | null; + readonly title: string; + readonly createdAt: string; + readonly message: QueuedThreadMessage; + readonly creation: QueuedThreadCreation; +} + +export interface PendingDraftTask { + readonly kind: "draft"; + readonly key: string; + readonly environmentId: EnvironmentId; + readonly projectId: ProjectId; + readonly projectTitle: undefined; + readonly projectCwd: undefined; + readonly branch: string | null; + readonly title: string; + readonly createdAt: string; + readonly draftKey: string; + readonly draft: ComposerDraft; +} + +/** + * Settings-only drafts (a model pick with no text) are not work the user + * would look for in the list; only text or attachments make a draft visible. + */ +export function composerDraftHasUserContent(draft: ComposerDraft): boolean { + return draft.text.trim().length > 0 || draft.attachments.length > 0; +} + +type DraftRecord = Readonly>; + +function isListedNewTaskDraft(key: string, draft: ComposerDraft): boolean { + return ( + isNewTaskDraftKey(key) && draft.project !== undefined && composerDraftHasUserContent(draft) + ); +} + +/** + * The new-task drafts the thread list shows. Returns `previous` when those + * entries are unchanged, so typing in a thread composer (a different key) or + * picking a model on an empty draft leaves the list's input untouched. + */ +export function selectListedNewTaskDrafts( + drafts: DraftRecord, + previous: DraftRecord | undefined, +): DraftRecord { + const listed: Record = {}; + let count = 0; + let changed = previous === undefined; + for (const [key, draft] of Object.entries(drafts)) { + if (!isListedNewTaskDraft(key, draft)) continue; + listed[key] = draft; + count += 1; + if (previous !== undefined && previous[key] !== draft) changed = true; + } + if (!changed && previous !== undefined && Object.keys(previous).length === count) { + return previous; + } + return listed; +} + +/** Derives the listed drafts from the whole draft store without re-notifying on unrelated edits. */ +export function makeListedNewTaskDraftsAtom( + source: Atom.Atom, +): Atom.Atom { + return Atom.make((get) => + selectListedNewTaskDrafts(get(source), Option.getOrUndefined(get.self())), + ); +} + +/** What happens next to a queued task, as this device sees it. */ +export type PendingTaskDelivery = "held" | "offline" | "uploading" | "sending"; + +export function resolvePendingTaskDelivery(input: { + readonly message: QueuedThreadMessage; + readonly connected: boolean; + readonly serverConfig: Pick | null | undefined; +}): PendingTaskDelivery { + const { message } = input; + if (message.deliveryHold !== undefined) return "held"; + if (!input.connected) return "offline"; + // The drain uploads files the server has not received before it sends. + const awaitingUpload = message.attachments.some( + (attachment) => + canUploadComposerAttachment(attachment, input.serverConfig) && + (attachment.uploadedAttachmentId === undefined || + attachment.uploadEnvironmentId !== message.environmentId), + ); + return awaitingUpload ? "uploading" : "sending"; +} + +export const PENDING_TASK_DELIVERY_PRESENTATION: Readonly< + Record +> = { + held: { label: "Held", accessibilityHint: "Held until retargeted. Opens the task for editing" }, + offline: { + label: "Sends on reconnect", + accessibilityHint: "Sends when the environment reconnects. Opens the task for editing", + }, + uploading: { + label: "Waiting for upload", + accessibilityHint: "Sends after its attachments upload. Opens the task for editing", + }, + sending: { label: "Sending…", accessibilityHint: "Sending now. Opens the task for editing" }, +}; + +function draftTitle(draft: ComposerDraft): string { + if (draft.text.trim().length > 0) { + return deriveThreadTitleFromPrompt(draft.text); + } + const count = draft.attachments.length; + return count === 1 ? "1 attachment" : `${count} attachments`; +} + +export function buildPendingNewTasks(input: { + readonly queuedMessages: ReadonlyArray; + readonly drafts: Readonly>; +}): ReadonlyArray { + const tasks: PendingNewTask[] = []; + for (const message of input.queuedMessages) { + if (!message.creation) { + continue; + } + tasks.push({ + kind: "pending", + key: `pending-task:${message.messageId}`, + environmentId: message.environmentId, + projectId: message.creation.projectId, + projectTitle: message.creation.projectTitle, + projectCwd: message.creation.projectCwd, + branch: message.creation.branch, + title: deriveThreadTitleFromPrompt(message.text), + createdAt: message.createdAt, + message, + creation: message.creation, + }); + } + for (const [draftKey, draft] of Object.entries(input.drafts)) { + if (!isListedNewTaskDraft(draftKey, draft) || !draft.project) { + continue; + } + tasks.push({ + kind: "draft", + key: `draft-task:${draftKey}`, + environmentId: draft.project.environmentId, + projectId: draft.project.projectId, + projectTitle: undefined, + projectCwd: undefined, + branch: draft.workspaceSelection?.branch ?? null, + title: draftTitle(draft), + createdAt: draft.project.createdAt, + draftKey, + draft, + }); + } + // Drafts are what the user is writing now, so they lead; within each kind, + // newest first. + tasks.sort((left, right) => { + if (left.kind !== right.kind) { + return left.kind === "draft" ? -1 : 1; + } + return right.createdAt.localeCompare(left.createdAt) || left.key.localeCompare(right.key); + }); + return tasks; +} diff --git a/apps/mobile/src/state/pending-thread-creation.test.ts b/apps/mobile/src/state/pending-thread-creation.test.ts new file mode 100644 index 0000000000..6588339bb9 --- /dev/null +++ b/apps/mobile/src/state/pending-thread-creation.test.ts @@ -0,0 +1,278 @@ +import { + CommandId, + EnvironmentId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + isPendingThreadCreationVisible, + pendingThreadCreationMessage, + pendingThreadCreationShell, + resolvePendingThreadCreation, + type PendingThreadCreation, +} from "./pending-thread-creation"; +import type { QueuedThreadMessage } from "./thread-outbox-model"; + +const creation: QueuedThreadMessage = { + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), + messageId: MessageId.make("message-1"), + commandId: CommandId.make("command-1"), + text: "Fix the flaky login test", + attachments: [ + { + id: "draft-image", + type: "image", + name: "screen.png", + mimeType: "image/png", + sizeBytes: 10, + previewUri: "data:image/png;base64,AAAA", + }, + ], + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }, + runtimeMode: "full-access", + creation: { + projectId: ProjectId.make("project-1"), + workspaceMode: "worktree", + branch: "main", + worktreePath: null, + }, + createdAt: "2026-08-24T12:00:00.000Z", +}; + +describe("resolvePendingThreadCreation", () => { + const threadKey = `${creation.environmentId}:${creation.threadId}`; + const pending: PendingThreadCreation = { message: creation, outcome: null }; + const prompt = { id: creation.messageId }; + + it("keeps setup visible through the prompt echo and shell cleanup until detail has a turn", () => { + let previous = resolvePendingThreadCreation({ + threadKey, + pending, + previous: null, + detail: null, + }); + expect(previous).toBe(pending); + + previous = resolvePendingThreadCreation({ + threadKey, + pending, + previous, + detail: { messages: [], latestTurn: null, session: null }, + }); + expect(previous).toBe(pending); + + // The user message arrives before the provider publishes a timed turn. + previous = resolvePendingThreadCreation({ + threadKey, + pending, + previous, + detail: { messages: [prompt], latestTurn: null, session: { status: "starting" } }, + }); + expect(previous).toBe(pending); + + // The shell stream may observe the turn and collect the global outcome + // before this screen's detail stream catches up. + previous = resolvePendingThreadCreation({ + threadKey, + pending: null, + previous, + detail: { messages: [prompt], latestTurn: null, session: { status: "starting" } }, + }); + expect(previous).toBe(pending); + + expect( + resolvePendingThreadCreation({ + threadKey, + pending: null, + previous, + detail: { + messages: [prompt], + latestTurn: { turnId: "turn-1" }, + session: { status: "running" }, + }, + }), + ).toBeNull(); + }); + + it("ends when a held creation leaves the outbox", () => { + const held: PendingThreadCreation = { + message: { + ...creation, + deliveryHold: { kind: "admission-rejected", reason: "Provider refused the turn" }, + }, + outcome: null, + }; + expect( + resolvePendingThreadCreation({ threadKey, pending: null, previous: held, detail: null }), + ).toBeNull(); + expect( + resolvePendingThreadCreation({ + threadKey, + pending: null, + previous: held, + detail: { messages: [], latestTurn: null, session: null }, + }), + ).toBeNull(); + }); + + it("bridges a delivered creation until its detail takes over", () => { + const delivered: PendingThreadCreation = { + message: creation, + outcome: { kind: "delivered", message: creation }, + }; + expect( + resolvePendingThreadCreation({ threadKey, pending: null, previous: delivered, detail: null }), + ).toBe(delivered); + }); + + it("keeps the prompt until both the turn and its message have arrived", () => { + expect( + resolvePendingThreadCreation({ + threadKey, + pending, + previous: null, + detail: { messages: [], latestTurn: { turnId: "turn-1" }, session: { status: "running" } }, + }), + ).toBe(pending); + }); + + it.each(["error", "stopped", "interrupted"])("ends setup when startup is %s", (status) => { + expect( + resolvePendingThreadCreation({ + threadKey, + pending: null, + previous: pending, + detail: { messages: [prompt], latestTurn: null, session: { status } }, + }), + ).toBeNull(); + }); + + it("preserves rejected task recovery", () => { + const failed: PendingThreadCreation = { + message: creation, + outcome: { kind: "failed", message: creation, reason: "Checkout failed" }, + }; + expect( + resolvePendingThreadCreation({ + threadKey, + pending: failed, + previous: pending, + detail: { messages: [], latestTurn: null, session: { status: "error" } }, + }), + ).toBe(failed); + }); + + it("does not carry setup into another thread or invent it for existing threads", () => { + expect( + resolvePendingThreadCreation({ + threadKey: "another-thread", + pending: null, + previous: pending, + detail: null, + }), + ).toBeNull(); + expect( + resolvePendingThreadCreation({ + threadKey, + pending: null, + previous: null, + detail: null, + }), + ).toBeNull(); + }); +}); + +describe("pendingThreadCreationShell", () => { + it("shapes a queued creation as the thread shell the screen renders before creation", () => { + expect(pendingThreadCreationShell(creation)).toMatchObject({ + environmentId: creation.environmentId, + id: creation.threadId, + projectId: creation.creation!.projectId, + title: "Fix the flaky login test", + modelSelection: creation.modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + latestTurn: null, + session: null, + latestUserMessageAt: creation.createdAt, + }); + }); + + it("keeps a local task's explicit worktree path", () => { + expect( + pendingThreadCreationShell({ + ...creation, + creation: { + ...creation.creation!, + workspaceMode: "local", + worktreePath: "/repo/.worktrees/feature", + }, + })?.worktreePath, + ).toBe("/repo/.worktrees/feature"); + }); + + it("returns null for a follow-up message or a creation without a model", () => { + expect(pendingThreadCreationShell({ ...creation, creation: undefined })).toBeNull(); + expect(pendingThreadCreationShell({ ...creation, modelSelection: undefined })).toBeNull(); + }); +}); + +describe("isPendingThreadCreationVisible", () => { + const creationMessageId = String(creation.messageId); + + it("stands in before any detail has loaded", () => { + expect(isPendingThreadCreationVisible({ creationMessageId, loadedMessageIds: null })).toBe( + true, + ); + }); + + // The regression: the server creates the thread, THEN builds the worktree, + // then starts the turn. The shell and an empty detail arrive seconds before + // the prompt, and keying on the shell left the thread empty for that whole + // window. + it("keeps standing in while the created thread has no messages yet", () => { + expect(isPendingThreadCreationVisible({ creationMessageId, loadedMessageIds: [] })).toBe(true); + }); + + it("keeps standing in when the thread holds only unrelated messages", () => { + expect( + isPendingThreadCreationVisible({ creationMessageId, loadedMessageIds: ["someone-else"] }), + ).toBe(true); + }); + + it("stands down once the delivered prompt lands under the same id", () => { + expect( + isPendingThreadCreationVisible({ + creationMessageId, + loadedMessageIds: ["someone-else", creationMessageId], + }), + ).toBe(false); + }); +}); + +describe("pendingThreadCreationMessage", () => { + it("renders the queued prompt as the first user message", () => { + expect(pendingThreadCreationMessage(creation)).toEqual({ + id: creation.messageId, + role: "user", + text: creation.text, + turnId: null, + streaming: false, + createdAt: creation.createdAt, + updatedAt: creation.createdAt, + }); + }); + + // Draft attachment ids are local; the feed resolves attachment rows against + // the server and would spin forever on them. + it("omits the queued attachments rather than passing local draft ids to the feed", () => { + expect(pendingThreadCreationMessage(creation)).not.toHaveProperty("attachments"); + }); +}); diff --git a/apps/mobile/src/state/pending-thread-creation.ts b/apps/mobile/src/state/pending-thread-creation.ts new file mode 100644 index 0000000000..bacfd6b40c --- /dev/null +++ b/apps/mobile/src/state/pending-thread-creation.ts @@ -0,0 +1,170 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import type { OrchestrationThread } from "@t3tools/contracts"; +import { DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import { deriveThreadTitleFromPrompt } from "../lib/projectThreadStartTurn"; +import { scopedThreadKey } from "../lib/scopedEntities"; +import { appAtomRegistry } from "./atom-registry"; +import type { QueuedThreadMessage } from "./thread-outbox-model"; + +/** + * A new task navigates to its thread screen the moment it is queued, before the + * server has created the thread. Until the shell arrives the screen renders a + * stand-in built from the queued creation. The outcome recorded by the outbox + * drain covers the two windows that stand-in cannot: the gap between delivery + * and the first turn (keep showing setup) and a rejected creation + * (the drain restored the content into the project draft; offer to reopen it). + */ +export type PendingThreadCreationOutcome = + | { readonly kind: "delivered"; readonly message: QueuedThreadMessage } + | { readonly kind: "failed"; readonly message: QueuedThreadMessage; readonly reason: string }; + +export type PendingThreadCreation = { + readonly message: QueuedThreadMessage; + readonly outcome: PendingThreadCreationOutcome | null; +}; + +/** Keep the screen's creation state until its detail can take over the pill. */ +export function resolvePendingThreadCreation(input: { + readonly threadKey: string | null; + readonly pending: PendingThreadCreation | null; + readonly previous: PendingThreadCreation | null; + readonly detail: { + readonly messages: ReadonlyArray<{ readonly id: string }>; + readonly latestTurn: { readonly turnId: string } | null; + readonly session: { readonly status: string } | null; + } | null; +}): PendingThreadCreation | null { + // A held creation never delivers or fails on its own; once it leaves the + // outbox it was deleted or retargeted, so there is nothing left to bridge. + const previous = input.previous; + const retainedPrevious = + previous !== null && previous.outcome === null && previous.message.deliveryHold !== undefined + ? null + : previous; + const creation = input.pending ?? retainedPrevious; + if ( + creation === null || + scopedThreadKey(creation.message.environmentId, creation.message.threadId) !== input.threadKey + ) { + return null; + } + if (creation.outcome?.kind === "failed") return creation; + const detail = input.detail; + if ( + detail?.session?.status === "error" || + detail?.session?.status === "stopped" || + detail?.session?.status === "interrupted" + ) + return null; + // Message delivery and turn startup are separate events. The prompt alone + // cannot replace the preparing pill; wait for the turn's timing too. Retain + // the local creation if the outbox has already collected its shell outcome. + if ( + detail !== null && + detail.latestTurn !== null && + !isPendingThreadCreationVisible({ + creationMessageId: creation.message.messageId, + loadedMessageIds: detail.messages.map((message) => message.id), + }) + ) { + return null; + } + return creation; +} + +export const pendingThreadCreationOutcomesAtom = Atom.make< + Readonly> +>({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:pending-thread-creation:outcomes")); + +export function recordPendingThreadCreationOutcome(outcome: PendingThreadCreationOutcome): void { + const key = scopedThreadKey(outcome.message.environmentId, outcome.message.threadId); + appAtomRegistry.set(pendingThreadCreationOutcomesAtom, { + ...appAtomRegistry.get(pendingThreadCreationOutcomesAtom), + [key]: outcome, + }); +} + +export function clearPendingThreadCreationOutcome(threadKey: string): void { + const current = appAtomRegistry.get(pendingThreadCreationOutcomesAtom); + if (!current[threadKey]) { + return; + } + const next = { ...current }; + delete next[threadKey]; + appAtomRegistry.set(pendingThreadCreationOutcomesAtom, next); +} + +/** + * Whether the queued prompt still has to stand in for the real message. + * + * The server creates the thread, then builds the worktree, and only then + * starts the turn, so the thread shell and an empty detail arrive seconds + * ahead of the prompt. Keying this on the shell's arrival left the thread + * showing "No conversation yet" for that whole window. The queued message id + * is reused as the delivered message id, so its presence is the exact signal. + */ +export function isPendingThreadCreationVisible(input: { + readonly creationMessageId: string; + /** Null while no detail has loaded; empty during a worktree checkout. */ + readonly loadedMessageIds: ReadonlyArray | null; +}): boolean { + return !input.loadedMessageIds?.includes(input.creationMessageId); +} + +export function pendingThreadCreationMessage( + message: QueuedThreadMessage, +): OrchestrationThread["messages"][number] { + return { + id: message.messageId, + role: "user", + text: message.text, + // Deliberately no attachments. Their ids are local draft ids the server + // cannot resolve, so the feed's attachment rows would sit on a spinner + // that only ends when the real message arrives — and never, if the + // creation is rejected. The delivered message renders them moments later. + turnId: null, + streaming: false, + createdAt: message.createdAt, + updatedAt: message.createdAt, + }; +} + +/** + * Thread shell shaped from a queued creation. `modelSelection` is required on + * the shell; a creation is only sendable with one, so the fallback never sends. + */ +export function pendingThreadCreationShell( + message: QueuedThreadMessage, +): EnvironmentThreadShell | null { + const creation = message.creation; + if (!creation || !message.modelSelection) { + return null; + } + return { + environmentId: message.environmentId, + id: message.threadId, + projectId: creation.projectId, + title: deriveThreadTitleFromPrompt(message.text), + modelSelection: message.modelSelection, + runtimeMode: message.runtimeMode ?? DEFAULT_RUNTIME_MODE, + interactionMode: message.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE, + branch: creation.branch, + worktreePath: creation.workspaceMode === "worktree" ? null : creation.worktreePath, + linkedPullRequest: null, + latestTurn: null, + createdAt: message.createdAt, + updatedAt: message.createdAt, + archivedAt: null, + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, + session: null, + latestUserMessageAt: message.createdAt, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }; +} diff --git a/apps/mobile/src/state/recover-failed-thread-draft.ts b/apps/mobile/src/state/recover-failed-thread-draft.ts new file mode 100644 index 0000000000..4463a9802b --- /dev/null +++ b/apps/mobile/src/state/recover-failed-thread-draft.ts @@ -0,0 +1,81 @@ +import type { QueuedThreadMessage } from "./thread-outbox-model"; +import { scopedThreadKey } from "../lib/scopedEntities"; +import { pendingTaskDraftKey, restoredNewTaskDraftKey } from "./new-task-draft-key"; +import { + appendComposerDraftAttachments, + clearComposerDraftContent, + flushComposerDrafts, + getComposerDraftSnapshot, + isComposerDraftEmpty, + mergeComposerDraftContent, + replaceComposerDraftAttachments, + setComposerDraftText, + updateComposerDraftSettings, +} from "./use-composer-drafts"; + +/** + * Seeds a queued new task's editor draft from the message. Only a fresh + * editing draft is filled; reopening mid-edit keeps the newer edits. + */ +export function hydratePendingTaskEditorDraft(message: QueuedThreadMessage): void { + const creation = message.creation; + if (!creation) return; + const draftKey = pendingTaskDraftKey(message.messageId); + if (!isComposerDraftEmpty(getComposerDraftSnapshot(draftKey))) return; + setComposerDraftText(draftKey, message.text); + replaceComposerDraftAttachments(draftKey, message.attachments); + updateComposerDraftSettings(draftKey, { + modelSelection: message.modelSelection, + providerSelectionExplicit: message.modelSelection !== undefined, + runtimeMode: message.runtimeMode, + interactionMode: message.interactionMode, + workspaceSelection: { + mode: creation.workspaceMode, + branch: creation.branch, + worktreePath: creation.worktreePath, + startFromOrigin: creation.startFromOrigin ?? false, + }, + }); +} + +/** Moves what was typed on the thread screen during setup into `targetKey`. */ +async function moveSetupEdits(message: QueuedThreadMessage, targetKey: string): Promise { + const sourceKey = scopedThreadKey(message.environmentId, message.threadId); + const source = getComposerDraftSnapshot(sourceKey); + await mergeComposerDraftContent(targetKey, { text: source.text, attachments: [] }); + const existingIds = new Set( + getComposerDraftSnapshot(targetKey).attachments.map((attachment) => attachment.id), + ); + appendComposerDraftAttachments( + targetKey, + source.attachments.filter((attachment) => !existingIds.has(attachment.id)), + { allowOverflow: true }, + ); + // Recovery may exceed the send cap. Preserve every file and let the editor + // ask the user to remove extras; never discard them during a failed send. + await flushComposerDrafts(); + clearComposerDraftContent(sourceKey); +} + +function hasSetupEdits(message: QueuedThreadMessage): boolean { + const source = getComposerDraftSnapshot(scopedThreadKey(message.environmentId, message.threadId)); + return source.text.length > 0 || source.attachments.length > 0; +} + +/** Move unsent setup edits into the restored task before reopening its editor. */ +export async function recoverFailedThreadDraft(message: QueuedThreadMessage): Promise { + if (!hasSetupEdits(message)) return; + await moveSetupEdits(message, restoredNewTaskDraftKey(message.messageId)); +} + +/** + * A held creation stays queued, so its content opens in the pending-task + * editor. Fill that editor from the message first, then add what was typed + * on the thread screen during setup; the thread's own draft belongs to a + * thread the server never created. + */ +export async function recoverHeldCreationDraft(message: QueuedThreadMessage): Promise { + if (!message.creation || !hasSetupEdits(message)) return; + hydratePendingTaskEditorDraft(message); + await moveSetupEdits(message, pendingTaskDraftKey(message.messageId)); +} diff --git a/apps/mobile/src/state/thread-order.test.ts b/apps/mobile/src/state/thread-order.test.ts index 4959ad9896..92db3a89b2 100644 --- a/apps/mobile/src/state/thread-order.test.ts +++ b/apps/mobile/src/state/thread-order.test.ts @@ -24,6 +24,10 @@ vi.mock("./server", async () => { const { Atom } = await import("effect/unstable/reactivity"); return { environmentServerConfigsAtom: Atom.make(new Map()).pipe(Atom.keepAlive) }; }); +vi.mock("./use-thread-outbox", async () => { + const { Atom } = await import("effect/unstable/reactivity"); + return { queuedThreadKeysAtom: Atom.make(new Set()).pipe(Atom.keepAlive) }; +}); // The mocked shell source is writable so tests can deliver canonical upserts. const shellsAtom = environmentThreadShells.threadShellsAtom as Atom.Writable< diff --git a/apps/mobile/src/state/thread-order.ts b/apps/mobile/src/state/thread-order.ts index 0fb57fc826..f83252e9cb 100644 --- a/apps/mobile/src/state/thread-order.ts +++ b/apps/mobile/src/state/thread-order.ts @@ -10,6 +10,12 @@ import { getThreadListV2OrderedSection } from "../features/threads/threadListV2" import { appAtomRegistry } from "./atom-registry"; import { environmentServerConfigsAtom } from "./server"; import { environmentThreadShells } from "./threads"; +import { queuedThreadKeysAtom } from "./use-thread-outbox"; + +// Covers lifecycle commands before a cross-section move can acquire an order hold. +export const threadArrangementOpenAtom = Atom.make(false).pipe(Atom.keepAlive); + +export const threadDropBusyAtom = Atom.make(false).pipe(Atom.keepAlive); export const pendingThreadOrderAtom = Atom.make(null).pipe( Atom.keepAlive, @@ -50,6 +56,7 @@ export function beginPendingThreadOrder(pending: PendingThreadOrder) { threads: appAtomRegistry.get(environmentThreadShells.threadShellsAtom), section: current.section, now: new Date().toISOString(), + queuedThreadKeys: appAtomRegistry.get(queuedThreadKeysAtom), settlementEnvironmentIds: new Set( [...configs].flatMap(([id, config]) => config.environment.capabilities.threadSettlement === true ? [id] : [], @@ -70,6 +77,7 @@ export function beginPendingThreadOrder(pending: PendingThreadOrder) { unsubscribers.push( appAtomRegistry.subscribe(environmentThreadShells.threadShellsAtom, refresh), appAtomRegistry.subscribe(environmentServerConfigsAtom, refresh), + appAtomRegistry.subscribe(queuedThreadKeysAtom, refresh), ); return { isPending: () => { diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index 4176740958..1463a4872b 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -3,6 +3,7 @@ import { CommandId, EnvironmentId, MessageId, + ProjectId, ProviderInstanceId, ThreadId, } from "@t3tools/contracts"; @@ -147,7 +148,9 @@ vi.mock("../features/sharing/incoming-share-storage", () => ({ import type { DraftComposerAttachment } from "../lib/composerImages"; import { appAtomRegistry } from "./atom-registry"; +import { recoverHeldCreationDraft } from "./recover-failed-thread-draft"; import { threadOutboxManager } from "./thread-outbox"; +import type { QueuedThreadMessage } from "./thread-outbox-model"; import { appendComposerDraftAttachments, archiveCloudComposerDrafts, @@ -157,14 +160,15 @@ import { ComposerDraftPersistenceError, composerDraftsAtom, composerCloudDraftsAtom, - copyComposerDraftContentIfEmpty, - copyComposerDraftContentState, + createNewTaskDraft, decodePersistedComposerState, ensureComposerDraftsLoaded, type ComposerDraft, + findNewTaskDraftKeys, flushComposerDrafts, getComposerDraftSnapshot, mergeComposerDraftContentState, + migrateLegacyNewTaskDraft, releaseUnusedComposerAttachmentFiles, removeComposerDraftsForEnvironment, resetComposerDraftsLoadState, @@ -172,6 +176,7 @@ import { restoreComposerDraftSnapshotState, restoreCloudComposerDrafts, restorePendingSendComposerDraftState, + retargetNewTaskDraft, setComposerDraftText, setComposerDraftAttachmentUpload, waitForComposerDraftsLoaded, @@ -179,6 +184,7 @@ import { stickyComposerModelSelectionAtom, undoComposerDraftMerge, undoComposerDraftMergeState, + updateComposerDraftSettings, } from "./use-composer-drafts"; const DRAFT: ComposerDraft = { @@ -211,37 +217,6 @@ afterEach(() => { describe("mobile composer drafts", () => { // Hydration is one-shot per module instance and the attachment sweep now // triggers it too, so this test must observe it before any sweep test runs. - it("waits for persisted drafts before copying content between projects", async () => { - const sourceKey = "new-task:environment-1:project-1"; - const targetKey = "new-task:environment-1:project-2"; - const unrelatedKey = "environment-1:thread-1"; - const source = { text: "Current task", attachments: [] } satisfies ComposerDraft; - const target = { text: "Persisted target", attachments: [] } satisfies ComposerDraft; - const unrelated = { text: "Keep me", attachments: [] } satisfies ComposerDraft; - - composerDraftFileMocks.setDocument({ - schemaVersion: 1, - drafts: { - [targetKey]: target, - [unrelatedKey]: unrelated, - }, - }); - composerDraftFileMocks.blockRead(); - appAtomRegistry.set(composerDraftsAtom, { [sourceKey]: source }); - - const copy = copyComposerDraftContentIfEmpty(sourceKey, targetKey); - expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({ [sourceKey]: source }); - - composerDraftFileMocks.releaseRead(); - await copy; - - expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({ - [sourceKey]: source, - [targetKey]: target, - [unrelatedKey]: unrelated, - }); - }); - it("hydrates generic file attachments from their saved local paths", () => { const file = { id: "file-1", @@ -1069,7 +1044,7 @@ describe("mobile composer drafts", () => { }); it("hydrates selector state even when the message content is empty", () => { - expect( + const hydrated = Object.entries( decodePersistedComposerState({ schemaVersion: 1, drafts: { @@ -1092,27 +1067,34 @@ describe("mobile composer drafts", () => { }, }, }).drafts, - ).toEqual({ - "new-task:environment-1:project-1": { - text: "", - attachments: [], - modelSelection: { - instanceId: "codex", - model: "gpt-5.4", - options: [{ id: "reasoningEffort", value: "xhigh" }], - }, - providerSelectionExplicit: true, - runtimeMode: "approval-required", - interactionMode: "plan", - workspaceSelection: { - mode: "worktree", - branch: "main", - worktreePath: null, - }, + ); + expect(hydrated).toHaveLength(1); + const [key, draft] = hydrated[0]!; + // Legacy project keys are rewritten to id keys on load. + expect(key).toMatch(/^new-task:[0-9a-z-]+$/); + expect(draft).toEqual({ + text: "", + attachments: [], + modelSelection: { + instanceId: "codex", + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, + providerSelectionExplicit: true, + runtimeMode: "approval-required", + interactionMode: "plan", + workspaceSelection: { + mode: "worktree", + branch: "main", + worktreePath: null, + }, + project: { + environmentId: "environment-1", + projectId: "project-1", + createdAt: expect.any(String), }, }); }); - it("keeps legacy content-only drafts and rejects invalid selector state", () => { expect( decodePersistedComposerState({ @@ -1147,7 +1129,7 @@ describe("mobile composer drafts", () => { // The stale-model strip must not touch receipt-bearing drafts, and the // empty filter must keep them — or the same share would re-import after // restart. - expect( + const stripped = Object.values( decodePersistedComposerState({ schemaVersion: 1, drafts: { @@ -1160,20 +1142,170 @@ describe("mobile composer drafts", () => { }, }, }).drafts, - ).toEqual({ - "new-task:environment-1:project-1": { - text: "", - attachments: [], - importedShareIds: ["share-1"], - }, + ); + expect(stripped).toHaveLength(1); + expect(stripped[0]).toMatchObject({ + text: "", + attachments: [], + importedShareIds: ["share-1"], + project: { environmentId: "environment-1", projectId: "project-1" }, }); + expect(stripped[0]?.modelSelection).toBeUndefined(); - expect( + const kept = Object.values( decodePersistedComposerState({ schemaVersion: 1, drafts: { "new-task:environment-1:project-1": receiptDraft }, }).drafts, - ).toEqual({ "new-task:environment-1:project-1": receiptDraft }); + ); + expect(kept).toHaveLength(1); + expect(kept[0]).toMatchObject(receiptDraft); + }); + + it("migrates archived signed-out new-task drafts the same way as live ones", () => { + const decoded = decodePersistedComposerState({ + schemaVersion: 1, + drafts: {}, + cloudAccountId: "account-1", + signedOutDrafts: { + "account-1": { + drafts: { "new-task:environment-1:project-1": { text: "archived", attachments: [] } }, + queuedMessages: [], + }, + }, + }); + const archived = Object.entries(decoded.cloudDrafts.signedOut["account-1"]?.drafts ?? {}); + expect(archived).toHaveLength(1); + expect(archived[0]?.[0]).toMatch(/^new-task:[0-9a-z]+-[0-9a-z]+$/); + expect(archived[0]?.[1]).toMatchObject({ + text: "archived", + project: { environmentId: "environment-1", projectId: "project-1" }, + }); + }); + + it("migrates project-keyed new-task drafts to id keys with the project stamped in", () => { + const now = "2026-09-05T12:00:00.000Z"; + const [key, draft] = migrateLegacyNewTaskDraft( + "new-task:environment-1:project-1", + { text: "keep me", attachments: [] }, + now, + ); + // The new key has no colon after the prefix, so it can never be + // mistaken for the legacy shape on the next load. + expect(key).toMatch(/^new-task:[0-9a-z-]+$/); + expect(draft).toEqual({ + text: "keep me", + attachments: [], + project: { + environmentId: EnvironmentId.make("environment-1"), + projectId: ProjectId.make("project-1"), + createdAt: now, + }, + }); + + // Already-migrated, thread, and pending-task keys pass through untouched. + const stamped: ComposerDraft = { + text: "x", + attachments: [], + project: { + environmentId: EnvironmentId.make("environment-1"), + projectId: ProjectId.make("project-1"), + createdAt: now, + }, + }; + expect(migrateLegacyNewTaskDraft("new-task:some-id", stamped, now)).toEqual([ + "new-task:some-id", + stamped, + ]); + expect(migrateLegacyNewTaskDraft("environment-1:thread-1", DRAFT, now)).toEqual([ + "environment-1:thread-1", + DRAFT, + ]); + expect(migrateLegacyNewTaskDraft("pending-task:message-1", DRAFT, now)).toEqual([ + "pending-task:message-1", + DRAFT, + ]); + }); + + it("keeps a freshly minted new-task draft bound until content arrives, then lists it per project", () => { + const project = { + environmentId: EnvironmentId.make("environment-1"), + projectId: ProjectId.make("project-1"), + }; + const first = createNewTaskDraft(project); + const second = createNewTaskDraft(project); + expect(first).not.toBe(second); + // Empty stamped drafts stay in memory so the composer has a key to write + // to, but the persisted document leaves them out. + expect(appAtomRegistry.get(composerDraftsAtom)[first]?.project).toMatchObject(project); + + setComposerDraftText(first, "first idea"); + setComposerDraftText(second, "second idea"); + expect(findNewTaskDraftKeys(appAtomRegistry.get(composerDraftsAtom), project)).toEqual( + expect.arrayContaining([first, second]), + ); + + // Clearing content on the way out drops the stamp with it. + clearComposerDraftContent(first, { clearModelSelection: true, clearWorkspaceSelection: true }); + expect(appAtomRegistry.get(composerDraftsAtom)[first]).toBeUndefined(); + expect(getComposerDraftSnapshot(second).text).toBe("second idea"); + }); + + it("removes a finished new-task draft entirely, including its mode choices", async () => { + const key = createNewTaskDraft({ + environmentId: EnvironmentId.make("environment-1"), + projectId: ProjectId.make("project-1"), + }); + setComposerDraftText(key, "ship it"); + updateComposerDraftSettings(key, { + runtimeMode: "approval-required", + interactionMode: "plan", + }); + await flushComposerDrafts(); + expect( + decodePersistedComposerState(JSON.parse(composerDraftFileMocks.getDocument())).drafts[key], + ).toMatchObject({ text: "ship it", runtimeMode: "approval-required" }); + + clearComposerDraftContent(key, { clearModelSelection: true, clearWorkspaceSelection: true }); + await flushComposerDrafts(); + + expect(appAtomRegistry.get(composerDraftsAtom)[key]).toBeUndefined(); + expect( + decodePersistedComposerState(JSON.parse(composerDraftFileMocks.getDocument())).drafts, + ).toEqual({}); + }); + + it("retargets a new-task draft to another project without losing its text", () => { + const from = { + environmentId: EnvironmentId.make("environment-1"), + projectId: ProjectId.make("project-1"), + }; + const to = { + environmentId: EnvironmentId.make("environment-2"), + projectId: ProjectId.make("project-2"), + }; + const key = createNewTaskDraft(from); + setComposerDraftText(key, "moving house"); + appAtomRegistry.set(composerDraftsAtom, { + ...appAtomRegistry.get(composerDraftsAtom), + [key]: { + ...getComposerDraftSnapshot(key), + runtimeMode: "approval-required", + workspaceSelection: { mode: "worktree", branch: "feature/a", worktreePath: null }, + }, + }); + const createdAt = getComposerDraftSnapshot(key).project?.createdAt; + + retargetNewTaskDraft(key, to); + + const moved = getComposerDraftSnapshot(key); + expect(moved.text).toBe("moving house"); + expect(moved.runtimeMode).toBe("approval-required"); + // Branch and worktree belong to the old repo. + expect(moved.workspaceSelection).toBeUndefined(); + expect(moved.project).toEqual({ ...to, createdAt }); + expect(findNewTaskDraftKeys(appAtomRegistry.get(composerDraftsAtom), from)).toEqual([]); + expect(findNewTaskDraftKeys(appAtomRegistry.get(composerDraftsAtom), to)).toEqual([key]); }); it("hydrates the global sticky model selection", () => { @@ -1449,56 +1581,7 @@ describe("mobile composer drafts", () => { expect(getComposerDraftSnapshot(draftKey)).toEqual(selectedDraft); }); - it("carries unfinished content to a newly selected project without overwriting its settings", () => { - const sourceKey = "new-task:environment-1:project-1"; - const targetKey = "new-task:environment-1:project-2"; - const source: ComposerDraft = { - text: "Keep this task", - attachments: [], - importedShareIds: ["share-1"], - workspaceSelection: { - mode: "worktree", - branch: "feature/source", - worktreePath: null, - }, - }; - const target: ComposerDraft = { - text: "", - attachments: [], - runtimeMode: "approval-required", - }; - - expect( - copyComposerDraftContentState( - { [sourceKey]: source, [targetKey]: target }, - sourceKey, - targetKey, - ), - ).toEqual({ - [sourceKey]: source, - [targetKey]: { - ...target, - text: source.text, - attachments: source.attachments, - importedShareIds: source.importedShareIds, - }, - }); - }); - - it("does not overwrite unfinished content already stored for the selected project", () => { - const sourceKey = "new-task:environment-1:project-1"; - const targetKey = "new-task:environment-1:project-2"; - const drafts: Record = { - [sourceKey]: { text: "Source task", attachments: [] }, - [targetKey]: { text: "Target task", attachments: [] }, - }; - - expect(copyComposerDraftContentState(drafts, sourceKey, targetKey)).toBe(drafts); - }); - - it("drops another environment's upload stamp when carrying attachments across machines", () => { - const sourceKey = "new-task:environment-1:project-1"; - const targetKey = "new-task:environment-2:project-2"; + it("drops another environment's upload stamp when a draft moves across machines", () => { const uploadedElsewhere: DraftComposerAttachment = { id: "image-1", type: "image", @@ -1516,14 +1599,25 @@ describe("mobile composer drafts", () => { uploadedAttachmentId: "upload-2", uploadEnvironmentId: EnvironmentId.make("environment-2"), }; + const key = createNewTaskDraft({ + environmentId: EnvironmentId.make("environment-1"), + projectId: ProjectId.make("project-1"), + }); + appAtomRegistry.set(composerDraftsAtom, { + ...appAtomRegistry.get(composerDraftsAtom), + [key]: { + ...getComposerDraftSnapshot(key), + text: "Ship it", + attachments: [uploadedElsewhere, uploadedOnTarget], + }, + }); - const next = copyComposerDraftContentState( - { [sourceKey]: { text: "Ship it", attachments: [uploadedElsewhere, uploadedOnTarget] } }, - sourceKey, - targetKey, - ); + retargetNewTaskDraft(key, { + environmentId: EnvironmentId.make("environment-2"), + projectId: ProjectId.make("project-2"), + }); - expect(next[targetKey]?.attachments).toEqual([ + expect(getComposerDraftSnapshot(key).attachments).toEqual([ { id: "image-1", type: "image", @@ -1535,7 +1629,6 @@ describe("mobile composer drafts", () => { }, uploadedOnTarget, ]); - expect(next[sourceKey]?.attachments).toEqual([uploadedElsewhere, uploadedOnTarget]); }); it("merges shared content into a project draft without duplicating retries", () => { @@ -1626,19 +1719,36 @@ describe("mobile composer drafts", () => { const environmentId = EnvironmentId.make("environment-cloud"); const retainedEnvironmentId = EnvironmentId.make("environment-local"); + const cloudDraft: ComposerDraft = { + ...DRAFT, + project: { + environmentId, + projectId: ProjectId.make("project-cloud"), + createdAt: "2026-09-05T00:00:00.000Z", + }, + }; + const localDraft: ComposerDraft = { + ...DRAFT, + project: { + environmentId: retainedEnvironmentId, + projectId: ProjectId.make("project-local"), + createdAt: "2026-09-05T00:00:00.000Z", + }, + }; + expect( removeComposerDraftsForEnvironment( { [`${environmentId}:thread-cloud`]: DRAFT, - [`new-task:${environmentId}:project-cloud`]: DRAFT, + "new-task:cloud-draft": cloudDraft, [`${retainedEnvironmentId}:thread-local`]: DRAFT, - [`new-task:${retainedEnvironmentId}:project-local`]: DRAFT, + "new-task:local-draft": localDraft, }, environmentId, ), ).toEqual({ [`${retainedEnvironmentId}:thread-local`]: DRAFT, - [`new-task:${retainedEnvironmentId}:project-local`]: DRAFT, + "new-task:local-draft": localDraft, }); }); @@ -1852,6 +1962,81 @@ describe("mobile composer drafts", () => { expect(composerAttachmentCleanupMocks.remove.mock.calls).toEqual([[first.fileUri]]); }); + describe("held creation recovery", () => { + // Inline images: file-backed attachments would start an async ownership + // sweep that outlives the test. + const queuedFile = { + id: "image-queued", + type: "image" as const, + name: "before.png", + mimeType: "image/png", + sizeBytes: 3, + previewUri: "data:image/png;base64,YWJj", + dataUrl: "data:image/png;base64,YWJj", + }; + const typedFile = { + id: "image-typed", + type: "image" as const, + name: "after.png", + mimeType: "image/png", + sizeBytes: 3, + previewUri: "data:image/png;base64,ZGVm", + dataUrl: "data:image/png;base64,ZGVm", + }; + const heldCreation: QueuedThreadMessage = { + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-held"), + messageId: MessageId.make("message-held"), + commandId: CommandId.make("command-held"), + text: "Fix the flaky login test", + attachments: [queuedFile], + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "approval-required", + deliveryHold: { kind: "admission-rejected", reason: "Provider refused the turn" }, + creation: { + projectId: ProjectId.make("project-1"), + workspaceMode: "worktree", + branch: "main", + worktreePath: null, + }, + createdAt: "2026-09-10T12:00:00.000Z", + }; + + it("carries text typed during setup into the held task's editor after its prompt", async () => { + appAtomRegistry.set(composerDraftsAtom, { + "environment-1:thread-held": { text: "Also check CI", attachments: [typedFile] }, + }); + + await recoverHeldCreationDraft(heldCreation); + + const editor = getComposerDraftSnapshot("pending-task:message-held"); + expect(editor).toMatchObject({ + text: "Fix the flaky login test\n\nAlso check CI", + attachments: [queuedFile, typedFile], + modelSelection: heldCreation.modelSelection, + providerSelectionExplicit: true, + runtimeMode: "approval-required", + workspaceSelection: { mode: "worktree", branch: "main", worktreePath: null }, + }); + expect(getComposerDraftSnapshot("environment-1:thread-held")).toMatchObject({ + text: "", + attachments: [], + }); + // The move is durable before the thread draft is cleared. + expect( + decodePersistedComposerState(JSON.parse(composerDraftFileMocks.getDocument())).drafts[ + "pending-task:message-held" + ]?.text, + ).toBe("Fix the flaky login test\n\nAlso check CI"); + }); + + it("leaves the editor to hydrate itself when nothing was typed", async () => { + await recoverHeldCreationDraft(heldCreation); + + expect(appAtomRegistry.get(composerDraftsAtom)["pending-task:message-held"]).toBeUndefined(); + }); + }); + // Uses a fresh module instance (hydration is one-shot), so it stays last. it("hydrates persisted drafts before a cold-start sweep deletes their files", async () => { const file = { diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index fa56a2dfa1..8559f2d791 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -1,11 +1,14 @@ import { useAtomValue } from "@effect/atom-react"; import { + EnvironmentId as EnvironmentIdSchema, ModelSelection as ModelSelectionSchema, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + ProjectId as ProjectIdSchema, ProviderInteractionMode as ProviderInteractionModeSchema, RuntimeMode as RuntimeModeSchema, type EnvironmentId, type ModelSelection, + type ProjectId, type ProviderInteractionMode, type RuntimeMode, } from "@t3tools/contracts"; @@ -23,6 +26,11 @@ import { import type { DraftComposerAttachment, FileBackedComposerAttachment } from "../lib/composerImages"; import { SerializedAsyncQueue } from "../lib/serialized-async-queue"; import { appAtomRegistry } from "./atom-registry"; +import { + isNewTaskDraftKey, + newTaskDraftKey, + parseLegacyNewTaskDraftKey, +} from "./new-task-draft-key"; import { decodeQueuedThreadMessage, encodeQueuedThreadMessage, @@ -61,6 +69,18 @@ export interface ComposerDraft { readonly runtimeMode?: RuntimeMode; readonly interactionMode?: ProviderInteractionMode; readonly workspaceSelection?: ComposerDraftWorkspaceSelection; + /** + * Set on new-task drafts only. The project is stored here rather than in + * the key so a project can hold any number of drafts and a draft can be + * retargeted to another project without changing identity. + */ + readonly project?: ComposerDraftProject; +} + +export interface ComposerDraftProject { + readonly environmentId: EnvironmentId; + readonly projectId: ProjectId; + readonly createdAt: string; } export interface ComposerDraftContent { @@ -83,6 +103,7 @@ export type ComposerDraftSettingsUpdate = Pick< | "runtimeMode" | "interactionMode" | "workspaceSelection" + | "project" >; export interface PendingSendComposerSnapshot { @@ -101,6 +122,12 @@ const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({ startFromOrigin: Schema.optional(Schema.Boolean), }); +const ComposerDraftProjectSchema = Schema.Struct({ + environmentId: EnvironmentIdSchema, + projectId: ProjectIdSchema, + createdAt: Schema.String, +}); + const ComposerDraftSchema = Schema.Struct({ text: Schema.String, attachments: Schema.Array(DraftComposerAttachmentSchema), @@ -110,6 +137,7 @@ const ComposerDraftSchema = Schema.Struct({ runtimeMode: Schema.optional(RuntimeModeSchema), interactionMode: Schema.optional(ProviderInteractionModeSchema), workspaceSelection: Schema.optional(ComposerDraftWorkspaceSelectionSchema), + project: Schema.optional(ComposerDraftProjectSchema), }); const PersistedComposerDraftsSchema = Schema.Struct({ @@ -192,6 +220,8 @@ export function isComposerDraftEmpty(draft: ComposerDraft): boolean { return isEmptyDraft(draft); } +// The project stamp is identity, not content: a new-task draft with nothing +// else in it is still empty and gets dropped like any other. function isEmptyDraft(draft: ComposerDraft): boolean { return ( draft.text.length === 0 && @@ -204,26 +234,80 @@ function isEmptyDraft(draft: ComposerDraft): boolean { ); } +/** + * Writes a draft back, dropping it once empty. A new-task draft keeps its + * entry while the composer is bound to it (the project stamp is what the + * composer binds to); the persist sweep still leaves empty ones off disk. + */ +function withComposerDraft( + current: Record, + draftKey: string, + draft: ComposerDraft, +): Record { + if (isEmptyDraft(draft) && draft.project === undefined) { + const next = { ...current }; + delete next[draftKey]; + return next; + } + return { ...current, [draftKey]: draft }; +} + +export { isNewTaskDraftKey, newTaskDraftKey } from "./new-task-draft-key"; + +// Draft ids only need to be unique within this device's draft file. Deriving +// them from time plus randomness keeps this module free of native imports, +// which the persistence tests rely on. +function newDraftId(): string { + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +} + +/** + * Project-keyed new-task drafts from earlier builds are rewritten on load into + * id-keyed drafts with the project stamped in, so existing drafts survive the + * switch to many-per-project. + */ +export function migrateLegacyNewTaskDraft( + key: string, + draft: ComposerDraft, + now: string, +): readonly [key: string, draft: ComposerDraft] { + const legacy = draft.project === undefined ? parseLegacyNewTaskDraftKey(key) : null; + if (legacy === null) { + return [key, draft]; + } + return [ + newTaskDraftKey(newDraftId()), + { + ...draft, + project: { + environmentId: EnvironmentIdSchema.make(legacy.environmentId), + projectId: ProjectIdSchema.make(legacy.projectId), + createdAt: now, + }, + }, + ]; +} + export function decodePersistedComposerState(value: unknown): { readonly drafts: Record; readonly stickyModelSelection: ModelSelection | null; readonly cloudDrafts: ComposerCloudDraftState; } { const parsed = decodePersistedComposerDraftsDocument(value); + const now = new Date().toISOString(); return { drafts: Object.fromEntries( Object.entries(parsed.drafts) - .map( - ([key, draft]) => - [ - key, - // Stale new-task drafts left on disk by builds before the - // model-precedence fix carry a bare modelSelection with no - // other selector settings. Strip it so the next compose pass - // re-resolves project → sticky → provider defaults. Drafts - // with runtime/interaction/workspace settings or actual text / - // attachments were deliberately configured and are left alone. - key.startsWith("new-task:") && + .map(([key, draft]) => + migrateLegacyNewTaskDraft( + key, + // Stale new-task drafts left on disk by builds before the + // model-precedence fix carry a bare modelSelection with no + // other selector settings. Strip it so the next compose pass + // re-resolves project → sticky → provider defaults. Drafts + // with runtime/interaction/workspace settings or actual text / + // attachments were deliberately configured and are left alone. + isNewTaskDraftKey(key) && draft.modelSelection && draft.providerSelectionExplicit !== true && draft.text.length === 0 && @@ -231,9 +315,10 @@ export function decodePersistedComposerState(value: unknown): { draft.runtimeMode === undefined && draft.interactionMode === undefined && draft.workspaceSelection === undefined - ? { ...draft, modelSelection: undefined } - : draft, - ] as const, + ? { ...draft, modelSelection: undefined } + : draft, + now, + ), ) // importedShareIds are share-import receipts: a contentless draft // carrying one is not empty, or the same native share would be @@ -247,7 +332,13 @@ export function decodePersistedComposerState(value: unknown): { Object.entries(parsed.signedOutDrafts ?? {}).map(([id, saved]) => [ id, { - drafts: saved.drafts, + // Archived drafts come back through restoreCloudComposerDrafts + // without another decode, so they get the same key migration. + drafts: Object.fromEntries( + Object.entries(saved.drafts).map(([key, draft]) => + migrateLegacyNewTaskDraft(key, draft, now), + ), + ), queuedMessages: saved.queuedMessages.map(decodeQueuedThreadMessage), }, ]), @@ -640,7 +731,7 @@ export async function archiveCloudComposerDrafts( const remaining = { ...current }; const savedDrafts = { ...cloud.signedOut[owner]?.drafts }; for (const [key, draft] of Object.entries(current)) { - const environmentId = composerDraftEnvironmentId(key, queued); + const environmentId = composerDraftEnvironmentId(key, queued, draft); if (environmentId !== null && environmentIds.has(environmentId)) { savedDrafts[key] = draft; delete remaining[key]; @@ -826,15 +917,7 @@ export function setComposerDraftText(draftKey: string, value: string): void { ...normalizeDraft(current[draftKey]), text: value, }; - if (isEmptyDraft(draft)) { - const next = { ...current }; - delete next[draftKey]; - return next; - } - return { - ...current, - [draftKey]: draft, - }; + return withComposerDraft(current, draftKey, draft); }); } @@ -899,15 +982,7 @@ export function replaceComposerDraftAttachments( ...normalizeDraft(current[draftKey]), attachments, }; - if (isEmptyDraft(draft)) { - const next = { ...current }; - delete next[draftKey]; - return next; - } - return { - ...current, - [draftKey]: draft, - }; + return withComposerDraft(current, draftKey, draft); }); const retainedIds = new Set(attachments.map((attachment) => attachment.id)); scheduleUnusedComposerAttachmentCleanup( @@ -923,15 +998,7 @@ export function removeComposerDraftAttachment(draftKey: string, imageId: string) ...existing, attachments: existing.attachments.filter((image) => image.id !== imageId), }; - if (isEmptyDraft(draft)) { - const next = { ...current }; - delete next[draftKey]; - return next; - } - return { - ...current, - [draftKey]: draft, - }; + return withComposerDraft(current, draftKey, draft); }); scheduleUnusedComposerAttachmentCleanup( previousAttachments.filter((attachment) => attachment.id === imageId), @@ -982,15 +1049,7 @@ export function updateComposerDraftSettings( ...normalizeDraft(current[draftKey]), ...settings, }; - if (isEmptyDraft(draft)) { - const next = { ...current }; - delete next[draftKey]; - return next; - } - return { - ...current, - [draftKey]: draft, - }; + return withComposerDraft(current, draftKey, draft); }); } @@ -1062,11 +1121,21 @@ export function clearComposerDraftContentState( if (!existing) { return current; } + // Clearing content is the "this draft is done" moment (sent, queued, or + // discarded). A new-task draft id is never reused, so the whole entry goes: + // retained mode choices would otherwise persist under a key nothing can + // reach again. + if (isNewTaskDraftKey(draftKey)) { + const next = { ...current }; + delete next[draftKey]; + return next; + } const { importedShareIds: _importedShareIds, modelSelection, providerSelectionExplicit, workspaceSelection, + project: _project, ...retained } = existing; const draft = { @@ -1108,49 +1177,6 @@ export function restoreComposerDraftSnapshotState( return next; } -export function copyComposerDraftContentState( - current: Record, - sourceDraftKey: string, - targetDraftKey: string, -): Record { - if (sourceDraftKey === targetDraftKey) { - return current; - } - const source = normalizeDraft(current[sourceDraftKey]); - const target = normalizeDraft(current[targetDraftKey]); - const sourceHasContent = - source.text.length > 0 || - source.attachments.length > 0 || - (source.importedShareIds?.length ?? 0) > 0; - const targetHasContent = - target.text.length > 0 || - target.attachments.length > 0 || - (target.importedShareIds?.length ?? 0) > 0; - if (!sourceHasContent || targetHasContent) { - return current; - } - // Pending uploads live on one server. Crossing environments keeps the local - // bytes (the upload worker re-sends them to the new key's environment) but - // drops the old stamp, so it cannot pin the source environment's pending - // upload alive from the copy. - const targetEnvironmentId = composerDraftEnvironmentId(targetDraftKey, []); - const attachments = source.attachments.map((attachment) => - attachment.uploadEnvironmentId !== undefined && - attachment.uploadEnvironmentId !== targetEnvironmentId - ? stripAttachmentUploadReference(attachment) - : attachment, - ); - return { - ...current, - [targetDraftKey]: { - ...target, - text: source.text, - attachments, - ...(source.importedShareIds ? { importedShareIds: source.importedShareIds } : {}), - }, - }; -} - function stripAttachmentUploadReference( attachment: DraftComposerAttachment, ): DraftComposerAttachment { @@ -1158,19 +1184,6 @@ function stripAttachmentUploadReference( return rest; } -export async function copyComposerDraftContentIfEmpty( - sourceDraftKey: string, - targetDraftKey: string, -): Promise { - ensureComposerDraftsLoaded(); - if (loadPromise !== null) { - await loadPromise; - } - updateComposerDrafts((current) => - copyComposerDraftContentState(current, sourceDraftKey, targetDraftKey), - ); -} - function mergeComposerDraftText(existing: string, incoming: string): string { if (incoming.length === 0) { return existing; @@ -1334,7 +1347,12 @@ export function undoComposerDraftMergeState( // A setting still holding the merge's value is the merge's doing: restore // the snapshot's. One the user changed since the merge stays theirs. const undoSetting = < - K extends "modelSelection" | "runtimeMode" | "interactionMode" | "workspaceSelection", + K extends + | "modelSelection" + | "providerSelectionExplicit" + | "runtimeMode" + | "interactionMode" + | "workspaceSelection", >( key: K, ): ComposerDraft[K] => (existing[key] === merged[key] ? snapshot[key] : existing[key]); @@ -1351,19 +1369,12 @@ export function undoComposerDraftMergeState( (attachment) => !insertedAttachmentIds.has(attachment.id), ), modelSelection: undoSetting("modelSelection"), + providerSelectionExplicit: undoSetting("providerSelectionExplicit"), runtimeMode: undoSetting("runtimeMode"), interactionMode: undoSetting("interactionMode"), workspaceSelection: undoSetting("workspaceSelection"), }; - if (isEmptyDraft(draft)) { - const next = { ...current }; - delete next[draftKey]; - return next; - } - return { - ...current, - [draftKey]: draft, - }; + return withComposerDraft(current, draftKey, draft); } /** Applies undoComposerDraftMergeState and lands it durably. */ @@ -1435,15 +1446,100 @@ export function removeComposerDraftsForEnvironment( environmentId: EnvironmentId, ): Record { const environmentPrefix = `${environmentId}:`; - const newTaskPrefix = `new-task:${environmentId}:`; return Object.fromEntries( Object.entries(drafts).filter( - ([draftKey]) => - !draftKey.startsWith(environmentPrefix) && !draftKey.startsWith(newTaskPrefix), + ([draftKey, draft]) => + !draftKey.startsWith(environmentPrefix) && draft.project?.environmentId !== environmentId, ), ); } +/** + * Mints a new-task draft for a project. The entry is published immediately so + * the composer can bind to its key before the user types; it stays out of the + * list until it has content, and the empty-draft sweep drops it on persist if + * nothing is ever written. + */ +export function createNewTaskDraft(project: { + readonly environmentId: EnvironmentId; + readonly projectId: ProjectId; +}): string { + const draftKey = newTaskDraftKey(newDraftId()); + const stamp: ComposerDraftProject = { + environmentId: project.environmentId, + projectId: project.projectId, + createdAt: new Date().toISOString(), + }; + updateComposerDrafts((current) => ({ + ...current, + [draftKey]: { ...EMPTY_DRAFT, project: stamp }, + })); + return draftKey; +} + +/** + * Points an existing new-task draft at a different project, keeping its + * content and identity. Workspace selection is project-specific (branch, + * worktree), so it is cleared; model and mode choices carry over. + */ +export function retargetNewTaskDraft( + draftKey: string, + project: { readonly environmentId: EnvironmentId; readonly projectId: ProjectId }, +): void { + updateComposerDrafts((current) => { + const existing = current[draftKey]; + const stamp = existing?.project; + if ( + stamp !== undefined && + stamp.environmentId === project.environmentId && + stamp.projectId === project.projectId + ) { + return current; + } + const { workspaceSelection: _workspaceSelection, ...retained } = normalizeDraft(existing); + // Pending uploads live on one server. Crossing environments keeps the + // local bytes (the upload worker re-sends them to the new environment) + // but drops the old stamp, so it cannot pin the source environment's + // pending upload alive from the moved draft. + const attachments = retained.attachments.map((attachment) => + attachment.uploadEnvironmentId !== undefined && + attachment.uploadEnvironmentId !== project.environmentId + ? stripAttachmentUploadReference(attachment) + : attachment, + ); + return { + ...current, + [draftKey]: { + ...retained, + attachments, + project: { + environmentId: project.environmentId, + projectId: project.projectId, + createdAt: stamp?.createdAt ?? new Date().toISOString(), + }, + }, + }; + }); +} + +/** New-task drafts for a project, newest first. */ +export function findNewTaskDraftKeys( + drafts: Readonly>, + project: { readonly environmentId: EnvironmentId; readonly projectId: ProjectId }, +): ReadonlyArray { + return Object.entries(drafts) + .filter( + ([key, draft]) => + isNewTaskDraftKey(key) && + draft.project?.environmentId === project.environmentId && + draft.project.projectId === project.projectId, + ) + .sort(([, left], [, right]) => + (right.project?.createdAt ?? "").localeCompare(left.project?.createdAt ?? ""), + ) + .map(([key]) => key); +} + export async function clearComposerDraftsEnvironment(environmentId: EnvironmentId): Promise { ensureComposerDraftsLoaded(); if (loadPromise !== null) { diff --git a/apps/mobile/src/state/use-pending-new-tasks.ts b/apps/mobile/src/state/use-pending-new-tasks.ts index ccfe1527b3..2ec9c5db85 100644 --- a/apps/mobile/src/state/use-pending-new-tasks.ts +++ b/apps/mobile/src/state/use-pending-new-tasks.ts @@ -1,35 +1,38 @@ +import { useAtomValue } from "@effect/atom-react"; +import { Atom } from "effect/unstable/reactivity"; import { useMemo } from "react"; -import { deriveThreadTitleFromPrompt } from "../lib/projectThreadStartTurn"; import { - flattenQueuedThreadMessages, - type QueuedThreadCreation, - type QueuedThreadMessage, -} from "./thread-outbox-model"; + buildPendingNewTasks, + makeListedNewTaskDraftsAtom, + type PendingNewTask, +} from "./pending-new-tasks-model"; +import { flattenQueuedThreadMessages } from "./thread-outbox-model"; +import { composerDraftsAtom } from "./use-composer-drafts"; import { useThreadOutboxMessages } from "./use-thread-outbox"; -/** A queued new-task creation, shaped for thread-list presentation. */ -export interface PendingNewTask { - readonly message: QueuedThreadMessage; - readonly creation: QueuedThreadCreation; - readonly title: string; -} +// The draft store changes on every keystroke in any composer; the lists only +// need the stamped new-task drafts that have content. +const listedNewTaskDraftsAtom = makeListedNewTaskDraftsAtom(composerDraftsAtom).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:pending-new-tasks:listed-drafts"), +); + +export type { + PendingDraftTask, + PendingNewTask, + PendingQueuedTask, +} from "./pending-new-tasks-model"; export function usePendingNewTasks(): ReadonlyArray { const queuedMessagesByThreadKey = useThreadOutboxMessages(); - return useMemo(() => { - const tasks: PendingNewTask[] = []; - for (const message of flattenQueuedThreadMessages(queuedMessagesByThreadKey)) { - if (!message.creation) { - continue; - } - tasks.push({ - message, - creation: message.creation, - title: deriveThreadTitleFromPrompt(message.text), - }); - } - tasks.sort((left, right) => right.message.createdAt.localeCompare(left.message.createdAt)); - return tasks; - }, [queuedMessagesByThreadKey]); + const drafts = useAtomValue(listedNewTaskDraftsAtom); + return useMemo( + () => + buildPendingNewTasks({ + queuedMessages: flattenQueuedThreadMessages(queuedMessagesByThreadKey), + drafts, + }), + [queuedMessagesByThreadKey, drafts], + ); } diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index e95d779510..bbe416b101 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -2,7 +2,6 @@ import { useAtomValue } from "@effect/atom-react"; import { useNavigation } from "@react-navigation/native"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Alert } from "react-native"; -import * as Cause from "effect/Cause"; import { CommandId, @@ -18,7 +17,6 @@ import { import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { resolveProviderContinuationTransition } from "@t3tools/client-runtime/providerContinuation"; import { - codexFeedbackMessage, parseCodexFeedbackCommand, submitCodexFeedback, type CodexFeedbackSubmission, @@ -66,16 +64,18 @@ import { } from "../lib/composerImages"; import type { DraftComposerImageAttachment } from "../lib/composerImages"; import { prepareTurnAttachments, validateDraftFileAttachments } from "../lib/attachmentUpload"; -import { scopedProjectKey, scopedThreadKey } from "../lib/scopedEntities"; +import { scopedThreadKey } from "../lib/scopedEntities"; import { canSendToModelSelection, resolveModelSelectionRuntimeMode, showModelSelectionInteractionModeToggle, } from "../lib/modelOptions"; -import { copyTextWithHaptic } from "../lib/copyTextWithHaptic"; import { buildThreadFeed } from "../lib/threadActivity"; +import { acknowledgedThreadMessagesAtom } from "./acknowledged-thread-messages"; +import { appendPendingThreadMessages } from "../features/threads/pending-thread-feed"; import { appAtomRegistry } from "../state/atom-registry"; import { serverEnvironment } from "../state/server"; +import { pendingThreadCreationMessage } from "./pending-thread-creation"; import { appendComposerDraftAttachments, appendComposerDraftText, @@ -84,6 +84,7 @@ import { ensureComposerDraftsLoaded, getComposerDraftSnapshot, mergeComposerDraftContent, + newTaskDraftKey, removeComposerDraftAttachment, scheduleUnusedComposerAttachmentCleanup, setComposerDraftText, @@ -106,6 +107,7 @@ import { dispatchingQueuedMessageIdAtom, useThreadOutboxMessages } from "./use-t import { useAtomCommand } from "./use-atom-command"; import { composerAttachmentUploadBlockReason, + composerAttachmentsStillUploading, composerAttachmentUploadsAtom, } from "./composer-attachment-uploads"; import { threadEnvironment } from "./threads"; @@ -151,7 +153,11 @@ export function useThreadDraftForThread(input: { export function useThreadComposerState() { const navigation = useNavigation(); - const { selectedThread: selectedThreadShell, selectedEnvironmentRuntime } = useThreadSelection(); + const { + selectedThread: selectedThreadShell, + selectedThreadCreation, + selectedEnvironmentRuntime, + } = useThreadSelection(); const selectedThreadDetail = useSelectedThreadDetail(); const selectedThreadContextWindow = useMemo( () => deriveLatestContextWindowSnapshot(selectedThreadDetail?.activities ?? []), @@ -170,6 +176,7 @@ export function useThreadComposerState() { project.id === selectedThreadShell.projectId, ) ?? null); const composerDrafts = useAtomValue(composerDraftsAtom); + const acknowledgedMessages = useAtomValue(acknowledgedThreadMessagesAtom); const queuedMessagesByThreadKey = useThreadOutboxMessages(); const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom); const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState< @@ -215,32 +222,79 @@ export function useThreadComposerState() { const selectedThreadKey = selectedThreadShell ? scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id) : null; + // The creation entry is the thread itself (rendered as the first message), + // not a follow-up waiting behind it. const selectedThreadQueuedMessages = useMemo( - () => (selectedThreadKey ? (queuedMessagesByThreadKey[selectedThreadKey] ?? []) : []), - [queuedMessagesByThreadKey, selectedThreadKey], - ); - const localFeedbackMessages = useMemo(() => { - const submissions = selectedThreadKey - ? (feedbackSubmissionsByThreadKey[selectedThreadKey] ?? []) - : []; - return submissions.flatMap((submission) => - submission.status === "interrupted" - ? [] - : [codexFeedbackMessage(submission), codexFeedbackMessage(submission, "assistant")], - ); - }, [feedbackSubmissionsByThreadKey, selectedThreadKey]); - const selectedThreadMessages = selectedThreadDetail?.messages; - const selectedThreadActivities = selectedThreadDetail?.activities; - const selectedThreadFeed = useMemo( () => - selectedThreadMessages && selectedThreadActivities - ? buildThreadFeed( - { messages: selectedThreadMessages, activities: selectedThreadActivities }, - { localMessages: localFeedbackMessages }, + selectedThreadKey + ? (queuedMessagesByThreadKey[selectedThreadKey] ?? []).filter( + (message) => message.creation === undefined, ) : [], - [localFeedbackMessages, selectedThreadActivities, selectedThreadMessages], + [queuedMessagesByThreadKey, selectedThreadKey], ); + const feedbackSubmissions = useMemo( + () => (selectedThreadKey ? (feedbackSubmissionsByThreadKey[selectedThreadKey] ?? []) : []), + [feedbackSubmissionsByThreadKey, selectedThreadKey], + ); + const dismissFeedback = useCallback( + (id: MessageId) => { + if (!selectedThreadKey) return; + setFeedbackSubmissionsByThreadKey((current) => ({ + ...current, + [selectedThreadKey]: (current[selectedThreadKey] ?? []).filter((entry) => entry.id !== id), + })); + }, + [selectedThreadKey], + ); + const selectedThreadMessages = selectedThreadDetail?.messages; + const selectedThreadActivities = selectedThreadDetail?.activities; + // A thread whose creation has not delivered its turn yet: the prompt only + // exists in the outbox, so it is appended to whatever the server has. The + // detail is usually present but empty during a worktree checkout, so this + // cannot be an either/or with the loaded messages. + const pendingCreationMessage = selectedThreadCreation?.message ?? null; + const selectedThreadFeed = useMemo(() => { + const loadedMessages = selectedThreadMessages ?? []; + const feed = + (selectedThreadMessages && selectedThreadActivities) || pendingCreationMessage !== null + ? buildThreadFeed({ + messages: + pendingCreationMessage !== null && + !loadedMessages.some((message) => message.id === pendingCreationMessage.messageId) + ? [...loadedMessages, pendingThreadCreationMessage(pendingCreationMessage)] + : loadedMessages, + activities: selectedThreadActivities ?? [], + }) + : []; + const pendingAcknowledgments = acknowledgedMessages.filter( + (message) => + scopedThreadKey(message.environmentId, message.threadId) === selectedThreadKey && + !selectedThreadQueuedMessages.some((queued) => queued.messageId === message.messageId), + ); + if (pendingAcknowledgments.length === 0) return feed; + return appendPendingThreadMessages(feed, feed, pendingAcknowledgments).map((entry) => + entry.pendingMessage ? { ...entry, acknowledged: true } : entry, + ); + }, [ + selectedThreadActivities, + selectedThreadMessages, + pendingCreationMessage, + selectedThreadKey, + selectedThreadQueuedMessages, + acknowledgedMessages, + ]); + useEffect(() => { + const echoedIds = new Set(selectedThreadMessages?.map((message) => message.id)); + if (acknowledgedMessages.some((message) => echoedIds.has(message.messageId))) { + appAtomRegistry.set( + acknowledgedThreadMessagesAtom, + appAtomRegistry + .get(acknowledgedThreadMessagesAtom) + .filter((message) => !echoedIds.has(message.messageId)), + ); + } + }, [acknowledgedMessages, selectedThreadMessages]); const selectedThreadAgents = useMemo(() => { const status = selectedThreadDetail?.session?.status; const sessionLive = status === "starting" || status === "ready" || status === "running"; @@ -613,6 +667,13 @@ export function useThreadComposerState() { if (!selectedThreadShell || sessionCompactionBlocksSubmission) { return null; } + // The server has not created this thread yet. Queuing a follow-up against + // its id would strand the message: if the creation is rejected the thread + // never appears and the drain drops the orphan. The composer disables its + // send button too; this guard also covers the editor's submit key. + if (selectedThreadCreation !== null) { + return null; + } const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); const draft = getComposerDraftSnapshot(threadKey); @@ -672,7 +733,7 @@ export function useThreadComposerState() { return null; } const metadata = makeQueuedMessageMetadata(); - const result = await submitCodexFeedback({ + await submitCodexFeedback({ submission: { id: MessageId.make(metadata.messageId), command: text, @@ -700,25 +761,6 @@ export function useThreadComposerState() { }, }), }); - if (result._tag === "Failure") { - if (isAtomCommandInterrupted(result)) { - return null; - } - const error = Cause.squash(result.cause); - Alert.alert( - "Could not send feedback to OpenAI", - error instanceof Error ? error.message : "An error occurred.", - ); - return null; - } - const feedbackId = result.value.feedbackId; - Alert.alert("Feedback sent to OpenAI", `Thread ID: ${feedbackId}`, [ - { text: "OK", style: "cancel" }, - { - text: "Copy ID", - onPress: () => copyTextWithHaptic(feedbackId, { target: "Codex feedback thread ID" }), - }, - ]); return null; } @@ -791,6 +833,7 @@ export function useThreadComposerState() { selectedEnvironmentRuntime?.connectionState, selectedEnvironmentRuntime?.serverConfig, selectedSessionProviderInstanceId, + selectedThreadCreation, selectedThreadDetail, sessionCompactionBlocksSubmission, selectedThreadProject, @@ -827,17 +870,20 @@ export function useThreadComposerState() { const attachments = draft.attachments; if (text.length === 0 && attachments.length === 0) return null; - // Same gate onSendMessage applies: queueing while an upload is still in - // flight would start a second transfer of the same bytes alongside the - // background worker's. + // Stricter than onSendMessage: a follow-up skips the outbox and uploads + // its files here, so queueing while an upload is still in flight would + // start a second transfer of the same bytes alongside the background + // worker's. + const uploadInput = { + environmentId: selectedThreadShell.environmentId, + attachments, + connected: selectedEnvironmentRuntime?.connectionState === "connected", + serverConfig: selectedEnvironmentRuntime?.serverConfig ?? null, + states: appAtomRegistry.get(composerAttachmentUploadsAtom), + }; if ( - composerAttachmentUploadBlockReason({ - environmentId: selectedThreadShell.environmentId, - attachments, - connected: selectedEnvironmentRuntime?.connectionState === "connected", - serverConfig: selectedEnvironmentRuntime?.serverConfig ?? null, - states: appAtomRegistry.get(composerAttachmentUploadsAtom), - }) !== null + composerAttachmentUploadBlockReason(uploadInput) !== null || + (uploadInput.connected && composerAttachmentsStillUploading(uploadInput)) ) { return null; } @@ -1423,6 +1469,7 @@ export function useThreadComposerState() { params: { environmentId: String(queuedMessage.environmentId), projectId: String(destination.projectId), + draftId: input.draftKey, }, }); } @@ -1518,13 +1565,19 @@ export function useThreadComposerState() { }); } if (newThreadDestination) { - const newThreadDraftKey = `new-task:${scopedProjectKey( - queuedMessage.environmentId, - newThreadDestination.projectId, - )}`; + // Its own new-task draft, keyed by the held message so a retried + // recovery lands on the same draft instead of minting another. + const newThreadDraftKey = newTaskDraftKey(`restored-${queuedMessage.messageId}`); actions.push({ text: "Start a new thread", onPress: () => { + updateComposerDraftSettings(newThreadDraftKey, { + project: { + environmentId: queuedMessage.environmentId, + projectId: newThreadDestination.projectId, + createdAt: queuedMessage.createdAt, + }, + }); void recover({ draftKey: newThreadDraftKey, startNewThread: true }); }, }); @@ -1578,6 +1631,8 @@ export function useThreadComposerState() { ]); return { + feedbackSubmissions, + dismissFeedback, selectedThreadFeed, selectedThreadAgents, selectedThreadContextWindow, @@ -1593,6 +1648,8 @@ export function useThreadComposerState() { : null, selectedThreadQueueCount, selectedThreadQueueHold: selectedThreadQueuedMessages[0]?.deliveryHold ?? null, + selectedThreadQueuedMessages, + dispatchingQueuedMessageId, activeWorkStartedAt, isCompacting, draftMessage, diff --git a/apps/mobile/src/state/use-thread-detail.ts b/apps/mobile/src/state/use-thread-detail.ts index 388b4d9afc..c071f2aad9 100644 --- a/apps/mobile/src/state/use-thread-detail.ts +++ b/apps/mobile/src/state/use-thread-detail.ts @@ -13,12 +13,12 @@ export function useThreadDetail(target: ThreadDetailTarget) { return useEnvironmentThread(target.environmentId, target.threadId); } +/** + * The selection owns the subscription so it can hold it back while a queued + * creation has not reached the server yet. + */ export function useSelectedThreadDetailState() { - const { selectedThread } = useThreadSelection(); - return useThreadDetail({ - environmentId: selectedThread?.environmentId ?? null, - threadId: selectedThread?.id ?? null, - }); + return useThreadSelection().selectedThreadDetailState; } export function useSelectedThreadDetail() { diff --git a/apps/mobile/src/state/use-thread-outbox-drain.test.ts b/apps/mobile/src/state/use-thread-outbox-drain.test.ts index 917b01cb23..1fec26d1bc 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.test.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.test.ts @@ -1,3 +1,4 @@ +import { acknowledgedThreadMessagesAtom } from "./acknowledged-thread-messages"; import { CommandId, EnvironmentId, @@ -162,8 +163,13 @@ vi.mock("./thread-outbox", async () => { }); import { appAtomRegistry } from "./atom-registry"; +import { + clearPendingThreadCreationOutcome, + pendingThreadCreationOutcomesAtom, +} from "./pending-thread-creation"; import type { QueuedThreadMessage } from "./thread-outbox-model"; import * as composerDrafts from "./use-composer-drafts"; +import { recoverFailedThreadDraft } from "./recover-failed-thread-draft"; import { editingQueuedMessageIdsAtom } from "./use-thread-outbox"; import { completeQueuedMessageDelivery, @@ -223,6 +229,7 @@ function remainingMessages(): ReadonlyArray { } beforeEach(() => { + appAtomRegistry.set(acknowledgedThreadMessagesAtom, []); harness.draftFile.setDocument({ schemaVersion: 1, drafts: {} }); }); @@ -231,6 +238,7 @@ afterEach(() => { appAtomRegistry.set(composerDrafts.composerDraftsAtom, {}); appAtomRegistry.set(composerDrafts.composerCloudDraftsAtom, { accountId: null, signedOut: {} }); appAtomRegistry.set(editingQueuedMessageIdsAtom, {}); + appAtomRegistry.set(pendingThreadCreationOutcomesAtom, {}); harness.draftFile.setWriteError(null); harness.removePersistedFile.mockClear(); harness.removeOutboxMessage.mockClear(); @@ -468,6 +476,7 @@ describe("thread outbox drain delivery cleanup", () => { await expect(completeQueuedMessageDelivery(message, deliveryRevision)).resolves.toBe("removed"); expect(remainingMessages()).toEqual([]); + expect(appAtomRegistry.get(acknowledgedThreadMessagesAtom)).toEqual([message]); }); it("keeps a delivered message when its editor opens during storage removal", async () => { @@ -619,7 +628,56 @@ describe("thread outbox delivered creation recovery", () => { }); describe("thread outbox recovery rollback", () => { - it("restores a rejected new task into its durable project draft", async () => { + it("reopens a rejected task with setup edits and every attachment, even above the send cap", async () => { + const message = queuedMessage({ messageId: "failed-setup", text: "Original prompt" }); + const sourceKey = `${message.environmentId}:${message.threadId}`; + const targetKey = "new-task:restored-failed-setup"; + const files = Array.from( + { length: 10 }, + (_, index) => + queuedMessage({ + messageId: `attachment-${index}`, + text: "", + fileUri: `file:///file-${index}`, + }).attachments[0]!, + ); + appAtomRegistry.set(composerDrafts.composerDraftsAtom, { + [targetKey]: { text: message.text, attachments: files.slice(0, 8) }, + [sourceKey]: { text: "Please include tests", attachments: files.slice(8) }, + }); + await recoverFailedThreadDraft(message); + expect(composerDrafts.getComposerDraftSnapshot(targetKey)).toMatchObject({ + text: "Original prompt\n\nPlease include tests", + attachments: files, + }); + expect(composerDrafts.getComposerDraftSnapshot(sourceKey)).toMatchObject({ + text: "", + attachments: [], + }); + await recoverFailedThreadDraft(message); + expect(composerDrafts.getComposerDraftSnapshot(targetKey).text).toBe( + "Original prompt\n\nPlease include tests", + ); + }); + + it("keeps setup edits recoverable when saving their recovery draft fails", async () => { + const message = queuedMessage({ messageId: "failed-save", text: "Original prompt" }); + const sourceKey = `${message.environmentId}:${message.threadId}`; + appAtomRegistry.set(composerDrafts.composerDraftsAtom, { + "new-task:restored-failed-save": { text: message.text, attachments: [] }, + [sourceKey]: { text: "Follow-up", attachments: [] }, + }); + harness.draftFile.setWriteError(new Error("disk full")); + await expect(recoverFailedThreadDraft(message)).rejects.toThrow("Composer draft persistence"); + expect(composerDrafts.getComposerDraftSnapshot(sourceKey).text).toBe("Follow-up"); + harness.draftFile.setWriteError(null); + await recoverFailedThreadDraft(message); + expect(composerDrafts.getComposerDraftSnapshot("new-task:restored-failed-save").text).toBe( + "Original prompt\n\nFollow-up", + ); + }); + + it("restores a rejected new task as its own draft for the project", async () => { const message: QueuedThreadMessage = { ...queuedMessage({ messageId: "message-creation-restore", text: "new task text" }), modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }, @@ -636,17 +694,58 @@ describe("thread outbox recovery rollback", () => { "restored", ); + // The draft is keyed by the message so a retry lands on the same one, and + // stamped with the project so it shows up as a Draft row for that project. expect( - composerDrafts.getComposerDraftSnapshot( - `new-task:${message.environmentId}:${message.creation!.projectId}`, - ), + composerDrafts.getComposerDraftSnapshot(`new-task:restored-${message.messageId}`), ).toMatchObject({ text: message.text, attachments: message.attachments, modelSelection: message.modelSelection, + project: { + environmentId: message.environmentId, + projectId: message.creation!.projectId, + createdAt: message.createdAt, + }, }); expect(remainingMessages()).toEqual([]); expect(harness.setPendingConnectionError).toHaveBeenCalledWith("rejected by server"); + // The thread screen opened for this creation reads the failure from here. + expect( + appAtomRegistry.get(pendingThreadCreationOutcomesAtom)[ + `${message.environmentId}:${message.threadId}` + ], + ).toEqual({ kind: "failed", message, reason: "rejected by server" }); + }); + + it("keeps a failed outcome until its thread screen consumes it", async () => { + const message: QueuedThreadMessage = { + ...queuedMessage({ messageId: "message-creation-kept", text: "new task text" }), + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }, + creation: { + projectId: ProjectId.make("project-1"), + workspaceMode: "local", + branch: null, + worktreePath: null, + }, + }; + await harness.manager.enqueue(message); + await restoreRejectedQueuedMessage(message, "rejected by server"); + + const key = `${message.environmentId}:${message.threadId}`; + expect(appAtomRegistry.get(pendingThreadCreationOutcomesAtom)[key]?.kind).toBe("failed"); + + clearPendingThreadCreationOutcome(key); + expect(appAtomRegistry.get(pendingThreadCreationOutcomesAtom)[key]).toBeUndefined(); + }); + + it("does not record a creation outcome for a rejected follow-up message", async () => { + const message = queuedMessage({ messageId: "message-followup-restore", text: "follow up" }); + await harness.manager.enqueue(message); + + await expect(restoreRejectedQueuedMessage(message, "rejected")).resolves.toBe("restored"); + + expect(appAtomRegistry.get(pendingThreadCreationOutcomesAtom)).toEqual({}); }); it("rolls a failed recovery merge back so the retry cannot duplicate the text", async () => { diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index ea2756e464..b1c7e6b255 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -15,12 +15,22 @@ import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useEffect, useRef, useState } from "react"; -import { scopedProjectKey, scopedThreadKey } from "../lib/scopedEntities"; +import { scopedThreadKey } from "../lib/scopedEntities"; import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn"; import { prepareTurnAttachments, type PreparedTurnAttachments } from "../lib/attachmentUpload"; import { randomHex } from "../lib/uuid"; +import { + retainAcknowledgedThreadMessage, + forgetAcknowledgedThreadMessage, +} from "./acknowledged-thread-messages"; import { appAtomRegistry } from "./atom-registry"; +import { restoredNewTaskDraftKey } from "./new-task-draft-key"; import { useServerConfigs, useThreadShells } from "./entities"; +import { + clearPendingThreadCreationOutcome, + pendingThreadCreationOutcomesAtom, + recordPendingThreadCreationOutcome, +} from "./pending-thread-creation"; import { confirmThreadOutboxMessageQueued, ensureThreadOutboxLoaded, @@ -194,6 +204,7 @@ export async function completeQueuedMessageDelivery( if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { return "edited"; } + retainAcknowledgedThreadMessage(queuedMessage); // Removal also releases the message's local attachment files. const removed = await removeThreadOutboxMessage( queuedMessage, @@ -201,6 +212,7 @@ export async function completeQueuedMessageDelivery( () => !appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId], ); if (!removed) { + forgetAcknowledgedThreadMessage(queuedMessage); console.warn( "[thread-outbox] delivered message was edited before cleanup; keeping the newer message", { @@ -213,6 +225,7 @@ export async function completeQueuedMessageDelivery( } return "removed"; } catch (error) { + forgetAcknowledgedThreadMessage(queuedMessage); console.warn("[thread-outbox] failed to remove delivered queued message", { environmentId: queuedMessage.environmentId, threadId: queuedMessage.threadId, @@ -368,6 +381,7 @@ export async function restoreRejectedQueuedMessage( let mergedDraft: ComposerDraft; try { + stampRecoveryDraftProject(queuedMessage, draftKey); await mergeComposerDraftContent(draftKey, { text: queuedMessage.text, attachments: queuedMessage.attachments, @@ -429,6 +443,15 @@ export async function restoreRejectedQueuedMessage( // The queued message is gone; from here the draft owns the content and // must never be rolled back. rollback = null; + if (queuedMessage.creation) { + // The thread screen for this creation is likely open; it reads the + // outcome to offer reopening the restored draft. + recordPendingThreadCreationOutcome({ + kind: "failed", + message: queuedMessage, + reason: message, + }); + } setPendingConnectionError(message); return "restored"; } catch (error) { @@ -450,12 +473,31 @@ export async function restoreRejectedQueuedMessage( } } +/** + * A rejected creation becomes its own new-task draft rather than merging into + * whatever the user is typing for that project. The key derives from the + * message id so a retry after a mid-recovery failure lands on the same draft + * instead of minting another. + */ function recoveryDraftKey(queuedMessage: QueuedThreadMessage): string { return queuedMessage.creation - ? `new-task:${scopedProjectKey(queuedMessage.environmentId, queuedMessage.creation.projectId)}` + ? restoredNewTaskDraftKey(queuedMessage.messageId) : scopedThreadKey(queuedMessage.environmentId, queuedMessage.threadId); } +function stampRecoveryDraftProject(queuedMessage: QueuedThreadMessage, draftKey: string): void { + if (!queuedMessage.creation) { + return; + } + updateComposerDraftSettings(draftKey, { + project: { + environmentId: queuedMessage.environmentId, + projectId: queuedMessage.creation.projectId, + createdAt: queuedMessage.createdAt, + }, + }); +} + async function preserveUploadedAttachmentsForEditor( originalMessage: QueuedThreadMessage, uploadedMessage: QueuedThreadMessage, @@ -500,6 +542,7 @@ export function useThreadOutboxDrain(): void { const queuedMessagesByThreadKey = useThreadOutboxMessages(); const shellStatuses = useThreadOutboxShellStatuses(); const threads = useThreadShells(); + const creationOutcomes = useAtomValue(pendingThreadCreationOutcomesAtom); const serverConfigs = useServerConfigs(); const { connectedEnvironments } = useRemoteConnectionStatus(); const [retryTick, setRetryTick] = useState(0); @@ -825,6 +868,9 @@ export function useThreadOutboxDrain(): void { ); } + // Recorded before the queue entry goes so the thread screen never sees a + // gap between the queued creation and the server's shell. + recordPendingThreadCreationOutcome({ kind: "delivered", message: persistedMessage }); const outcome = await completeQueuedMessageDelivery(persistedMessage, deliveryRevision); if (outcome === "edited") { if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { @@ -844,6 +890,30 @@ export function useThreadOutboxDrain(): void { [makeDeliveryHelpers, restoreQueuedMessage, serverConfigs, startTurn], ); + // A creation outcome bridges setup until the server's shell has a turn. + // Drop it once that happens so the map cannot grow for a whole session; a + // failed outcome stays until its thread screen consumes it. + // Subscribed, not read once: the shell often lands before the outcome is + // recorded, and a non-reactive read would leave that entry uncollected + // because `threads` never changes again. + useEffect(() => { + for (const [threadKey, outcome] of Object.entries(creationOutcomes)) { + if ( + outcome.kind === "delivered" && + threads.some( + (thread) => + scopedThreadKey(thread.environmentId, thread.id) === threadKey && + (thread.latestTurn !== null || + thread.session?.status === "error" || + thread.session?.status === "stopped" || + thread.session?.status === "interrupted"), + ) + ) { + clearPendingThreadCreationOutcome(threadKey); + } + } + }, [creationOutcomes, threads]); + useEffect(() => { if (dispatchingQueuedMessageId !== null) { return; diff --git a/apps/mobile/src/state/use-thread-outbox.ts b/apps/mobile/src/state/use-thread-outbox.ts index 6ed00e2a0b..e8599e1ecb 100644 --- a/apps/mobile/src/state/use-thread-outbox.ts +++ b/apps/mobile/src/state/use-thread-outbox.ts @@ -58,6 +58,28 @@ export function useThreadOutboxMessages() { return useAtomValue(threadOutboxManager.queuedMessagesByThreadKeyAtom); } +/** + * Thread keys (`environmentId:threadId`) of existing threads with a message + * waiting in the outbox. Creations are excluded: they have no thread row yet + * and surface as pending tasks instead. Derived once so list builders and + * reorder planners agree on which settled threads are pulled back to active. + */ +export const queuedThreadKeysAtom = Atom.make((get): ReadonlySet => { + const keys = new Set(); + for (const [threadKey, queue] of Object.entries( + get(threadOutboxManager.queuedMessagesByThreadKeyAtom), + )) { + if (queue.some((message) => message.creation === undefined)) { + keys.add(threadKey); + } + } + return keys; +}).pipe(Atom.withLabel("mobile:thread-outbox:queued-thread-keys")); + +export function useQueuedThreadKeys(): ReadonlySet { + return useAtomValue(queuedThreadKeysAtom); +} + export function useThreadOutboxShellStatuses() { return useAtomValue(threadOutboxShellStatusesAtom); } diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index ae90540093..0e1cfb849e 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -1,5 +1,6 @@ +import { useAtomValue } from "@effect/atom-react"; import { useRoute, type RouteProp } from "@react-navigation/native"; -import { useMemo, useRef } from "react"; +import { useMemo, useRef, useState } from "react"; import { EnvironmentId, type OrchestrationThread, @@ -10,12 +11,20 @@ import { import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import * as Option from "effect/Option"; +import { scopedThreadKey } from "../lib/scopedEntities"; import { useProject, useThreadShell } from "../state/entities"; import { useEnvironmentThread } from "../state/threads"; +import { + resolvePendingThreadCreation, + pendingThreadCreationOutcomesAtom, + pendingThreadCreationShell, + type PendingThreadCreation, +} from "./pending-thread-creation"; import { useRemoteEnvironmentRuntime, useSavedRemoteConnection, } from "./use-remote-environment-registry"; +import { useThreadOutboxMessages } from "./use-thread-outbox"; type ThreadSelectionRouteParams = { readonly environmentId?: string | string[]; readonly threadId?: string | string[]; @@ -98,9 +107,36 @@ function useResolvedThreadSelection(params: ThreadSelectionRouteParams | undefin } const selectedThreadRef = routeThreadRef ?? lastRouteThreadRef.current; const selectedThreadShell = useThreadShell(selectedThreadRef); + const selectedThreadKey = + selectedThreadRef === null + ? null + : scopedThreadKey(selectedThreadRef.environmentId, selectedThreadRef.threadId); + const queuedMessagesByThreadKey = useThreadOutboxMessages(); + const creationOutcome = useAtomValue(pendingThreadCreationOutcomesAtom); + // A creation the outbox still holds or just delivered: the thread screen + // opened before the server made the thread, so present a stand-in shell. + const pendingCreation = useMemo(() => { + if (selectedThreadKey === null) { + return null; + } + const queued = queuedMessagesByThreadKey[selectedThreadKey]?.find( + (message) => message.creation !== undefined, + ); + const outcome = creationOutcome[selectedThreadKey] ?? null; + const message = queued ?? outcome?.message ?? null; + return message === null ? null : { message, outcome }; + }, [creationOutcome, queuedMessagesByThreadKey, selectedThreadKey]); + // Until the creation is delivered the server has no thread to subscribe + // to; subscribing anyway would retry "not found" for the whole setup. + const selectedThreadDetailRef = + selectedThreadShell !== null || + pendingCreation === null || + pendingCreation.outcome?.kind === "delivered" + ? selectedThreadRef + : null; const selectedThreadDetailState = useEnvironmentThread( - selectedThreadRef?.environmentId ?? null, - selectedThreadRef?.threadId ?? null, + selectedThreadDetailRef?.environmentId ?? null, + selectedThreadDetailRef?.threadId ?? null, ); const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data); const selectedThread = useMemo( @@ -108,9 +144,21 @@ function useResolvedThreadSelection(params: ThreadSelectionRouteParams | undefin selectedThreadShell ?? (selectedThreadRef !== null && selectedThreadDetail !== null ? threadDetailToShell(selectedThreadRef.environmentId, selectedThreadDetail) - : null), - [selectedThreadDetail, selectedThreadRef, selectedThreadShell], + : pendingCreation !== null + ? pendingThreadCreationShell(pendingCreation.message) + : null), + [pendingCreation, selectedThreadDetail, selectedThreadRef, selectedThreadShell], ); + const [previousCreation, setPreviousCreation] = useState(null); + const selectedThreadCreation = resolvePendingThreadCreation({ + threadKey: selectedThreadKey, + pending: pendingCreation, + previous: previousCreation, + detail: selectedThreadDetail, + }); + if (previousCreation !== selectedThreadCreation) { + setPreviousCreation(selectedThreadCreation); + } const selectedProjectRef = useMemo( () => selectedThread === null @@ -130,6 +178,8 @@ function useResolvedThreadSelection(params: ThreadSelectionRouteParams | undefin () => ({ selectedThreadRef, selectedThread, + selectedThreadCreation, + selectedThreadDetailState, selectedThreadProject, selectedEnvironmentConnection, selectedEnvironmentRuntime, @@ -138,6 +188,8 @@ function useResolvedThreadSelection(params: ThreadSelectionRouteParams | undefin selectedEnvironmentConnection, selectedEnvironmentRuntime, selectedThread, + selectedThreadCreation, + selectedThreadDetailState, selectedThreadProject, selectedThreadRef, ], diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6d2c4b6646..43b690be12 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -50,7 +50,6 @@ import { import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; import { - codexFeedbackMessage, parseCodexFeedbackCommand, submitCodexFeedback, type CodexFeedbackSubmission, @@ -396,6 +395,7 @@ import { useLinkedThreadPullRequest, } from "./ThreadStatusIndicators"; import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack"; +import { feedbackBannerItem } from "./chat/ComposerFeedback"; import { usageLimitsBannerItem } from "./chat/ComposerUsageLimits"; import { collectProviderUsageLimits, @@ -3217,14 +3217,7 @@ export default function ChatView(props: ChatViewProps) { }); }); - const localMessages = [ - ...optimisticUserMessages, - ...feedbackSubmissions.flatMap((submission) => - submission.status === "interrupted" - ? [] - : [codexFeedbackMessage(submission), codexFeedbackMessage(submission, "assistant")], - ), - ]; + const localMessages = optimisticUserMessages; if (localMessages.length === 0) { return serverMessagesWithPreviewHandoff; } @@ -3237,7 +3230,6 @@ export default function ChatView(props: ChatViewProps) { }, [ attachmentPreviewHandoffByMessageId, displayServerMessages, - feedbackSubmissions, optimisticUserMessages, projectHandoffMessagePreviews, ]); @@ -5989,6 +5981,21 @@ export default function ChatView(props: ChatViewProps) { : null, [usageLimitsNotice, routeThreadKey, environmentId], ); + const feedbackBannerItems = useMemo( + () => + feedbackSubmissions.flatMap((submission) => { + const item = feedbackBannerItem(submission, () => { + setFeedbackSubmissionsByThreadKey((current) => ({ + ...current, + [routeThreadKey]: (current[routeThreadKey] ?? []).filter( + (entry) => entry.id !== submission.id, + ), + })); + }); + return item ? [item] : []; + }), + [feedbackSubmissions, routeThreadKey], + ); const composerBannerItems = useMemo(() => { const backgroundLivenessItems = backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem]; @@ -5998,6 +6005,7 @@ export default function ChatView(props: ChatViewProps) { const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { return [ + ...feedbackBannerItems, ...systemComposerBannerItems, ...(usageLimitsBanner ? [usageLimitsBanner] : []), ...backgroundLivenessItems, @@ -6007,6 +6015,7 @@ export default function ChatView(props: ChatViewProps) { ]; } return [ + ...feedbackBannerItems, ...systemComposerBannerItems, ...(usageLimitsBanner ? [usageLimitsBanner] : []), ...backgroundLivenessItems, @@ -6055,6 +6064,7 @@ export default function ChatView(props: ChatViewProps) { }, [ activeBranchMismatchKey, backgroundLivenessBannerItem, + feedbackBannerItems, handleRestoreThreadBranch, isRestoringThreadBranch, localCheckoutBranchMismatch, @@ -6648,7 +6658,7 @@ export default function ChatView(props: ChatViewProps) { return; } feedbackUploadsInFlightRef.current.add(routeThreadKey); - const result = await submitCodexFeedback({ + await submitCodexFeedback({ submission: { id: newMessageId(), command: trimmed, @@ -6658,7 +6668,6 @@ export default function ChatView(props: ChatViewProps) { promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); - scrollToEnd(); }, onUpdate: (submission) => { setFeedbackSubmissionsByThreadKey((current) => { @@ -6683,43 +6692,7 @@ export default function ChatView(props: ChatViewProps) { }).finally(() => { feedbackUploadsInFlightRef.current.delete(routeThreadKey); }); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not send feedback to OpenAI", - description: chatActionErrorMessage(squashAtomCommandFailure(result)), - }), - ); - } - return; - } - const feedbackId = result.value.feedbackId; - toastManager.add( - stackedThreadToast({ - type: "success", - title: "Feedback sent to OpenAI", - description: `Thread ID: ${feedbackId}`, - timeout: 0, - actionProps: { - children: "Copy ID", - onClick: () => { - void writeTextToClipboard(feedbackId, "Codex feedback thread ID").catch( - (error: unknown) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not copy thread ID", - description: chatActionErrorMessage(error), - }), - ); - }, - ); - }, - }, - }), - ); + return; } if ( diff --git a/apps/web/src/components/chat/ComposerFeedback.tsx b/apps/web/src/components/chat/ComposerFeedback.tsx new file mode 100644 index 0000000000..8322b1a994 --- /dev/null +++ b/apps/web/src/components/chat/ComposerFeedback.tsx @@ -0,0 +1,49 @@ +import { + codexFeedbackNotice, + type CodexFeedbackSubmission, +} from "@t3tools/client-runtime/state/threads"; +import { MessageSquareIcon } from "lucide-react"; + +import { writeTextToClipboard } from "../../hooks/useCopyToClipboard"; +import { Button } from "../ui/button"; +import { toastManager } from "../ui/toast"; +import type { ComposerBannerStackItem } from "./ComposerBannerStack"; + +export function feedbackBannerItem( + submission: CodexFeedbackSubmission, + onDismiss: () => void, +): ComposerBannerStackItem | null { + const notice = codexFeedbackNotice(submission); + if (!notice) return null; + return { + id: `feedback:${submission.id}`, + variant: + submission.status === "failed" ? "error" : submission.status === "sent" ? "success" : "info", + priority: submission.status === "uploading" ? "activity" : "notice", + icon: , + ...notice, + actions: + submission.status === "sent" ? ( + + ) : undefined, + ...(submission.status !== "uploading" + ? { dismissLabel: "Dismiss feedback notice", onDismiss } + : {}), + }; +} diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index a52dd3bcfa..e18eaa607f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1,5 +1,4 @@ import { CheckpointRef, EnvironmentId, MessageId, TurnId } from "@t3tools/contracts"; -import { codexFeedbackMessage } from "@t3tools/client-runtime/state/threads"; import type { AgentPanelModel, RuntimeSubagent, @@ -393,61 +392,6 @@ describe("MessagesTimeline", () => { }, ); - it("renders a feedback command and its pending response as normal thread messages", () => { - const submission = { - id: MessageId.make("feedback-command"), - command: "/feedback The agent stopped early.", - createdAt: MESSAGE_CREATED_AT, - status: "uploading" as const, - }; - const messages = [ - codexFeedbackMessage(submission), - codexFeedbackMessage(submission, "assistant"), - ]; - const markup = renderToStaticMarkup( - ({ - id: message.id, - kind: "message" as const, - createdAt: message.createdAt, - message, - }))} - />, - ); - - expect(markup).toContain("/feedback The agent stopped early."); - expect(markup).toContain("Sending feedback to OpenAI..."); - }); - - it("renders the returned Codex thread ID in the feedback response", () => { - const submission = { - id: MessageId.make("feedback-command"), - command: "/feedback The agent stopped early.", - createdAt: MESSAGE_CREATED_AT, - status: "sent" as const, - feedbackId: "codex-thread-1", - }; - const messages = [ - codexFeedbackMessage(submission), - codexFeedbackMessage(submission, "assistant"), - ]; - const markup = renderToStaticMarkup( - ({ - id: message.id, - kind: "message" as const, - createdAt: message.createdAt, - message, - }))} - />, - ); - - expect(markup).toContain("Feedback sent to OpenAI."); - expect(markup).toContain("codex-thread-1"); - }); - it("renders the worked-for row at assistant response text size", () => { const turnId = TurnId.make("turn-with-fold"); const assistantEntry = buildAssistantTimelineEntry("Done."); diff --git a/apps/web/src/components/chat/ProviderInstanceIcon.tsx b/apps/web/src/components/chat/ProviderInstanceIcon.tsx index 53c66667f9..4a40ed15bc 100644 --- a/apps/web/src/components/chat/ProviderInstanceIcon.tsx +++ b/apps/web/src/components/chat/ProviderInstanceIcon.tsx @@ -1,18 +1,11 @@ import { type CSSProperties, memo } from "react"; import { type ProviderDriverKind } from "@t3tools/contracts"; +import { providerInstanceInitials } from "@t3tools/client-runtime/state/provider-instance-display"; import { PROVIDER_ICON_BY_PROVIDER } from "./providerIconUtils"; import { cn } from "~/lib/utils"; -export function providerInstanceInitials(label: string): string { - const words = label.replace(/[_-]+/g, " ").split(/\s+/u).filter(Boolean); - if (words.length === 0) return ""; - if (words.length === 1) return words[0]!.slice(0, 2).toUpperCase(); - return words - .slice(0, 2) - .map((word) => word[0]?.toUpperCase() ?? "") - .join(""); -} +export { providerInstanceInitials }; export const ProviderInstanceIcon = memo(function ProviderInstanceIcon(props: { driverKind: ProviderDriverKind; diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index f24dfd6438..c9af0c11d8 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -21,7 +21,6 @@ import { defaultInstanceIdForDriver, isBuiltInDriverKind, type ModelSelection, - PROVIDER_DISPLAY_NAMES, type ProviderDriverKind, ProviderInstanceId, resolveProviderInstanceEnabled, @@ -31,8 +30,13 @@ import { type ServerProviderState, type ServerSettings, } from "@t3tools/contracts"; +import { + normalizeProviderAccentColor, + resolveProviderInstanceDisplayName, + shouldShowInstanceBadge, +} from "@t3tools/client-runtime/state/provider-instance-display"; -import { formatProviderDriverKindLabel } from "./providerModels"; +export { normalizeProviderAccentColor, shouldShowInstanceBadge }; export { getProviderUnavailablePresentation }; @@ -99,93 +103,6 @@ export function isProviderInstancePickerVisible(entry: ProviderInstanceEntry): b return entry.enabled; } -/** - * Turn an instance id slug into a human-readable label. Splits on `_` / `-` - * and camelCase boundaries and title-cases each token, so `codex_personal` - * becomes "Codex Personal" and `myCustomInstance` becomes "My Custom - * Instance". - * - * This is a fallback used only when the wire snapshot's `displayName` - * doesn't disambiguate a non-default instance from the default one of the - * same driver (today every built-in driver hard-codes a single presentation - * label per kind, so two instances of the same kind arrive with identical - * display names). When a server/driver later plumbs the user's configured - * `ProviderInstanceConfig.displayName` through to the snapshot, that value - * will take precedence over this fallback. - */ -function humanizeInstanceId(instanceId: ProviderInstanceId): string { - const words: string[] = []; - for (const token of instanceId - .replace(/[_-]+/g, " ") - .replace(/([a-z])([A-Z])/g, "$1 $2") - .split(" ")) { - if (token.length === 0) continue; - words.push(token.charAt(0).toUpperCase() + token.slice(1)); - } - return words.join(" "); -} - -function driverKindLabel(driverKind: ProviderDriverKind): string { - return PROVIDER_DISPLAY_NAMES[driverKind] ?? formatProviderDriverKindLabel(driverKind); -} - -/** - * Whether an instance's icon carries the account badge: accent color set, or - * several instances sharing a driver so the brand glyph alone is ambiguous. - * Shared by the composer trigger, the picker rail, and sidebar rows. - */ -export function shouldShowInstanceBadge( - entry: ProviderInstanceEntry, - entries: Iterable, -): boolean { - if (entry.accentColor) return true; - let sharedDriverCount = 0; - for (const candidate of entries) { - if (candidate.driverKind === entry.driverKind && ++sharedDriverCount > 1) return true; - } - return false; -} - -export function normalizeProviderAccentColor(value: string | undefined): string | undefined { - const trimmed = value?.trim(); - if (!trimmed) return undefined; - return /^#[0-9a-fA-F]{6}$/u.test(trimmed) ? trimmed : undefined; -} - -/** - * Resolve an entry's displayName with a tiered priority: - * - * 1. A snapshot `displayName` that differs from the driver-kind label — - * the server has explicitly named this instance, trust it. - * 2. For non-default instances, a humanized `instanceId` — the server - * fell back to the driver-level presentation constant (which is the - * same for every instance of that kind), so we differentiate at the - * UI layer by slug. This is what keeps "Codex" + "Codex Personal" - * distinguishable in tooltips and list labels today. - * 3. The snapshot's `displayName` (if any) — default instance, trust - * whatever label the driver stamped. - * 4. `driverKindLabel(driverKind)` — nothing else on hand, so use the - * canonical brand label from contracts (falling back to a generic - * title-case of the kind slug). - */ -function resolveInstanceDisplayName( - snapshot: ServerProvider, - instanceId: ProviderInstanceId, - driverKind: ProviderDriverKind, - isDefault: boolean, -): string { - const trimmedSnapshotName = snapshot.displayName?.trim(); - const kindLabel = driverKindLabel(driverKind); - if (trimmedSnapshotName && trimmedSnapshotName !== kindLabel) { - return trimmedSnapshotName; - } - if (!isDefault) { - const humanized = humanizeInstanceId(instanceId); - if (humanized.length > 0) return humanized; - } - return trimmedSnapshotName || kindLabel; -} - /** * Project the wire `ServerProvider[]` into instance entries, one per * configured instance. Preserves the server's ordering (which sources @@ -201,11 +118,10 @@ export function deriveProviderInstanceEntries( const driverKind = snapshot.driver; const defaultId = defaultInstanceIdForDriver(driverKind); const isDefault = instanceId === defaultId; - const displayName = resolveInstanceDisplayName(snapshot, instanceId, driverKind, isDefault); return { instanceId, driverKind, - displayName, + displayName: resolveProviderInstanceDisplayName(snapshot), accentColor: normalizeProviderAccentColor(snapshot.accentColor), continuationGroupKey: snapshot.continuation?.groupKey, enabled: snapshot.enabled, diff --git a/docs/user/composer.md b/docs/user/composer.md index fb370e9261..e2487e9c8c 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -14,8 +14,10 @@ by the server, capped at 50 MB. Each message can carry up to eight attachments i upload directly to the environment, where your agent can read, copy, or edit them by their file path. Attachments upload as soon as you add them while connected to a server that supports uploads. -The send button becomes available after every upload finishes. Failed uploads can be retried or -removed. In the mobile app, tap **+** to open the photo library from either the compact or expanded +On web and desktop, the send button becomes available after every upload finishes. In the mobile +app you can send or start a task while an upload is still running: the message waits on your device +and sends once its files are on the server. A Prime Agent follow-up still waits for its uploads. +Failed uploads can be retried or removed. In the mobile app, tap **+** to open the photo library from either the compact or expanded composer. When the connected server supports file uploads, **+** opens a menu beside the button with **Photo Library** and **Choose Files**. Videos use the server's file upload limit. You can also share photos, videos, and files into Pylon from other apps through the system share sheet. Mobile diff --git a/docs/user/mobile-thread-status.md b/docs/user/mobile-thread-status.md index fdce9a8332..02b55863ec 100644 --- a/docs/user/mobile-thread-status.md +++ b/docs/user/mobile-thread-status.md @@ -7,3 +7,7 @@ When the agent is working through a plan, the pill also names the step it is on, A working timer is hidden while the agent waits for approval, an answer, or another interaction. When there is no active status, the pill disappears. The scroll-to-end button remains available when you have scrolled away from the latest messages. Status spinners respect Reduce Motion and stop while the app is inactive or the screen is unfocused. + +When you start a new task while connected, its thread opens as soon as the task is saved on your device. Your prompt appears in the conversation and the pill reads **Setting up worktree…** or **Starting…** until the agent begins working. Sending another message waits until the task has started. If the server rejects the task, a **Could not start task** card replaces the composer; choose **Edit task** to reopen your prompt, including anything you typed during setup. When Pylon holds a new task back, the card shows why, and **Edit task** opens the held task with anything you typed during setup added to its prompt, so you can change or retarget it. To delete a held task, touch and hold it in the thread list. + +Messages waiting on your device appear at the end of the conversation labelled **Pending**, or **Held** when Pylon is holding them back. Tap the pencil to move a pending message back into the composer. The message keeps its place in the conversation while it is delivered. diff --git a/docs/user/providers-codex.md b/docs/user/providers-codex.md index 6536bd0378..d6ad105f60 100644 --- a/docs/user/providers-codex.md +++ b/docs/user/providers-codex.md @@ -44,8 +44,10 @@ spend limit to continue sooner. ## Send feedback to OpenAI In an existing Codex thread, send `/feedback` or `/feedback` followed by a description of the -issue. Pylon uploads the thread and Codex logs to OpenAI and shows a thread ID that you can copy -and share with OpenAI employees. +issue. Pylon uploads the thread and Codex logs to OpenAI and shows the upload's progress and +result in a notice above the composer, without adding messages to the conversation. When the +upload succeeds, choose **Copy ID** to copy the thread ID to share with OpenAI employees, then +dismiss the notice. ## Answer questions while Codex works diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index c5b0c14d66..9a55de68e3 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -59,6 +59,26 @@ files directly; see [Composer](./composer.md). Project settings are available from the project menu in either sidebar and from the breadcrumb context menu when composing a new thread. +## Mobile thread list + +On mobile, unsent work appears under **Unsent** at the top of the thread list and the iPad +sidebar. A new-task draft with text or attachments shows an amber **Draft** label; tap it to +continue writing, or touch and hold it and choose **Discard**. Each **New Task** starts its own +draft, so a project can hold several ideas at once. A queued task says what happens next: +**Sends on reconnect** while its environment is disconnected, **Waiting for upload** while its +files upload, and **Sending…** once it is on its way. A task Pylon held back reads **Held** until +you edit or retarget it; touch and hold it and choose **Delete** to remove it. + +An existing thread with a message waiting on this device shows a small outbox icon beside its +status or time. The thread stays in the active list until that message is sent or deleted. + +Touch and hold a thread that has a branch and choose **New thread on branch** to start a new task +there. Pylon checks out the branch before the composer opens, or reuses the thread's existing +worktree. If the checkout fails, Pylon shows the Git error and returns to the list. + +When several accounts share a provider, or an account has an accent color, the provider icon on +each mobile thread row carries that account's initials in its accent color. + ## Arrange threads On web and desktop, drag a thread between sections to change its state. Drag a thread up into @@ -77,7 +97,11 @@ thread is over takes the accent color. Section labels also identify empty sectio Drag within the pinned or active section to change its order. Other rows slide aside to show the spot where the thread will land. Drops into either section keep the position you choose. On -mobile, open a pinned or active thread's menu and choose **Move up** or **Move down**. The server +mobile, open a thread's menu and choose **Arrange threads**. Drag a handle within or between +**Pinned** and **Active** to reorder, pin, or unpin. Drop onto the **Settled** divider to +settle a thread. The dragged card shows the action before you release it. Expand **Snoozed** +or **Settled** to drag a parked thread back into either live section. Each drop saves; **Done** returns to the thread list. +**Move up** and **Move down** are also available in the thread menu. The server saves the order, so it survives a refresh and appears on your other connected devices. On web and desktop, the list also animates section changes made with thread actions such as diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 09f09d8a7b..180a30b356 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -171,6 +171,10 @@ "types": "./src/state/projects.ts", "default": "./src/state/projects.ts" }, + "./state/provider-instance-display": { + "types": "./src/state/providerInstanceDisplay.ts", + "default": "./src/state/providerInstanceDisplay.ts" + }, "./state/pull-requests": { "types": "./src/state/pullRequests.ts", "default": "./src/state/pullRequests.ts" diff --git a/packages/client-runtime/src/state/providerInstanceDisplay.test.ts b/packages/client-runtime/src/state/providerInstanceDisplay.test.ts new file mode 100644 index 0000000000..732cd8c050 --- /dev/null +++ b/packages/client-runtime/src/state/providerInstanceDisplay.test.ts @@ -0,0 +1,108 @@ +import { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + normalizeProviderAccentColor, + providerInstanceInitials, + resolveProviderInstanceDisplayName, + shouldShowInstanceBadge, +} from "./providerInstanceDisplay.ts"; + +const codex = ProviderDriverKind.make("codex"); +const claude = ProviderDriverKind.make("claudeAgent"); + +describe("resolveProviderInstanceDisplayName", () => { + it("keeps a snapshot name that differs from the brand label", () => { + expect( + resolveProviderInstanceDisplayName({ + instanceId: ProviderInstanceId.make("codex"), + driver: codex, + displayName: "Work", + }), + ).toBe("Work"); + }); + + it("humanizes a custom instance id when the snapshot only carries the brand label", () => { + expect( + resolveProviderInstanceDisplayName({ + instanceId: ProviderInstanceId.make("codex_personal"), + driver: codex, + displayName: "Codex", + }), + ).toBe("Codex Personal"); + }); + + it("uses the brand label for the default instance", () => { + expect( + resolveProviderInstanceDisplayName({ + instanceId: ProviderInstanceId.make("codex"), + driver: codex, + }), + ).toBe("Codex"); + }); +}); + +describe("providerInstanceInitials", () => { + it("takes the first two characters of a single word", () => { + expect(providerInstanceInitials("Codex")).toBe("CO"); + }); + + it("takes the first character of each of the first two words", () => { + expect(providerInstanceInitials("Codex Personal")).toBe("CP"); + }); + + it("ignores words past the first two", () => { + expect(providerInstanceInitials("Codex Personal Backup Account")).toBe("CP"); + }); + + it("returns an empty string for an empty label", () => { + expect(providerInstanceInitials("")).toBe(""); + }); + + it("keeps an emoji whole instead of splitting its surrogate pair", () => { + expect(providerInstanceInitials("😀 Work")).toBe("😀W"); + expect(providerInstanceInitials("😀")).toBe("😀"); + }); +}); + +describe("normalizeProviderAccentColor", () => { + it("accepts a lowercase hex color", () => { + expect(normalizeProviderAccentColor("#ff8800")).toBe("#ff8800"); + }); + + it("accepts an uppercase hex color", () => { + expect(normalizeProviderAccentColor("#FF8800")).toBe("#FF8800"); + }); + + it("rejects a non-hex value", () => { + expect(normalizeProviderAccentColor("blue")).toBeUndefined(); + }); + + it("rejects a short hex value", () => { + expect(normalizeProviderAccentColor("#fff")).toBeUndefined(); + }); + + it("treats undefined and blank as unset", () => { + expect(normalizeProviderAccentColor(undefined)).toBeUndefined(); + expect(normalizeProviderAccentColor(" ")).toBeUndefined(); + }); +}); + +describe("shouldShowInstanceBadge", () => { + it("shows the badge when the entry has an accent color", () => { + const entry = { driverKind: codex, accentColor: "#ff8800" }; + expect(shouldShowInstanceBadge(entry, [entry])).toBe(true); + }); + + it("shows the badge when two entries share a driver, even without an accent", () => { + const first = { driverKind: codex, accentColor: undefined }; + const second = { driverKind: codex, accentColor: undefined }; + expect(shouldShowInstanceBadge(first, [first, second])).toBe(true); + }); + + it("hides the badge for a single instance of a driver with no accent", () => { + const entry = { driverKind: codex, accentColor: undefined }; + const other = { driverKind: claude, accentColor: undefined }; + expect(shouldShowInstanceBadge(entry, [entry, other])).toBe(false); + }); +}); diff --git a/packages/client-runtime/src/state/providerInstanceDisplay.ts b/packages/client-runtime/src/state/providerInstanceDisplay.ts new file mode 100644 index 0000000000..6d7a0629b1 --- /dev/null +++ b/packages/client-runtime/src/state/providerInstanceDisplay.ts @@ -0,0 +1,88 @@ +/** + * How a configured provider instance presents itself in a client: its label, + * its accent color, and whether its icon carries the account badge. Shared by + * web and mobile so both clients name and badge the same instance identically. + * + * @module providerInstanceDisplay + */ +import { + defaultInstanceIdForDriver, + PROVIDER_DISPLAY_NAMES, + type ProviderDriverKind, + type ServerProvider, +} from "@t3tools/contracts"; + +/** + * Title-case a slug: splits on `_` / `-` and camelCase boundaries, so + * `codex_personal` becomes "Codex Personal" and `myCustomInstance` becomes + * "My Custom Instance". + */ +function humanizeSlug(slug: string): string { + return slug + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/[_-]+/g, " ") + .trim() + .replace(/\b\w/g, (char) => char.toUpperCase()); +} + +/** + * Resolve an instance's label with a tiered priority: + * + * 1. A snapshot `displayName` that differs from the driver's brand label — + * the server has explicitly named this instance, trust it. + * 2. For non-default instances, a humanized `instanceId` — the server fell + * back to the driver-level label (the same for every instance of that + * kind), so the slug is what keeps "Codex" and "Codex Personal" apart. + * 3. The snapshot's `displayName`, or the brand label from contracts. + */ +export function resolveProviderInstanceDisplayName( + snapshot: Pick, +): string { + const trimmedSnapshotName = snapshot.displayName?.trim(); + const kindLabel = PROVIDER_DISPLAY_NAMES[snapshot.driver] ?? humanizeSlug(snapshot.driver); + if (trimmedSnapshotName && trimmedSnapshotName !== kindLabel) return trimmedSnapshotName; + if (snapshot.instanceId !== defaultInstanceIdForDriver(snapshot.driver)) { + const humanized = humanizeSlug(snapshot.instanceId); + if (humanized.length > 0) return humanized; + } + return trimmedSnapshotName || kindLabel; +} + +/** + * Turn a display name into up to two initials for the badge: the first two + * characters of a single word, or the first character of each of the first + * two words. Iterates by code point so an emoji never splits into surrogates. + */ +export function providerInstanceInitials(label: string): string { + const words = label.replace(/[_-]+/g, " ").split(/\s+/u).filter(Boolean); + if (words.length === 0) return ""; + if (words.length === 1) return Array.from(words[0]!).slice(0, 2).join("").toUpperCase(); + return words + .slice(0, 2) + .map((word) => Array.from(word)[0]?.toUpperCase() ?? "") + .join(""); +} + +/** Only `#rrggbb` accent colors render; anything else is treated as unset. */ +export function normalizeProviderAccentColor(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) return undefined; + return /^#[0-9a-fA-F]{6}$/u.test(trimmed) ? trimmed : undefined; +} + +/** + * Whether an instance's icon carries the account badge: accent color set, or + * several instances sharing a driver so the brand glyph alone is ambiguous. + * Shared by the composer trigger, the picker rail, and sidebar/thread rows. + */ +export function shouldShowInstanceBadge( + entry: { readonly driverKind: ProviderDriverKind; readonly accentColor?: string | undefined }, + entries: Iterable<{ readonly driverKind: ProviderDriverKind }>, +): boolean { + if (entry.accentColor) return true; + let sharedDriverCount = 0; + for (const candidate of entries) { + if (candidate.driverKind === entry.driverKind && ++sharedDriverCount > 1) return true; + } + return false; +} diff --git a/packages/client-runtime/src/state/threadFeedback.test.ts b/packages/client-runtime/src/state/threadFeedback.test.ts index 14ce5185f4..cd66961a59 100644 --- a/packages/client-runtime/src/state/threadFeedback.test.ts +++ b/packages/client-runtime/src/state/threadFeedback.test.ts @@ -4,7 +4,7 @@ import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import { - codexFeedbackMessage, + codexFeedbackNotice, parseCodexFeedbackCommand, submitCodexFeedback, type CodexFeedbackSubmission, @@ -40,7 +40,7 @@ describe("submitCodexFeedback", () => { createdAt: "2026-08-23T00:00:00.000Z", } as const; - it("shows the command and clears the draft before the upload finishes", async () => { + it("reports upload progress and clears the draft before the upload finishes", async () => { let draft: string = submission.command; let finishUpload: | ((result: ReturnType>) => void) @@ -66,14 +66,10 @@ describe("submitCodexFeedback", () => { expect(draft).toBe(""); expect(states).toEqual([{ ...submission, status: "uploading" }]); - expect(codexFeedbackMessage(states[0]!)).toMatchObject({ - id: submission.id, - role: "user", - text: submission.command, + expect(codexFeedbackNotice(states[0]!)).toEqual({ + title: "Sending feedback to OpenAI...", + description: undefined, }); - expect(codexFeedbackMessage(states[0]!, "assistant").text).toBe( - "Sending feedback to OpenAI...", - ); draft = "Keep this newer message."; finishUpload?.(AsyncResult.success({ feedbackId: "codex-thread-1" })); @@ -85,7 +81,7 @@ describe("submitCodexFeedback", () => { status: "sent", feedbackId: "codex-thread-1", }); - expect(codexFeedbackMessage(states.at(-1)!, "assistant").text).toContain("codex-thread-1"); + expect(codexFeedbackNotice(states.at(-1)!)?.description).toContain("codex-thread-1"); }); it("records a failed upload without losing its user-facing error", async () => { @@ -105,6 +101,7 @@ describe("submitCodexFeedback", () => { status: "failed", errorMessage: "Upload rejected.", }); + expect(codexFeedbackNotice(states.at(-1)!)?.description).toBe("Upload rejected."); }); it("marks interruptions without reporting them as upload failures", async () => { @@ -119,6 +116,7 @@ describe("submitCodexFeedback", () => { }); expect(states.at(-1)).toEqual({ ...submission, status: "interrupted" }); + expect(codexFeedbackNotice(states.at(-1)!)).toBeNull(); }); it("lets another feedback submission finish while the first remains in flight", async () => { diff --git a/packages/client-runtime/src/state/threadFeedback.ts b/packages/client-runtime/src/state/threadFeedback.ts index 29abb26893..1b02a8982a 100644 --- a/packages/client-runtime/src/state/threadFeedback.ts +++ b/packages/client-runtime/src/state/threadFeedback.ts @@ -1,8 +1,4 @@ -import { - MessageId, - type OrchestrationMessage, - type ProviderUploadFeedbackResult, -} from "@t3tools/contracts"; +import type { MessageId, ProviderUploadFeedbackResult } from "@t3tools/contracts"; import { isAtomCommandInterrupted, @@ -32,28 +28,20 @@ export function parseCodexFeedbackCommand(text: string): { readonly reason?: str return reason ? { reason } : {}; } -export function codexFeedbackMessage( - submission: CodexFeedbackSubmission, - role: "user" | "assistant" = "user", -): OrchestrationMessage { - const text = - role === "user" - ? submission.command - : submission.status === "sent" - ? `Feedback sent to OpenAI.\n\nThread ID: \`${submission.feedbackId}\`` - : submission.status === "failed" - ? `Could not send feedback to OpenAI.\n\n${submission.errorMessage}` - : "Sending feedback to OpenAI..."; - - return { - id: role === "user" ? submission.id : MessageId.make(`${submission.id}:feedback`), - role, - text, - turnId: null, - streaming: false, - createdAt: submission.createdAt, - updatedAt: submission.createdAt, - }; +export function codexFeedbackNotice(submission: CodexFeedbackSubmission) { + switch (submission.status) { + case "interrupted": + return null; + case "uploading": + return { title: "Sending feedback to OpenAI...", description: undefined }; + case "sent": + return { + title: "Feedback sent to OpenAI", + description: `Thread ID: ${submission.feedbackId}`, + }; + case "failed": + return { title: "Could not send feedback to OpenAI", description: submission.errorMessage }; + } } export async function submitCodexFeedback(input: { diff --git a/packages/shared/src/orchestrationTiming.test.ts b/packages/shared/src/orchestrationTiming.test.ts index dab35ad3e0..74e90d45a5 100644 --- a/packages/shared/src/orchestrationTiming.test.ts +++ b/packages/shared/src/orchestrationTiming.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { formatDuration } from "./orchestrationTiming.ts"; +import { formatDuration, deriveActiveWorkStartedAt } from "./orchestrationTiming.ts"; describe("formatDuration", () => { it.each([ @@ -29,3 +29,110 @@ describe("formatDuration", () => { expect(formatDuration(durationMs)).toBe("0ms"); }); }); + +describe("deriveActiveWorkStartedAt", () => { + it.each([null, "2026-09-06T23:34:00.000Z"])( + "does not time a superseded turn when the active turn differs", + (sendStartedAt) => { + expect( + deriveActiveWorkStartedAt( + { + turnId: "old", + requestedAt: "2026-09-06T23:33:00.000Z", + startedAt: null, + completedAt: null, + }, + { orchestrationStatus: "running", activeTurnId: "new" }, + sendStartedAt, + ), + ).toBe(sendStartedAt); + }, + ); + + it("stops timing a turn that failed before its provider started", () => { + expect( + deriveActiveWorkStartedAt( + { + turnId: "turn-1", + requestedAt: "2026-09-06T23:33:00.000Z", + startedAt: null, + completedAt: "2026-09-06T23:33:05.000Z", + }, + { orchestrationStatus: "error", activeTurnId: null }, + null, + ), + ).toBeNull(); + }); + // The gap this closes. The projector stamps startedAt in the same update + // that moves the session to "running", so during provider spin-up the turn + // is requested with no startedAt and the session is "starting". Returning + // null there blinks the working indicator out between "Setting up + // worktree..." and "Working for 0s". + it("counts from requestedAt while the provider is still starting", () => { + expect( + deriveActiveWorkStartedAt( + { + turnId: "turn-1", + requestedAt: "2026-09-06T23:33:00.000Z", + startedAt: null, + completedAt: null, + }, + { orchestrationStatus: "starting", activeTurnId: null }, + null, + ), + ).toBe("2026-09-06T23:33:00.000Z"); + }); + + it("prefers the turn's own startedAt once the provider reports it", () => { + expect( + deriveActiveWorkStartedAt( + { + turnId: "turn-1", + requestedAt: "2026-09-06T23:33:00.000Z", + startedAt: "2026-09-06T23:33:05.000Z", + completedAt: null, + }, + { orchestrationStatus: "running", activeTurnId: "turn-1" }, + null, + ), + ).toBe("2026-09-06T23:33:05.000Z"); + }); + + // requestedAt must not leak past the end of the work. + it("stops counting once the turn has settled", () => { + expect( + deriveActiveWorkStartedAt( + { + turnId: "turn-1", + requestedAt: "2026-09-06T23:33:00.000Z", + startedAt: "2026-09-06T23:33:05.000Z", + completedAt: "2026-09-06T23:33:09.000Z", + }, + { orchestrationStatus: "idle", activeTurnId: null }, + null, + ), + ).toBeNull(); + }); + + // A session restarting with no new turn must not resurrect the old one. + it("does not count a settled turn while a session is starting again", () => { + expect( + deriveActiveWorkStartedAt( + { + turnId: "turn-1", + requestedAt: "2026-09-06T23:33:00.000Z", + startedAt: "2026-09-06T23:33:05.000Z", + completedAt: "2026-09-06T23:33:09.000Z", + }, + { orchestrationStatus: "starting", activeTurnId: null }, + null, + ), + ).toBeNull(); + }); + + it("falls back to the caller's send timestamp when there is no turn yet", () => { + expect(deriveActiveWorkStartedAt(null, null, "2026-09-06T23:33:00.000Z")).toBe( + "2026-09-06T23:33:00.000Z", + ); + }); +}); diff --git a/packages/shared/src/orchestrationTiming.ts b/packages/shared/src/orchestrationTiming.ts index 2ae82c22a6..b87fa95474 100644 --- a/packages/shared/src/orchestrationTiming.ts +++ b/packages/shared/src/orchestrationTiming.ts @@ -1,5 +1,7 @@ type LatestTurnTiming = { readonly turnId: string | null; + /** Set when the turn is created; `startedAt` waits for the provider. */ + readonly requestedAt?: string | null; readonly startedAt: string | null; readonly completedAt: string | null; }; @@ -32,20 +34,33 @@ function isLatestTurnSettled( latestTurn: LatestTurnTiming | null, session: SessionActivityState | null, ): boolean { - if (!latestTurn?.startedAt) return false; + if (!latestTurn) return false; if (!latestTurn.completedAt) return false; if (!session) return true; if (session.orchestrationStatus === "running") return false; return true; } +/** + * When the working indicator should be counting, and from when. + * + * `requestedAt` is the floor for an unsettled turn. The projector only stamps + * `startedAt` in the same update that moves the session to "running", so while + * the provider spins up (session "starting") a requested turn has no + * `startedAt` at all — and returning null there blinks the indicator out for + * the whole spin-up. A settled turn still falls through to `sendStartedAt`, so + * this cannot leave the indicator counting after the work is done. + */ export function deriveActiveWorkStartedAt( latestTurn: LatestTurnTiming | null, session: SessionActivityState | null, sendStartedAt: string | null, ): string | null { + if (session?.activeTurnId && session.activeTurnId !== latestTurn?.turnId) { + return sendStartedAt; + } if (!isLatestTurnSettled(latestTurn, session)) { - return latestTurn?.startedAt ?? sendStartedAt; + return latestTurn?.startedAt ?? latestTurn?.requestedAt ?? sendStartedAt; } return sendStartedAt; } diff --git a/patches/@legendapp__list@3.3.5.patch b/patches/@legendapp__list@3.3.5.patch index e731209233..3559a66614 100644 --- a/patches/@legendapp__list@3.3.5.patch +++ b/patches/@legendapp__list@3.3.5.patch @@ -241,7 +241,7 @@ index ce1fe00001c9e5aee6c6ea8bb2d4757d4586d002..3ccf6f16067152dfcb0c143371e2ec6a * Number of columns to render items in. * @default 1 diff --git a/react-native.js b/react-native.js -index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df752a843de0 100644 +index b3c5a306b293f797a8b338adfca3060c0f6db22b..5ac6bbe8fe40bf252fefa6b885c2196d0677d75f 100644 --- a/react-native.js +++ b/react-native.js @@ -717,6 +717,15 @@ function hasActiveInitialScroll(state) { @@ -269,16 +269,25 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 set$(ctx, "isNearEnd", isContentLess || distanceFromEnd <= onEndReachedThreshold * scrollLength); set$( ctx, -@@ -954,7 +963,7 @@ function setInitialRenderState(ctx, { +@@ -954,7 +963,16 @@ function setInitialRenderState(ctx, { if (didInitialScroll) { state.didFinishInitialScroll = true; } - const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); ++ // Native contentOffset can seed an end target without dispatchInitialScroll. ++ // Both overflow completion paths must wait for the native end landing. ++ // Underflow needs no scroll: preserve UIKit's untouched resting position. ++ if (state.didContainersLayout && state.didFinishInitialScroll && state.initialScroll && state.initialScroll.viewPosition === 1 && state.initialScroll.index === state.props.data.length - 1 && state.props.data.length > 0) { ++ const insetStartAdjustment = getContentInsetStartAdjustment(ctx); ++ if (insetStartAdjustment > 0 && getContentSize(ctx) > state.scrollLength - insetStartAdjustment + 1) { ++ startInsetEndSettleWatchdog(ctx); ++ } ++ } + const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll && !state.insetEndRevealHold); if (isReadyToRender && !peek$(ctx, "readyToRender")) { set$(ctx, "readyToRender", true); setAdaptiveRender(ctx, "normal", "ready"); -@@ -1090,7 +1099,7 @@ function getRawContentLength(ctx) { +@@ -1090,7 +1108,7 @@ function getRawContentLength(ctx) { function getAlignItemsAtEndPadding(ctx) { const { state } = ctx; const shouldPad = !!state.props.alignItemsAtEndPaddingEnabled && !state.props.horizontal && state.props.data.length > 0 && state.scrollLength > 0; @@ -287,7 +296,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 } function updateContentMetricsState(ctx) { const previousPadding = peek$(ctx, "alignItemsAtEndPadding") || 0; -@@ -1115,6 +1124,10 @@ function addTotalSize(ctx, key, add, notifyTotalSize = true) { +@@ -1115,6 +1133,10 @@ function addTotalSize(ctx, key, add, notifyTotalSize = true) { totalSize += add; } if (prevTotalSize !== totalSize) { @@ -298,7 +307,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 if (!IsNewArchitecture && state.initialScroll && totalSize < prevTotalSize) { state.pendingTotalSize = totalSize; } else { -@@ -1304,18 +1317,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { +@@ -1304,18 +1326,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { } // src/core/clampScrollOffset.ts @@ -324,7 +333,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 return clampedOffset; } -@@ -1451,10 +1469,10 @@ function checkFinishedScrollFrame(ctx) { +@@ -1451,10 +1478,10 @@ function checkFinishedScrollFrame(ctx) { finishScrollTo(ctx); } } @@ -337,7 +346,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 x: ctx.state.props.horizontal ? offset : 0, y: ctx.state.props.horizontal ? 0 : offset }); -@@ -1503,7 +1521,10 @@ function checkFinishedScrollFallback(ctx) { +@@ -1503,7 +1530,10 @@ function checkFinishedScrollFallback(ctx) { ); scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); } else if (shouldRetryUnalignedEndScroll) { @@ -349,7 +358,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 scheduleFallbackCheck(100); } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { finishScrollTo(ctx); -@@ -1560,15 +1581,28 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1560,15 +1590,28 @@ function doMaintainScrollAtEnd(ctx) { } = state; const isWithinMaintainScrollAtEndThreshold = peek$(ctx, "isWithinMaintainScrollAtEndThreshold"); const shouldMaintainScrollAtEnd = !!(isWithinMaintainScrollAtEndThreshold && maintainScrollAtEnd && didContainersLayout); @@ -379,7 +388,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 } if (!state.maintainingScrollAtEnd) { const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant"; -@@ -1591,9 +1625,18 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1591,9 +1634,18 @@ function doMaintainScrollAtEnd(ctx) { y: 0 }); } else { @@ -401,7 +410,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 } setTimeout( () => { -@@ -1624,6 +1667,10 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1624,6 +1676,10 @@ function doMaintainScrollAtEnd(ctx) { function requestAdjust(ctx, positionDiff, dataChanged) { const state = ctx.state; if (Math.abs(positionDiff) > 0.1) { @@ -412,7 +421,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff; const doit = () => { if (needsScrollWorkaround) { -@@ -1728,7 +1775,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { +@@ -1728,7 +1784,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { return 0; } @@ -423,7 +432,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 const clampDelta = maxScroll - state.scroll; if (unresolvedAmount < 0) { return Math.max(unresolvedAmount, Math.min(0, clampDelta)); -@@ -1790,7 +1839,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { +@@ -1790,7 +1848,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); return true; } @@ -432,7 +441,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; if (isAtExpectedNativeClamp) { -@@ -1923,7 +1972,7 @@ function prepareMVCP(ctx, dataChanged) { +@@ -1923,7 +1981,7 @@ function prepareMVCP(ctx, dataChanged) { if (diff > 0) { diff = Math.max(0, totalSize - state.scroll - state.scrollLength); } else { @@ -441,7 +450,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 state.scroll = maxScroll; state.scrollPending = maxScroll; diff = 0; -@@ -2320,8 +2369,121 @@ function scrollToIndex(ctx, { +@@ -2320,8 +2378,121 @@ function scrollToIndex(ctx, { } // src/core/initialScroll.ts @@ -451,11 +460,11 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 +var INSET_END_REVEAL_MAX_HOLD_FRAMES = 40; +function startInsetEndSettleWatchdog(ctx) { + const state = ctx.state; -+ if (state.insetEndSettleWatchdogActive) { ++ if (state.insetEndSettleWatchdogStarted || state.didLoad) { + return; + } ++ state.insetEndSettleWatchdogStarted = true; + state.insetEndSettleWatchdogActive = true; -+ state.didUserDrag = false; + // Hold the readyToRender opacity gate until the end landing is stable, so + // the estimated-to-measured settle chase happens before first VISIBLE + // paint instead of in front of the user. Capped so slow measurement can @@ -464,6 +473,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 + let frames = 0; + let settledFrames = 0; + let revealStableFrames = 0; ++ let previousEndOffset; + const releaseRevealHold = () => { + if (state.insetEndRevealHold) { + state.insetEndRevealHold = false; @@ -503,33 +513,32 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 + const insetStartAdjustment = getContentInsetStartAdjustment(ctx); + const contentSize = getContentSize(ctx); + const scrollLength = state.scrollLength; -+ if (insetStartAdjustment > 0 && scrollLength > 0 && Number.isFinite(contentSize) && contentSize > scrollLength && !state.scrollingTo && !state.maintainingScrollAtEnd) { -+ const endOffset = Math.max(-insetStartAdjustment, contentSize - scrollLength); -+ const distance = endOffset - state.scroll; -+ // Estimated row sizes converging to measured ones can strand the initial -+ // end landing when the library's own end-anchor bookkeeping gives up. -+ // While still near the end (never fighting a user who scrolled away), -+ // re-pin to the current true end until sizes stop changing. -+ if (Math.abs(distance) > 2 && Math.abs(distance) <= scrollLength * 0.5) { -+ settledFrames = 0; -+ revealStableFrames = 0; ++ const endOffset = Math.max(-insetStartAdjustment, contentSize - scrollLength); ++ // state.scroll is optimistic: non-animated scrollTo writes it before UIKit ++ // moves. Only a native scroll event proves that the requested offset landed. ++ const nativeOffset = state.lastNativeScroll; ++ const hasNativeOffset = typeof nativeOffset === "number" && Number.isFinite(nativeOffset); ++ const distance = hasNativeOffset ? endOffset - nativeOffset : Infinity; ++ const isIdle = !state.scrollingTo && !state.maintainingScrollAtEnd; ++ const hasViewport = insetStartAdjustment > 0 && scrollLength > 0 && Number.isFinite(contentSize); ++ const isStable = hasViewport && isIdle && Math.abs(distance) <= 2 && previousEndOffset !== void 0 && Math.abs(endOffset - previousEndOffset) <= 1; ++ previousEndOffset = endOffset; ++ if (isStable) { ++ settledFrames++; ++ revealStableFrames++; ++ if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) { ++ onRevealStability(); ++ } ++ } else { ++ settledFrames = 0; ++ revealStableFrames = 0; ++ // Keep the existing near-end correction limit. In-flight scrolls and ++ // unknown native offsets wait; neither counts as a settled frame. ++ if (hasViewport && isIdle && Math.abs(distance) > 2 && Math.abs(distance) <= scrollLength * 0.5) { + const scroller = state.refScroller.current; + if (scroller) { + scroller.scrollTo({ animated: false, x: 0, y: endOffset }); + } -+ } else { -+ settledFrames++; -+ revealStableFrames++; -+ if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) { -+ onRevealStability(); -+ } -+ } -+ } else { -+ // Conditions that make re-pinning unnecessary (underflow, in-flight -+ // programmatic scroll) count toward stability for the reveal. -+ revealStableFrames++; -+ if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) { -+ onRevealStability(); + } + } + requestAnimationFrame(tick); @@ -563,18 +572,18 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 const requestedIndex = target.index; const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; const itemSize = getItemSizeAtIndex(ctx, index); -@@ -2747,7 +2909,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { +@@ -2747,7 +2918,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { return; } if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { - const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && peek$(ctx, "isAtEnd"); + const endTargetDistanceFromEnd = getContentSize(ctx) - state.scroll - state.scrollLength - getContentInsetEnd(ctx); + const isNearEndForInsetList = getContentInsetStartAdjustment(ctx) > 0 && Number.isFinite(endTargetDistanceFromEnd) && endTargetDistanceFromEnd <= state.scrollLength * 0.5; -+ const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && (peek$(ctx, "isAtEnd") || isNearEndForInsetList); ++ const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && (peek$(ctx, "isAtEnd") || !state.didUserDrag && isNearEndForInsetList); if (!shouldKeepEndTargetAlive) { if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { clearPendingInitialScrollFooterLayout(ctx, { -@@ -4672,7 +4836,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { +@@ -4672,7 +4845,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap); contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props); isReady = !hasUnknownTailSize; @@ -584,7 +593,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 } else if (anchorIndex >= 0) { isReady = false; } -@@ -4692,6 +4857,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { +@@ -4692,6 +4866,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { updateScroll(ctx, state.scroll, true, { markHasScrolled: false }); } (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); @@ -597,7 +606,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 } return nextSize; } -@@ -5715,6 +5886,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ +@@ -5715,6 +5895,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ horizontal }) { const ctx = useStateContext(); @@ -605,7 +614,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 const columnWrapperStyle = ctx.columnWrapperStyle; const animSize = useValue$("totalSize"); const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]); -@@ -5725,6 +5897,13 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ +@@ -5725,6 +5906,13 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ opacity: isVisible ? 1 : 0, width: animSize } : { height: animSize, minWidth: otherAxisSize, opacity: isVisible ? 1 : 0 }; @@ -619,7 +628,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 if (columnWrapperStyle) { const { columnGap, rowGap, gap } = columnWrapperStyle; const gapX = columnGap || gap || 0; -@@ -5745,7 +5924,8 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ +@@ -5745,7 +5933,8 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ } } } @@ -629,7 +638,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 }); var Containers = typedMemo(function Containers2({ freshDataTransitionEpoch, -@@ -5896,7 +6076,12 @@ var StyleSheet = ReactNative.StyleSheet; +@@ -5896,7 +6085,12 @@ var StyleSheet = ReactNative.StyleSheet; // src/components/ListComponent.tsx var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizontal }) { @@ -642,7 +651,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 if (alignItemsAtEndPadding <= 0) { return null; } -@@ -5929,8 +6114,12 @@ var ListComponent = typedMemo(function ListComponent2({ +@@ -5929,8 +6123,12 @@ var ListComponent = typedMemo(function ListComponent2({ refScrollView, renderScrollComponent, onLayoutFooter, @@ -655,7 +664,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 scrollAdjustHandler, snapToIndices, stickyHeaderConfig, -@@ -6001,7 +6190,17 @@ var ListComponent = typedMemo(function ListComponent2({ +@@ -6001,7 +6199,17 @@ var ListComponent = typedMemo(function ListComponent2({ SnapOrScroll, { ...rest, @@ -674,7 +683,19 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 contentContainerStyle: [ horizontal ? { height: "100%" } : {}, contentContainerStyle, -@@ -6751,7 +6950,7 @@ function createImperativeHandle(ctx, scheduleImperativeScrollCommit) { +@@ -6010,7 +6218,10 @@ var ListComponent = typedMemo(function ListComponent2({ + ], + contentOffset: initialContentOffset !== void 0 ? horizontal ? { x: initialContentOffset, y: 0 } : { x: 0, y: initialContentOffset } : void 0, + horizontal, +- maintainVisibleContentPosition: maintainVisibleContentPosition.size || maintainVisibleContentPosition.data ? { minIndexForVisible: 0 } : void 0, ++ // Keep iOS anchored to ScrollAdjust even when JS position restoration is ++ // disabled. Re-enabling native MVCP mid-drag can compare its stale anchor ++ // with the 1e7 sentinel and scroll the entire list out of view. ++ maintainVisibleContentPosition: Platform.OS === "ios" || maintainVisibleContentPosition.size || maintainVisibleContentPosition.data ? { minIndexForVisible: 0 } : void 0, + onLayout, + onScroll: onScroll2, + ref: refScrollView, +@@ -6751,7 +6962,7 @@ function createImperativeHandle(ctx, scheduleImperativeScrollCommit) { endBuffered: state.endBuffered, getAverageItemSizes: () => getAverageItemSizes(state), indexByKey: (key) => state.indexByKey.get(key), @@ -683,7 +704,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 isAtStart: peek$(ctx, "isAtStart"), isEndReached: state.isEndReached, isNearEnd: peek$(ctx, "isNearEnd"), -@@ -7075,6 +7274,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7075,6 +7286,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded dataVersion, drawDistance = 250, contentInsetEndAdjustment, @@ -691,7 +712,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 estimatedItemSize = 100, estimatedListSize, extraData, -@@ -7132,10 +7332,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7132,10 +7344,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded const animatedPropsInternal = props.animatedPropsInternal; const anchoredEndSpaceOwner = (_a3 = props.anchoredEndSpaceOwnerInternal) != null ? _a3 : "list"; const positionComponentInternal = props.positionComponentInternal; @@ -704,7 +725,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 stickyPositionComponentInternal: _stickyPositionComponentInternal, ...restProps } = rest; -@@ -7200,7 +7402,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7200,7 +7414,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded const combinedRef = useCombinedRef(refScroller, refScrollView); const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString()); const stickyHeaderIndices = stickyHeaderIndicesProp; @@ -713,7 +734,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 const previousContentInsetEndAdjustmentRef = React2.useRef(contentInsetEndAdjustmentResolved); const alwaysRenderIndices = React2.useMemo(() => { const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex); -@@ -7341,6 +7543,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7341,6 +7555,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded contentContainerAlignItems: contentContainerStyle.alignItems, contentInset, contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, @@ -721,7 +742,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 data: dataProp, dataKey, dataVersion, -@@ -7372,6 +7575,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7372,6 +7587,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded renderItem, rtl, snapToIndices, @@ -729,7 +750,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 stickyHeaderIndicesArr: stickyHeaderIndices != null ? stickyHeaderIndices : [], stickyHeaderIndicesSet: React2.useMemo(() => new Set(stickyHeaderIndices != null ? stickyHeaderIndices : []), [stickyHeaderIndices == null ? void 0 : stickyHeaderIndices.join(",")]), stickyPositionComponentInternal, -@@ -7423,6 +7627,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7423,6 +7639,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded return void 0; } const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); @@ -743,7 +764,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; }, [usesBootstrapInitialScroll]); React2.useLayoutEffect(() => { -@@ -7547,6 +7758,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7547,6 +7770,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded [ dataKey, dataVersion, @@ -751,7 +772,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 memoizedLastItemKeys.join(","), numColumnsProp, nextScrollAxisGap, -@@ -7643,6 +7855,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7643,6 +7867,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded () => ({ getRenderedItem: (key) => getRenderedItem(ctx, key), onMomentumScrollEnd: (event) => { @@ -759,7 +780,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 checkFinishedScrollFallback(ctx); if (state.props.onMomentumScrollEnd) { state.props.onMomentumScrollEnd(event); -@@ -7651,6 +7864,8 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7651,6 +7876,8 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded onScroll: (event) => onScroll(ctx, event), onScrollBeginDrag: (event) => { var _a4, _b2; @@ -768,7 +789,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 prepareReachedEdgeForNextUserScroll(ctx); (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event); }, -@@ -7676,11 +7891,18 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7676,11 +7903,18 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded ListFooterComponent, ListFooterComponentStyle, ListHeaderComponent, @@ -788,7 +809,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 recycleItems, refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2__namespace.cloneElement(refreshControlElement, { diff --git a/react-native.mjs b/react-native.mjs -index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff7e3ad022 100644 +index 40e87cda8c9bc79a889e5542f29af429a24b24d4..93aac741d2cf77ce35996362439108176f19da7a 100644 --- a/react-native.mjs +++ b/react-native.mjs @@ -696,6 +696,15 @@ function hasActiveInitialScroll(state) { @@ -816,16 +837,25 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff set$(ctx, "isNearEnd", isContentLess || distanceFromEnd <= onEndReachedThreshold * scrollLength); set$( ctx, -@@ -933,7 +942,7 @@ function setInitialRenderState(ctx, { +@@ -933,7 +942,16 @@ function setInitialRenderState(ctx, { if (didInitialScroll) { state.didFinishInitialScroll = true; } - const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); ++ // Native contentOffset can seed an end target without dispatchInitialScroll. ++ // Both overflow completion paths must wait for the native end landing. ++ // Underflow needs no scroll: preserve UIKit's untouched resting position. ++ if (state.didContainersLayout && state.didFinishInitialScroll && state.initialScroll && state.initialScroll.viewPosition === 1 && state.initialScroll.index === state.props.data.length - 1 && state.props.data.length > 0) { ++ const insetStartAdjustment = getContentInsetStartAdjustment(ctx); ++ if (insetStartAdjustment > 0 && getContentSize(ctx) > state.scrollLength - insetStartAdjustment + 1) { ++ startInsetEndSettleWatchdog(ctx); ++ } ++ } + const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll && !state.insetEndRevealHold); if (isReadyToRender && !peek$(ctx, "readyToRender")) { set$(ctx, "readyToRender", true); setAdaptiveRender(ctx, "normal", "ready"); -@@ -1069,7 +1078,7 @@ function getRawContentLength(ctx) { +@@ -1069,7 +1087,7 @@ function getRawContentLength(ctx) { function getAlignItemsAtEndPadding(ctx) { const { state } = ctx; const shouldPad = !!state.props.alignItemsAtEndPaddingEnabled && !state.props.horizontal && state.props.data.length > 0 && state.scrollLength > 0; @@ -834,7 +864,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff } function updateContentMetricsState(ctx) { const previousPadding = peek$(ctx, "alignItemsAtEndPadding") || 0; -@@ -1094,6 +1103,10 @@ function addTotalSize(ctx, key, add, notifyTotalSize = true) { +@@ -1094,6 +1112,10 @@ function addTotalSize(ctx, key, add, notifyTotalSize = true) { totalSize += add; } if (prevTotalSize !== totalSize) { @@ -845,7 +875,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff if (!IsNewArchitecture && state.initialScroll && totalSize < prevTotalSize) { state.pendingTotalSize = totalSize; } else { -@@ -1283,18 +1296,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { +@@ -1283,18 +1305,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { } // src/core/clampScrollOffset.ts @@ -871,7 +901,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff return clampedOffset; } -@@ -1430,10 +1448,10 @@ function checkFinishedScrollFrame(ctx) { +@@ -1430,10 +1457,10 @@ function checkFinishedScrollFrame(ctx) { finishScrollTo(ctx); } } @@ -884,7 +914,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff x: ctx.state.props.horizontal ? offset : 0, y: ctx.state.props.horizontal ? 0 : offset }); -@@ -1482,7 +1500,10 @@ function checkFinishedScrollFallback(ctx) { +@@ -1482,7 +1509,10 @@ function checkFinishedScrollFallback(ctx) { ); scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); } else if (shouldRetryUnalignedEndScroll) { @@ -896,7 +926,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff scheduleFallbackCheck(100); } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { finishScrollTo(ctx); -@@ -1539,15 +1560,28 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1539,15 +1569,28 @@ function doMaintainScrollAtEnd(ctx) { } = state; const isWithinMaintainScrollAtEndThreshold = peek$(ctx, "isWithinMaintainScrollAtEndThreshold"); const shouldMaintainScrollAtEnd = !!(isWithinMaintainScrollAtEndThreshold && maintainScrollAtEnd && didContainersLayout); @@ -926,7 +956,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff } if (!state.maintainingScrollAtEnd) { const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant"; -@@ -1570,9 +1604,18 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1570,9 +1613,18 @@ function doMaintainScrollAtEnd(ctx) { y: 0 }); } else { @@ -948,7 +978,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff } setTimeout( () => { -@@ -1603,6 +1646,10 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1603,6 +1655,10 @@ function doMaintainScrollAtEnd(ctx) { function requestAdjust(ctx, positionDiff, dataChanged) { const state = ctx.state; if (Math.abs(positionDiff) > 0.1) { @@ -959,7 +989,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff; const doit = () => { if (needsScrollWorkaround) { -@@ -1707,7 +1754,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { +@@ -1707,7 +1763,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { return 0; } @@ -970,7 +1000,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff const clampDelta = maxScroll - state.scroll; if (unresolvedAmount < 0) { return Math.max(unresolvedAmount, Math.min(0, clampDelta)); -@@ -1769,7 +1818,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { +@@ -1769,7 +1827,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); return true; } @@ -979,7 +1009,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; if (isAtExpectedNativeClamp) { -@@ -1902,7 +1951,7 @@ function prepareMVCP(ctx, dataChanged) { +@@ -1902,7 +1960,7 @@ function prepareMVCP(ctx, dataChanged) { if (diff > 0) { diff = Math.max(0, totalSize - state.scroll - state.scrollLength); } else { @@ -988,7 +1018,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff state.scroll = maxScroll; state.scrollPending = maxScroll; diff = 0; -@@ -2299,8 +2348,121 @@ function scrollToIndex(ctx, { +@@ -2299,8 +2357,121 @@ function scrollToIndex(ctx, { } // src/core/initialScroll.ts @@ -998,11 +1028,11 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff +var INSET_END_REVEAL_MAX_HOLD_FRAMES = 40; +function startInsetEndSettleWatchdog(ctx) { + const state = ctx.state; -+ if (state.insetEndSettleWatchdogActive) { ++ if (state.insetEndSettleWatchdogStarted || state.didLoad) { + return; + } ++ state.insetEndSettleWatchdogStarted = true; + state.insetEndSettleWatchdogActive = true; -+ state.didUserDrag = false; + // Hold the readyToRender opacity gate until the end landing is stable, so + // the estimated-to-measured settle chase happens before first VISIBLE + // paint instead of in front of the user. Capped so slow measurement can @@ -1011,6 +1041,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff + let frames = 0; + let settledFrames = 0; + let revealStableFrames = 0; ++ let previousEndOffset; + const releaseRevealHold = () => { + if (state.insetEndRevealHold) { + state.insetEndRevealHold = false; @@ -1050,33 +1081,32 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff + const insetStartAdjustment = getContentInsetStartAdjustment(ctx); + const contentSize = getContentSize(ctx); + const scrollLength = state.scrollLength; -+ if (insetStartAdjustment > 0 && scrollLength > 0 && Number.isFinite(contentSize) && contentSize > scrollLength && !state.scrollingTo && !state.maintainingScrollAtEnd) { -+ const endOffset = Math.max(-insetStartAdjustment, contentSize - scrollLength); -+ const distance = endOffset - state.scroll; -+ // Estimated row sizes converging to measured ones can strand the initial -+ // end landing when the library's own end-anchor bookkeeping gives up. -+ // While still near the end (never fighting a user who scrolled away), -+ // re-pin to the current true end until sizes stop changing. -+ if (Math.abs(distance) > 2 && Math.abs(distance) <= scrollLength * 0.5) { -+ settledFrames = 0; -+ revealStableFrames = 0; ++ const endOffset = Math.max(-insetStartAdjustment, contentSize - scrollLength); ++ // state.scroll is optimistic: non-animated scrollTo writes it before UIKit ++ // moves. Only a native scroll event proves that the requested offset landed. ++ const nativeOffset = state.lastNativeScroll; ++ const hasNativeOffset = typeof nativeOffset === "number" && Number.isFinite(nativeOffset); ++ const distance = hasNativeOffset ? endOffset - nativeOffset : Infinity; ++ const isIdle = !state.scrollingTo && !state.maintainingScrollAtEnd; ++ const hasViewport = insetStartAdjustment > 0 && scrollLength > 0 && Number.isFinite(contentSize); ++ const isStable = hasViewport && isIdle && Math.abs(distance) <= 2 && previousEndOffset !== void 0 && Math.abs(endOffset - previousEndOffset) <= 1; ++ previousEndOffset = endOffset; ++ if (isStable) { ++ settledFrames++; ++ revealStableFrames++; ++ if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) { ++ onRevealStability(); ++ } ++ } else { ++ settledFrames = 0; ++ revealStableFrames = 0; ++ // Keep the existing near-end correction limit. In-flight scrolls and ++ // unknown native offsets wait; neither counts as a settled frame. ++ if (hasViewport && isIdle && Math.abs(distance) > 2 && Math.abs(distance) <= scrollLength * 0.5) { + const scroller = state.refScroller.current; + if (scroller) { + scroller.scrollTo({ animated: false, x: 0, y: endOffset }); + } -+ } else { -+ settledFrames++; -+ revealStableFrames++; -+ if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) { -+ onRevealStability(); -+ } -+ } -+ } else { -+ // Conditions that make re-pinning unnecessary (underflow, in-flight -+ // programmatic scroll) count toward stability for the reveal. -+ revealStableFrames++; -+ if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) { -+ onRevealStability(); + } + } + requestAnimationFrame(tick); @@ -1110,18 +1140,18 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff const requestedIndex = target.index; const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; const itemSize = getItemSizeAtIndex(ctx, index); -@@ -2726,7 +2888,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { +@@ -2726,7 +2897,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { return; } if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { - const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && peek$(ctx, "isAtEnd"); + const endTargetDistanceFromEnd = getContentSize(ctx) - state.scroll - state.scrollLength - getContentInsetEnd(ctx); + const isNearEndForInsetList = getContentInsetStartAdjustment(ctx) > 0 && Number.isFinite(endTargetDistanceFromEnd) && endTargetDistanceFromEnd <= state.scrollLength * 0.5; -+ const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && (peek$(ctx, "isAtEnd") || isNearEndForInsetList); ++ const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && (peek$(ctx, "isAtEnd") || !state.didUserDrag && isNearEndForInsetList); if (!shouldKeepEndTargetAlive) { if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { clearPendingInitialScrollFooterLayout(ctx, { -@@ -4651,7 +4815,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { +@@ -4651,7 +4824,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap); contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props); isReady = !hasUnknownTailSize; @@ -1131,7 +1161,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff } else if (anchorIndex >= 0) { isReady = false; } -@@ -4671,6 +4836,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { +@@ -4671,6 +4845,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { updateScroll(ctx, state.scroll, true, { markHasScrolled: false }); } (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); @@ -1144,7 +1174,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff } return nextSize; } -@@ -5694,6 +5865,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ +@@ -5694,6 +5874,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ horizontal }) { const ctx = useStateContext(); @@ -1152,7 +1182,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff const columnWrapperStyle = ctx.columnWrapperStyle; const animSize = useValue$("totalSize"); const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]); -@@ -5704,6 +5876,13 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ +@@ -5704,6 +5885,13 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ opacity: isVisible ? 1 : 0, width: animSize } : { height: animSize, minWidth: otherAxisSize, opacity: isVisible ? 1 : 0 }; @@ -1166,7 +1196,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff if (columnWrapperStyle) { const { columnGap, rowGap, gap } = columnWrapperStyle; const gapX = columnGap || gap || 0; -@@ -5724,7 +5903,8 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ +@@ -5724,7 +5912,8 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ } } } @@ -1176,7 +1206,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff }); var Containers = typedMemo(function Containers2({ freshDataTransitionEpoch, -@@ -5875,7 +6055,12 @@ var StyleSheet = StyleSheet$1; +@@ -5875,7 +6064,12 @@ var StyleSheet = StyleSheet$1; // src/components/ListComponent.tsx var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizontal }) { @@ -1189,7 +1219,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff if (alignItemsAtEndPadding <= 0) { return null; } -@@ -5908,8 +6093,12 @@ var ListComponent = typedMemo(function ListComponent2({ +@@ -5908,8 +6102,12 @@ var ListComponent = typedMemo(function ListComponent2({ refScrollView, renderScrollComponent, onLayoutFooter, @@ -1202,7 +1232,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff scrollAdjustHandler, snapToIndices, stickyHeaderConfig, -@@ -5980,7 +6169,17 @@ var ListComponent = typedMemo(function ListComponent2({ +@@ -5980,7 +6178,17 @@ var ListComponent = typedMemo(function ListComponent2({ SnapOrScroll, { ...rest, @@ -1221,7 +1251,19 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff contentContainerStyle: [ horizontal ? { height: "100%" } : {}, contentContainerStyle, -@@ -6730,7 +6929,7 @@ function createImperativeHandle(ctx, scheduleImperativeScrollCommit) { +@@ -5989,7 +6197,10 @@ var ListComponent = typedMemo(function ListComponent2({ + ], + contentOffset: initialContentOffset !== void 0 ? horizontal ? { x: initialContentOffset, y: 0 } : { x: 0, y: initialContentOffset } : void 0, + horizontal, +- maintainVisibleContentPosition: maintainVisibleContentPosition.size || maintainVisibleContentPosition.data ? { minIndexForVisible: 0 } : void 0, ++ // Keep iOS anchored to ScrollAdjust even when JS position restoration is ++ // disabled. Re-enabling native MVCP mid-drag can compare its stale anchor ++ // with the 1e7 sentinel and scroll the entire list out of view. ++ maintainVisibleContentPosition: Platform.OS === "ios" || maintainVisibleContentPosition.size || maintainVisibleContentPosition.data ? { minIndexForVisible: 0 } : void 0, + onLayout, + onScroll: onScroll2, + ref: refScrollView, +@@ -6730,7 +6941,7 @@ function createImperativeHandle(ctx, scheduleImperativeScrollCommit) { endBuffered: state.endBuffered, getAverageItemSizes: () => getAverageItemSizes(state), indexByKey: (key) => state.indexByKey.get(key), @@ -1230,7 +1272,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff isAtStart: peek$(ctx, "isAtStart"), isEndReached: state.isEndReached, isNearEnd: peek$(ctx, "isNearEnd"), -@@ -7054,6 +7253,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7054,6 +7265,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded dataVersion, drawDistance = 250, contentInsetEndAdjustment, @@ -1238,7 +1280,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff estimatedItemSize = 100, estimatedListSize, extraData, -@@ -7111,10 +7311,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7111,10 +7323,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded const animatedPropsInternal = props.animatedPropsInternal; const anchoredEndSpaceOwner = (_a3 = props.anchoredEndSpaceOwnerInternal) != null ? _a3 : "list"; const positionComponentInternal = props.positionComponentInternal; @@ -1251,7 +1293,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff stickyPositionComponentInternal: _stickyPositionComponentInternal, ...restProps } = rest; -@@ -7179,7 +7381,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7179,7 +7393,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded const combinedRef = useCombinedRef(refScroller, refScrollView); const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString()); const stickyHeaderIndices = stickyHeaderIndicesProp; @@ -1260,7 +1302,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff const previousContentInsetEndAdjustmentRef = useRef(contentInsetEndAdjustmentResolved); const alwaysRenderIndices = useMemo(() => { const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex); -@@ -7320,6 +7522,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7320,6 +7534,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded contentContainerAlignItems: contentContainerStyle.alignItems, contentInset, contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, @@ -1268,7 +1310,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff data: dataProp, dataKey, dataVersion, -@@ -7351,6 +7554,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7351,6 +7566,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded renderItem, rtl, snapToIndices, @@ -1276,7 +1318,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff stickyHeaderIndicesArr: stickyHeaderIndices != null ? stickyHeaderIndices : [], stickyHeaderIndicesSet: useMemo(() => new Set(stickyHeaderIndices != null ? stickyHeaderIndices : []), [stickyHeaderIndices == null ? void 0 : stickyHeaderIndices.join(",")]), stickyPositionComponentInternal, -@@ -7402,6 +7606,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7402,6 +7618,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded return void 0; } const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); @@ -1290,7 +1332,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; }, [usesBootstrapInitialScroll]); useLayoutEffect(() => { -@@ -7526,6 +7737,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7526,6 +7749,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded [ dataKey, dataVersion, @@ -1298,7 +1340,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff memoizedLastItemKeys.join(","), numColumnsProp, nextScrollAxisGap, -@@ -7622,6 +7834,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7622,6 +7846,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded () => ({ getRenderedItem: (key) => getRenderedItem(ctx, key), onMomentumScrollEnd: (event) => { @@ -1306,7 +1348,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff checkFinishedScrollFallback(ctx); if (state.props.onMomentumScrollEnd) { state.props.onMomentumScrollEnd(event); -@@ -7630,6 +7843,8 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7630,6 +7855,8 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded onScroll: (event) => onScroll(ctx, event), onScrollBeginDrag: (event) => { var _a4, _b2; @@ -1315,7 +1357,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff prepareReachedEdgeForNextUserScroll(ctx); (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event); }, -@@ -7655,11 +7870,18 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7655,11 +7882,18 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded ListFooterComponent, ListFooterComponentStyle, ListHeaderComponent, diff --git a/patches/effect@4.0.0-beta.103.patch b/patches/effect@4.0.0-beta.103.patch index a46ccf9c97..2b2aa65281 100644 --- a/patches/effect@4.0.0-beta.103.patch +++ b/patches/effect@4.0.0-beta.103.patch @@ -326,3 +326,19 @@ index b536d0a..12ffac0 100644 /** * Represents optional client protocol hooks that run when a transport connects * and disconnects. +diff --git a/dist/unstable/http/HttpClientResponse.js b/dist/unstable/http/HttpClientResponse.js +--- a/dist/unstable/http/HttpClientResponse.js ++++ b/dist/unstable/http/HttpClientResponse.js +@@ -177,3 +177,3 @@ + } +- return this.cachedCookies = Cookies.fromSetCookie(this.source.headers.getSetCookie()); ++ return this.cachedCookies = Cookies.fromSetCookie(this.source.headers.getSetCookie?.() ?? []); + } +diff --git a/src/unstable/http/HttpClientResponse.ts b/src/unstable/http/HttpClientResponse.ts +--- a/src/unstable/http/HttpClientResponse.ts ++++ b/src/unstable/http/HttpClientResponse.ts +@@ -309,3 +309,3 @@ + } +- return this.cachedCookies = Cookies.fromSetCookie(this.source.headers.getSetCookie()) ++ return this.cachedCookies = Cookies.fromSetCookie(this.source.headers.getSetCookie?.() ?? []) + } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 601eb11471..9c5d2e3e94 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,13 +89,13 @@ patchedDependencies: '@effect/vitest@4.0.0-beta.103': a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b '@expo/metro-config@57.0.12': 96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2 '@ff-labs/fff-node@0.9.4': ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368 - '@legendapp/list@3.3.5': bbc9bd2c43392aacabc7a7a31d94fec0510cdf9fc53a55402b1f74d5a15160e5 + '@legendapp/list@3.3.5': ae73c2fbab0e16563b5bb2639ee85b4b72b18b478a425a4fb2e13b8de9a29fa4 '@pierre/diffs@1.3.0-beta.10': 0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4 '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d '@react-native-menu/menu@2.0.0': f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd '@react-navigation/native-stack@7.17.6': e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552 app-builder-lib@26.15.6: 0fc9a327982b3fdd5d9f4946e17bd8bf8d3d5c9b3753f77f0b18f7c0f0bdb4db - effect@4.0.0-beta.103: af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6 + effect@4.0.0-beta.103: 9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908 expo-audio@57.0.4: fa9a3e0442ed395d4071bb406e08c3a471c9a84700bdfa0b9ad7ff144c96041a expo-modules-jsi@57.0.7: 0794db2805abb43f770292fea9afbd80a85726082d33709237182a8d1568f133 expo-sharing@57.0.17: 8d2e3b10eb3f52036a9a086800180ec6cebf3b75bccc5b1775117a7244d4ac45 @@ -138,7 +138,7 @@ importers: version: 0.0.3 '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@napi-rs/keyring': specifier: ^1.3.0 version: 1.3.0 @@ -159,7 +159,7 @@ importers: version: link:../../packages/tailscale effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + version: 4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908) electron: specifier: 41.5.0 version: 41.5.0 @@ -178,7 +178,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -224,7 +224,7 @@ importers: version: 4.2.0(patch_hash=72e426f44fc1cde16fc2cbba3d1e96cdca7c6d957faa73d0fe6b43948608a6c1)(1fcd0592788ddcf326eeeb90d875ed47) '@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) + version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))(react@19.2.3)(scheduler@0.27.0) '@expo-google-fonts/dm-sans': specifier: ^0.4.2 version: 0.4.2 @@ -236,7 +236,7 @@ importers: version: 57.0.14(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.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))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.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) '@legendapp/list': specifier: 'catalog:' - version: 3.3.5(patch_hash=bbc9bd2c43392aacabc7a7a31d94fec0510cdf9fc53a55402b1f74d5a15160e5)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.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) + version: 3.3.5(patch_hash=ae73c2fbab0e16563b5bb2639ee85b4b72b18b478a425a4fb2e13b8de9a29fa4)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.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) '@noble/curves': specifier: 'catalog:' version: 1.9.1 @@ -299,7 +299,7 @@ importers: version: 8.0.3 effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + version: 4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908) expo: specifier: ~57.0.18 version: 57.0.18(f9c992a5d7c53d81398568d3950992dc) @@ -456,7 +456,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908)) '@pierre/trees': specifier: 1.0.0-beta.4 version: 1.0.0-beta.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -480,13 +480,13 @@ importers: version: 0.3.260(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) '@effect/platform-bun': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))(utf-8-validate@6.0.6) '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/sql-sqlite-bun': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908)) '@ff-labs/fff-node': specifier: 0.9.4 version: 0.9.4(patch_hash=ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368) @@ -507,7 +507,7 @@ importers: version: 3.0.0 effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + version: 4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908) msgpackr-extract: specifier: 3.0.4 version: 3.0.4 @@ -523,7 +523,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908)) '@t3tools/contracts': specifier: workspace:* version: link:../../packages/contracts @@ -580,7 +580,7 @@ importers: version: 3.2.2(react@19.2.6) '@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.6)(scheduler@0.27.0) + version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))(react@19.2.6)(scheduler@0.27.0) '@fontsource-variable/dm-sans': specifier: ^5.2.8 version: 5.3.0 @@ -592,7 +592,7 @@ importers: version: 0.9.0 '@legendapp/list': specifier: 'catalog:' - version: 3.3.5(patch_hash=bbc9bd2c43392aacabc7a7a31d94fec0510cdf9fc53a55402b1f74d5a15160e5)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 3.3.5(patch_hash=ae73c2fbab0e16563b5bb2639ee85b4b72b18b478a425a4fb2e13b8de9a29fa4)(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) @@ -628,7 +628,7 @@ importers: version: 4.0.2 effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + version: 4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908) heic-to: specifier: ^1.5.2 version: 1.5.2 @@ -677,10 +677,10 @@ importers: devDependencies: '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908)) '@rolldown/plugin-babel': specifier: ^0.2.0 version: 0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5) @@ -740,10 +740,10 @@ importers: version: 3.14.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@effect/sql-mysql2': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(@types/node@24.12.4)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + version: 4.0.0-beta.103(@types/node@24.12.4)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908)) '@effect/sql-pg': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908)) '@noble/curves': specifier: 'catalog:' version: 1.9.1 @@ -761,23 +761,23 @@ importers: version: link:../../packages/shared alchemy: specifier: 2.0.0-beta.70 - version: 2.0.0-beta.70(cf6cec2221c093bc1b6ebef8d879ae63) + version: 2.0.0-beta.70(709167320cb7d92a03cdf5649332d5d0) drizzle-orm: specifier: 1.0.0-rc.4 - version: 1.0.0-rc.4(80d2d0353bc10ad7366e7ca2e59269cd) + version: 1.0.0-rc.4(021fbda0249a78e7e1a89977540a36ad) effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + version: 4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908) devDependencies: '@cloudflare/workers-types': specifier: ^4.20260601.1 version: 4.20260604.1 '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -795,17 +795,17 @@ importers: dependencies: '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@oxlint/plugins': specifier: ^1.63.0 version: 1.68.0 effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + version: 4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908) devDependencies: '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908)) vite-plus: specifier: 'catalog:' version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -820,7 +820,7 @@ importers: version: link:../shared effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + version: 4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908) mdast-util-directive: specifier: ^3.1.0 version: 3.1.0 @@ -839,7 +839,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908)) micromark-util-types: specifier: ^2.0.2 version: 2.0.2 @@ -851,11 +851,11 @@ importers: dependencies: effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + version: 4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908) devDependencies: '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908)) vite-plus: specifier: 'catalog:' version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -864,17 +864,17 @@ importers: dependencies: effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + version: 4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908) devDependencies: '@effect/openapi-generator': specifier: 'catalog:' - version: 4.0.0-beta.103(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + version: 4.0.0-beta.103(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908)) '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -886,17 +886,17 @@ importers: dependencies: effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + version: 4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908) devDependencies: '@effect/openapi-generator': specifier: 'catalog:' - version: 4.0.0-beta.103(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + version: 4.0.0-beta.103(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908)) '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -917,7 +917,7 @@ importers: version: link:../contracts effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + version: 4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908) jose: specifier: 'catalog:' version: 6.2.2 @@ -927,10 +927,10 @@ importers: devDependencies: '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -948,14 +948,14 @@ importers: version: link:../shared effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + version: 4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908) devDependencies: '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -970,11 +970,11 @@ importers: version: link:../shared effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + version: 4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908) devDependencies: '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -986,7 +986,7 @@ importers: dependencies: '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@electron/asar': specifier: ^3.4.1 version: 3.4.1 @@ -1001,7 +1001,7 @@ importers: version: link:../packages/tailscale effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + version: 4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908) pngjs: specifier: 7.0.0 version: 7.0.0 @@ -1011,7 +1011,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908)) '@types/pngjs': specifier: 6.0.5 version: 6.0.5 @@ -12356,25 +12356,25 @@ snapshots: '@cloudflare/workers-types@5.20260726.1': {} - '@distilled.cloud/aws@1.0.0-rc.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': + '@distilled.cloud/aws@1.0.0-rc.2(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))': dependencies: '@aws-crypto/crc32': 5.2.0 '@aws-crypto/util': 5.2.0 '@aws-sdk/credential-providers': 3.1062.0 '@aws-sdk/types': 3.973.10 - '@distilled.cloud/core': 1.0.0-rc.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + '@distilled.cloud/core': 1.0.0-rc.2(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908)) '@smithy/shared-ini-file-loader': 4.5.6 '@smithy/types': 4.14.3 '@smithy/util-base64': 4.4.6 aws4fetch: 1.0.20 - effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + effect: 4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908) fast-xml-parser: 5.8.0 '@distilled.cloud/axiom@1.0.0-rc.2(bufferutil@4.1.0)(utf-8-validate@6.0.6)': dependencies: - '@distilled.cloud/core': 1.0.0-rc.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) - '@effect/platform-bun': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6) - effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + '@distilled.cloud/core': 1.0.0-rc.2(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908)) + '@effect/platform-bun': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))(utf-8-validate@6.0.6) + effect: 4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908) transitivePeerDependencies: - bufferutil - utf-8-validate @@ -12390,64 +12390,64 @@ snapshots: transitivePeerDependencies: - workerd - '@distilled.cloud/cloudflare-runtime@0.16.1(@distilled.cloud/cloudflare@1.0.0-rc.2(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@effect/platform-bun@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': + '@distilled.cloud/cloudflare-runtime@0.16.1(@distilled.cloud/cloudflare@1.0.0-rc.2(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@effect/platform-bun@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))': dependencies: '@alchemy.run/node-utils': 0.0.5 '@distilled.cloud/cloudflare': 1.0.0-rc.2(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@puppeteer/browsers': 2.13.2 - effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + effect: 4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908) sharp: 0.34.5 workerd: 1.20260704.1 optionalDependencies: - '@effect/platform-bun': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6) - '@effect/platform-node': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) + '@effect/platform-bun': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))(utf-8-validate@6.0.6) + '@effect/platform-node': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))(ioredis@5.11.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bare-abort-controller - bare-buffer - react-native-b4a - supports-color - '@distilled.cloud/cloudflare-vite-plugin@0.16.1(b68c76c5a1c871b25a2ef67936318459)': + '@distilled.cloud/cloudflare-vite-plugin@0.16.1(546a21c2c8fa55d3d6d2cfc408dc2b0c)': dependencies: '@distilled.cloud/cloudflare': 1.0.0-rc.2(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@distilled.cloud/cloudflare-rolldown-plugin': 0.16.1(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5)(workerd@1.20260704.1) - '@distilled.cloud/cloudflare-runtime': 0.16.1(@distilled.cloud/cloudflare@1.0.0-rc.2(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@effect/platform-bun@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) - effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + '@distilled.cloud/cloudflare-runtime': 0.16.1(@distilled.cloud/cloudflare@1.0.0-rc.2(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@effect/platform-bun@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908)) + effect: 4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908) vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' optionalDependencies: - '@effect/platform-bun': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6) - '@effect/platform-node': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) + '@effect/platform-bun': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))(utf-8-validate@6.0.6) + '@effect/platform-node': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))(ioredis@5.11.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - rolldown - workerd '@distilled.cloud/cloudflare@1.0.0-rc.2(bufferutil@4.1.0)(utf-8-validate@6.0.6)': dependencies: - '@distilled.cloud/core': 1.0.0-rc.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) - '@effect/platform-bun': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6) - effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + '@distilled.cloud/core': 1.0.0-rc.2(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908)) + '@effect/platform-bun': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))(utf-8-validate@6.0.6) + effect: 4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908) transitivePeerDependencies: - bufferutil - utf-8-validate - '@distilled.cloud/core@1.0.0-rc.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': + '@distilled.cloud/core@1.0.0-rc.2(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))': dependencies: - effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + effect: 4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908) '@distilled.cloud/neon@1.0.0-rc.2(bufferutil@4.1.0)(utf-8-validate@6.0.6)': dependencies: - '@distilled.cloud/core': 1.0.0-rc.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) - '@effect/platform-bun': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6) - effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + '@distilled.cloud/core': 1.0.0-rc.2(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908)) + '@effect/platform-bun': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))(utf-8-validate@6.0.6) + effect: 4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908) transitivePeerDependencies: - bufferutil - utf-8-validate '@distilled.cloud/planetscale@1.0.0-rc.2(bufferutil@4.1.0)(utf-8-validate@6.0.6)': dependencies: - '@distilled.cloud/core': 1.0.0-rc.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) - '@effect/platform-bun': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6) - effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + '@distilled.cloud/core': 1.0.0-rc.2(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908)) + '@effect/platform-bun': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))(utf-8-validate@6.0.6) + effect: 4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908) transitivePeerDependencies: - bufferutil - utf-8-validate @@ -12486,47 +12486,47 @@ snapshots: '@drizzle-team/brocli@0.12.0': {} - '@effect/atom-react@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(react@19.2.3)(scheduler@0.27.0)': + '@effect/atom-react@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))(react@19.2.3)(scheduler@0.27.0)': dependencies: - effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + effect: 4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908) react: 19.2.3 scheduler: 0.27.0 - '@effect/atom-react@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(react@19.2.6)(scheduler@0.27.0)': + '@effect/atom-react@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))(react@19.2.6)(scheduler@0.27.0)': dependencies: - effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + effect: 4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908) react: 19.2.6 scheduler: 0.27.0 - '@effect/openapi-generator@4.0.0-beta.103(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': + '@effect/openapi-generator@4.0.0-beta.103(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))': dependencies: - '@effect/platform-node': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) - effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + '@effect/platform-node': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))(ioredis@5.11.0)(utf-8-validate@6.0.6) + effect: 4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908) swagger2openapi: 7.0.8 transitivePeerDependencies: - encoding - '@effect/platform-bun@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6)': + '@effect/platform-bun@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))(utf-8-validate@6.0.6)': dependencies: - '@effect/platform-node-shared': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6) - effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + '@effect/platform-node-shared': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))(utf-8-validate@6.0.6) + effect: 4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908) transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/platform-node-shared@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6)': + '@effect/platform-node-shared@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))(utf-8-validate@6.0.6)': dependencies: '@types/ws': 8.18.1 - effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + effect: 4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908) ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6)': + '@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))(ioredis@5.11.0)(utf-8-validate@6.0.6)': dependencies: - '@effect/platform-node-shared': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6) - effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + '@effect/platform-node-shared': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))(utf-8-validate@6.0.6) + effect: 4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908) ioredis: 5.11.0 mime: 4.1.0 undici: 8.9.0 @@ -12534,21 +12534,21 @@ snapshots: - bufferutil - utf-8-validate - '@effect/sql-d1@4.0.0-beta.104(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': + '@effect/sql-d1@4.0.0-beta.104(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))': dependencies: '@cloudflare/workers-types': 5.20260726.1 - effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + effect: 4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908) - '@effect/sql-mysql2@4.0.0-beta.103(@types/node@24.12.4)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': + '@effect/sql-mysql2@4.0.0-beta.103(@types/node@24.12.4)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))': dependencies: - effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + effect: 4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908) mysql2: 3.23.2(@types/node@24.12.4) transitivePeerDependencies: - '@types/node' - '@effect/sql-pg@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': + '@effect/sql-pg@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))': dependencies: - effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + effect: 4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908) pg: 8.22.0 pg-connection-string: 2.14.0 pg-cursor: 2.21.0(pg@8.22.0) @@ -12557,13 +12557,13 @@ snapshots: transitivePeerDependencies: - pg-native - '@effect/sql-sqlite-bun@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': + '@effect/sql-sqlite-bun@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))': dependencies: - effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + effect: 4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908) - '@effect/sql-sqlite-do@4.0.0-beta.104(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': + '@effect/sql-sqlite-do@4.0.0-beta.104(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))': dependencies: - effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + effect: 4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908) '@effect/tsgo-darwin-arm64@0.13.2': optional: true @@ -12596,9 +12596,9 @@ snapshots: '@effect/tsgo-win32-arm64': 0.13.2 '@effect/tsgo-win32-x64': 0.13.2 - '@effect/vitest@4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': + '@effect/vitest@4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))': dependencies: - effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + effect: 4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908) '@egjs/hammerjs@2.0.17': dependencies: @@ -13737,7 +13737,7 @@ snapshots: dependencies: jsbi: 4.3.2 - '@legendapp/list@3.3.5(patch_hash=bbc9bd2c43392aacabc7a7a31d94fec0510cdf9fc53a55402b1f74d5a15160e5)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.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)': + '@legendapp/list@3.3.5(patch_hash=ae73c2fbab0e16563b5bb2639ee85b4b72b18b478a425a4fb2e13b8de9a29fa4)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.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 use-sync-external-store: 1.6.0(react@19.2.3) @@ -13745,7 +13745,7 @@ snapshots: react-dom: 19.2.3(react@19.2.3) react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.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.5(patch_hash=bbc9bd2c43392aacabc7a7a31d94fec0510cdf9fc53a55402b1f74d5a15160e5)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@legendapp/list@3.3.5(patch_hash=ae73c2fbab0e16563b5bb2639ee85b4b72b18b478a425a4fb2e13b8de9a29fa4)(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) @@ -16169,23 +16169,23 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@2.0.0-beta.70(cf6cec2221c093bc1b6ebef8d879ae63): + alchemy@2.0.0-beta.70(709167320cb7d92a03cdf5649332d5d0): dependencies: '@alchemy.run/node-utils': 0.0.5 '@aws-sdk/credential-providers': 3.1062.0 '@clack/prompts': 1.7.0 - '@distilled.cloud/aws': 1.0.0-rc.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + '@distilled.cloud/aws': 1.0.0-rc.2(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908)) '@distilled.cloud/axiom': 1.0.0-rc.2(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@distilled.cloud/cloudflare': 1.0.0-rc.2(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@distilled.cloud/cloudflare-rolldown-plugin': 0.16.1(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5)(workerd@1.20260704.1) - '@distilled.cloud/cloudflare-runtime': 0.16.1(@distilled.cloud/cloudflare@1.0.0-rc.2(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@effect/platform-bun@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) - '@distilled.cloud/cloudflare-vite-plugin': 0.16.1(b68c76c5a1c871b25a2ef67936318459) - '@distilled.cloud/core': 1.0.0-rc.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + '@distilled.cloud/cloudflare-runtime': 0.16.1(@distilled.cloud/cloudflare@1.0.0-rc.2(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@effect/platform-bun@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908)) + '@distilled.cloud/cloudflare-vite-plugin': 0.16.1(546a21c2c8fa55d3d6d2cfc408dc2b0c) + '@distilled.cloud/core': 1.0.0-rc.2(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908)) '@distilled.cloud/neon': 1.0.0-rc.2(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@distilled.cloud/planetscale': 1.0.0-rc.2(bufferutil@4.1.0)(utf-8-validate@6.0.6) - '@effect/sql-d1': 4.0.0-beta.104(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) - '@effect/sql-sqlite-do': 4.0.0-beta.104(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) - '@effect/vitest': 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + '@effect/sql-d1': 4.0.0-beta.104(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908)) + '@effect/sql-sqlite-do': 4.0.0-beta.104(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908)) + '@effect/vitest': 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908)) '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@octokit/rest': 22.0.1 '@octokit/webhooks': 14.2.0 @@ -16196,7 +16196,7 @@ snapshots: '@types/aws-lambda': 8.10.161 aws4fetch: 1.0.20 capnweb: 0.6.1 - effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + effect: 4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908) fast-glob: 3.3.3 fast-xml-parser: 5.8.0 ink: 6.8.0(@types/react@19.2.16)(bufferutil@4.1.0)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react@19.2.6)(utf-8-validate@6.0.6) @@ -16209,12 +16209,12 @@ snapshots: undici: 7.27.1 yaml: 2.9.0 optionalDependencies: - '@effect/platform-bun': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6) - '@effect/platform-node': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) - '@effect/sql-mysql2': 4.0.0-beta.103(@types/node@24.12.4)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) - '@effect/sql-pg': 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + '@effect/platform-bun': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))(utf-8-validate@6.0.6) + '@effect/platform-node': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908))(ioredis@5.11.0)(utf-8-validate@6.0.6) + '@effect/sql-mysql2': 4.0.0-beta.103(@types/node@24.12.4)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908)) + '@effect/sql-pg': 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908)) drizzle-kit: 1.0.0-rc.4 - drizzle-orm: 1.0.0-rc.4(80d2d0353bc10ad7366e7ca2e59269cd) + drizzle-orm: 1.0.0-rc.4(021fbda0249a78e7e1a89977540a36ad) mongodb: 6.21.0(@aws-sdk/credential-providers@3.1062.0)(socks@2.8.9) mysql2: 3.23.2(@types/node@24.12.4) pg: 8.22.0 @@ -17248,18 +17248,18 @@ snapshots: get-tsconfig: 4.14.0 jiti: 2.7.0 - drizzle-orm@1.0.0-rc.4(80d2d0353bc10ad7366e7ca2e59269cd): + drizzle-orm@1.0.0-rc.4(021fbda0249a78e7e1a89977540a36ad): optionalDependencies: '@cloudflare/workers-types': 4.20260604.1 - '@effect/sql-d1': 4.0.0-beta.104(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) - '@effect/sql-mysql2': 4.0.0-beta.103(@types/node@24.12.4)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) - '@effect/sql-pg': 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) - '@effect/sql-sqlite-bun': 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) - '@effect/sql-sqlite-do': 4.0.0-beta.104(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + '@effect/sql-d1': 4.0.0-beta.104(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908)) + '@effect/sql-mysql2': 4.0.0-beta.103(@types/node@24.12.4)(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908)) + '@effect/sql-pg': 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908)) + '@effect/sql-sqlite-bun': 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908)) + '@effect/sql-sqlite-do': 4.0.0-beta.104(effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908)) '@electric-sql/pglite': 0.3.15 '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) bun-types: 1.3.14 - effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + effect: 4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908) expo-sqlite: 57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.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) mysql2: 3.23.2(@types/node@24.12.4) pg: 8.22.0 @@ -17280,7 +17280,7 @@ snapshots: ee-first@1.1.1: {} - effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6): + effect@4.0.0-beta.103(patch_hash=9609a3bf608eb8604111cd111eaca70facbb4ff80ae8ef963342726c1f7b3908): dependencies: '@standard-schema/spec': 1.1.0 fast-check: 4.9.0 diff --git a/scripts/legend-list-initial-reveal.test.ts b/scripts/legend-list-initial-reveal.test.ts new file mode 100644 index 0000000000..15ebc3f597 --- /dev/null +++ b/scripts/legend-list-initial-reveal.test.ts @@ -0,0 +1,214 @@ +// @effect-diagnostics nodeBuiltinImport:off - reads the installed native JS bundle for synchronous VM tests. +import * as NodeFS from "node:fs"; +import * as NodeVM from "node:vm"; +import { describe, expect, it, vi } from "vite-plus/test"; + +// Exercise the shipped patch without loading React Native in the Node runner. +// Native scroll delivery and animation frames advance independently here. +function createList(bundle: string) { + const source = NodeFS.readFileSync( + new URL(`../apps/mobile/node_modules/@legendapp/list/${bundle}`, import.meta.url), + "utf8", + ); + const renderState = source.slice( + source.indexOf("function setInitialRenderState("), + source.indexOf("// src/core/finishInitialScroll.ts"), + ); + const watchdog = source.slice( + source.indexOf("var INSET_END_SETTLE_WATCHDOG_FRAMES"), + source.indexOf("function dispatchInitialScroll("), + ); + const frames: Array<() => void> = []; + const values = new Map(); + const scrollTo = vi.fn(); + const onLoad = vi.fn(); + const state = { + props: { data: ["message"], onLoad, drawDistance: 500 }, + initialScroll: { index: 0, viewPosition: 1 }, + loadStartTime: 0, + didContainersLayout: true, + didFinishInitialScroll: true, + didLoad: false, + didUserDrag: false, + scrollLength: 800, + scroll: 400, + lastNativeScroll: 200 as number | undefined, + scrollingTo: undefined as { offset: number } | undefined, + maintainingScrollAtEnd: false, + refScroller: { current: { scrollTo } }, + }; + const ctx = { state }; + let contentSize = 1200; + let inset = 100; + const api = NodeVM.runInNewContext( + `${renderState}\n${watchdog}\n({ start: startInsetEndSettleWatchdog, complete: setInitialRenderState })`, + { + requestAnimationFrame: (callback: () => void) => frames.push(callback), + getContentSize: () => contentSize, + getContentInsetStartAdjustment: () => inset, + peek$: (_ctx: unknown, key: string) => values.get(key), + set$: (_ctx: unknown, key: string, value: unknown) => values.set(key, value), + setAdaptiveRender: vi.fn(), + scheduleFullDrawDistancePrewarm: vi.fn(), + INITIAL_DRAW_DISTANCE: 250, + }, + ) as { + start: (context: typeof ctx) => void; + complete: (context: typeof ctx, flags: Record) => void; + }; + return { + state, + scrollTo, + onLoad, + start: () => api.start(ctx), + complete: () => api.complete(ctx, {}), + ready: () => values.get("readyToRender") === true, + resize: (size: number) => { + contentSize = size; + }, + setInset: (value: number) => { + inset = value; + }, + advance(count: number) { + for (let index = 0; index < count; index++) { + const batch = frames.splice(0); + for (const frame of batch) frame(); + } + }, + }; +} + +for (const bundle of ["react-native.js", "react-native.mjs"]) { + describe(`initial inset end reveal (${bundle})`, () => { + it("waits for the native offset instead of the optimistic scroll target", () => { + const list = createList(bundle); + list.start(); + list.advance(8); + expect(list.ready()).toBe(false); + expect(list.scrollTo).toHaveBeenCalledWith({ animated: false, x: 0, y: 400 }); + list.state.lastNativeScroll = 400; + list.advance(7); + expect(list.ready()).toBe(true); + expect(list.onLoad).toHaveBeenCalledTimes(1); + }); + + it("gates the seeded contentOffset completion path too", () => { + const list = createList(bundle); + list.complete(); + expect(list.ready()).toBe(false); + list.state.lastNativeScroll = 400; + list.advance(7); + expect(list.ready()).toBe(true); + }); + + it("preserves an initial index before the last item", () => { + const list = createList(bundle); + list.state.props.data = ["requested message", "later message"]; + list.state.scroll = 200; + list.complete(); + list.advance(8); + expect(list.ready()).toBe(true); + expect(list.scrollTo).not.toHaveBeenCalled(); + }); + + it("does not count an in-flight scroll as stability", () => { + const list = createList(bundle); + list.state.lastNativeScroll = 400; + list.state.scrollingTo = { offset: 400 }; + list.start(); + list.advance(8); + expect(list.ready()).toBe(false); + list.state.scrollingTo = undefined; + list.state.maintainingScrollAtEnd = true; + list.advance(8); + expect(list.ready()).toBe(false); + list.state.maintainingScrollAtEnd = false; + list.advance(7); + expect(list.ready()).toBe(true); + }); + + it("waits for a stable end target even when native scrolling follows each measurement", () => { + const list = createList(bundle); + list.start(); + for (let index = 0; index < 10; index++) { + list.resize(1200 + index * 10); + list.state.lastNativeScroll = 400 + index * 10; + list.advance(1); + } + expect(list.ready()).toBe(false); + list.advance(7); + expect(list.ready()).toBe(true); + }); + + it("accounts for header insets when content is shorter than the full viewport", () => { + const list = createList(bundle); + list.resize(780); + list.state.scroll = -20; + list.state.lastNativeScroll = -100; + list.start(); + list.advance(8); + expect(list.ready()).toBe(false); + expect(list.scrollTo).toHaveBeenCalledWith({ animated: false, x: 0, y: -20 }); + list.state.lastNativeScroll = -20; + list.advance(7); + expect(list.ready()).toBe(true); + }); + + it("keeps the reveal bounded when native events never arrive", () => { + const list = createList(bundle); + list.state.lastNativeScroll = undefined; + list.start(); + list.advance(8); + expect(list.ready()).toBe(false); + expect(list.scrollTo).not.toHaveBeenCalled(); + list.advance(32); + expect(list.ready()).toBe(true); + }); + + it("releases control on drag and never starts another initial hold", () => { + const list = createList(bundle); + list.start(); + list.state.didUserDrag = true; + list.advance(1); + expect(list.ready()).toBe(true); + expect(list.scrollTo).not.toHaveBeenCalled(); + list.complete(); + list.start(); + list.advance(20); + expect(list.state.didUserDrag).toBe(true); + expect(list.scrollTo).not.toHaveBeenCalled(); + expect(list.onLoad).toHaveBeenCalledTimes(1); + }); + + it("preserves a drag that started before initial layout completed", () => { + const list = createList(bundle); + list.state.didUserDrag = true; + list.complete(); + list.advance(1); + expect(list.ready()).toBe(true); + expect(list.state.didUserDrag).toBe(true); + expect(list.scrollTo).not.toHaveBeenCalled(); + }); + + it("reveals short content at UIKit's resting offset without awaiting a scroll event", () => { + const list = createList(bundle); + list.resize(700); + list.state.lastNativeScroll = undefined; + list.complete(); + expect(list.ready()).toBe(true); + list.advance(10); + expect(list.scrollTo).not.toHaveBeenCalled(); + }); + + it("does not gate empty lists or lists without an automatic header inset", () => { + const list = createList(bundle); + list.setInset(0); + list.complete(); + expect(list.ready()).toBe(true); + const empty = createList(bundle); + empty.state.props.data = []; + empty.complete(); + expect(empty.ready()).toBe(true); + }); + }); +}