Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 30 additions & 14 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
85 changes: 69 additions & 16 deletions apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, EnvironmentProject>();
for (const project of projects) {
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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;
Expand All @@ -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) =>
Expand Down Expand Up @@ -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",
Expand Down
32 changes: 23 additions & 9 deletions apps/mobile/src/features/threads/new-task-flow-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
43 changes: 26 additions & 17 deletions apps/mobile/src/state/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) => {
Expand Down
Loading