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
package/ui/Agent/Chat.tsx CHANGED
@@ -37,9 +37,11 @@ import type { BuiltinOption } from "./sessionView";
37
37
  import { tokenCount } from "./tokenCount";
38
38
  import { useChatAttachments } from "./useChatAttachments";
39
39
  import { type QueuedMessage, useChatQueue } from "./useChatQueue";
40
+ import { useChatReferences } from "./useChatReferences";
40
41
  import { useChatVoice } from "./useChatVoice";
41
42
  import { useDraftRecall } from "./useDraftRecall";
42
43
  import { useKeyboardInset } from "./useKeyboardInset";
44
+ import { type ReferenceSource, useReferenceMenu } from "./useReferenceMenu";
43
45
  import { useSlashMenu } from "./useSlashMenu";
44
46
  import type { VoiceEngine } from "./voice";
45
47
 
@@ -117,6 +119,15 @@ export interface ChatProps {
117
119
  * uploading reader gets a thumbnail without betting the answer on who can reach its storage.
118
120
  */
119
121
  attach?: AttachReader;
122
+ /**
123
+ * What the composer's `@` menu can point at — one entry per kind of document a user may name while asking.
124
+ *
125
+ * Which documents those are is the app's answer, so the source carries its own `search`; the framework carries
126
+ * the token, the masking and the snapshot. Whole documents only: a field inside one is pointed at from the
127
+ * component that draws it, with `useAgentReference`, because that component is the thing that knows a rich-text
128
+ * field reads as a paragraph rather than as the editor document it is stored as.
129
+ */
130
+ reference?: readonly ReferenceSource[];
120
131
  /**
121
132
  * Raises or lowers what the composer accepts — per file, per message, and how many. The defaults are what one
122
133
  * turn's JSON safely carries to a conservative provider; an app pointed at a larger request limit, or one whose
@@ -176,6 +187,7 @@ export const DefaultChat = ({
176
187
  defaultDraft,
177
188
  attach,
178
189
  attachLimits,
190
+ reference,
179
191
  voice,
180
192
  }: ChatProps) => {
181
193
  const { l } = usePage();
@@ -225,6 +237,7 @@ export const DefaultChat = ({
225
237
  const listRef = useRef<HTMLDivElement>(null);
226
238
  const inputRef = useRef<HTMLTextAreaElement>(null);
227
239
  const launcherRef = useRef<HTMLButtonElement>(null);
240
+ const refs = useChatReferences({ session, draft, version, inputRef, onDraft: setDraft });
228
241
 
229
242
  const sticky = useRef(true);
230
243
  const returning = useRef(false);
@@ -274,6 +287,7 @@ export const DefaultChat = ({
274
287
  const write = (text: string) => {
275
288
  setDraft(text);
276
289
  menu.reopen();
290
+ mentions.reopen();
277
291
  };
278
292
 
279
293
  const closable = openProp === undefined || !!onOpenChange;
@@ -287,6 +301,7 @@ export const DefaultChat = ({
287
301
 
288
302
  if (command.name === "new") {
289
303
  files.clear();
304
+ session.clearStaged();
290
305
  queue.take();
291
306
  }
292
307
  void ChatCommands.run(command, { session, l });
@@ -299,10 +314,15 @@ export const DefaultChat = ({
299
314
  if (session.isRunning) return queue.push(message);
300
315
  sticky.current = true;
301
316
  speech.take(message.byVoice);
302
- if (!message.attachments.length) void session.send(message.text);
317
+ if (!message.attachments.length && !message.references.length) void session.send(message.text);
303
318
  else
304
319
  void session.send([
305
- { role: "user", ...(message.text ? { text: message.text } : {}), attachments: message.attachments },
320
+ {
321
+ role: "user",
322
+ ...(message.text ? { text: message.text } : {}),
323
+ ...(message.attachments.length ? { attachments: message.attachments } : {}),
324
+ ...(message.references.length ? { references: message.references } : {}),
325
+ },
306
326
  ]);
307
327
  return true;
308
328
  };
@@ -311,10 +331,15 @@ export const DefaultChat = ({
311
331
  const message = queue.queued;
312
332
  if (!message || !files.restore(message.attachments)) return;
313
333
  queue.take();
334
+
335
+ session.restoreStaged(message.references);
314
336
  write([message.text, draft].filter(Boolean).join("\n"));
315
337
  speech.hold(message.byVoice);
316
338
  };
317
339
  const menu = useSlashMenu({ draft, l, onCommand: runCommand });
340
+ const mentions = useReferenceMenu({ draft, sources: reference ?? [], session, l, onWrite: write });
341
+
342
+ const list = menu.at() ? menu : mentions;
318
343
  const send = () => {
319
344
  const text = draft.trim();
320
345
  if (!text && !files.attached.length) return;
@@ -338,12 +363,13 @@ export const DefaultChat = ({
338
363
  return;
339
364
  }
340
365
  const byVoice = speech.lift();
341
- if (!dispatch({ text, attachments: files.attached, byVoice })) {
366
+ if (!dispatch({ text, attachments: files.attached, references: refs.references, byVoice })) {
342
367
  speech.hold(byVoice);
343
368
  return;
344
369
  }
345
370
  write("");
346
371
  files.clear();
372
+ session.clearStaged();
347
373
  if (text) recall.remember(text);
348
374
  };
349
375
  const onKeyDown = (event: ReactKeyboardEvent<HTMLTextAreaElement>) => {
@@ -352,22 +378,24 @@ export const DefaultChat = ({
352
378
  const onEdgeLine = (up: boolean) =>
353
379
  area.selectionStart === area.selectionEnd &&
354
380
  !(up ? area.value.slice(0, area.selectionStart) : area.value.slice(area.selectionEnd)).includes("\n");
355
- const row = menu.at();
381
+ const row = list.at();
356
382
  if (row) {
357
383
 
358
384
  if (event.key === "ArrowDown" || event.key === "ArrowUp") {
359
385
  event.preventDefault();
360
- menu.move(event.key === "ArrowDown" ? 1 : -1);
386
+ list.move(event.key === "ArrowDown" ? 1 : -1);
361
387
  return;
362
388
  }
363
389
  if (event.key === "Tab") {
364
390
  event.preventDefault();
365
- write(`/${row.name} `);
391
+
392
+ if (list === menu) write(`/${row.name} `);
393
+ else row.pick();
366
394
  return;
367
395
  }
368
396
  if (event.key === "Escape") {
369
397
  event.preventDefault();
370
- menu.hide();
398
+ list.hide();
371
399
  return;
372
400
  }
373
401
  if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
@@ -501,7 +529,7 @@ export const DefaultChat = ({
501
529
  <Question key={session.pendingQuestion.callId} question={session.pendingQuestion} />
502
530
  ) : null}
503
531
  {queue.queued ? <Queued message={queue.queued} onCancel={() => queue.take()} onEdit={unpark} /> : null}
504
- <Menu onPick={(row) => row.pick()} rows={menu.rows} selected={menu.selected} />
532
+ <Menu onPick={(row) => row.pick()} prefix={list === menu ? "/" : "@"} rows={list.rows} selected={list.selected} />
505
533
  <Composer
506
534
  attached={files.attached}
507
535
  pending={files.pending}
@@ -512,7 +540,9 @@ export const DefaultChat = ({
512
540
  onFiles={(picked) => void files.add(picked)}
513
541
  onKeyDown={onKeyDown}
514
542
  onRemoveFile={files.remove}
543
+ onRemoveReference={refs.remove}
515
544
  onSend={send}
545
+ references={refs.references}
516
546
  onStop={() => {
517
547
  speech.silence();
518
548
 
@@ -1,18 +1,24 @@
1
1
  "use client";
2
2
  import { cn, usePage } from "akanjs/client";
3
3
  import { type KeyboardEvent, type RefObject, useEffect } from "react";
4
- import type { AgentSession, MessageAttachment } from "../../vendor/use-agentic";
4
+ import type { AgentSession, MessageAttachment, MessageReference } from "../../vendor/use-agentic";
5
5
  import { Button } from "../Button";
6
6
  import { inputRecipe } from "../recipe";
7
7
  import { createOverridable, useUiRecipe } from "../UiOverride";
8
8
  import { Attach, Chips } from "./Attach";
9
9
  import { Mic } from "./Mic";
10
+ import { ReferenceChips } from "./Refer";
10
11
 
11
12
  export interface ComposerProps {
12
13
  className?: string;
13
14
  session: AgentSession;
14
15
  draft: string;
15
16
  attached: readonly MessageAttachment[];
17
+ /**
18
+ * What the draft's `@[…](mention:…)` tokens point at. The text is what carries them, so a composer that draws no
19
+ * chip still sends them — the chip is how somebody sees and removes one, not how it travels.
20
+ */
21
+ references?: readonly MessageReference[];
16
22
  /** Files still being read. An `attach` that uploads takes seconds, and a panel that shows nothing looks broken. */
17
23
  pending?: number;
18
24
  /** Absent when the screen cannot listen — the same rule as publishing no tool for a control that is not drawn. */
@@ -21,6 +27,8 @@ export interface ComposerProps {
21
27
  onKeyDown: (event: KeyboardEvent<HTMLTextAreaElement>) => void;
22
28
  onFiles: (files: File[]) => void;
23
29
  onRemoveFile: (idx: number) => void;
30
+ /** Keyed on `refName/refId#path`, not on a row index: the order is the draft's, and the draft is the truth. */
31
+ onRemoveReference?: (key: string) => void;
24
32
  onSend: () => void;
25
33
  onStop: () => void;
26
34
  inputRef?: RefObject<HTMLTextAreaElement | null>;
@@ -35,12 +43,14 @@ export const DefaultComposer = ({
35
43
  session,
36
44
  draft,
37
45
  attached,
46
+ references = [],
38
47
  pending = 0,
39
48
  mic,
40
49
  onDraft,
41
50
  onKeyDown,
42
51
  onFiles,
43
52
  onRemoveFile,
53
+ onRemoveReference,
44
54
  onSend,
45
55
  onStop,
46
56
  inputRef,
@@ -56,6 +66,13 @@ export const DefaultComposer = ({
56
66
  }, [draft, inputRef]);
57
67
  return (
58
68
  <div className={cn("flex flex-col gap-2 border-foreground/5 border-t p-3", className)}>
69
+ {references.length ? (
70
+ <ReferenceChips
71
+ onRemove={onRemoveReference}
72
+ references={references}
73
+ removeLabel={l("base.agentReferenceRemove")}
74
+ />
75
+ ) : null}
59
76
  {attached.length || pending ? (
60
77
  <Chips
61
78
  attachments={attached}
package/ui/Agent/Menu.tsx CHANGED
@@ -15,11 +15,13 @@ export interface MenuProps {
15
15
  rows: MenuRow[];
16
16
  /** Index the arrows are on. Enter picks it and Tab completes its name, so it has to be visible. */
17
17
  selected: number;
18
+ /** What the rows are being typed after, so a name reads as the thing the user is completing. */
19
+ prefix?: string;
18
20
  onPick: (row: MenuRow) => void;
19
21
  }
20
22
 
21
- /** The `/` menu: this chat's own commands first, then the app's `prompt()` endpoints. */
22
- export const DefaultAgentMenu = ({ className, rows, selected, onPick }: MenuProps) => {
23
+ /** The composer's completion list: `/` commands and `prompt()` endpoints, or the `@` menu's reference rows. */
24
+ export const DefaultAgentMenu = ({ className, rows, selected, prefix = "/", onPick }: MenuProps) => {
23
25
  if (!rows.length) return null;
24
26
  return (
25
27
  <div
@@ -41,7 +43,10 @@ export const DefaultAgentMenu = ({ className, rows, selected, onPick }: MenuProp
41
43
  role="option"
42
44
  type="button"
43
45
  >
44
- <span className="shrink-0 font-mono text-xs">/{row.name}</span>
46
+ <span className="shrink-0 font-mono text-xs">
47
+ {prefix}
48
+ {row.name}
49
+ </span>
45
50
  {row.hint ? <span className="shrink-0 font-mono text-[10px] text-foreground/40">{row.hint}</span> : null}
46
51
  {row.description ? (
47
52
  <span className="ml-auto truncate text-[10px] text-foreground/50">{row.description}</span>
@@ -3,6 +3,7 @@ import { cn, usePage } from "akanjs/client";
3
3
  import { AiOutlineClose, AiOutlineEdit } from "react-icons/ai";
4
4
  import { createOverridable } from "../UiOverride";
5
5
  import { Chips } from "./Attach";
6
+ import { ReferenceChips } from "./Refer";
6
7
  import type { QueuedMessage } from "./useChatQueue";
7
8
 
8
9
  export interface QueuedProps {
@@ -29,6 +30,7 @@ export const DefaultQueued = ({ className, message, onEdit, onCancel }: QueuedPr
29
30
  <div className="flex min-w-0 flex-1 flex-col gap-1">
30
31
  <span className="text-[10px] text-foreground/40">{l("base.agentQueued")}</span>
31
32
  {message.attachments.length ? <Chips attachments={message.attachments} /> : null}
33
+ {message.references.length ? <ReferenceChips references={message.references} /> : null}
32
34
  {message.text ? (
33
35
  <p className="line-clamp-2 whitespace-pre-wrap text-foreground/70 text-sm">{message.text}</p>
34
36
  ) : null}
@@ -0,0 +1,44 @@
1
+ "use client";
2
+ import { cn } from "akanjs/client";
3
+ import { AiOutlineClose } from "react-icons/ai";
4
+ import { type MessageReference, Reference } from "../../vendor/use-agentic";
5
+
6
+ export interface ReferenceChipsProps {
7
+ className?: string;
8
+
9
+ references: readonly MessageReference[];
10
+ /** Omitted for a sent message: what is already on the wire cannot be taken back. */
11
+ onRemove?: (key: string) => void;
12
+ removeLabel?: string;
13
+ }
14
+
15
+ /**
16
+ * What the user pointed at, beside what they are typing. Keyed on the pointer rather than on the row's position:
17
+ * the order a message's references appear in is its text's, and removing one is removing its token.
18
+ */
19
+ export const ReferenceChips = ({ className, references, onRemove, removeLabel }: ReferenceChipsProps) => (
20
+ <div className={cn("flex flex-wrap gap-1", className)}>
21
+ {references.map((reference) => {
22
+ const key = Reference.keyOf(reference);
23
+ return (
24
+ <span
25
+ className="flex items-center gap-1 rounded-field bg-primary/10 px-2 py-0.5 text-primary text-xs"
26
+ key={key}
27
+ title={key}
28
+ >
29
+ <span className="max-w-32 truncate">{reference.label || key}</span>
30
+ {onRemove ? (
31
+ <button
32
+ aria-label={removeLabel}
33
+ className="text-primary/50 hover:text-primary"
34
+ onClick={() => onRemove(key)}
35
+ type="button"
36
+ >
37
+ <AiOutlineClose />
38
+ </button>
39
+ ) : null}
40
+ </span>
41
+ );
42
+ })}
43
+ </div>
44
+ );
@@ -11,19 +11,42 @@ export type PersistOption = boolean | { storage?: "session" | "local"; key?: str
11
11
  * letting it answer from the filename. A `ref` stays for the same reason and matters more: it is the host's own
12
12
  * handle on the file, so dropping it leaves a restored conversation with nothing but a name and size to guess
13
13
  * from — the guess `ref` exists to retire.
14
+ *
15
+ * A reference's `value` goes the same way and for the same reason — it is capped at 20,000 characters each, and a
16
+ * transcript of 50 messages would not fit beside it. What is left behind is better than what an attachment leaves,
17
+ * though, and the note says so rather than leaving the model to notice: `refName`/`refId`/`path` is a live pointer,
18
+ * so the answer to a restored reference is to read it again with a tool, not to guess from the label.
14
19
  */
15
- const withoutContent = (message: ChatMessage): ChatMessage =>
16
- message.attachments?.length
17
- ? {
18
- ...message,
19
- attachments: message.attachments.map(({ name, mimeType, url, ref }) => ({
20
- name,
21
- mimeType,
22
- ...(url ? { url } : {}),
23
- ...(ref ? { ref } : {}),
24
- })),
25
- }
26
- : message;
20
+ const restoredValue =
21
+ "the conversation was restored from storage, which keeps what the user pointed at but not the value it held";
22
+
23
+ const withoutContent = (message: ChatMessage): ChatMessage => {
24
+ if (!message.attachments?.length && !message.references?.length) return message;
25
+ return {
26
+ ...message,
27
+ ...(message.attachments?.length
28
+ ? {
29
+ attachments: message.attachments.map(({ name, mimeType, url, ref }) => ({
30
+ name,
31
+ mimeType,
32
+ ...(url ? { url } : {}),
33
+ ...(ref ? { ref } : {}),
34
+ })),
35
+ }
36
+ : {}),
37
+ ...(message.references?.length
38
+ ? {
39
+ references: message.references.map(({ refName, refId, label, path }) => ({
40
+ refName,
41
+ refId,
42
+ label,
43
+ ...(path ? { path } : {}),
44
+ note: restoredValue,
45
+ })),
46
+ }
47
+ : {}),
48
+ };
49
+ };
27
50
 
28
51
  /**
29
52
  * Maps the `persist` prop onto a `SessionHistory` over web storage. Session storage is the default on purpose:
@@ -1,12 +1,15 @@
1
1
  "use client";
2
2
  import { useEffect, useRef, useState } from "react";
3
- import type { AgentSession, MessageAttachment } from "../../vendor/use-agentic";
3
+ import type { AgentSession, MessageAttachment, MessageReference } from "../../vendor/use-agentic";
4
+ import { Reference } from "../../vendor/use-agentic";
4
5
  import { type AttachLimits, Attachment, maxMessageAttachments } from "./attachment";
5
6
 
6
7
  /** What the composer held when it was sent: the shape a turn opens with, whether now or after the running one. */
7
8
  export interface QueuedMessage {
8
9
  text: string;
9
10
  attachments: MessageAttachment[];
11
+ /** What the parked text's tokens pointed at. Carried so a turn that opens later still knows what they held. */
12
+ references: MessageReference[];
10
13
  /** The ask came from the microphone, so the reply it gets is read aloud — carried until the send that opens it. */
11
14
  byVoice: boolean;
12
15
  }
@@ -56,6 +59,13 @@ export const useChatQueue = ({ session, limits = {}, version, l, onFlush }: Chat
56
59
  put({
57
60
  text: [before?.text, message.text].filter(Boolean).join("\n"),
58
61
  attachments,
62
+
63
+ references: [
64
+ ...message.references,
65
+ ...(before?.references ?? []).filter(
66
+ (one) => !message.references.some((fresh) => Reference.keyOf(fresh) === Reference.keyOf(one)),
67
+ ),
68
+ ],
59
69
  byVoice: !!before?.byVoice || message.byVoice,
60
70
  });
61
71
  return true;
@@ -0,0 +1,67 @@
1
+ "use client";
2
+ import { type RefObject, useEffect, useMemo, useRef } from "react";
3
+ import { type AgentSession, type MessageReference, Reference } from "../../vendor/use-agentic";
4
+
5
+ interface ChatReferencesSetup {
6
+ session: AgentSession;
7
+ draft: string;
8
+ /** The chat's own version snapshot — a `refer` from anywhere on the page is one of the changes it counts. */
9
+ version: number;
10
+ inputRef: RefObject<HTMLTextAreaElement | null>;
11
+ onDraft: (text: string) => void;
12
+ }
13
+
14
+ /**
15
+ * The composer's half of a reference: writing the token for one somebody pointed at elsewhere, and reading back
16
+ * which references the draft now carries.
17
+ *
18
+ * **The text is the source of truth, and the staged values are looked up beside it.** A reference is on the
19
+ * message because its token is in the message — so deleting the token by hand drops it, exactly as deleting the
20
+ * chip does, and neither can leave the other behind. The draft is never scanned to re-derive a value; a token
21
+ * whose value was never staged (pasted out of an earlier message) travels as a pointer with a note saying to read
22
+ * it again, which is the same shape a restored conversation produces.
23
+ */
24
+ export const useChatReferences = ({ session, draft, version, inputRef, onDraft }: ChatReferencesSetup) => {
25
+ const caret = useRef<number | null>(null);
26
+
27
+ useEffect(() => session.attachChat(), [session]);
28
+ useEffect(() => {
29
+ const pending = session.pendingInserts;
30
+ if (!pending.length) return;
31
+ const area = inputRef.current;
32
+
33
+ const at = area ? area.selectionStart : draft.length;
34
+ const head = draft.slice(0, at);
35
+ const tail = draft.slice(at);
36
+ const tokens = pending.map((one) => Reference.token(one)).join(" ");
37
+
38
+ const lead = head && !/\s$/.test(head) ? " " : "";
39
+ onDraft(`${head}${lead}${tokens} ${tail}`);
40
+ caret.current = head.length + lead.length + tokens.length + 1;
41
+ for (const one of pending) session.insertApplied(Reference.keyOf(one));
42
+ }, [version]);
43
+ useEffect(() => {
44
+ const at = caret.current;
45
+ if (at === null) return;
46
+ caret.current = null;
47
+ const area = inputRef.current;
48
+ if (!area) return;
49
+
50
+ area.focus();
51
+ area.setSelectionRange(at, at);
52
+ }, [draft]);
53
+ const references = useMemo<MessageReference[]>(() => {
54
+ const staged = new Map(session.staged.map((one) => [Reference.keyOf(one), one]));
55
+ return Reference.parse(draft).map(
56
+ (pointer) => staged.get(Reference.keyOf(pointer)) ?? { ...pointer, note: Reference.unstagedNote },
57
+ );
58
+ }, [draft, version]);
59
+ return {
60
+ references,
61
+ /** Removing the chip removes the token, because the token is what puts the reference on the message. */
62
+ remove: (key: string) => {
63
+ onDraft(Reference.without(draft, key));
64
+ session.unstage(key);
65
+ },
66
+ };
67
+ };
@@ -0,0 +1,108 @@
1
+ "use client";
2
+ import { type AgentFieldType, AgentValue } from "akanjs/store";
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { type AgentSession, Reference } from "../../vendor/use-agentic";
5
+ import type { MenuRow } from "./Menu";
6
+
7
+ /** One row the `@` menu offers: a document of its source's model, named the way the screen names it. */
8
+ export interface ReferenceCandidate {
9
+ refId: string;
10
+ label: string;
11
+ description?: string;
12
+ }
13
+
14
+ /**
15
+ * A kind of document the `@` menu can point at.
16
+ *
17
+ * `type` is the whole decision about what leaves the browser, in `st.expose`'s vocabulary — a model class masks by
18
+ * that model. **Name the class that actually carries the fields somebody is pointing at**: a `Light<Model>` is
19
+ * usually not it, and a reference masked by one arrives without the field that was the reason for pointing.
20
+ *
21
+ * `search` is the app's own query, because which documents a person may point at is the app's answer and not the
22
+ * framework's; the signal is aborted when the query moves on. `resolve` is called once, when a row is picked.
23
+ */
24
+ export interface ReferenceSource<T extends AgentFieldType = AgentFieldType> {
25
+ refName: string;
26
+ /** What this group of rows is called in the menu. */
27
+ label: string;
28
+ type: T;
29
+ search: (query: string, signal: AbortSignal) => Promise<ReferenceCandidate[]>;
30
+ resolve: (refId: string) => Promise<unknown>;
31
+ }
32
+
33
+ interface ReferenceMenuSetup {
34
+ draft: string;
35
+ sources: readonly ReferenceSource[];
36
+ session: AgentSession;
37
+ l: (key: string, param?: Record<string, string | number>) => string;
38
+ onWrite: (text: string) => void;
39
+ }
40
+
41
+ /**
42
+ * Only a word being typed at the end of the draft opens the menu. A `@` mid-sentence is left alone because the
43
+ * draft is the only thing this can read — a textarea's caret is not in React state — and a completed token always
44
+ * ends in a space, so the menu never reopens onto one it just wrote.
45
+ */
46
+ const atQuery = /(^|\s)@([^\s@[\]()]*)$/;
47
+
48
+ /** Long enough that typing a name is one query rather than one per letter, short enough to feel like a menu. */
49
+ const searchDelay = 150;
50
+
51
+ export const useReferenceMenu = ({ draft, sources, session, l, onWrite }: ReferenceMenuSetup) => {
52
+ const [rows, setRows] = useState<MenuRow[]>([]);
53
+ const [cursor, setCursor] = useState(0);
54
+ const [hidden, setHidden] = useState(false);
55
+
56
+ const held = useRef(sources);
57
+ held.current = sources;
58
+ const match = hidden ? null : atQuery.exec(draft);
59
+
60
+ const query = match ? match[2] : null;
61
+ const write = (candidate: ReferenceCandidate, source: ReferenceSource) => {
62
+ const reference = { refName: source.refName, refId: candidate.refId, label: candidate.label };
63
+
64
+ onWrite(`${draft.slice(0, match?.index ?? 0)}${match?.[1] ?? ""}${Reference.token(reference)} `);
65
+ void source
66
+ .resolve(candidate.refId)
67
+ .then((value) => session.stage({ ...reference, value: AgentValue.serialize(source.type, value) }))
68
+ .catch(() => session.note(l("base.agentReferenceFailed", { label: candidate.label })));
69
+ };
70
+ useEffect(() => {
71
+ if (query === null || !held.current.length) {
72
+ setRows([]);
73
+ return;
74
+ }
75
+ const controller = new AbortController();
76
+ const timer = setTimeout(() => {
77
+ const current = held.current;
78
+ void Promise.all(
79
+ current.map((source) => source.search(query, controller.signal).catch(() => [] as ReferenceCandidate[])),
80
+ ).then((found) => {
81
+ if (controller.signal.aborted) return;
82
+ setCursor(0);
83
+ setRows(
84
+ found.flatMap((candidates, at) =>
85
+ candidates.map((candidate) => ({
86
+ name: candidate.label,
87
+ description: candidate.description ?? current[at].label,
88
+ pick: () => write(candidate, current[at]),
89
+ })),
90
+ ),
91
+ );
92
+ });
93
+ }, searchDelay);
94
+ return () => {
95
+ clearTimeout(timer);
96
+ controller.abort();
97
+ };
98
+ }, [query, draft]);
99
+ const selected = Math.min(cursor, Math.max(rows.length - 1, 0));
100
+ return {
101
+ rows: query === null ? [] : rows,
102
+ selected,
103
+ reopen: () => setHidden(false),
104
+ hide: () => setHidden(true),
105
+ move: (delta: number) => setCursor(Math.max(0, Math.min(selected + delta, rows.length - 1))),
106
+ at: () => (query === null ? undefined : rows[selected]),
107
+ };
108
+ };