1import type { OlderSettledScope } from "@t3tools/client-runtime/state/threads";
2import type { EnvironmentId } from "@t3tools/contracts";
3import { useMemo, useSyncExternalStore } from "react";
4
5import { scopedProjectKey } from "../../lib/scopedEntities";
6import { useOlderSettledShelf, useOlderSettledShelfGrowth } from "../../state/olderSettledShelf";
7import type { PendingNewTask } from "../../state/use-pending-new-tasks";
8import {
9 buildThreadListV2Items,
10 buildThreadListV2ListItems,
11 getThreadListV2OrderedSection,
12 threadListInboxReturns,
13 threadListV2LayoutTime,
14} from "./threadListV2";
15import { computeThreadMoveAvailability } from "./threadOrder";
16
17/**
18 * Screens' select handlers read the content-only key set; a fresh one per list rebuild gave
19 * every row a new handler whenever any thread shell changed. Keyed by a string, the set keeps
20 * its identity until its keys change.
21 */
22function useKeySet(newlineSeparatedKeys: string): ReadonlySet<string> {
23 return useMemo(
24 () => new Set(newlineSeparatedKeys.length === 0 ? [] : newlineSeparatedKeys.split("\n")),
25 [newlineSeparatedKeys],
26 );
27}
28
29type LayoutInput = Parameters<typeof buildThreadListV2Items>[0];
30
31/**
32 * The React Compiler drops unread dependencies, so pass the expired-grace version through
33 * this call to keep inbox updates tied to its subscription.
34 */
35function observeInbox(
36 threads: Parameters<typeof threadListInboxReturns.observe>[0],
37 _expiredGraceVersion: number,
38) {
39 return threadListInboxReturns.observe(threads);
40}
41
42/**
43 * Shelf callbacks prevent compiler caching across the whole derivation, so each stage keeps
44 * its own memo boundary to preserve the list's identity during unrelated navigation renders.
45 */
46export function useThreadListV2(input: {
47 readonly threads: LayoutInput["threads"];
48 readonly pendingTasks: ReadonlyArray<PendingNewTask>;
49 readonly environmentId: EnvironmentId | null;
50 readonly projectRefs: LayoutInput["projectRefs"];
51 readonly searchQuery: string;
52 /** Threads that match the search by content, keyed like `threadSearchMatchKey`. */
53 readonly searchMatches: ReadonlyMap<string, unknown>;
54 readonly settlementEnvironmentIds: ReadonlySet<EnvironmentId>;
55 readonly snoozeEnvironmentIds: ReadonlySet<EnvironmentId>;
56 readonly pinReorderEnvironmentIds: ReadonlySet<EnvironmentId>;
57 readonly activeReorderEnvironmentIds: ReadonlySet<EnvironmentId>;
58 readonly queuedThreadKeys: ReadonlySet<string>;
59 readonly settledLimit: number;
60 readonly now: string;
61 readonly pendingOrder: LayoutInput["pendingOrder"];
62 readonly shelfPreferencesLoaded: boolean;
63 readonly workingShelfEnabled: boolean;
64 readonly workingShelfExpanded: boolean;
65 readonly snoozedShelfExpanded: boolean;
66 readonly settledShelfExpanded: boolean;
67 readonly selectedThreadKey: string | null;
68}) {
69 const { threads, workingShelfEnabled } = input;
70 const layoutTime = useMemo(
71 () => threadListV2LayoutTime({ threads, now: input.now, searchQuery: input.searchQuery }),
72 [threads, input.now, input.searchQuery],
73 );
74 // A thread can stop working when the threads change, or when its exit grace expires.
75 const inboxVersion = useSyncExternalStore(
76 threadListInboxReturns.subscribe,
77 threadListInboxReturns.getSnapshot,
78 );
79 const inbox = useMemo(
80 () => observeInbox(workingShelfEnabled ? threads : null, inboxVersion),
81 [threads, workingShelfEnabled, inboxVersion],
82 );
83 // Compute move availability per section; per-row planning is quadratic.
84 const moveAvailability = useMemo(() => {
85 const sectionAvailability = (section: "pinned" | "active") =>
86 computeThreadMoveAvailability({
87 allThreads: threads,
88 section,
89 pendingOrder: input.pendingOrder,
90 reorderableEnvironmentIds:
91 section === "pinned" ? input.pinReorderEnvironmentIds : input.activeReorderEnvironmentIds,
92 ordered: getThreadListV2OrderedSection({
93 threads,
94 section,
95 pendingOrder: input.pendingOrder,
96 now: layoutTime,
97 settlementEnvironmentIds: input.settlementEnvironmentIds,
98 snoozeEnvironmentIds: input.snoozeEnvironmentIds,
99 queuedThreadKeys: input.queuedThreadKeys,
100 }),
101 });
102 return new Map([
103 ...sectionAvailability("pinned"),
104 ...(workingShelfEnabled ? [] : sectionAvailability("active")),
105 ]);
106 }, [
107 threads,
108 input.pendingOrder,
109 input.pinReorderEnvironmentIds,
110 input.activeReorderEnvironmentIds,
111 layoutTime,
112 input.settlementEnvironmentIds,
113 input.snoozeEnvironmentIds,
114 input.queuedThreadKeys,
115 workingShelfEnabled,
116 ]);
117 // Older settled threads load in pages; the shelf lists them only as far as they are loaded.
118 const scopes = useMemo(
119 () =>
120 olderSettledScopes({
121 environmentId: input.environmentId,
122 projectRefs: input.projectRefs,
123 settlementEnvironmentIds: input.settlementEnvironmentIds,
124 }),
125 [input.environmentId, input.projectRefs, input.settlementEnvironmentIds],
126 );
127 const olderSettledShelf = useOlderSettledShelf({
128 scopes,
129 expanded: input.settledShelfExpanded,
130 // A search lists only matches, so the shelf would page forever; loaded pages stay.
131 visibleCount: input.searchQuery.trim().length === 0 ? input.settledLimit : 0,
132 });
133 const { isListed, unloadedCount } = olderSettledShelf;
134 const layout = useMemo(
135 () =>
136 buildThreadListV2Items({
137 pendingOrder: input.pendingOrder,
138 threads: threads.filter((thread) => thread.archivedAt === null),
139 environmentId: input.environmentId,
140 projectRefs: input.projectRefs,
141 searchQuery: input.searchQuery,
142 matchedThreadKeys: new Set(input.searchMatches.keys()),
143 settlementEnvironmentIds: input.settlementEnvironmentIds,
144 snoozeEnvironmentIds: input.snoozeEnvironmentIds,
145 queuedThreadKeys: input.queuedThreadKeys,
146 settledLimit: input.settledLimit,
147 now: layoutTime,
148 workingShelfEnabled,
149 workingShelfExpanded: input.workingShelfExpanded,
150 inboxReturnAt: inbox.returnedAt,
151 isWorking: inbox.isWorking,
152 snoozedShelfExpanded: input.snoozedShelfExpanded,
153 settledShelfExpanded: input.settledShelfExpanded,
154 selectedThreadKey: input.selectedThreadKey,
155 isListedSettled: isListed,
156 unloadedSettledCount: unloadedCount(
157 threads.filter(
158 (thread) =>
159 thread.archivedAt === null &&
160 scopes.some(
161 (scope) =>
162 scope.environmentId === thread.environmentId &&
163 (scope.projectIds === null || scope.projectIds.includes(thread.projectId)),
164 ),
165 ),
166 ),
167 }),
168 [
169 input.pendingOrder,
170 threads,
171 input.environmentId,
172 input.projectRefs,
173 input.searchQuery,
174 input.searchMatches,
175 input.settlementEnvironmentIds,
176 input.snoozeEnvironmentIds,
177 input.queuedThreadKeys,
178 input.settledLimit,
179 layoutTime,
180 workingShelfEnabled,
181 input.workingShelfExpanded,
182 inbox.returnedAt,
183 inbox.isWorking,
184 input.snoozedShelfExpanded,
185 input.settledShelfExpanded,
186 input.selectedThreadKey,
187 isListed,
188 unloadedCount,
189 scopes,
190 ],
191 );
192 useOlderSettledShelfGrowth(olderSettledShelf, layout.listedSettledThreads);
193 const contentOnlyThreadKeys = useKeySet([...layout.contentOnlyThreadKeys].sort().join("\n"));
194 const pendingTasks = useMemo(() => {
195 const scopedProjectKeys =
196 input.projectRefs == null
197 ? null
198 : new Set(
199 input.projectRefs.map((ref) => scopedProjectKey(ref.environmentId, ref.projectId)),
200 );
201 const searchQuery = input.searchQuery.trim().toLocaleLowerCase();
202 return input.pendingTasks.filter(
203 (pendingTask) =>
204 (input.environmentId === null || pendingTask.environmentId === input.environmentId) &&
205 (scopedProjectKeys === null ||
206 scopedProjectKeys.has(
207 scopedProjectKey(pendingTask.environmentId, pendingTask.projectId),
208 )) &&
209 (searchQuery.length === 0 || pendingTask.title.toLocaleLowerCase().includes(searchQuery)),
210 );
211 }, [input.projectRefs, input.searchQuery, input.pendingTasks, input.environmentId]);
212 const items = useMemo(
213 () =>
214 buildThreadListV2ListItems({
215 items: layout.items,
216 pendingTasks,
217 workingCount: layout.workingCount,
218 workingShelfExpanded: input.workingShelfExpanded,
219 workingShelfHeaderIndex: layout.workingShelfHeaderIndex,
220 snoozedCount: layout.snoozedCount,
221 snoozedShelfExpanded: input.snoozedShelfExpanded,
222 snoozedShelfHeaderIndex: layout.snoozedShelfHeaderIndex,
223 settledCount: layout.settledCount,
224 settledShelfExpanded: input.settledShelfExpanded,
225 settledShelfHeaderIndex: layout.settledShelfHeaderIndex,
226 snoozeLabelNow: `${input.now.slice(0, 16)}:00.000Z`,
227 snoozeEnvironmentIds: input.snoozeEnvironmentIds,
228 queuedThreadKeys: input.queuedThreadKeys,
229 moveAvailability,
230 shelfPreferencesLoading: !input.shelfPreferencesLoaded,
231 }),
232 [
233 layout,
234 pendingTasks,
235 input.workingShelfExpanded,
236 input.snoozedShelfExpanded,
237 input.settledShelfExpanded,
238 input.now,
239 input.snoozeEnvironmentIds,
240 input.queuedThreadKeys,
241 moveAvailability,
242 input.shelfPreferencesLoaded,
243 ],
244 );
245 return { layout, items, contentOnlyThreadKeys, olderSettledShelf };
246}
247
248/** The list's scope per environment: its projects there, or every project. */
249function olderSettledScopes(input: {
250 readonly environmentId: EnvironmentId | null;
251 readonly projectRefs: LayoutInput["projectRefs"];
252 readonly settlementEnvironmentIds: ReadonlySet<EnvironmentId>;
253}): ReadonlyArray<OlderSettledScope> {
254 const environmentIds =
255 input.environmentId === null ? [...input.settlementEnvironmentIds] : [input.environmentId];
256 return environmentIds.flatMap((environmentId): ReadonlyArray<OlderSettledScope> => {
257 if (input.projectRefs == null) return [{ environmentId, projectIds: null }];
258 const projectIds = input.projectRefs.flatMap((ref) =>
259 ref.environmentId === environmentId ? [ref.projectId] : [],
260 );
261 return projectIds.length === 0 ? [] : [{ environmentId, projectIds }];
262 });
263}