diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 293f6aceac63..f361322b936c 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -2091,22 +2091,38 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { reportHeaderMaterialVisibility(false); }, [feedThreadKey, reportHeaderMaterialVisibility]); - const presentedFeed = appendPendingThreadMessages( - deriveThreadFeedPresentation( - props.feed, - props.latestRun, - expandedTurnIds, - new Set( - Object.entries(expandedWorkGroups) - .filter(([, expanded]) => expanded) - .map(([groupId]) => groupId), + // The compiler can't memoize this: it stays live across later hook calls. A new array here + // reruns the whole feed derivation on every render and hands LegendList new data, which + // re-pins the list through maintainScrollAtEnd. + const presentedFeed = useMemo( + () => + appendPendingThreadMessages( + deriveThreadFeedPresentation( + props.feed, + props.latestRun, + expandedTurnIds, + new Set( + Object.entries(expandedWorkGroups) + .filter(([, expanded]) => expanded) + .map(([groupId]) => groupId), + ), + props.activeWorkStartedAt, + props.runlessWorkActive ?? false, + feedCache, + ), + props.feed, + props.queuedMessages, ), - props.activeWorkStartedAt, - props.runlessWorkActive ?? false, + [ + expandedTurnIds, + expandedWorkGroups, feedCache, - ), - props.feed, - props.queuedMessages, + props.activeWorkStartedAt, + props.feed, + props.latestRun, + props.queuedMessages, + props.runlessWorkActive, + ], ); const setupAnchorIndex = presentedFeed.findIndex( (entry) => entry.type === "message" && entry.message.role === "user", diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index e454e6ec91cb..3242759203f2 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -192,7 +192,14 @@ function ThreadNavigationSidebarPane( } return matches; }, [threadSearch.matches]); - const matchedThreadKeys = new Set(threadSearch.matches.map(threadSearchMatchKey)); + // listItems and the values it derives from: the compiler can't memoize them because they stay + // live across later hook calls. A new listItems hands LegendList new data and re-registers every + // thread jump shortcut, which re-renders the app-root keyboard command provider and resends the + // native command list. + const matchedThreadKeys = useMemo( + () => new Set(threadSearch.matches.map(threadSearchMatchKey)), + [threadSearch.matches], + ); const projectScopeKey = options.selectedProjectKey; const resolvedProjectKey = resolveScratchProjectScopeKey(projectScopeKey, projects, { sidebarProjectGroupingMode: options.projectGroupingMode, @@ -231,17 +238,22 @@ function ThreadNavigationSidebarPane( ), [projectScopes], ); - const selectedProjectScope = - projectScopes.find((scope) => scope.key === resolvedProjectKey) ?? null; + const selectedProjectScope = useMemo( + () => projectScopes.find((scope) => scope.key === resolvedProjectKey) ?? null, + [projectScopes, resolvedProjectKey], + ); const selectedProjectKey = selectedProjectScope?.key ?? null; - const selectedProjectRefs = - selectedProjectScope === null - ? null - : new Set( - selectedProjectScope.projectRefs.map((projectRef) => - scopedProjectKey(projectRef.environmentId, projectRef.projectId), + const selectedProjectRefs = useMemo( + () => + selectedProjectScope === null + ? null + : new Set( + selectedProjectScope.projectRefs.map((projectRef) => + scopedProjectKey(projectRef.environmentId, projectRef.projectId), + ), ), - ); + [selectedProjectScope], + ); const projectByKey = useMemo(() => { const map = new Map(); for (const project of projects) { @@ -299,7 +311,7 @@ function ThreadNavigationSidebarPane( // Up/down menu availability for every card, computed once per section per // rebuild (see computeThreadMoveAvailability): per-thread planner calls made // list construction quadratic, and this list rebuilds on every minute tick. - const threadMoveAvailability = (() => { + const threadMoveAvailability = useMemo(() => { const sectionAvailability = (section: "pinned" | "active") => computeThreadMoveAvailability({ allThreads: threads, @@ -321,8 +333,18 @@ function ThreadNavigationSidebarPane( ...sectionAvailability("pinned"), ...(workingShelfEnabled ? [] : sectionAvailability("active")), ]); - })(); - const threadListV2Layout = (() => { + }, [ + activeReorderEnvironmentIds, + now, + pendingOrder, + pinReorderEnvironmentIds, + queuedThreadKeys, + settlementEnvironmentIds, + snoozeEnvironmentIds, + threads, + workingShelfEnabled, + ]); + const threadListV2Layout = useMemo(() => { threadListInboxReturns.observe(workingShelfEnabled ? threads : null); return buildThreadListV2Items({ pendingOrder, @@ -343,7 +365,24 @@ function ThreadNavigationSidebarPane( settledShelfExpanded, selectedThreadKey: props.selectedThreadKey ?? null, }); - })(); + }, [ + matchedThreadKeys, + now, + options.selectedEnvironmentId, + pendingOrder, + props.searchQuery, + props.selectedThreadKey, + queuedThreadKeys, + selectedProjectScope, + settledShelfExpanded, + settledVisibleCount, + settlementEnvironmentIds, + snoozeEnvironmentIds, + snoozedShelfExpanded, + threads, + workingShelfEnabled, + workingShelfExpanded, + ]); const nextSnoozeWakeAt = threadListV2Layout.nextSnoozeWakeAt; useEffect(() => { if (nextSnoozeWakeAt === null) return; @@ -359,7 +398,7 @@ function ThreadNavigationSidebarPane( // unchanged: after a clamped fire (wake beyond the 32-bit setTimeout // range) the boundary string is identical and the chain would die. }, [nextSnoozeWakeAt, snoozeWakeTick]); - const listItems = ((): readonly SidebarListItem[] => { + const listItems = useMemo((): readonly SidebarListItem[] => { const v2SearchQuery = props.searchQuery.trim().toLocaleLowerCase(); const v2PendingTasks = pendingTasks.filter( (pendingTask) => @@ -398,7 +437,21 @@ function ThreadNavigationSidebarPane( }); } return items; - })(); + }, [ + nowMinute, + options.selectedEnvironmentId, + pendingTasks, + props.searchQuery, + queuedThreadKeys, + threadMoveAvailability, + selectedProjectRefs, + settledShelfExpanded, + shelfPreferencesLoaded, + snoozedShelfExpanded, + snoozeEnvironmentIds, + threadListV2Layout, + workingShelfExpanded, + ]); const listMenuActions = [ { id: "environment", 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 1da765bc775f..4844b8a0a8d1 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -2,7 +2,7 @@ import * as Dialog from "../../components/ui/Dialog"; import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem"; import { useEnvironmentPresentation } from "../../state/presentation"; import { environmentSession } from "../../state/session"; -import React, { useEffect, useRef, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import type { EnvironmentId, @@ -353,12 +353,18 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { isScratchProject(selectedProject, selectedEnvironmentServerConfig?.scratchWorkspaceRoot); const serverConfigs = useServerConfigs(); const { connectedEnvironments } = useRemoteConnectionStatus(); - const scratchEnvironments = connectedEnvironments.filter( - (environment) => - availableScratchWorkspaceRoot( - environment.connectionState, - serverConfigs.get(environment.environmentId), - ) !== null, + // The compiler can't memoize the scratch environments: they stay live across later hook + // calls. A new list here changes the new-task context value on every render in scratch drafts. + const scratchEnvironments = useMemo( + () => + connectedEnvironments.filter( + (environment) => + availableScratchWorkspaceRoot( + environment.connectionState, + serverConfigs.get(environment.environmentId), + ) !== null, + ), + [connectedEnvironments, serverConfigs], ); const selectedRepositoryKey = selectedProject?.repositoryIdentity @@ -368,7 +374,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { // workspaceRoot, and an "" basename would reject every real host below. const selectedWorkspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null; const selectedProjectTitle = selectedProject?.title ?? null; - const environments = (() => { + const environments = useMemo(() => { if (isScratchDraft) { return scratchEnvironments.map((environment) => ({ environmentId: environment.environmentId, @@ -413,7 +419,15 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { }); } return result; - })(); + }, [ + isScratchDraft, + projects, + savedConnectionsById, + scratchEnvironments, + selectedProjectTitle, + selectedRepositoryKey, + selectedWorkspaceBasename, + ]); const selectedProjectDraftKey = editingPendingTask ? pendingTaskDraftKey(editingPendingTask.messageId) diff --git a/apps/mobile/src/state/queries.ts b/apps/mobile/src/state/queries.ts index 2f64e6f164af..5122daf6b8f7 100644 --- a/apps/mobile/src/state/queries.ts +++ b/apps/mobile/src/state/queries.ts @@ -24,7 +24,7 @@ import * as Cause from "effect/Cause"; import { turnItemDetailRevision } from "@t3tools/client-runtime/work-log/item-detail"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { appAtomRegistry } from "./atom-registry"; import { orchestrationEnvironment } from "./orchestration"; @@ -216,22 +216,31 @@ export function usePaginatedBranches(target: VcsRefTarget) { cursors: INITIAL_BRANCH_CURSORS, }); const cursors = pagination.targetKey === targetKey ? pagination.cursors : INITIAL_BRANCH_CURSORS; - const pageAtoms = - target.environmentId !== null && target.cwd !== null - ? cursors.map((cursor) => - vcsEnvironment.listRefs({ - environmentId: target.environmentId!, - input: { - cwd: target.cwd!, - ...(query.length > 0 ? { query } : {}), - ...(cursor === undefined ? {} : { cursor }), - limit: VCS_REF_LIST_LIMIT, - }, - }), - ) - : []; - const pagesAtom = Atom.make((get) => pageAtoms.map((atom) => get(atom))).pipe( - Atom.withLabel(`mobile:vcs-ref-pages:${targetKey ?? "empty"}`), + // The compiler can't memoize these: they stay live across the useAtomValue call. A new + // pagesAtom on every render makes useAtomValue resubscribe and render the caller twice. + const pageAtoms = useMemo( + () => + target.environmentId !== null && target.cwd !== null + ? cursors.map((cursor) => + vcsEnvironment.listRefs({ + environmentId: target.environmentId!, + input: { + cwd: target.cwd!, + ...(query.length > 0 ? { query } : {}), + ...(cursor === undefined ? {} : { cursor }), + limit: VCS_REF_LIST_LIMIT, + }, + }), + ) + : [], + [cursors, query, target.cwd, target.environmentId], + ); + const pagesAtom = useMemo( + () => + Atom.make((get) => pageAtoms.map((atom) => get(atom))).pipe( + Atom.withLabel(`mobile:vcs-ref-pages:${targetKey ?? "empty"}`), + ), + [pageAtoms, targetKey], ); const results = useAtomValue(pagesAtom); const values = results.flatMap((result) => {