akanjs 3.0.0-beta.7 → 3.0.0-beta.8

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.
Files changed (60) hide show
  1. package/common/index.ts +1 -0
  2. package/common/pathGet.ts +12 -2
  3. package/common/pathSet.ts +2 -3
  4. package/common/toPathSegments.ts +10 -0
  5. package/dictionary/base.dictionary.ts +5 -0
  6. package/index.ts +5 -0
  7. package/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
  8. package/local/apps/serverLifecycle/serverLifecycle-local_solid.db-shm +0 -0
  9. package/package.json +1 -1
  10. package/service/agent.service.ts +92 -1
  11. package/service/predefinedAdaptor/llm.adaptor.ts +17 -0
  12. package/store/agentic/index.ts +1 -0
  13. package/store/agentic/useAgentReference.ts +49 -0
  14. package/store/hooks.ts +1 -1
  15. package/types/common/index.d.ts +1 -0
  16. package/types/common/toPathSegments.d.ts +9 -0
  17. package/types/dictionary/base.dictionary.d.ts +1 -1
  18. package/types/dictionary/dictionary.d.ts +8 -8
  19. package/types/index.d.ts +5 -0
  20. package/types/service/agent.service.d.ts +54 -0
  21. package/types/service/predefinedAdaptor/llm.adaptor.d.ts +16 -0
  22. package/types/store/agentic/index.d.ts +1 -0
  23. package/types/store/agentic/useAgentReference.d.ts +32 -0
  24. package/types/store/hooks.d.ts +1 -1
  25. package/types/ui/Agent/Chat.d.ts +11 -1
  26. package/types/ui/Agent/Composer.d.ts +9 -2
  27. package/types/ui/Agent/Menu.d.ts +4 -2
  28. package/types/ui/Agent/Refer.d.ts +13 -0
  29. package/types/ui/Agent/useChatQueue.d.ts +3 -1
  30. package/types/ui/Agent/useChatReferences.d.ts +26 -0
  31. package/types/ui/Agent/useReferenceMenu.d.ts +43 -0
  32. package/types/ui/Field/lightRefCache.d.ts +19 -0
  33. package/types/ui/Field/useRelationOptions.d.ts +39 -0
  34. package/types/ui/Select.d.ts +2 -0
  35. package/types/ui/index.d.ts +4 -1
  36. package/types/vendor/use-agentic/AgentSession.d.ts +54 -1
  37. package/types/vendor/use-agentic/Reference.d.ts +56 -0
  38. package/types/vendor/use-agentic/index.d.ts +1 -0
  39. package/types/vendor/use-agentic/types.d.ts +36 -6
  40. package/ui/Agent/Bubble.tsx +2 -0
  41. package/ui/Agent/Chat.tsx +38 -8
  42. package/ui/Agent/Composer.tsx +18 -1
  43. package/ui/Agent/Menu.tsx +8 -3
  44. package/ui/Agent/Queued.tsx +2 -0
  45. package/ui/Agent/Refer.tsx +44 -0
  46. package/ui/Agent/sessionHistory.ts +35 -12
  47. package/ui/Agent/useChatQueue.ts +11 -1
  48. package/ui/Agent/useChatReferences.ts +67 -0
  49. package/ui/Agent/useReferenceMenu.ts +108 -0
  50. package/ui/Field/Relation.tsx +70 -150
  51. package/ui/Field/lightRefCache.ts +73 -0
  52. package/ui/Field/useRelationOptions.ts +106 -0
  53. package/ui/Select.tsx +24 -14
  54. package/ui/index.ts +8 -0
  55. package/vendor/use-agentic/AgentSession.ts +127 -1
  56. package/vendor/use-agentic/Compaction.ts +6 -0
  57. package/vendor/use-agentic/Reference.ts +99 -0
  58. package/vendor/use-agentic/Transcript.ts +7 -1
  59. package/vendor/use-agentic/index.ts +1 -0
  60. package/vendor/use-agentic/types.ts +38 -7
@@ -1,13 +1,28 @@
1
1
  "use client";
2
2
  import type { DataList } from "akanjs/base";
3
3
  import { cn } from "akanjs/client";
4
- import { capitalize, lowerlize } from "akanjs/common";
5
4
  import type { SliceMeta } from "akanjs/fetch";
6
- import { st, useFieldTool, useRelationFieldTool } from "akanjs/store";
5
+ import { useFieldTool, useRelationFieldTool } from "akanjs/store";
7
6
  import type { ReactNode } from "react";
8
7
  import { agentAttrs } from "../agentAttrs";
9
8
  import { Select } from "../Select";
10
9
  import { Label } from "./Label";
10
+ import { useRelationOptions } from "./useRelationOptions";
11
+
12
+ interface UnresolvedProps {
13
+ id: string;
14
+ }
15
+ /** A row nothing could resolve — removed, unreadable, or still being read. The id beats an empty control. */
16
+ const Unresolved = ({ id }: UnresolvedProps) => <span className="font-mono text-foreground/45">{id}</span>;
17
+
18
+ const renderRow = <Light extends { id: string }>(
19
+ models: DataList<Light>,
20
+ id: string,
21
+ render: (model: Light) => ReactNode,
22
+ ) => {
23
+ const model = models.get(id);
24
+ return model ? render(model) : <Unresolved id={id} />;
25
+ };
11
26
 
12
27
  export interface ParentProps<Light> {
13
28
  label?: string;
@@ -26,12 +41,6 @@ export interface ParentProps<Light> {
26
41
  renderOption: (model: Light) => ReactNode;
27
42
  renderSelected?: (value: Light) => ReactNode;
28
43
  }
29
- /** The one line an option renders as, so an agent can match an id against what it reads on screen. */
30
- const optionLabel = <Light extends { id: string }>(model: Light, render: (model: Light) => ReactNode) => {
31
- const rendered = render(model);
32
- return typeof rendered === "string" ? rendered : model.id;
33
- };
34
-
35
44
  export const Parent = <Light extends { id: string }>({
36
45
  label,
37
46
  desc,
@@ -49,32 +58,15 @@ export const Parent = <Light extends { id: string }>({
49
58
  renderOption,
50
59
  renderSelected = renderOption,
51
60
  }: ParentProps<Light>) => {
52
- const { refName, sliceName } = slice;
53
- const [modelName, ModelName] = [lowerlize(refName), capitalize(refName)];
54
- const storeUse = st.use as { [key: string]: () => unknown };
55
- const storeDo = st.do as unknown as { [key: string]: (...args: any[]) => Promise<void> };
56
- const storeGet = st.get as unknown as <V>() => { [key: string]: V };
57
-
58
- const names = {
59
- model: modelName,
60
- modelList: `${modelName}List`,
61
- modelListLoading: `${modelName}ListLoading`,
62
- refreshModel: `refresh${ModelName}`,
63
- };
64
-
65
- const namesOfSlice = {
66
- modelList: sliceName.replace(names.model, names.modelList),
67
- modelListLoading: sliceName.replace(names.model, names.modelListLoading),
68
- refreshModel: sliceName.replace(names.model, names.refreshModel),
69
- };
70
-
71
- const modelList = storeUse[namesOfSlice.modelList]() as DataList<Light>;
72
- useRelationFieldTool(onChange, {
73
- read: () => storeGet<DataList<Light>>()[namesOfSlice.modelList],
74
- load: () => storeDo[namesOfSlice.refreshModel]({ invalidate: true, queryArgs: initArgs }),
75
- label: (model) => optionLabel(model, renderOption),
76
- disabled,
61
+ const { models, options, optionLabel, listLoading, load, read } = useRelationOptions<Light>({
62
+ slice,
63
+ ids: value ? [value.id] : [],
64
+ pinned: [value],
65
+ initArgs,
66
+ sortOption,
67
+ renderOption,
77
68
  });
69
+ useRelationFieldTool(onChange, { read, load, label: optionLabel, disabled });
78
70
 
79
71
  return (
80
72
  <div {...agentAttrs(onChange)} className={cn("flex flex-col", className)}>
@@ -84,29 +76,16 @@ export const Parent = <Light extends { id: string }>({
84
76
  selectClassName={selectClassName}
85
77
  value={value?.id ?? null}
86
78
  searchable
87
- options={modelList.map((model) => {
88
- const render = renderOption(model);
89
- return { label: typeof render === "string" ? render : model.id, value: model.id };
90
- })}
91
- renderOption={(modelId) => {
92
- if (!modelId) return null;
93
- const model = modelList.get(modelId);
94
- if (!model) return null;
95
- return renderOption(model);
96
- }}
97
- renderSelected={(modelId) => {
98
- if (!modelId) return null;
99
- const model = modelList.get(modelId);
100
- if (!model) return null;
101
- return renderSelected(model);
102
- }}
79
+ loading={listLoading}
80
+ options={options}
81
+ renderOption={(modelId) => (modelId ? renderRow(models, modelId, renderOption) : null)}
82
+ renderSelected={(modelId) => (modelId ? renderRow(models, modelId, renderSelected) : null)}
103
83
  onChange={(modelId) => {
104
- if (modelId) onChange(modelList.get(modelId) ?? null);
105
- else onChange(null);
84
+ onChange(modelId ? (models.get(modelId) ?? null) : null);
106
85
  }}
107
86
  onOpen={() => {
108
87
  if (disabled) return;
109
- void storeDo[namesOfSlice.refreshModel]({ invalidate: true, queryArgs: initArgs });
88
+ void load();
110
89
  }}
111
90
  onSearch={onSearch}
112
91
  />
@@ -148,22 +127,13 @@ export const ParentId = <Light extends { id: string }>({
148
127
  renderOption,
149
128
  renderSelected = renderOption,
150
129
  }: ParentIdProps<Light>) => {
151
- const { refName, sliceName } = slice;
152
- const [modelName, ModelName] = [lowerlize(refName), capitalize(refName)];
153
- const storeUse = st.use as { [key: string]: () => unknown };
154
- const storeDo = st.do as unknown as { [key: string]: (...args: any[]) => Promise<void> };
155
- const names = {
156
- model: modelName,
157
- modelList: `${modelName}List`,
158
- modelListLoading: `${modelName}ListLoading`,
159
- refreshModel: `refresh${ModelName}`,
160
- };
161
- const namesOfSlice = {
162
- modelList: sliceName.replace(names.model, names.modelList),
163
- modelListLoading: sliceName.replace(names.model, names.modelListLoading),
164
- refreshModel: sliceName.replace(names.model, names.refreshModel),
165
- };
166
- const modelList = storeUse[namesOfSlice.modelList]() as DataList<Light>;
130
+ const { models, options, optionLabel, listLoading, load } = useRelationOptions<Light>({
131
+ slice,
132
+ ids: value ? [value] : [],
133
+ initArgs,
134
+ sortOption,
135
+ renderOption,
136
+ });
167
137
 
168
138
  useFieldTool(onChange, { disabled });
169
139
 
@@ -175,25 +145,16 @@ export const ParentId = <Light extends { id: string }>({
175
145
  searchable
176
146
  selectClassName={selectClassName}
177
147
  value={value}
178
- options={modelList.map((model) => model.id)}
179
- renderOption={(renderId) => {
180
- if (!renderId) return null;
181
- const model = modelList.get(renderId);
182
- if (!model) return null;
183
- return renderOption?.(model) ?? null;
184
- }}
185
- renderSelected={(renderId) => {
186
- if (!renderId) return null;
187
- const model = modelList.get(renderId);
188
- if (!model) return null;
189
- return renderSelected?.(model) ?? null;
190
- }}
148
+ loading={listLoading}
149
+ options={options}
150
+ renderOption={(renderId) => (renderId ? renderRow(models, renderId, renderOption ?? optionLabel) : null)}
151
+ renderSelected={(renderId) => (renderId ? renderRow(models, renderId, renderSelected ?? optionLabel) : null)}
191
152
  onOpen={() => {
192
153
  if (disabled) return;
193
- void storeDo[namesOfSlice.refreshModel]({ invalidate: true, queryArgs: initArgs });
154
+ void load();
194
155
  }}
195
156
  onChange={(modelId) => {
196
- if (modelId) onChange(modelId, modelList.get(modelId) ?? null);
157
+ if (modelId) onChange(modelId, models.get(modelId) ?? null);
197
158
  else onChange(null, null);
198
159
  }}
199
160
  onSearch={onSearch}
@@ -236,29 +197,15 @@ export const Children = <Light extends { id: string }>({
236
197
  renderOption,
237
198
  renderSelected = renderOption,
238
199
  }: ChildrenProps<Light>) => {
239
- const { refName, sliceName } = slice;
240
- const [modelName, ModelName] = [lowerlize(refName), capitalize(refName)];
241
- const storeUse = st.use as { [key: string]: () => unknown };
242
- const storeDo = st.do as unknown as { [key: string]: (...args: any[]) => Promise<void> };
243
- const storeGet = st.get as unknown as <T>() => { [key: string]: T };
244
- const names = {
245
- model: modelName,
246
- modelList: `${modelName}List`,
247
- modelListLoading: `${modelName}ListLoading`,
248
- refreshModel: `refresh${ModelName}`,
249
- };
250
- const namesOfSlice = {
251
- modelList: sliceName.replace(names.model, names.modelList),
252
- modelListLoading: sliceName.replace(names.model, names.modelListLoading),
253
- refreshModel: sliceName.replace(names.model, names.refreshModel),
254
- };
255
- const modelList = storeUse[namesOfSlice.modelList]() as DataList<Light>;
256
- useRelationFieldTool(onChange, {
257
- read: () => storeGet<DataList<Light>>()[namesOfSlice.modelList],
258
- load: () => storeDo[namesOfSlice.refreshModel]({ invalidate: true, queryArgs: initArgs }),
259
- label: (model) => optionLabel(model, renderOption),
260
- disabled,
200
+ const { models, options, optionLabel, listLoading, load, read } = useRelationOptions<Light>({
201
+ slice,
202
+ ids: (value ?? []).map((model) => model.id),
203
+ pinned: value ?? [],
204
+ initArgs,
205
+ sortOption,
206
+ renderOption,
261
207
  });
208
+ useRelationFieldTool(onChange, { read, load, label: optionLabel, disabled });
262
209
 
263
210
  return (
264
211
  <div {...agentAttrs(onChange)} className={cn("flex flex-col", className)}>
@@ -269,28 +216,16 @@ export const Children = <Light extends { id: string }>({
269
216
  selectClassName={selectClassName}
270
217
  multiple
271
218
  value={(value ?? []).map((model) => model.id)}
272
- options={modelList.map((model) => {
273
- const label = renderOption(model);
274
- return { label: typeof label === "string" ? label : model.id, value: model.id };
275
- })}
219
+ loading={listLoading}
220
+ options={options}
276
221
  onOpen={() => {
277
222
  if (disabled) return;
278
- void storeDo[namesOfSlice.refreshModel]({ invalidate: true, queryArgs: initArgs });
279
- }}
280
- renderOption={(modelId: string) => {
281
- const model = modelList.get(modelId);
282
- if (!model) return null;
283
- return renderOption(model);
284
- }}
285
- renderSelected={(modelId: string) => {
286
- const model = modelList.get(modelId);
287
- if (!model) return null;
288
- return renderSelected(model);
223
+ void load();
289
224
  }}
225
+ renderOption={(modelId: string) => renderRow(models, modelId, renderOption)}
226
+ renderSelected={(modelId: string) => renderRow(models, modelId, renderSelected)}
290
227
  onChange={(modelIds: string[]) => {
291
- onChange(
292
- modelIds.map((id) => modelList.get(id)).filter((model) => model !== undefined) as unknown as Light[],
293
- );
228
+ onChange(modelIds.map((id) => models.get(id)).filter((model): model is Light => !!model));
294
229
  }}
295
230
  onSearch={onSearch}
296
231
  />
@@ -328,22 +263,13 @@ export const ChildrenId = <Light extends { id: string }>({
328
263
  sortOption,
329
264
  renderOption,
330
265
  }: ChildrenIdProps<Light>) => {
331
- const { refName, sliceName } = slice;
332
- const [modelName, ModelName] = [lowerlize(refName), capitalize(refName)];
333
- const storeUse = st.use as { [key: string]: () => unknown };
334
- const storeDo = st.do as unknown as { [key: string]: (...args: any[]) => Promise<void> };
335
- const names = {
336
- model: modelName,
337
- modelList: `${modelName}List`,
338
- modelListLoading: `${modelName}ListLoading`,
339
- refreshModel: `refresh${ModelName}`,
340
- };
341
- const namesOfSlice = {
342
- modelList: sliceName.replace(names.model, names.modelList),
343
- modelListLoading: sliceName.replace(names.model, names.modelListLoading),
344
- refreshModel: sliceName.replace(names.model, names.refreshModel),
345
- };
346
- const modelList = storeUse[namesOfSlice.modelList]() as DataList<Light>;
266
+ const { models, options, listLoading, load } = useRelationOptions<Light>({
267
+ slice,
268
+ ids: value ?? [],
269
+ initArgs,
270
+ sortOption,
271
+ renderOption,
272
+ });
347
273
 
348
274
  useFieldTool(onChange, { disabled });
349
275
 
@@ -354,20 +280,14 @@ export const ChildrenId = <Light extends { id: string }>({
354
280
  nullable={nullable}
355
281
  searchable
356
282
  multiple
357
-
358
283
  value={value ?? []}
359
- options={modelList.map((model) => {
360
- const label = renderOption(model);
361
- return { label: typeof label === "string" ? label : model.id, value: model.id };
362
- })}
363
- renderOption={(renderId) => {
364
- const model = modelList.get(renderId);
365
- if (!model) return null;
366
- return renderOption(model);
367
- }}
284
+ loading={listLoading}
285
+ options={options}
286
+ renderOption={(renderId: string) => renderRow(models, renderId, renderOption)}
287
+ renderSelected={(renderId: string) => renderRow(models, renderId, renderOption)}
368
288
  onOpen={() => {
369
289
  if (disabled) return;
370
- void storeDo[namesOfSlice.refreshModel]({ invalidate: true, queryArgs: initArgs });
290
+ void load();
371
291
  }}
372
292
  onChange={(modelIds) => {
373
293
  onChange(modelIds);
@@ -0,0 +1,73 @@
1
+ "use client";
2
+ import { fetch, isClientRuntimeRegistered } from "akanjs/client";
3
+ import { capitalize } from "akanjs/common";
4
+
5
+ interface LightRow {
6
+ id: string;
7
+ }
8
+
9
+ /**
10
+ * Light rows read one id at a time, for a control holding an id its option list does not carry — a form opened
11
+ * through `edit<Model>` before the dropdown was ever opened, or a row outside the one `limit`-sized window a
12
+ * slice list holds.
13
+ *
14
+ * Deliberately not the model's store: that store is a singleton, so reading into it would overwrite whatever
15
+ * listing of the same model is already on the screen — the same reason `Data/RefPicker` keeps its rows local.
16
+ */
17
+ export class LightRefCache {
18
+ static #rows = new Map<string, LightRow | null>();
19
+ static #inflight = new Map<string, Promise<void>>();
20
+ static #listeners = new Set<(refName: string, id: string) => void>();
21
+
22
+ static get<Light extends LightRow>(refName: string, id: string) {
23
+ return (LightRefCache.#rows.get(LightRefCache.#keyOf(refName, id)) ?? null) as Light | null;
24
+ }
25
+ static subscribe(listener: (refName: string, id: string) => void) {
26
+ LightRefCache.#listeners.add(listener);
27
+ return () => {
28
+ LightRefCache.#listeners.delete(listener);
29
+ };
30
+ }
31
+ static load(refName: string, ids: string[]) {
32
+ for (const id of ids) LightRefCache.#read(refName, id);
33
+ }
34
+ static reset() {
35
+ LightRefCache.#rows.clear();
36
+ LightRefCache.#inflight.clear();
37
+ }
38
+ static #keyOf(refName: string, id: string) {
39
+ return `${refName}:${id}`;
40
+ }
41
+ /**
42
+ * A row that cannot be read is remembered as `null` rather than retried: `light<Model>` carries the model's own
43
+ * `get` guards, which can be narrower than the slice the control lists from, and a removed row answers the same
44
+ * way every time. A runtime that is not registered yet is not an answer, so nothing is remembered for it.
45
+ */
46
+ static #read(refName: string, id: string) {
47
+ const key = LightRefCache.#keyOf(refName, id);
48
+ if (LightRefCache.#rows.has(key) || LightRefCache.#inflight.has(key)) return;
49
+ if (!isClientRuntimeRegistered()) return;
50
+ const read = (fetch as unknown as { [key: string]: unknown })[`light${capitalize(refName)}`];
51
+ if (typeof read !== "function") {
52
+ LightRefCache.#write(refName, id, null);
53
+ return;
54
+ }
55
+ LightRefCache.#inflight.set(
56
+ key,
57
+ (read as (id: string) => Promise<LightRow | null>)(id)
58
+ .then((row) => {
59
+ LightRefCache.#write(refName, id, row?.id ? row : null);
60
+ })
61
+ .catch(() => {
62
+ LightRefCache.#write(refName, id, null);
63
+ })
64
+ .finally(() => {
65
+ LightRefCache.#inflight.delete(key);
66
+ }),
67
+ );
68
+ }
69
+ static #write(refName: string, id: string, row: LightRow | null) {
70
+ LightRefCache.#rows.set(LightRefCache.#keyOf(refName, id), row);
71
+ for (const listener of LightRefCache.#listeners) listener(refName, id);
72
+ }
73
+ }
@@ -0,0 +1,106 @@
1
+ "use client";
2
+ import { DataList } from "akanjs/base";
3
+ import { capitalize, lowerlize } from "akanjs/common";
4
+ import { ConstantRegistry, labelOf } from "akanjs/constant";
5
+ import type { SliceMeta } from "akanjs/fetch";
6
+ import { st } from "akanjs/store";
7
+ import { type ReactNode, useEffect, useMemo, useState } from "react";
8
+ import { LightRefCache } from "./lightRefCache";
9
+
10
+ export interface RelationOptionsSource<Light extends { id: string }> {
11
+ slice: SliceMeta;
12
+ /** Ids the control holds right now, whether or not the option list carries them. */
13
+ ids: string[];
14
+ /** Rows the control was handed outright, which a `Light`-valued field's own value already is. */
15
+ pinned?: (Light | null)[];
16
+ initArgs?: any[];
17
+ sortOption?: (a: Light, b: Light) => number;
18
+ renderOption?: (model: Light) => ReactNode;
19
+ }
20
+
21
+ const merge = <Light extends { id: string }>(
22
+ list: DataList<Light>,
23
+ rows: (Light | null | undefined)[],
24
+ sortOption?: (a: Light, b: Light) => number,
25
+ ) => {
26
+ const merged = new DataList<Light>(list);
27
+
28
+ for (const row of rows) if (row && !merged.get(row.id)) merged.set(row);
29
+ return sortOption ? merged.sort(sortOption) : merged;
30
+ };
31
+
32
+ /**
33
+ * The options a relation control offers, which are never only the slice list: that list arrives when the dropdown
34
+ * opens and holds one page when it does, so a control rendering a value the list does not carry would render an
35
+ * empty field over a form that holds the value. What is missing from it is filled from the value the control was
36
+ * handed, and — for an id-valued control, which holds no row at all — read by id through {@link LightRefCache}.
37
+ */
38
+ export const useRelationOptions = <Light extends { id: string }>({
39
+ slice,
40
+ ids,
41
+ pinned,
42
+ initArgs,
43
+ sortOption,
44
+ renderOption,
45
+ }: RelationOptionsSource<Light>) => {
46
+ const { refName, sliceName } = slice;
47
+ const [modelName, ModelName] = [lowerlize(refName), capitalize(refName)];
48
+ const names = {
49
+ modelList: sliceName.replace(modelName, `${modelName}List`),
50
+ modelListLoading: sliceName.replace(modelName, `${modelName}ListLoading`),
51
+ refreshModel: sliceName.replace(modelName, `refresh${ModelName}`),
52
+ };
53
+ const storeUse = st.use as { [key: string]: () => unknown };
54
+ const storeDo = st.do as unknown as { [key: string]: (...args: any[]) => Promise<void> };
55
+ const storeGet = st.get as unknown as <V>() => { [key: string]: V };
56
+ const sliceList = storeUse[names.modelList]() as DataList<Light>;
57
+ const listLoading = storeUse[names.modelListLoading]() as boolean;
58
+ const [resolvedCount, setResolvedCount] = useState(0);
59
+
60
+ const held = (pinned ?? []).filter((row): row is Light => !!row);
61
+ const heldKey = held.map((row) => row.id).join(",");
62
+ const missing = ids.filter((id) => !!id && !sliceList.get(id) && !held.some((row) => row.id === id));
63
+ const missingKey = missing.join(",");
64
+
65
+ useEffect(() => {
66
+ if (!missingKey) return;
67
+ const wanted = new Set(missingKey.split(","));
68
+ const off = LightRefCache.subscribe((readRefName, id) => {
69
+ if (readRefName === refName && wanted.has(id)) setResolvedCount((count) => count + 1);
70
+ });
71
+ LightRefCache.load(refName, [...wanted]);
72
+ return off;
73
+ }, [refName, missingKey]);
74
+
75
+ const cnst = useMemo(() => ConstantRegistry.getDatabase(refName, { allowEmpty: true }), [refName]);
76
+ const models = useMemo(
77
+ () => merge(sliceList, [...held, ...missing.map((id) => LightRefCache.get<Light>(refName, id))], sortOption),
78
+ [sliceList, heldKey, missingKey, resolvedCount],
79
+ );
80
+ /** The one line an option reads as, so a search matches it and an agent can match an id against the screen. */
81
+ const optionLabel = (model: Light) => {
82
+ const rendered = renderOption?.(model);
83
+ if (typeof rendered === "string" && rendered) return rendered;
84
+ return labelOf(cnst?.full, model) ?? model.id;
85
+ };
86
+ const options = useMemo(() => models.map((model) => ({ label: optionLabel(model), value: model.id })), [models]);
87
+
88
+ return {
89
+ models,
90
+ options,
91
+ optionLabel,
92
+ listLoading,
93
+ /**
94
+ * `invalidate: false` so an open reuses a list the page already loaded rather than replacing it: the slice
95
+ * list is one store key, so refetching it here overwrites whatever listing of the same model is on screen.
96
+ */
97
+ load: () => storeDo[names.refreshModel]({ invalidate: false, queryArgs: initArgs }),
98
+ /** Live rather than the rendered list, because the agent's list tool loads and reads within one call. */
99
+ read: () =>
100
+ merge(
101
+ storeGet<DataList<Light>>()[names.modelList],
102
+ [...held, ...ids.map((id) => LightRefCache.get<Light>(refName, id))],
103
+ sortOption,
104
+ ),
105
+ };
106
+ };
package/ui/Select.tsx CHANGED
@@ -21,9 +21,12 @@ import { BsQuestionCircleFill } from "react-icons/bs";
21
21
  import { TiDelete } from "react-icons/ti";
22
22
  import { agentAttrs } from "./agentAttrs";
23
23
  import { Label } from "./Field/Label";
24
+ import { Spin } from "./Loading/Spin";
24
25
  import { overlayZ, useOverlayLayerProps } from "./overlayLayer";
25
26
  import { useOverlayPosition } from "./overlayPosition";
26
- import { useUiOverride } from "./UiOverride";
27
+ import { createOverridable, useUiOverride } from "./UiOverride";
28
+
29
+ const LoadingSpin = createOverridable("LoadingSpin", Spin);
27
30
 
28
31
  interface LabelOption<T> {
29
32
  label: string | boolean | number;
@@ -59,6 +62,8 @@ export interface SelectProps<
59
62
  nullable?: boolean;
60
63
  /** Disable open and selection behavior. */
61
64
  disabled?: boolean;
65
+ /** The options are still being loaded: shows a spinner in place of the empty placeholder. */
66
+ loading?: boolean;
62
67
  /** Called when the dropdown opens. */
63
68
  onOpen?: () => void;
64
69
  /** Controlled change callback. Receives next and previous value. */
@@ -87,6 +92,7 @@ const DefaultSelect = <
87
92
  options,
88
93
  nullable,
89
94
  disabled,
95
+ loading,
90
96
  multiple,
91
97
  searchable,
92
98
  placeholder,
@@ -286,20 +292,20 @@ const DefaultSelect = <
286
292
  <span className="flex w-full flex-wrap items-center gap-1">
287
293
  {multiple
288
294
  ? selectedValues.map((v, index) => {
295
+
289
296
  const optionValue = labeledOptions.find((option) => option.value === v);
290
- if (!optionValue) return null;
291
297
  return (
292
298
  <div
293
299
  key={index}
294
300
  className="flex items-center gap-2 rounded-field bg-primary px-2 py-1 text-primary-foreground text-xs"
295
301
  >
296
- {renderSelected ? renderSelected(optionValue.value) : optionValue.label}
302
+ {renderSelected ? renderSelected(v) : (optionValue?.label ?? String(v))}
297
303
  <button
298
304
  type="button"
299
305
  onClick={(e) => {
300
306
  e.stopPropagation();
301
307
  e.preventDefault();
302
- onSelect(optionValue.value);
308
+ onSelect(v);
303
309
  }}
304
310
  className="opacity-50 duration-300 hover:opacity-100"
305
311
  >
@@ -314,8 +320,8 @@ const DefaultSelect = <
314
320
  {(() => {
315
321
 
316
322
  const optionValue = labeledOptions.find((option) => selected(option.value));
317
- if (!optionValue) return null;
318
- return renderSelected ? renderSelected(optionValue.value) : optionValue.label;
323
+ const v = selectedValues[0] as T;
324
+ return renderSelected ? renderSelected(v) : (optionValue?.label ?? String(v));
319
325
  })()}
320
326
  </div>
321
327
  ) : null}
@@ -431,14 +437,18 @@ const DefaultSelect = <
431
437
  </div>
432
438
  );
433
439
  })}
434
- {searchOptions.length
435
- ? null
436
- : (empty ?? (
437
- <div className="flex size-full flex-col items-center justify-center gap-2 p-2 text-center text-5xl text-foreground/50">
438
- <BsQuestionCircleFill />
439
- <div className="text-sm">{l("base.noOptions")}</div>
440
- </div>
441
- ))}
440
+ {searchOptions.length ? null : loading ? (
441
+ <div className="flex size-full items-center justify-center p-4">
442
+ <LoadingSpin />
443
+ </div>
444
+ ) : (
445
+ (empty ?? (
446
+ <div className="flex size-full flex-col items-center justify-center gap-2 p-2 text-center text-5xl text-foreground/50">
447
+ <BsQuestionCircleFill />
448
+ <div className="text-sm">{l("base.noOptions")}</div>
449
+ </div>
450
+ ))
451
+ )}
442
452
  </div>,
443
453
  portal,
444
454
  )
package/ui/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
 
2
2
 
3
+ export { type AgentReferenceInput, useAgentReference } from "akanjs/store";
3
4
  export {
4
5
  AgentProvider,
5
6
  type AgentProviderProps,
@@ -11,7 +12,9 @@ export {
11
12
  type ContextBlock,
12
13
  httpRunner,
13
14
  type MessageAttachment,
15
+ type MessageReference,
14
16
  type PublishedTool,
17
+ Reference,
15
18
  type RunnerEvent,
16
19
  type RunnerRequest,
17
20
  SessionContext,
@@ -41,10 +44,15 @@ export { type CodeProps, DefaultCode, DefaultMarkdown, type MarkdownProps } from
41
44
  export { DefaultAgentMenu, type MenuProps as AgentMenuProps, type MenuRow } from "./Agent/Menu";
42
45
  export { DefaultQuestion, type QuestionProps } from "./Agent/Question";
43
46
  export { DefaultQueued, type QueuedProps } from "./Agent/Queued";
47
+ export {
48
+ ReferenceChips as AgentReferences,
49
+ type ReferenceChipsProps as AgentReferencesProps,
50
+ } from "./Agent/Refer";
44
51
  export type { PersistOption } from "./Agent/sessionHistory";
45
52
  export type { AgentBuiltin, BuiltinOption } from "./Agent/sessionView";
46
53
  export { tokenCount } from "./Agent/tokenCount";
47
54
  export type { QueuedMessage } from "./Agent/useChatQueue";
55
+ export type { ReferenceCandidate, ReferenceSource } from "./Agent/useReferenceMenu";
48
56
  export type { VoiceEngine, VoiceHandlers, VoiceListener, VoiceSpeech } from "./Agent/voice";
49
57
  export { agentAttrs } from "./agentAttrs";
50
58
  export { animated } from "./animated";