@graview/primitives 0.1.64 → 0.1.65

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 (127) hide show
  1. package/README.md +1 -1
  2. package/dist/app-bar.js +1 -1
  3. package/dist/app-bar.js.map +1 -1
  4. package/dist/chat.js +1 -1
  5. package/dist/chat.js.map +1 -1
  6. package/dist/find.js +3 -3
  7. package/dist/find.js.map +1 -1
  8. package/dist/first-look.js +1 -1
  9. package/dist/first-look.js.map +1 -1
  10. package/dist/here.d.ts +1 -1
  11. package/dist/here.js +2 -2
  12. package/dist/here.js.map +1 -1
  13. package/dist/index.d.ts +4 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +4 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/inline-ai-menu.d.ts +4 -2
  18. package/dist/inline-ai-menu.d.ts.map +1 -1
  19. package/dist/inline-ai-menu.js +34 -8
  20. package/dist/inline-ai-menu.js.map +1 -1
  21. package/dist/kind-stop.d.ts.map +1 -1
  22. package/dist/kind-stop.js +204 -21
  23. package/dist/kind-stop.js.map +1 -1
  24. package/dist/lines-key.d.ts.map +1 -1
  25. package/dist/lines-key.js +22 -2
  26. package/dist/lines-key.js.map +1 -1
  27. package/dist/map-panel.js +98 -6
  28. package/dist/map-panel.js.map +1 -1
  29. package/dist/menu-rank.d.ts +25 -0
  30. package/dist/menu-rank.d.ts.map +1 -0
  31. package/dist/menu-rank.js +56 -0
  32. package/dist/menu-rank.js.map +1 -0
  33. package/dist/menu-told-door.d.ts +17 -0
  34. package/dist/menu-told-door.d.ts.map +1 -0
  35. package/dist/menu-told-door.js +49 -0
  36. package/dist/menu-told-door.js.map +1 -0
  37. package/dist/menu-told.d.ts +37 -0
  38. package/dist/menu-told.d.ts.map +1 -0
  39. package/dist/menu-told.js +66 -0
  40. package/dist/menu-told.js.map +1 -0
  41. package/dist/menu-words.d.ts +15 -0
  42. package/dist/menu-words.d.ts.map +1 -0
  43. package/dist/menu-words.js +28 -0
  44. package/dist/menu-words.js.map +1 -0
  45. package/dist/pages.d.ts +2 -0
  46. package/dist/pages.d.ts.map +1 -1
  47. package/dist/pages.js +2 -0
  48. package/dist/pages.js.map +1 -1
  49. package/dist/quick-ask-desk.d.ts +16 -0
  50. package/dist/quick-ask-desk.d.ts.map +1 -1
  51. package/dist/quick-ask-desk.js +20 -0
  52. package/dist/quick-ask-desk.js.map +1 -1
  53. package/dist/quick-ask-door.d.ts.map +1 -1
  54. package/dist/quick-ask-door.js +5 -0
  55. package/dist/quick-ask-door.js.map +1 -1
  56. package/dist/quick-ask-plan.d.ts +52 -0
  57. package/dist/quick-ask-plan.d.ts.map +1 -0
  58. package/dist/quick-ask-plan.js +112 -0
  59. package/dist/quick-ask-plan.js.map +1 -0
  60. package/dist/quick-ask.d.ts.map +1 -1
  61. package/dist/quick-ask.js +341 -19
  62. package/dist/quick-ask.js.map +1 -1
  63. package/dist/scene-css.d.ts.map +1 -1
  64. package/dist/scene-css.js +16 -6
  65. package/dist/scene-css.js.map +1 -1
  66. package/dist/scene-places.d.ts +3 -2
  67. package/dist/scene-places.d.ts.map +1 -1
  68. package/dist/scene-places.js +47 -8
  69. package/dist/scene-places.js.map +1 -1
  70. package/dist/scene.d.ts +2 -0
  71. package/dist/scene.d.ts.map +1 -1
  72. package/dist/scene.js +2 -0
  73. package/dist/scene.js.map +1 -1
  74. package/dist/section-better.d.ts +38 -0
  75. package/dist/section-better.d.ts.map +1 -0
  76. package/dist/section-better.js +411 -0
  77. package/dist/section-better.js.map +1 -0
  78. package/dist/section-door.d.ts +32 -0
  79. package/dist/section-door.d.ts.map +1 -0
  80. package/dist/section-door.js +32 -0
  81. package/dist/section-door.js.map +1 -0
  82. package/dist/section-menu.d.ts +18 -0
  83. package/dist/section-menu.d.ts.map +1 -0
  84. package/dist/section-menu.js +76 -0
  85. package/dist/section-menu.js.map +1 -0
  86. package/dist/section-outline.d.ts +8 -0
  87. package/dist/section-outline.d.ts.map +1 -0
  88. package/dist/section-outline.js +48 -0
  89. package/dist/section-outline.js.map +1 -0
  90. package/dist/section-words.d.ts +24 -0
  91. package/dist/section-words.d.ts.map +1 -0
  92. package/dist/section-words.js +91 -0
  93. package/dist/section-words.js.map +1 -0
  94. package/dist/shell.d.ts.map +1 -1
  95. package/dist/shell.js +2 -1
  96. package/dist/shell.js.map +1 -1
  97. package/dist/subject.js +2 -2
  98. package/dist/subject.js.map +1 -1
  99. package/dist/told-card.d.ts +66 -0
  100. package/dist/told-card.d.ts.map +1 -0
  101. package/dist/told-card.js +197 -0
  102. package/dist/told-card.js.map +1 -0
  103. package/dist/told-listed.d.ts +14 -0
  104. package/dist/told-listed.d.ts.map +1 -0
  105. package/dist/told-listed.js +46 -0
  106. package/dist/told-listed.js.map +1 -0
  107. package/dist/told-panel-door.d.ts +17 -0
  108. package/dist/told-panel-door.d.ts.map +1 -0
  109. package/dist/told-panel-door.js +6 -0
  110. package/dist/told-panel-door.js.map +1 -0
  111. package/dist/told-panel.d.ts +19 -0
  112. package/dist/told-panel.d.ts.map +1 -0
  113. package/dist/told-panel.js +83 -0
  114. package/dist/told-panel.js.map +1 -0
  115. package/dist/told-ui.d.ts +5 -0
  116. package/dist/told-ui.d.ts.map +1 -0
  117. package/dist/told-ui.js +12 -0
  118. package/dist/told-ui.js.map +1 -0
  119. package/dist/workbench/ai-entries.d.ts.map +1 -1
  120. package/dist/workbench/ai-entries.js +23 -2
  121. package/dist/workbench/ai-entries.js.map +1 -1
  122. package/dist/workbench/back-out.js +1 -1
  123. package/dist/workbench/back-out.js.map +1 -1
  124. package/dist/workbench/inspector.d.ts.map +1 -1
  125. package/dist/workbench/inspector.js +218 -18
  126. package/dist/workbench/inspector.js.map +1 -1
  127. package/package.json +6 -6
package/dist/quick-ask.js CHANGED
@@ -1,16 +1,63 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
- import { layer, pluralOf } from "@graview/core";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { layer, nameOfAuthor, pluralOf } from "@graview/core";
3
3
  import { aiTargetOf, catalogOf, existingViewsOf, recordsOfTarget } from "@graview/core/catalog";
4
4
  import { search } from "@graview/core/search";
5
5
  import { useAiDeskState, useGraview } from "@graview/react/provider";
6
6
  import { applyAffordance, deriveAffordances, previewAffordance } from "@graview/tools/derive";
7
- import { answerHere, targetWords } from "@graview/tools/inline-ai";
7
+ import { answerHere, plainly, targetWords } from "@graview/tools/inline-ai";
8
8
  import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
9
9
  import { askOf, drawPanel, GhostTray, takeOffer } from "./inline-ai-menu.js";
10
10
  import { useInlineEnv } from "./inline-ai-env.js";
11
11
  import { openQuickAsk } from "./quick-ask-desk.js";
12
12
  import { VISUALLY_HIDDEN } from "./primitives/measure.js";
13
+ import { retryingImport } from "@graview/core/retry";
14
+ import { lazyModule } from "@graview/react/provider";
13
15
  import "./frame.js"; // A door: tied to what the page drew first (TIED DOORS, packages/primitives/src/frame.ts).
16
+ /*
17
+ * WHAT WAS TOLD, FETCHED APART (Roadmap f-menu-learns): the telling, its
18
+ * thumbs and the like ones that worked are a chunk of their own, asked for
19
+ * once the card is open and the page is idle, and only where the host keeps
20
+ * tellings — never in the way of the bar, the answer or its first paint.
21
+ */
22
+ const toldCard = /* @__PURE__ */ lazyModule(retryingImport(() => import("./told-ui.js")));
23
+ const Rate = /* @__PURE__ */ toldCard.part((module, props) => _jsx(module.RateTelling, { ...props }), { quiet: true });
24
+ /** After the next paint: what is kept never holds up what is drawn. */
25
+ const afterPaint = (run) => {
26
+ if (typeof requestAnimationFrame === "function")
27
+ requestAnimationFrame(() => setTimeout(run, 0));
28
+ else
29
+ setTimeout(run, 0);
30
+ };
31
+ const freshTellingId = () => `tl-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
32
+ /** Words in the follow-up that say yes, or no, to a plan the card asks about. */
33
+ const YES = /^(yes|yep|yeah|sure|ok(ay)?|do it|go ahead|please do)\b/i;
34
+ const NO = /^(no|nope|don'?t|not now|never ?mind)\b/i;
35
+ /**
36
+ * THE RECORDS A LIST OR A PLACE SHOWS (Roadmap f-menu-type-anything): what
37
+ * "these" and "all the tasks in this list" mean — the ones drawn on the
38
+ * page, of the target's kind where it has one; on the scene, every record
39
+ * of the kind this reader may see.
40
+ */
41
+ function membersOf(store, target, face) {
42
+ const kind = target.on === "list" ? target.kind : target.on === "lens" ? target.kind : undefined;
43
+ // A selection is those records; what a record holds the Fast slot reads from the graph (`decideOverTheGraph`).
44
+ if (target.on === "selection")
45
+ return target.ids;
46
+ if (target.on !== "list" && target.on !== "lens")
47
+ return [];
48
+ if (face === "pages" && typeof document !== "undefined") {
49
+ const ids = new Set();
50
+ for (const element of document.querySelectorAll("main [data-graview-listed], main [data-graview-record], main [data-graview-pick]")) {
51
+ const id = element.getAttribute("data-graview-listed") ?? element.getAttribute("data-graview-record") ?? element.getAttribute("data-graview-pick");
52
+ const node = id ? store.graph.getNode(id) : undefined;
53
+ if (id && node && (kind === undefined || node.kind === kind))
54
+ ids.add(id);
55
+ }
56
+ if (ids.size > 0)
57
+ return [...ids];
58
+ }
59
+ return kind ? store.graph.allNodes().filter((node) => node.kind === kind).map((node) => node.id) : [];
60
+ }
14
61
  /** Words that say what kind of row is wanted, not which: "show", "go to", "open". */
15
62
  const LEADING = new Set(["show", "go", "to", "open", "the", "a", "an", "me"]);
16
63
  const folded = (text) => text.toLowerCase().normalize("NFKD").replace(/[̀-ͯ]/g, "");
@@ -28,7 +75,8 @@ const WIDTH = 360;
28
75
  const GAP = 12;
29
76
  export function QuickAskCard({ face, opening, close }) {
30
77
  const env = useInlineEnv();
31
- const { setMenuAt } = useGraview();
78
+ const { setMenuAt, aiHere, session, people, seats, onProposeChange, composeChange } = useGraview();
79
+ const [planState, setPlanState] = useState({ state: "offered" });
32
80
  const target = useMemo(() => opening.target ?? aiTargetOf(opening.pointed ?? { about: [] }, (id) => env.store.graph.has(id)), [opening, env.store]);
33
81
  const about = useMemo(() => targetWords(env.store, target, env.places), [env.store, target, env.places]);
34
82
  const [words, setWords] = useState("");
@@ -37,16 +85,77 @@ export function QuickAskCard({ face, opening, close }) {
37
85
  const [said, setSaid] = useState(null);
38
86
  const pane = useRef(null);
39
87
  const field = useRef(null);
88
+ const mark = useRef(null);
40
89
  const list = useId();
41
90
  const live = useRef(true);
42
91
  const { ghost: held } = useAiDeskState(env.desk);
92
+ /* WHAT WAS TOLD (Roadmap f-menu-learns): this card's latest telling, kept with the host's door, and the watch on how it ends. */
93
+ const [telling, setTelling] = useState(null);
94
+ const told = useRef(null);
95
+ /* A change to this card's telling: handed on once it is kept, held until then. */
96
+ const toldSay = useCallback((change) => {
97
+ const now = told.current;
98
+ if (!now || !env.ai.told)
99
+ return;
100
+ if (now.kept && toldCard.current)
101
+ toldCard.current.changeTelling(env.ai.told, now.id, change);
102
+ else
103
+ now.queued = { ...now.queued, ...change };
104
+ }, [env.ai.told]);
105
+ /* Open, and the page idle: the telling's chunk and what the host has kept, so an Enter has its examples at hand without waiting. */
106
+ useEffect(() => {
107
+ const keeps = env.ai.told;
108
+ if (!keeps)
109
+ return;
110
+ const idle = globalThis;
111
+ const go = () => void toldCard.load().then((module) => module.toldListed(keeps), () => undefined);
112
+ if (idle.requestIdleCallback) {
113
+ const asked = idle.requestIdleCallback(go, { timeout: 1500 });
114
+ return () => idle.cancelIdleCallback?.(asked);
115
+ }
116
+ const later = setTimeout(go, 200);
117
+ return () => clearTimeout(later);
118
+ }, [env.ai.told]);
43
119
  useEffect(() => () => {
44
120
  live.current = false;
121
+ // Put away with nothing done: abandoned — unless a change it proposed still waits on the tray, whose answer the watch hears.
122
+ const last = told.current;
123
+ const waitingOn = env.desk.get().ghost;
124
+ if (last && !(waitingOn && waitingOn.ghost.interaction === last.interaction && waitingOn.batch === undefined))
125
+ toldSay({ outcome: "abandoned" });
45
126
  // Put away with a change still proposed on it: the change waits on the tray at the foot instead.
46
127
  const waiting = env.desk.get().ghost;
47
128
  if (waiting?.onCard)
48
129
  env.desk.setGhost({ ...waiting, onCard: false });
49
130
  }, [env.desk]);
131
+ /*
132
+ * GRAVIEW IS HERE WHILE IT THINKS (Roadmap f-menu-type-anything): from the
133
+ * words asked until this card is put away — its close, Escape, a press
134
+ * away, an answer taken back — the app's AI stands among who is here, as
135
+ * a bot editing does: "Graview, for Ada", at what it was asked about, so
136
+ * the bar says it and the picture marks the thing; the others are told
137
+ * on the presence channel where there is one. A page that dies leaves it
138
+ * to lapse by the presence's own time.
139
+ */
140
+ const thinking = answering !== null;
141
+ useEffect(() => {
142
+ if (!thinking)
143
+ return;
144
+ const at = recordsOfTarget(target)[0] ?? (target.on === "list" ? `kind:${target.kind}` : target.on === "lens" && target.kind ? `kind:${target.kind}` : null);
145
+ const person = env.principal;
146
+ const name = person.id ? nameOfAuthor(person, { graph: env.store.graph, schema: env.store.schema, ...(seats ? { seats } : {}), ...(people ? { people } : {}) }) : undefined;
147
+ aiHere({
148
+ participant: `agent:graview-for-${person.id ?? "anyone"}:${session}`,
149
+ kind: "agent",
150
+ name: "Graview",
151
+ ...(person.id ? { onBehalfOf: person.id, ...(name ? { onBehalfOfName: name } : {}) } : {}),
152
+ over: at,
153
+ robot: { at, mode: "thinking" },
154
+ });
155
+ return () => aiHere(null);
156
+ // Stands from the ask to the card's close; where it is said once, as it began.
157
+ // eslint-disable-next-line react-hooks/exhaustive-deps
158
+ }, [thinking]);
50
159
  /* THE CATALOG OF THIS TARGET: its panels ("Show …") and places, and the acts the derivation offers this viewer on its records. */
51
160
  const catalog = useMemo(() => {
52
161
  const ask = askOf(env, target);
@@ -94,19 +203,119 @@ export function QuickAskCard({ face, opening, close }) {
94
203
  const asked = question.trim();
95
204
  if (!asked)
96
205
  return;
97
- const before = answering?.result?.answer ? { said: answering.said, answer: answering.result.answer } : undefined;
206
+ // A reply carries what it replies to: the words, the answer, and which records they were read as being about.
207
+ const before = answering?.result?.answer ? { said: answering.said, answer: answering.result.answer, ...(answering.result.reading ? { reading: answering.result.reading.which } : {}) } : undefined;
208
+ const place = target.on === "lens" ? env.places.find((one) => one.as === target.place)?.title : target.on === "list" ? undefined : undefined;
98
209
  setWords("");
99
210
  setActive(-1);
100
211
  setSaid(null);
101
212
  setAnswering({ said: asked, result: null });
102
- const result = await answerHere(env.ai, { ...askOf(env, target, asked), said: asked, ...(before ? { follows: before } : {}) });
213
+ setPlanState({ state: "offered" });
214
+ const members = membersOf(env.store, target, face);
215
+ /* A reply on the card follows the telling before it, in one conversation; one still only previewed was not taken. */
216
+ const earlier = told.current;
217
+ if (earlier)
218
+ toldSay({ outcome: "abandoned" });
219
+ const started = Date.now();
220
+ // The like 👍 tellings of this app, given to Jev and the light model as examples (Roadmap f-menu-learns): only what is at hand, never waited for.
221
+ // Already fetched (by the menu, or as this card opened) it is here in a tick; otherwise not waited for.
222
+ const toldHere = env.ai.told ? (toldCard.current ?? (await Promise.race([toldCard.load().catch(() => undefined), new Promise((resolve) => setTimeout(resolve, 50))]))) : undefined;
223
+ const like = env.ai.told && toldHere ? toldHere.examplesHere(env.ai.told, env.store, env.principal, asked, target) : { examples: [], ids: [] };
224
+ const examples = [...new Set([...(earlier ? [] : (opening.suggested ? [opening.suggested] : [])), ...like.ids])];
225
+ const result = await answerHere(env.ai, {
226
+ ...askOf(env, target, asked),
227
+ said: asked,
228
+ ...(before ? { follows: before } : {}),
229
+ ...(members.length > 0 ? { members } : {}),
230
+ ...(place ? { where: place } : {}),
231
+ ...(like.examples.length > 0 ? { examples: like.examples } : {}),
232
+ });
103
233
  env.desk.record(result.interaction);
104
234
  // Its proposed change is a ghost, drawn on this card: accepted there as one batch, with its one undo.
105
235
  if (result.ghost && result.ghost.proposals.length > 0)
106
236
  env.desk.setGhost({ ghost: result.ghost, onCard: true });
237
+ /* The app lacks it: the whole plan that would finish the ask, composed and rehearsed before it is said. */
238
+ let plan;
239
+ if (result.missing) {
240
+ try {
241
+ const { planFor } = await import("./quick-ask-plan.js");
242
+ // The records the words were read as being about, where the plan says it.
243
+ const from = Date.now();
244
+ const where = place ?? (result.reading && result.reading.which === "held" && target.on === "record" ? result.reading.words.replace(/^the \d+ \S+ in /, "") : undefined);
245
+ plan = await planFor(env, { said: asked, missing: result.missing, members: result.reading?.records ?? members, host: onProposeChange !== undefined, ...(composeChange ? { compose: composeChange } : {}), ...(where ? { where } : {}) });
246
+ // The plan composed and rehearsed, timed beside the slots' stages.
247
+ env.desk.record({ ...result.interaction, stages: [...(result.interaction.stages ?? []), { stage: "compose", ms: Date.now() - from }] });
248
+ }
249
+ catch (error) {
250
+ // The error is the pipeline's; the person is told plainly.
251
+ plan = { refused: `${result.missing.said} That change could not be made here.` };
252
+ }
253
+ }
107
254
  if (live.current)
108
- setAnswering({ said: asked, result });
109
- }, [answering, env, target]);
255
+ setAnswering({ said: asked, result, ...(plan ? { plan } : {}) });
256
+ /*
257
+ * KEPT AS A TELLING, AFTER THE ANSWER IS DRAWN: what was said and
258
+ * decided and how long it took, its id now so how it ends — said as
259
+ * the person goes on — is held until it is kept.
260
+ */
261
+ const keeps = env.ai.told;
262
+ if (!keeps)
263
+ return;
264
+ const ms = Date.now() - started;
265
+ told.current?.stop?.();
266
+ const now = { id: freshTellingId(), conversation: earlier?.conversation ?? "", interaction: result.interaction.id };
267
+ told.current = { ...now, conversation: now.conversation || now.id };
268
+ const mine = told.current;
269
+ afterPaint(() => void toldCard.load().then((module) => {
270
+ const kept = module.keepTelling(keeps, { ...module.tellingDraftOf(env.store, { words: asked, target, result, ...(plan ? { plan } : {}), ms, ...(earlier ? { conversation: earlier.conversation, follows: earlier.id } : {}), examples }), id: mine.id, conversation: mine.conversation }, env.principal);
271
+ if (!kept)
272
+ return;
273
+ mine.kept = true;
274
+ if (mine.queued)
275
+ module.changeTelling(keeps, kept.id, mine.queued);
276
+ mine.stop = module.watchTelling(env.desk, keeps, kept);
277
+ if (live.current && told.current === mine)
278
+ setTelling(kept);
279
+ }, () => undefined));
280
+ }, [answering, env, target, face, onProposeChange, composeChange, opening.suggested, toldSay]);
281
+ /*
282
+ * YES: THE PLAN LANDS AS ONE CHANGE. The host applies the change to the
283
+ * app and the records that finish the ask together, as the person, and
284
+ * hands back its one way back; a host that declines is said on the card.
285
+ */
286
+ const offered = answering?.plan && "question" in answering.plan ? answering.plan : undefined;
287
+ const yes = useCallback(async () => {
288
+ const proposal = offered?.proposal;
289
+ if (!proposal || !onProposeChange || planState.state !== "offered")
290
+ return;
291
+ setPlanState({ state: "applying" });
292
+ try {
293
+ const answered = await onProposeChange(proposal);
294
+ if (!live.current)
295
+ return;
296
+ toldSay({ outcome: answered === "declined" ? "declined" : "accepted" });
297
+ if (answered === "declined")
298
+ setPlanState({ state: "declined", said: "The host declined this change to the app." });
299
+ else
300
+ setPlanState({ state: "applied", ...(typeof answered === "object" ? { undo: answered.undo, ...(answered.note ? { note: answered.note } : {}) } : {}) });
301
+ }
302
+ catch (error) {
303
+ if (live.current)
304
+ setPlanState({ state: "declined", said: error instanceof Error ? error.message : String(error) });
305
+ }
306
+ }, [offered, onProposeChange, planState, toldSay]);
307
+ const no = useCallback(() => {
308
+ toldSay({ outcome: "declined" });
309
+ setPlanState({ state: "declined", said: "Left as it is." });
310
+ }, [toldSay]);
311
+ const undoPlan = useCallback(async () => {
312
+ if (planState.state !== "applied" || !planState.undo)
313
+ return;
314
+ await planState.undo();
315
+ toldSay({ outcome: "undone" });
316
+ if (live.current)
317
+ setPlanState({ state: "undone" });
318
+ }, [planState, toldSay]);
110
319
  /* Opened with words already (Find's "Ask: ‘…’", Activity's "Ask again"): asked at once. */
111
320
  const first = useRef(opening.asked);
112
321
  useEffect(() => {
@@ -202,13 +411,34 @@ export function QuickAskCard({ face, opening, close }) {
202
411
  }
203
412
  field.current?.focus({ preventScroll: true });
204
413
  let frame = 0;
414
+ let marked = null;
415
+ /* OPENED FROM THE MENU, IT OPENS WHERE THE MENU STOOD and never moves: placed once, before it is first painted. */
416
+ const from = opening.from;
417
+ if (from)
418
+ pinAt(element, from);
205
419
  const follow = () => {
206
- placeBeside(element, anchorOf(element, target, face));
420
+ const anchor = anchorOf(element, target, face);
421
+ if (from)
422
+ sayBeside(element, anchor);
423
+ else
424
+ placeBeside(element, anchor);
425
+ // The thinking mark, on the thing itself while the answer is on its way.
426
+ const on = mark.current ? anchor : null;
427
+ if (marked !== on) {
428
+ marked?.removeAttribute("data-graview-ai-thinking");
429
+ on?.setAttribute("data-graview-ai-thinking", "");
430
+ marked = on;
431
+ }
432
+ if (mark.current)
433
+ placeOn(mark.current, on);
207
434
  frame = requestAnimationFrame(follow);
208
435
  };
209
436
  follow();
210
- return () => cancelAnimationFrame(frame);
211
- }, [target, face]);
437
+ return () => {
438
+ cancelAnimationFrame(frame);
439
+ marked?.removeAttribute("data-graview-ai-thinking");
440
+ };
441
+ }, [target, face, opening.from]);
212
442
  /* A press outside the bar puts it away; an answer stays until it is closed. */
213
443
  useEffect(() => {
214
444
  if (answering)
@@ -235,28 +465,52 @@ export function QuickAskCard({ face, opening, close }) {
235
465
  const row = rows[active];
236
466
  if (row)
237
467
  row.take();
468
+ // A plan the card asks about takes its answer in words too: "yes", "no"; anything else revises it.
469
+ else if (offered?.proposal && planState.state === "offered" && YES.test(words.trim())) {
470
+ setWords("");
471
+ void yes();
472
+ }
473
+ else if (offered && planState.state === "offered" && NO.test(words.trim())) {
474
+ setWords("");
475
+ no();
476
+ }
238
477
  else if (words.trim())
239
478
  void ask(words);
240
479
  }
241
480
  };
242
481
  const result = answering?.result ?? null;
243
482
  const lit = rows[active];
244
- return (_jsxs("section", { ref: pane, popover: "manual", role: "region", "aria-label": answering ? `Answer about ${about}` : `Ask about ${about}`, "data-testid": "quick-ask", "data-quick-ask": answering ? "answer" : "bar", "data-quick-ask-face": face, "data-graview-offstage": "", "data-graview-overlay": "", onKeyDown: (event) => {
483
+ return (_jsxs("section", { ref: pane, popover: "manual", role: "region", "aria-label": answering ? `Answer about ${about}` : `Tell Graview about ${about}`, "data-testid": "quick-ask", "data-quick-ask": answering ? "answer" : "bar", "data-quick-ask-face": face, "data-graview-offstage": "", "data-graview-overlay": "", onKeyDown: (event) => {
245
484
  if (event.key !== "Escape")
246
485
  return;
247
486
  // The card's Escape is the card's: the scene's back-out does not hear it too.
248
487
  event.preventDefault();
249
488
  event.stopPropagation();
250
489
  putAway();
251
- }, onMouseDown: (event) => event.stopPropagation(), style: CARD, children: [_jsxs("div", { style: { display: "flex", alignItems: "baseline", gap: 8 }, children: [_jsxs("p", { "data-testid": "quick-ask-about", style: { margin: 0, flex: "1 1 auto", minWidth: 0, fontSize: "0.75rem", color: "var(--graview-ink-muted)", overflowWrap: "anywhere" }, children: ["About ", about] }), _jsx("button", { type: "button", "data-testid": "quick-ask-close", "aria-label": "Close", title: "Close", onClick: putAway, style: ICON, children: _jsx("span", { "aria-hidden": "true", children: "\u2715" }) })] }), answering ? (_jsxs("div", { "data-testid": "quick-answer", style: { display: "grid", gap: 8 }, children: [_jsxs("p", { "data-testid": "quick-answer-asked", style: { margin: 0, fontSize: "0.8125rem", color: "var(--graview-ink-muted)", fontStyle: "italic", overflowWrap: "anywhere" }, children: ["\u201C", answering.said, "\u201D"] }), _jsx("p", { "data-testid": "quick-answer-text", "aria-live": "polite", style: { margin: 0, fontSize: "0.9375rem", lineHeight: 1.45, color: result?.interaction.outcome === "failed" ? "var(--graview-warn)" : "var(--graview-ink)", overflowWrap: "anywhere" }, children: result === null ? "Thinking…" : result.interaction.outcome === "failed" ? (result.interaction.failed ?? "The AI could not answer that here.") : (result.answer ?? "") }), result && result.offers.length > 0 ? (_jsx("ul", { "aria-label": "What acts on it", style: { margin: 0, padding: 0, listStyle: "none", display: "flex", flexWrap: "wrap", gap: 6 }, children: result.offers.map((offer) => (_jsx("li", { children: _jsx("button", { type: "button", "data-testid": "quick-answer-offer", "data-ai-offer": offer.id, "data-ai-offer-type": offer.type, onClick: () => {
490
+ }, onMouseDown: (event) => event.stopPropagation(), style: CARD, children: [_jsxs("div", { style: { display: "flex", alignItems: "baseline", gap: 8 }, children: [_jsxs("p", { "data-testid": "quick-ask-about", style: { margin: 0, flex: "1 1 auto", minWidth: 0, fontSize: "0.75rem", color: "var(--graview-ink-muted)", overflowWrap: "anywhere" }, children: ["About ", about] }), _jsx("button", { type: "button", "data-testid": "quick-ask-close", "aria-label": "Close", title: "Close", onClick: putAway, style: ICON, children: _jsx("span", { "aria-hidden": "true", children: "\u2715" }) })] }), answering ? (_jsxs("div", { "data-testid": "quick-answer", style: { display: "grid", gap: 8 }, children: [_jsxs("p", { "data-testid": "quick-answer-asked", style: { margin: 0, fontSize: "0.8125rem", color: "var(--graview-ink-muted)", fontStyle: "italic", overflowWrap: "anywhere" }, children: ["\u201C", answering.said, "\u201D"] }), _jsx("p", { "data-testid": "quick-answer-text", "aria-live": "polite", style: { margin: 0, fontSize: "0.9375rem", lineHeight: 1.45, color: result?.interaction.outcome === "failed" ? "var(--graview-warn)" : "var(--graview-ink)", overflowWrap: "anywhere" }, children: result === null
491
+ ? "Thinking…"
492
+ : result.interaction.outcome === "failed"
493
+ ? (result.interaction.failed ?? "The AI could not answer that here.")
494
+ : /* Every answer the card shows is a whole sentence for the person, never the pipeline's own note. */
495
+ plainly(answering.plan
496
+ ? "question" in answering.plan
497
+ ? answering.plan.question
498
+ : answering.plan.refused
499
+ : result.answer, result.ghost && result.ghost.proposals.length > 0 ? "Here is what I would change." : result.offers.length > 0 ? "These may help." : undefined) }), result?.taken ? (
500
+ /* A person said as a pronoun, and whom it was taken as, so a reply can correct it. */
501
+ _jsxs("p", { "data-testid": "quick-ask-taken", style: { margin: 0, fontSize: "0.8125rem", color: "var(--graview-ink-muted)" }, children: ["Taking \u201C", result.taken.said, "\u201D as ", result.taken.as, "."] })) : null, result?.reading?.close ? (
502
+ /* A close reading said, so a reply can flip it: "Taking this as the 6 tasks in This week." */
503
+ _jsxs("p", { "data-testid": "quick-ask-reading", style: { margin: 0, fontSize: "0.8125rem", color: "var(--graview-ink-muted)" }, children: ["Taking this as ", result.reading.words, "."] })) : null, offered ? (
504
+ /* The plan, whole, before it lands: each part said, Yes and No — or, for a seat that may not change the app, who could. */
505
+ _jsxs("section", { "data-testid": "quick-ask-plan", "data-plan-state": planState.state, "aria-label": "What I would do", style: PLAN, children: [_jsx("ol", { style: { margin: 0, paddingInlineStart: 18, display: "grid", gap: 2 }, children: offered.parts.map((part, at) => (_jsx("li", { "data-plan-part": part.part, style: { fontStyle: planState.state === "applied" ? "normal" : "italic" }, children: part.said }, at))) }), offered.proposal && onProposeChange ? (planState.state === "offered" || planState.state === "applying" ? (_jsxs("div", { style: { display: "flex", gap: 6 }, children: [_jsx("button", { type: "button", "data-testid": "quick-ask-plan-yes", disabled: planState.state === "applying", onClick: () => void yes(), style: OFFER, children: "Yes" }), _jsx("button", { type: "button", "data-testid": "quick-ask-plan-no", disabled: planState.state === "applying", onClick: no, style: OFFER, children: "No" })] })) : planState.state === "applied" ? (_jsxs("div", { role: "status", style: { display: "flex", gap: 6, alignItems: "center" }, children: [_jsxs("span", { "data-testid": "quick-ask-plan-done", children: ["Done, as one change.", planState.note ? ` ${planState.note}` : ""] }), planState.undo ? (_jsx("button", { type: "button", "data-testid": "quick-ask-plan-undo", onClick: () => void undoPlan(), style: OFFER, children: "Undo" })) : null] })) : (_jsx("p", { role: "status", style: { margin: 0, color: "var(--graview-ink-muted)" }, children: planState.state === "undone" ? "Taken back." : planState.said }))) : null] })) : null, result && result.offers.length > 0 ? (_jsx("ul", { "aria-label": "What acts on it", style: { margin: 0, padding: 0, listStyle: "none", display: "flex", flexWrap: "wrap", gap: 6 }, children: result.offers.map((offer) => (_jsx("li", { children: _jsx("button", { type: "button", "data-testid": "quick-answer-offer", "data-ai-offer": offer.id, "data-ai-offer-type": offer.type, onClick: () => {
252
506
  // Put away as it lands, the way this face goes; a failure is said on the card that offered it.
253
- void takeOffer(env, result, offer, close).then((failed) => {
507
+ void takeOffer(env, result, offer, close, { onCard: offer.type === "act" || offer.type === "fill" }).then((failed) => {
254
508
  if (failed && live.current)
255
509
  setSaid(failed);
256
510
  });
257
- }, style: OFFER, children: offer.type === "panel" ? `Show ${sentenceCase(offer.label)}` : offer.type === "place" ? `Go to ${offer.label}` : offer.label }) }, offer.id))) })) : null, held?.onCard && held.ghost.interaction === result?.interaction.id ? _jsx(GhostTray, { placement: "card" }) : null] })) : null, _jsxs("div", { style: { display: "grid", gap: 4 }, children: [_jsx("input", { ref: field, type: "text", role: "combobox", "data-testid": "quick-ask-input", "aria-label": answering ? `Ask a follow-up about ${about}` : `Ask about ${about}, or go somewhere`, "aria-expanded": rows.length > 0, "aria-controls": list, "aria-autocomplete": "list", ...(lit ? { "aria-activedescendant": `${list}-${active}` } : {}), autoComplete: "off", enterKeyHint: "go", placeholder: answering ? "Ask a follow-up…" : "Ask, or go to…", value: words,
511
+ }, style: OFFER, children: offer.type === "panel" ? `Show ${sentenceCase(offer.label)}` : offer.type === "place" ? `Go to ${offer.label}` : offer.type === "act" || offer.type === "fill" ? `${offer.label.replace(/[.?]$/, "")}?` : offer.label }) }, offer.id))) })) : null, held?.onCard && held.ghost.interaction === result?.interaction.id ? _jsx(GhostTray, { placement: "card" }) : null, result && telling && env.ai.told && telling.interaction === result.interaction.id ? _jsx(Rate, { told: env.ai.told, telling: telling }) : null] })) : null, _jsxs("div", { style: { display: "grid", gap: 4 }, children: [_jsx("input", { ref: field, type: "text", role: "combobox", "data-testid": "quick-ask-input", "aria-label": answering ? `Reply about ${about}` : `Tell Graview about ${about}, or go somewhere`, "aria-expanded": rows.length > 0, "aria-controls": list, "aria-autocomplete": "list", ...(lit ? { "aria-activedescendant": `${list}-${active}` } : {}), autoComplete: "off", enterKeyHint: "go", placeholder: answering ? "Reply…" : "Tell Graview, or go to…", value: words,
258
512
  // Read-only while it answers, never disabled: a disabled field lets go of the keyboard.
259
- readOnly: answering !== null && result === null, "aria-busy": answering !== null && result === null, onChange: (event) => setWords(event.target.value), onKeyDown: onKeyDown, style: FIELD }), _jsx("div", { id: list, role: "listbox", "aria-label": words.trim() ? `What “${words.trim()}” finds here` : "What can be done here", hidden: rows.length === 0, style: { display: rows.length === 0 ? "none" : "grid", gap: 1, maxHeight: 280, overflowY: "auto" }, children: rows.map((row, at) => (_jsx("div", { id: `${list}-${at}`, role: "option", "aria-selected": at === active, "data-testid": "quick-ask-option", "data-quick-ask-kind": row.kind, "data-quick-ask-row": row.id, onMouseEnter: () => setActive(at), onMouseDown: (event) => event.preventDefault(), onClick: row.take, style: { ...OPTION, background: at === active ? "var(--graview-panel-muted)" : "transparent", ...(row.kind === "ask" ? { borderTop: "1px solid var(--graview-edge)", marginTop: 2 } : {}) }, children: row.kind === "ask" ? (_jsxs("span", { children: ["Ask: ", _jsxs("span", { style: { fontWeight: 600 }, children: ["\u2018", row.label, "\u2019"] })] })) : (_jsxs("span", { children: [row.label, row.hint ? _jsxs("span", { style: { color: "var(--graview-ink-faint)" }, children: [" \u00B7 ", row.hint] }) : null] })) }, row.id))) })] }), said ? (_jsx("p", { role: "alert", "data-testid": "quick-ask-refused", style: { margin: 0, fontSize: "0.8125rem", color: "var(--graview-warn)" }, children: said })) : null, _jsx("span", { style: VISUALLY_HIDDEN, "aria-live": "polite", children: rows.length > 0 && words.trim() ? `${rows.length} ${rows.length === 1 ? "way" : "ways"} to go on` : "" })] }));
513
+ readOnly: answering !== null && result === null, "aria-busy": answering !== null && result === null, onChange: (event) => setWords(event.target.value), onKeyDown: onKeyDown, style: FIELD }), _jsx("div", { id: list, role: "listbox", "aria-label": words.trim() ? `What “${words.trim()}” finds here` : "What can be done here", hidden: rows.length === 0, style: { display: rows.length === 0 ? "none" : "grid", gap: 1, maxHeight: 280, overflowY: "auto" }, children: rows.map((row, at) => (_jsx("div", { id: `${list}-${at}`, role: "option", "aria-selected": at === active, "data-testid": "quick-ask-option", "data-quick-ask-kind": row.kind, "data-quick-ask-row": row.id, onMouseEnter: () => setActive(at), onMouseDown: (event) => event.preventDefault(), onClick: row.take, style: { ...OPTION, background: at === active ? "var(--graview-panel-muted)" : "transparent", ...(row.kind === "ask" ? { borderTop: "1px solid var(--graview-edge)", marginTop: 2 } : {}) }, children: row.kind === "ask" ? (_jsxs("span", { children: ["Tell Graview: ", _jsxs("span", { style: { fontWeight: 600 }, children: ["\u2018", row.label, "\u2019"] })] })) : (_jsxs("span", { children: [row.label, row.hint ? _jsxs("span", { style: { color: "var(--graview-ink-faint)" }, children: [" \u00B7 ", row.hint] }) : null] })) }, row.id))) })] }), said ? (_jsx("p", { role: "alert", "data-testid": "quick-ask-refused", style: { margin: 0, fontSize: "0.8125rem", color: "var(--graview-warn)" }, children: said })) : null, _jsx("span", { style: VISUALLY_HIDDEN, "aria-live": "polite", children: rows.length > 0 && words.trim() ? `${rows.length} ${rows.length === 1 ? "way" : "ways"} to go on` : "" }), answering !== null && result === null ? (_jsxs("span", { ref: mark, role: "status", "data-testid": "ai-thinking-mark", style: THINKING, children: [_jsx("style", { children: "@keyframes graview-thinking{from{opacity:.35}to{opacity:1}}@media (prefers-reduced-motion:reduce){[data-testid=ai-thinking-mark]>span{animation:none!important}}" }), _jsx("span", { "aria-hidden": "true", style: THINKING_DOT }), _jsxs("span", { children: ["Thinking about ", about, "\u2026"] })] })) : null] }));
260
514
  }
261
515
  /**
262
516
  * WHAT THE CARD STANDS BESIDE: the record's card or the kind's district on
@@ -268,8 +522,8 @@ function anchorOf(pane, target, face) {
268
522
  const root = pane?.closest("[data-graview-embed]") ?? document;
269
523
  const ids = target.on === "list" ? [`aggregate:${target.kind}`, `kind:${target.kind}`] : target.on === "lens" && target.kind ? [`aggregate:${target.kind}`] : recordsOfTarget(target);
270
524
  for (const id of ids) {
271
- // The record's own card where the picture draws one; else where it is picked from (a row of a district's list).
272
- for (const element of root.querySelectorAll(`[data-graview-view="${CSS.escape(id)}"], [data-graview-pick="${CSS.escape(id)}"]`)) {
525
+ // The record's own card where the picture draws one; else where it is picked from (a row of a district's list, a page's row or card).
526
+ for (const element of root.querySelectorAll(`[data-graview-view="${CSS.escape(id)}"], [data-graview-pick="${CSS.escape(id)}"], [data-graview-record="${CSS.escape(id)}"], [data-graview-listed="${CSS.escape(id)}"]`)) {
273
527
  if (pane?.contains(element))
274
528
  continue;
275
529
  const box = element.getBoundingClientRect();
@@ -322,15 +576,83 @@ function placeBeside(pane, anchor) {
322
576
  style.top = next.top;
323
577
  if (style.width !== next.width)
324
578
  style.width = next.width;
325
- const beside = anchor?.getAttribute("data-graview-view") ?? anchor?.getAttribute("data-graview-pick") ?? (anchor ? "page" : "");
579
+ const beside = anchor?.getAttribute("data-graview-view") ?? anchor?.getAttribute("data-graview-pick") ?? anchor?.getAttribute("data-graview-record") ?? anchor?.getAttribute("data-graview-listed") ?? (anchor ? "page" : "");
326
580
  if (pane.getAttribute("data-quick-ask-beside") !== beside)
327
581
  pane.setAttribute("data-quick-ask-beside", beside);
328
582
  if (pane.getAttribute("data-quick-ask-side") !== side)
329
583
  pane.setAttribute("data-quick-ask-side", side);
330
584
  }
585
+ /**
586
+ * WHERE THE MENU STOOD: the card's top-left corner is the menu's, growing
587
+ * down and right; it moves left only where it would leave the window, and
588
+ * up only where too little of the window is left below it — once, here,
589
+ * before it is painted. What it holds grows inside it and scrolls past the
590
+ * window's foot; nothing that arrives moves it.
591
+ */
592
+ function pinAt(pane, from) {
593
+ const view = { width: document.documentElement.clientWidth || innerWidth, height: innerHeight };
594
+ const width = Math.min(WIDTH, view.width - 2 * GAP);
595
+ const left = from.left + width > view.width - GAP ? Math.max(GAP, view.width - GAP - width) : Math.max(GAP, from.left);
596
+ const room = 240;
597
+ const top = from.top + room > view.height - GAP ? Math.max(GAP, view.height - GAP - room) : Math.max(GAP, from.top);
598
+ pane.style.left = `${Math.round(left)}px`;
599
+ pane.style.top = `${Math.round(top)}px`;
600
+ pane.style.width = `${Math.round(width)}px`;
601
+ pane.style.maxHeight = `${Math.round(view.height - top - GAP)}px`;
602
+ pane.setAttribute("data-quick-ask-side", "where-the-menu-stood");
603
+ pane.setAttribute("data-quick-ask-from", `${Math.round(from.left)},${Math.round(from.top)}`);
604
+ }
605
+ /** What a pinned card is about, said on it without moving it. */
606
+ function sayBeside(pane, anchor) {
607
+ const beside = anchor?.getAttribute("data-graview-view") ?? anchor?.getAttribute("data-graview-pick") ?? anchor?.getAttribute("data-graview-record") ?? anchor?.getAttribute("data-graview-listed") ?? (anchor ? "page" : "");
608
+ if (pane.getAttribute("data-quick-ask-beside") !== beside)
609
+ pane.setAttribute("data-quick-ask-beside", beside);
610
+ }
611
+ /** The thinking mark, at the top right of what it is on, inside its corner; hidden where there is nothing to stand on. */
612
+ function placeOn(mark, anchor) {
613
+ const at = anchor?.getBoundingClientRect();
614
+ const on = anchor?.getAttribute("data-graview-view") ?? anchor?.getAttribute("data-graview-pick") ?? anchor?.getAttribute("data-graview-record") ?? anchor?.getAttribute("data-graview-listed") ?? (anchor ? "page" : "");
615
+ if (mark.getAttribute("data-thinking-on") !== on)
616
+ mark.setAttribute("data-thinking-on", on);
617
+ if (!at || at.width === 0) {
618
+ if (mark.style.visibility !== "hidden")
619
+ mark.style.visibility = "hidden";
620
+ return;
621
+ }
622
+ const width = mark.getBoundingClientRect().width;
623
+ const left = `${Math.round(Math.max(GAP / 2, at.right - width - 6))}px`;
624
+ const top = `${Math.round(Math.max(GAP / 2, at.top - 10))}px`;
625
+ if (mark.style.left !== left)
626
+ mark.style.left = left;
627
+ if (mark.style.top !== top)
628
+ mark.style.top = top;
629
+ if (mark.style.visibility !== "visible")
630
+ mark.style.visibility = "visible";
631
+ }
632
+ const THINKING = {
633
+ position: "fixed",
634
+ visibility: "hidden",
635
+ display: "inline-flex",
636
+ alignItems: "center",
637
+ gap: 6,
638
+ padding: "2px 8px",
639
+ borderRadius: 999,
640
+ fontSize: "0.75rem",
641
+ lineHeight: 1.4,
642
+ whiteSpace: "nowrap",
643
+ color: "var(--graview-ink)",
644
+ background: "var(--graview-float)",
645
+ border: "1px dashed var(--graview-accent)",
646
+ boxShadow: "var(--graview-lift-low)",
647
+ pointerEvents: "none",
648
+ };
649
+ const THINKING_DOT = { width: 7, height: 7, borderRadius: 999, background: "var(--graview-accent)", animation: "graview-thinking 1s ease-in-out infinite alternate" };
650
+ const PLAN = { display: "grid", gap: 8, padding: 10, borderRadius: 10, border: "1px dashed var(--graview-accent)", fontSize: "0.875rem" };
331
651
  const CARD = {
332
652
  position: "fixed",
333
653
  inset: "auto",
654
+ // As tall as what it holds: WebKit's own popover height stretched the card to the window's (its max-height).
655
+ height: "auto",
334
656
  margin: 0,
335
657
  zIndex: layer("popover"),
336
658
  boxSizing: "border-box",