akanjs 3.0.0-alpha.96 → 3.0.0-alpha.98

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -78,6 +78,8 @@ export class SliceInitHandle {
78
78
  [`pageOf${capRefName}`]: page,
79
79
  [`lastPageOf${capRefName}`]: lastPage,
80
80
  [`limitOf${capRefName}`]: limit,
81
+
82
+ [`hasMoreOf${capRefName}`]: !!limit && modelObjList.length >= limit,
81
83
  [`queryArgsOf${capRefName}`]: queryArgs,
82
84
  [`sortOf${capRefName}`]: sort,
83
85
  [`${refName}InitAt`]: new Date(),
@@ -43,6 +43,8 @@ type ServerInitShape<
43
43
  [K in `lastPageOf${CapRefName}`]: number;
44
44
  } & {
45
45
  [K in `limitOf${CapRefName}`]: number;
46
+ } & {
47
+ [K in `hasMoreOf${CapRefName}`]: boolean;
46
48
  } & {
47
49
  [K in `queryArgsOf${CapRefName}`]: QueryArgs;
48
50
  } & {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.0-alpha.96",
3
+ "version": "3.0.0-alpha.98",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
package/store/action.ts CHANGED
@@ -177,7 +177,7 @@ export type SliceAction<
177
177
  } & {
178
178
  [K in `setPageOf${_CapitalizedRefName}${_CapitalizedSuffix}`]: (page: number, options?: FetchPolicy) => Promise<void>;
179
179
  } & {
180
- [K in `addPageOf${_CapitalizedRefName}${_CapitalizedSuffix}`]: (page: number, options?: FetchPolicy) => Promise<void>;
180
+ [K in `loadMoreOf${_CapitalizedRefName}${_CapitalizedSuffix}`]: (options?: FetchPolicy) => Promise<void>;
181
181
  } & {
182
182
  [K in `setLimitOf${_CapitalizedRefName}${_CapitalizedSuffix}`]: (
183
183
  limit: number,
@@ -273,11 +273,14 @@ type DefaultSliceActionFields<
273
273
  } & {
274
274
  [Suffix in _Suffixes as
275
275
  | `setPageOf${_CapRef}${StoreSliceSuffixCap<SlceCls, Suffix>}`
276
- | `addPageOf${_CapRef}${StoreSliceSuffixCap<SlceCls, Suffix>}`
277
276
  | `setLimitOf${_CapRef}${StoreSliceSuffixCap<SlceCls, Suffix>}`]: (
278
277
  value: number,
279
278
  options?: FetchPolicy,
280
279
  ) => Promise<void>;
280
+ } & {
281
+ [Suffix in _Suffixes as `loadMoreOf${_CapRef}${StoreSliceSuffixCap<SlceCls, Suffix>}`]: (
282
+ options?: FetchPolicy,
283
+ ) => Promise<void>;
281
284
  } & {
282
285
  [Suffix in _Suffixes as `setQueryArgsOf${_CapRef}${StoreSliceSuffixCap<SlceCls, Suffix>}`]: (
283
286
  ...args:
@@ -528,7 +531,7 @@ export const makeActions = (refName: string, slice: { [key: string]: SerializedS
528
531
  refreshModel: `refresh${className}`,
529
532
  selectModel: `select${className}`,
530
533
  setPageOfModel: `setPageOf${className}`,
531
- addPageOfModel: `addPageOf${className}`,
534
+ loadMoreOfModel: `loadMoreOf${className}`,
532
535
  setLimitOfModel: `setLimitOf${className}`,
533
536
  setQueryArgsOfModel: `setQueryArgsOf${className}`,
534
537
  setSortOfModel: `setSortOf${className}`,
@@ -537,6 +540,8 @@ export const makeActions = (refName: string, slice: { [key: string]: SerializedS
537
540
  lastPageOfModel: `lastPageOf${className}`,
538
541
  pageOfModel: `pageOf${className}`,
539
542
  limitOfModel: `limitOf${className}`,
543
+ hasMoreOfModel: `hasMoreOf${className}`,
544
+ isCumulativeOfModel: `isCumulativeOf${className}`,
540
545
  queryArgsOfModel: `queryArgsOf${className}`,
541
546
  sortOfModel: `sortOf${className}`,
542
547
  };
@@ -579,6 +584,12 @@ export const makeActions = (refName: string, slice: { [key: string]: SerializedS
579
584
 
580
585
  const current = (this.get() as { [key: string]: any })[names.modelDraft] as DraftState | null;
581
586
  if (current?.key !== draft.key) return;
587
+
588
+ const openedForm = (this.get() as { [key: string]: any })[names.modelForm] as object;
589
+ if (DraftStore.contentHash(record.form) === DraftStore.contentHash(DraftStore.encodeForm(refName, openedForm))) {
590
+ await DraftStore.remove(draft.key);
591
+ return;
592
+ }
582
593
  let form: object;
583
594
  try {
584
595
  form = DraftStore.decodeForm(refName, record.form);
@@ -1034,7 +1045,7 @@ export const makeActions = (refName: string, slice: { [key: string]: SerializedS
1034
1045
  refreshModel: SliceName.replace(names.Model, names.refreshModel),
1035
1046
  selectModel: SliceName.replace(names.Model, names.selectModel),
1036
1047
  setPageOfModel: SliceName.replace(names.Model, names.setPageOfModel),
1037
- addPageOfModel: SliceName.replace(names.Model, names.addPageOfModel),
1048
+ loadMoreOfModel: SliceName.replace(names.Model, names.loadMoreOfModel),
1038
1049
  setLimitOfModel: SliceName.replace(names.Model, names.setLimitOfModel),
1039
1050
  setQueryArgsOfModel: SliceName.replace(names.Model, names.setQueryArgsOfModel),
1040
1051
  setSortOfModel: SliceName.replace(names.Model, names.setSortOfModel),
@@ -1043,10 +1054,23 @@ export const makeActions = (refName: string, slice: { [key: string]: SerializedS
1043
1054
  lastPageOfModel: SliceName.replace(names.Model, names.lastPageOfModel),
1044
1055
  pageOfModel: SliceName.replace(names.Model, names.pageOfModel),
1045
1056
  limitOfModel: SliceName.replace(names.Model, names.limitOfModel),
1057
+ hasMoreOfModel: SliceName.replace(names.Model, names.hasMoreOfModel),
1058
+ isCumulativeOfModel: SliceName.replace(names.Model, names.isCumulativeOfModel),
1046
1059
  queryArgsOfModel: SliceName.replace(names.Model, names.queryArgsOfModel),
1047
1060
  sortOfModel: SliceName.replace(names.Model, names.sortOfModel),
1048
1061
  modelSelection: SliceName.replace(names.Model, names.modelSelection),
1049
1062
  };
1063
+ /**
1064
+ * Whether the server still holds rows past the ones just returned.
1065
+ *
1066
+ * Read off the batch rather than off `<model>Insight`, because the count is a second query — absent entirely
1067
+ * under `{ insight: false }`, and one live event out of step with the list the rest of the time. A short
1068
+ * batch is the server saying it had nothing more; a full one costs at most one extra request that comes back
1069
+ * empty when the total happens to be a multiple of the limit.
1070
+ */
1071
+ const hasMoreFrom = (batch: unknown[], askedFor: number) => ({
1072
+ [namesOfSlice.hasMoreOfModel]: batch.length >= askedFor && askedFor > 0,
1073
+ });
1050
1074
  const singleSliceAction = {
1051
1075
  [namesOfSlice.initModel]: async function (
1052
1076
  this: SetGet,
@@ -1057,7 +1081,7 @@ export const makeActions = (refName: string, slice: { [key: string]: SerializedS
1057
1081
  FetchPolicy;
1058
1082
  const queryArgs = new Array(initArgLength).fill(null).map((_, i) => args[i] as object);
1059
1083
  const defaultModel = new cnst.full().set(initForm.default ?? {}) as unknown as Full;
1060
- this.set({ [names.defaultModel]: defaultModel });
1084
+ this.set({ [names.defaultModel]: defaultModel, [namesOfSlice.isCumulativeOfModel]: false });
1061
1085
  await ((this as unknown as DynamicRecord)[namesOfSlice.refreshModel] as (...args: any[]) => Promise<void>)({
1062
1086
  ...initForm,
1063
1087
  queryArgs,
@@ -1089,6 +1113,10 @@ export const makeActions = (refName: string, slice: { [key: string]: SerializedS
1089
1113
  ...fetchPolicy
1090
1114
  } = initForm;
1091
1115
  const modelOperation = currentState[names.modelOperation] as string;
1116
+ const isCumulative = currentState[namesOfSlice.isCumulativeOfModel] as boolean;
1117
+ const loadedLength = (currentState[namesOfSlice.modelList] as DataList<Light>).length;
1118
+
1119
+ const fetchLimit = isCumulative ? Math.max(limit, loadedLength) : limit;
1092
1120
  const queryArgsOfModel = currentState[namesOfSlice.queryArgsOfModel] as object[];
1093
1121
  const pageOfModel = currentState[namesOfSlice.pageOfModel] as number;
1094
1122
  const limitOfModel = currentState[namesOfSlice.limitOfModel] as number;
@@ -1112,7 +1140,7 @@ export const makeActions = (refName: string, slice: { [key: string]: SerializedS
1112
1140
  (fetch[namesOfSlice.modelList] as (...args: any[]) => Promise<Light[]>)(
1113
1141
  ...fetchQueryArgs,
1114
1142
  (page - 1) * limit,
1115
- limit,
1143
+ fetchLimit,
1116
1144
  sort,
1117
1145
  { ...fetchPolicy, onError: initForm.onError },
1118
1146
  ),
@@ -1134,6 +1162,7 @@ export const makeActions = (refName: string, slice: { [key: string]: SerializedS
1134
1162
  [namesOfSlice.modelInitList]: modelList,
1135
1163
  [namesOfSlice.modelInitAt]: new Date(),
1136
1164
  [namesOfSlice.lastPageOfModel]: Math.max(Math.floor((modelInsight.count - 1) / (limit || 20)) + 1, 1),
1165
+ ...hasMoreFrom(modelDataList, fetchLimit),
1137
1166
  [namesOfSlice.limitOfModel]: limit,
1138
1167
  [namesOfSlice.queryArgsOfModel]: queryArgs,
1139
1168
  [namesOfSlice.sortOfModel]: sort,
@@ -1183,35 +1212,58 @@ export const makeActions = (refName: string, slice: { [key: string]: SerializedS
1183
1212
  this.set({
1184
1213
  [namesOfSlice.modelList]: new DataList(modelDataList),
1185
1214
  [namesOfSlice.pageOfModel]: page,
1215
+ [namesOfSlice.isCumulativeOfModel]: false,
1216
+ ...hasMoreFrom(modelDataList, limitOfModel),
1186
1217
  });
1187
1218
  } finally {
1188
1219
  if (requests.isCurrent(ticket)) this.set({ [namesOfSlice.modelListLoading]: false });
1189
1220
  }
1190
1221
  },
1191
- [namesOfSlice.addPageOfModel]: async function (this: SetGet, page: number, options?: FetchPolicy) {
1222
+ /**
1223
+ * Appends the rows after the ones already loaded, turning this slice's window into a cumulative list.
1224
+ *
1225
+ * The offset is the list on screen rather than a page number, and that is the whole point. A page number
1226
+ * assumes the server's ordering baseline has not moved, which a live insertion at the front breaks on the
1227
+ * first event; the rows in hand are the first `n` the query returns whatever has been inserted since, so
1228
+ * asking for what comes after them cannot drift from what is displayed. It also leaves `pageOf<Model>` at
1229
+ * 1, which is what keeps live placement — refused anywhere but the first page — working past the first
1230
+ * "more".
1231
+ *
1232
+ * It reads `<model>ListLoading` and never sets it: an append leaves the rows on screen, so raising it
1233
+ * would put the whole-list spinner over one and would make `applyLive<Model>` drop every live event until
1234
+ * the batch lands. So concurrent calls are NOT rejected — two of them fetch the same offset, since the
1235
+ * list they measure has not grown yet. That is a wasted round trip and never a wrong list: the ticket
1236
+ * makes only the newest response apply, and both asked for the same rows. A caller that minds the wasted
1237
+ * request holds its own in-flight flag, the way `InfiniteScroll` does.
1238
+ */
1239
+ [namesOfSlice.loadMoreOfModel]: async function (this: SetGet, options?: FetchPolicy) {
1192
1240
  const currentState = this.get() as { [key: string]: any };
1241
+
1242
+ if (currentState[namesOfSlice.modelListLoading] as boolean) return;
1243
+ if (!(currentState[namesOfSlice.hasMoreOfModel] as boolean)) return;
1193
1244
  const modelList = currentState[namesOfSlice.modelList] as DataList<Light>;
1194
1245
  const queryArgsOfModel = currentState[namesOfSlice.queryArgsOfModel] as object[];
1195
1246
  const pageOfModel = currentState[namesOfSlice.pageOfModel] as number;
1196
- const limitOfModel = currentState[namesOfSlice.limitOfModel] as number;
1247
+ const limitOfModel = (currentState[namesOfSlice.limitOfModel] as number) || 20;
1197
1248
  const sortOfModel = currentState[namesOfSlice.sortOfModel] as Sort;
1198
- if (pageOfModel === page) return;
1199
- const addFront = page < pageOfModel;
1200
1249
  const ticket = requests.claim();
1201
1250
  const fetchQueryArgs = expandQueryArgs(queryArgsOfModel, slice.args);
1202
1251
  const modelDataList = await (fetch[namesOfSlice.modelList] as (...args: any[]) => Promise<Light[]>)(
1203
1252
  ...fetchQueryArgs,
1204
- (page - 1) * limitOfModel,
1253
+ (pageOfModel - 1) * limitOfModel + modelList.length,
1205
1254
  limitOfModel,
1206
1255
  sortOfModel,
1207
1256
  options,
1208
1257
  );
1209
1258
 
1210
1259
  if (!requests.isCurrent(ticket)) return;
1211
- const newModelList = new DataList(
1212
- addFront ? [...modelDataList, ...modelList] : [...modelList, ...modelDataList],
1213
- );
1214
- this.set({ [namesOfSlice.modelList]: newModelList, [namesOfSlice.pageOfModel]: page });
1260
+
1261
+ const currentModelList = (this.get() as { [key: string]: any })[namesOfSlice.modelList] as DataList<Light>;
1262
+ this.set({
1263
+ [namesOfSlice.modelList]: new DataList([...currentModelList.values, ...modelDataList]),
1264
+ [namesOfSlice.isCumulativeOfModel]: true,
1265
+ ...hasMoreFrom(modelDataList, limitOfModel),
1266
+ });
1215
1267
  },
1216
1268
  [namesOfSlice.setLimitOfModel]: async function (this: SetGet, limit: number, options?: FetchPolicy) {
1217
1269
  const currentState = this.get() as { [key: string]: any };
@@ -1240,6 +1292,8 @@ export const makeActions = (refName: string, slice: { [key: string]: SerializedS
1240
1292
  [namesOfSlice.lastPageOfModel]: Math.max(Math.floor((modelInsight.count - 1) / limit) + 1, 1),
1241
1293
  [namesOfSlice.limitOfModel]: limit,
1242
1294
  [namesOfSlice.pageOfModel]: page,
1295
+ [namesOfSlice.isCumulativeOfModel]: false,
1296
+ ...hasMoreFrom(modelDataList, limit),
1243
1297
  });
1244
1298
  } finally {
1245
1299
  if (requests.isCurrent(ticket)) this.set({ [namesOfSlice.modelListLoading]: false });
@@ -1291,6 +1345,8 @@ export const makeActions = (refName: string, slice: { [key: string]: SerializedS
1291
1345
  [namesOfSlice.modelInsight]: modelInsight,
1292
1346
  [namesOfSlice.lastPageOfModel]: Math.max(Math.floor((modelInsight.count - 1) / limitOfModel) + 1, 1),
1293
1347
  [namesOfSlice.pageOfModel]: 1,
1348
+ [namesOfSlice.isCumulativeOfModel]: false,
1349
+ ...hasMoreFrom(modelDataList, limitOfModel),
1294
1350
  [namesOfSlice.modelSelection]: new Map(),
1295
1351
  });
1296
1352
  } finally {
@@ -1319,6 +1375,8 @@ export const makeActions = (refName: string, slice: { [key: string]: SerializedS
1319
1375
  [namesOfSlice.modelList]: new DataList(modelDataList),
1320
1376
  [namesOfSlice.sortOfModel]: sort,
1321
1377
  [namesOfSlice.pageOfModel]: 1,
1378
+ [namesOfSlice.isCumulativeOfModel]: false,
1379
+ ...hasMoreFrom(modelDataList, limitOfModel),
1322
1380
  });
1323
1381
  } finally {
1324
1382
  if (requests.isCurrent(ticket)) this.set({ [namesOfSlice.modelListLoading]: false });
@@ -1329,7 +1387,7 @@ export const makeActions = (refName: string, slice: { [key: string]: SerializedS
1329
1387
  *
1330
1388
  * The verb already answers membership — the server evaluated this slice's own query against the document
1331
1389
  * before and after the write, so arriving here at all is the proof, and nothing is re-checked. What is left
1332
- * is the window: `<slice>List` is one page of the list, not the list, so an insertion is only ever attempted
1390
+ * is the window: `<slice>List` is a prefix of the list, not the list, so an insertion is only ever attempted
1333
1391
  * where its position can be known. Everywhere else the list is stamped stale and refetched, which is always
1334
1392
  * correct and merely costs a round trip.
1335
1393
  */
@@ -1344,6 +1402,7 @@ export const makeActions = (refName: string, slice: { [key: string]: SerializedS
1344
1402
  }
1345
1403
  const modelInsight = currentState[namesOfSlice.modelInsight] as Insight & BaseInsight;
1346
1404
  const limit = (currentState[namesOfSlice.limitOfModel] as number) || 20;
1405
+ const isCumulative = currentState[namesOfSlice.isCumulativeOfModel] as boolean;
1347
1406
  const countedTo = (count: number) => ({
1348
1407
  [namesOfSlice.modelInsight]: new cnst.insight().set({ ...modelInsight, count }),
1349
1408
  [namesOfSlice.lastPageOfModel]: Math.max(Math.floor((count - 1) / limit) + 1, 1),
@@ -1372,6 +1431,8 @@ export const makeActions = (refName: string, slice: { [key: string]: SerializedS
1372
1431
  row: light as unknown as LiveSortableRow,
1373
1432
  page: currentState[namesOfSlice.pageOfModel] as number,
1374
1433
  limit,
1434
+ cumulative: isCumulative,
1435
+ hasMore: currentState[namesOfSlice.hasMoreOfModel] as boolean,
1375
1436
  sortKey: String(currentState[namesOfSlice.sortOfModel]),
1376
1437
  allowedSorts: slice.live?.sort ?? [],
1377
1438
  sorts: fetch.sortValueMap?.get(refName),
@@ -1382,7 +1443,8 @@ export const makeActions = (refName: string, slice: { [key: string]: SerializedS
1382
1443
  return;
1383
1444
  }
1384
1445
 
1385
- const placed = [...modelList.slice(0, placement), light, ...modelList.slice(placement)].slice(0, limit);
1446
+ const inserted = [...modelList.slice(0, placement), light, ...modelList.slice(placement)];
1447
+ const placed = isCumulative ? inserted : inserted.slice(0, limit);
1386
1448
  this.set({
1387
1449
  [namesOfSlice.modelList]: new DataList(placed as Light[]),
1388
1450
  ...countedTo(modelInsight.count + 1),
@@ -17,6 +17,9 @@ const MAX_DRAFTS_PER_IDENTITY = 30;
17
17
  */
18
18
  const VOLATILE_CLAIMS = ["iat", "exp", "nbf", "jti"] as const;
19
19
 
20
+ /** The fields a form carries from its row rather than from what the user typed. */
21
+ const RECORD_STAMPS = new Set(["createdAt", "updatedAt", "removedAt"]);
22
+
20
23
  export interface DraftRecord {
21
24
  v: number;
22
25
  /** ISO. Drives both the "n minutes ago" label and the TTL/LRU sweep. */
@@ -143,6 +146,17 @@ export class DraftStore {
143
146
  return DraftStore.#hash8(DraftStore.#stableStringify(DraftStore.encodeForm(refName, form)));
144
147
  }
145
148
 
149
+ /**
150
+ * What two encoded forms holding the same values hash to, whatever record stamps they carry.
151
+ *
152
+ * The stamps come from the row rather than from the user, so a save the form made itself moves `updatedAt` and
153
+ * would otherwise make a draft look different from the record that already holds it.
154
+ */
155
+ static contentHash(encoded: Record<string, unknown>): string {
156
+ const content = Object.fromEntries(Object.entries(encoded).filter(([key]) => !RECORD_STAMPS.has(key)));
157
+ return DraftStore.#hash8(DraftStore.#stableStringify(content));
158
+ }
159
+
146
160
  static async read(key: string): Promise<DraftRecord | null> {
147
161
  const storage = DraftStore.#storage();
148
162
  if (!storage) return null;
@@ -8,6 +8,10 @@ export interface LivePlacementProps {
8
8
  /** Which page of the list is on screen. Only the first can place a row. */
9
9
  page: number;
10
10
  limit: number;
11
+ /** Whether the list is pages `1..N` concatenated rather than one window. */
12
+ cumulative: boolean;
13
+ /** Whether the server still holds rows past the ones in hand. */
14
+ hasMore: boolean;
11
15
  /** The sort key the window is ordered by. */
12
16
  sortKey: string;
13
17
  /** The sort keys the slice declared a subscriber may reproduce. */
@@ -38,6 +42,8 @@ export const livePlacementIndex = ({
38
42
  row,
39
43
  page,
40
44
  limit,
45
+ cumulative,
46
+ hasMore,
41
47
  sortKey,
42
48
  allowedSorts,
43
49
  sorts,
@@ -49,9 +55,10 @@ export const livePlacementIndex = ({
49
55
  const paths = Object.entries(sort);
50
56
  if (paths.some(([path]) => comparableOf(row[path]) === null)) return null;
51
57
  const index = list.findIndex((item) => compareRows(row, item, paths) < 0);
58
+ if (index !== -1) return index;
52
59
 
53
- if (index === -1) return list.length < limit ? list.length : null;
54
- return index;
60
+ if (cumulative) return hasMore ? null : list.length;
61
+ return list.length < limit ? list.length : null;
55
62
  };
56
63
 
57
64
  const compareRows = (left: LiveSortableRow, right: LiveSortableRow, paths: [string, 1 | -1][]): number => {
@@ -7,7 +7,7 @@ export type SliceActionKey =
7
7
  | "refreshModel"
8
8
  | "selectModel"
9
9
  | "setPageOfModel"
10
- | "addPageOfModel"
10
+ | "loadMoreOfModel"
11
11
  | "setLimitOfModel"
12
12
  | "setQueryArgsOfModel"
13
13
  | "setSortOfModel"
package/store/state.ts CHANGED
@@ -49,6 +49,8 @@ export type SliceStateKey =
49
49
  | "lastPageOfModel"
50
50
  | "pageOfModel"
51
51
  | "limitOfModel"
52
+ | "hasMoreOfModel"
53
+ | "isCumulativeOfModel"
52
54
  | "queryArgsOfModel"
53
55
  | "sortOfModel";
54
56
  type _SliceMap<S extends SliceCls> = StoreSliceMap<S>;
@@ -114,6 +116,10 @@ export type SliceState<
114
116
  [K in `pageOf${_CapitalizedRefName}${_CapitalizedSuffix}`]: number;
115
117
  } & {
116
118
  [K in `limitOf${_CapitalizedRefName}${_CapitalizedSuffix}`]: number;
119
+ } & {
120
+ [K in
121
+ | `hasMoreOf${_CapitalizedRefName}${_CapitalizedSuffix}`
122
+ | `isCumulativeOf${_CapitalizedRefName}${_CapitalizedSuffix}`]: boolean;
117
123
  } & {
118
124
  [K in `queryArgsOf${_CapitalizedRefName}${_CapitalizedSuffix}`]: Args;
119
125
  } & {
@@ -150,6 +156,10 @@ type DefaultSliceStateFields<
150
156
  | `lastPageOf${_CapRefName}${StoreSliceSuffixCap<SlceCls, Suffix>}`
151
157
  | `pageOf${_CapRefName}${StoreSliceSuffixCap<SlceCls, Suffix>}`
152
158
  | `limitOf${_CapRefName}${StoreSliceSuffixCap<SlceCls, Suffix>}`]: number;
159
+ } & {
160
+ [Suffix in _Suffixes as
161
+ | `hasMoreOf${_CapRefName}${StoreSliceSuffixCap<SlceCls, Suffix>}`
162
+ | `isCumulativeOf${_CapRefName}${StoreSliceSuffixCap<SlceCls, Suffix>}`]: boolean;
153
163
  } & {
154
164
  [Suffix in _Suffixes as `queryArgsOf${_CapRefName}${StoreSliceSuffixCap<SlceCls, Suffix>}`]: StoreSliceArgs<
155
165
  SlceCls,
@@ -206,6 +216,8 @@ export const createSliceState = (refName: string, slice: { [key: string]: Serial
206
216
  lastPageOfModel: `lastPageOf${className}`,
207
217
  pageOfModel: `pageOf${className}`,
208
218
  limitOfModel: `limitOf${className}`,
219
+ hasMoreOfModel: `hasMoreOf${className}`,
220
+ isCumulativeOfModel: `isCumulativeOf${className}`,
209
221
  queryArgsOfModel: `queryArgsOf${className}`,
210
222
  sortOfModel: `sortOf${className}`,
211
223
  };
@@ -225,6 +237,8 @@ export const createSliceState = (refName: string, slice: { [key: string]: Serial
225
237
  lastPageOfModel: SliceName.replace(names.Model, names.lastPageOfModel),
226
238
  pageOfModel: SliceName.replace(names.Model, names.pageOfModel),
227
239
  limitOfModel: SliceName.replace(names.Model, names.limitOfModel),
240
+ hasMoreOfModel: SliceName.replace(names.Model, names.hasMoreOfModel),
241
+ isCumulativeOfModel: SliceName.replace(names.Model, names.isCumulativeOfModel),
228
242
  queryArgsOfModel: SliceName.replace(names.Model, names.queryArgsOfModel),
229
243
  sortOfModel: SliceName.replace(names.Model, names.sortOfModel),
230
244
  };
@@ -240,6 +254,8 @@ export const createSliceState = (refName: string, slice: { [key: string]: Serial
240
254
  [namesOfSlice.lastPageOfModel]: 1,
241
255
  [namesOfSlice.pageOfModel]: 1,
242
256
  [namesOfSlice.limitOfModel]: 20,
257
+ [namesOfSlice.hasMoreOfModel]: false,
258
+ [namesOfSlice.isCumulativeOfModel]: false,
243
259
  [namesOfSlice.queryArgsOfModel]: [],
244
260
  [namesOfSlice.sortOfModel]: "latest",
245
261
  };
@@ -433,6 +433,8 @@ export class StoreInstance {
433
433
  modelStaleAt: `${fieldName}StaleAt`,
434
434
  pageOfModel: `pageOf${className}`,
435
435
  limitOfModel: `limitOf${className}`,
436
+ hasMoreOfModel: `hasMoreOf${className}`,
437
+ isCumulativeOfModel: `isCumulativeOf${className}`,
436
438
  queryArgsOfModel: `queryArgsOf${className}`,
437
439
  sortOfModel: `sortOf${className}`,
438
440
  modelSelection: `${fieldName}Selection`,
@@ -440,7 +442,7 @@ export class StoreInstance {
440
442
  refreshModel: `refresh${className}`,
441
443
  selectModel: `select${className}`,
442
444
  setPageOfModel: `setPageOf${className}`,
443
- addPageOfModel: `addPageOf${className}`,
445
+ loadMoreOfModel: `loadMoreOf${className}`,
444
446
  setLimitOfModel: `setLimitOf${className}`,
445
447
  setQueryArgsOfModel: `setQueryArgsOf${className}`,
446
448
  setSortOfModel: `setSortOf${className}`,
@@ -460,6 +462,8 @@ export class StoreInstance {
460
462
  lastPageOfModel: SliceName.replace(names.Model, names.lastPageOfModel),
461
463
  pageOfModel: SliceName.replace(names.Model, names.pageOfModel),
462
464
  limitOfModel: SliceName.replace(names.Model, names.limitOfModel),
465
+ hasMoreOfModel: SliceName.replace(names.Model, names.hasMoreOfModel),
466
+ isCumulativeOfModel: SliceName.replace(names.Model, names.isCumulativeOfModel),
463
467
  queryArgsOfModel: SliceName.replace(names.Model, names.queryArgsOfModel),
464
468
  sortOfModel: SliceName.replace(names.Model, names.sortOfModel),
465
469
  modelSelection: SliceName.replace(names.Model, names.modelSelection),
@@ -469,7 +473,7 @@ export class StoreInstance {
469
473
  refreshModel: SliceName.replace(names.Model, names.refreshModel),
470
474
  selectModel: SliceName.replace(names.Model, names.selectModel),
471
475
  setPageOfModel: SliceName.replace(names.Model, names.setPageOfModel),
472
- addPageOfModel: SliceName.replace(names.Model, names.addPageOfModel),
476
+ loadMoreOfModel: SliceName.replace(names.Model, names.loadMoreOfModel),
473
477
  setLimitOfModel: SliceName.replace(names.Model, names.setLimitOfModel),
474
478
  setQueryArgsOfModel: SliceName.replace(names.Model, names.setQueryArgsOfModel),
475
479
  setSortOfModel: SliceName.replace(names.Model, names.setSortOfModel),
@@ -32,6 +32,8 @@ type ServerInitShape<RefName extends string, QueryArgs, CapRefName extends strin
32
32
  [K in `lastPageOf${CapRefName}`]: number;
33
33
  } & {
34
34
  [K in `limitOf${CapRefName}`]: number;
35
+ } & {
36
+ [K in `hasMoreOf${CapRefName}`]: boolean;
35
37
  } & {
36
38
  [K in `queryArgsOf${CapRefName}`]: QueryArgs;
37
39
  } & {
@@ -100,7 +100,7 @@ export type SliceAction<RefName extends string, Suffix extends string, Input, Li
100
100
  } & {
101
101
  [K in `setPageOf${_CapitalizedRefName}${_CapitalizedSuffix}`]: (page: number, options?: FetchPolicy) => Promise<void>;
102
102
  } & {
103
- [K in `addPageOf${_CapitalizedRefName}${_CapitalizedSuffix}`]: (page: number, options?: FetchPolicy) => Promise<void>;
103
+ [K in `loadMoreOf${_CapitalizedRefName}${_CapitalizedSuffix}`]: (options?: FetchPolicy) => Promise<void>;
104
104
  } & {
105
105
  [K in `setLimitOf${_CapitalizedRefName}${_CapitalizedSuffix}`]: (limit: number, options?: FetchPolicy) => Promise<void>;
106
106
  } & {
@@ -143,7 +143,9 @@ type DefaultSliceActionFields<SlceCls extends SliceCls, _CapRef extends string,
143
143
  remove?: boolean;
144
144
  }) => void;
145
145
  } & {
146
- [Suffix in _Suffixes as `setPageOf${_CapRef}${StoreSliceSuffixCap<SlceCls, Suffix>}` | `addPageOf${_CapRef}${StoreSliceSuffixCap<SlceCls, Suffix>}` | `setLimitOf${_CapRef}${StoreSliceSuffixCap<SlceCls, Suffix>}`]: (value: number, options?: FetchPolicy) => Promise<void>;
146
+ [Suffix in _Suffixes as `setPageOf${_CapRef}${StoreSliceSuffixCap<SlceCls, Suffix>}` | `setLimitOf${_CapRef}${StoreSliceSuffixCap<SlceCls, Suffix>}`]: (value: number, options?: FetchPolicy) => Promise<void>;
147
+ } & {
148
+ [Suffix in _Suffixes as `loadMoreOf${_CapRef}${StoreSliceSuffixCap<SlceCls, Suffix>}`]: (options?: FetchPolicy) => Promise<void>;
147
149
  } & {
148
150
  [Suffix in _Suffixes as `setQueryArgsOf${_CapRef}${StoreSliceSuffixCap<SlceCls, Suffix>}`]: (...args: [...args: StoreSliceArgs<SlceCls, Suffix>, options?: FetchPolicy] | [
149
151
  setQueryArgs: (...prevQueryArgs: StoreSliceArgs<SlceCls, Suffix>) => StoreSliceArgs<SlceCls, Suffix>,
@@ -112,7 +112,7 @@ declare const RootStore_base: import("./rootStore.d.ts").RootStoreCls<"root", im
112
112
  } & {
113
113
  [x: `setPageOf${any}`]: (page: number, options?: import("../common.d.ts").FetchPolicy) => Promise<void>;
114
114
  } & {
115
- [x: `addPageOf${any}`]: (page: number, options?: import("../common.d.ts").FetchPolicy) => Promise<void>;
115
+ [x: `loadMoreOf${any}`]: (options?: import("../common.d.ts").FetchPolicy) => Promise<void>;
116
116
  } & {
117
117
  [x: `setLimitOf${any}`]: (limit: number, options?: import("../common.d.ts").FetchPolicy) => Promise<void>;
118
118
  } & {
@@ -418,7 +418,7 @@ export declare const st: import("./agentic.d.ts").StAgentic & {
418
418
  } & {
419
419
  [x: `setPageOf${any}`]: (page: number, options?: import("../common.d.ts").FetchPolicy) => Promise<void>;
420
420
  } & {
421
- [x: `addPageOf${any}`]: (page: number, options?: import("../common.d.ts").FetchPolicy) => Promise<void>;
421
+ [x: `loadMoreOf${any}`]: (options?: import("../common.d.ts").FetchPolicy) => Promise<void>;
422
422
  } & {
423
423
  [x: `setLimitOf${any}`]: (limit: number, options?: import("../common.d.ts").FetchPolicy) => Promise<void>;
424
424
  } & {
@@ -36,6 +36,13 @@ export declare class DraftStore {
36
36
  static decodeForm(refName: string, plain: Record<string, unknown>): object;
37
37
  /** What the dirty check compares. Cheap enough to take once per debounce window, not once per keystroke. */
38
38
  static formHash(refName: string, form: object): string;
39
+ /**
40
+ * What two encoded forms holding the same values hash to, whatever record stamps they carry.
41
+ *
42
+ * The stamps come from the row rather than from the user, so a save the form made itself moves `updatedAt` and
43
+ * would otherwise make a draft look different from the record that already holds it.
44
+ */
45
+ static contentHash(encoded: Record<string, unknown>): string;
39
46
  static read(key: string): Promise<DraftRecord | null>;
40
47
  static write(key: string, record: DraftRecord): Promise<void>;
41
48
  static remove(key: string): Promise<void>;
@@ -9,6 +9,10 @@ export interface LivePlacementProps {
9
9
  /** Which page of the list is on screen. Only the first can place a row. */
10
10
  page: number;
11
11
  limit: number;
12
+ /** Whether the list is pages `1..N` concatenated rather than one window. */
13
+ cumulative: boolean;
14
+ /** Whether the server still holds rows past the ones in hand. */
15
+ hasMore: boolean;
12
16
  /** The sort key the window is ordered by. */
13
17
  sortKey: string;
14
18
  /** The sort keys the slice declared a subscriber may reproduce. */
@@ -37,4 +41,4 @@ export interface LivePlacementProps {
37
41
  * And every sorted field has to be on the row, because a comparison against a value that is not there silently
38
42
  * places the row at one end.
39
43
  */
40
- export declare const livePlacementIndex: ({ list, row, page, limit, sortKey, allowedSorts, sorts, }: LivePlacementProps) => number | null;
44
+ export declare const livePlacementIndex: ({ list, row, page, limit, cumulative, hasMore, sortKey, allowedSorts, sorts, }: LivePlacementProps) => number | null;
@@ -1,7 +1,7 @@
1
1
  import type { SerializedArg } from "akanjs/signal";
2
2
  import type { SliceStateKey } from "./state.d.ts";
3
3
  /** The generated slice actions, named by what they do rather than by the key any one slice publishes them under. */
4
- export type SliceActionKey = "initModel" | "refreshModel" | "selectModel" | "setPageOfModel" | "addPageOfModel" | "setLimitOfModel" | "setQueryArgsOfModel" | "setSortOfModel" | "applyLiveModel" | "watchLiveModel";
4
+ export type SliceActionKey = "initModel" | "refreshModel" | "selectModel" | "setPageOfModel" | "loadMoreOfModel" | "setLimitOfModel" | "setQueryArgsOfModel" | "setSortOfModel" | "applyLiveModel" | "watchLiveModel";
5
5
  /**
6
6
  * What a generated key on `st.do` / `st.use` actually is.
7
7
  *
@@ -25,7 +25,7 @@ export interface DraftState {
25
25
  /** Set while a draft is the thing in the form. Drives the "continuing, saved n minutes ago" chip. */
26
26
  appliedAt: Date | null;
27
27
  }
28
- export type SliceStateKey = "defaultModel" | "modelInsight" | "modelList" | "modelListLoading" | "modelInitList" | "modelInitAt" | "modelStaleAt" | "modelSelection" | "lastPageOfModel" | "pageOfModel" | "limitOfModel" | "queryArgsOfModel" | "sortOfModel";
28
+ export type SliceStateKey = "defaultModel" | "modelInsight" | "modelList" | "modelListLoading" | "modelInitList" | "modelInitAt" | "modelStaleAt" | "modelSelection" | "lastPageOfModel" | "pageOfModel" | "limitOfModel" | "hasMoreOfModel" | "isCumulativeOfModel" | "queryArgsOfModel" | "sortOfModel";
29
29
  type _SliceMap<S extends SliceCls> = StoreSliceMap<S>;
30
30
  type _StateRefName<S extends SliceCls> = SlceCnstRefName<S>;
31
31
  type _StateCap<S extends SliceCls> = SlceCnstCapitalizedRefName<S>;
@@ -78,6 +78,8 @@ export type SliceState<RefName extends string, Suffix extends string, Full, Ligh
78
78
  [K in `pageOf${_CapitalizedRefName}${_CapitalizedSuffix}`]: number;
79
79
  } & {
80
80
  [K in `limitOf${_CapitalizedRefName}${_CapitalizedSuffix}`]: number;
81
+ } & {
82
+ [K in `hasMoreOf${_CapitalizedRefName}${_CapitalizedSuffix}` | `isCumulativeOf${_CapitalizedRefName}${_CapitalizedSuffix}`]: boolean;
81
83
  } & {
82
84
  [K in `queryArgsOf${_CapitalizedRefName}${_CapitalizedSuffix}`]: Args;
83
85
  } & {
@@ -97,6 +99,8 @@ type DefaultSliceStateFields<SlceCls extends SliceCls, _RefName extends string,
97
99
  [Suffix in _Suffixes as `${_RefName}Insight${StoreSliceSuffixCap<SlceCls, Suffix>}`]: _Insight;
98
100
  } & {
99
101
  [Suffix in _Suffixes as `lastPageOf${_CapRefName}${StoreSliceSuffixCap<SlceCls, Suffix>}` | `pageOf${_CapRefName}${StoreSliceSuffixCap<SlceCls, Suffix>}` | `limitOf${_CapRefName}${StoreSliceSuffixCap<SlceCls, Suffix>}`]: number;
102
+ } & {
103
+ [Suffix in _Suffixes as `hasMoreOf${_CapRefName}${StoreSliceSuffixCap<SlceCls, Suffix>}` | `isCumulativeOf${_CapRefName}${StoreSliceSuffixCap<SlceCls, Suffix>}`]: boolean;
100
104
  } & {
101
105
  [Suffix in _Suffixes as `queryArgsOf${_CapRefName}${StoreSliceSuffixCap<SlceCls, Suffix>}`]: StoreSliceArgs<SlceCls, Suffix>;
102
106
  } & {
@@ -1,12 +1,12 @@
1
1
  export interface InfiniteScrollProps {
2
- total: number;
3
- currentPage: number;
4
- itemsPerPage: number;
5
- onAddPage: (page: number) => Promise<void>;
6
- onPageSelect: (page: number, option?: {
7
- scrollToTop?: boolean;
8
- }) => void;
2
+ hasMore: boolean;
3
+ onLoadMore: () => Promise<void>;
9
4
  children: React.ReactNode;
5
+ /**
6
+ * Load earlier rows above the ones in hand, preserving the reading position across the prepend. Assumes
7
+ * normal column flow. It does not scroll anywhere at mount, so a list meant to open at its newest row scrolls
8
+ * itself — and until it does, the sentinel is on screen and loads one window unasked.
9
+ */
10
10
  reverse?: boolean;
11
11
  }
12
- export declare const InfiniteScroll: ({ itemsPerPage, currentPage, total, onPageSelect, onAddPage, children, reverse, }: InfiniteScrollProps) => import("react/jsx-runtime").JSX.Element;
12
+ export declare const InfiniteScroll: ({ hasMore, onLoadMore, children, reverse }: InfiniteScrollProps) => import("react/jsx-runtime").JSX.Element;
@@ -1,14 +1,24 @@
1
1
  import type { SliceMeta } from "akanjs/fetch";
2
+ export interface DraftBarViewProps {
3
+ className?: string;
4
+ /** The model the recovered form belongs to. */
5
+ refName: string;
6
+ /**
7
+ * `conflict` is a decision the user has to settle — the record moved since the draft was taken, so the form
8
+ * shows the server's value and the bar offers the older one. `applied` is a notice: the draft is what is on
9
+ * screen, and the bar is the way back.
10
+ */
11
+ state: "conflict" | "applied";
12
+ /** When the draft was taken. */
13
+ savedAt: Date;
14
+ /** Puts the offered draft into the form. Passed in the `conflict` state only. */
15
+ onRestore?: () => void;
16
+ /** Drops the draft and keeps the form as it was opened. */
17
+ onDiscard: () => void;
18
+ }
2
19
  interface DraftBarProps {
3
20
  className?: string;
4
21
  slice: SliceMeta;
5
22
  }
6
- /**
7
- * What the user is told about a recovered form.
8
- *
9
- * Two states, and the difference is whether the draft is already in the form. A pending one is a conflict the
10
- * user has to settle — the record moved since the draft was taken, so the form shows the server's value and this
11
- * offers the older one. An applied one is just a notice: the draft is what is on screen, and this is the way back.
12
- */
13
23
  export default function DraftBar({ className, slice }: DraftBarProps): import("react/jsx-runtime").JSX.Element | null;
14
24
  export {};
@@ -1,5 +1,6 @@
1
1
  import type { SliceMeta } from "akanjs/fetch";
2
2
  import type { ReactNode } from "react";
3
+ import type { DraftProp } from "./draftScope.d.ts";
3
4
  interface EditProps {
4
5
  type?: "icon" | "button";
5
6
  className?: string;
@@ -11,6 +12,8 @@ interface EditProps {
11
12
  renderTitle?: ((model: {
12
13
  id: string;
13
14
  }) => string | ReactNode) | string;
15
+ /** Draft recovery for the form this opens. `false` turns it off; a string names the scope explicitly. */
16
+ draft?: DraftProp;
14
17
  }
15
- export default function Edit({ className, wrapperClassName, type, children, slice, modelId, modal, renderTitle, }: EditProps): import("react/jsx-runtime").JSX.Element;
18
+ export default function Edit({ className, wrapperClassName, type, children, slice, modelId, modal, renderTitle, draft, }: EditProps): import("react/jsx-runtime").JSX.Element;
16
19
  export {};
@@ -9,6 +9,8 @@ interface EditModelProps<Full> {
9
9
  slice: SliceMeta;
10
10
  /** Additional classes for the wrapper. */
11
11
  className?: string;
12
+ /** Additional classes for the recovered-form banner this shell draws above the form. */
13
+ draftBarClassName?: string;
12
14
  /** Re-check submit eligibility when form state changes. */
13
15
  checkSubmit?: boolean;
14
16
  /** Client edit promise or partial form seed. */
@@ -54,5 +56,5 @@ interface EditModalProps<Full extends {
54
56
  }
55
57
  export default function EditModal<Full extends {
56
58
  id: string;
57
- }>({ type, slice, id, className, disabled, checkSubmit, modalClassName, edit, modal, renderTitle, children, submitText, submitClassName, submitOption, renderSubmit, loadingWrapper, draft, onSubmit, onCancel, }: EditModalProps<Full>): import("react/jsx-runtime").JSX.Element | undefined;
59
+ }>({ type, slice, id, className, draftBarClassName, disabled, checkSubmit, modalClassName, edit, modal, renderTitle, children, submitText, submitClassName, submitOption, renderSubmit, loadingWrapper, draft, onSubmit, onCancel, }: EditModalProps<Full>): import("react/jsx-runtime").JSX.Element | undefined;
58
60
  export {};
@@ -2,7 +2,8 @@ interface MoreProps {
2
2
  total: number;
3
3
  itemsPerPage: number;
4
4
  currentPage: number;
5
- onAddPage: (page: number) => Promise<void>;
5
+ hasMore: boolean;
6
+ onLoadMore: () => Promise<void>;
6
7
  onPageSelect: (page: number, option?: {
7
8
  scrollToTop?: boolean;
8
9
  }) => void;
@@ -10,5 +11,5 @@ interface MoreProps {
10
11
  className?: string;
11
12
  reverse?: boolean;
12
13
  }
13
- export declare const More: ({ total, itemsPerPage, currentPage, onAddPage, onPageSelect, children, className, reverse, }: MoreProps) => import("react/jsx-runtime").JSX.Element;
14
+ export declare const More: ({ total, itemsPerPage, currentPage, hasMore, onLoadMore, onPageSelect, children, className, reverse, }: MoreProps) => import("react/jsx-runtime").JSX.Element;
14
15
  export {};
@@ -22,6 +22,7 @@ import type { SkeletonProps } from "../Loading/Skeleton.d.ts";
22
22
  import type { SpinProps } from "../Loading/Spin.d.ts";
23
23
  import type { MenuProps } from "../Menu.d.ts";
24
24
  import type { ModalProps } from "../Modal.d.ts";
25
+ import type { DraftBarViewProps } from "../Model/DraftBar.d.ts";
25
26
  import type { PaginationProps } from "../Pagination.d.ts";
26
27
  import type { PopconfirmProps } from "../Popconfirm.d.ts";
27
28
  import type { ItemProps as RadioItemProps, RadioProps } from "../Radio.d.ts";
@@ -54,6 +55,7 @@ export interface AkanUiOverrides {
54
55
  Menu: ComponentType<MenuProps>;
55
56
  Tooltip: ComponentType<TooltipProps>;
56
57
  Unauthorized: ComponentType<UnauthorizedProps>;
58
+ DraftBar: ComponentType<DraftBarViewProps>;
57
59
  AgentChat: ComponentType<AgentChatProps>;
58
60
  AgentLauncher: ComponentType<AgentLauncherProps>;
59
61
  AgentBubble: ComponentType<AgentBubbleProps>;
@@ -3,56 +3,90 @@ import { useEffect, useRef, useState } from "react";
3
3
  import { BiLoaderAlt } from "react-icons/bi";
4
4
 
5
5
  export interface InfiniteScrollProps {
6
- total: number;
7
- currentPage: number;
8
- itemsPerPage: number;
9
- onAddPage: (page: number) => Promise<void>;
10
- onPageSelect: (page: number, option?: { scrollToTop?: boolean }) => void;
6
+ hasMore: boolean;
7
+ onLoadMore: () => Promise<void>;
11
8
  children: React.ReactNode;
9
+ /**
10
+ * Load earlier rows above the ones in hand, preserving the reading position across the prepend. Assumes
11
+ * normal column flow. It does not scroll anywhere at mount, so a list meant to open at its newest row scrolls
12
+ * itself — and until it does, the sentinel is on screen and loads one window unasked.
13
+ */
12
14
  reverse?: boolean;
13
15
  }
14
16
 
15
- export const InfiniteScroll = ({
16
- itemsPerPage,
17
- currentPage,
18
- total,
19
- onPageSelect,
20
- onAddPage,
21
- children,
22
- reverse,
23
- }: InfiniteScrollProps) => {
17
+ let warnedColumnReverse = false;
18
+
19
+ /**
20
+ * The sentinel is positioned by DOM order alone — first child to load earlier, last child to load more — so a
21
+ * `column-reverse` parent paints it at the opposite end from the rows it controls, and `scrollTop: 0` is then
22
+ * that same end, so it also fires at mount. `flex-col-reverse` is the usual no-JS way to pin a chat to the
23
+ * bottom, so a caller reaching for `reverse` may well already have it; the result reads as a control placed
24
+ * wrongly rather than as an error, which is why it is worth saying out loud once.
25
+ */
26
+ const warnColumnReverse = (sentinel: Element | null) => {
27
+ if (warnedColumnReverse || process.env.AKAN_PUBLIC_ENV !== "local") return;
28
+ const parent = sentinel?.parentElement;
29
+ if (!parent || getComputedStyle(parent).flexDirection !== "column-reverse") return;
30
+ warnedColumnReverse = true;
31
+ console.warn(
32
+ "<InfiniteScroll> sits in a `flex-col-reverse` parent, which paints its load sentinel at the end opposite the rows it loads, and fires it at mount. Drop `flex-col-reverse` and let `reverse` hold the reading position instead.",
33
+ );
34
+ };
35
+
36
+ const scrollableOverflows = new Set(["auto", "scroll", "overlay"]);
37
+
38
+ /**
39
+ * The element that actually scrolls the sentinel — the document only once no ancestor has taken the job.
40
+ *
41
+ * A chat timeline or a log tail scrolls inside its own `overflow-y-auto` box, and there the document does not
42
+ * move at all, so anchoring `document.scrollingElement` restores a position nothing changed. Resolved per load
43
+ * rather than once, because the box that scrolls is a layout outcome and a caller cannot be asked to name it.
44
+ */
45
+ const scrollerOf = (sentinel: Element | null) => {
46
+ if (typeof document === "undefined") return null;
47
+ let el = sentinel?.parentElement ?? null;
48
+ while (el && el !== document.body && el !== document.documentElement) {
49
+
50
+ if (el.scrollHeight > el.clientHeight && scrollableOverflows.has(getComputedStyle(el).overflowY)) return el;
51
+ el = el.parentElement;
52
+ }
53
+ return document.scrollingElement;
54
+ };
55
+
56
+ export const InfiniteScroll = ({ hasMore, onLoadMore, children, reverse }: InfiniteScrollProps) => {
24
57
  const [isFetching, setIsFetching] = useState(false);
25
58
  const isFetchingRef = useRef(false);
26
59
  const target = useRef<HTMLDivElement>(null);
27
- const page = useRef<number>(currentPage);
28
- const totalPages = Math.ceil(total / (itemsPerPage || 1));
29
60
 
30
61
  useEffect(() => {
31
- const observer = new IntersectionObserver((entries) => {
32
- const [entry] = entries;
33
- if (entry.isIntersecting) void fetchMoreItems();
34
- });
62
+
63
+ warnColumnReverse(target.current);
64
+ const scroller = scrollerOf(target.current);
65
+ const root = scroller && scroller !== document.scrollingElement ? scroller : null;
66
+ const observer = new IntersectionObserver(
67
+ (entries) => {
68
+ const [entry] = entries;
69
+ if (entry.isIntersecting) void fetchMoreItems();
70
+ },
71
+ { root },
72
+ );
35
73
  if (target.current) observer.observe(target.current);
36
74
  return () => {
37
75
  observer.disconnect();
38
76
  };
39
- }, []);
77
+ }, [hasMore]);
40
78
 
41
79
  const fetchMoreItems = async () => {
42
80
  if (isFetchingRef.current) return;
43
- const nextPage = page.current + 1;
44
- if (nextPage > totalPages) return;
45
81
 
46
- const scroller = reverse ? document.scrollingElement : null;
82
+ const scroller = reverse ? scrollerOf(target.current) : null;
47
83
  const prevScrollHeight = scroller?.scrollHeight ?? 0;
48
84
  const prevScrollTop = scroller?.scrollTop ?? 0;
49
85
 
50
86
  isFetchingRef.current = true;
51
87
  setIsFetching(true);
52
88
  try {
53
- await onAddPage(nextPage);
54
- onPageSelect(nextPage, { scrollToTop: false });
55
- page.current = nextPage;
89
+ await onLoadMore();
56
90
 
57
91
  const restoreScroll = () => {
58
92
  if (scroller) {
@@ -76,15 +110,15 @@ export const InfiniteScroll = ({
76
110
 
77
111
  return (
78
112
  <>
79
- {reverse ? (
113
+ {reverse && hasMore ? (
80
114
  <div ref={target} className="flex w-full items-end justify-center">
81
- {isFetching && <BiLoaderAlt className="h-10 animate-spin pb-4 text-2xl" />}
115
+ {isFetching ? <BiLoaderAlt className="h-10 animate-spin pb-4 text-2xl" /> : null}
82
116
  </div>
83
117
  ) : null}
84
118
  {children}
85
- {!reverse ? (
119
+ {!reverse && hasMore ? (
86
120
  <div ref={target} className="flex h-32 w-full justify-center pt-4">
87
- {isFetching && <BiLoaderAlt className="animate-spin text-2xl" />}
121
+ {isFetching ? <BiLoaderAlt className="animate-spin text-2xl" /> : null}
88
122
  </div>
89
123
  ) : null}
90
124
  </>
@@ -39,7 +39,6 @@ function Render<RefName extends string, Light>({ className, init, scrollToTop }:
39
39
  lastPageOfModel: `lastPageOf${ModelName}`,
40
40
  limitOfModel: `limitOf${ModelName}`,
41
41
  setPageOfModel: `setPageOf${ModelName}`,
42
- addPageOfModel: `addPageOf${ModelName}`,
43
42
  };
44
43
  const namesOfSlice = {
45
44
  modelInsight: sliceName.replace(names.model, names.modelInsight),
@@ -47,7 +46,6 @@ function Render<RefName extends string, Light>({ className, init, scrollToTop }:
47
46
  lastPageOfModel: sliceName.replace(names.model, names.lastPageOfModel),
48
47
  pageOfModel: sliceName.replace(names.model, names.pageOfModel),
49
48
  setPageOfModel: sliceName.replace(names.model, names.setPageOfModel),
50
- addPageOfModel: sliceName.replace(names.model, names.addPageOfModel),
51
49
  };
52
50
  const modelInsight = storeUse[namesOfSlice.modelInsight]() as BaseInsight;
53
51
  const limitOfModel = storeUse[namesOfSlice.limitOfModel]() as number;
package/ui/Load/Units.tsx CHANGED
@@ -85,10 +85,12 @@ function Render<RefName extends string, Light extends { id: string }>({
85
85
  pageOfModel: `pageOf${ModelName}`,
86
86
  lastPageOfModel: `lastPageOf${ModelName}`,
87
87
  limitOfModel: `limitOf${ModelName}`,
88
+ hasMoreOfModel: `hasMoreOf${ModelName}`,
89
+ isCumulativeOfModel: `isCumulativeOf${ModelName}`,
88
90
  queryArgsOfModel: `queryArgsOf${ModelName}`,
89
91
  sortOfModel: `sortOf${ModelName}`,
90
92
  setPageOfModel: `setPageOf${ModelName}`,
91
- addPageOfModel: `addPageOf${ModelName}`,
93
+ loadMoreOfModel: `loadMoreOf${ModelName}`,
92
94
  refreshModel: `refresh${ModelName}`,
93
95
  watchLiveModel: `watchLive${ModelName}`,
94
96
  };
@@ -102,10 +104,12 @@ function Render<RefName extends string, Light extends { id: string }>({
102
104
  pageOfModel: sliceName.replace(names.model, names.pageOfModel),
103
105
  lastPageOfModel: sliceName.replace(names.model, names.lastPageOfModel),
104
106
  limitOfModel: sliceName.replace(names.model, names.limitOfModel),
107
+ hasMoreOfModel: sliceName.replace(names.model, names.hasMoreOfModel),
108
+ isCumulativeOfModel: sliceName.replace(names.model, names.isCumulativeOfModel),
105
109
  queryArgsOfModel: sliceName.replace(names.model, names.queryArgsOfModel),
106
110
  sortOfModel: sliceName.replace(names.model, names.sortOfModel),
107
111
  setPageOfModel: sliceName.replace(names.model, names.setPageOfModel),
108
- addPageOfModel: sliceName.replace(names.model, names.addPageOfModel),
112
+ loadMoreOfModel: sliceName.replace(names.model, names.loadMoreOfModel),
109
113
  refreshModel: sliceName.replace(names.model, names.refreshModel),
110
114
  watchLiveModel: sliceName.replace(names.model, names.watchLiveModel),
111
115
  };
@@ -116,6 +120,7 @@ function Render<RefName extends string, Light extends { id: string }>({
116
120
  const initModelObjInsight = (init as DynamicRecord)[names.modelObjInsight] as BaseInsight | null;
117
121
  const initLimitOfModel = (init as DynamicRecord)[names.limitOfModel] as number;
118
122
  const initPageOfModel = (init as DynamicRecord)[names.pageOfModel] as number;
123
+ const initHasMoreOfModel = (init as DynamicRecord)[names.hasMoreOfModel] as boolean;
119
124
  const initSignature = JSON.stringify(initQueryArgs);
120
125
 
121
126
  const useCache =
@@ -155,6 +160,9 @@ function Render<RefName extends string, Light extends { id: string }>({
155
160
  [namesOfSlice.pageOfModel]: initPageOfModel,
156
161
  [namesOfSlice.lastPageOfModel]: initLastPageOfModel,
157
162
  [namesOfSlice.limitOfModel]: initLimitOfModel,
163
+
164
+ [namesOfSlice.hasMoreOfModel]: initHasMoreOfModel,
165
+ [namesOfSlice.isCumulativeOfModel]: false,
158
166
  [namesOfSlice.queryArgsOfModel]: initQueryArgsOfModel,
159
167
  [namesOfSlice.sortOfModel]: initSortOfModel,
160
168
  });
@@ -180,6 +188,7 @@ function Render<RefName extends string, Light extends { id: string }>({
180
188
  const modelInsight = storeUse[namesOfSlice.modelInsight]() as BaseInsight;
181
189
  const limitOfModel = storeUse[namesOfSlice.limitOfModel]() as number;
182
190
  const pageOfModel = storeUse[namesOfSlice.pageOfModel]() as number;
191
+ const hasMoreOfModel = storeUse[namesOfSlice.hasMoreOfModel]() as boolean;
183
192
  const insight = loaded ? modelInsight : initModelObjInsight;
184
193
  const limit = loaded ? limitOfModel : initLimitOfModel;
185
194
  const page = loaded ? pageOfModel : initPageOfModel;
@@ -188,8 +197,9 @@ function Render<RefName extends string, Light extends { id: string }>({
188
197
  total,
189
198
  currentPage: page,
190
199
  itemsPerPage: limit || total,
191
- onAddPage: async (page: number) => {
192
- await storeDo[namesOfSlice.addPageOfModel](page);
200
+ hasMore: loaded ? hasMoreOfModel : initHasMoreOfModel,
201
+ onLoadMore: async () => {
202
+ await storeDo[namesOfSlice.loadMoreOfModel]();
193
203
  },
194
204
  onPageSelect: (page: number, option?: { scrollToTop?: boolean }) => {
195
205
  void storeDo[namesOfSlice.setPageOfModel](page);
@@ -342,7 +352,8 @@ interface MoreProps {
342
352
  total: number;
343
353
  itemsPerPage: number;
344
354
  currentPage: number;
345
- onAddPage: (page: number) => Promise<void>;
355
+ hasMore: boolean;
356
+ onLoadMore: () => Promise<void>;
346
357
  onPageSelect: (page: number, option?: { scrollToTop?: boolean }) => void;
347
358
  children?: React.ReactNode;
348
359
  className?: string;
@@ -9,21 +9,75 @@ import { AiOutlineDelete, AiOutlineHistory, AiOutlineRollback } from "react-icon
9
9
  import { agentAttrs } from "../agentAttrs";
10
10
  import { Button } from "../Button";
11
11
  import { RecentTime } from "../RecentTime";
12
+ import { createOverridable } from "../UiOverride";
12
13
 
13
- interface DraftBarProps {
14
+ export interface DraftBarViewProps {
14
15
  className?: string;
15
- slice: SliceMeta;
16
+ /** The model the recovered form belongs to. */
17
+ refName: string;
18
+ /**
19
+ * `conflict` is a decision the user has to settle — the record moved since the draft was taken, so the form
20
+ * shows the server's value and the bar offers the older one. `applied` is a notice: the draft is what is on
21
+ * screen, and the bar is the way back.
22
+ */
23
+ state: "conflict" | "applied";
24
+ /** When the draft was taken. */
25
+ savedAt: Date;
26
+ /** Puts the offered draft into the form. Passed in the `conflict` state only. */
27
+ onRestore?: () => void;
28
+ /** Drops the draft and keeps the form as it was opened. */
29
+ onDiscard: () => void;
16
30
  }
17
31
 
32
+ const DefaultDraftBar = ({ className, state, savedAt, onRestore, onDiscard }: DraftBarViewProps) => {
33
+ const { l } = usePage();
34
+ if (state === "conflict")
35
+ return (
36
+ <div
37
+ className={cn(
38
+ "mb-4 flex flex-wrap items-center gap-2 rounded-box border border-warning/40 bg-warning/10 p-3",
39
+ className,
40
+ )}
41
+ >
42
+ <AiOutlineHistory className="text-warning" />
43
+ <span className="flex-1 text-foreground/80 text-sm">
44
+ {l("base.draftConflict")} <RecentTime date={savedAt} />
45
+ </span>
46
+ <Button {...agentAttrs(onRestore)} size="sm" onClick={() => onRestore?.()}>
47
+ <AiOutlineRollback /> {l("base.draftRestore")}
48
+ </Button>
49
+ <Button {...agentAttrs(onDiscard)} size="sm" variant="ghost" onClick={() => onDiscard()}>
50
+ <AiOutlineDelete /> {l("base.draftDiscard")}
51
+ </Button>
52
+ </div>
53
+ );
54
+ return (
55
+ <div className={cn("mb-4 flex flex-wrap items-center gap-2 text-foreground/60 text-xs", className)}>
56
+ <AiOutlineHistory />
57
+ <span className="flex-1">
58
+ {l("base.draftApplied")} <RecentTime date={savedAt} />
59
+ </span>
60
+ <Button {...agentAttrs(onDiscard)} size="xs" variant="ghost" onClick={() => onDiscard()}>
61
+ {l("base.draftStartOver")}
62
+ </Button>
63
+ </div>
64
+ );
65
+ };
66
+
18
67
  /**
19
- * What the user is told about a recovered form.
68
+ * The banner itself, route-overridable through `page/**\/_overrides.tsx` (slot `DraftBar`).
20
69
  *
21
- * Two states, and the difference is whether the draft is already in the form. A pending one is a conflict the
22
- * user has to settle — the record moved since the draft was taken, so the form shows the server's value and this
23
- * offers the older one. An applied one is just a notice: the draft is what is on screen, and this is the way back.
70
+ * The shell below keeps the draft state and publishes the two agent tools, so a replacement re-skins the notice
71
+ * without reaching into the store under string keys or re-declaring what an agent may pull.
24
72
  */
73
+ const DraftBarView = createOverridable("DraftBar", DefaultDraftBar);
74
+
75
+ interface DraftBarProps {
76
+ className?: string;
77
+ slice: SliceMeta;
78
+ }
79
+
25
80
  export default function DraftBar({ className, slice }: DraftBarProps) {
26
- const { l } = usePage();
27
81
  const { refName } = slice;
28
82
  const [modelName, ModelName] = useMemo(() => [lowerlize(refName), capitalize(refName)], []);
29
83
  const names = useMemo(
@@ -50,35 +104,24 @@ export default function DraftBar({ className, slice }: DraftBarProps) {
50
104
 
51
105
  if (draft?.pending)
52
106
  return (
53
- <div
54
- className={cn(
55
- "mb-4 flex flex-wrap items-center gap-2 rounded-box border border-warning/40 bg-warning/10 p-3",
56
- className,
57
- )}
58
- >
59
- <AiOutlineHistory className="text-warning" />
60
- <span className="flex-1 text-foreground/80 text-sm">
61
- {l("base.draftConflict")} <RecentTime date={draft.pending.savedAt} />
62
- </span>
63
- <Button {...agentAttrs(restoreDraft)} size="sm" onClick={() => restoreDraft()}>
64
- <AiOutlineRollback /> {l("base.draftRestore")}
65
- </Button>
66
- <Button {...agentAttrs(discardDraft)} size="sm" variant="ghost" onClick={() => discardDraft()}>
67
- <AiOutlineDelete /> {l("base.draftDiscard")}
68
- </Button>
69
- </div>
107
+ <DraftBarView
108
+ className={className}
109
+ refName={refName}
110
+ state="conflict"
111
+ savedAt={draft.pending.savedAt}
112
+ onRestore={restoreDraft}
113
+ onDiscard={discardDraft}
114
+ />
70
115
  );
71
116
  if (draft?.appliedAt)
72
117
  return (
73
- <div className={cn("mb-4 flex flex-wrap items-center gap-2 text-foreground/60 text-xs", className)}>
74
- <AiOutlineHistory />
75
- <span className="flex-1">
76
- {l("base.draftApplied")} <RecentTime date={draft.appliedAt} />
77
- </span>
78
- <Button {...agentAttrs(discardDraft)} size="xs" variant="ghost" onClick={() => discardDraft()}>
79
- {l("base.draftStartOver")}
80
- </Button>
81
- </div>
118
+ <DraftBarView
119
+ className={className}
120
+ refName={refName}
121
+ state="applied"
122
+ savedAt={draft.appliedAt}
123
+ onDiscard={discardDraft}
124
+ />
82
125
  );
83
126
  return null;
84
127
  }
package/ui/Model/Edit.tsx CHANGED
@@ -3,6 +3,7 @@ import type { SliceMeta } from "akanjs/fetch";
3
3
  import type { ReactNode } from "react";
4
4
  import { AiOutlineEdit } from "react-icons/ai";
5
5
 
6
+ import type { DraftProp } from "./draftScope";
6
7
  import EditModal from "./EditModal";
7
8
  import EditWrapper from "./EditWrapper";
8
9
 
@@ -15,6 +16,8 @@ interface EditProps {
15
16
  modelId: string;
16
17
  modal?: string | null;
17
18
  renderTitle?: ((model: { id: string }) => string | ReactNode) | string;
19
+ /** Draft recovery for the form this opens. `false` turns it off; a string names the scope explicitly. */
20
+ draft?: DraftProp;
18
21
  }
19
22
 
20
23
  export default function Edit({
@@ -26,6 +29,7 @@ export default function Edit({
26
29
  modelId,
27
30
  modal,
28
31
  renderTitle,
32
+ draft,
29
33
  }: EditProps) {
30
34
  const { l } = usePage();
31
35
  return (
@@ -35,10 +39,11 @@ export default function Edit({
35
39
  slice={slice}
36
40
  modelId={modelId}
37
41
  modal={modal}
42
+ draft={draft}
38
43
  >
39
44
  <AiOutlineEdit /> {type === "button" ? l("base.edit") : null}
40
45
  </EditWrapper>
41
- <EditModal renderTitle={renderTitle} slice={slice} id={modelId}>
46
+ <EditModal renderTitle={renderTitle} slice={slice} id={modelId} draft={draft}>
42
47
  {children}
43
48
  </EditModal>
44
49
  </div>
@@ -27,6 +27,8 @@ interface EditModelProps<Full> {
27
27
  slice: SliceMeta;
28
28
  /** Additional classes for the wrapper. */
29
29
  className?: string;
30
+ /** Additional classes for the recovered-form banner this shell draws above the form. */
31
+ draftBarClassName?: string;
30
32
  /** Re-check submit eligibility when form state changes. */
31
33
  checkSubmit?: boolean;
32
34
  /** Client edit promise or partial form seed. */
@@ -52,6 +54,7 @@ const EditModel = <Full,>({
52
54
  type = "modal",
53
55
  slice,
54
56
  className,
57
+ draftBarClassName,
55
58
  checkSubmit = true,
56
59
  edit,
57
60
  modal,
@@ -107,7 +110,7 @@ const EditModel = <Full,>({
107
110
 
108
111
  return (
109
112
  <LoadingWrapper className={cn("w-full", className)}>
110
- <DraftBar slice={slice} />
113
+ <DraftBar className={draftBarClassName} slice={slice} />
111
114
  {children}
112
115
  </LoadingWrapper>
113
116
  );
@@ -142,6 +145,7 @@ export default function EditModal<Full extends { id: string }>({
142
145
  slice,
143
146
  id,
144
147
  className,
148
+ draftBarClassName,
145
149
  disabled,
146
150
  checkSubmit = true,
147
151
  modalClassName,
@@ -368,6 +372,7 @@ export default function EditModal<Full extends { id: string }>({
368
372
  type={type}
369
373
  slice={slice}
370
374
  className={className}
375
+ draftBarClassName={draftBarClassName}
371
376
  checkSubmit={checkSubmit}
372
377
  edit={edit}
373
378
  modal={modal}
@@ -385,6 +390,7 @@ export default function EditModal<Full extends { id: string }>({
385
390
  type={type}
386
391
  slice={slice}
387
392
  className={className}
393
+ draftBarClassName={draftBarClassName}
388
394
  checkSubmit={checkSubmit}
389
395
  edit={edit}
390
396
  modal={modal}
package/ui/More.tsx CHANGED
@@ -8,7 +8,8 @@ interface MoreProps {
8
8
  total: number;
9
9
  itemsPerPage: number;
10
10
  currentPage: number;
11
- onAddPage: (page: number) => Promise<void>;
11
+ hasMore: boolean;
12
+ onLoadMore: () => Promise<void>;
12
13
  onPageSelect: (page: number, option?: { scrollToTop?: boolean }) => void;
13
14
  children?: React.ReactNode;
14
15
  className?: string;
@@ -19,7 +20,8 @@ export const More = ({
19
20
  total,
20
21
  itemsPerPage,
21
22
  currentPage,
22
- onAddPage,
23
+ hasMore,
24
+ onLoadMore,
23
25
  onPageSelect,
24
26
  children,
25
27
  className,
@@ -31,24 +33,15 @@ export const More = ({
31
33
  setIsMobile(isMobileDevice());
32
34
  }, []);
33
35
 
34
- if (total <= itemsPerPage) {
35
- return <>{children}</>;
36
- }
37
-
38
- if (isMobile) {
36
+ if (isMobile)
39
37
  return (
40
- <InfiniteScroll
41
- total={total}
42
- currentPage={currentPage}
43
- itemsPerPage={itemsPerPage}
44
- onAddPage={onAddPage}
45
- onPageSelect={onPageSelect}
46
- reverse={reverse}
47
- >
38
+ <InfiniteScroll hasMore={hasMore} onLoadMore={onLoadMore} reverse={reverse}>
48
39
  {children}
49
40
  </InfiniteScroll>
50
41
  );
51
- }
42
+
43
+ if (total <= itemsPerPage) return <>{children}</>;
44
+
52
45
  return (
53
46
  <>
54
47
  {children}
@@ -24,6 +24,7 @@ import type { SkeletonProps } from "../Loading/Skeleton";
24
24
  import type { SpinProps } from "../Loading/Spin";
25
25
  import type { MenuProps } from "../Menu";
26
26
  import type { ModalProps } from "../Modal";
27
+ import type { DraftBarViewProps } from "../Model/DraftBar";
27
28
  import type { PaginationProps } from "../Pagination";
28
29
  import type { PopconfirmProps } from "../Popconfirm";
29
30
  import type { ItemProps as RadioItemProps, RadioProps } from "../Radio";
@@ -58,6 +59,8 @@ export interface AkanUiOverrides {
58
59
  Menu: ComponentType<MenuProps>;
59
60
  Tooltip: ComponentType<TooltipProps>;
60
61
  Unauthorized: ComponentType<UnauthorizedProps>;
62
+
63
+ DraftBar: ComponentType<DraftBarViewProps>;
61
64
  AgentChat: ComponentType<AgentChatProps>;
62
65
 
63
66
  AgentLauncher: ComponentType<AgentLauncherProps>;