From 1bcf91ff0f640f78e0c33535eadfc3ee29f5f878 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 6 Sep 2026 12:31:03 -0700 Subject: [PATCH 01/29] feat(mobile): show new-task drafts alongside pending tasks in the thread list (#10260) Co-authored-by: Claude Fable 5 (cherry picked from commit c0bf35466c8aa1862572dfa84b51b6fe30fc82d6) --- apps/mobile/src/features/home/HomeScreen.tsx | 23 ++- .../mobile/src/features/home/homeListItems.ts | 2 +- .../src/features/home/homeThreadList.ts | 28 ++-- .../home/usePendingTaskListActions.ts | 29 +++- .../threads/ThreadNavigationSidebar.tsx | 24 ++-- .../features/threads/thread-list-items.tsx | 58 +++++--- .../features/threads/thread-list-v2-items.tsx | 54 ++++--- .../src/features/threads/threadListV2.test.ts | 30 ++-- .../src/features/threads/threadListV2.ts | 2 +- .../src/state/pending-new-tasks-model.test.ts | 126 ++++++++++++++++ .../src/state/pending-new-tasks-model.ts | 134 ++++++++++++++++++ .../mobile/src/state/use-pending-new-tasks.ts | 48 +++---- 12 files changed, 436 insertions(+), 122 deletions(-) create mode 100644 apps/mobile/src/state/pending-new-tasks-model.test.ts create mode 100644 apps/mobile/src/state/pending-new-tasks-model.ts diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 2b1b4167b..2c5942ec8 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -370,7 +370,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], @@ -743,10 +743,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,8 +777,8 @@ 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} @@ -994,12 +994,9 @@ export function HomeScreen(props: HomeScreenProps) { variant="compact" pendingTask={item.pendingTask} environmentLabel={ - props.savedConnectionsById[item.pendingTask.message.environmentId] - ?.environmentLabel ?? null + props.savedConnectionsById[item.pendingTask.environmentId]?.environmentLabel ?? null } - environmentMachine={machineByEnvironmentId.get( - item.pendingTask.message.environmentId, - )} + environmentMachine={machineByEnvironmentId.get(item.pendingTask.environmentId)} isLast={item.isLast} onSelectPendingTask={props.onSelectPendingTask} onDeletePendingTask={props.onDeletePendingTask} diff --git a/apps/mobile/src/features/home/homeListItems.ts b/apps/mobile/src/features/home/homeListItems.ts index 6709a81e9..910ddb589 100644 --- a/apps/mobile/src/features/home/homeListItems.ts +++ b/apps/mobile/src/features/home/homeListItems.ts @@ -173,7 +173,7 @@ export function buildHomeListLayout(input: { for (const [pendingIndex, pendingTask] of group.pendingTasks.entries()) { items.push({ type: "pending-task", - key: `pending-task:${pendingTask.message.messageId}`, + key: pendingTask.key, pendingTask, isLast: pendingIndex === group.pendingTasks.length - 1 && diff --git a/apps/mobile/src/features/home/homeThreadList.ts b/apps/mobile/src/features/home/homeThreadList.ts index 2a9e0ec2c..f0c9e1bc6 100644 --- a/apps/mobile/src/features/home/homeThreadList.ts +++ b/apps/mobile/src/features/home/homeThreadList.ts @@ -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); } @@ -235,14 +233,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 +249,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: [], diff --git a/apps/mobile/src/features/home/usePendingTaskListActions.ts b/apps/mobile/src/features/home/usePendingTaskListActions.ts index 403c3af39..e87df9dc2 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"; @@ -14,12 +15,16 @@ export function usePendingTaskListActions(): { const openPendingTask = useCallback( (pendingTask: PendingNewTask) => { + // A draft is the project's own new-task composer content, so opening + // the project's new-task screen lands on it without extra params. 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) } + : {}), }, }); }, @@ -27,6 +32,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/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 30c20e62b..681afdb5b 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -300,7 +300,7 @@ function ThreadNavigationSidebarPane( ? pendingTasks : pendingTasks.filter((pendingTask) => selectedProjectRefs.has( - scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), + scopedProjectKey(pendingTask.environmentId, pendingTask.projectId), ), ), [threadListV2Enabled, pendingTasks, selectedProjectRefs], @@ -576,10 +576,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,8 +861,8 @@ 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} @@ -1016,12 +1013,9 @@ function ThreadNavigationSidebarPane( variant="sidebar" pendingTask={item.pendingTask} environmentLabel={ - savedConnectionsById[item.pendingTask.message.environmentId]?.environmentLabel ?? - null + savedConnectionsById[item.pendingTask.environmentId]?.environmentLabel ?? null } - environmentMachine={machineByEnvironmentId.get( - item.pendingTask.message.environmentId, - )} + environmentMachine={machineByEnvironmentId.get(item.pendingTask.environmentId)} isLast={item.isLast} onSelectPendingTask={openPendingTask} onDeletePendingTask={confirmDeletePendingTask} diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index 606a5d472..553251b14 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -270,10 +270,17 @@ 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; @@ -289,10 +296,15 @@ 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); + // 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. + const subtitleParts = [ + isDraft || pendingTask.message.deliveryHold ? null : "Sends on reconnect", + props.environmentLabel, + pendingTask.branch, + ].filter((part): part is string => Boolean(part)); const handleMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { @@ -302,7 +314,11 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { [onDeletePendingTask, onSelectPendingTask, pendingTask], ); - const statusPill = ( + const statusPill = isDraft ? ( + + Draft + + ) : ( {pendingTask.message.deliveryHold ? "Held" : "Pending"} @@ -314,7 +330,7 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { subtitleParts.length > 0 ? ( ) : null; + const accessibilityHint = isDraft + ? "Opens the draft in the new task composer" + : pendingTask.message.deliveryHold + ? "Held until retargeted. Opens the task for editing" + : "Sends when the environment reconnects. Opens the task for editing"; + const rowContent = compact ? ( {statusPill} - {timestamp} + {timestamp !== null ? ( + {timestamp} + ) : null} ) : ( {statusPill} - - {timestamp} - + {timestamp !== null ? ( + + {timestamp} + + ) : null} {subtitleRow} @@ -403,7 +429,7 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { return ( diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 17d79d26f..33381e705 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -190,12 +190,16 @@ 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, in the same idiom as an active v2 row: it is work the - * user wrote, so it reads like the threads it will become. "Queued" takes - * the status slot — the state is the one thing that differs — and stays - * uncolored because nothing is asked of the user; the environment is simply - * not reachable yet. + * Unsent work, in the same idiom as an active v2 row: it is work the user + * wrote, so it reads like the thread it will become. The status slot says + * what happens next, not where the item sits: "Sends on reconnect" stays + * uncolored because nothing is asked of the user; "Draft" takes the amber the + * web sidebar uses for drafts, because this one waits on the user. */ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props: { readonly pendingTask: PendingNewTask; @@ -205,7 +209,7 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props /** Drawn beside the label; ignored while the label is null. */ readonly environmentMachine?: EnvironmentMachineKind; readonly pane?: "screen" | "sidebar"; - /** Draws the "Pending" divider above the first queued row. */ + /** Draws the "Unsent" divider above the first draft or queued row. */ readonly showPendingDivider: boolean; /** Keeps row hairlines inside a section; section headers draw their own rule. */ readonly showTrailingDivider?: boolean; @@ -214,9 +218,9 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props }) { const { pendingTask, onSelectPendingTask, onDeletePendingTask } = props; const sidebarPane = props.pane === "sidebar"; - const projectTitle = - props.projectTitle ?? props.project?.title ?? pendingTask.creation.projectTitle ?? ""; - const branch = pendingTask.creation.branch; + const isDraft = pendingTask.kind === "draft"; + const projectTitle = props.projectTitle ?? props.project?.title ?? pendingTask.projectTitle ?? ""; + const branch = pendingTask.branch; const handleMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { @@ -231,7 +235,7 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props {props.project ? ( {projectTitle} - - {pendingTask.message.deliveryHold ? "Held" : "Queued"} - + {isDraft ? ( + + + Draft + + ) : ( + + {pendingTask.message.deliveryHold ? "Held" : "Sends on reconnect"} + + )} {/* 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 +295,21 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props return ( <> {props.showPendingDivider ? ( - + ) : null} { }); 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 +997,9 @@ function makePendingTask(id: string): PendingNewTask { text: id, attachments: [], createdAt: NOW, - creation: { - projectId: ProjectId.make("project-1"), - workspaceMode: "worktree", - branch: null, - worktreePath: null, - }, + creation, }, - creation: { - projectId: ProjectId.make("project-1"), - workspaceMode: "worktree", - branch: null, - worktreePath: null, - }, - title: id, + creation, }; } diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 0914b558d..6d1e77acd 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -334,7 +334,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, }), 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 000000000..6cedca381 --- /dev/null +++ b/apps/mobile/src/state/pending-new-tasks-model.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "@effect/vitest"; +import { CommandId, EnvironmentId, MessageId, ProjectId, ThreadId } from "@t3tools/contracts"; + +import type { QueuedThreadMessage } from "./thread-outbox-model"; +import type { ComposerDraft } from "./use-composer-drafts"; +import { buildPendingNewTasks, parseNewTaskDraftKey } from "./pending-new-tasks-model"; + +const environmentId = EnvironmentId.make("env-1"); +const projectId = ProjectId.make("project-1"); +const NOW = "2026-09-05T12:00:00.000Z"; + +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, overrides: Partial = {}): ComposerDraft { + return { text, attachments: [], ...overrides }; +} + +describe("parseNewTaskDraftKey", () => { + it("splits the environment and project ids", () => { + expect(parseNewTaskDraftKey(`new-task:${environmentId}:${projectId}`)).toEqual({ + environmentId, + projectId, + }); + }); + + it("ignores thread drafts and pending-task editor drafts", () => { + expect(parseNewTaskDraftKey(`${environmentId}:thread-1`)).toBeNull(); + expect(parseNewTaskDraftKey("pending-task:message-1")).toBeNull(); + expect(parseNewTaskDraftKey("new-task:")).toBeNull(); + expect(parseNewTaskDraftKey("new-task:env-only")).toBeNull(); + }); +}); + +describe("buildPendingNewTasks", () => { + it("surfaces new-task drafts with content alongside queued creations", () => { + const tasks = buildPendingNewTasks({ + queuedMessages: [queuedCreation("a", "2026-09-05T10:00:00.000Z")], + drafts: { + [`new-task:${environmentId}:${projectId}`]: draft("fix the offline outbox", { + workspaceSelection: { mode: "worktree", branch: "main", worktreePath: null }, + }), + }, + now: NOW, + }); + + expect(tasks.map((task) => [task.kind, task.title, task.branch])).toEqual([ + ["draft", "fix the offline outbox", "main"], + ["pending", "queued a", "main"], + ]); + expect(tasks[0]).toMatchObject({ + key: `draft-task:new-task:${environmentId}:${projectId}`, + environmentId, + projectId, + draftKey: `new-task:${environmentId}:${projectId}`, + }); + }); + + it("hides settings-only drafts and drafts for other surfaces", () => { + const tasks = buildPendingNewTasks({ + queuedMessages: [], + drafts: { + [`new-task:${environmentId}:${projectId}`]: draft("", { + modelSelection: { instanceId: "codex" as never, model: "gpt" }, + }), + [`new-task:${environmentId}:${projectId}-2`]: draft(" "), + [`${environmentId}:thread-1`]: draft("thread composer text"), + "pending-task:message-1": draft("editor copy of a queued task"), + }, + now: NOW, + }); + + 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:${environmentId}:${projectId}`]: draft("", { attachments: [attachment] }), + }, + now: NOW, + }); + + 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: {}, + now: NOW, + }); + + expect(tasks.map((task) => task.title)).toEqual(["queued new", "queued old"]); + }); +}); 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 000000000..3dafea540 --- /dev/null +++ b/apps/mobile/src/state/pending-new-tasks-model.ts @@ -0,0 +1,134 @@ +import { EnvironmentId, ProjectId } from "@t3tools/contracts"; + +import { deriveThreadTitleFromPrompt } from "../lib/projectThreadStartTurn"; +import type { QueuedThreadCreation, QueuedThreadMessage } from "./thread-outbox-model"; +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 the project's 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; + /** Drafts have no creation timestamp; they sort as current work. */ + readonly createdAt: string; + readonly draftKey: string; + readonly draft: ComposerDraft; +} + +const NEW_TASK_DRAFT_PREFIX = "new-task:"; + +/** Parses a `new-task::` composer draft key. */ +export function parseNewTaskDraftKey( + draftKey: string, +): { readonly environmentId: EnvironmentId; readonly projectId: ProjectId } | null { + if (!draftKey.startsWith(NEW_TASK_DRAFT_PREFIX)) { + 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: EnvironmentId.make(scope.slice(0, separator)), + projectId: ProjectId.make(scope.slice(separator + 1)), + }; +} + +/** + * 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; +} + +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>; + /** ISO timestamp drafts sort by; they carry no creation time of their own. */ + readonly now: string; +}): 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)) { + const ref = parseNewTaskDraftKey(draftKey); + if (ref === null || !composerDraftHasUserContent(draft)) { + continue; + } + tasks.push({ + kind: "draft", + key: `draft-task:${draftKey}`, + environmentId: ref.environmentId, + projectId: ref.projectId, + projectTitle: undefined, + projectCwd: undefined, + branch: draft.workspaceSelection?.branch ?? null, + title: draftTitle(draft), + createdAt: input.now, + draftKey, + draft, + }); + } + // Drafts are what the user is writing now, so they lead; queued tasks + // follow 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/use-pending-new-tasks.ts b/apps/mobile/src/state/use-pending-new-tasks.ts index ccfe1527b..d4d4d5c7c 100644 --- a/apps/mobile/src/state/use-pending-new-tasks.ts +++ b/apps/mobile/src/state/use-pending-new-tasks.ts @@ -1,35 +1,29 @@ +import { useAtomValue } from "@effect/atom-react"; import { useMemo } from "react"; -import { deriveThreadTitleFromPrompt } from "../lib/projectThreadStartTurn"; -import { - flattenQueuedThreadMessages, - type QueuedThreadCreation, - type QueuedThreadMessage, -} from "./thread-outbox-model"; +import { buildPendingNewTasks, 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; -} +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(composerDraftsAtom); + return useMemo( + () => + buildPendingNewTasks({ + queuedMessages: flattenQueuedThreadMessages(queuedMessagesByThreadKey), + drafts, + // Stamped when the inputs change, not per render, so a draft keeps one + // sort position while the user is not typing in it. + now: new Date().toISOString(), + }), + [queuedMessagesByThreadKey, drafts], + ); } From a7905b6a5f283bae5961b6f72874f1f3c5bd4d05 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 6 Sep 2026 12:31:03 -0700 Subject: [PATCH 02/29] feat(mobile): allow several new-task drafts per project (#10327) Co-authored-by: Claude Fable 5 (cherry picked from commit 8e129a0dfb35ac2c03a7320245b5bfb976f5d3bf) --- .../home/usePendingTaskListActions.ts | 4 +- .../threads/NewTaskDraftRouteScreen.tsx | 2 + .../features/threads/NewTaskDraftScreen.tsx | 50 ++- .../threads/new-task-flow-provider.tsx | 81 ++++- .../lib/composerAttachmentUploadQueue.test.ts | 18 + .../src/lib/composerAttachmentUploadQueue.ts | 20 +- .../src/state/composer-attachment-uploads.ts | 4 +- apps/mobile/src/state/new-task-draft-key.ts | 30 ++ .../src/state/pending-new-tasks-model.test.ts | 66 ++-- .../src/state/pending-new-tasks-model.ts | 45 +-- .../src/state/use-composer-drafts.test.ts | 325 +++++++++++------- apps/mobile/src/state/use-composer-drafts.ts | 324 ++++++++++------- .../mobile/src/state/use-pending-new-tasks.ts | 3 - .../src/state/use-thread-composer-state.ts | 18 +- .../src/state/use-thread-outbox-drain.test.ts | 13 +- .../src/state/use-thread-outbox-drain.ts | 25 +- 16 files changed, 678 insertions(+), 350 deletions(-) create mode 100644 apps/mobile/src/state/new-task-draft-key.ts diff --git a/apps/mobile/src/features/home/usePendingTaskListActions.ts b/apps/mobile/src/features/home/usePendingTaskListActions.ts index e87df9dc2..dcf127cca 100644 --- a/apps/mobile/src/features/home/usePendingTaskListActions.ts +++ b/apps/mobile/src/features/home/usePendingTaskListActions.ts @@ -15,8 +15,6 @@ export function usePendingTaskListActions(): { const openPendingTask = useCallback( (pendingTask: PendingNewTask) => { - // A draft is the project's own new-task composer content, so opening - // the project's new-task screen lands on it without extra params. navigation.navigate("NewTaskSheet", { screen: "NewTaskDraft", params: { @@ -24,7 +22,7 @@ export function usePendingTaskListActions(): { projectId: String(pendingTask.projectId), ...(pendingTask.kind === "pending" ? { pendingTaskId: String(pendingTask.message.messageId) } - : {}), + : { draftId: pendingTask.draftKey }), }, }); }, diff --git a/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx index 8e6819378..dc1ee942d 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx @@ -9,6 +9,7 @@ type NewTaskDraftRouteParams = { readonly projectId?: string | string[]; readonly title?: string | string[]; readonly pendingTaskId?: string | string[]; + readonly draftId?: string | string[]; readonly incomingShareId?: string | string[]; }; @@ -43,6 +44,7 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps ); diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index a11851876..61d67d87a 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -85,6 +85,7 @@ import { restoreComposerDraftSnapshot, scheduleUnusedComposerAttachmentCleanup, type ComposerDraft, + waitForComposerDraftsLoaded, } from "../../state/use-composer-drafts"; import { useEnvironmentServerConfig, useProjects } from "../../state/entities"; import { resolveSelectableModelSelection } from "../../lib/modelOptions"; @@ -148,6 +149,8 @@ export function NewTaskDraftScreen(props: { }; /** 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; }) { @@ -421,7 +424,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 +498,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) { @@ -519,6 +560,7 @@ export function NewTaskDraftScreen(props: { props.initialProjectRef, props.incomingShareId, props.pendingTaskId, + props.draftId, navigation, selectedProject, selectedProjectKey, diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 2c9a94aad..c7fffcc43 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -48,11 +48,14 @@ import { useEnvironmentQuery } from "../../state/query"; import { appendComposerDraftAttachments, clearComposerDraft, - copyComposerDraftContentIfEmpty, + composerDraftsAtom, + createNewTaskDraft, getComposerDraftSnapshot, isComposerDraftEmpty, + isNewTaskDraftKey, removeComposerDraftAttachment, replaceComposerDraftAttachments, + retargetNewTaskDraft, scheduleUnusedComposerAttachmentCleanup, setComposerDraftText, setStickyComposerModelSelection, @@ -174,6 +177,12 @@ type NewTaskFlowContextValue = { readonly filteredBranches: ReadonlyArray; 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, @@ -236,6 +245,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 +263,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const reset = useCallback(() => { setSelectedEnvironmentId(null); setSelectedProjectKey(null); + setActiveDraftKey(null); setSubmitting(false); setBranchQuery(""); setExpandedProvider(null); @@ -371,12 +384,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 +701,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 +725,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( @@ -1143,6 +1196,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { filteredBranches, reset, setProject, + openDraft, selectEnvironment, setSelectedModelKey, setWorkspaceMode, @@ -1208,6 +1262,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedProjectKey, selectedWorktreePath, setProject, + openDraft, selectBranch, selectEnvironment, setInteractionMode, diff --git a/apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts b/apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts index 6b040b698..2ebacc740 100644 --- a/apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts +++ b/apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts @@ -230,6 +230,24 @@ describe("draft upload scope and offline submission", () => { ); }); + 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("allows offline queuing while a connected composer waits for upload or retry", () => { const key = composerAttachmentUploadKey(environmentId, "file"); const input = { diff --git a/apps/mobile/src/lib/composerAttachmentUploadQueue.ts b/apps/mobile/src/lib/composerAttachmentUploadQueue.ts index 071afefa4..538b343ab 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 = { diff --git a/apps/mobile/src/state/composer-attachment-uploads.ts b/apps/mobile/src/state/composer-attachment-uploads.ts index ad38551d5..d454133f1 100644 --- a/apps/mobile/src/state/composer-attachment-uploads.ts +++ b/apps/mobile/src/state/composer-attachment-uploads.ts @@ -79,7 +79,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 +113,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/new-task-draft-key.ts b/apps/mobile/src/state/new-task-draft-key.ts new file mode 100644 index 000000000..942380dba --- /dev/null +++ b/apps/mobile/src/state/new-task-draft-key.ts @@ -0,0 +1,30 @@ +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); +} + +/** + * 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 index 6cedca381..0bb61ed2c 100644 --- a/apps/mobile/src/state/pending-new-tasks-model.test.ts +++ b/apps/mobile/src/state/pending-new-tasks-model.test.ts @@ -3,11 +3,10 @@ import { CommandId, EnvironmentId, MessageId, ProjectId, ThreadId } from "@t3too import type { QueuedThreadMessage } from "./thread-outbox-model"; import type { ComposerDraft } from "./use-composer-drafts"; -import { buildPendingNewTasks, parseNewTaskDraftKey } from "./pending-new-tasks-model"; +import { buildPendingNewTasks } from "./pending-new-tasks-model"; const environmentId = EnvironmentId.make("env-1"); const projectId = ProjectId.make("project-1"); -const NOW = "2026-09-05T12:00:00.000Z"; function queuedCreation(id: string, createdAt: string): QueuedThreadMessage { return { @@ -27,62 +26,57 @@ function queuedCreation(id: string, createdAt: string): QueuedThreadMessage { }; } -function draft(text: string, overrides: Partial = {}): ComposerDraft { - return { text, attachments: [], ...overrides }; +function draft( + text: string, + createdAt: string, + overrides: Partial = {}, +): ComposerDraft { + return { + text, + attachments: [], + project: { environmentId, projectId, createdAt }, + ...overrides, + }; } -describe("parseNewTaskDraftKey", () => { - it("splits the environment and project ids", () => { - expect(parseNewTaskDraftKey(`new-task:${environmentId}:${projectId}`)).toEqual({ - environmentId, - projectId, - }); - }); - - it("ignores thread drafts and pending-task editor drafts", () => { - expect(parseNewTaskDraftKey(`${environmentId}:thread-1`)).toBeNull(); - expect(parseNewTaskDraftKey("pending-task:message-1")).toBeNull(); - expect(parseNewTaskDraftKey("new-task:")).toBeNull(); - expect(parseNewTaskDraftKey("new-task:env-only")).toBeNull(); - }); -}); - describe("buildPendingNewTasks", () => { - it("surfaces new-task drafts with content alongside queued creations", () => { + 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:${environmentId}:${projectId}`]: draft("fix the offline outbox", { + "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"), }, - now: NOW, }); expect(tasks.map((task) => [task.kind, task.title, task.branch])).toEqual([ - ["draft", "fix the offline outbox", "main"], + ["draft", "second idea", null], + ["draft", "first idea", "main"], ["pending", "queued a", "main"], ]); - expect(tasks[0]).toMatchObject({ - key: `draft-task:new-task:${environmentId}:${projectId}`, + expect(tasks[1]).toMatchObject({ + key: "draft-task:new-task:draft-old", environmentId, projectId, - draftKey: `new-task:${environmentId}:${projectId}`, + draftKey: "new-task:draft-old", + createdAt: "2026-09-05T09:00:00.000Z", }); }); - it("hides settings-only drafts and drafts for other surfaces", () => { + it("hides settings-only drafts, unstamped drafts, and drafts for other surfaces", () => { const tasks = buildPendingNewTasks({ queuedMessages: [], drafts: { - [`new-task:${environmentId}:${projectId}`]: draft("", { + "new-task:settings-only": draft("", "2026-09-05T09:00:00.000Z", { modelSelection: { instanceId: "codex" as never, model: "gpt" }, }), - [`new-task:${environmentId}:${projectId}-2`]: draft(" "), - [`${environmentId}:thread-1`]: draft("thread composer text"), - "pending-task:message-1": draft("editor copy of a queued task"), + "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: [] }, }, - now: NOW, }); expect(tasks).toEqual([]); @@ -102,9 +96,10 @@ describe("buildPendingNewTasks", () => { const tasks = buildPendingNewTasks({ queuedMessages: [], drafts: { - [`new-task:${environmentId}:${projectId}`]: draft("", { attachments: [attachment] }), + "new-task:with-image": draft("", "2026-09-05T09:00:00.000Z", { + attachments: [attachment], + }), }, - now: NOW, }); expect(tasks.map((task) => task.title)).toEqual(["1 attachment"]); @@ -118,7 +113,6 @@ describe("buildPendingNewTasks", () => { queuedCreation("new", "2026-09-05T10:00:00.000Z"), ], drafts: {}, - now: NOW, }); expect(tasks.map((task) => task.title)).toEqual(["queued new", "queued old"]); diff --git a/apps/mobile/src/state/pending-new-tasks-model.ts b/apps/mobile/src/state/pending-new-tasks-model.ts index 3dafea540..b66c5273b 100644 --- a/apps/mobile/src/state/pending-new-tasks-model.ts +++ b/apps/mobile/src/state/pending-new-tasks-model.ts @@ -1,15 +1,16 @@ -import { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; 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 the project's 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. + * 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; @@ -36,32 +37,11 @@ export interface PendingDraftTask { readonly projectCwd: undefined; readonly branch: string | null; readonly title: string; - /** Drafts have no creation timestamp; they sort as current work. */ readonly createdAt: string; readonly draftKey: string; readonly draft: ComposerDraft; } -const NEW_TASK_DRAFT_PREFIX = "new-task:"; - -/** Parses a `new-task::` composer draft key. */ -export function parseNewTaskDraftKey( - draftKey: string, -): { readonly environmentId: EnvironmentId; readonly projectId: ProjectId } | null { - if (!draftKey.startsWith(NEW_TASK_DRAFT_PREFIX)) { - 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: EnvironmentId.make(scope.slice(0, separator)), - projectId: ProjectId.make(scope.slice(separator + 1)), - }; -} - /** * 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. @@ -81,8 +61,6 @@ function draftTitle(draft: ComposerDraft): string { export function buildPendingNewTasks(input: { readonly queuedMessages: ReadonlyArray; readonly drafts: Readonly>; - /** ISO timestamp drafts sort by; they carry no creation time of their own. */ - readonly now: string; }): ReadonlyArray { const tasks: PendingNewTask[] = []; for (const message of input.queuedMessages) { @@ -104,26 +82,25 @@ export function buildPendingNewTasks(input: { }); } for (const [draftKey, draft] of Object.entries(input.drafts)) { - const ref = parseNewTaskDraftKey(draftKey); - if (ref === null || !composerDraftHasUserContent(draft)) { + if (!isNewTaskDraftKey(draftKey) || !draft.project || !composerDraftHasUserContent(draft)) { continue; } tasks.push({ kind: "draft", key: `draft-task:${draftKey}`, - environmentId: ref.environmentId, - projectId: ref.projectId, + environmentId: draft.project.environmentId, + projectId: draft.project.projectId, projectTitle: undefined, projectCwd: undefined, branch: draft.workspaceSelection?.branch ?? null, title: draftTitle(draft), - createdAt: input.now, + createdAt: draft.project.createdAt, draftKey, draft, }); } - // Drafts are what the user is writing now, so they lead; queued tasks - // follow newest-first. + // 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; diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index 417674095..ae6c6137b 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"; @@ -157,14 +158,15 @@ import { ComposerDraftPersistenceError, composerDraftsAtom, composerCloudDraftsAtom, - copyComposerDraftContentIfEmpty, - copyComposerDraftContentState, + createNewTaskDraft, decodePersistedComposerState, ensureComposerDraftsLoaded, type ComposerDraft, + findNewTaskDraftKeys, flushComposerDrafts, getComposerDraftSnapshot, mergeComposerDraftContentState, + migrateLegacyNewTaskDraft, releaseUnusedComposerAttachmentFiles, removeComposerDraftsForEnvironment, resetComposerDraftsLoadState, @@ -172,6 +174,7 @@ import { restoreComposerDraftSnapshotState, restoreCloudComposerDrafts, restorePendingSendComposerDraftState, + retargetNewTaskDraft, setComposerDraftText, setComposerDraftAttachmentUpload, waitForComposerDraftsLoaded, @@ -211,37 +214,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 +1041,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 +1064,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 +1126,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 +1139,146 @@ 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("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 +1554,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 +1572,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 +1602,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 +1692,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, }); }); diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index fa56a2dfa..7831a20d9 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,15 @@ export function clearComposerDraftContentState( if (!existing) { return current; } + // Clearing content is the "this draft is done" moment (sent, queued, or + // discarded), so the project stamp goes too and an otherwise-empty new-task + // draft leaves the store rather than lingering as a blank row. const { importedShareIds: _importedShareIds, modelSelection, providerSelectionExplicit, workspaceSelection, + project: _project, ...retained } = existing; const draft = { @@ -1108,49 +1171,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 +1178,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; @@ -1355,15 +1362,7 @@ export function undoComposerDraftMergeState( 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 +1434,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 d4d4d5c7c..bf5f0191b 100644 --- a/apps/mobile/src/state/use-pending-new-tasks.ts +++ b/apps/mobile/src/state/use-pending-new-tasks.ts @@ -20,9 +20,6 @@ export function usePendingNewTasks(): ReadonlyArray { buildPendingNewTasks({ queuedMessages: flattenQueuedThreadMessages(queuedMessagesByThreadKey), drafts, - // Stamped when the inputs change, not per render, so a draft keeps one - // sort position while the user is not typing in it. - now: new Date().toISOString(), }), [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 e95d77951..aeccfd940 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -66,7 +66,7 @@ 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, @@ -84,6 +84,7 @@ import { ensureComposerDraftsLoaded, getComposerDraftSnapshot, mergeComposerDraftContent, + newTaskDraftKey, removeComposerDraftAttachment, scheduleUnusedComposerAttachmentCleanup, setComposerDraftText, @@ -1423,6 +1424,7 @@ export function useThreadComposerState() { params: { environmentId: String(queuedMessage.environmentId), projectId: String(destination.projectId), + draftId: input.draftKey, }, }); } @@ -1518,13 +1520,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 }); }, }); 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 917b01cb2..184d3b856 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.test.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.test.ts @@ -619,7 +619,7 @@ describe("thread outbox delivered creation recovery", () => { }); describe("thread outbox recovery rollback", () => { - it("restores a rejected new task into its durable project draft", async () => { + 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,14 +636,19 @@ 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"); diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index ea2756e46..e73937f30 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -15,7 +15,7 @@ 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"; @@ -56,6 +56,7 @@ import { type ComposerDraft, getComposerDraftSnapshot, mergeComposerDraftContent, + newTaskDraftKey, replaceComposerDraftAttachments, removeDeliveredCloudQueuedMessage, undoComposerDraftMerge, @@ -368,6 +369,7 @@ export async function restoreRejectedQueuedMessage( let mergedDraft: ComposerDraft; try { + stampRecoveryDraftProject(queuedMessage, draftKey); await mergeComposerDraftContent(draftKey, { text: queuedMessage.text, attachments: queuedMessage.attachments, @@ -450,12 +452,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)}` + ? newTaskDraftKey(`restored-${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, From 1b0eb003a1bbc03f042f1224623f19b09ada3b1a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 6 Sep 2026 12:44:41 -0700 Subject: [PATCH 03/29] fix(clients): show feedback results in composer banners (#10398) (cherry picked from commit 95d99373b74edfb3bfd092c61a15999a95727423) --- .../src/features/threads/ComposerFeedback.tsx | 63 ++++++++++++++++ .../features/threads/ThreadDetailScreen.tsx | 15 +++- .../features/threads/ThreadRouteScreen.tsx | 2 + apps/mobile/src/lib/threadActivity.test.ts | 67 ----------------- .../src/state/use-thread-composer-state.ts | 60 ++++++---------- apps/web/src/components/ChatView.tsx | 71 ++++++------------- .../src/components/chat/ComposerFeedback.tsx | 49 +++++++++++++ .../components/chat/MessagesTimeline.test.tsx | 56 --------------- .../src/state/threadFeedback.test.ts | 18 +++-- .../src/state/threadFeedback.ts | 42 ++++------- 10 files changed, 195 insertions(+), 248 deletions(-) create mode 100644 apps/mobile/src/features/threads/ComposerFeedback.tsx create mode 100644 apps/web/src/components/chat/ComposerFeedback.tsx diff --git a/apps/mobile/src/features/threads/ComposerFeedback.tsx b/apps/mobile/src/features/threads/ComposerFeedback.tsx new file mode 100644 index 000000000..dbbdc166d --- /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/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index d5461ffae..f3216f283 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"; @@ -94,6 +97,7 @@ 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"; @@ -125,6 +129,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; @@ -966,6 +972,13 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread onScrollToEnd={handleScrollToEnd} /> + {props.feedbackSubmissions.map((submission) => ( + props.onDismissFeedback(submission.id)} + /> + ))} {hasAboveEditorPresentation ? ( { - 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/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index aeccfd940..0e7b65931 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, @@ -72,7 +70,6 @@ import { resolveModelSelectionRuntimeMode, showModelSelectionInteractionModeToggle, } from "../lib/modelOptions"; -import { copyTextWithHaptic } from "../lib/copyTextWithHaptic"; import { buildThreadFeed } from "../lib/threadActivity"; import { appAtomRegistry } from "../state/atom-registry"; import { serverEnvironment } from "../state/server"; @@ -220,27 +217,31 @@ export function useThreadComposerState() { () => (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 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; const selectedThreadFeed = useMemo( () => selectedThreadMessages && selectedThreadActivities - ? buildThreadFeed( - { messages: selectedThreadMessages, activities: selectedThreadActivities }, - { localMessages: localFeedbackMessages }, - ) + ? buildThreadFeed({ + messages: selectedThreadMessages, + activities: selectedThreadActivities, + }) : [], - [localFeedbackMessages, selectedThreadActivities, selectedThreadMessages], + [selectedThreadActivities, selectedThreadMessages], ); const selectedThreadAgents = useMemo(() => { const status = selectedThreadDetail?.session?.status; @@ -673,7 +674,7 @@ export function useThreadComposerState() { return null; } const metadata = makeQueuedMessageMetadata(); - const result = await submitCodexFeedback({ + await submitCodexFeedback({ submission: { id: MessageId.make(metadata.messageId), command: text, @@ -701,25 +702,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; } @@ -1586,6 +1568,8 @@ export function useThreadComposerState() { ]); return { + feedbackSubmissions, + dismissFeedback, selectedThreadFeed, selectedThreadAgents, selectedThreadContextWindow, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6d2c4b664..43b690be1 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 000000000..8322b1a99 --- /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 a52dd3bcf..e18eaa607 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/packages/client-runtime/src/state/threadFeedback.test.ts b/packages/client-runtime/src/state/threadFeedback.test.ts index 14ce5185f..cd66961a5 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 29abb2689..1b02a8982 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: { From 8a22d54aebfbfe4ccc2c3d29379b6cc2d7874ce0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 6 Sep 2026 16:40:05 -0700 Subject: [PATCH 04/29] feat(mobile): queue a message while its attachment is still uploading (#10404) Co-authored-by: Claude Fable 5 (cherry picked from commit 66a24d6c1a008430eebd855cd5640607d5aef38c) --- .../features/threads/NewTaskDraftScreen.tsx | 35 ++++++++++++++----- .../src/features/threads/ThreadComposer.tsx | 34 +++++++++++++++--- .../lib/composerAttachmentUploadQueue.test.ts | 25 +++++++++++-- .../src/lib/composerAttachmentUploadQueue.ts | 21 ++++++++++- .../src/state/composer-attachment-uploads.ts | 5 ++- .../src/state/use-thread-composer-state.ts | 24 +++++++------ 6 files changed, 116 insertions(+), 28 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 61d67d87a..487d57d13 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -44,6 +44,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"; @@ -198,6 +199,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); @@ -976,10 +989,12 @@ 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. + if (queuesInsteadOfStarting) { + // Offline, or an attachment is still uploading: park the task in the + // outbox and let the drain send it once the environment is reachable + // and the bytes are on the server. Ordinary edits preserve their + // identifiers; explicitly submitting a held retarget uses the fresh + // metadata above. const metadata = retryTurnMetadata ?? (editingPendingTask @@ -1259,7 +1274,7 @@ export function NewTaskDraftScreen(props: { const workspaceControls = ( - {flow.submitting && environmentConnected && flow.workspaceMode === "worktree" ? ( + {flow.submitting && !queuesInsteadOfStarting && flow.workspaceMode === "worktree" ? ( void handleStart()} variant="primary" /> diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index bcda2d10b..8cc4aaa7d 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, { @@ -479,6 +480,20 @@ 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 followUpBlockReason = + attachmentBlockReason ?? (attachmentsUploading ? "Attachment still uploading" : null); const canSend = hasContent && !props.sessionInputBlocked && @@ -750,7 +765,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,10 +1802,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer /> {canQueueFollowUp ? ( ) : null} @@ -2044,10 +2061,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer the user with no way to send until they dismiss the error. */} {voicePresentation.showsSend ? ( diff --git a/apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts b/apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts index 2ebacc740..ba70a2845 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, @@ -248,7 +249,7 @@ describe("draft upload scope and offline submission", () => { ); }); - it("allows offline queuing while a connected composer waits for upload or retry", () => { + it("only a failed upload blocks sending; an in-flight one queues instead", () => { const key = composerAttachmentUploadKey(environmentId, "file"); const input = { environmentId, @@ -261,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({ @@ -269,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 538b343ab..3ee30ce8b 100644 --- a/apps/mobile/src/lib/composerAttachmentUploadQueue.ts +++ b/apps/mobile/src/lib/composerAttachmentUploadQueue.ts @@ -75,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; @@ -87,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/state/composer-attachment-uploads.ts b/apps/mobile/src/state/composer-attachment-uploads.ts index d454133f1..352367c09 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> diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 0e7b65931..745f18924 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -104,6 +104,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"; @@ -810,17 +811,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; } From 5be3f4af91056bf37c6da22aae2054ec31bdd739 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 6 Sep 2026 16:40:05 -0700 Subject: [PATCH 05/29] feat(mobile): show when an existing thread has a message waiting in the outbox (#10405) Co-authored-by: Claude Fable 5 (cherry picked from commit d6aa179ad2afe0424e1410cbafbb119bbd5f7809) --- apps/mobile/src/components/AppSymbol.tsx | 2 + apps/mobile/src/features/home/HomeScreen.tsx | 12 ++++++ .../src/features/home/homeThreadList.test.ts | 37 ++++++++++++++++ .../src/features/home/homeThreadList.ts | 13 ++++-- .../src/features/home/useThreadListActions.ts | 2 + .../threads/ThreadNavigationSidebar.tsx | 11 +++++ .../features/threads/queued-message-icon.tsx | 15 +++++++ .../features/threads/thread-list-items.tsx | 13 +++++- .../features/threads/thread-list-v2-items.tsx | 13 +++++- .../src/features/threads/threadListV2.test.ts | 42 +++++++++++++++++++ .../src/features/threads/threadListV2.ts | 12 +++++- apps/mobile/src/state/thread-order.test.ts | 4 ++ apps/mobile/src/state/thread-order.ts | 3 ++ apps/mobile/src/state/use-thread-outbox.ts | 22 ++++++++++ 14 files changed, 193 insertions(+), 8 deletions(-) create mode 100644 apps/mobile/src/features/threads/queued-message-icon.tsx diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index 58a1178d9..d34137e9a 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -79,6 +79,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"; @@ -168,6 +169,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/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 2c5942ec8..8a1e8576d 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -38,6 +38,7 @@ import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { usePendingThreadOrder } from "../../state/thread-order"; import { environmentServerConfigsAtom } from "../../state/server"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; +import { useQueuedThreadKeys } from "../../state/use-thread-outbox"; import { PendingTaskListRow, ThreadListGroupHeader, @@ -212,6 +213,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); @@ -384,6 +386,7 @@ export function HomeScreen(props: HomeScreenProps) { projects: scopedProjects, threads: scopedThreads, pendingTasks: scopedPendingTasks, + queuedThreadKeys, environmentId: props.selectedEnvironmentId, searchQuery: props.searchQuery, matchedThreadKeys, @@ -393,6 +396,7 @@ export function HomeScreen(props: HomeScreenProps) { }), [ threadListV2Enabled, + queuedThreadKeys, props.projectGroupingMode, props.projectSortOrder, props.searchQuery, @@ -663,6 +667,7 @@ export function HomeScreen(props: HomeScreenProps) { now: new Date().toISOString(), settlementEnvironmentIds, snoozeEnvironmentIds, + queuedThreadKeys, }), }); return { pinned: sectionPlanner("pinned"), active: sectionPlanner("active") }; @@ -670,6 +675,7 @@ export function HomeScreen(props: HomeScreenProps) { serverConfigs, props.threads, pendingOrder, + queuedThreadKeys, settlementEnvironmentIds, snoozeEnvironmentIds, nowMinute, @@ -697,6 +703,7 @@ export function HomeScreen(props: HomeScreenProps) { matchedThreadKeys, settlementEnvironmentIds, snoozeEnvironmentIds, + queuedThreadKeys, settledLimit: settledVisibleCount, now: new Date().toISOString(), snoozedShelfExpanded, @@ -705,6 +712,7 @@ export function HomeScreen(props: HomeScreenProps) { }); }, [ pendingOrder, + queuedThreadKeys, nowMinute, snoozeWakeTick, snoozedShelfExpanded, @@ -826,6 +834,7 @@ 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 f0c9e1bc6..d7a9d38eb 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; @@ -190,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); } @@ -202,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; @@ -326,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/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 7b0b7b701..f6983bd58 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -12,6 +12,7 @@ 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"; @@ -480,6 +481,7 @@ export function useThreadListActions(): { threads: shells, section, now: new Date().toISOString(), + queuedThreadKeys: appAtomRegistry.get(queuedThreadKeysAtom), settlementEnvironmentIds: new Set( [...configs].flatMap(([id, config]) => config.environment.capabilities.threadSettlement === true ? [id] : [], diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 681afdb5b..77acc8198 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -33,6 +33,7 @@ import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-pref import { usePendingThreadOrder } from "../../state/thread-order"; import { environmentServerConfigsAtom } from "../../state/server"; import { usePendingNewTasks } 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"; @@ -165,6 +166,7 @@ function ThreadNavigationSidebarPane( } = useThreadListActions(); const threadListV2Enabled = useThreadListV2Enabled(); const pendingTasks = usePendingNewTasks(); + const queuedThreadKeys = useQueuedThreadKeys(); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); const environments = useMemo( () => @@ -313,6 +315,7 @@ function ThreadNavigationSidebarPane( projects: scopedProjects, threads: scopedThreads, pendingTasks: scopedPendingTasks, + queuedThreadKeys, environmentId: options.selectedEnvironmentId, searchQuery: props.searchQuery, matchedThreadKeys, @@ -322,6 +325,7 @@ function ThreadNavigationSidebarPane( }), [ threadListV2Enabled, + queuedThreadKeys, matchedThreadKeys, options, props.searchQuery, @@ -496,6 +500,7 @@ function ThreadNavigationSidebarPane( now: new Date().toISOString(), settlementEnvironmentIds, snoozeEnvironmentIds, + queuedThreadKeys, }), }); return { pinned: sectionPlanner("pinned"), active: sectionPlanner("active") }; @@ -503,6 +508,7 @@ function ThreadNavigationSidebarPane( serverConfigs, threads, pendingOrder, + queuedThreadKeys, settlementEnvironmentIds, snoozeEnvironmentIds, nowMinute, @@ -528,6 +534,7 @@ function ThreadNavigationSidebarPane( matchedThreadKeys, settlementEnvironmentIds, snoozeEnvironmentIds, + queuedThreadKeys, settledLimit: settledVisibleCount, now: new Date().toISOString(), snoozedShelfExpanded, @@ -536,6 +543,7 @@ function ThreadNavigationSidebarPane( }); }, [ pendingOrder, + queuedThreadKeys, nowMinute, snoozeWakeTick, snoozedShelfExpanded, @@ -893,6 +901,7 @@ function ThreadNavigationSidebarPane( + ); +} diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index 553251b14..cd153e4cb 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -27,6 +27,7 @@ import { useThreadPr, type ThreadPrPresentation } from "../../state/use-thread-p 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"; @@ -451,6 +452,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; @@ -491,7 +494,13 @@ export const ThreadListRow = memo(function ThreadListRow(props: { 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), ); @@ -622,6 +631,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { {thread.title} + {props.hasQueuedMessages ? : null} {statusPill} {timestamp} + {props.hasQueuedMessages ? : null} {statusPill} {props.projectTitle ?? props.project?.title ?? ""} + {props.hasQueuedMessages ? : null} {pinnedRow ? ( ) : null} @@ -841,7 +845,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { variant === "card" ? ( { @@ -881,7 +887,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ) : ( ) : null} + {props.hasQueuedMessages ? : null} { }); }); +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( diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 6d1e77acd..aa774cf00 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; } @@ -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/state/thread-order.test.ts b/apps/mobile/src/state/thread-order.test.ts index 4959ad989..92db3a89b 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 0fb57fc82..1dad42de6 100644 --- a/apps/mobile/src/state/thread-order.ts +++ b/apps/mobile/src/state/thread-order.ts @@ -10,6 +10,7 @@ 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"; export const pendingThreadOrderAtom = Atom.make(null).pipe( Atom.keepAlive, @@ -50,6 +51,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 +72,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-thread-outbox.ts b/apps/mobile/src/state/use-thread-outbox.ts index 6ed00e2a0..e8599e1ec 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); } From cb53d2c29c6b578ba66dc9247fea27ad00db0b6c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 6 Sep 2026 22:24:11 -0700 Subject: [PATCH 06/29] fix(mobile): keep pending messages in the chat timeline (#10449) (cherry picked from commit e1230d6031bc55a21668818d0b585d8887b88579) --- .../features/threads/ThreadDetailScreen.tsx | 57 +++++++-- .../src/features/threads/ThreadFeed.tsx | 115 +++++++++++++----- .../features/threads/ThreadRouteScreen.tsx | 2 + .../threads/pending-thread-feed.test.ts | 41 +++++++ .../features/threads/pending-thread-feed.ts | 41 +++++++ .../src/state/acknowledged-thread-messages.ts | 27 ++++ .../state/edit-pending-thread-message.test.ts | 109 +++++++++++++++++ .../src/state/edit-pending-thread-message.ts | 76 ++++++++++++ apps/mobile/src/state/use-composer-drafts.ts | 8 +- .../src/state/use-thread-composer-state.ts | 40 +++++- .../src/state/use-thread-outbox-drain.test.ts | 3 + .../src/state/use-thread-outbox-drain.ts | 7 ++ 12 files changed, 478 insertions(+), 48 deletions(-) create mode 100644 apps/mobile/src/features/threads/pending-thread-feed.test.ts create mode 100644 apps/mobile/src/features/threads/pending-thread-feed.ts create mode 100644 apps/mobile/src/state/acknowledged-thread-messages.ts create mode 100644 apps/mobile/src/state/edit-pending-thread-message.test.ts create mode 100644 apps/mobile/src/state/edit-pending-thread-message.ts diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index f3216f283..b83911365 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -54,6 +54,7 @@ import { useState, } from "react"; import { + Alert, AppState, Keyboard, Platform, @@ -85,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, @@ -175,6 +178,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; @@ -494,6 +499,15 @@ 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. + const showFloatingStatus = + showWorkingControl || + props.connectionStateLabel !== "connected" || + props.queuedMessages.length > 0 || + props.selectedThreadFeed.some( + (entry) => "acknowledged" in entry && entry.acknowledged === true, + ); const selectedThreadFeed = props.selectedThreadFeed; const hasCompactableConversation = selectedThreadFeed.some( @@ -574,14 +588,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. @@ -641,12 +655,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 @@ -654,7 +668,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; @@ -740,11 +754,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; } @@ -783,9 +799,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread }); return () => cancelAnimationFrame(frame); }, [ + anchorMessageId, submittedMessageId, freeze, contentPresentationKind, + props.queuedMessages, selectedThreadFeed, scrollMessageToEnd, selectedThreadKey, @@ -834,6 +852,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(); }, []); @@ -911,6 +945,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} @@ -922,7 +959,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} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 4c9855751..457b565ba 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -75,12 +75,7 @@ import { FilePreviewModal, type FilePreviewSource } from "../../components/FileP import { isPdfFile } from "../../lib/filePreview"; import { PresentationSource } from "../../components/NativePresentation"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import Animated, { - FadeIn, - FadeInUp, - LinearTransition, - type SharedValue, -} from "react-native-reanimated"; +import Animated, { FadeIn, LinearTransition, type SharedValue } from "react-native-reanimated"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; import { useFontFamily } from "../../lib/useFontFamily"; @@ -166,6 +161,8 @@ import { THREAD_DISCLOSURE_TRANSITION_MS, WORK_GROUP_TOGGLE_HEIGHT, } from "./thread-work-log"; +import { appendPendingThreadMessages, type PendingThreadFeedEntry } from "./pending-thread-feed"; +import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; import { assetEnvironment, @@ -229,6 +226,9 @@ function isFreshTimestamp(input: string): boolean { } export interface ThreadFeedProps { + readonly queuedMessages: ReadonlyArray; + readonly dispatchingMessageId: MessageId | null; + readonly onEditPendingMessage: (message: QueuedThreadMessage) => void; readonly environmentId: EnvironmentId; readonly threadId: ThreadId; readonly workspaceRoot?: string | null; @@ -1325,7 +1325,7 @@ function useMarkdownStyles( } function renderFeedEntry( - info: { item: ThreadFeedEntry; index: number }, + info: { item: PendingThreadFeedEntry; index: number }, props: Pick< ThreadFeedProps, | "environmentId" @@ -1335,6 +1335,8 @@ function renderFeedEntry( | "rollbackTargetIdle" | "rollbackCommandPending" | "onRevertMessage" + | "dispatchingMessageId" + | "onEditPendingMessage" > & { readonly copiedRowId: string | null; readonly expandedWorkRows: Record; @@ -1480,12 +1482,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} - + ); } @@ -2257,6 +2297,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // Keep row-local interaction props in extraData so disclosures and copy feedback repaint. const listAppearanceData = useMemo( () => ({ + dispatchingMessageId: props.dispatchingMessageId, copiedRowId, expandedWorkRows, workRowSizing, @@ -2268,6 +2309,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { viewportWidth, }), [ + props.dispatchingMessageId, copiedRowId, expandedWorkRows, workRowSizing, @@ -2412,14 +2454,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 +2478,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) { @@ -2676,7 +2723,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 +2768,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ), [ + props.dispatchingMessageId, + props.onEditPendingMessage, copiedRowId, disclosureToggleSettling, expandedWorkRows, @@ -2753,7 +2804,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/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 596403eb5..822914e4b 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -1013,6 +1013,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/pending-thread-feed.test.ts b/apps/mobile/src/features/threads/pending-thread-feed.test.ts new file mode 100644 index 000000000..2cc0c5d88 --- /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 000000000..10b7d113e --- /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/state/acknowledged-thread-messages.ts b/apps/mobile/src/state/acknowledged-thread-messages.ts new file mode 100644 index 000000000..0ceeb4903 --- /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/edit-pending-thread-message.test.ts b/apps/mobile/src/state/edit-pending-thread-message.test.ts new file mode 100644 index 000000000..c9c8b4f6a --- /dev/null +++ b/apps/mobile/src/state/edit-pending-thread-message.test.ts @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { CommandId, EnvironmentId, MessageId, 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 () => {}), +})); +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: () => {}, + 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("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 000000000..655a2c42f --- /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/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 7831a20d9..47316e5f9 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -1341,7 +1341,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]); @@ -1358,6 +1363,7 @@ export function undoComposerDraftMergeState( (attachment) => !insertedAttachmentIds.has(attachment.id), ), modelSelection: undoSetting("modelSelection"), + providerSelectionExplicit: undoSetting("providerSelectionExplicit"), runtimeMode: undoSetting("runtimeMode"), interactionMode: undoSetting("interactionMode"), workspaceSelection: undoSetting("workspaceSelection"), diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 745f18924..96eb570b2 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -71,6 +71,8 @@ import { showModelSelectionInteractionModeToggle, } from "../lib/modelOptions"; 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 { @@ -169,6 +171,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< @@ -234,16 +237,41 @@ export function useThreadComposerState() { ); const selectedThreadMessages = selectedThreadDetail?.messages; const selectedThreadActivities = selectedThreadDetail?.activities; - const selectedThreadFeed = useMemo( - () => + const selectedThreadFeed = useMemo(() => { + const feed = selectedThreadMessages && selectedThreadActivities ? buildThreadFeed({ messages: selectedThreadMessages, activities: selectedThreadActivities, }) - : [], - [selectedThreadActivities, selectedThreadMessages], - ); + : []; + 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, + 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"; @@ -1589,6 +1617,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-outbox-drain.test.ts b/apps/mobile/src/state/use-thread-outbox-drain.test.ts index 184d3b856..775090bf7 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, @@ -223,6 +224,7 @@ function remainingMessages(): ReadonlyArray { } beforeEach(() => { + appAtomRegistry.set(acknowledgedThreadMessagesAtom, []); harness.draftFile.setDocument({ schemaVersion: 1, drafts: {} }); }); @@ -468,6 +470,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 () => { diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index e73937f30..edc4da471 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -19,6 +19,10 @@ 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 { useServerConfigs, useThreadShells } from "./entities"; import { @@ -195,6 +199,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, @@ -202,6 +207,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", { @@ -214,6 +220,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, From 7d2b47486690a41086a00478d06d0d6319d07d3c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 7 Sep 2026 00:12:40 -0700 Subject: [PATCH 07/29] feat(mobile): open the thread screen as soon as a new task is submitted (#10435) Co-authored-by: Claude Fable 5 (cherry picked from commit 5b68b2c8e1e971e4ee6956157a1698646ef52a55) --- .../features/threads/NewTaskDraftScreen.tsx | 265 ++++++------------ .../src/features/threads/ThreadComposer.tsx | 12 +- .../threads/ThreadCreationFailedCard.tsx | 38 +++ .../features/threads/ThreadDetailScreen.tsx | 59 +++- .../features/threads/ThreadRouteScreen.tsx | 110 +++++++- .../threads/floating-working-control.tsx | 27 ++ .../threads/floating-working-status.ts | 3 + .../threads/new-task-flow-provider.tsx | 28 +- .../src/features/threads/thread-work-log.tsx | 8 +- .../features/threads/use-project-actions.ts | 155 ---------- apps/mobile/src/state/new-task-draft-key.ts | 9 + .../src/state/pending-thread-creation.test.ts | 247 ++++++++++++++++ .../src/state/pending-thread-creation.ts | 163 +++++++++++ .../src/state/recover-failed-thread-draft.ts | 32 +++ .../src/state/use-thread-composer-state.ts | 41 ++- apps/mobile/src/state/use-thread-detail.ts | 10 +- .../src/state/use-thread-outbox-drain.test.ts | 91 ++++++ .../src/state/use-thread-outbox-drain.ts | 46 ++- apps/mobile/src/state/use-thread-selection.ts | 62 +++- .../shared/src/orchestrationTiming.test.ts | 109 ++++++- packages/shared/src/orchestrationTiming.ts | 19 +- 21 files changed, 1163 insertions(+), 371 deletions(-) create mode 100644 apps/mobile/src/features/threads/ThreadCreationFailedCard.tsx delete mode 100644 apps/mobile/src/features/threads/use-project-actions.ts create mode 100644 apps/mobile/src/state/pending-thread-creation.test.ts create mode 100644 apps/mobile/src/state/pending-thread-creation.ts create mode 100644 apps/mobile/src/state/recover-failed-thread-draft.ts diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 487d57d13..a45dd7a7a 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, @@ -53,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"; @@ -80,7 +75,6 @@ import { import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; import { clearComposerDraftContent, - flushComposerDrafts, getComposerDraftSnapshot, mergeComposerDraftContent, restoreComposerDraftSnapshot, @@ -97,7 +91,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, @@ -156,7 +149,6 @@ export function NewTaskDraftScreen(props: { readonly incomingShareId?: string; }) { const projects = useProjects(); - const createProjectThread = useCreateProjectThread(); const flow = useNewTaskFlow(); const navigation = useNavigation(); const { @@ -946,13 +938,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 ( @@ -989,138 +974,93 @@ export function NewTaskDraftScreen(props: { const retryTurnMetadata = editingPendingTask?.deliveryHold === undefined ? null : makeTurnCommandMetadata(); - if (queuesInsteadOfStarting) { - // Offline, or an attachment is still uploading: park the task in the - // outbox and let the drain send it once the environment is reachable - // and the bytes are on the server. 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) { @@ -1274,50 +1214,31 @@ export function NewTaskDraftScreen(props: { const workspaceControls = ( - {flow.submitting && !queuesInsteadOfStarting && 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")} + /> ); diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 8cc4aaa7d..a851be55f 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -232,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; @@ -492,14 +494,15 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer }); // 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 = - attachmentBlockReason ?? (attachmentsUploading ? "Attachment still uploading" : null); + 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(() => { @@ -1814,7 +1817,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ) : ( 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 b83911365..c9d4a6084 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -104,6 +104,7 @@ 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, @@ -146,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; @@ -477,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 }; } @@ -1025,6 +1050,22 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread /> ) : 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. */} + { + 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 + // offers the retarget, edit and delete actions. + const handleEditHeldCreation = useCallback(() => { + const creation = selectedThreadCreation?.message; + if (!creation?.creation) { + 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 +1036,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) => ( <> @@ -985,6 +1076,7 @@ function ThreadRouteContent( sessionCompactionPendingAction={composer.sessionCompactionPendingAction} activeWorkStartedAt={composer.activeWorkStartedAt} isCompacting={composer.isCompacting} + creationState={creationState} activePendingApproval={requests.activePendingApproval} respondingApprovalId={requests.respondingApprovalId} activePendingUserInput={requests.activePendingUserInput} diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx index 1afc871c9..5e9e6fcd2 100644 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ b/apps/mobile/src/features/threads/floating-working-control.tsx @@ -27,6 +27,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 @@ -318,6 +319,32 @@ function FloatingStatusLabel(props: { ); } + if (props.status.kind === "preparing") { + return ( + + + + + ); + } return ( 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. */ @@ -962,7 +969,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; } @@ -1026,11 +1036,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 diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 7b537c3b4..5ecd753be 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)} > ; - 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/state/new-task-draft-key.ts b/apps/mobile/src/state/new-task-draft-key.ts index 942380dba..8ea11faaf 100644 --- a/apps/mobile/src/state/new-task-draft-key.ts +++ b/apps/mobile/src/state/new-task-draft-key.ts @@ -9,6 +9,15 @@ export function isNewTaskDraftKey(draftKey: string): boolean { return draftKey.startsWith(NEW_TASK_DRAFT_PREFIX); } +/** + * 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 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 000000000..772b7cc91 --- /dev/null +++ b/apps/mobile/src/state/pending-thread-creation.test.ts @@ -0,0 +1,247 @@ +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("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 000000000..100391cbf --- /dev/null +++ b/apps/mobile/src/state/pending-thread-creation.ts @@ -0,0 +1,163 @@ +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 { + const creation = input.pending ?? input.previous; + 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 000000000..2eb172921 --- /dev/null +++ b/apps/mobile/src/state/recover-failed-thread-draft.ts @@ -0,0 +1,32 @@ +import type { QueuedThreadMessage } from "./thread-outbox-model"; +import { scopedThreadKey } from "../lib/scopedEntities"; +import { restoredNewTaskDraftKey } from "./new-task-draft-key"; +import { + appendComposerDraftAttachments, + clearComposerDraftContent, + flushComposerDrafts, + getComposerDraftSnapshot, + mergeComposerDraftContent, +} from "./use-composer-drafts"; + +/** Move unsent setup edits into the restored task before reopening its editor. */ +export async function recoverFailedThreadDraft(message: QueuedThreadMessage): Promise { + const sourceKey = scopedThreadKey(message.environmentId, message.threadId); + const targetKey = restoredNewTaskDraftKey(message.messageId); + const source = getComposerDraftSnapshot(sourceKey); + if (source.text.length === 0 && source.attachments.length === 0) return; + + 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); +} diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 96eb570b2..bbe416b10 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -75,6 +75,7 @@ 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, @@ -152,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 ?? []), @@ -217,8 +222,15 @@ 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] ?? []) : []), + () => + selectedThreadKey + ? (queuedMessagesByThreadKey[selectedThreadKey] ?? []).filter( + (message) => message.creation === undefined, + ) + : [], [queuedMessagesByThreadKey, selectedThreadKey], ); const feedbackSubmissions = useMemo( @@ -237,12 +249,22 @@ export function useThreadComposerState() { ); 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 + (selectedThreadMessages && selectedThreadActivities) || pendingCreationMessage !== null ? buildThreadFeed({ - messages: selectedThreadMessages, - activities: selectedThreadActivities, + messages: + pendingCreationMessage !== null && + !loadedMessages.some((message) => message.id === pendingCreationMessage.messageId) + ? [...loadedMessages, pendingThreadCreationMessage(pendingCreationMessage)] + : loadedMessages, + activities: selectedThreadActivities ?? [], }) : []; const pendingAcknowledgments = acknowledgedMessages.filter( @@ -257,6 +279,7 @@ export function useThreadComposerState() { }, [ selectedThreadActivities, selectedThreadMessages, + pendingCreationMessage, selectedThreadKey, selectedThreadQueuedMessages, acknowledgedMessages, @@ -644,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); @@ -803,6 +833,7 @@ export function useThreadComposerState() { selectedEnvironmentRuntime?.connectionState, selectedEnvironmentRuntime?.serverConfig, selectedSessionProviderInstanceId, + selectedThreadCreation, selectedThreadDetail, sessionCompactionBlocksSubmission, selectedThreadProject, diff --git a/apps/mobile/src/state/use-thread-detail.ts b/apps/mobile/src/state/use-thread-detail.ts index 388b4d9af..c071f2aad 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 775090bf7..1fec26d1b 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.test.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.test.ts @@ -163,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, @@ -233,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(); @@ -622,6 +628,55 @@ describe("thread outbox delivered creation recovery", () => { }); describe("thread outbox recovery rollback", () => { + 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" }), @@ -655,6 +710,42 @@ describe("thread outbox recovery rollback", () => { }); 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 edc4da471..b1c7e6b25 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -24,7 +24,13 @@ import { 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, @@ -60,7 +66,6 @@ import { type ComposerDraft, getComposerDraftSnapshot, mergeComposerDraftContent, - newTaskDraftKey, replaceComposerDraftAttachments, removeDeliveredCloudQueuedMessage, undoComposerDraftMerge, @@ -438,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) { @@ -467,7 +481,7 @@ export async function restoreRejectedQueuedMessage( */ function recoveryDraftKey(queuedMessage: QueuedThreadMessage): string { return queuedMessage.creation - ? newTaskDraftKey(`restored-${queuedMessage.messageId}`) + ? restoredNewTaskDraftKey(queuedMessage.messageId) : scopedThreadKey(queuedMessage.environmentId, queuedMessage.threadId); } @@ -528,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); @@ -853,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]) { @@ -872,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-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index ae9054009..0e1cfb849 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/packages/shared/src/orchestrationTiming.test.ts b/packages/shared/src/orchestrationTiming.test.ts index dab35ad3e..74e90d45a 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 2ae82c22a..b87fa9547 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; } From fc4c83571dac692e801d25345c5f405304d83cca Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Mon, 7 Sep 2026 09:22:41 +0200 Subject: [PATCH 08/29] feat(mobile): start a new thread on an existing branch (#10359) (cherry picked from commit 7dda0b1c0a95603f1954b81a9fee699be92668f0) --- .../src/features/home/HomeRouteScreen.tsx | 11 ++ apps/mobile/src/features/home/HomeScreen.tsx | 5 + .../layout/AdaptiveWorkspaceLayout.tsx | 16 ++ .../threads/NewTaskContextPickerScreens.tsx | 46 ++---- .../threads/NewTaskDraftRouteScreen.tsx | 149 ++++++++++++++++-- .../features/threads/NewTaskDraftScreen.tsx | 24 +++ .../threads/ThreadNavigationSidebar.tsx | 4 + .../threads/checkout-new-task-branch.test.ts | 132 ++++++++++++++++ .../threads/checkout-new-task-branch.ts | 49 ++++++ .../features/threads/thread-list-items.tsx | 28 +++- .../features/threads/thread-list-v2-items.tsx | 25 ++- .../src/lib/projectThreadStartTurn.test.ts | 35 ++++ 12 files changed, 471 insertions(+), 53 deletions(-) create mode 100644 apps/mobile/src/features/threads/checkout-new-task-branch.test.ts create mode 100644 apps/mobile/src/features/threads/checkout-new-task-branch.ts diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index a9833d2d6..00731ce5a 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -214,6 +214,17 @@ export function HomeRouteScreen() { onSelectThread={handleSelectThread} onSelectPendingTask={openPendingTask} onDeletePendingTask={confirmDeletePendingTask} + onNewThreadOnBranch={(thread) => { + 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 8a1e8576d..4b20d0562 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -124,6 +124,7 @@ interface HomeScreenProps { 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; } @@ -832,6 +833,7 @@ export function HomeScreen(props: HomeScreenProps) { const movedId = `${thread.environmentId}:${thread.id}`; return ( { + 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/NewTaskContextPickerScreens.tsx b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx index 96bd74380..ef066feca 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 dc1ee942d..aab423a37 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,6 +18,8 @@ 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[]; @@ -14,7 +27,15 @@ type NewTaskDraftRouteParams = { }; 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 @@ -25,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 a45dd7a7a..45699c245 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -78,6 +78,7 @@ import { getComposerDraftSnapshot, mergeComposerDraftContent, restoreComposerDraftSnapshot, + updateComposerDraftSettings, scheduleUnusedComposerAttachmentCleanup, type ComposerDraft, waitForComposerDraftsLoaded, @@ -140,6 +141,8 @@ 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; @@ -529,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 && @@ -562,6 +585,7 @@ export function NewTaskDraftScreen(props: { }, [ projectScopes, projects, + flow.draftKey, props.initialProjectRef, props.incomingShareId, props.pendingTaskId, diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 77acc8198..3ec0b4634 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -103,6 +103,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; @@ -899,6 +900,7 @@ function ThreadNavigationSidebarPane( const scopeKey = scopedProjectKey(thread.environmentId, thread.projectId); return ( 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 000000000..2652581f6 --- /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/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index cd153e4cb..d4d8f70e2 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"; @@ -464,6 +464,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; @@ -487,8 +488,14 @@ 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( @@ -526,6 +533,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, @@ -533,7 +550,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( () => ({ @@ -546,11 +563,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 ? ( diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 3ee0c05c2..322f5266a 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -382,6 +382,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly fullSwipeWidth?: number; readonly onSelectThread: (thread: EnvironmentThreadShell) => 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; @@ -422,6 +423,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onSelectThread, onDeleteThread, onRegenerateThreadTitle, + onNewThreadOnBranch, onSettleThread, onSnoozeThread, onUnsnoozeThread, @@ -603,6 +605,7 @@ 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(); @@ -625,6 +628,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { } }, [ + onNewThreadOnBranch, + thread, handleArchive, handleDelete, handleRegenerateTitle, @@ -990,8 +995,20 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { > {(close) => ( diff --git a/apps/mobile/src/lib/projectThreadStartTurn.test.ts b/apps/mobile/src/lib/projectThreadStartTurn.test.ts index bd7918e52..57df389cf 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"); + }, + ); +}); From 98b6076ac11af849cf0385a6c7da14292a7c2300 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 7 Sep 2026 01:40:57 -0700 Subject: [PATCH 09/29] fix(mobile): prevent chat from disappearing when scrolling (#10479) (cherry picked from commit 71297974c666b0db50a3e3b1a861742f2cdd4d7d) --- patches/@legendapp__list@3.3.5.patch | 72 ++++++++++++++++++---------- pnpm-lock.yaml | 10 ++-- 2 files changed, 53 insertions(+), 29 deletions(-) diff --git a/patches/@legendapp__list@3.3.5.patch b/patches/@legendapp__list@3.3.5.patch index e73120923..da7338b68 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..ddca4bcd8a5dc5863cb65e03b6cfac0047a8ead3 100644 --- a/react-native.js +++ b/react-native.js @@ -717,6 +717,15 @@ function hasActiveInitialScroll(state) { @@ -674,7 +674,19 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 contentContainerStyle: [ horizontal ? { height: "100%" } : {}, contentContainerStyle, -@@ -6751,7 +6950,7 @@ function createImperativeHandle(ctx, scheduleImperativeScrollCommit) { +@@ -6010,7 +6209,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 +6953,7 @@ function createImperativeHandle(ctx, scheduleImperativeScrollCommit) { endBuffered: state.endBuffered, getAverageItemSizes: () => getAverageItemSizes(state), indexByKey: (key) => state.indexByKey.get(key), @@ -683,7 +695,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 +7277,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded dataVersion, drawDistance = 250, contentInsetEndAdjustment, @@ -691,7 +703,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 estimatedItemSize = 100, estimatedListSize, extraData, -@@ -7132,10 +7332,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7132,10 +7335,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 +716,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 stickyPositionComponentInternal: _stickyPositionComponentInternal, ...restProps } = rest; -@@ -7200,7 +7402,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7200,7 +7405,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 +725,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 +7546,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded contentContainerAlignItems: contentContainerStyle.alignItems, contentInset, contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, @@ -721,7 +733,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 data: dataProp, dataKey, dataVersion, -@@ -7372,6 +7575,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7372,6 +7578,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded renderItem, rtl, snapToIndices, @@ -729,7 +741,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 +7630,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded return void 0; } const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); @@ -743,7 +755,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 +7761,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded [ dataKey, dataVersion, @@ -751,7 +763,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df75 memoizedLastItemKeys.join(","), numColumnsProp, nextScrollAxisGap, -@@ -7643,6 +7855,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7643,6 +7858,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded () => ({ getRenderedItem: (key) => getRenderedItem(ctx, key), onMomentumScrollEnd: (event) => { @@ -759,7 +771,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 +7867,8 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded onScroll: (event) => onScroll(ctx, event), onScrollBeginDrag: (event) => { var _a4, _b2; @@ -768,7 +780,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 +7894,18 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded ListFooterComponent, ListFooterComponentStyle, ListHeaderComponent, @@ -788,7 +800,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..b8c6faf0be51cbda3e8af01f290dbe97440bc623 100644 --- a/react-native.mjs +++ b/react-native.mjs @@ -696,6 +696,15 @@ function hasActiveInitialScroll(state) { @@ -1221,7 +1233,19 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff contentContainerStyle: [ horizontal ? { height: "100%" } : {}, contentContainerStyle, -@@ -6730,7 +6929,7 @@ function createImperativeHandle(ctx, scheduleImperativeScrollCommit) { +@@ -5989,7 +6188,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 +6932,7 @@ function createImperativeHandle(ctx, scheduleImperativeScrollCommit) { endBuffered: state.endBuffered, getAverageItemSizes: () => getAverageItemSizes(state), indexByKey: (key) => state.indexByKey.get(key), @@ -1230,7 +1254,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 +7256,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded dataVersion, drawDistance = 250, contentInsetEndAdjustment, @@ -1238,7 +1262,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff estimatedItemSize = 100, estimatedListSize, extraData, -@@ -7111,10 +7311,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7111,10 +7314,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 +1275,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff stickyPositionComponentInternal: _stickyPositionComponentInternal, ...restProps } = rest; -@@ -7179,7 +7381,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7179,7 +7384,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 +1284,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 +7525,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded contentContainerAlignItems: contentContainerStyle.alignItems, contentInset, contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, @@ -1268,7 +1292,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff data: dataProp, dataKey, dataVersion, -@@ -7351,6 +7554,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7351,6 +7557,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded renderItem, rtl, snapToIndices, @@ -1276,7 +1300,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 +7609,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded return void 0; } const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); @@ -1290,7 +1314,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 +7740,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded [ dataKey, dataVersion, @@ -1298,7 +1322,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff memoizedLastItemKeys.join(","), numColumnsProp, nextScrollAxisGap, -@@ -7622,6 +7834,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7622,6 +7837,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded () => ({ getRenderedItem: (key) => getRenderedItem(ctx, key), onMomentumScrollEnd: (event) => { @@ -1306,7 +1330,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 +7846,8 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded onScroll: (event) => onScroll(ctx, event), onScrollBeginDrag: (event) => { var _a4, _b2; @@ -1315,7 +1339,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 +7873,18 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded ListFooterComponent, ListFooterComponentStyle, ListHeaderComponent, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 601eb1147..e158dce2d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,7 +89,7 @@ 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': 6ae6f1a8fb0616d00653b204be96c0cc819828f6ff9b8fc2c2ac4b8502d6aa10 '@pierre/diffs@1.3.0-beta.10': 0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4 '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d '@react-native-menu/menu@2.0.0': f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd @@ -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=6ae6f1a8fb0616d00653b204be96c0cc819828f6ff9b8fc2c2ac4b8502d6aa10)(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 @@ -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=6ae6f1a8fb0616d00653b204be96c0cc819828f6ff9b8fc2c2ac4b8502d6aa10)(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) @@ -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=6ae6f1a8fb0616d00653b204be96c0cc819828f6ff9b8fc2c2ac4b8502d6aa10)(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=6ae6f1a8fb0616d00653b204be96c0cc819828f6ff9b8fc2c2ac4b8502d6aa10)(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) From aa7e45e82e7c79603bf9b9ca93016b2561986043 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 7 Sep 2026 02:14:28 -0700 Subject: [PATCH 10/29] fix(mobile): smooth composer status pill resizing (#10484) (cherry picked from commit b248f5ad566a8cd3ea2d7d678c1e8aa49c748879) --- .../threads/floating-working-control.tsx | 56 ++++++++++++------- 1 file changed, 35 insertions(+), 21 deletions(-) diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx index 5e9e6fcd2..bb55de1b0 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, { @@ -69,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(() => { @@ -82,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) => { @@ -127,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; @@ -144,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} > @@ -286,7 +296,9 @@ function FloatingStatusLabel(props: { animating={animate} hidesWhenStopped={false} /> - {props.status.label} + + {props.status.label} + ); } @@ -313,7 +325,10 @@ function FloatingStatusLabel(props: { ) : ( )} - + {props.status.label} @@ -356,8 +371,7 @@ function FloatingStatusLabel(props: { ); } -// Rows are absolute with no insets, so the capsule centers them on itself and an -// exiting row fading out never shifts the incoming one. +// Absolute rows cross-fade around the same center without affecting each other. function StatusLabelRow(props: { readonly accessibilityLabel: string; readonly accessibilityRole?: "button"; @@ -369,7 +383,7 @@ function StatusLabelRow(props: { const rowClassName = `h-11 flex-row items-center px-4 ${props.className ?? ""}`; return ( Date: Mon, 7 Sep 2026 02:21:37 -0700 Subject: [PATCH 11/29] fix(mobile): release initial scroll target after dragging (#10483) (cherry picked from commit c0d4e95c0d921b5960ad04c7f8abec459ee3c59e) --- patches/@legendapp__list@3.3.5.patch | 4 ++-- pnpm-lock.yaml | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/patches/@legendapp__list@3.3.5.patch b/patches/@legendapp__list@3.3.5.patch index da7338b68..02a7d3de2 100644 --- a/patches/@legendapp__list@3.3.5.patch +++ b/patches/@legendapp__list@3.3.5.patch @@ -570,7 +570,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 - 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, { @@ -1129,7 +1129,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 - 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, { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e158dce2d..67c613bf4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,7 +89,7 @@ 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': 6ae6f1a8fb0616d00653b204be96c0cc819828f6ff9b8fc2c2ac4b8502d6aa10 + '@legendapp/list@3.3.5': fcd1ede0567f6d34b3c228e8c9ef431f9941810eeac9a5c78869e5aa36ad71df '@pierre/diffs@1.3.0-beta.10': 0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4 '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d '@react-native-menu/menu@2.0.0': f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd @@ -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=6ae6f1a8fb0616d00653b204be96c0cc819828f6ff9b8fc2c2ac4b8502d6aa10)(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=fcd1ede0567f6d34b3c228e8c9ef431f9941810eeac9a5c78869e5aa36ad71df)(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 @@ -592,7 +592,7 @@ importers: version: 0.9.0 '@legendapp/list': specifier: 'catalog:' - version: 3.3.5(patch_hash=6ae6f1a8fb0616d00653b204be96c0cc819828f6ff9b8fc2c2ac4b8502d6aa10)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 3.3.5(patch_hash=fcd1ede0567f6d34b3c228e8c9ef431f9941810eeac9a5c78869e5aa36ad71df)(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) @@ -13737,7 +13737,7 @@ snapshots: dependencies: jsbi: 4.3.2 - '@legendapp/list@3.3.5(patch_hash=6ae6f1a8fb0616d00653b204be96c0cc819828f6ff9b8fc2c2ac4b8502d6aa10)(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=fcd1ede0567f6d34b3c228e8c9ef431f9941810eeac9a5c78869e5aa36ad71df)(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=6ae6f1a8fb0616d00653b204be96c0cc819828f6ff9b8fc2c2ac4b8502d6aa10)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@legendapp/list@3.3.5(patch_hash=fcd1ede0567f6d34b3c228e8c9ef431f9941810eeac9a5c78869e5aa36ad71df)(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) From 381320291344604f67f76370029f086291c0deb5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 7 Sep 2026 02:57:58 -0700 Subject: [PATCH 12/29] fix(mobile): animate thread lifecycle transitions consistently (#10487) (cherry picked from commit e32dd42f8ba0daa2d0c6d6d2e474b7af6b3eb7b4) --- .../archive/ArchivedThreadsScreen.tsx | 2 + .../features/home/thread-dismissal.test.ts | 73 +++++++++++ .../src/features/home/thread-dismissal.ts | 35 +++++ .../features/home/thread-swipe-actions.tsx | 124 ++++++++---------- .../src/features/home/useThreadListActions.ts | 77 ++++++----- .../features/threads/thread-list-items.tsx | 1 + .../features/threads/thread-list-v2-items.tsx | 4 +- 7 files changed, 213 insertions(+), 103 deletions(-) create mode 100644 apps/mobile/src/features/home/thread-dismissal.test.ts create mode 100644 apps/mobile/src/features/home/thread-dismissal.ts diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index c2d5b6cf6..97f9c5b69 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 ( { + 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 000000000..1e1561570 --- /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 93085fec6..44f3c36e6 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/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index f6983bd58..2c08f2647 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -5,6 +5,7 @@ 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"; @@ -131,26 +132,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; @@ -275,13 +280,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( @@ -316,10 +326,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( diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index d4d8f70e2..67ab0a052 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -736,6 +736,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { return ( From 62c9d3df3c44570ea2cbc5df476f325fd1d02e57 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 7 Sep 2026 03:01:51 -0700 Subject: [PATCH 13/29] fix(mobile): restore assistant message bottom padding (#10491) (cherry picked from commit b7175371d9f32f3b759a922cdbf727a3214c99a2) --- apps/mobile/src/features/threads/ThreadFeed.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 457b565ba..3e52ae1b1 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -2293,11 +2293,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, @@ -2310,6 +2317,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }), [ props.dispatchingMessageId, + unsettledTurnId, copiedRowId, expandedWorkRows, workRowSizing, @@ -2505,12 +2513,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; From 5cb726871c97ca727cd1c63edbff199ff1139a33 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 7 Sep 2026 03:05:57 -0700 Subject: [PATCH 14/29] fix(mobile): preserve chat rows when toggling commands (#10492) (cherry picked from commit dc39615aec702ea6d402168f80b4d1613f4f2e0f) --- apps/mobile/src/features/threads/ThreadFeed.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 3e52ae1b1..5af4286e3 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -211,6 +211,7 @@ function formatMessageTime(input: string): string { // text fits at the current font settings. Larger accessibility text is measured. const TURN_FOLD_HEIGHT = 42; // min-h-11 (38.5) + mb-1 (3.5), with the mobile 14px rem const THREAD_FEED_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS); +const THREAD_FEED_IMMEDIATE_TRANSITION = LinearTransition.duration(0); // Let neighboring rows move out of the new rows' space before showing their text. const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.delay( THREAD_DISCLOSURE_TRANSITION_MS, @@ -2905,8 +2906,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { entry.type === "message" ? `message:${entry.message.role}` : entry.type } getFixedItemSize={getFixedItemSize} + // LegendList swaps its position and size component types when this + // becomes undefined, remounting the feed and replaying row entrances. + // Keep those containers mounted while ordinary updates stay immediate. itemLayoutAnimation={ - disclosureToggleSettling ? THREAD_FEED_LAYOUT_TRANSITION : undefined + disclosureToggleSettling + ? THREAD_FEED_LAYOUT_TRANSITION + : THREAD_FEED_IMMEDIATE_TRANSITION } onItemSizeChanged={handleItemSizeChanged} // Measure rows well before they scroll into view so estimate→actual From f41f6c25139558c08d45cda4c2d04997e67e6d0a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 7 Sep 2026 10:10:00 -0700 Subject: [PATCH 15/29] fix(mobile): wait for native thread scroll before reveal (#10486) (cherry picked from commit 8d7f78121660eb8599d4c5ff40cb4394159abfab) --- patches/@legendapp__list@3.3.5.patch | 258 +++++++++++---------- pnpm-lock.yaml | 10 +- scripts/legend-list-initial-reveal.test.ts | 214 +++++++++++++++++ 3 files changed, 357 insertions(+), 125 deletions(-) create mode 100644 scripts/legend-list-initial-reveal.test.ts diff --git a/patches/@legendapp__list@3.3.5.patch b/patches/@legendapp__list@3.3.5.patch index 02a7d3de2..3559a6661 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..ddca4bcd8a5dc5863cb65e03b6cfac0047a8ead3 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..ddca4bcd8a5dc5863cb65e03b6cfac00 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..ddca4bcd8a5dc5863cb65e03b6cfac00 } 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..ddca4bcd8a5dc5863cb65e03b6cfac00 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..ddca4bcd8a5dc5863cb65e03b6cfac00 return clampedOffset; } -@@ -1451,10 +1469,10 @@ function checkFinishedScrollFrame(ctx) { +@@ -1451,10 +1478,10 @@ function checkFinishedScrollFrame(ctx) { finishScrollTo(ctx); } } @@ -337,7 +346,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 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..ddca4bcd8a5dc5863cb65e03b6cfac00 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..ddca4bcd8a5dc5863cb65e03b6cfac00 } 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..ddca4bcd8a5dc5863cb65e03b6cfac00 } 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..ddca4bcd8a5dc5863cb65e03b6cfac00 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..ddca4bcd8a5dc5863cb65e03b6cfac00 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..ddca4bcd8a5dc5863cb65e03b6cfac00 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..ddca4bcd8a5dc5863cb65e03b6cfac00 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..ddca4bcd8a5dc5863cb65e03b6cfac00 +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..ddca4bcd8a5dc5863cb65e03b6cfac00 + 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..ddca4bcd8a5dc5863cb65e03b6cfac00 + 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,7 +572,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 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)) { @@ -574,7 +583,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 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..ddca4bcd8a5dc5863cb65e03b6cfac00 } 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..ddca4bcd8a5dc5863cb65e03b6cfac00 } 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..ddca4bcd8a5dc5863cb65e03b6cfac00 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..ddca4bcd8a5dc5863cb65e03b6cfac00 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..ddca4bcd8a5dc5863cb65e03b6cfac00 }); 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..ddca4bcd8a5dc5863cb65e03b6cfac00 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..ddca4bcd8a5dc5863cb65e03b6cfac00 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,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 contentContainerStyle: [ horizontal ? { height: "100%" } : {}, contentContainerStyle, -@@ -6010,7 +6209,10 @@ var ListComponent = typedMemo(function ListComponent2({ +@@ -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, @@ -686,7 +695,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 onLayout, onScroll: onScroll2, ref: refScrollView, -@@ -6751,7 +6953,7 @@ function createImperativeHandle(ctx, scheduleImperativeScrollCommit) { +@@ -6751,7 +6962,7 @@ function createImperativeHandle(ctx, scheduleImperativeScrollCommit) { endBuffered: state.endBuffered, getAverageItemSizes: () => getAverageItemSizes(state), indexByKey: (key) => state.indexByKey.get(key), @@ -695,7 +704,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 isAtStart: peek$(ctx, "isAtStart"), isEndReached: state.isEndReached, isNearEnd: peek$(ctx, "isNearEnd"), -@@ -7075,6 +7277,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7075,6 +7286,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded dataVersion, drawDistance = 250, contentInsetEndAdjustment, @@ -703,7 +712,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 estimatedItemSize = 100, estimatedListSize, extraData, -@@ -7132,10 +7335,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; @@ -716,7 +725,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 stickyPositionComponentInternal: _stickyPositionComponentInternal, ...restProps } = rest; -@@ -7200,7 +7405,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; @@ -725,7 +734,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 const previousContentInsetEndAdjustmentRef = React2.useRef(contentInsetEndAdjustmentResolved); const alwaysRenderIndices = React2.useMemo(() => { const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex); -@@ -7341,6 +7546,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, @@ -733,7 +742,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 data: dataProp, dataKey, dataVersion, -@@ -7372,6 +7578,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7372,6 +7587,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded renderItem, rtl, snapToIndices, @@ -741,7 +750,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 stickyHeaderIndicesArr: stickyHeaderIndices != null ? stickyHeaderIndices : [], stickyHeaderIndicesSet: React2.useMemo(() => new Set(stickyHeaderIndices != null ? stickyHeaderIndices : []), [stickyHeaderIndices == null ? void 0 : stickyHeaderIndices.join(",")]), stickyPositionComponentInternal, -@@ -7423,6 +7630,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); @@ -755,7 +764,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; }, [usesBootstrapInitialScroll]); React2.useLayoutEffect(() => { -@@ -7547,6 +7761,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7547,6 +7770,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded [ dataKey, dataVersion, @@ -763,7 +772,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 memoizedLastItemKeys.join(","), numColumnsProp, nextScrollAxisGap, -@@ -7643,6 +7858,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) => { @@ -771,7 +780,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 checkFinishedScrollFallback(ctx); if (state.props.onMomentumScrollEnd) { state.props.onMomentumScrollEnd(event); -@@ -7651,6 +7867,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; @@ -780,7 +789,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 prepareReachedEdgeForNextUserScroll(ctx); (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event); }, -@@ -7676,11 +7894,18 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7676,11 +7903,18 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded ListFooterComponent, ListFooterComponentStyle, ListHeaderComponent, @@ -800,7 +809,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..ddca4bcd8a5dc5863cb65e03b6cfac00 recycleItems, refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2__namespace.cloneElement(refreshControlElement, { diff --git a/react-native.mjs b/react-native.mjs -index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97440bc623 100644 +index 40e87cda8c9bc79a889e5542f29af429a24b24d4..93aac741d2cf77ce35996362439108176f19da7a 100644 --- a/react-native.mjs +++ b/react-native.mjs @@ -696,6 +696,15 @@ function hasActiveInitialScroll(state) { @@ -828,16 +837,25 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 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; @@ -846,7 +864,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 } 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) { @@ -857,7 +875,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 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 @@ -883,7 +901,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 return clampedOffset; } -@@ -1430,10 +1448,10 @@ function checkFinishedScrollFrame(ctx) { +@@ -1430,10 +1457,10 @@ function checkFinishedScrollFrame(ctx) { finishScrollTo(ctx); } } @@ -896,7 +914,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 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) { @@ -908,7 +926,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 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); @@ -938,7 +956,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 } 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 { @@ -960,7 +978,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 } 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) { @@ -971,7 +989,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 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; } @@ -982,7 +1000,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 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; } @@ -991,7 +1009,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 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 { @@ -1000,7 +1018,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 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 @@ -1010,11 +1028,11 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 +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 @@ -1023,6 +1041,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 + let frames = 0; + let settledFrames = 0; + let revealStableFrames = 0; ++ let previousEndOffset; + const releaseRevealHold = () => { + if (state.insetEndRevealHold) { + state.insetEndRevealHold = false; @@ -1062,33 +1081,32 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 + 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); @@ -1122,7 +1140,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 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)) { @@ -1133,7 +1151,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 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; @@ -1143,7 +1161,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 } 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 }); @@ -1156,7 +1174,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 } return nextSize; } -@@ -5694,6 +5865,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ +@@ -5694,6 +5874,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ horizontal }) { const ctx = useStateContext(); @@ -1164,7 +1182,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 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 }; @@ -1178,7 +1196,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 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({ } } } @@ -1188,7 +1206,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 }); 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 }) { @@ -1201,7 +1219,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 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, @@ -1214,7 +1232,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 scrollAdjustHandler, snapToIndices, stickyHeaderConfig, -@@ -5980,7 +6169,17 @@ var ListComponent = typedMemo(function ListComponent2({ +@@ -5980,7 +6178,17 @@ var ListComponent = typedMemo(function ListComponent2({ SnapOrScroll, { ...rest, @@ -1233,7 +1251,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 contentContainerStyle: [ horizontal ? { height: "100%" } : {}, contentContainerStyle, -@@ -5989,7 +6188,10 @@ var ListComponent = typedMemo(function ListComponent2({ +@@ -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, @@ -1245,7 +1263,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 onLayout, onScroll: onScroll2, ref: refScrollView, -@@ -6730,7 +6932,7 @@ function createImperativeHandle(ctx, scheduleImperativeScrollCommit) { +@@ -6730,7 +6941,7 @@ function createImperativeHandle(ctx, scheduleImperativeScrollCommit) { endBuffered: state.endBuffered, getAverageItemSizes: () => getAverageItemSizes(state), indexByKey: (key) => state.indexByKey.get(key), @@ -1254,7 +1272,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 isAtStart: peek$(ctx, "isAtStart"), isEndReached: state.isEndReached, isNearEnd: peek$(ctx, "isNearEnd"), -@@ -7054,6 +7256,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7054,6 +7265,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded dataVersion, drawDistance = 250, contentInsetEndAdjustment, @@ -1262,7 +1280,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 estimatedItemSize = 100, estimatedListSize, extraData, -@@ -7111,10 +7314,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; @@ -1275,7 +1293,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 stickyPositionComponentInternal: _stickyPositionComponentInternal, ...restProps } = rest; -@@ -7179,7 +7384,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; @@ -1284,7 +1302,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 const previousContentInsetEndAdjustmentRef = useRef(contentInsetEndAdjustmentResolved); const alwaysRenderIndices = useMemo(() => { const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex); -@@ -7320,6 +7525,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, @@ -1292,7 +1310,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 data: dataProp, dataKey, dataVersion, -@@ -7351,6 +7557,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7351,6 +7566,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded renderItem, rtl, snapToIndices, @@ -1300,7 +1318,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 stickyHeaderIndicesArr: stickyHeaderIndices != null ? stickyHeaderIndices : [], stickyHeaderIndicesSet: useMemo(() => new Set(stickyHeaderIndices != null ? stickyHeaderIndices : []), [stickyHeaderIndices == null ? void 0 : stickyHeaderIndices.join(",")]), stickyPositionComponentInternal, -@@ -7402,6 +7609,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); @@ -1314,7 +1332,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; }, [usesBootstrapInitialScroll]); useLayoutEffect(() => { -@@ -7526,6 +7740,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7526,6 +7749,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded [ dataKey, dataVersion, @@ -1322,7 +1340,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 memoizedLastItemKeys.join(","), numColumnsProp, nextScrollAxisGap, -@@ -7622,6 +7837,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) => { @@ -1330,7 +1348,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 checkFinishedScrollFallback(ctx); if (state.props.onMomentumScrollEnd) { state.props.onMomentumScrollEnd(event); -@@ -7630,6 +7846,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; @@ -1339,7 +1357,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..b8c6faf0be51cbda3e8af01f290dbe97 prepareReachedEdgeForNextUserScroll(ctx); (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event); }, -@@ -7655,11 +7873,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/pnpm-lock.yaml b/pnpm-lock.yaml index 67c613bf4..4f148ad91 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,7 +89,7 @@ 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': fcd1ede0567f6d34b3c228e8c9ef431f9941810eeac9a5c78869e5aa36ad71df + '@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 @@ -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=fcd1ede0567f6d34b3c228e8c9ef431f9941810eeac9a5c78869e5aa36ad71df)(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 @@ -592,7 +592,7 @@ importers: version: 0.9.0 '@legendapp/list': specifier: 'catalog:' - version: 3.3.5(patch_hash=fcd1ede0567f6d34b3c228e8c9ef431f9941810eeac9a5c78869e5aa36ad71df)(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) @@ -13737,7 +13737,7 @@ snapshots: dependencies: jsbi: 4.3.2 - '@legendapp/list@3.3.5(patch_hash=fcd1ede0567f6d34b3c228e8c9ef431f9941810eeac9a5c78869e5aa36ad71df)(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=fcd1ede0567f6d34b3c228e8c9ef431f9941810eeac9a5c78869e5aa36ad71df)(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) diff --git a/scripts/legend-list-initial-reveal.test.ts b/scripts/legend-list-initial-reveal.test.ts new file mode 100644 index 000000000..15ebc3f59 --- /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); + }); + }); +} From bcfbe615eebf7ea0f00437bd936998a8ef4ee73f Mon Sep 17 00:00:00 2001 From: Vitaly Iegorov Date: Tue, 8 Sep 2026 01:02:40 +0200 Subject: [PATCH 16/29] fix(mobile): show the provider account badge on thread rows (#9899) (cherry picked from commit 2c8e95a4b641898322c7312cb67535612606745e) --- apps/mobile/src/components/ProviderIcon.tsx | 58 ++++++++++ apps/mobile/src/features/home/HomeScreen.tsx | 11 +- .../threads/ThreadNavigationSidebar.tsx | 11 +- .../features/threads/thread-list-v2-items.tsx | 27 ++++- .../threads/thread-provider-instance.test.ts | 98 ++++++++++++++++ .../threads/thread-provider-instance.ts | 42 +++++++ .../components/chat/ProviderInstanceIcon.tsx | 11 +- apps/web/src/providerInstances.ts | 98 ++-------------- packages/client-runtime/package.json | 4 + .../src/state/providerInstanceDisplay.test.ts | 108 ++++++++++++++++++ .../src/state/providerInstanceDisplay.ts | 88 ++++++++++++++ 11 files changed, 432 insertions(+), 124 deletions(-) create mode 100644 apps/mobile/src/features/threads/thread-provider-instance.test.ts create mode 100644 apps/mobile/src/features/threads/thread-provider-instance.ts create mode 100644 packages/client-runtime/src/state/providerInstanceDisplay.test.ts create mode 100644 packages/client-runtime/src/state/providerInstanceDisplay.ts diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index 7bee96973..8959b1bc4 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/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 4b20d0562..0c9903fbf 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -51,6 +51,7 @@ import { ThreadListV2SettledShelfHeader, ThreadListV2SnoozedShelfHeader, } from "../threads/thread-list-v2-items"; +import { resolveThreadProviderInstance } from "../threads/thread-provider-instance"; import { buildThreadListV2Items, getThreadListV2OrderedSection, @@ -848,15 +849,7 @@ export function HomeScreen(props: HomeScreenProps) { projectTitle={v2ProjectTitleByProjectKey.get( scopedProjectKey(thread.environmentId, thread.projectId), )} - providerDriver={ - serverConfigs - .get(thread.environmentId) - ?.providers.find( - (provider) => - 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) diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 3ec0b4634..6290b1d48 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -78,6 +78,7 @@ import { ThreadListV2SettledShelfHeader, ThreadListV2SnoozedShelfHeader, } from "./thread-list-v2-items"; +import { resolveThreadProviderInstance } from "./thread-provider-instance"; import { buildThreadListV2Items, getThreadListV2OrderedSection, @@ -910,15 +911,7 @@ function ThreadNavigationSidebarPane( snoozeWakeLabelText={item.snoozeWakeLabelText} project={projectByKey.get(scopeKey) ?? null} projectTitle={projectTitleByProjectKey.get(scopeKey)} - providerDriver={ - serverConfigs - .get(thread.environmentId) - ?.providers.find( - (provider) => - 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) diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index ded07c40a..4805fd01a 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -16,7 +16,8 @@ import { AppText as Text } from "../../components/AppText"; import { ControlPillMenu } from "../../components/ControlPill"; import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; import { ProjectFavicon } from "../../components/ProjectFavicon"; -import { ProviderIcon } from "../../components/ProviderIcon"; +import { ProviderInstanceIcon } from "../../components/ProviderIcon"; +import type { ThreadRowProviderInstance } from "./thread-provider-instance"; import { cn } from "../../lib/cn"; import { relativeTime } from "../../lib/time"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; @@ -358,7 +359,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly snoozePresetMinute: string; readonly project: EnvironmentProject | null; readonly projectTitle?: string; - readonly providerDriver: string | null; + readonly providerInstance: ThreadRowProviderInstance | null; /** Which machine hosts the thread. Null when only one environment is connected — repeating the same label on every row is noise. Mirrors the web sidebar's remote-environment cloud icon, but as text since @@ -445,6 +446,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]; @@ -836,10 +846,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { #{pr.label} ) : null} - {props.providerDriver ? ( - - - + {props.providerInstance ? ( + ) : null} 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 000000000..2afef9759 --- /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 000000000..29dbe8828 --- /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/web/src/components/chat/ProviderInstanceIcon.tsx b/apps/web/src/components/chat/ProviderInstanceIcon.tsx index 53c66667f..4a40ed15b 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 f24dfd643..c9af0c11d 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/packages/client-runtime/package.json b/packages/client-runtime/package.json index 09f09d8a7..180a30b35 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 000000000..732cd8c05 --- /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 000000000..6d7a0629b --- /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; +} From fc287a42855be0905b5efbf38c3435bec6eb4063 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 8 Sep 2026 16:39:49 -0700 Subject: [PATCH 17/29] fix(mobile): tolerate native Headers without getSetCookie (#10851) Co-authored-by: Ryan Hughes (cherry picked from commit 3e6f856f2359421958a3aa046f2c393e00f3dc6a) --- apps/mobile/src/lib/http-response.test.ts | 23 ++ patches/effect@4.0.0-beta.103.patch | 16 ++ pnpm-lock.yaml | 248 +++++++++++----------- 3 files changed, 163 insertions(+), 124 deletions(-) create mode 100644 apps/mobile/src/lib/http-response.test.ts 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 000000000..8b170cd90 --- /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/patches/effect@4.0.0-beta.103.patch b/patches/effect@4.0.0-beta.103.patch index a46ccf9c9..2b2aa6528 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 4f148ad91..9c5d2e3e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -95,7 +95,7 @@ patchedDependencies: '@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 @@ -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 @@ -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: @@ -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 From 2ed4bde21d823057c4dd36dcf318223142952452 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 8 Sep 2026 20:34:58 -0700 Subject: [PATCH 18/29] feat(mobile): arrange threads with drag handles (#10496) (cherry picked from commit 2a303535305d7d224a750fdee78c66e2a52ab3ab) --- apps/mobile/src/App.tsx | 2 + apps/mobile/src/components/AppSymbol.tsx | 2 + apps/mobile/src/features/home/HomeScreen.tsx | 5 +- .../src/features/home/useThreadListActions.ts | 124 +++- .../threads/ThreadArrangementSheet.tsx | 564 ++++++++++++++++++ .../features/threads/thread-list-v2-items.tsx | 18 +- .../features/threads/threadDragGap.test.ts | 48 ++ .../src/features/threads/threadDragGap.ts | 13 + .../src/features/threads/threadListV2.test.ts | 201 +++++++ .../src/features/threads/threadOrder.ts | 87 ++- apps/mobile/src/state/thread-order.ts | 5 + docs/user/thread-sidebar.md | 6 +- 12 files changed, 1037 insertions(+), 38 deletions(-) create mode 100644 apps/mobile/src/features/threads/ThreadArrangementSheet.tsx create mode 100644 apps/mobile/src/features/threads/threadDragGap.test.ts create mode 100644 apps/mobile/src/features/threads/threadDragGap.ts diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index eddce11b5..8095205a5 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 d34137e9a..5af847f47 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -53,6 +53,7 @@ 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"; @@ -139,6 +140,7 @@ 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, diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 0c9903fbf..381c35b58 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, @@ -120,7 +121,7 @@ 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; @@ -524,7 +525,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], diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 2c08f2647..72e45c9bb 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -1,5 +1,6 @@ +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"; @@ -15,8 +16,16 @@ 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 @@ -229,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; } { @@ -474,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; @@ -491,7 +520,6 @@ export function useThreadListActions(): { ); return false; } - const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom); const ordered = getThreadListV2OrderedSection({ threads: shells, section, @@ -515,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({ @@ -552,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/threads/ThreadArrangementSheet.tsx b/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx new file mode 100644 index 000000000..5c9eb7e54 --- /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/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 4805fd01a..0b1c2e135 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -1,3 +1,6 @@ +import { appAtomRegistry } from "../../state/atom-registry"; +import { threadArrangementOpenAtom } from "../../state/thread-order"; +import type { ThreadMoveDestination } from "./threadOrder"; import type { EnvironmentProject, EnvironmentThreadShell, @@ -403,7 +406,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; @@ -526,8 +532,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", @@ -594,11 +601,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]!], @@ -621,6 +630,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { if (nativeEvent.event === "unsnooze") handleUnsnooze(); if (nativeEvent.event === "pin") handlePin(); if (nativeEvent.event === "unpin") handleUnpin(); + if (nativeEvent.event === "arrange") appAtomRegistry.set(threadArrangementOpenAtom, true); if (nativeEvent.event === "move-up") handleMoveUp(); if (nativeEvent.event === "move-down") handleMoveDown(); if (nativeEvent.event === "archive") handleArchive(); diff --git a/apps/mobile/src/features/threads/threadDragGap.test.ts b/apps/mobile/src/features/threads/threadDragGap.test.ts new file mode 100644 index 000000000..32b41ab03 --- /dev/null +++ b/apps/mobile/src/features/threads/threadDragGap.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vite-plus/test"; +import { threadDragAction, threadOrderAfterMove } from "./threadOrder"; +import { threadDragGapOffset } from "./threadDragGap"; + +describe("live thread insertion gap", () => { + // 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 000000000..eb65d5911 --- /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 06ffdeade..0ea460a63 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"; @@ -1383,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/threadOrder.ts b/apps/mobile/src/features/threads/threadOrder.ts index 2b722e694..c71c1040b 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/state/thread-order.ts b/apps/mobile/src/state/thread-order.ts index 1dad42de6..f83252e9c 100644 --- a/apps/mobile/src/state/thread-order.ts +++ b/apps/mobile/src/state/thread-order.ts @@ -12,6 +12,11 @@ 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, ); diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index c5b0c14d6..6ae1d8847 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -77,7 +77,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 From 3144ea1fc252c70de53f223df66c3114f6d82810 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 10 Sep 2026 18:27:51 -0600 Subject: [PATCH 19/29] docs: describe mobile drafts, outbox rows, and new-task handoff Documents the behavior adopted from the mobile-drafts lane in user docs: Unsent drafts and held tasks in the mobile thread list, the outbox icon, New thread on branch, account badges, the new-task setup pill and failure card, pending messages in the conversation, sending while uploads run on mobile, and Codex feedback notices. --- docs/user/composer.md | 6 ++++-- docs/user/mobile-thread-status.md | 4 ++++ docs/user/providers-codex.md | 6 ++++-- docs/user/thread-sidebar.md | 19 +++++++++++++++++++ 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/docs/user/composer.md b/docs/user/composer.md index fb370e926..e2487e9c8 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 fdce9a833..907837b36 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. A task Pylon holds back offers the same action for its held copy. + +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 6536bd037..d6ad105f6 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 6ae1d8847..f71058cf7 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -59,6 +59,25 @@ 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 task queued while its environment is +offline reads **Sends on reconnect**, and a task Pylon held back reads **Held** until you edit or +retarget 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 From 7cfc7b8185ad8477adde6396729f3ca688d754f9 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 10 Sep 2026 18:29:27 -0600 Subject: [PATCH 20/29] test(mobile): keep a pending message's provider choice explicit on edit Pylon marks restored provider picks as explicit so an existing thread's composer does not re-seed them from thread defaults. Editing a pending message from the timeline follows the held-send restore path. --- .../state/edit-pending-thread-message.test.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/state/edit-pending-thread-message.test.ts b/apps/mobile/src/state/edit-pending-thread-message.test.ts index c9c8b4f6a..87b73e009 100644 --- a/apps/mobile/src/state/edit-pending-thread-message.test.ts +++ b/apps/mobile/src/state/edit-pending-thread-message.test.ts @@ -1,5 +1,11 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { CommandId, EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; +import { + CommandId, + EnvironmentId, + MessageId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; import type { QueuedThreadMessage } from "./thread-outbox-model"; const state = vi.hoisted(() => ({ @@ -9,6 +15,7 @@ const state = vi.hoisted(() => ({ 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: { @@ -39,7 +46,7 @@ vi.mock("./use-composer-drafts", () => ({ attachments: [...state.draft.attachments, ...message.attachments], }; }, - updateComposerDraftSettings: () => {}, + updateComposerDraftSettings: state.settings, flushComposerDrafts: state.flush, undoComposerDraftMerge: async (_key: string, snapshot: typeof state.draft) => { state.draft = snapshot; @@ -89,6 +96,14 @@ describe("editing a pending message", () => { 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); From e7b554fd731f16ff35538ddefc9fa3cbe57eb396 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 10 Sep 2026 18:30:06 -0600 Subject: [PATCH 21/29] docs(upstream): record the mobile drafts lane decisions --- .agents/upstream-review.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.agents/upstream-review.md b/.agents/upstream-review.md index 0c4194b88..c8d009490 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 [#PRNUM](https://github.com/pylon-code/pylon/pull/PRNUM), from #10260 through #10496 | 18 adopted; `357b8d5217cd62826767b6ce6d7c47915f2777d2` already covered: mobile Working already uses sky under Pylon's documented status vocabulary. 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, 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 #PRNUM](https://github.com/pylon-code/pylon/pull/PRNUM); 1,276 focused tests, four package typechecks, scoped lint, format and export checks; no local client pass. | ## Deferred register From 57d035288a5388a197bf5521a8af86fb1c3f9c47 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 10 Sep 2026 18:33:02 -0600 Subject: [PATCH 22/29] docs(upstream): link the mobile drafts lane to #460 --- .agents/upstream-review.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/upstream-review.md b/.agents/upstream-review.md index c8d009490..b9d639363 100644 --- a/.agents/upstream-review.md +++ b/.agents/upstream-review.md @@ -55,7 +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 [#PRNUM](https://github.com/pylon-code/pylon/pull/PRNUM), from #10260 through #10496 | 18 adopted; `357b8d5217cd62826767b6ce6d7c47915f2777d2` already covered: mobile Working already uses sky under Pylon's documented status vocabulary. 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, 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 #PRNUM](https://github.com/pylon-code/pylon/pull/PRNUM); 1,276 focused tests, four package typechecks, scoped lint, format and export checks; 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 | 18 adopted; `357b8d5217cd62826767b6ce6d7c47915f2777d2` already covered: mobile Working already uses sky under Pylon's documented status vocabulary. 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, 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,276 focused tests, four package typechecks, scoped lint, format and export checks; no local client pass. | ## Deferred register From 681c53ad2834825d13a84a50c217a3e4a85ebe4e Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 10 Sep 2026 19:21:35 -0600 Subject: [PATCH 23/29] fix(mobile): delete a finished new-task draft entirely Clearing a new-task draft on send or Discard kept its runtime and interaction modes, so every started or discarded task left a project-less entry on disk under an id nothing reuses. Clearing a new-task key now removes the whole entry; thread drafts keep their mode choices as before. --- .../src/state/use-composer-drafts.test.ts | 25 +++++++++++++++++++ apps/mobile/src/state/use-composer-drafts.ts | 10 ++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index ae6c6137b..7e08a621d 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -182,6 +182,7 @@ import { stickyComposerModelSelectionAtom, undoComposerDraftMerge, undoComposerDraftMergeState, + updateComposerDraftSettings, } from "./use-composer-drafts"; const DRAFT: ComposerDraft = { @@ -1248,6 +1249,30 @@ describe("mobile composer drafts", () => { 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"), diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 47316e5f9..8559f2d79 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -1122,8 +1122,14 @@ export function clearComposerDraftContentState( return current; } // Clearing content is the "this draft is done" moment (sent, queued, or - // discarded), so the project stamp goes too and an otherwise-empty new-task - // draft leaves the store rather than lingering as a blank row. + // 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, From 1e039188aa4fa3837288b33082c7b8d3997eb2f7 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 10 Sep 2026 19:21:45 -0600 Subject: [PATCH 24/29] fix(mobile): carry setup edits into a held task's editor Edit task on a held new task opened the pending-task editor but left anything typed on the thread screen during setup under a thread id the server never created. Recovery now seeds the editor draft from the queued message, merges the thread composer's text and attachments after it, persists, and clears the thread draft, as a rejected creation already does. The editor's hydration is shared so both paths fill it identically. Also corrects the handler comment and user doc: the editor edits or retargets a held task, and deleting stays in the thread list's menu. --- .../features/threads/ThreadRouteScreen.tsx | 22 +++++- .../threads/new-task-flow-provider.tsx | 26 +------ apps/mobile/src/state/new-task-draft-key.ts | 5 ++ .../src/state/recover-failed-thread-draft.ts | 61 +++++++++++++-- .../src/state/use-composer-drafts.test.ts | 77 +++++++++++++++++++ docs/user/mobile-thread-status.md | 2 +- 6 files changed, 159 insertions(+), 34 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 6a0a82d80..494215bc6 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -35,7 +35,10 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useWorkspaceState } from "../../state/workspace"; import { restoredNewTaskDraftKey } from "../../state/new-task-draft-key"; import { clearPendingThreadCreationOutcome } from "../../state/pending-thread-creation"; -import { recoverFailedThreadDraft } from "../../state/recover-failed-thread-draft"; +import { + recoverFailedThreadDraft, + recoverHeldCreationDraft, +} from "../../state/recover-failed-thread-draft"; import { useEnvironmentQuery } from "../../state/query"; import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state"; import { vcsEnvironment } from "../../state/vcs"; @@ -964,13 +967,24 @@ function ThreadRouteContent( ); }, [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 - // offers the retarget, edit and delete actions. - const handleEditHeldCreation = useCallback(() => { + // 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", diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 4412f4400..c23aa8d31 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -51,7 +51,6 @@ import { composerDraftsAtom, createNewTaskDraft, getComposerDraftSnapshot, - isComposerDraftEmpty, isNewTaskDraftKey, removeComposerDraftAttachment, replaceComposerDraftAttachments, @@ -63,10 +62,12 @@ import { useComposerDraft, useStickyComposerModelSelection, } from "../../state/use-composer-drafts"; +import { pendingTaskDraftKey } from "../../state/new-task-draft-key"; import { capturePendingTaskEditorWriteBaseline, flushPendingTaskEditorWrite, } from "../../state/pending-task-editor-writes"; +import { hydratePendingTaskEditorDraft } from "../../state/recover-failed-thread-draft"; import { useDebouncedValue, usePaginatedBranches } from "../../state/queries"; import { vcsEnvironment } from "../../state/vcs"; import { @@ -105,10 +106,6 @@ type WorkspaceMode = "local" | "worktree"; const BRANCH_SEARCH_DEBOUNCE_MS = 150; -function pendingTaskDraftKey(messageId: string): string { - return `pending-task:${messageId}`; -} - // The message id owned by the currently active editing session, tracked // across provider instances. An in-flight flush from a dismissed session // consults it so it never drops the draft or releases the drain lock out from @@ -939,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; diff --git a/apps/mobile/src/state/new-task-draft-key.ts b/apps/mobile/src/state/new-task-draft-key.ts index 8ea11faaf..c425bf95f 100644 --- a/apps/mobile/src/state/new-task-draft-key.ts +++ b/apps/mobile/src/state/new-task-draft-key.ts @@ -9,6 +9,11 @@ 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 diff --git a/apps/mobile/src/state/recover-failed-thread-draft.ts b/apps/mobile/src/state/recover-failed-thread-draft.ts index 2eb172921..4463a9802 100644 --- a/apps/mobile/src/state/recover-failed-thread-draft.ts +++ b/apps/mobile/src/state/recover-failed-thread-draft.ts @@ -1,21 +1,47 @@ import type { QueuedThreadMessage } from "./thread-outbox-model"; import { scopedThreadKey } from "../lib/scopedEntities"; -import { restoredNewTaskDraftKey } from "./new-task-draft-key"; +import { pendingTaskDraftKey, restoredNewTaskDraftKey } from "./new-task-draft-key"; import { appendComposerDraftAttachments, clearComposerDraftContent, flushComposerDrafts, getComposerDraftSnapshot, + isComposerDraftEmpty, mergeComposerDraftContent, + replaceComposerDraftAttachments, + setComposerDraftText, + updateComposerDraftSettings, } from "./use-composer-drafts"; -/** Move unsent setup edits into the restored task before reopening its editor. */ -export async function recoverFailedThreadDraft(message: QueuedThreadMessage): Promise { +/** + * 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 targetKey = restoredNewTaskDraftKey(message.messageId); const source = getComposerDraftSnapshot(sourceKey); - if (source.text.length === 0 && source.attachments.length === 0) return; - await mergeComposerDraftContent(targetKey, { text: source.text, attachments: [] }); const existingIds = new Set( getComposerDraftSnapshot(targetKey).attachments.map((attachment) => attachment.id), @@ -30,3 +56,26 @@ export async function recoverFailedThreadDraft(message: QueuedThreadMessage): Pr 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/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index 7e08a621d..1463a4872 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -148,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, @@ -1960,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/docs/user/mobile-thread-status.md b/docs/user/mobile-thread-status.md index 907837b36..02b55863e 100644 --- a/docs/user/mobile-thread-status.md +++ b/docs/user/mobile-thread-status.md @@ -8,6 +8,6 @@ A working timer is hidden while the agent waits for approval, an answer, or anot 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. A task Pylon holds back offers the same action for its held copy. +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. From 9c9c90db8261dafcdbbe8790987c53c2c6010b1b Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 10 Sep 2026 19:22:00 -0600 Subject: [PATCH 25/29] fix(mobile): keep the unsent list cheap and its labels truthful MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The thread list read the whole composer draft store, which changes on every keystroke in any composer, so typing a reply rebuilt and re-sorted the Home list and iPad sidebar. A derived atom now selects only stamped new-task drafts with content and keeps its previous value when those entries are unchanged, so unrelated edits no longer notify the lists. Queued tasks said Sends on reconnect even while connected. The label now follows this device's view of delivery: Sends on reconnect while disconnected, Waiting for upload while files still need uploading, Sending… otherwise, and Pylon's Held for held tasks, in both list layouts and their accessibility hints. --- apps/mobile/src/features/home/HomeScreen.tsx | 25 +++ .../threads/ThreadNavigationSidebar.tsx | 26 +++- .../features/threads/thread-list-items.tsx | 20 ++- .../features/threads/thread-list-v2-items.tsx | 24 +-- .../src/state/pending-new-tasks-model.test.ts | 145 +++++++++++++++++- .../src/state/pending-new-tasks-model.ts | 84 +++++++++- .../mobile/src/state/use-pending-new-tasks.ts | 16 +- docs/user/thread-sidebar.md | 7 +- 8 files changed, 322 insertions(+), 25 deletions(-) diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 381c35b58..774692ae7 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -38,6 +38,7 @@ 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 { @@ -584,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) { @@ -794,6 +815,7 @@ export function HomeScreen(props: HomeScreenProps) { return ( + 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) { @@ -877,6 +898,7 @@ function ThreadNavigationSidebarPane( return ( Boolean(part)); @@ -322,7 +332,7 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { ) : ( - {pendingTask.message.deliveryHold ? "Held" : "Pending"} + {delivery === "held" ? "Held" : "Pending"} ); @@ -358,9 +368,7 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { const accessibilityHint = isDraft ? "Opens the draft in the new task composer" - : pendingTask.message.deliveryHold - ? "Held until retargeted. Opens the task for editing" - : "Sends when the environment reconnects. Opens the task for editing"; + : deliveryPresentation.accessibilityHint; const rowContent = compact ? ( { @@ -261,9 +271,7 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props Draft ) : ( - - {pendingTask.message.deliveryHold ? "Held" : "Sends on reconnect"} - + {deliveryPresentation.label} )} {/* One line, unlike the two an active row allows: a queued title is @@ -311,9 +319,7 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props accessibilityHint={ isDraft ? "Opens the draft in the new task composer" - : pendingTask.message.deliveryHold - ? "Held until retargeted. Opens the task for editing" - : "Sends when the environment reconnects. Opens the task for editing" + : deliveryPresentation.accessibilityHint } accessibilityLabel={pendingTask.title} accessibilityRole="button" diff --git a/apps/mobile/src/state/pending-new-tasks-model.test.ts b/apps/mobile/src/state/pending-new-tasks-model.test.ts index 0bb61ed2c..98a20c938 100644 --- a/apps/mobile/src/state/pending-new-tasks-model.test.ts +++ b/apps/mobile/src/state/pending-new-tasks-model.test.ts @@ -1,9 +1,22 @@ import { describe, expect, it } from "@effect/vitest"; -import { CommandId, EnvironmentId, MessageId, ProjectId, ThreadId } from "@t3tools/contracts"; +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 } from "./pending-new-tasks-model"; +import { + buildPendingNewTasks, + makeListedNewTaskDraftsAtom, + resolvePendingTaskDelivery, + selectListedNewTaskDrafts, +} from "./pending-new-tasks-model"; const environmentId = EnvironmentId.make("env-1"); const projectId = ProjectId.make("project-1"); @@ -118,3 +131,131 @@ describe("buildPendingNewTasks", () => { 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 index b66c5273b..d16ba3dac 100644 --- a/apps/mobile/src/state/pending-new-tasks-model.ts +++ b/apps/mobile/src/state/pending-new-tasks-model.ts @@ -1,5 +1,8 @@ -import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; +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"; @@ -50,6 +53,83 @@ 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); @@ -82,7 +162,7 @@ export function buildPendingNewTasks(input: { }); } for (const [draftKey, draft] of Object.entries(input.drafts)) { - if (!isNewTaskDraftKey(draftKey) || !draft.project || !composerDraftHasUserContent(draft)) { + if (!isListedNewTaskDraft(draftKey, draft) || !draft.project) { continue; } tasks.push({ diff --git a/apps/mobile/src/state/use-pending-new-tasks.ts b/apps/mobile/src/state/use-pending-new-tasks.ts index bf5f0191b..2ec9c5db8 100644 --- a/apps/mobile/src/state/use-pending-new-tasks.ts +++ b/apps/mobile/src/state/use-pending-new-tasks.ts @@ -1,11 +1,23 @@ import { useAtomValue } from "@effect/atom-react"; +import { Atom } from "effect/unstable/reactivity"; import { useMemo } from "react"; -import { buildPendingNewTasks, type PendingNewTask } from "./pending-new-tasks-model"; +import { + 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"; +// 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, @@ -14,7 +26,7 @@ export type { export function usePendingNewTasks(): ReadonlyArray { const queuedMessagesByThreadKey = useThreadOutboxMessages(); - const drafts = useAtomValue(composerDraftsAtom); + const drafts = useAtomValue(listedNewTaskDraftsAtom); return useMemo( () => buildPendingNewTasks({ diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index f71058cf7..9a55de68e 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -64,9 +64,10 @@ context menu when composing a new thread. 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 task queued while its environment is -offline reads **Sends on reconnect**, and a task Pylon held back reads **Held** until you edit or -retarget it. +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. From 54df7c2f05f51c2f2bc1ad40132a58d6e4e9ae0a Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 10 Sep 2026 19:22:01 -0600 Subject: [PATCH 26/29] fix(mobile): draw the pending message pencil on Android The Edit pending message button uses the pencil SF Symbol, which had no Android mapping, leaving a blank tap target. --- apps/mobile/src/components/AppSymbol.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index 5af847f47..491493c0e 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -60,6 +60,7 @@ 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"; @@ -146,6 +147,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { "line.3.horizontal.decrease.circle.fill": IconFilterFilled, magnifyingglass: IconSearch, paintbrush: IconPalette, + pencil: IconPencil, "person.crop.circle": IconUserCircle, photo: IconPhoto, pin: IconPin, From fe4b432a2ff3294855066bdbbbd9d04efdc86bc0 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 10 Sep 2026 19:22:02 -0600 Subject: [PATCH 27/29] fix(mobile): stop held work from holding thread screen state A held follow-up waits for the user, not delivery, yet it reserved the floating status pill's space indefinitely, leaving an empty gap above the composer. Held messages no longer count toward that reservation. A held creation never delivers or fails on its own, so once it leaves the outbox (deleted or retargeted, for example from the iPad sidebar) the thread screen no longer keeps showing its Could not start task card. --- .../features/threads/ThreadDetailScreen.tsx | 4 ++- .../src/state/pending-thread-creation.test.ts | 31 +++++++++++++++++++ .../src/state/pending-thread-creation.ts | 9 +++++- 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index c9d4a6084..fc3463571 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -526,10 +526,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread 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.length > 0 || + props.queuedMessages.some((message) => message.deliveryHold === undefined) || props.selectedThreadFeed.some( (entry) => "acknowledged" in entry && entry.acknowledged === true, ); diff --git a/apps/mobile/src/state/pending-thread-creation.test.ts b/apps/mobile/src/state/pending-thread-creation.test.ts index 772b7cc91..6588339bb 100644 --- a/apps/mobile/src/state/pending-thread-creation.test.ts +++ b/apps/mobile/src/state/pending-thread-creation.test.ts @@ -99,6 +99,37 @@ describe("resolvePendingThreadCreation", () => { ).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({ diff --git a/apps/mobile/src/state/pending-thread-creation.ts b/apps/mobile/src/state/pending-thread-creation.ts index 100391cbf..bacfd6b40 100644 --- a/apps/mobile/src/state/pending-thread-creation.ts +++ b/apps/mobile/src/state/pending-thread-creation.ts @@ -36,7 +36,14 @@ export function resolvePendingThreadCreation(input: { readonly session: { readonly status: string } | null; } | null; }): PendingThreadCreation | null { - const creation = input.pending ?? input.previous; + // 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 From c194208797449a3658e01c8c0705f2703c1643b7 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 10 Sep 2026 19:22:04 -0600 Subject: [PATCH 28/29] fix(mobile): use upstream's Working shade on the legacy list pill Styling follows upstream; the sky meaning for active work is unchanged. Adopted from 357b8d5217cd62826767b6ce6d7c47915f2777d2 --- .../threads/threadPresentation.test.ts | 43 ++++++++++++------- .../features/threads/threadPresentation.ts | 4 +- 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/apps/mobile/src/features/threads/threadPresentation.test.ts b/apps/mobile/src/features/threads/threadPresentation.test.ts index cb8044440..eaa70b727 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 16467ef5d..cbfd8c644 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, From ccd94cb51e6eda95872c501831f9ceb9294d7356 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 10 Sep 2026 19:23:00 -0600 Subject: [PATCH 29/29] docs(upstream): update the mobile drafts ledger row after review --- .agents/upstream-review.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/upstream-review.md b/.agents/upstream-review.md index b9d639363..81437b6fb 100644 --- a/.agents/upstream-review.md +++ b/.agents/upstream-review.md @@ -55,7 +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 | 18 adopted; `357b8d5217cd62826767b6ce6d7c47915f2777d2` already covered: mobile Working already uses sky under Pylon's documented status vocabulary. 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, 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,276 focused tests, four package typechecks, scoped lint, format and export checks; 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